Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 30 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@

DevIntern is a self-hosted worker that picks up ready tickets from Jira, Linear, GitHub Issues, or your other trackers, runs Claude Code, Codex, Cursor, or OpenCode, and opens pull requests. When reviewers request changes, it follows up on the same branch. Your team controls what gets picked up and what gets merged.

![Ready tracker tickets flow through the local worker, coding agent, and quality gates into pull requests. Review feedback returns as commits; DevIntern PM provides optional ticket intake.](assets/readme/ticket-pipeline.png)
![The local DevIntern dashboard showing one run: ticket PROJ-1842 passed the feasibility check, implementation, verification, and self-review, then opened pull request #4419.](assets/readme/dashboard-run-detail.png)

<p align="center"><em>One ticket, start to finish, in the worker's local dashboard. Example data.</em></p>

Interactive runs are free with no signup. Unattended automation starts with a 14-day Worker Pilot (sign-in required, no credit card or task-count limit), then requires a paid license. Your coding-agent usage costs are separate.

Expand Down Expand Up @@ -89,11 +91,29 @@ Start with a narrow queue of well-specified tasks. Watch the runs in the local d

Prefer guided setup through your coding agent? Use the [DevIntern setup skill](docs/code/agent-skills.md). Want to inspect one run first? [Run one ticket first](#run-one-ticket-first).

## See it working

![A tour of the local dashboard: the runs list, one run's timeline from feasibility check to opened pull request, the pull requests awaiting review, scheduled automations, and work routing across repositories.](assets/readme/dashboard-tour.gif)

<p align="center"><em>The worker's local dashboard: runs, one run step by step, pull requests awaiting review, automations, and work routing. Example data.</em></p>

Browse the [DevIntern-authored pull requests](https://github.com/getdevintern/devintern/pulls?q=is%3Apr+label%3Adevintern-authored) to inspect changes produced by DevIntern on this repository. Start with these merged examples:

- [Worker daemon smoke coverage and PR label checks (#306)](https://github.com/getdevintern/devintern/pull/306)
- [Sandbox-aware coding-agent readiness (#300)](https://github.com/getdevintern/devintern/pull/300)
- [Per-agent state directories in the sandbox (#296)](https://github.com/getdevintern/devintern/pull/296)

Open each PR to inspect its diff, checks, and discussion. The [full pull request list](https://github.com/getdevintern/devintern/pulls?q=is%3Apr+label%3Adevintern-authored) includes work still in review.

If DevIntern is useful to you, [star the repo](https://github.com/getdevintern/devintern) or share a workflow you have automated.

## Drive development across projects

One workspace brings your teams’ trackers, repositories, scheduled maintenance, and Sentry fixes together under a single worker. Start with one repository, then add projects as you grow.

![Jira, Linear, and GitHub Issues feed one local workspace serving API, infrastructure, web, and SDK repositories, with scheduled log triage, dependency updates, CI repair, and Sentry fixes.](assets/readme/multi-project-workspace.png)
![The dashboard's Workspace view: Jira, Linear, and GitHub Issues sources, scheduled automations, and a Sentry monitor routed to four repositories from one workspace.toml.](assets/readme/dashboard-workspace.png)

<p align="center"><em>Work routing for an example workspace: three teams' trackers, scheduled automations, and a Sentry monitor feeding four repositories.</em></p>

Choose each team’s ready queue and route work to the right repositories. Each repository keeps its agent and pipeline settings. Scheduled prompts handle recurring chores, and Sentry monitoring feeds qualifying errors into the same ticket-to-PR pipeline. Your team reviews the changes and decides what merges.

Expand All @@ -110,6 +130,8 @@ Choose each team’s ready queue and route work to the right repositories. Each

## What the worker does

![Ready tracker tickets flow through the local worker, coding agent, and quality gates into pull requests. Review feedback returns as commits; DevIntern PM provides optional ticket intake.](assets/readme/ticket-pipeline.png)

`devintern worker` is a long-running daemon that polls your tracker, implements ready tickets, opens pull requests, and keeps its own PRs healthy while your team writes specs, reviews code, or sleeps. Agent execution runs on your machine. Your code and credentials never pass through DevIntern servers; your configured agent, tracker, and source-control providers handle their own requests.

Once it's running, the worker:
Expand Down Expand Up @@ -145,22 +167,15 @@ For teams this changes what "keeping the repo healthy" costs: maintenance ticket

Sign in to evaluate unattended automation with a no-card Worker Pilot: 14 days with no task-count limit. Continued use requires the paid automation tier (one-time Supporter license or Team/Business subscription); interactive use stays free forever. [Pricing](https://devintern.com/pricing/) · [Worker docs](docs/code/worker.md)

## See it working
## Watch it from the dashboard

<p align="center">
<video src="https://github.com/user-attachments/assets/f62b17c0-4e5b-4a2f-ac3a-761c44af3680" width="100%" controls muted autoplay loop playsinline></video>
</p>
<p align="center"><em>Ready ticket → pull request → review feedback → follow-up commits.</em></p>
The worker serves a local dashboard at `http://localhost:4400`. It runs on the worker machine and binds to loopback by default, so run history, transcripts, and logs stay next to your code.

Browse the [DevIntern-authored pull requests](https://github.com/getdevintern/devintern/pulls?q=is%3Apr+label%3Adevintern-authored) to inspect changes produced by DevIntern on this repository. Start with these merged examples:
| Runs | Pull requests awaiting review |
| ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| ![The Runs view listing tracker tasks and scheduled automations with status, pull request, harness, and duration.](assets/readme/dashboard-runs.png) | ![The Pull requests view listing open agent pull requests with their ticket, branch, and waiting time.](assets/readme/dashboard-pull-requests.png) |

- [Worker daemon smoke coverage and PR label checks (#306)](https://github.com/getdevintern/devintern/pull/306)
- [Sandbox-aware coding-agent readiness (#300)](https://github.com/getdevintern/devintern/pull/300)
- [Per-agent state directories in the sandbox (#296)](https://github.com/getdevintern/devintern/pull/296)

Open each PR to inspect its diff, checks, and discussion. The [full pull request list](https://github.com/getdevintern/devintern/pulls?q=is%3Apr+label%3Adevintern-authored) includes work still in review.

If DevIntern is useful to you, [star the repo](https://github.com/getdevintern/devintern) or share a workflow you have automated.
Every run is recorded stage by stage, including tickets handed back to the tracker with questions. Screenshots show example data. [Dashboard guide](docs/code/dashboard.md)

## Run one ticket first

Expand Down
Binary file added assets/readme/dashboard-pull-requests.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/readme/dashboard-run-detail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/readme/dashboard-runs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/readme/dashboard-tour.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/readme/dashboard-workspace.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 59 additions & 0 deletions packages/dashboard-ui/scripts/demo/capture.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Regenerate the marketing dashboard screenshots from a seeded demo workspace.
#
# bash scripts/demo/capture.sh <output-dir> [port]
# RECORD=1 bash scripts/demo/capture.sh <output-dir> [port] # also writes tour.gif (needs ffmpeg)
#
# Runs the seed and the real dashboard inside a bubblewrap mount namespace so
# the workspace appears at /srv/devintern (the path the Workspace view prints),
# against a throwaway data directory. Nothing under ~/.devintern is read or
# written, and no other dashboard instance is touched.
set -euo pipefail

OUT="$(realpath -m "${1:?output dir required}")"
PORT="${2:-4410}"
HERE="$(cd "$(dirname "$0")/../.." && pwd)" # packages/dashboard-ui
DATA="$(mktemp -d)"
PROFILE="$(mktemp -d)"
trap 'kill "${SERVER_PID:-0}" 2>/dev/null || true; rm -rf "$DATA" "$DATA.log" "$PROFILE"' EXIT

SANDBOX=(bwrap --die-with-parent --dev-bind / / --tmpfs /srv --bind "$DATA" /srv/devintern
--setenv DEVINTERN_WORKSPACE_DIR /srv/devintern --setenv HOME /home/worker)

(cd "$HERE" && bun run build >/dev/null)
(cd "$HERE" && "${SANDBOX[@]}" bun run scripts/demo/seed.ts /srv/devintern)
(cd "$HERE" && exec "${SANDBOX[@]}" bun run scripts/demo/serve.ts /srv/devintern "$PORT") >"$DATA.log" 2>&1 &
SERVER_PID=$!
for _ in $(seq 40); do curl -sf "http://127.0.0.1:$PORT/api/health" >/dev/null && break; sleep 0.25; done

RUN_ID="$(curl -s "http://127.0.0.1:$PORT/api/runs?limit=200" |
bun -e 'const r=await Bun.stdin.json();console.log(r.runs.find((x:any)=>x.taskKey==="PROJ-1842").id)')"

mkdir -p "$OUT"
shoot() { # <file> <hash route>
/usr/bin/chromium --headless=new --hide-scrollbars --force-device-scale-factor=2 \
--window-size=1440,900 --virtual-time-budget=8000 --user-data-dir="$PROFILE" \
--screenshot="$OUT/$1" "http://127.0.0.1:$PORT/#$2" >/dev/null 2>&1
}
shoot run-detail.png "/runs/$RUN_ID"
shoot runs.png "/runs"
shoot pull-requests.png "/prs"
shoot automations.png "/automations"
shoot workspace.png "/workspace"
echo "Screenshots written to $OUT"

if [ -n "${RECORD:-}" ]; then
CDP_PORT=$((PORT + 1))
FRAMES="$(mktemp -d)"
/usr/bin/chromium --headless=new --hide-scrollbars --window-size=1280,800 \
--remote-debugging-port="$CDP_PORT" --user-data-dir="$PROFILE" about:blank >/dev/null 2>&1 &
CHROME_PID=$!
for _ in $(seq 40); do curl -sf "http://127.0.0.1:$CDP_PORT/json/version" >/dev/null && break; sleep 0.25; done
(cd "$HERE" && bun run scripts/demo/record.ts "http://127.0.0.1:$PORT" "$RUN_ID" "$FRAMES" "$CDP_PORT")
kill "$CHROME_PID" 2>/dev/null || true
ffmpeg -y -loglevel error -framerate 10 -i "$FRAMES/frame-%04d.png" \
-vf "scale=1000:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=128:stats_mode=diff[p];[b][p]paletteuse=dither=none:diff_mode=rectangle" \
"$OUT/tour.gif"
rm -rf "$FRAMES"
echo "Tour written to $OUT/tour.gif"
fi
199 changes: 199 additions & 0 deletions packages/dashboard-ui/scripts/demo/record.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/**
* Record a short tour of the seeded demo dashboard as numbered PNG frames.
*
* bun run scripts/demo/record.ts <base-url> <run-id> <frames-dir> <cdp-port>
*
* Drives an already running headless Chromium over the DevTools protocol (no
* browser-automation dependency): it moves a drawn cursor, clicks through the
* views, and captures one frame per step at a fixed 10 fps. `capture.sh`
* starts Chromium and turns the frames into a GIF.
*/
import { mkdirSync } from "node:fs";
import { join } from "node:path";

const [baseUrl, runId, framesDir, cdpPort] = process.argv.slice(2);
if (!baseUrl || !runId || !framesDir || !cdpPort) {
console.error("usage: record.ts <base-url> <run-id> <frames-dir> <cdp-port>");
process.exit(1);
}

const WIDTH = 1280;
const HEIGHT = 800;
const FRAME_MS = 100;

interface Target {
type: string;
webSocketDebuggerUrl: string;
}
interface Rect {
x: number;
y: number;
width: number;
height: number;
}

const targets = (await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json()) as Target[];
const page = targets.find((target) => target.type === "page");
if (!page) throw new Error("No page target found on the DevTools port");

const socket = new WebSocket(page.webSocketDebuggerUrl);
await new Promise<void>((resolve, reject) => {
socket.addEventListener("open", () => resolve());
socket.addEventListener("error", () => reject(new Error("DevTools socket failed to open")));
});

let nextId = 0;
const pending = new Map<number, (result: Record<string, unknown>) => void>();
socket.addEventListener("message", (event) => {
const message = JSON.parse(String(event.data)) as {
id?: number;
result?: Record<string, unknown>;
error?: { message: string };
};
if (message.id === undefined) return;
if (message.error) throw new Error(`DevTools: ${message.error.message}`);
pending.get(message.id)?.(message.result ?? {});
pending.delete(message.id);
});

function send(method: string, params: Record<string, unknown> = {}) {
const id = ++nextId;
return new Promise<Record<string, unknown>>((resolve) => {
pending.set(id, resolve);
socket.send(JSON.stringify({ id, method, params }));
});
}

async function evaluate<T>(expression: string): Promise<T> {
const result = await send("Runtime.evaluate", { expression, returnByValue: true });
return (result.result as { value: T }).value;
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

mkdirSync(framesDir, { recursive: true });
let frameCount = 0;
async function frame() {
const started = Date.now();
const shot = await send("Page.captureScreenshot", { format: "png" });
const name = `frame-${String(frameCount++).padStart(4, "0")}.png`;
await Bun.write(join(framesDir, name), Buffer.from(String(shot.data), "base64"));
// Keep real time roughly in step with the recording so UI transitions and
// the dashboard's own polling look natural.
await sleep(Math.max(0, FRAME_MS - (Date.now() - started)));
}

async function hold(ms: number) {
for (let elapsed = 0; elapsed < ms; elapsed += FRAME_MS) await frame();
}

const cursor = { x: WIDTH / 2, y: HEIGHT / 2 };
async function moveTo(x: number, y: number, ms = 700) {
const steps = Math.max(1, Math.round(ms / FRAME_MS));
const from = { ...cursor };
for (let step = 1; step <= steps; step++) {
const t = step / steps;
const eased = t * t * (3 - 2 * t);
cursor.x = from.x + (x - from.x) * eased;
cursor.y = from.y + (y - from.y) * eased;
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: cursor.x, y: cursor.y });
await frame();
}
}

async function click() {
const at = { x: cursor.x, y: cursor.y, button: "left", clickCount: 1 };
await send("Input.dispatchMouseEvent", { type: "mousePressed", ...at });
await send("Input.dispatchMouseEvent", { type: "mouseReleased", ...at });
}

/** Bounding box of the smallest element matching `selector` whose text includes `text`. */
async function rectOf(selector: string, text: string): Promise<Rect> {
const rect = await evaluate<Rect | null>(`(() => {
const boxes = [...document.querySelectorAll(${JSON.stringify(selector)})]
.filter((node) => node.textContent.includes(${JSON.stringify(text)}))
.map((node) => node.getBoundingClientRect())
.filter((r) => r.width > 0 && r.height > 0)
.sort((a, b) => a.width * a.height - b.width * b.height);
const r = boxes[0];
if (!r) return null;
return { x: r.x, y: r.y, width: r.width, height: r.height };
})()`);
if (!rect) throw new Error(`Element not found: ${selector} containing "${text}"`);
return rect;
}

async function moveToElement(selector: string, text: string, ms?: number) {
const rect = await rectOf(selector, text);
await moveTo(rect.x + Math.min(rect.width / 2, 60), rect.y + rect.height / 2, ms);
}

// A drawn pointer: headless screenshots do not include the system cursor.
const cursorScript = `(() => {
const install = () => {
const el = document.createElement("div");
el.style.cssText = "position:fixed;z-index:2147483647;left:0;top:0;width:22px;height:22px;pointer-events:none;transform:translate(${cursor.x}px,${cursor.y}px)";
el.innerHTML = '<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 2l15 9-7 1.5L8.5 20z" fill="#fff" stroke="#0b1326" stroke-width="1.5" stroke-linejoin="round"/></svg>';
document.documentElement.appendChild(el);
addEventListener("mousemove", (e) => {
el.style.transform = "translate(" + e.clientX + "px," + e.clientY + "px)";
}, true);
};
if (document.documentElement) install();
else addEventListener("DOMContentLoaded", install);
})()`;

await send("Page.enable");
await send("Runtime.enable");
await send("Emulation.setDeviceMetricsOverride", {
width: WIDTH,
height: HEIGHT,
deviceScaleFactor: 1,
mobile: false,
});
await send("Page.addScriptToEvaluateOnNewDocument", { source: cursorScript });

// 1. Runs: the queue of work that happened without anyone watching.
await send("Page.navigate", { url: `${baseUrl}/#/runs` });
await sleep(2500);
await hold(2200);

// 2. Open the featured run and expand its timeline.
await moveToElement("tr, [role='row'], a, button", "PROJ-1842");
await hold(300);
await click();
await sleep(600);
if (!(await evaluate<string>("location.hash")).includes(`/runs/${runId}`)) {
await evaluate(`location.hash = "#/runs/${runId}"`);
await sleep(600);
}
await hold(2500);
await moveToElement("button", "Expand all");
await hold(300);
await click();
await hold(3500);

// 3. Pull requests waiting on a human.
await moveToElement("nav a, aside a", "Pull requests");
await hold(200);
await click();
await hold(2800);

// 4. Scheduled automations.
await moveToElement("nav a, aside a", "Automations");
await hold(200);
await click();
await hold(2500);

// 5. Workspace: hover a tracker source and a repository to trace routes.
await moveToElement("nav a, aside a", "Workspace");
await hold(200);
await click();
await hold(1800);
await moveToElement("span, strong, div, p", "engineering", 900);
await hold(2200);
await moveToElement("span, strong, div, p", "acme/web", 900);
await hold(2500);

socket.close();
console.log(`Recorded ${frameCount} frames to ${framesDir}`);
Loading
Loading