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.

Phase 4b: give a task to the Claude Code or Codex signed in to my web terminal (#142)

* Phase 4b: run agent tasks in my web terminal, with the CLI signed in there

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2

* Terminal tasks: branch names reach the boot script only as quoted data; the notice says repository content can steer a task

Security's must-fixes on #142.
- terminalBoot interpolated the ref into bash -c, and branch names may hold $( ), backticks, ; or |.
  The ref, worktree and start commit now go through /exec's env, quoted; the worktree removal too.
  The e2e opens its terminal on t$(touch${IFS}injected): on the old code the shell ran it (and
  fetched refs/heads/t); now the task checks out that branch and nothing runs.
- A task runs the agent without asking, where the person's sign-in is, and repository content can
  steer it: the notice and terminal.md say so, and TERMINAL_TERMS_VERSION is 2 so everyone sees it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2

---------

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 7bc0a57commit 94ad4715384637f0801b8f466c87e6d1852c6acaBrowse files
10 files changed+466−40
Files (10)
modifiedapps/diffinery/docs/agents/tasks.md+1−1View file
@@ -5,7 +5,7 @@
55
66 An **agent task** is a goal you give one of your agents, such as "Fix the Contact sales link on the pricing page and add a test". Diffinery starts a sandbox as that agent with the repository checked out, runs a coding agent (Claude Code, Codex or a command of your own) with **your own model API key**, streams its transcript to the task page, then pushes the work to a branch and opens a pull request as the agent. The pull request needs a person's approval like any agent pull request.
77
8-Tasks are optional. Nothing changes for a repository until someone with a model key starts one.
8+Tasks are optional. Nothing changes for a repository until someone with a model key starts one. To use your Claude or ChatGPT subscription instead of an API key, run tasks in a [terminal](/docs/agents/terminal#give-it-a-task).
99
1010 The same machinery reviews pull requests: see [Agent review](/docs/agents/review). Reviews show in the repository's task list, labelled **Review**. The repository's **Project** tab shows tasks on a board, with a conversation and a memory every task reads: see [Projects](/docs/agents/projects).
1111
modifiedapps/diffinery/docs/agents/terminal.md+24−2View file
@@ -19,7 +19,7 @@
1919
2020 1. Open the repository's **Project** tab and click **Terminals** (the **Agent tasks** page has the same button), or go to `/<owner>/<repo>/terminals`.
2121 2. Pick the **agent** and the **branch**.
22-3. The first time, tick the notice: you use Claude Code and Codex with your own Anthropic or OpenAI account, under that provider's terms, and usage is billed to your account. Diffinery records when you accepted it and which version of the notice it was. When the notice changes, you're asked again.
22+3. The first time, tick the notice: you use Claude Code and Codex with your own Anthropic or OpenAI account, under that provider's terms, and usage is billed to your account. Diffinery records when you accepted it and which version of the notice it was. When the notice changes, you're asked again. It also says what a task in your terminal can reach (see [Give it a task](#give-it-a-task)).
2323 4. Click **Open a terminal**.
2424
2525 The page shows **Starting** while the sandbox starts, then the terminal. Reloading the page, or opening it in another tab, connects to the same shell and redraws its recent output. Only one tab is connected at a time: the newest one wins, and the older tab says so.
@@ -35,6 +35,28 @@
3535
3636 The sign-in happens in the provider's own flow and stays in that sandbox. Diffinery never sees or keeps it, and it's gone when the terminal ends. The providers' terms: [Anthropic's Consumer Terms](https://www.anthropic.com/legal/consumer-terms), [Claude Code's legal and compliance page](https://code.claude.com/docs/en/legal-and-compliance) and [OpenAI's Terms of Use](https://openai.com/policies/terms-of-use).
3737
38+## Give it a task
39+
40+Your terminal can run [agent tasks](/docs/agents/tasks) with the Claude Code or Codex you signed in to there, on your own subscription, with no API key.
41+
42+- **Start one from the terminal.** Under the terminal, **Give it a task** takes a goal and **Runs with** Claude Code or Codex (Codex on private repositories only). Click **Run with my subscription**. The task's page opens and shows the transcript as it runs.
43+- **Run a task again in it.** On a task you started, with the same agent as an open terminal of yours, **Run in my terminal** runs it again there, and **Send to my terminal** sends your follow-up there.
44+
45+What happens:
46+
47+| Step | What Diffinery does |
48+| --- | --- |
49+| Check | Checks again, on each click, that the terminal is yours, open and acting as the task's agent, that you accepted the notice, and that you and the agent may still write to the repository. |
50+| Checkout | Checks out the task's branch (or the terminal's branch, for a new task) in a directory of its own beside yours, so your checkout and anything you're doing in it are left alone. |
51+| Run | Runs the CLI headless with the goal, as a task run does, with no model key or token: it uses the account you signed in to in the terminal. If it isn't installed yet, Diffinery installs the pinned version first. |
52+| Push | Pushes the work to the task's branch as the agent and opens a pull request, as for any task. Then it removes the task's directory. |
53+
54+Only your own click starts a run in your terminal. Nothing else does: not a coordinator, a schedule, a retry, [Best of N](/docs/agents/tasks#best-of-n) or a failing check. A task whose last run was in a terminal doesn't start a fix run when its checks fail; run it again yourself. A terminal runs one task at a time, it counts as attached while it does (so it doesn't end for being idle), and ending the terminal stops the task. Tasks that plan first or have more than one attempt run in sandboxes of their own.
55+
56+The task's transcript is kept, as for any task: it is the CLI's output for that run, not what you type in the terminal.
57+
58+**Give it tasks only in repositories whose contents you trust.** A task runs the agent without asking before each step, in your terminal, where your sign-in is. Anything in the repository the agent reads (a CLAUDE.md, a file, an issue the goal quotes) can steer it, for example into reading your sign-in and sending it somewhere. A task in its own sandbox can't do that: it holds only a token for that run. The notice says this too.
59+
3860 ## Who can reach it
3961
4062 - **Only you.** Nobody else sees your terminal or connects to it: not a repository admin, not the repository's owner, not a site administrator. The terminal may hold your provider sign-in.
@@ -70,7 +92,7 @@
7092
7193 ## Nothing is stored
7294
73-What you type and what the shell prints go only between your browser and the sandbox. Diffinery keeps no copy and doesn't log it: the recent output that redraws the screen lives in the sandbox's memory and is gone when the terminal ends. The [audit log](/docs/security/audit-log) records only that a terminal was opened or ended, by whom, in which repository, with which agent and branch and the Claude Code and Codex versions its buttons install, and that you accepted the notice (`terminal.terms`, `terminal.open`, `terminal.end`).
95+What you type and what the shell prints go only between your browser and the sandbox. Diffinery keeps no copy and doesn't log it: the recent output that redraws the screen lives in the sandbox's memory and is gone when the terminal ends. The [audit log](/docs/security/audit-log) records only that a terminal was opened or ended, by whom, in which repository, with which agent and branch and the Claude Code and Codex versions its buttons install, and that you accepted the notice (`terminal.terms`, `terminal.open`, `terminal.task`, `terminal.end`).
7496
7597 ## For administrators
7698
modifiedapps/diffinery/docs/security/audit-log.md+1−0View file
@@ -85,6 +85,7 @@
8585 | `task.stop` | Someone stops a task (its starter, its agent or a repository admin) | `owner/repo#<task number>` | none |
8686 | `terminal.terms` | Someone accepts the notice for [terminals](/docs/agents/terminal) | `owner/repo` | `version` |
8787 | `terminal.open` | Someone opens a [terminal](/docs/agents/terminal) | `owner/repo` | `terminal`, `agent`, `ref`, `runtimes` (the Claude Code and Codex versions its buttons install) |
88+| `terminal.task` | Someone runs a task in their [terminal](/docs/agents/terminal#give-it-a-task) | `owner/repo` | `terminal`, `task`, `runtime`, `version` (the pinned version of the CLI) |
8889 | `terminal.end` | A terminal ends | `owner/repo` | `terminal`, `reason` (`person`, `idle`, `limit`, `access`, `stopped`, `failed`, `disabled` or `suspended`) |
8990 | `project.memory` | Someone saves or restores the [project](/docs/agents/projects) memory | `owner/repo` | `version`, `length`, and `restored` (the version restored) for a restore |
9091 | `project.settings` | A repository admin changes **Settings → Project** | `owner/repo` | `max_running`, `instructions_length`, `plan_first` |
modifiedapps/diffinery/src/agentic/ProjectDO.ts+144−22View file
@@ -136,6 +136,8 @@
136136 /** One of a task's attempts (best of N): which, and the branch it pushes to. */
137137 attempt: number | null;
138138 branch: string | null;
139+ /** Run in the person's web terminal (phase 4b): its sandbox and the CLI signed in there, not a sandbox of its own. */
140+ terminal_id: string | null;
139141 started_at: string;
140142 ended_at: string | null;
141143 };
@@ -156,7 +158,7 @@
156158 }
157159
158160 export interface TaskView extends TaskRow {
159- runs: Array<Pick<RunRow, "seq" | "kind" | "message" | "step" | "exit_code" | "error" | "model_calls" | "tokens" | "started_at" | "ended_at" | "attempt">>;
161+ runs: Array<Pick<RunRow, "seq" | "kind" | "message" | "step" | "exit_code" | "error" | "model_calls" | "tokens" | "started_at" | "ended_at" | "attempt" | "terminal_id">>;
160162 }
161163
162164 const SCHEMA = `
@@ -297,6 +299,7 @@
297299 ["tasks", "check_fixes", "check_fixes INTEGER NOT NULL DEFAULT 0"],
298300 ["tasks", "check_fix_sha", "check_fix_sha TEXT"],
299301 ["tasks", "land", "land INTEGER NOT NULL DEFAULT 0"],
302+ ["runs", "terminal_id", "terminal_id TEXT"],
300303 ];
301304
302305 /** A finding as stored. */
@@ -362,8 +365,16 @@
362365 /** Verify and land (phase 3e). */
363366 fixChecks?: boolean;
364367 land?: boolean;
368+ /** Run it in the person's web terminal (phase 4b; agentic/terminals.ts checked it is theirs and open). */
369+ terminal?: InTerminal;
365370 }
366371
372+/** A web terminal a run goes to: its id and its sandbox. */
373+export interface InTerminal {
374+ id: string;
375+ sandboxId: string;
376+}
377+
367378 export interface ReviewRequest {
368379 repoId: string;
369380 pr: number;
@@ -401,7 +412,7 @@
401412 get(number: number): TaskView | null {
402413 const t = this.task(number);
403414 if (!t) return null;
404- const runs = this.sql.exec<RunRow>("SELECT seq, kind, message, step, exit_code, error, model_calls, tokens, started_at, ended_at, attempt FROM runs WHERE task = ? ORDER BY seq", number).toArray();
415+ const runs = this.sql.exec<RunRow>("SELECT seq, kind, message, step, exit_code, error, model_calls, tokens, started_at, ended_at, attempt, terminal_id FROM runs WHERE task = ? ORDER BY seq", number).toArray();
405416 return { ...t, runs };
406417 }
407418
@@ -570,7 +581,8 @@
570581 at,
571582 );
572583 this.event(number, `${input.autofixOf ? "Autofix" : input.coordinator ? `${input.person.login}'s coordinator` : input.person.login} started task #${number}: ${title}`);
573- if (input.planFirst) this.startRun(number, "plan", null, input.providers);
584+ if (input.terminal) this.startRun(number, "start", null, [], null, input.terminal);
585+ else if (input.planFirst) this.startRun(number, "plan", null, input.providers);
574586 else this.startAttempts(number, "start", input.providers);
575587 await this.index(number);
576588 return this.get(number)!;
@@ -599,6 +611,7 @@
599611 async message(number: number, text: string, providers: Provider[], from?: string): Promise<TaskView> {
600612 const t = this.mustTask(number);
601613 if (this.picking(t)) throw new HttpError(409, t.pick_state === "running" ? `the task is running ${t.attempts} attempts: send follow-ups once you've picked one` : "pick an attempt first: follow-ups go to the picked attempt's branch");
614+ if (this.activeRun(number)?.terminal_id) throw new HttpError(409, "the task is running in your terminal: send your message once it ends, or stop it");
602615 if (this.activeRun(number)) {
603616 this.sql.exec("UPDATE tasks SET queued = ?, updated_at = ? WHERE number = ?", t.queued ? `${t.queued}\n\n${text}` : text, now(), number);
604617 this.note(number, this.lastRun(number)?.seq ?? 0, `${from ?? t.person_login} sent a message; it starts a new run when this one ends.`);
@@ -621,6 +634,37 @@
621634 return this.get(number)!;
622635 }
623636
637+ /**
638+ * Run the task again in the person's open web terminal (phase 4b), with the CLI signed in there: only on their own
639+ * click (agentic/terminals.ts checked the terminal, its agent and the opt-in). Nothing else starts runs in a terminal.
640+ */
641+ async runInTerminal(number: number, terminal: InTerminal, message: string | null): Promise<TaskView> {
642+ const t = this.mustTask(number);
643+ if (t.kind !== "task") throw new HttpError(409, "only agent tasks run in a terminal");
644+ if (this.activeRun(number)) throw new HttpError(409, "the task is running; stop it first");
645+ if (t.attempts > 1 && t.picked === null) throw new HttpError(409, "a task with attempts runs in sandboxes of its own until you pick one");
646+ if (t.plan_state === "planning" || t.plan_state === "waiting") throw new HttpError(409, "approve the plan first: plans are written in a read-only sandbox");
647+ const busy = this.sql.exec<{ task: number }>(`SELECT task FROM runs WHERE terminal_id = ? AND step IN (${ACTIVE.map(() => "?").join(", ")})`, terminal.id, ...ACTIVE).toArray()[0];
648+ if (busy) throw new HttpError(409, `your terminal is already running task #${busy.task}; one task at a time`);
649+ const last = this.lastRun(number);
650+ if (message) this.startRun(number, "message", message, [], null, terminal);
651+ else this.startRun(number, "retry", last?.kind === "plan" ? null : (last?.message ?? null), [], null, terminal);
652+ await this.index(number);
653+ return this.get(number)!;
654+ }
655+
656+ /** Whether a terminal is running a task, for its idle rule and its page. */
657+ terminalTask(terminalId: string): number | null {
658+ return this.sql.exec<{ task: number }>(`SELECT task FROM runs WHERE terminal_id = ? AND step IN (${ACTIVE.map(() => "?").join(", ")})`, terminalId, ...ACTIVE).toArray()[0]?.task ?? null;
659+ }
660+
661+ /** A terminal ended: the run in it stops (its sandbox is gone). */
662+ async terminalEnded(terminalId: string): Promise<void> {
663+ for (const r of this.sql.exec<RunRow>(`SELECT * FROM runs WHERE terminal_id = ? AND step IN (${ACTIVE.map(() => "?").join(", ")})`, terminalId, ...ACTIVE).toArray()) {
664+ await this.endRun(r, "stopped", "The terminal it ran in ended.");
665+ }
666+ }
667+
624668 /** Stop the active run: kill the agent, close the sandbox (revoking its git credential) and the model token. */
625669 async stop(number: number, by: string, reason?: string): Promise<TaskView> {
626670 this.mustTask(number);
@@ -722,6 +766,12 @@
722766 if (now_.check_fix_sha === head || now_.state !== "ready") continue;
723767 this.sql.exec("UPDATE tasks SET check_fix_sha = ? WHERE number = ?", head, t.number);
724768 const run = this.lastRun(t.number);
769+ // Nothing starts a run in a terminal by itself, and the run's own model key may not exist: the person reruns it.
770+ if (run?.terminal_id) {
771+ this.note(t.number, run.seq, `Checks failed on #${t.pr_number}. The last run was in ${t.person_login}'s terminal, so no fix run starts by itself: run it again from the task.`);
772+ this.event(t.number, `Task #${t.number}'s pull request #${t.pr_number} failed checks.`);
773+ continue;
774+ }
725775 if (now_.check_fixes >= MAX_CHECK_FIXES) {
726776 this.note(t.number, run?.seq ?? 0, `Checks failed on #${t.pr_number} again; ${MAX_CHECK_FIXES} fix runs is the most a task starts by itself.`);
727777 this.event(t.number, `Task #${t.number}'s pull request #${t.pr_number} still fails checks after ${MAX_CHECK_FIXES} fix runs.`);
@@ -1001,7 +1051,7 @@
10011051
10021052 // ---------------------------------------------------------------- runs
10031053
1004- private startRun(number: number, kind: RunRow["kind"], message: string | null, providers: Provider[], attempt: number | null = null): void {
1054+ private startRun(number: number, kind: RunRow["kind"], message: string | null, providers: Provider[], attempt: number | null = null, terminal: InTerminal | null = null): void {
10051055 const t = this.mustTask(number);
10061056 const seq = (this.lastRun(number)?.seq ?? 0) + 1;
10071057 // A follow-up continues from the run before it on the task's branch: after a pick, the picked attempt's.
@@ -1019,8 +1069,23 @@
10191069 if (kind === "plan") this.sql.exec("UPDATE tasks SET plan_state = 'planning' WHERE number = ?", number);
10201070 const at = now();
10211071 // A task's run waits its turn under the project's "how many at once"; reviews and coordinators have their own limits.
1022- const step: RunStep = own ? "boot" : "queued";
1023- this.sql.exec("INSERT INTO runs (task, seq, kind, message, prompt, providers, step, started_at, attempt, branch) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", number, seq, kind, message, prompt, JSON.stringify(providers), step, at, attempt, attempt ? attemptBranch(t.branch, attempt) : null);
1072+ // A run in the person's terminal uses that terminal's sandbox, so it takes no slot.
1073+ const step: RunStep = own || terminal ? "boot" : "queued";
1074+ this.sql.exec(
1075+ "INSERT INTO runs (task, seq, kind, message, prompt, providers, step, started_at, attempt, branch, terminal_id, sandbox_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1076+ number,
1077+ seq,
1078+ kind,
1079+ message,
1080+ prompt,
1081+ JSON.stringify(providers),
1082+ step,
1083+ at,
1084+ attempt,
1085+ attempt ? attemptBranch(t.branch, attempt) : null,
1086+ terminal?.id ?? null,
1087+ terminal?.sandboxId ?? null,
1088+ );
10241089 // A task with an attempt already running stays running while the next one queues.
10251090 const busy = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM runs WHERE task = ? AND step IN (${RUNNING.map(() => "?").join(", ")})`, number, ...RUNNING).one().n > 0;
10261091 this.sql.exec("UPDATE tasks SET state = ?, error = NULL, updated_at = ? WHERE number = ?", step === "queued" && !busy ? "queued" : "running", at, number);
@@ -1048,6 +1113,7 @@
10481113 : "Planning again."
10491114 : `${t.coordinator_post ? `${t.person_login}'s coordinator` : t.person_login} started the task with ${runtimeLabel(t.runtime, t)}${kind === "plan" ? ". It plans first" : ""}.`,
10501115 );
1116+ if (terminal) this.note(number, seq, `Running in ${t.person_login}'s terminal, with the ${RUNTIMES[t.runtime].name} signed in there on ${t.person_login}'s own account.`);
10511117 }
10521118 if (step === "queued" && !this.promote().includes(number) && this.run(number, seq)?.step === "queued") {
10531119 const waits = this.waitsFor(t);
@@ -1062,7 +1128,7 @@
10621128
10631129 /** Start queued runs, oldest first, while the project has room; returns the tasks started. */
10641130 private promote(): number[] {
1065- const running = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM runs r JOIN tasks t ON t.number = r.task WHERE t.kind = 'task' AND r.step IN (${RUNNING.map(() => "?").join(", ")})`, ...RUNNING).one().n;
1131+ const running = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM runs r JOIN tasks t ON t.number = r.task WHERE t.kind = 'task' AND r.terminal_id IS NULL AND r.step IN (${RUNNING.map(() => "?").join(", ")})`, ...RUNNING).one().n;
10661132 let room = this.projectSettings().maxRunning - running;
10671133 const queued: RunRow[] = [];
10681134 const at = now();
@@ -1167,9 +1233,9 @@
11671233 }
11681234
11691235 private async tickTerminal(t: TerminalRow, terminals: Terminals, dir: Directory): Promise<void> {
1170- if (!terminalsEnabled(this.env)) return endTerminal(this.env, dir, t, "disabled");
1236+ if (!terminalsEnabled(this.env)) return endTerminal(this.env, dir, t, "disabled", undefined, this);
11711237 // A kill switch (lib/killswitch.ts) that stops sandboxes here ends the terminal too.
1172- if (blockedBy(await killSwitches(this.env, t.repo_id), "sandbox")) return endTerminal(this.env, dir, t, "suspended");
1238+ if (blockedBy(await killSwitches(this.env, t.repo_id), "sandbox")) return endTerminal(this.env, dir, t, "suspended", undefined, this);
11731239 // The same rule as tasks, checked again every time: the person drives the agent, and both may write.
11741240 const [person, agent, repo] = await Promise.all([dir.account(t.person_id), dir.account(t.agent_id), dir.repoById(t.repo_id)]);
11751241 let caller;
@@ -1178,7 +1244,7 @@
11781244 caller = await taskCaller(dir, person, agent, repo);
11791245 } catch (e) {
11801246 if (!(e instanceof TaskAccessError)) throw e;
1181- return endTerminal(this.env, dir, t, "access");
1247+ return endTerminal(this.env, dir, t, "access", undefined, this);
11821248 }
11831249 const stub = this.env.REPO.get(this.env.REPO.idFromName(t.repo_id));
11841250 if (t.status === "starting") {
@@ -1187,19 +1253,20 @@
11871253 sb = await stub.createSandbox(caller, t.ref, { personId: t.person_id, kind: "terminal" });
11881254 } catch (e) {
11891255 console.error("terminal sandbox failed to start", t.id, e);
1190- return endTerminal(this.env, dir, t, "failed");
1256+ return endTerminal(this.env, dir, t, "failed", undefined, this);
11911257 }
11921258 // Ended while it started: stop it again.
11931259 if (!(await terminals.started(t.id, sb.id))) await stub.closeTaskSandbox(sb.id).catch(() => {});
11941260 return;
11951261 }
1196- if (Date.now() - Date.parse(t.started_at ?? t.created_at) > TERMINAL_MAX_MS) return endTerminal(this.env, dir, t, "limit");
1262+ if (Date.now() - Date.parse(t.started_at ?? t.created_at) > TERMINAL_MAX_MS) return endTerminal(this.env, dir, t, "limit", undefined, this);
11971263 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(t.sandbox_id!));
11981264 type PtyStatus = { attached?: boolean; idleSince?: string | null };
11991265 const res = (await sandbox.running()) ? await sandbox.call("/pty/status", "{}").catch(() => null) : null;
12001266 const status = res?.status === 200 ? (JSON.parse(res.body) as PtyStatus) : null;
1201- if (!status) return endTerminal(this.env, dir, t, "stopped");
1202- if (!status.attached && Date.now() - Date.parse(status.idleSince ?? t.started_at ?? t.created_at) > TERMINAL_IDLE_MS) return endTerminal(this.env, dir, t, "idle");
1267+ if (!status) return endTerminal(this.env, dir, t, "stopped", undefined, this);
1268+ // A task running in it counts as someone attached.
1269+ if (!status.attached && this.terminalTask(t.id) === null && Date.now() - Date.parse(status.idleSince ?? t.started_at ?? t.created_at) > TERMINAL_IDLE_MS) return endTerminal(this.env, dir, t, "idle", undefined, this);
12031270 }
12041271
12051272 private arm(ms: number): void {
@@ -1213,17 +1280,26 @@
12131280 return this.endRun(r, "failed", t.kind === "review" ? `The review took longer than ${REVIEW_TIMEOUT_MS / 60_000} minutes and was stopped.` : t.kind === "coordinator" ? `The coordinator run took longer than ${COORDINATOR_TIMEOUT_MS / 60_000} minutes and was stopped.` : "The run took longer than 6 hours and was stopped.");
12141281 switch (r.step) {
12151282 case "boot":
1283+ if (r.terminal_id) return this.terminalBoot(t, r);
12161284 return t.kind === "review" ? this.reviewBoot(t, r) : t.kind === "coordinator" ? this.coordinatorBoot(t, r) : this.boot(t, r);
12171285 case "install": {
1218- const install = RUNTIMES[t.runtime].install;
1286+ let install = RUNTIMES[t.runtime].install;
12191287 if (!install) return this.setStep(r, "agent");
1220- if (!r.proc) return this.startProc(r, "install", install, {}, {}, INSTALL_TIMEOUT_MS, `Installing ${RUNTIMES[t.runtime].name}.`);
1288+ // In a terminal the CLI is usually there already, signed in: keep it.
1289+ const bin = TERMINAL_BIN[t.runtime];
1290+ if (r.terminal_id && bin) install = `command -v ${bin} >/dev/null 2>&1 || { ${install}; }`;
1291+ if (!r.proc) return this.startProc(r, "install", install, {}, {}, INSTALL_TIMEOUT_MS, r.terminal_id ? null : `Installing ${RUNTIMES[t.runtime].name}.`);
12211292 const done = await this.poll(r, "setup");
12221293 if (done === null) return;
12231294 if (done !== 0) return this.endRun(this.run(r.task, r.seq)!, "failed", `Installing ${RUNTIMES[t.runtime].name} failed (exit ${done}).`);
12241295 return this.setStep(this.run(r.task, r.seq)!, "agent");
12251296 }
12261297 case "agent": {
1298+ if (!r.proc && r.terminal_id) {
1299+ // No model token and no proxy: the CLI uses the account the person signed in to in their terminal.
1300+ const env = { IS_SANDBOX: "1", DISABLE_AUTOUPDATER: "1", DIFFINERY_TASK_PROMPT: r.prompt };
1301+ return this.startProc(r, "agent", RUNTIMES[t.runtime].command(), env, {}, timeoutOf(t) - (Date.now() - Date.parse(r.started_at)), null, worktree(r));
1302+ }
12271303 if (!r.proc) {
12281304 // The key itself never enters the sandbox: the runtime gets a token for this run that only the model proxy accepts.
12291305 const providers = JSON.parse(r.providers) as Provider[];
@@ -1234,6 +1310,7 @@
12341310 }
12351311 if (!r.checked_at || Date.parse(r.checked_at) + RECHECK_MS < Date.now()) {
12361312 await this.checkAccess(t);
1313+ if (r.terminal_id) await this.checkTerminal(t, r);
12371314 this.sql.exec("UPDATE runs SET checked_at = ? WHERE task = ? AND seq = ?", now(), r.task, r.seq);
12381315 }
12391316 const done = await this.poll(r, "out");
@@ -1250,7 +1327,7 @@
12501327 if (r.kind === "plan") return this.keepPlan(t, r);
12511328 const branch = r.branch ?? t.branch;
12521329 if (!r.proc) {
1253- return this.startProc(r, "finish", FINISH_SCRIPT, { DIFFINERY_TASK_BRANCH: branch, DIFFINERY_TASK_START: r.start_sha ?? "", DIFFINERY_TASK_MESSAGE: commitMessage(t, r) }, {}, 10 * 60 * 1000, `Pushing to ${branch}.`);
1330+ return this.startProc(r, "finish", FINISH_SCRIPT, { DIFFINERY_TASK_BRANCH: branch, DIFFINERY_TASK_START: r.start_sha ?? "", DIFFINERY_TASK_MESSAGE: commitMessage(t, r) }, {}, 10 * 60 * 1000, `Pushing to ${branch}.`, r.terminal_id ? worktree(r) : undefined);
12541331 }
12551332 const done = await this.poll(r, "setup");
12561333 if (done === null) return;
@@ -1305,6 +1382,42 @@
13051382 }
13061383
13071384 /**
1385+ * A run in the person's web terminal (phase 4b): no sandbox of its own. It checks the terminal is still theirs and
1386+ * open, and the opt-in, then checks out the task's branch in a separate directory there (a git worktree), so the
1387+ * person's own checkout is left alone.
1388+ */
1389+ private async terminalBoot(t: TaskRow, r: RunRow): Promise<void> {
1390+ await this.checkAccess(t);
1391+ await this.checkTerminal(t, r);
1392+ const stub = this.env.REPO.get(this.env.REPO.idFromName(t.repo_id));
1393+ const branchSha = await stub.getRef(`refs/heads/${t.branch}`);
1394+ const ref = branchSha ? t.branch : t.base;
1395+ const startSha = branchSha ?? (await stub.getRef(`refs/heads/${t.base}`));
1396+ if (!startSha) throw new HttpError(422, `the branch ${t.base} doesn't exist`);
1397+ this.note(t.number, r.seq, `Checking out ${ref} in ${t.person_login}'s terminal, in its own directory.`);
1398+ const wt = worktree(r);
1399+ // Branch names may hold `$`, backticks, `;` or `|`: everything reaches the script through its environment, quoted.
1400+ const prepared = (await this.sandbox(r, "/exec", {
1401+ cmd: ["set -e", 'git fetch -q origin "refs/heads/$DIFFINERY_TASK_REF"', 'git worktree remove --force "$DIFFINERY_WORKTREE" 2>/dev/null || rm -rf "$DIFFINERY_WORKTREE"', "git worktree prune", 'git worktree add -q --detach "$DIFFINERY_WORKTREE" "$DIFFINERY_TASK_START"'].join("\n"),
1402+ env: { DIFFINERY_TASK_REF: ref, DIFFINERY_WORKTREE: wt, DIFFINERY_TASK_START: startSha },
1403+ timeoutMs: 5 * 60_000,
1404+ })) as { exitCode: number; stderr: string };
1405+ if (prepared.exitCode !== 0) throw new HttpError(422, `checking out ${ref} in the terminal failed: ${prepared.stderr.trim().split("\n").slice(-3).join(" ")}`);
1406+ if (this.run(t.number, r.seq)?.step !== "boot") return;
1407+ this.sql.exec("UPDATE runs SET start_sha = ?, step = 'install', proc = NULL, checked_at = ? WHERE task = ? AND seq = ?", startSha, now(), t.number, r.seq);
1408+ }
1409+
1410+ /** The terminal a run is in must still be open, the task's person's, with the opt-in in force. */
1411+ private async checkTerminal(t: TaskRow, r: RunRow): Promise<void> {
1412+ const terminals = new Terminals(this.env);
1413+ const term = await terminals.get(r.terminal_id!);
1414+ if (!term || term.status !== "running" || term.sandbox_id !== r.sandbox_id || term.person_id !== t.person_id || term.agent_id !== t.agent_id) throw new TaskAccessError("the terminal it ran in is no longer open");
1415+ if (!(await terminals.termsAt(t.person_id))) throw new TaskAccessError(`${t.person_login} hasn't accepted the notice for terminals`);
1416+ const block = blockedBy(await killSwitches(this.env, t.repo_id), "sandbox");
1417+ if (block) throw new TaskAccessError(block);
1418+ }
1419+
1420+ /**
13081421 * A coordinator's sandbox: the default branch, read-only, with the prompt
13091422 * built from the project as it is now and the posts it handles.
13101423 */
@@ -1638,9 +1751,9 @@
16381751 return repo ? `${repo.owner_login}/${repo.name}` : "";
16391752 }
16401753
1641- private async startProc(r: RunRow, id: string, cmd: string, env: Record<string, string>, secretEnv: Record<string, string>, timeoutMs: number, note: string | null): Promise<void> {
1754+ private async startProc(r: RunRow, id: string, cmd: string, env: Record<string, string>, secretEnv: Record<string, string>, timeoutMs: number, note: string | null, cwd?: string): Promise<void> {
16421755 if (note) this.note(r.task, r.seq, note);
1643- await this.sandbox(r, "/proc/start", { id, cmd, env, secretEnv, timeoutMs: Math.max(timeoutMs, 1000) });
1756+ await this.sandbox(r, "/proc/start", { id, cmd, env, secretEnv, timeoutMs: Math.max(timeoutMs, 1000), ...(cwd ? { cwd } : {}) });
16441757 this.sql.exec("UPDATE runs SET proc = ?, out_off = 0, err_off = 0 WHERE task = ? AND seq = ?", id, r.task, r.seq);
16451758 }
16461759
@@ -1736,9 +1849,12 @@
17361849 await this.index(r.task);
17371850 if (r.sandbox_id) {
17381851 if (r.proc) await this.sandbox(r, "/proc/kill", { id: r.proc, signal: "SIGKILL" }).catch(() => {});
1739- await this.env.REPO.get(this.env.REPO.idFromName(t.repo_id))
1740- .closeTaskSandbox(r.sandbox_id)
1741- .catch((e) => console.error("closing a task sandbox failed", e));
1852+ // A terminal's sandbox stays the person's: only the run's own checkout goes.
1853+ if (r.terminal_id) await this.sandbox(r, "/exec", { cmd: 'git worktree remove --force "$DIFFINERY_WORKTREE" 2>/dev/null; rm -rf "$DIFFINERY_WORKTREE"; git worktree prune', env: { DIFFINERY_WORKTREE: worktree(r) }, timeoutMs: 60_000 }).catch(() => {});
1854+ else
1855+ await this.env.REPO.get(this.env.REPO.idFromName(t.repo_id))
1856+ .closeTaskSandbox(r.sandbox_id)
1857+ .catch((e) => console.error("closing a task sandbox failed", e));
17421858 }
17431859 if (t.kind === "review" && step !== "done" && r.head_sha) {
17441860 await this.reviewCheck(t, r.head_sha, "completed", step === "stopped" ? "cancelled" : "failure", step === "stopped" ? "The review was stopped" : "The review didn't finish", error ?? "");
@@ -1834,6 +1950,12 @@
18341950 }
18351951 }
18361952
1953+/** A terminal run's own checkout, beside the terminal's (relative to the sandbox's repository directory). */
1954+const worktree = (r: RunRow): string => `../task-${r.task}-${r.seq}`;
1955+
1956+/** The CLI a terminal run looks for before installing it. */
1957+const TERMINAL_BIN: Partial<Record<RuntimeId, string>> = { "claude-code": "claude", codex: "codex" };
1958+
18371959 const timeoutOf = (t: TaskRow): number => (t.kind === "review" ? REVIEW_TIMEOUT_MS : t.kind === "coordinator" ? COORDINATOR_TIMEOUT_MS : RUN_TIMEOUT_MS);
18381960
18391961 function splitLines(s: string): string[] {
modifiedapps/diffinery/src/agentic/tasks-web.tsx+30−3View file
@@ -15,6 +15,7 @@
1515 import { OPENROUTER_DEFAULT_MODEL, parseRuntimeChoice, runtimeChoices, runtimeLabel, transcriptItem, type TranscriptItem } from "./runtimes.js";
1616 import { approvePlan, compareAttempts, drivableAgents, getTask, pickAttempt, setTaskOptions, type AttemptCompare, mayStop, messageTask, projectStub, requireTaskReader, retryTask, startTask, stopTask, taskNumber } from "./tasks.js";
1717 import { splitPlan } from "./projects.js";
18+import { runTaskInTerminal, terminalForTask, terminalId } from "./terminals.js";
1819
1920 /**
2021 * Web pages for agent tasks: Settings → Model access, and a repository's
@@ -191,7 +192,7 @@
191192 return <pre class={`tx-pre${item.kind === "error" ? " tx-err" : ""}`}>{item.body}</pre>;
192193 };
193194
194-function taskPage(c: AppContext, l: Loaded, t: TaskView, items: Array<{ run: number; item: TranscriptItem }>, truncated: boolean, canSplit = false, attempts: AttemptCompare[] = []) {
195+function taskPage(c: AppContext, l: Loaded, t: TaskView, items: Array<{ run: number; item: TranscriptItem }>, truncated: boolean, canSplit = false, attempts: AttemptCompare[] = [], terminal: string | null = null) {
195196 const base = repoUrl(l.repo);
196197 const tk = csrfToken(c);
197198 const me = requireAccount(c);
@@ -227,6 +228,14 @@
227228 <button class="btn sm">Retry</button>
228229 </Form>
229230 ) : null}
231+ {!running && terminal ? (
232+ <Form action={`${base}/tasks/${t.number}/terminal`} t={tk} class="inline">
233+ <input type="hidden" name="terminal" value={terminal} />
234+ <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.`}>
235+ Run in my terminal
236+ </button>
237+ </Form>
238+ ) : null}
230239 </div>
231240 {t.coordinator_post ? (
232241 <div class="flash info">
@@ -315,7 +324,7 @@
315324 ) : null}
316325 {items.length ? items.map((x, i) => (
317326 <>
318- {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" : ""}</h4> : null}
327+ {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}
319328 <ItemView item={x.item} base={base} />
320329 </>
321330 )) : <div class="empty">Starting…</div>}
@@ -333,6 +342,14 @@
333342 <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."} />
334343 </div>
335344 <button class="btn primary">Send</button>
345+ {!running && terminal ? (
346+ <>
347+ {" "}
348+ <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.">
349+ Send to my terminal
350+ </button>
351+ </>
352+ ) : null}
336353 </div>
337354 </Form>
338355 ) : review ? (
@@ -597,7 +614,8 @@
597614 const items = tail.flatMap((line) => transcriptItem(line).map((item) => ({ run: line.run, item })));
598615 const canSplit = t.plan_state === "waiting" && !!(await stub.coordinator(requireAccount(c).id));
599616 const attempts = t.attempts > 1 ? await compareAttempts(c, l, n) : [];
600- return taskPage(c, l, t, items, lines.length > tail.length, canSplit, attempts);
617+ const terminal = await terminalForTask(c.env, requireAccount(c), l.repo, t).catch(() => null);
618+ return taskPage(c, l, t, items, lines.length > tail.length, canSplit, attempts, terminal);
601619 });
602620
603621 post(web, "/:owner/:repo/tasks/:number/messages", async (c, f) => {
@@ -650,6 +668,15 @@
650668 }, "Retrying.");
651669 });
652670
671+ // Phase 4b: only the person's own click here runs a task in their terminal (with the follow-up, if they wrote one).
672+ post(web, "/:owner/:repo/tasks/:number/terminal", async (c, f) => {
673+ const l = await forTasks(c);
674+ const n = taskNumber(c.req.param("number"));
675+ return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => {
676+ await runTaskInTerminal(c, l, n, terminalId(f.s("terminal")), f.s("body"));
677+ }, "Running in your terminal.");
678+ });
679+
653680 post(web, "/:owner/:repo/tasks/:number/stop", async (c) => {
654681 const l = await forTasks(c);
655682 const n = taskNumber(c.req.param("number"));
modifiedapps/diffinery/src/agentic/tasks.ts+1−1View file
@@ -323,7 +323,7 @@
323323 html_url: `${base}/${repoFull}/tasks/${t.number}`,
324324 created_at: t.created_at,
325325 updated_at: t.updated_at,
326- ...("runs" in t ? { runs: t.runs.map((r) => ({ number: r.seq, kind: r.kind, message: r.message, state: r.step, exit_code: r.exit_code, error: r.error, model_requests: r.model_calls ?? 0, tokens: r.tokens ?? 0, ...(r.attempt ? { attempt: r.attempt } : {}), started_at: r.started_at, ended_at: r.ended_at })) } : {}),
326+ ...("runs" in t ? { runs: t.runs.map((r) => ({ number: r.seq, kind: r.kind, message: r.message, state: r.step, exit_code: r.exit_code, error: r.error, model_requests: r.model_calls ?? 0, tokens: r.tokens ?? 0, ...(r.attempt ? { attempt: r.attempt } : {}), ...(r.terminal_id ? { in_terminal: true } : {}), started_at: r.started_at, ended_at: r.ended_at })) } : {}),
327327 };
328328 }
329329
modifiedapps/diffinery/src/agentic/terminal-web.tsx+49−4View file
@@ -5,14 +5,14 @@
55 import { loadRepo } from "../http/common.js";
66 import { HttpError } from "../lib/util.js";
77 import { csrfToken } from "../web/csrf.js";
8-import { KCheckbox, KSelect } from "../web/kumo.js";
8+import { KCheckbox, KRadio, KSelect } from "../web/kumo.js";
99 import { Form, Time, act, post, prm, render, repoUrl, statusOf, type WebApp } from "../web/ui.js";
1010 import TERMINAL_JS from "../../build/islands/terminal.js.txt";
1111 import TERMINAL_CSS from "../../build/islands/terminal.css.txt";
1212 import { taskCaller, TaskAccessError } from "./access.js";
1313 import { RUNTIME_VERSIONS } from "./runtimes.js";
14-import { drivableAgents, requirePersonSession, requireTaskReader } from "./tasks.js";
15-import { endTerminal, MAX_TERMINALS, MAX_TERMINALS_PER_REPO, openTerminal, TERMINAL_END_TEXT, TERMINAL_IDLE_MS, TERMINAL_MAX_MS, terminalId, terminalsEnabled, Terminals, type TerminalEndReason, type TerminalRow } from "./terminals.js";
14+import { drivableAgents, projectStub, requirePersonSession, requireTaskReader } from "./tasks.js";
15+import { endTerminal, MAX_TERMINALS, MAX_TERMINALS_PER_REPO, openTerminal, startTaskInTerminal, TERMINAL_END_TEXT, TERMINAL_IDLE_MS, TERMINAL_MAX_MS, terminalId, terminalsEnabled, Terminals, type TerminalEndReason, type TerminalRow } from "./terminals.js";
1616
1717 /**
1818 * Web pages for terminals (phase 4): the list and open form on a repository, the terminal page
@@ -147,7 +147,7 @@
147147 label={
148148 <>
149149 I use Claude Code and Codex in my terminals with my own Anthropic or OpenAI account, under that provider's terms (<TermsLinks />
150- ). Usage is billed to my account. Diffinery only provides the sandbox, and never sees or keeps my sign-in.
150+ ). Usage is billed to my account. Diffinery only provides the sandbox, and never sees or keeps my sign-in. A task I give a terminal runs the agent without asking before each step, in that terminal, where my sign-in is. Content in the repository can steer it, so I give it tasks only in repositories whose contents I trust.
151151 </>
152152 }
153153 />
@@ -172,6 +172,7 @@
172172 const agent = await c.get("dir").account(t.agent_id);
173173 const endsAt = t.started_at ? new Date(Date.parse(t.started_at) + TERMINAL_MAX_MS).toISOString() : null;
174174 const running = t.status === "running";
175+ const current = running ? await projectStub(c.env, l.repo.id).terminalTask(t.id) : null;
175176 return render(
176177 c,
177178 {
@@ -231,6 +232,38 @@
231232 {running ? (
232233 <>
233234 <div id="dfy-terminal" class="term" data-socket={`${base}/terminals/${t.id}/socket`}></div>
235+ <div class="box mt">
236+ <div class="head">
237+ <h3>Give it a task</h3>
238+ </div>
239+ <div class="body">
240+ {current ? (
241+ <p class="m0">
242+ <a href={`${base}/tasks/${current}`}>Task #{current}</a> is running in this terminal. One task at a time: it ends when the task does, or when you stop it.
243+ </p>
244+ ) : (
245+ <Form action={`${base}/terminals/${t.id}/task`} t={tk}>
246+ <div class="field">
247+ <label for="goal">Goal</label>
248+ <textarea id="goal" name="goal" rows={3} required maxlength={20000} placeholder="Fix the Contact sales link on the pricing page so it goes to /contact, and add a test."></textarea>
249+ </div>
250+ <div class="field">
251+ <KRadio
252+ name="runtime"
253+ value="claude-code"
254+ orientation="horizontal"
255+ label="Runs with"
256+ items={[{ value: "claude-code", label: "Claude Code" }, ...(l.repo.visibility !== "public" ? [{ value: "codex", label: "Codex" }] : [])]}
257+ />
258+ </div>
259+ <p class="muted small">
260+ It runs here with the one you're signed in to, on your own subscription, as {agent?.login ?? "your agent"}, in a checkout of {t.ref} of its own (yours is left alone). Diffinery pushes its work to a new branch and opens a pull request. Its checks don't start fix runs by themselves.
261+ </p>
262+ <button class="btn primary">Run with my subscription</button>
263+ </Form>
264+ )}
265+ </div>
266+ </div>
234267 <details class="box mt">
235268 <summary class="body">Signing in</summary>
236269 <div class="body muted small">
@@ -284,6 +317,18 @@
284317 return terminalPage(c, l, t);
285318 });
286319
320+ // Phase 4b: only the opener's own click here starts a task in their terminal.
321+ post(web, "/:owner/:repo/terminals/:id/task", async (c, f) => {
322+ const l = await forTerminals(c);
323+ const id = terminalId(c.req.param("id"));
324+ return act(
325+ c,
326+ `${repoUrl(l.repo)}/terminals/${encodeURIComponent(id)}`,
327+ async () => `${repoUrl(l.repo)}/tasks/${(await startTaskInTerminal(c, l, id, { goal: f.s("goal"), runtime: f.s("runtime") })).number}`,
328+ "The task is running in your terminal.",
329+ );
330+ });
331+
287332 post(web, "/:owner/:repo/terminals/:id/end", async (c) => {
288333 const l = await forTerminals(c);
289334 const me = requireAccount(c);
modifiedapps/diffinery/src/agentic/terminals.ts+97−5View file
@@ -6,8 +6,9 @@
66 import { mayStart } from "../lib/quotas.js";
77 import { HttpError, newId, now } from "../lib/util.js";
88 import { taskCaller, TaskAccessError } from "./access.js";
9-import { RUNTIME_VERSIONS } from "./runtimes.js";
10-import { projectStub, requirePersonSession } from "./tasks.js";
9+import type { TaskView } from "./ProjectDO.js";
10+import { MAX_GOAL, RUNTIME_VERSIONS, type RuntimeId } from "./runtimes.js";
11+import { projectStub, requirePersonSession, starter } from "./tasks.js";
1112
1213 /**
1314 * Web terminals (phase 4, /mnt/project-files/agentic/phase4-design.md): a sandbox one person opens
@@ -29,7 +30,7 @@
2930 export const TERMINAL_IDLE_MS = 30 * 60_000;
3031 export const TERMINAL_MAX_MS = 8 * 3600_000;
3132 /** Bump when the notice changes: everyone opts in again. */
32-export const TERMINAL_TERMS_VERSION = 1;
33+export const TERMINAL_TERMS_VERSION = 2;
3334
3435 export type TerminalStatus = "starting" | "running" | "ended";
3536 export type TerminalEndReason = "person" | "idle" | "limit" | "access" | "stopped" | "failed" | "disabled" | "suspended";
@@ -176,9 +177,13 @@
176177 return t;
177178 }
178179
179-/** Ends a terminal: marks it ended, then stops its sandbox (which revokes its git credential). */
180-export async function endTerminal(env: Env, dir: Directory, t: TerminalRow, reason: TerminalEndReason, by?: Account): Promise<void> {
180+/**
181+ * Ends a terminal: marks it ended, stops a task running in it, then stops its sandbox (which revokes its git
182+ * credential). `project` is the repository's ProjectDO when the caller is that object itself.
183+ */
184+export async function endTerminal(env: Env, dir: Directory, t: TerminalRow, reason: TerminalEndReason, by?: Account, project?: { terminalEnded(id: string): Promise<void> }): Promise<void> {
181185 if (!(await new Terminals(env).markEnded(t.id, reason))) return;
186+ await (project ?? projectStub(env, t.repo_id)).terminalEnded(t.id).catch((e: unknown) => console.error("stopping the terminal's task failed", e));
182187 if (t.sandbox_id) await env.REPO.get(env.REPO.idFromName(t.repo_id)).closeTaskSandbox(t.sandbox_id).catch((e) => console.error("terminal sandbox close failed", e));
183188 const repo = await dir.repoById(t.repo_id).catch(() => null);
184189 const actor = by ?? (await dir.account(t.person_id).catch(() => null));
@@ -195,3 +200,90 @@
195200 .catch((e) => console.error("audit failed", e)),
196201 );
197202 }
203+
204+// ---------------------------------------------------------------- 4b: tasks in a terminal
205+
206+/**
207+ * The runtimes a terminal runs tasks with, signed in there: Claude Code, and Codex on private repositories only
208+ * (OpenAI allows signing in with a ChatGPT plan only there).
209+ */
210+export function terminalRuntimes(repo: Repo): RuntimeId[] {
211+ return repo.visibility === "public" ? ["claude-code"] : ["claude-code", "codex"];
212+}
213+
214+/** The person's own open terminal in this repository, with the opt-in in force, checked again at every dispatch. */
215+async function dispatchTerminal(c: AppContext, l: Loaded, me: Account, id: string | undefined, agentId?: string): Promise<{ t: TerminalRow; to: { id: string; sandboxId: string } }> {
216+ if (!terminalsEnabled(c.env)) throw new HttpError(403, "terminals are switched off on this Diffinery");
217+ const terminals = new Terminals(c.env);
218+ const t = await terminals.mine(terminalId(id), me, l.repo);
219+ if (t.status !== "running" || !t.sandbox_id) throw new HttpError(409, "the terminal isn't running");
220+ if (!(await terminals.termsAt(me.id))) throw new HttpError(403, "accept the notice for terminals first: tasks there use your own provider account");
221+ // Kill switches (lib/killswitch.ts) stop sandboxes here: the terminal's is ending, and starts no task meanwhile.
222+ const block = blockedBy(await killSwitches(c.env, l.repo.id), "sandbox");
223+ if (block) throw new HttpError(403, block);
224+ if (agentId && t.agent_id !== agentId) throw new HttpError(422, "this terminal acts as a different agent than the task's: open one with the task's agent");
225+ if (await projectStub(c.env, l.repo.id).terminalTask(t.id)) throw new HttpError(409, "your terminal is already running a task; one at a time");
226+ return { t, to: { id: t.id, sandboxId: t.sandbox_id } };
227+}
228+
229+function terminalRuntime(l: Loaded, runtime: unknown): RuntimeId {
230+ const ok = terminalRuntimes(l.repo);
231+ if (typeof runtime !== "string" || !ok.includes(runtime as RuntimeId)) throw new HttpError(422, l.repo.visibility === "public" ? "a public repository's terminal runs tasks with Claude Code" : "a terminal runs tasks with Claude Code or Codex");
232+ return runtime as RuntimeId;
233+}
234+
235+/**
236+ * Start a task in the person's terminal (phase 4b): only from their own click on the terminal's page. The CLI signed
237+ * in there does the work, on their own subscription, as the terminal's agent; Diffinery pushes and opens the pull
238+ * request as for any task. Its checks don't start fix runs by themselves.
239+ */
240+export async function startTaskInTerminal(c: AppContext, l: Loaded, id: string | undefined, input: { goal?: unknown; runtime?: unknown }): Promise<TaskView> {
241+ const me = requirePersonSession(c);
242+ if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)");
243+ const goal = typeof input.goal === "string" ? input.goal.trim() : "";
244+ if (!goal) throw new HttpError(422, "write the goal");
245+ if (goal.length > MAX_GOAL) throw new HttpError(422, `the goal is longer than ${MAX_GOAL} characters`);
246+ const runtime = terminalRuntime(l, input.runtime);
247+ const { t, to } = await dispatchTerminal(c, l, me, id);
248+ const dir = c.get("dir");
249+ const agent = await dir.account(t.agent_id);
250+ if (!agent) throw new HttpError(403, "the terminal's agent no longer exists");
251+ try {
252+ await taskCaller(dir, me, agent, l.repo);
253+ } catch (e) {
254+ if (e instanceof TaskAccessError) throw new HttpError(403, e.message);
255+ throw e;
256+ }
257+ if (!(await l.stub.getRef(`refs/heads/${t.ref}`))) throw new HttpError(422, `the branch ${t.ref} doesn't exist any more`);
258+ const project = projectStub(c.env, l.repo.id);
259+ if ((await project.projectSettings()).planFirst === "required") throw new HttpError(422, "this project requires a plan first, which is written in a sandbox of its own: start the task from New task");
260+ const task = await project.create({ repoId: l.repo.id, goal, runtime, command: null, agent: { id: agent.id, login: agent.login }, person: { id: me.id, login: me.login }, base: t.ref, providers: [], planFirst: false, attempts: 1, fixChecks: false, land: false, terminal: to });
261+ audit(c, me, l.repo, "task.start", { agent: agent.login, runtime, terminal: t.id, fix_checks: false, land: false });
262+ audit(c, me, l.repo, "terminal.task", { terminal: t.id, task: task.number, runtime, version: RUNTIME_VERSIONS[runtime as "claude-code" | "codex"] });
263+ return task;
264+}
265+
266+/** Run an existing task again in the person's terminal (phase 4b): only from their own click on the task's page. */
267+export async function runTaskInTerminal(c: AppContext, l: Loaded, number: number, id: string | undefined, message: unknown): Promise<TaskView> {
268+ const { person, task } = await starter(c, l, number);
269+ if (task.kind !== "task") throw new HttpError(409, "only agent tasks run in a terminal");
270+ const runtime = terminalRuntime(l, task.runtime);
271+ const text = typeof message === "string" ? message.trim() : "";
272+ if (text.length > MAX_GOAL) throw new HttpError(422, `the message is longer than ${MAX_GOAL} characters`);
273+ const { t, to } = await dispatchTerminal(c, l, person, id, task.agent_id);
274+ const out = await projectStub(c.env, l.repo.id).runInTerminal(number, to, text || null);
275+ audit(c, person, l.repo, "terminal.task", { terminal: t.id, task: number, runtime, version: RUNTIME_VERSIONS[runtime as "claude-code" | "codex"] });
276+ return out;
277+}
278+
279+/** The person's open terminal a task could run in now, for its page's Run in my terminal button; null when none. */
280+export async function terminalForTask(env: Env, me: Account, repo: Repo, task: TaskView): Promise<string | null> {
281+ if (!terminalsEnabled(env) || task.kind !== "task" || task.person_id !== me.id || !terminalRuntimes(repo).includes(task.runtime)) return null;
282+ if ((task.attempts > 1 && task.picked === null) || task.plan_state === "planning" || task.plan_state === "waiting") return null;
283+ const terminals = new Terminals(env);
284+ const open = (await terminals.list(me.id, repo.id)).filter((t) => t.status === "running" && t.agent_id === task.agent_id);
285+ if (!open.length || !(await terminals.termsAt(me.id))) return null;
286+ const project = projectStub(env, repo.id);
287+ for (const t of open) if ((await project.terminalTask(t.id)) === null) return t.id;
288+ return null;
289+}
modifiedapps/diffinery/test/e2e/agents-harness.mjs+17−1View file
@@ -115,9 +115,25 @@
115115 await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
116116 const upstreamUrl = `http://127.0.0.1:${upstream.address().port}`;
117117
118+ // A stand-in for a Claude Code someone signed in to in their terminal (phase 4b): it reports what it was given and
119+ // where it ran, and changes a file, without any model.
120+ const fakeBin = join(work, "bin");
121+ mkdirSync(fakeBin, { recursive: true });
122+ writeFileSync(
123+ join(fakeBin, "claude"),
124+ [
125+ "#!/bin/sh",
126+ 'd=$(basename "$PWD")',
127+ 'echo "{\\"type\\":\\"assistant\\",\\"message\\":{\\"content\\":[{\\"type\\":\\"text\\",\\"text\\":\\"signed-in claude: key=${ANTHROPIC_API_KEY:-none} base=${ANTHROPIC_BASE_URL:-none} dir=$d\\"}]}}"',
128+ 'printf "%s\\n" "$DIFFINERY_TASK_PROMPT"',
129+ 'echo "made in the terminal" > terminal.txt',
130+ 'echo \'{"type":"result","subtype":"success","result":"Done in the terminal."}\'',
131+ ].join("\n"),
132+ { mode: 0o755 },
133+ );
118134 const poolPort = await freePort();
119135 const poolSecret = randomBytes(16).toString("hex");
120- pool = spawn("node", [AGENT, "--sandbox-pool", "--work-root", poolRoot], { env: { ...process.env, DIFFINERY_SANDBOX_POOL_SECRET: poolSecret, DIFFINERY_SANDBOX_PORT: String(poolPort) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
136+ pool = spawn("node", [AGENT, "--sandbox-pool", "--work-root", poolRoot], { env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, DIFFINERY_SANDBOX_POOL_SECRET: poolSecret, DIFFINERY_SANDBOX_PORT: String(poolPort) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
121137 pool.stdout.on("data", (d) => (poolLog += d));
122138 pool.stderr.on("data", (d) => (poolLog += d));
123139 await until("sandbox pool", async () => poolLog.includes("listening"), 30_000);
modifiedapps/diffinery/test/e2e/terminals.test.mjs+102−1View file
@@ -1,4 +1,4 @@
1-// End-to-end web terminals and kill switches on them, on the same setup as tasks.test.mjs
1+// End-to-end web terminals, kill switches on them, and tasks run in them (phase 4b), on the same setup as tasks.test.mjs
22 // (agents-harness.mjs). In a file of their own so each file stays well under the test runner's per-file limit.
33 //
44 // pnpm --filter @diffinery/app e2e
@@ -175,6 +175,10 @@
175175 // Suspending the repository's Actions ends the terminal, refuses attaching and opening another.
176176 const S = `/admin/repos/${owner}/term-demo/actions-suspension`;
177177 assert.equal((await api("PUT", S, { reason: "abuse report" })).status, 200);
178+ // Nor does it start a task in the terminal (phase 4b).
179+ const before = (await ok("GET", `/repos/${owner}/term-demo/diffinery/tasks`)).length;
180+ assert.equal((await form(`terminals/${id}/task`, { goal: "Do it while suspended", runtime: "claude-code" })).status, 303);
181+ assert.equal((await ok("GET", `/repos/${owner}/term-demo/diffinery/tasks`)).length, before, "a suspended repository's terminal starts no task");
178182 const refused = await termSocket(url, { origin });
179183 assert.ok([403, 409].includes(refused.status), JSON.stringify(refused));
180184 const again = await form("terminals", { agent: "term-bot-2", ref: "main" });
@@ -188,3 +192,100 @@
188192 assert.equal(after.status, 303, await after.text());
189193 assert.equal((await form(`${new URL(after.headers.get("location"), base).pathname.split("/").slice(3).join("/")}/end`, {})).status, 303);
190194 });
195+
196+test("a task in my terminal: only on my click, with the CLI signed in there, in a checkout of its own", async () => {
197+ const owner = (await ok("GET", "/user")).login;
198+ await ok("POST", "/user/repos", { name: "term-tasks" });
199+ await ok("PUT", `/repos/${owner}/term-tasks/contents/README.md`, { message: "init", content: Buffer.from("# term tasks\n").toString("base64") });
200+ assert.equal((await asPerson("POST", "/diffinery/agents", { login: "term-hand", name: "Term Hand" })).status, 201);
201+ const R = `/repos/${owner}/term-tasks/diffinery/tasks`;
202+ const page = (path, headers = {}) => fetch(`${base}/${owner}/term-tasks/${path}`, { headers }).then(async (r) => ({ status: r.status, text: await r.text() }));
203+ async function form(path, fields, headers = {}) {
204+ const res = await fetch(`${base}/${owner}/term-tasks/terminals`, { headers });
205+ const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
206+ const cookie = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
207+ return fetch(`${base}/${owner}/term-tasks/${path}`, { method: "POST", redirect: "manual", headers: { ...headers, cookie: [headers.cookie, `dfy_csrf=${cookie}`].filter(Boolean).join("; "), "sec-fetch-site": "same-origin" }, body: new URLSearchParams({ _csrf: csrf ?? "", ...fields }) });
208+ }
209+ // The terminal opens on a branch whose name would run a command wherever it reached a shell unquoted.
210+ const EVIL = "t$(touch${IFS}injected)";
211+ const mainSha = (await ok("GET", `/repos/${owner}/term-tasks/git/ref/heads/main`)).object.sha;
212+ await ok("POST", `/repos/${owner}/term-tasks/git/refs`, { ref: `refs/heads/${EVIL}`, sha: mainSha });
213+ const opened = await form("terminals", { agent: "term-hand", ref: EVIL, accept: "1" });
214+ assert.equal(opened.status, 303, await opened.text());
215+ const id = new URL(opened.headers.get("location"), base).pathname.split("/").pop();
216+ const running = await until("the terminal to start", async () => {
217+ const p = await page(`terminals/${id}`);
218+ return p.text.includes("data-socket") ? p : null;
219+ });
220+ assert.match(running.text, /Give it a task/);
221+ assert.match(running.text, /Run with my subscription/);
222+ assert.match(running.text, /on your own subscription/);
223+
224+ // Someone else, even an admin of the repository, can't send a task to it; nor can a token (there is no API for it).
225+ const invite = await ok("PUT", `/repos/${owner}/term-tasks/collaborators/rival`, { permission: "admin" });
226+ if (invite?.id) await fetch(`${base}/api/v3/user/repository_invitations/${invite.id}`, { method: "PATCH", headers: { cookie: `__Host-dfy_session=${RIVAL_SESSION}`, "sec-fetch-site": "same-origin" } });
227+ const rival = { cookie: `__Host-dfy_session=${RIVAL_SESSION}` };
228+ assert.equal((await form(`terminals/${id}/task`, { goal: "Do it", runtime: "claude-code" }, rival)).status, 303);
229+ assert.deepEqual(await ok("GET", R), [], "someone else's click starts nothing");
230+ const viaToken = await api("POST", R, { goal: "Do it", runtime: "claude-code", agent: "term-hand", terminal: id });
231+ assert.equal(viaToken.status, 403, "a token starts no task, in a terminal or not");
232+ // Only Claude Code or Codex, as signed in there.
233+ const custom = await form(`terminals/${id}/task`, { goal: "Do it", runtime: "custom" });
234+ assert.equal(custom.status, 303);
235+ assert.deepEqual(await ok("GET", R), [], "a custom command doesn't run in a terminal");
236+
237+ // My click: the task runs in my terminal with the CLI there, with no model key or proxy, in its own checkout.
238+ const started = await form(`terminals/${id}/task`, { goal: "Add a terminal file", runtime: "claude-code" });
239+ assert.equal(started.status, 303);
240+ const n = Number(new URL(started.headers.get("location"), base).pathname.split("/").pop());
241+ assert.ok(n > 0, started.headers.get("location"));
242+ const done = await until("the terminal task to finish", async () => {
243+ const t = await ok("GET", `${R}/${n}`);
244+ return t.state !== "running" && t.state !== "queued" ? t : null;
245+ });
246+ const text = (await ok("GET", `${R}/${n}/transcript`)).map((l) => `[${l.stream}] ${l.text}`).join("\n");
247+ assert.equal(done.state, "ready", text);
248+ assert.ok(done.pull_request, text);
249+ assert.match(text, /Running in .*'s terminal/);
250+ assert.match(text, /signed-in claude: key=none/, "no model key or token reaches it");
251+ assert.ok(!/base=[^ ]*\/-\/model\//.test(text), "nor the model proxy");
252+ assert.match(text, new RegExp(`dir=task-${n}-1`), "in a checkout of its own");
253+ assert.match(text, /\[out\] Add a terminal file/, "the goal reaches it");
254+ const file = await ok("GET", `/repos/${owner}/term-tasks/contents/terminal.txt?ref=${encodeURIComponent(done.branch)}`);
255+ assert.equal(Buffer.from(file.content, "base64").toString(), "made in the terminal\n");
256+ assert.equal(done.runs[0].in_terminal, true);
257+ assert.equal(done.base, EVIL, "checked out from the terminal's branch, its name passed as data");
258+
259+ // The terminal's own checkout is untouched, and the terminal is still mine to use.
260+ const t = await termSocket(`${base.replace("http", "ws")}/${owner}/term-tasks/terminals/${id}/socket?cols=100&rows=30`, { origin: new URL(base).origin });
261+ assert.equal(t.status, 101);
262+ t.type("ls terminal.txt 2>&1 | head -1; ls injected ../injected 2>&1 | grep -c 'No such file'; git status --short | wc -l; ls .. | grep -c task- ; echo check-$((6*7))\n");
263+ await t.until(/check-42/);
264+ assert.match(t.out(), /No such file/);
265+ assert.match(t.out(), /\n2\r?\n0\r?\n0\r?\n/, "the branch name ran nothing");
266+ assert.match(t.out(), /\n0\r?\n0\r?\n/, "a clean checkout, and the task's checkout removed");
267+
268+ // Its page offers to run it again there; doing so is another run in the terminal.
269+ const taskPage = await page(`tasks/${n}`);
270+ assert.match(taskPage.text, /Run in my terminal/);
271+ assert.match(taskPage.text, /in [a-z0-9-]+(&#39;|')s terminal/);
272+ assert.doesNotMatch((await page(`tasks/${n}`, rival)).text, /Run in my terminal/, "not for anyone else");
273+ assert.equal((await form(`tasks/${n}/terminal`, { terminal: id }, rival)).status, 303);
274+ assert.equal((await ok("GET", `${R}/${n}`)).runs.length, 1, "someone else's click starts nothing");
275+ const again = await form(`tasks/${n}/terminal`, { terminal: id, body: "Say it again" });
276+ assert.equal(again.status, 303);
277+ const twice = await until("the second terminal run", async () => {
278+ const x = await ok("GET", `${R}/${n}`);
279+ return x.runs.length === 2 && x.state === "ready" ? x : null;
280+ });
281+ assert.equal(twice.runs[1].state, "done");
282+ assert.equal(twice.runs[1].in_terminal, true);
283+ assert.equal(twice.runs[1].kind, "message");
284+
285+ // Ending the terminal ends what it can run: the page no longer offers it, and a click is refused.
286+ assert.equal((await form(`terminals/${id}/end`, {})).status, 303);
287+ assert.doesNotMatch((await page(`tasks/${n}`)).text, /Run in my terminal/);
288+ assert.equal((await form(`tasks/${n}/terminal`, { terminal: id })).status, 303);
289+ assert.equal((await ok("GET", `${R}/${n}`)).runs.length, 2);
290+ t.close?.();
291+});