diff --git a/pilot/.env.example b/pilot/.env.example
new file mode 100644
index 00000000..98ad5e05
--- /dev/null
+++ b/pilot/.env.example
@@ -0,0 +1,11 @@
+# OAuth providers
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
+
+# Session encryption
+SESSION_SECRET=
+
+# Environment
+ENVIRONMENT=development
diff --git a/pilot/.gitignore b/pilot/.gitignore
new file mode 100644
index 00000000..eb1c387d
--- /dev/null
+++ b/pilot/.gitignore
@@ -0,0 +1,9 @@
+node_modules
+.svelte-kit
+build
+.wrangler
+.env
+.env.*
+!.env.example
+.dev.vars
+package-lock.json
diff --git a/pilot/Dockerfile.sandbox b/pilot/Dockerfile.sandbox
new file mode 100644
index 00000000..5044c0b5
--- /dev/null
+++ b/pilot/Dockerfile.sandbox
@@ -0,0 +1,14 @@
+FROM docker.io/cloudflare/sandbox:0.7.4
+
+# Install Claude Code and serve globally
+RUN npm install -g @anthropic-ai/claude-code serve
+
+# Set up git config
+RUN git config --global user.name "TaskYou Agent" && \
+ git config --global user.email "agent@taskyou.dev"
+
+# Initialize workspace
+RUN mkdir -p /root/worktrees /workspace
+
+# Expose port for web app previews
+EXPOSE 8080
diff --git a/pilot/package.json b/pilot/package.json
new file mode 100644
index 00000000..ce401176
--- /dev/null
+++ b/pilot/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "taskyou-pilot",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build && node scripts/postbuild.js",
+ "preview": "wrangler dev",
+ "deploy": "npm run build && wrangler deploy",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "test": "vitest"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4.20250129.0",
+ "@sveltejs/adapter-cloudflare": "^7.2.7",
+ "@sveltejs/kit": "^2.0.0",
+ "@sveltejs/vite-plugin-svelte": "^4.0.0",
+ "@tailwindcss/vite": "^4.0.0",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "tailwindcss": "^4.0.0",
+ "typescript": "^5.0.0",
+ "vite": "^5.0.0",
+ "vitest": "^2.0.0",
+ "wrangler": "^4.65.0"
+ },
+ "dependencies": {
+ "@ai-sdk/anthropic": "^3.0.44",
+ "@cloudflare/ai-chat": "^0.1.1",
+ "@cloudflare/sandbox": "^0.7.4",
+ "agents": "^0.5.0",
+ "ai": "^6.0.87",
+ "basecoat-css": "^0.3.11",
+ "lucide-svelte": "^0.460.0",
+ "marked": "^17.0.2",
+ "zod": "^4.3.6"
+ }
+}
diff --git a/pilot/sandbox-init.sh b/pilot/sandbox-init.sh
new file mode 100644
index 00000000..a3b2d954
--- /dev/null
+++ b/pilot/sandbox-init.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+# Initialization script for taskyou sandbox containers
+# This runs inside each user's isolated container
+
+echo "TaskYou sandbox initialized"
+echo "Container ready for task execution"
+
+# Keep the container running
+exec sleep infinity
diff --git a/pilot/scripts/postbuild.js b/pilot/scripts/postbuild.js
new file mode 100644
index 00000000..c90ee941
--- /dev/null
+++ b/pilot/scripts/postbuild.js
@@ -0,0 +1,59 @@
+// Post-build: Wrap SvelteKit worker with custom entry point
+// - Adds routeAgentRequest() for agent WebSocket/HTTP handling
+// - Adds proxyToSandbox() for sandbox preview URL routing
+// - Re-exports TaskYouAgent, TaskExecutionWorkflow, and Sandbox for wrangler bindings
+// - SvelteKit handles everything else (auth, CRUD, static assets)
+
+import { readFileSync, writeFileSync, existsSync } from 'fs';
+
+const workerFile = 'worker-entry.js';
+
+if (!existsSync(workerFile)) {
+ console.error(`✗ ${workerFile} not found - did vite build run?`);
+ process.exit(1);
+}
+
+let content = readFileSync(workerFile, 'utf8');
+
+// Check if already patched
+if (content.includes('routeAgentRequest')) {
+ console.log('✓ worker-entry.js already patched');
+ process.exit(0);
+}
+
+// Add agent + sandbox imports at the top of the file
+const imports = `
+import { routeAgentRequest } from "agents";
+import { proxyToSandbox } from "@cloudflare/sandbox";
+`;
+
+content = imports + content;
+
+// Wrap the existing fetch handler with sandbox proxy + agent routing
+content = content.replace(
+ 'async fetch(req, env2, ctx) {',
+ `async fetch(req, env2, ctx) {
+ // Route agent WebSocket/HTTP requests before SvelteKit
+ const agentResponse = await routeAgentRequest(req, env2);
+ if (agentResponse) return agentResponse;
+
+ // Proxy sandbox preview URLs (after agent routing)
+ // Only relevant with custom domains for preview URL subdomains
+ try {
+ const sandboxResponse = await proxyToSandbox(req, env2);
+ if (sandboxResponse && sandboxResponse.status !== 404) return sandboxResponse;
+ } catch (e) {
+ // Sandbox proxy not available — continue to SvelteKit
+ }`
+);
+
+// Add DO, Workflow, and Sandbox class re-exports at the end
+content += `
+// Re-export agent classes for Durable Object, Workflow, and Container bindings
+export { TaskYouAgent } from "./src/lib/server/agent.ts";
+export { TaskExecutionWorkflow } from "./src/lib/server/workflow.ts";
+export { Sandbox } from "@cloudflare/sandbox";
+`;
+
+writeFileSync(workerFile, content);
+console.log('✓ Patched worker-entry.js with proxyToSandbox + routeAgentRequest + exports');
diff --git a/pilot/src/app.css b/pilot/src/app.css
new file mode 100644
index 00000000..81a24042
--- /dev/null
+++ b/pilot/src/app.css
@@ -0,0 +1,364 @@
+@import "tailwindcss";
+@import "basecoat-css";
+
+@custom-variant dark (&:where(.dark, .dark *));
+
+@theme {
+ --font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, sans-serif;
+}
+
+@layer base {
+ :root {
+ --sidebar-width: 15.5rem;
+ --sidebar-collapsed-width: 3.5rem;
+
+ /* Warm-tinted light palette */
+ --background: oklch(0.985 0.005 75);
+ --foreground: oklch(0.16 0.015 55);
+ --muted: oklch(0.945 0.008 75);
+ --muted-foreground: oklch(0.50 0.015 55);
+ --accent: oklch(0.94 0.01 75);
+ --accent-foreground: oklch(0.16 0.015 55);
+ --border: oklch(0.90 0.012 75);
+ --input: oklch(0.88 0.012 75);
+ --ring: oklch(0.60 0.16 55);
+
+ /* Warm amber primary */
+ --primary: oklch(0.55 0.16 55);
+ --primary-foreground: oklch(0.99 0.005 75);
+
+ --card: oklch(0.993 0.003 75);
+ --card-foreground: oklch(0.16 0.015 55);
+
+ --secondary: oklch(0.93 0.01 75);
+ --secondary-foreground: oklch(0.22 0.015 55);
+
+ --destructive: oklch(0.55 0.2 25);
+ --destructive-foreground: oklch(0.99 0.005 75);
+
+ --popover: oklch(0.993 0.003 75);
+ --popover-foreground: oklch(0.16 0.015 55);
+
+ /* Sidebar: slightly cooler to contrast the warm content */
+ --sidebar-background: oklch(0.975 0.004 260);
+ --sidebar-foreground: oklch(0.22 0.01 260);
+ --sidebar-accent: oklch(0.935 0.008 260);
+ --sidebar-accent-foreground: oklch(0.16 0.01 260);
+ --sidebar-border: oklch(0.91 0.008 260);
+ --sidebar-primary: oklch(0.55 0.16 55);
+ --sidebar-primary-foreground: oklch(0.99 0.005 75);
+ }
+
+ .dark {
+ --background: oklch(0.13 0.01 260);
+ --foreground: oklch(0.92 0.008 75);
+ --muted: oklch(0.20 0.01 260);
+ --muted-foreground: oklch(0.58 0.015 75);
+ --accent: oklch(0.22 0.012 260);
+ --accent-foreground: oklch(0.92 0.008 75);
+ --border: oklch(0.24 0.012 260);
+ --input: oklch(0.26 0.012 260);
+ --ring: oklch(0.65 0.16 55);
+
+ --primary: oklch(0.68 0.16 55);
+ --primary-foreground: oklch(0.13 0.01 55);
+
+ --card: oklch(0.155 0.01 260);
+ --card-foreground: oklch(0.92 0.008 75);
+
+ --secondary: oklch(0.22 0.012 260);
+ --secondary-foreground: oklch(0.88 0.008 75);
+
+ --destructive: oklch(0.60 0.2 25);
+ --destructive-foreground: oklch(0.99 0.005 75);
+
+ --popover: oklch(0.17 0.01 260);
+ --popover-foreground: oklch(0.92 0.008 75);
+
+ --sidebar-background: oklch(0.12 0.008 260);
+ --sidebar-foreground: oklch(0.88 0.008 260);
+ --sidebar-accent: oklch(0.20 0.012 260);
+ --sidebar-accent-foreground: oklch(0.92 0.008 260);
+ --sidebar-border: oklch(0.22 0.008 260);
+ --sidebar-primary: oklch(0.68 0.16 55);
+ --sidebar-primary-foreground: oklch(0.13 0.01 55);
+ }
+
+ body {
+ @apply antialiased;
+ font-feature-settings: "cv02", "cv03", "cv04", "cv11";
+ letter-spacing: -0.011em;
+ }
+
+ /* Tighter headings */
+ h1, h2, h3, h4 {
+ letter-spacing: -0.025em;
+ font-weight: 600;
+ }
+}
+
+/* Status colors — desaturated, warm-shifted */
+:root {
+ --status-backlog: oklch(0.58 0.03 260);
+ --status-backlog-bg: oklch(0.96 0.006 260);
+ --status-queued: oklch(0.62 0.14 80);
+ --status-queued-bg: oklch(0.96 0.03 80);
+ --status-processing: oklch(0.55 0.16 55);
+ --status-processing-bg: oklch(0.96 0.03 55);
+ --status-blocked: oklch(0.58 0.16 30);
+ --status-blocked-bg: oklch(0.96 0.03 30);
+ --status-done: oklch(0.52 0.14 155);
+ --status-done-bg: oklch(0.96 0.03 155);
+ --status-failed: oklch(0.52 0.18 25);
+ --status-failed-bg: oklch(0.96 0.03 25);
+}
+
+.dark {
+ --status-backlog: oklch(0.52 0.03 260);
+ --status-backlog-bg: oklch(0.19 0.008 260);
+ --status-queued: oklch(0.65 0.14 80);
+ --status-queued-bg: oklch(0.22 0.03 80);
+ --status-processing: oklch(0.65 0.16 55);
+ --status-processing-bg: oklch(0.20 0.03 55);
+ --status-blocked: oklch(0.60 0.16 30);
+ --status-blocked-bg: oklch(0.20 0.03 30);
+ --status-done: oklch(0.58 0.14 155);
+ --status-done-bg: oklch(0.20 0.03 155);
+ --status-failed: oklch(0.58 0.18 25);
+ --status-failed-bg: oklch(0.20 0.03 25);
+}
+
+/* ---- Sidebar ---- */
+
+.sidebar nav > section ul li > a,
+.sidebar nav > section ul li > button {
+ @apply flex items-center gap-2 min-w-0;
+}
+
+.sidebar nav > section ul li > a > span:not(.flex-shrink-0):not(.sidebar-item-initial),
+.sidebar nav > section ul li > button > span:not(.flex-shrink-0):not(.sidebar-item-initial) {
+ @apply truncate min-w-0;
+}
+
+/* Collapsed icon-rail */
+.sidebar[data-collapsed] {
+ --sidebar-width: var(--sidebar-collapsed-width);
+
+ nav {
+ overflow: hidden;
+ }
+
+ & + * {
+ --sidebar-width: var(--sidebar-collapsed-width);
+ }
+
+ [data-sidebar-label] {
+ @apply hidden;
+ }
+
+ [data-sidebar-collapsed-only] {
+ @apply block!;
+ }
+
+ nav > hr[role="separator"],
+ nav > section > h3,
+ nav > section > [role="group"] > h3,
+ nav > section [role="group"] h3 {
+ @apply hidden;
+ }
+
+ nav > section ul li > a > button,
+ nav > section ul li .group > button {
+ @apply hidden;
+ }
+
+ nav > footer > button,
+ nav > footer > div {
+ @apply justify-center px-0;
+ }
+
+ nav > header > div {
+ @apply flex-col items-center gap-1.5 px-0;
+ }
+
+ nav > header > div > button {
+ @apply ml-0;
+ }
+
+ nav > section ul li > a,
+ nav > section ul li > button {
+ @apply justify-center px-0;
+ }
+
+ nav > section ul li > a > span:not(.sidebar-item-initial),
+ nav > section ul li > button > span:not(.sidebar-item-initial) {
+ @apply hidden;
+ }
+}
+
+/* ---- Kanban ---- */
+
+/* Column headers: clean top edge with status color */
+.kanban-column {
+ @apply flex flex-col overflow-hidden;
+ border-radius: 0;
+ background: transparent;
+ border: none;
+}
+
+.kanban-column-header {
+ @apply flex items-center justify-between px-1 pb-2;
+ border-bottom: 1px solid var(--border);
+}
+
+.kanban-column-header[data-has-tasks] {
+ border-bottom-color: color-mix(in oklch, var(--col-color, var(--border)) 50%, var(--border));
+}
+
+/* Focused column: highlight header when navigated to */
+.kanban-column.focused-column > .kanban-column-header {
+ border-bottom-color: var(--col-color, var(--primary));
+}
+
+/* Focused card: visible bg shift */
+.focused-card {
+ background: var(--accent);
+ outline: 1.5px solid color-mix(in oklch, var(--foreground) 12%, transparent);
+ outline-offset: -1.5px;
+}
+
+/* Multi-selected card */
+.selected-card {
+ background: color-mix(in oklch, var(--primary) 8%, transparent);
+ outline: 1.5px solid color-mix(in oklch, var(--primary) 25%, transparent);
+ outline-offset: -1.5px;
+}
+
+/* ---- Chat markdown ---- */
+
+.chat-markdown p {
+ margin: 0.25em 0;
+}
+.chat-markdown p:first-child {
+ margin-top: 0;
+}
+.chat-markdown p:last-child {
+ margin-bottom: 0;
+}
+.chat-markdown pre {
+ @apply bg-accent rounded-lg overflow-x-auto;
+ padding: 0.75rem 1rem;
+ margin: 0.5em 0;
+ font-size: 0.8125rem;
+}
+.chat-markdown code {
+ @apply bg-accent px-1 py-0.5 rounded;
+ font-size: 0.8125rem;
+}
+.chat-markdown pre code {
+ background: none;
+ padding: 0;
+}
+.chat-markdown ul, .chat-markdown ol {
+ padding-left: 1.25rem;
+ margin: 0.25em 0;
+}
+.chat-markdown li {
+ margin: 0.125em 0;
+}
+.chat-markdown blockquote {
+ @apply border-l-3 border-muted-foreground/30 pl-3 italic text-muted-foreground;
+ margin: 0.5em 0;
+}
+.chat-markdown table {
+ border-collapse: collapse;
+ margin: 0.5em 0;
+ font-size: 0.8125rem;
+}
+.chat-markdown th, .chat-markdown td {
+ @apply border border-border;
+ padding: 0.25rem 0.5rem;
+}
+
+/* ---- Scrollbar ---- */
+
+@utility scrollbar-thin {
+ scrollbar-width: thin;
+ scrollbar-color: oklch(0.556 0.015 55 / 0.25) transparent;
+ &::-webkit-scrollbar {
+ width: 5px;
+ height: 5px;
+ }
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: oklch(0.556 0.015 55 / 0.25);
+ border-radius: 3px;
+ }
+}
+
+/* ---- Status borders ---- */
+
+@utility border-status-backlog {
+ border-left: 3px solid var(--status-backlog);
+}
+@utility border-status-queued {
+ border-left: 3px solid var(--status-queued);
+}
+@utility border-status-processing {
+ border-left: 3px solid var(--status-processing);
+}
+@utility border-status-blocked {
+ border-left: 3px solid var(--status-blocked);
+}
+@utility border-status-done {
+ border-left: 3px solid var(--status-done);
+}
+@utility border-status-failed {
+ border-left: 3px solid var(--status-failed);
+}
+
+/* ---- Dialogs ---- */
+
+dialog.dialog[open] {
+ opacity: 1;
+}
+dialog.dialog[open]::backdrop {
+ opacity: 1;
+}
+dialog.dialog[open] > * {
+ scale: 1;
+}
+
+/* ---- Animations ---- */
+
+@theme {
+ --animate-slide-in-right: slide-in-right 0.2s ease-out;
+ --animate-fade-in: fade-in 0.15s ease-out;
+
+ @keyframes slide-in-right {
+ from { transform: translateX(100%); opacity: 0; }
+ to { transform: translateX(0); opacity: 1; }
+ }
+
+ @keyframes fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+ }
+}
+
+/* ---- Resize handle ---- */
+
+.resize-handle {
+ @apply w-1 flex-shrink-0 cursor-col-resize bg-transparent hover:bg-primary/20 transition-colors relative;
+}
+
+.resize-handle::after {
+ content: "";
+ @apply absolute inset-y-0 -left-1 -right-1;
+}
+
+.resize-handle.active {
+ @apply bg-primary/30;
+}
diff --git a/pilot/src/app.d.ts b/pilot/src/app.d.ts
new file mode 100644
index 00000000..b8a88de8
--- /dev/null
+++ b/pilot/src/app.d.ts
@@ -0,0 +1,30 @@
+///
+///
+
+declare global {
+ namespace App {
+ interface Locals {
+ user: import('$lib/types').User | null;
+ sessionId: string | null;
+ }
+ interface Platform {
+ env: {
+ DB: D1Database;
+ TASKYOU_AGENT: DurableObjectNamespace;
+ TASK_WORKFLOW: Workflow;
+ SESSIONS: KVNamespace;
+ STORAGE?: R2Bucket;
+ GOOGLE_CLIENT_ID?: string;
+ GOOGLE_CLIENT_SECRET?: string;
+ GITHUB_CLIENT_ID?: string;
+ GITHUB_CLIENT_SECRET?: string;
+ ANTHROPIC_API_KEY?: string;
+ SESSION_SECRET?: string;
+ ENVIRONMENT?: string;
+ };
+ context: ExecutionContext;
+ }
+ }
+}
+
+export {};
diff --git a/pilot/src/app.html b/pilot/src/app.html
new file mode 100644
index 00000000..b228260a
--- /dev/null
+++ b/pilot/src/app.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+ TaskYou
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/pilot/src/hooks.server.ts b/pilot/src/hooks.server.ts
new file mode 100644
index 00000000..715acb52
--- /dev/null
+++ b/pilot/src/hooks.server.ts
@@ -0,0 +1,67 @@
+import type { Handle } from '@sveltejs/kit';
+import { getSessionUserId } from '$lib/server/auth';
+import { getUserById, initHostDB } from '$lib/server/db';
+
+let dbInitialized = false;
+
+export const handle: Handle = async ({ event, resolve }) => {
+ const platform = event.platform;
+
+ // Initialize DB on first request
+ if (platform?.env?.DB && !dbInitialized) {
+ try {
+ await initHostDB(platform.env.DB);
+ dbInitialized = true;
+ } catch (e) {
+ // Table already exists is fine
+ dbInitialized = true;
+ }
+ }
+
+ // Dev mode: auto-authenticate with mock user
+ if (platform?.env?.ENVIRONMENT === 'development' || !platform?.env?.DB) {
+ const devUser = {
+ id: 'dev-user',
+ email: 'dev@localhost',
+ name: 'Development User',
+ avatar_url: '',
+ };
+ // Ensure dev user exists in DB
+ if (platform?.env?.DB && dbInitialized) {
+ try {
+ await platform.env.DB.prepare(
+ `INSERT OR IGNORE INTO users (id, email, name, avatar_url, provider, provider_id) VALUES (?, ?, ?, ?, ?, ?)`
+ ).bind(devUser.id, devUser.email, devUser.name, devUser.avatar_url, 'dev', 'dev').run();
+ } catch {
+ // ignore if already exists
+ }
+ }
+ event.locals.user = devUser;
+ event.locals.sessionId = 'dev-session';
+ return resolve(event);
+ }
+
+ // Extract session from cookie
+ const sessionId = event.cookies.get('session');
+ if (sessionId && platform?.env?.SESSIONS && platform?.env?.DB) {
+ const userId = await getSessionUserId(platform.env.SESSIONS, sessionId);
+ if (userId) {
+ const user = await getUserById(platform.env.DB, userId);
+ if (user) {
+ event.locals.user = user;
+ event.locals.sessionId = sessionId;
+ }
+ }
+ }
+
+ // Protect API routes (except auth endpoints)
+ const path = event.url.pathname;
+ if (path.startsWith('/api/') && !path.startsWith('/api/auth/') && !event.locals.user) {
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
+ status: 401,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
+ return resolve(event);
+};
diff --git a/pilot/src/lib/api/client.ts b/pilot/src/lib/api/client.ts
new file mode 100644
index 00000000..1dba45bf
--- /dev/null
+++ b/pilot/src/lib/api/client.ts
@@ -0,0 +1,171 @@
+import type {
+ User,
+ Task,
+ TaskFile,
+ CreateTaskRequest,
+ UpdateTaskRequest,
+ Project,
+ CreateProjectRequest,
+ UpdateProjectRequest,
+ TaskLog,
+ Chat,
+ Message,
+ Model,
+ Integration,
+ Workspace,
+} from '$lib/types';
+
+async function fetchJSON(path: string, options?: RequestInit): Promise {
+ const response = await fetch(`/api${path}`, {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options?.headers,
+ },
+ credentials: 'include',
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ error: response.statusText })) as { error?: string };
+ throw new Error(error.error || 'Request failed');
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return response.json();
+}
+
+// Auth API
+export const auth = {
+ getMe: () => fetchJSON('/auth'),
+ logout: () => fetchJSON<{ success: boolean }>('/auth', { method: 'POST' }),
+};
+
+// Tasks API
+export const tasks = {
+ list: (options?: { status?: string; project_id?: string; type?: string; all?: boolean }) => {
+ const params = new URLSearchParams();
+ if (options?.status) params.set('status', options.status);
+ if (options?.project_id) params.set('project_id', options.project_id);
+ if (options?.type) params.set('type', options.type);
+ if (options?.all) params.set('all', 'true');
+ const query = params.toString();
+ return fetchJSON(`/tasks${query ? `?${query}` : ''}`);
+ },
+ get: (id: number) => fetchJSON(`/tasks/${id}`),
+ create: (data: CreateTaskRequest) =>
+ fetchJSON('/tasks', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ }),
+ update: (id: number, data: UpdateTaskRequest) =>
+ fetchJSON(`/tasks/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ }),
+ delete: (id: number) =>
+ fetchJSON(`/tasks/${id}`, { method: 'DELETE' }),
+ getLogs: (id: number, limit?: number) => {
+ const params = limit ? `?limit=${limit}` : '';
+ return fetchJSON(`/tasks/${id}/logs${params}`);
+ },
+ listFiles: (id: number) => fetchJSON(`/tasks/${id}/files`),
+ getFileContent: (id: number, path: string) =>
+ fetch(`/api/tasks/${id}/file?path=${encodeURIComponent(path)}`, { credentials: 'include' }).then(r => r.text()),
+};
+
+// Projects API
+export const projects = {
+ list: () => fetchJSON('/projects'),
+ get: (id: string) => fetchJSON(`/projects/${id}`),
+ create: (data?: CreateProjectRequest) =>
+ fetchJSON('/projects', {
+ method: 'POST',
+ body: JSON.stringify(data || {}),
+ }),
+ update: (id: string, data: UpdateProjectRequest) =>
+ fetchJSON(`/projects/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ }),
+ delete: (id: string) =>
+ fetchJSON(`/projects/${id}`, { method: 'DELETE' }),
+};
+
+// Chats API
+export const chats = {
+ list: () => fetchJSON('/chats'),
+ get: (id: string) => fetchJSON(`/chats/${id}`),
+ create: (data?: { title?: string; model_id?: string; project_id?: string }) =>
+ fetchJSON('/chats', {
+ method: 'POST',
+ body: JSON.stringify(data || {}),
+ }),
+ update: (id: string, data: { title?: string; model_id?: string }) =>
+ fetchJSON(`/chats/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ }),
+ delete: (id: string) =>
+ fetchJSON(`/chats/${id}`, { method: 'DELETE' }),
+};
+
+// Messages API
+export const messages = {
+ list: (chatId: string) => fetchJSON(`/chats/${chatId}/messages`),
+};
+
+// Models API
+export const models = {
+ list: () => fetchJSON('/models'),
+};
+
+// Integrations API
+export const integrations = {
+ list: () => fetchJSON('/integrations'),
+};
+
+// Workspaces API
+export const workspaces = {
+ list: () => fetchJSON('/workspaces'),
+ get: (id: string) => fetchJSON(`/workspaces/${id}`),
+ create: (data: { name: string }) =>
+ fetchJSON('/workspaces', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ }),
+ update: (id: string, data: { name?: string; autonomous_enabled?: boolean; weekly_budget_cents?: number }) =>
+ fetchJSON(`/workspaces/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ }),
+ delete: (id: string) =>
+ fetchJSON(`/workspaces/${id}`, { method: 'DELETE' }),
+};
+
+// GitHub API
+export const github = {
+ status: () => fetchJSON<{ connected: boolean; login?: string; avatar_url?: string }>('/auth/github/status'),
+ startDeviceFlow: () =>
+ fetchJSON<{ device_code: string; user_code: string; verification_uri: string; expires_in: number; interval: number }>(
+ '/auth/github/device',
+ { method: 'POST' },
+ ),
+ pollDeviceFlow: (device_code: string) =>
+ fetchJSON<{ status: 'complete' | 'pending' | 'error'; error?: string; error_description?: string }>(
+ '/auth/github/device/poll',
+ { method: 'POST', body: JSON.stringify({ device_code }) },
+ ),
+};
+
+// Settings API
+export const settings = {
+ get: () => fetchJSON>('/settings'),
+ update: (data: Record) =>
+ fetchJSON<{ success: boolean }>('/settings', {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ }),
+};
diff --git a/pilot/src/lib/components/ApprovalsPage.svelte b/pilot/src/lib/components/ApprovalsPage.svelte
new file mode 100644
index 00000000..b0a29180
--- /dev/null
+++ b/pilot/src/lib/components/ApprovalsPage.svelte
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
Approvals
+
+
+ {#if pendingTasks.length === 0 && blockedTasks.length === 0}
+
+
+
+
+
All Clear
+
No tasks pending approval
+
+ {:else}
+
+ {#if pendingTasks.length > 0}
+
+
Pending Review
+
+ {#each pendingTasks as task}
+
+
+
+
{task.title}
+ {#if task.body}
+
{task.body}
+ {/if}
+
+ {task.type}
+ {task.project_id}
+
+
+
+
+
+
+
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if blockedTasks.length > 0}
+
+
Needs Attention
+
+ {#each blockedTasks as task}
+
+
+
+
+
{task.title}
+
Blocked since {new Date(task.updated_at).toLocaleDateString()}
+
+
{task.type}
+
+
+ {/each}
+
+
+ {/if}
+ {/if}
+
+
diff --git a/pilot/src/lib/components/AuditLogPage.svelte b/pilot/src/lib/components/AuditLogPage.svelte
new file mode 100644
index 00000000..c32862cb
--- /dev/null
+++ b/pilot/src/lib/components/AuditLogPage.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
Audit Log
+
+
+
+
+
+
+
Coming Soon
+
Agent actions will appear here once workflow execution is enabled
+
+
+
diff --git a/pilot/src/lib/components/ChatPanel.svelte b/pilot/src/lib/components/ChatPanel.svelte
new file mode 100644
index 00000000..0c6fb0f0
--- /dev/null
+++ b/pilot/src/lib/components/ChatPanel.svelte
@@ -0,0 +1,262 @@
+
+
+
+
+
+
+
+
+
Pilot Chat
+ {#if chatProject}
+
{chatProject.name}{chatProject.github_repo ? ` · ${chatProject.github_repo}` : ''}
+ {/if}
+
+
+
Claude Sonnet 4.5
+
+
+
+
+
+
+
+
+
+
diff --git a/pilot/src/lib/components/CommandPalette.svelte b/pilot/src/lib/components/CommandPalette.svelte
new file mode 100644
index 00000000..97a6610e
--- /dev/null
+++ b/pilot/src/lib/components/CommandPalette.svelte
@@ -0,0 +1,181 @@
+
+
+{#if isOpen}
+
+
+
+{/if}
diff --git a/pilot/src/lib/components/ContextMenu.svelte b/pilot/src/lib/components/ContextMenu.svelte
new file mode 100644
index 00000000..507facde
--- /dev/null
+++ b/pilot/src/lib/components/ContextMenu.svelte
@@ -0,0 +1,73 @@
+
+
+
+ {#each items as item}
+ {#if item.separator}
+
+ {/if}
+
+ {/each}
+
diff --git a/pilot/src/lib/components/Dashboard.svelte b/pilot/src/lib/components/Dashboard.svelte
new file mode 100644
index 00000000..70f69833
--- /dev/null
+++ b/pilot/src/lib/components/Dashboard.svelte
@@ -0,0 +1,293 @@
+
+
+
+
+
+
+ {#if activeProject}
+
+
+
{activeProject.name}
+ {#if activeProject.github_repo}
+
+
+ {activeProject.github_repo}
+ {#if activeProject.github_branch}
+ /
+ {activeProject.github_branch}
+ {/if}
+
+ {/if}
+
+ {/if}
+
+
+
+
+
+
+ (showNewTask = true)}
+ />
+
+
+
+ {#if navState.chatPanelOpen}
+
+
+
+
+
+
+
+
+ {/if}
+
+
+
+
+
+ ←→↑↓
+ navigate
+
+
+ ↵
+ open
+
+
+ N
+ new
+
+
+ [
+ sidebar
+
+
+ ]
+ chat
+
+
+ ⌘K
+ search
+
+
+
+
+
+
+{#if showKeyboardHelp}
+
+
+{/if}
+
+ (showCommandPalette = false)}
+ tasks={taskState.tasks}
+ onSelectTask={(task) => (selectedTask = task)}
+ onNewTask={() => (showNewTask = true)}
+ onNavigate={navigate}
+ onToggleSidebar={toggleSidebar}
+ onToggleChat={toggleChatPanel}
+ onRefreshTasks={fetchTasks}
+ onShowKeyboardHelp={() => (showKeyboardHelp = true)}
+/>
+
+{#if showNewTask}
+ (showNewTask = false)}
+ />
+{/if}
+
+{#if selectedTask}
+ (selectedTask = null)}
+ onUpdate={handleTaskUpdate}
+ onDelete={() => (selectedTask = null)}
+ />
+{/if}
diff --git a/pilot/src/lib/components/Header.svelte b/pilot/src/lib/components/Header.svelte
new file mode 100644
index 00000000..53e7b18c
--- /dev/null
+++ b/pilot/src/lib/components/Header.svelte
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if showUserMenu}
+
+
+
(showUserMenu = false)}>
+
+
+
{user.name}
+
{user.email}
+
+
+
+
+ {/if}
+
+
+
+
+
diff --git a/pilot/src/lib/components/IntegrationsPage.svelte b/pilot/src/lib/components/IntegrationsPage.svelte
new file mode 100644
index 00000000..e5011a03
--- /dev/null
+++ b/pilot/src/lib/components/IntegrationsPage.svelte
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+ Connect external services to extend Pilot's capabilities.
+
+
+ {#if loading}
+
+ {:else}
+
+ {#each providers as provider}
+ {@const status = getStatus(provider.id)}
+
+
+
+
+
+
{provider.name}
+ {#if status === 'connected'}
+
+
+ Connected
+
+ {:else if status === 'error'}
+
+
+ Error
+
+ {/if}
+
+
{provider.description}
+ {#if status === 'connected'}
+
+ {:else}
+
+ {/if}
+
+
+
+ {/each}
+
+ {/if}
+
+
diff --git a/pilot/src/lib/components/LoginPage.svelte b/pilot/src/lib/components/LoginPage.svelte
new file mode 100644
index 00000000..d03eca22
--- /dev/null
+++ b/pilot/src/lib/components/LoginPage.svelte
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Delegate coding tasks to Claude and watch them get done.
+ Review PRs, not implementations.
+
+
+
+
+ {#each features as feature}
+
+
+
+
+
+
{feature.title}
+
{feature.description}
+
+
+ {/each}
+
+
+
+
+ Powered by Anthropic's Claude • Running on Cloudflare
+
+
+
+
+
+
+
+
+
+
+
Sign in
+
+ Choose a method to get started
+
+
+
+
+
+
+ By signing in, you agree to our
+ Terms
+ and
+ Privacy Policy
+
+
+
+
diff --git a/pilot/src/lib/components/NewTaskDialog.svelte b/pilot/src/lib/components/NewTaskDialog.svelte
new file mode 100644
index 00000000..2184a11f
--- /dev/null
+++ b/pilot/src/lib/components/NewTaskDialog.svelte
@@ -0,0 +1,153 @@
+
+
+
+
diff --git a/pilot/src/lib/components/ProjectDialog.svelte b/pilot/src/lib/components/ProjectDialog.svelte
new file mode 100644
index 00000000..f0bbfa07
--- /dev/null
+++ b/pilot/src/lib/components/ProjectDialog.svelte
@@ -0,0 +1,259 @@
+
+
+
+
diff --git a/pilot/src/lib/components/ProjectsPage.svelte b/pilot/src/lib/components/ProjectsPage.svelte
new file mode 100644
index 00000000..d9283e80
--- /dev/null
+++ b/pilot/src/lib/components/ProjectsPage.svelte
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
Projects
+
+
+
+
+
+ Organize your tasks into projects. Use the chat to ask Pilot to create and manage tasks.
+
+
+ {#if loading}
+
+ {:else if projectList.length === 0}
+
+
+
+
+
No Projects
+
Create a project to organize your tasks
+
+
+ {:else}
+
+ {#each projectList as project (project.id)}
+
+
+
+
+
+
+
+
{project.name}
+ {#if project.github_repo}
+
+
+ {project.github_repo}
+ {#if project.github_branch}
+ ({project.github_branch})
+ {/if}
+
+ {/if}
+ {#if project.instructions}
+
{project.instructions}
+ {/if}
+
+ Created {new Date(project.created_at).toLocaleDateString()}
+
+
+
+
+
+
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+{#if showNewProject}
+ (showNewProject = false)}
+ />
+{/if}
+
+{#if editingProject}
+ (editingProject = null)}
+ />
+{/if}
diff --git a/pilot/src/lib/components/SettingsPage.svelte b/pilot/src/lib/components/SettingsPage.svelte
new file mode 100644
index 00000000..1554e280
--- /dev/null
+++ b/pilot/src/lib/components/SettingsPage.svelte
@@ -0,0 +1,265 @@
+
+
+
+
+{#if loading}
+
+
+
+
Loading settings...
+
+
+{:else}
+
+
+
+
+
+
+
Settings
+
Customize your taskyou experience
+
+
+
+
+
+
+
+
+
+
+
Appearance
+
Customize how taskyou looks
+
+
+
+
+
+ {#each ['light', 'dark', 'system'] as theme}
+ {@const isActive = settings.theme === theme || (!settings.theme && theme === 'system')}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Defaults
+
Default values for new tasks
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Projects
+
Manage your projects
+
+
+
+
+
+ {#if projects.length === 0}
+
+
+
No projects yet
+
+
+ {:else}
+
+ {#each projects as project}
+
+
+
+
+
{project.name}
+
{project.instructions || 'No instructions'}
+
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
Data
+
Export or manage your data
+
+
+
+ Data export is available through the API.
+
+
+
+
+
+
+
+
+
+
+
+{/if}
+
+{#if showNewProject}
+ (showNewProject = false)}
+ />
+{/if}
+
+{#if editingProject}
+ (editingProject = null)}
+ />
+{/if}
diff --git a/pilot/src/lib/components/Sidebar.svelte b/pilot/src/lib/components/Sidebar.svelte
new file mode 100644
index 00000000..4cada786
--- /dev/null
+++ b/pilot/src/lib/components/Sidebar.svelte
@@ -0,0 +1,379 @@
+
+
+
+{#if navState.sidebarMobileOpen}
+
+
+
+{/if}
+
+
+
+{#if showNewProject}
+ (showNewProject = false)}
+ />
+{/if}
+
+{#if editingProject}
+ (editingProject = null)}
+ />
+{/if}
diff --git a/pilot/src/lib/components/TaskBoard.svelte b/pilot/src/lib/components/TaskBoard.svelte
new file mode 100644
index 00000000..4c6ad479
--- /dev/null
+++ b/pilot/src/lib/components/TaskBoard.svelte
@@ -0,0 +1,418 @@
+
+
+
+
+
+
+ {#if selectedCount > 0}
+
+
{selectedCount} selected
+
+
+
+
+
+
+
+ {:else}
+
+
+
+
Tasks
+
+ {#if getInProgressTasks().length > 0}
+
+
+ {getInProgressTasks().length}
+
+ {/if}
+ {#if getBlockedTasks().length > 0}
+
+
+ {getBlockedTasks().length}
+
+ {/if}
+
+
+
+
+ {/if}
+
+
+
+ {#each columns as col, colIdx}
+ {@const columnTasks = getColumnTasks(col.key)}
+
+
handleDragOver(e, col.key)}
+ ondragleave={handleDragLeave}
+ ondrop={(e) => handleDrop(e, col.targetStatus)}
+ >
+
+
0 ? '' : undefined}
+ style:--col-color={col.color}
+ >
+
+
+
{col.title}
+
+
+ {columnTasks.length}
+
+
+
+
+
+
+
+ {#if col.showAdd}
+
+ {/if}
+
+ {/each}
+
+
+
+{#if contextMenu}
+ (contextMenu = null)}
+ />
+{/if}
diff --git a/pilot/src/lib/components/TaskCard.svelte b/pilot/src/lib/components/TaskCard.svelte
new file mode 100644
index 00000000..6af0b4e8
--- /dev/null
+++ b/pilot/src/lib/components/TaskCard.svelte
@@ -0,0 +1,45 @@
+
+
+
+
+ onClick(task)}
+>
+
+
{task.title}
+ {#if isRunning}
+
+ {/if}
+
+
+
+ {#if task.type}
+ {task.type}
+ {/if}
+ #{task.id}{#if task.started_at && isRunning} · {timeAgo(task.started_at)}{:else if task.completed_at} · {timeAgo(task.completed_at)}{:else if task.created_at} · {timeAgo(task.created_at)}{/if}
+
+
diff --git a/pilot/src/lib/components/TaskChat.svelte b/pilot/src/lib/components/TaskChat.svelte
new file mode 100644
index 00000000..4e81b0c6
--- /dev/null
+++ b/pilot/src/lib/components/TaskChat.svelte
@@ -0,0 +1,237 @@
+
+
+
+
+
+
+
+ Task Chat
+
+
+
+
+ {taskChatState.connected ? 'Connected' : 'Disconnected'}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pilot/src/lib/components/TaskDetail.svelte b/pilot/src/lib/components/TaskDetail.svelte
new file mode 100644
index 00000000..e5c1166b
--- /dev/null
+++ b/pilot/src/lib/components/TaskDetail.svelte
@@ -0,0 +1,647 @@
+
+
+
+
+
+
+{#if showRetryDialog}
+
+
+{/if}
diff --git a/pilot/src/lib/server/agent.ts b/pilot/src/lib/server/agent.ts
new file mode 100644
index 00000000..3f4bd25e
--- /dev/null
+++ b/pilot/src/lib/server/agent.ts
@@ -0,0 +1,635 @@
+import { AIChatAgent } from "@cloudflare/ai-chat";
+import { createAnthropic } from "@ai-sdk/anthropic";
+import { streamText, generateText, convertToModelMessages, tool, stepCountIs } from "ai";
+import { z } from "zod";
+import * as db from "./db";
+
+// Worker env type for agent context (not SvelteKit platform)
+interface AgentEnv {
+ DB: D1Database;
+ TASKYOU_AGENT: DurableObjectNamespace;
+ TASK_WORKFLOW: unknown; // Workflow binding for TaskExecutionWorkflow
+ SANDBOX: DurableObjectNamespace;
+ SESSIONS: KVNamespace;
+ STORAGE?: R2Bucket;
+ ANTHROPIC_API_KEY?: string;
+ [key: string]: unknown;
+}
+
+// State synced to all connected frontend clients in real-time
+export type AgentState = {
+ tasks: Array<{
+ id: number;
+ title: string;
+ status: string;
+ type: string;
+ project_id: string;
+ updated_at: string;
+ }>;
+ activeProject: string | null;
+ lastSync: string;
+};
+
+export class TaskYouAgent extends AIChatAgent {
+ initialState: AgentState = {
+ tasks: [],
+ activeProject: null,
+ lastSync: "",
+ };
+
+ onError(error: unknown) {
+ console.error("[TaskYouAgent] onError:", error);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ async onChatMessage(onFinish: any, options?: any) {
+ try {
+ if (!this.messages.length) {
+ throw new Error("No messages to process");
+ }
+
+ const apiKey = this.env.ANTHROPIC_API_KEY;
+ if (!apiKey) {
+ throw new Error("ANTHROPIC_API_KEY not configured");
+ }
+
+ const anthropic = createAnthropic({ apiKey });
+ const modelMessages = await convertToModelMessages(this.messages as any);
+
+ // Task-scoped instance: provide task context + sandbox tools
+ const taskId = this.getTaskId();
+ if (taskId !== null) {
+ const userId = this.getUserId();
+ const task = await db.getTask(this.env.DB, userId, taskId);
+ if (!task) {
+ throw new Error(`Task ${taskId} not found`);
+ }
+
+ let project: import("$lib/types").Project | null = null;
+ if (task.project_id) {
+ project = await db.getProjectById(this.env.DB, task.project_id);
+ }
+
+ const { getSandbox } = await import("@cloudflare/sandbox");
+ const sandbox = getSandbox(this.env.SANDBOX as any, `task-${taskId}`, {
+ normalizeId: true,
+ sleepAfter: "15m",
+ });
+
+ const sandboxTools = buildSandboxTools(sandbox, this.env.DB, taskId, undefined, this.env.STORAGE);
+
+ const result = streamText({
+ model: anthropic("claude-sonnet-4-5-20250929"),
+ system: this.buildTaskChatSystemPrompt(task, project),
+ messages: modelMessages,
+ tools: sandboxTools,
+ onFinish,
+ stopWhen: stepCountIs(10),
+ });
+
+ return result.toUIMessageStreamResponse();
+ }
+
+ // Main chat instance: orchestrator tools
+ const result = streamText({
+ model: anthropic("claude-sonnet-4-5-20250929"),
+ system: this.buildSystemPrompt(),
+ messages: modelMessages,
+ tools: this.getTools(),
+ onFinish,
+ stopWhen: stepCountIs(5),
+ });
+
+ return result.toUIMessageStreamResponse();
+ } catch (err) {
+ console.error("[TaskYouAgent] onChatMessage error:", err);
+ throw err;
+ }
+ }
+
+ getTaskId(): number | null {
+ const decoded = decodeURIComponent(this.name);
+ const parts = decoded.split(':');
+ if (parts.length < 2) return null;
+ const segment = parts[1];
+ if (segment.startsWith('task-')) {
+ const id = parseInt(segment.slice(5), 10);
+ return isNaN(id) ? null : id;
+ }
+ return null;
+ }
+
+ private getTools() {
+ const self = this;
+
+ return {
+ create_task: tool({
+ description: "Create a new task on the board",
+ inputSchema: z.object({
+ title: z.string().describe("Task title"),
+ body: z.string().optional().describe("Task description/details"),
+ type: z.enum(["code", "writing", "thinking"]).optional().describe("Task type"),
+ project_id: z.string().optional().describe("Project ID to assign to"),
+ }),
+ execute: async ({ title, body, type, project_id }) => {
+ const task = await db.createTask(self.env.DB, self.getUserId(), {
+ title, body, type, project_id,
+ });
+ await self.syncTasks();
+ return { id: task.id, title: task.title, status: task.status };
+ },
+ }),
+ list_tasks: tool({
+ description: "List tasks, optionally filtered by status or project",
+ inputSchema: z.object({
+ status: z.string().optional().describe("Filter by status: backlog, queued, processing, blocked, done, failed"),
+ project_id: z.string().optional().describe("Filter by project ID"),
+ }),
+ execute: async ({ status, project_id }) => {
+ const tasks = await db.listTasks(self.env.DB, self.getUserId(), {
+ status, project_id, includeClosed: true,
+ });
+ return tasks.map((t) => ({
+ id: t.id, title: t.title, status: t.status,
+ type: t.type, project_id: t.project_id, created_at: t.created_at,
+ }));
+ },
+ }),
+ update_task: tool({
+ description: "Update a task's title, body, status, or type",
+ inputSchema: z.object({
+ task_id: z.number().describe("Task ID to update"),
+ title: z.string().optional(),
+ body: z.string().optional(),
+ status: z.enum(["backlog", "queued", "processing", "blocked", "done", "failed"]).optional(),
+ type: z.string().optional(),
+ }),
+ execute: async ({ task_id, ...data }) => {
+ const task = await db.updateTask(self.env.DB, self.getUserId(), task_id, data);
+ await self.syncTasks();
+ return task
+ ? { id: task.id, title: task.title, status: task.status }
+ : { error: "Task not found" };
+ },
+ }),
+ delete_task: tool({
+ description: "Delete a task from the board",
+ inputSchema: z.object({
+ task_id: z.number().describe("Task ID to delete"),
+ }),
+ execute: async ({ task_id }) => {
+ const deleted = await db.deleteTask(self.env.DB, self.getUserId(), task_id);
+ await self.syncTasks();
+ return { deleted };
+ },
+ }),
+ run_task: tool({
+ description: "Execute a task using an AI agent with a sandbox environment. The task must already exist. The agent writes files, runs commands, and can serve web apps in the sandbox. If the task's project has a GitHub repo, the sandbox clones it first.",
+ inputSchema: z.object({
+ task_id: z.number().describe("Task ID to execute"),
+ }),
+ execute: async ({ task_id }) => {
+ const apiKey = self.env.ANTHROPIC_API_KEY;
+ if (!apiKey) return { error: "ANTHROPIC_API_KEY not configured" };
+
+ await self.syncTasks();
+ const result = await executeTask({
+ db: self.env.DB,
+ sandbox: self.env.SANDBOX,
+ sessions: self.env.SESSIONS,
+ storage: self.env.STORAGE,
+ apiKey,
+ userId: self.getUserId(),
+ taskId: task_id,
+ });
+ await self.syncTasks();
+ return 'error' in result ? result : { executed: true, output: result.output };
+ },
+ }),
+ show_board: tool({
+ description: "Show the current kanban board summary with task counts per column",
+ inputSchema: z.object({}),
+ execute: async () => {
+ const tasks = await db.listTasks(self.env.DB, self.getUserId(), { includeClosed: true });
+ return {
+ backlog: tasks.filter((t) => t.status === "backlog").length,
+ running: tasks.filter((t) => ["queued", "processing"].includes(t.status)).length,
+ blocked: tasks.filter((t) => t.status === "blocked").length,
+ done: tasks.filter((t) => t.status === "done").length,
+ failed: tasks.filter((t) => t.status === "failed").length,
+ total: tasks.length,
+ };
+ },
+ }),
+ list_projects: tool({
+ description: "List all projects",
+ inputSchema: z.object({}),
+ execute: async () => {
+ const projects = await db.listProjects(self.env.DB, self.getUserId());
+ return projects.map((p) => ({
+ id: p.id, name: p.name, color: p.color,
+ }));
+ },
+ }),
+ };
+ }
+
+ // Sync tasks from D1 -> agent state -> all connected clients
+ async syncTasks() {
+ const tasks = await db.listTasks(this.env.DB, this.getUserId(), { includeClosed: true });
+ this.setState({
+ ...this.state,
+ tasks: tasks.map((t) => ({
+ id: t.id,
+ title: t.title,
+ status: t.status,
+ type: t.type,
+ project_id: t.project_id,
+ updated_at: t.updated_at,
+ })),
+ lastSync: new Date().toISOString(),
+ });
+ }
+
+ // Workflow lifecycle callbacks — relay progress to connected frontend clients
+ async onWorkflowProgress(_name: string, _id: string, progress: unknown) {
+ this.broadcast(JSON.stringify({ type: 'workflow-progress', workflowId: _id, progress }));
+ await this.syncTasks();
+ }
+
+ async onWorkflowComplete(_name: string, _id: string, result?: unknown) {
+ this.broadcast(JSON.stringify({ type: 'workflow-complete', workflowId: _id, result }));
+ await this.syncTasks();
+ }
+
+ async onWorkflowError(_name: string, _id: string, error: string) {
+ this.broadcast(JSON.stringify({ type: 'workflow-error', workflowId: _id, error }));
+ await this.syncTasks();
+ }
+
+ private buildSystemPrompt(): string {
+ return `You are Pilot, the orchestrator for the TaskYou platform.
+You manage tasks and delegate execution to specialized workflow agents.
+
+You can:
+- Create tasks on the kanban board
+- Execute tasks by spawning AI workflow agents via run_task
+- List tasks, show board summary, list projects
+- Update or delete tasks
+
+When a user asks you to do something (write a poem, fix a bug, draft an email, etc.):
+1. Create a task for it using create_task
+2. Execute it using run_task — this spawns an AI agent with a sandbox environment
+3. The agent writes real files, runs commands, and can serve web apps
+4. The board updates in real-time as the task progresses
+
+The sandbox environment gives each task its own Linux container with a real filesystem.
+Tasks can produce files (code, documents) and web apps with live preview URLs.
+Be concise and action-oriented. Create the task, run it, and let the user know it's underway.`;
+ }
+
+ private buildTaskChatSystemPrompt(task: import("$lib/types").Task, project: import("$lib/types").Project | null): string {
+ let prompt = `You are Pilot, an AI assistant helping with a specific task in its sandbox environment.
+You have access to the task's Linux sandbox with tools to read/write files and run commands.
+
+## Current Task
+- **Title**: ${task.title}
+- **Status**: ${task.status}
+- **Type**: ${task.type}`;
+
+ if (task.body) {
+ prompt += `\n- **Details**: ${task.body}`;
+ }
+
+ if (project?.instructions) {
+ prompt += `\n\n## Project Instructions\n${project.instructions}`;
+ }
+
+ prompt += `\n
+## Capabilities
+- Use read_file to inspect files in the sandbox at /workspace
+- Use write_file to create or modify files
+- Use run_command to execute shell commands (build, test, install packages, etc.)
+- Use serve_app to start a web server and get a preview URL
+
+The sandbox may already contain files from a previous task execution. Explore first with run_command or read_file before making changes.
+Be concise and helpful. Focus on the task at hand.`;
+
+ return prompt;
+ }
+
+ private getUserId(): string {
+ // The agent instance name is URL-encoded "{userId}:{chatId}" — decode and extract userId
+ const decoded = decodeURIComponent(this.name);
+ return decoded.split(':')[0];
+ }
+}
+
+// ── Task execution (used by both agent run_task tool and API route) ──
+
+export async function executeTask(opts: {
+ db: D1Database;
+ sandbox: DurableObjectNamespace;
+ sessions: KVNamespace;
+ storage?: R2Bucket;
+ apiKey: string;
+ userId: string;
+ taskId: number;
+}): Promise<{ output: string } | { error: string }> {
+ const { db: database, sandbox: sandboxNs, sessions, storage, apiKey, userId, taskId } = opts;
+
+ const task = await db.getTask(database, userId, taskId);
+ if (!task) return { error: "Task not found" };
+
+ await db.updateTask(database, userId, taskId, { status: "processing" });
+ await db.addTaskLog(database, taskId, "system", `Starting task: ${task.title}`);
+
+ try {
+ const { getSandbox } = await import("@cloudflare/sandbox");
+ const sandbox = getSandbox(sandboxNs as any, `task-${taskId}`, {
+ normalizeId: true,
+ sleepAfter: "15m",
+ });
+
+ // Load project and check for GitHub repo
+ let gitContext: GitContext | undefined;
+ let project: import("$lib/types").Project | null = null;
+ if (task.project_id) {
+ project = await db.getProjectById(database, task.project_id);
+ if (project?.github_repo) {
+ const token = await sessions.get(`github-token:${userId}`);
+ if (!token) {
+ await db.updateTask(database, userId, taskId, { status: "failed", output: "GitHub not connected" });
+ return { error: "GitHub not connected. Connect your GitHub account in project settings." };
+ }
+
+ const branch = project.github_branch || "main";
+ await db.addTaskLog(database, taskId, "system", `Cloning ${project.github_repo} (branch: ${branch})...`);
+
+ const cloneResult = await sandbox.exec(
+ `git clone --depth=50 --branch ${branch} https://x-access-token:${token}@github.com/${project.github_repo}.git /workspace`,
+ { cwd: "/" } as any,
+ );
+ if (!cloneResult.success) {
+ await db.updateTask(database, userId, taskId, { status: "failed", output: `Clone failed: ${cloneResult.stderr}` });
+ return { error: `Failed to clone repo: ${cloneResult.stderr}` };
+ }
+
+ await sandbox.exec('git config user.name "TaskYou Bot"', { cwd: "/workspace" } as any);
+ await sandbox.exec('git config user.email "bot@taskyou.dev"', { cwd: "/workspace" } as any);
+ const taskBranch = `taskyou/task-${taskId}`;
+ await sandbox.exec(`git checkout -b ${taskBranch}`, { cwd: "/workspace" } as any);
+ await sandbox.exec(
+ `git config credential.helper '!f() { echo "username=x-access-token"; echo "password=${token}"; }; f'`,
+ { cwd: "/workspace" } as any,
+ );
+
+ await db.addTaskLog(database, taskId, "system", `Cloned. Working on branch ${taskBranch}`);
+ gitContext = { token, repo: project.github_repo, defaultBranch: branch };
+ }
+ }
+
+ const sandboxTools = buildSandboxTools(sandbox, database, taskId, undefined, storage, gitContext);
+ const anthropic = createAnthropic({ apiKey });
+ const response = await generateText({
+ model: anthropic("claude-sonnet-4-5-20250929"),
+ system: buildTaskExecutionPrompt(project || undefined),
+ prompt: `Execute this task:\nTitle: ${task.title}\n${task.body ? `Details: ${task.body}` : ""}\n${task.type ? `Type: ${task.type}` : ""}\n\nComplete this task thoroughly.${gitContext ? " The repo is already cloned at /workspace. Read existing code before making changes. After making changes, commit, push, and create a PR." : " Use write_file to create output files. For web apps, use serve_app after writing files."}`,
+ tools: sandboxTools,
+ stopWhen: stepCountIs(15),
+ onStepFinish: async (event) => {
+ try {
+ for (const tc of event.toolCalls || []) {
+ const args = JSON.stringify((tc as any).args) || '';
+ await db.addTaskLog(database, taskId, "tool", `${tc.toolName}(${args.slice(0, 200)})`);
+ }
+ for (const tr of event.toolResults || []) {
+ const result = JSON.stringify((tr as any).result) || '';
+ await db.addTaskLog(database, taskId, "output", result.slice(0, 500));
+ }
+ if (event.text) {
+ await db.addTaskLog(database, taskId, "text", event.text.slice(0, 500));
+ }
+ } catch (logErr) {
+ console.error("[executeTask] onStepFinish logging error:", logErr);
+ }
+ },
+ });
+
+ await db.updateTask(database, userId, taskId, {
+ status: "done",
+ output: response.text,
+ summary: response.text.slice(0, 200),
+ });
+ await db.addTaskLog(database, taskId, "output", response.text);
+ return { output: response.text };
+ } catch (execErr) {
+ console.error("[executeTask] Execution failed:", execErr);
+ const errMsg = execErr instanceof Error ? execErr.message : String(execErr);
+ await db.updateTask(database, userId, taskId, {
+ status: "failed",
+ output: `Execution error: ${errMsg}`,
+ });
+ await db.addTaskLog(database, taskId, "error", errMsg);
+ return { error: `Execution failed: ${errMsg}` };
+ }
+}
+
+// ── Sandbox tools shared between agent and workflow ──
+
+function guessMimeType(path: string): string {
+ const ext = path.split(".").pop()?.toLowerCase() || "";
+ const mimeMap: Record = {
+ html: "text/html", htm: "text/html", css: "text/css", js: "application/javascript",
+ ts: "text/typescript", json: "application/json", md: "text/markdown",
+ py: "text/x-python", rb: "text/x-ruby", sh: "text/x-shellscript",
+ txt: "text/plain", svg: "image/svg+xml", xml: "application/xml",
+ yaml: "text/yaml", yml: "text/yaml", toml: "text/toml",
+ };
+ return mimeMap[ext] || "text/plain";
+}
+
+export type GitContext = {
+ token: string;
+ repo: string; // "owner/repo"
+ defaultBranch: string;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function buildSandboxTools(
+ sandbox: any,
+ database: D1Database,
+ taskId: number,
+ hostname?: string,
+ storage?: R2Bucket,
+ gitContext?: GitContext,
+) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const tools: Record = {
+ write_file: tool({
+ description: "Write a file to the task sandbox. Use this to create deliverables — code, HTML, documents, etc.",
+ inputSchema: z.object({
+ path: z.string().describe("File path relative to /workspace (e.g. 'index.html', 'src/app.js')"),
+ content: z.string().describe("File content"),
+ }),
+ execute: async ({ path, content }) => {
+ const fullPath = `/workspace/${path}`;
+ await sandbox.writeFile(fullPath, content, { encoding: "utf-8" });
+ const mimeType = guessMimeType(path);
+ const sizeBytes = new TextEncoder().encode(content).length;
+ await db.addTaskFile(database, taskId, path, mimeType, sizeBytes);
+ // Persist to R2 so files survive sandbox shutdown
+ if (storage) {
+ await storage.put(`tasks/${taskId}/${path}`, content, {
+ httpMetadata: { contentType: mimeType },
+ });
+ }
+ return { written: true, path, size: sizeBytes };
+ },
+ }),
+ read_file: tool({
+ description: "Read a file from the task sandbox",
+ inputSchema: z.object({
+ path: z.string().describe("File path relative to /workspace"),
+ }),
+ execute: async ({ path }) => {
+ const file = await sandbox.readFile(`/workspace/${path}`, { encoding: "utf-8" });
+ return { content: file.content, path };
+ },
+ }),
+ run_command: tool({
+ description: "Run a shell command in the task sandbox (e.g. npm install, python script.py, build commands)",
+ inputSchema: z.object({
+ command: z.string().describe("Shell command to execute"),
+ cwd: z.string().optional().describe("Working directory (default: /workspace)"),
+ }),
+ execute: async ({ command, cwd }) => {
+ const result = await sandbox.exec(command, { cwd: cwd || "/workspace" } as any);
+ return {
+ success: result.success,
+ exitCode: result.exitCode,
+ stdout: result.stdout?.slice(0, 5000),
+ stderr: result.stderr?.slice(0, 2000),
+ };
+ },
+ }),
+ serve_app: tool({
+ description: "Start a web server in the sandbox and expose it via a preview URL. Use after writing HTML/JS/CSS files.",
+ inputSchema: z.object({
+ port: z.number().optional().describe("Port to serve on (default: 8080)"),
+ directory: z.string().optional().describe("Directory to serve (default: /workspace)"),
+ }),
+ execute: async ({ port, directory }) => {
+ const servePort = port || 8080;
+ const serveDir = directory || "/workspace";
+ await sandbox.startProcess(`npx serve -l ${servePort} ${serveDir}`, {
+ processId: `serve-${servePort}`,
+ cwd: "/workspace",
+ });
+ const exposed = await sandbox.exposePort(servePort, {
+ ...(hostname ? { hostname } : {}),
+ } as any);
+ const previewUrl = exposed.url;
+ await db.updateTask(database, "", taskId, { preview_url: previewUrl } as any);
+ return { preview_url: previewUrl, port: servePort };
+ },
+ }),
+ };
+
+ // Add git tools when working on a cloned repo
+ if (gitContext) {
+ tools.create_pull_request = tool({
+ description: "Commit all changes, push to GitHub, and create a pull request. Call this after making your code changes.",
+ inputSchema: z.object({
+ title: z.string().describe("PR title"),
+ body: z.string().optional().describe("PR description"),
+ }),
+ execute: async ({ title, body }) => {
+ // Get current branch
+ const branchResult = await sandbox.exec("git rev-parse --abbrev-ref HEAD", { cwd: "/workspace" } as any);
+ const branch = branchResult.stdout?.trim();
+ if (!branch) return { error: "Could not determine current branch" };
+
+ // Stage and commit
+ await sandbox.exec("git add -A", { cwd: "/workspace" } as any);
+ const commitResult = await sandbox.exec(
+ `git commit -m "${title.replace(/"/g, '\\"')}"`,
+ { cwd: "/workspace" } as any,
+ );
+ if (!commitResult.success && !commitResult.stderr?.includes("nothing to commit")) {
+ return { error: `Commit failed: ${commitResult.stderr}` };
+ }
+
+ // Push
+ const pushResult = await sandbox.exec(`git push -u origin ${branch}`, { cwd: "/workspace" } as any);
+ if (!pushResult.success) {
+ return { error: `Push failed: ${pushResult.stderr}` };
+ }
+
+ // Create PR via GitHub API
+ const [owner, repo] = gitContext.repo.split("/");
+ const prResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${gitContext.token}`,
+ "Content-Type": "application/json",
+ "User-Agent": "TaskYou-Pilot",
+ },
+ body: JSON.stringify({
+ title,
+ body: body || `Created by TaskYou (task #${taskId})`,
+ head: branch,
+ base: gitContext.defaultBranch,
+ }),
+ });
+
+ if (!prResponse.ok) {
+ const errText = await prResponse.text();
+ return { error: `Failed to create PR: ${errText}` };
+ }
+
+ const pr = await prResponse.json() as { html_url: string; number: number };
+ return { success: true, pr_url: pr.html_url, pr_number: pr.number, branch };
+ },
+ });
+ }
+
+ return tools;
+}
+
+export function buildTaskExecutionPrompt(project?: import("$lib/types").Project): string {
+ const hasRepo = !!project?.github_repo;
+
+ let prompt = `You are a task execution agent with a sandbox environment.
+You have access to a real Linux container with a filesystem.`;
+
+ if (hasRepo) {
+ prompt += `
+
+You are working on an existing codebase cloned from GitHub (${project!.github_repo}).
+The code is at /workspace on branch taskyou/task-*.
+
+Guidelines:
+- Use read_file and run_command to understand the existing code before making changes
+- Make focused, minimal changes to accomplish the task
+- Use run_command to run existing tests if available
+- After making changes, use create_pull_request to commit, push, and open a PR
+- Do NOT use write_file for files that already exist — use run_command with sed or similar, or read then write`;
+ } else {
+ prompt += `
+
+When executing tasks, always produce output files:
+- Use write_file to create deliverables (code, documents, reports)
+- For web apps: write HTML/CSS/JS files, use run_command if a build step is needed, then call serve_app
+- Use run_command for build steps (npm install, npm run build, etc.)
+- Every task should produce at least one output file`;
+ }
+
+ if (project?.instructions) {
+ prompt += `\n\nProject instructions:\n${project.instructions}`;
+ }
+
+ prompt += `\n\nBe thorough but concise. Write clean, well-structured code.`;
+
+ return prompt;
+}
diff --git a/pilot/src/lib/server/auth.ts b/pilot/src/lib/server/auth.ts
new file mode 100644
index 00000000..9c01591d
--- /dev/null
+++ b/pilot/src/lib/server/auth.ts
@@ -0,0 +1,156 @@
+import { findOrCreateUser } from './db';
+
+// Session management using KV
+const SESSION_TTL = 60 * 60 * 24 * 30; // 30 days
+
+export async function createSession(
+ kv: KVNamespace,
+ userId: string,
+): Promise {
+ const sessionId = crypto.randomUUID();
+ await kv.put(`session:${sessionId}`, userId, { expirationTtl: SESSION_TTL });
+ return sessionId;
+}
+
+export async function getSessionUserId(
+ kv: KVNamespace,
+ sessionId: string,
+): Promise {
+ return kv.get(`session:${sessionId}`);
+}
+
+export async function deleteSession(kv: KVNamespace, sessionId: string): Promise {
+ await kv.delete(`session:${sessionId}`);
+}
+
+// OAuth helpers
+interface GoogleUserInfo {
+ id: string;
+ email: string;
+ name: string;
+ picture: string;
+}
+
+interface GitHubUserInfo {
+ id: number;
+ login: string;
+ name: string | null;
+ email: string | null;
+ avatar_url: string;
+}
+
+export async function exchangeGoogleCode(
+ code: string,
+ clientId: string,
+ clientSecret: string,
+ redirectUri: string,
+): Promise {
+ // Exchange code for token
+ const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ code,
+ client_id: clientId,
+ client_secret: clientSecret,
+ redirect_uri: redirectUri,
+ grant_type: 'authorization_code',
+ }),
+ });
+
+ if (!tokenRes.ok) throw new Error('Failed to exchange Google OAuth code');
+ const tokenData = (await tokenRes.json()) as { access_token: string };
+
+ // Get user info
+ const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
+ headers: { Authorization: `Bearer ${tokenData.access_token}` },
+ });
+
+ if (!userRes.ok) throw new Error('Failed to get Google user info');
+ return userRes.json() as Promise;
+}
+
+export async function exchangeGitHubCode(
+ code: string,
+ clientId: string,
+ clientSecret: string,
+): Promise {
+ // Exchange code for token
+ const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ body: JSON.stringify({
+ client_id: clientId,
+ client_secret: clientSecret,
+ code,
+ }),
+ });
+
+ if (!tokenRes.ok) throw new Error('Failed to exchange GitHub OAuth code');
+ const tokenData = (await tokenRes.json()) as { access_token: string };
+
+ // Get user info
+ const userRes = await fetch('https://api.github.com/user', {
+ headers: {
+ Authorization: `Bearer ${tokenData.access_token}`,
+ 'User-Agent': 'TaskYou-Pilot',
+ },
+ });
+
+ if (!userRes.ok) throw new Error('Failed to get GitHub user info');
+ const user = (await userRes.json()) as GitHubUserInfo;
+
+ // Get primary email if not public
+ if (!user.email) {
+ const emailRes = await fetch('https://api.github.com/user/emails', {
+ headers: {
+ Authorization: `Bearer ${tokenData.access_token}`,
+ 'User-Agent': 'TaskYou-Pilot',
+ },
+ });
+ if (emailRes.ok) {
+ const emails = (await emailRes.json()) as Array<{ email: string; primary: boolean }>;
+ const primary = emails.find((e) => e.primary);
+ if (primary) user.email = primary.email;
+ }
+ }
+
+ return user;
+}
+
+export async function handleGoogleCallback(
+ db: D1Database,
+ kv: KVNamespace,
+ code: string,
+ clientId: string,
+ clientSecret: string,
+ redirectUri: string,
+): Promise<{ sessionId: string; user: { id: string; email: string; name: string; avatar_url: string } }> {
+ const googleUser = await exchangeGoogleCode(code, clientId, clientSecret, redirectUri);
+ const user = await findOrCreateUser(db, 'google', googleUser.id, googleUser.email, googleUser.name, googleUser.picture);
+ const sessionId = await createSession(kv, user.id);
+ return { sessionId, user };
+}
+
+export async function handleGitHubCallback(
+ db: D1Database,
+ kv: KVNamespace,
+ code: string,
+ clientId: string,
+ clientSecret: string,
+): Promise<{ sessionId: string; user: { id: string; email: string; name: string; avatar_url: string } }> {
+ const ghUser = await exchangeGitHubCode(code, clientId, clientSecret);
+ const user = await findOrCreateUser(
+ db,
+ 'github',
+ String(ghUser.id),
+ ghUser.email || `${ghUser.login}@github`,
+ ghUser.name || ghUser.login,
+ ghUser.avatar_url,
+ );
+ const sessionId = await createSession(kv, user.id);
+ return { sessionId, user };
+}
diff --git a/pilot/src/lib/server/db.ts b/pilot/src/lib/server/db.ts
new file mode 100644
index 00000000..f8fa1d5f
--- /dev/null
+++ b/pilot/src/lib/server/db.ts
@@ -0,0 +1,685 @@
+import type { Task, TaskFile, Chat, Message, Workspace, TaskStatus, TaskLog, Project, Model, Integration } from '$lib/types';
+
+export async function initHostDB(db: D1Database): Promise {
+ await db.batch([
+ db.prepare(`
+ CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ email TEXT UNIQUE NOT NULL,
+ name TEXT NOT NULL DEFAULT '',
+ avatar_url TEXT NOT NULL DEFAULT '',
+ provider TEXT NOT NULL,
+ provider_id TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ `),
+ db.prepare(`
+ CREATE TABLE IF NOT EXISTS workspaces (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ owner_id TEXT NOT NULL,
+ autonomous_enabled INTEGER NOT NULL DEFAULT 0,
+ weekly_budget_cents INTEGER NOT NULL DEFAULT 10000,
+ budget_spent_cents INTEGER NOT NULL DEFAULT 0,
+ polling_interval INTEGER NOT NULL DEFAULT 30,
+ brand_voice TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY (owner_id) REFERENCES users(id)
+ )
+ `),
+ db.prepare(`
+ CREATE TABLE IF NOT EXISTS memberships (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ workspace_id TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'member',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(user_id, workspace_id),
+ FOREIGN KEY (user_id) REFERENCES users(id),
+ FOREIGN KEY (workspace_id) REFERENCES workspaces(id)
+ )
+ `),
+ // Projects — organizational containers for tasks
+ db.prepare(`
+ CREATE TABLE IF NOT EXISTS projects (
+ id TEXT PRIMARY KEY,
+ workspace_id TEXT NOT NULL DEFAULT 'default',
+ user_id TEXT NOT NULL DEFAULT 'dev-user',
+ name TEXT NOT NULL DEFAULT 'Default',
+ instructions TEXT NOT NULL DEFAULT '',
+ color TEXT NOT NULL DEFAULT '#888888',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ )
+ `),
+ db.prepare(`
+ CREATE TABLE IF NOT EXISTS tasks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ workspace_id TEXT NOT NULL DEFAULT 'default',
+ user_id TEXT NOT NULL,
+ title TEXT NOT NULL,
+ body TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'backlog',
+ type TEXT NOT NULL DEFAULT 'code',
+ project_id TEXT,
+ chat_id TEXT,
+ parent_task_id INTEGER,
+ subtasks_json TEXT,
+ cost_cents INTEGER NOT NULL DEFAULT 0,
+ output TEXT,
+ summary TEXT,
+ approval_status TEXT,
+ dangerous_mode INTEGER NOT NULL DEFAULT 0,
+ scheduled_at TEXT,
+ recurrence TEXT,
+ last_run_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ started_at TEXT,
+ completed_at TEXT,
+ FOREIGN KEY (user_id) REFERENCES users(id),
+ FOREIGN KEY (parent_task_id) REFERENCES tasks(id),
+ FOREIGN KEY (project_id) REFERENCES projects(id),
+ FOREIGN KEY (chat_id) REFERENCES chats(id)
+ )
+ `),
+ db.prepare(`
+ CREATE TABLE IF NOT EXISTS task_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ task_id INTEGER NOT NULL,
+ line_type TEXT NOT NULL DEFAULT 'text',
+ content TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
+ )
+ `),
+ db.prepare(`
+ CREATE TABLE IF NOT EXISTS chats (
+ id TEXT PRIMARY KEY,
+ workspace_id TEXT NOT NULL DEFAULT 'default',
+ user_id TEXT NOT NULL,
+ project_id TEXT,
+ title TEXT NOT NULL DEFAULT 'New Chat',
+ model_id TEXT NOT NULL DEFAULT 'claude-sonnet-4-5-20250929',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY (user_id) REFERENCES users(id),
+ FOREIGN KEY (project_id) REFERENCES projects(id)
+ )
+ `),
+ db.prepare(`
+ CREATE TABLE IF NOT EXISTS messages (
+ id TEXT PRIMARY KEY,
+ chat_id TEXT NOT NULL,
+ role TEXT NOT NULL,
+ content TEXT NOT NULL DEFAULT '',
+ input_tokens INTEGER NOT NULL DEFAULT 0,
+ output_tokens INTEGER NOT NULL DEFAULT 0,
+ model_id TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE
+ )
+ `),
+ db.prepare(`
+ CREATE TABLE IF NOT EXISTS integrations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ workspace_id TEXT NOT NULL DEFAULT 'default',
+ provider TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'disconnected',
+ external_id TEXT,
+ access_token_encrypted TEXT,
+ refresh_token_encrypted TEXT,
+ token_expires_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ `),
+ db.prepare(`
+ CREATE TABLE IF NOT EXISTS models (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ provider TEXT NOT NULL,
+ api_id TEXT NOT NULL,
+ context_window INTEGER NOT NULL DEFAULT 200000,
+ input_price_per_million REAL NOT NULL DEFAULT 0,
+ output_price_per_million REAL NOT NULL DEFAULT 0,
+ supports_tools INTEGER NOT NULL DEFAULT 1,
+ supports_streaming INTEGER NOT NULL DEFAULT 1
+ )
+ `),
+ db.prepare(`
+ CREATE TABLE IF NOT EXISTS settings (
+ user_id TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value TEXT NOT NULL DEFAULT '',
+ PRIMARY KEY (user_id, key),
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ )
+ `),
+ db.prepare(`
+ CREATE TABLE IF NOT EXISTS task_files (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ task_id INTEGER NOT NULL,
+ path TEXT NOT NULL,
+ mime_type TEXT NOT NULL DEFAULT 'text/plain',
+ size_bytes INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ UNIQUE(task_id, path)
+ )
+ `),
+ ]);
+
+ // Add preview_url column if missing (safe to run repeatedly)
+ await db.prepare(`ALTER TABLE tasks ADD COLUMN preview_url TEXT`).run().catch(() => {});
+
+ // Add GitHub repo fields to projects (safe to run repeatedly)
+ await db.prepare(`ALTER TABLE projects ADD COLUMN github_repo TEXT`).run().catch(() => {});
+ await db.prepare(`ALTER TABLE projects ADD COLUMN github_branch TEXT`).run().catch(() => {});
+
+ // Seed default models
+ await db.prepare(`INSERT OR IGNORE INTO models (id, name, provider, api_id, context_window, input_price_per_million, output_price_per_million) VALUES
+ ('claude-sonnet-4-5-20250929', 'Claude Sonnet 4.5', 'anthropic', 'claude-sonnet-4-5-20250929', 200000, 3, 15),
+ ('claude-haiku-4-5-20251001', 'Claude Haiku 4.5', 'anthropic', 'claude-haiku-4-5-20251001', 200000, 0.8, 4),
+ ('claude-opus-4-6', 'Claude Opus 4.6', 'anthropic', 'claude-opus-4-6', 200000, 15, 75)
+ `).run();
+
+ // Seed default workspace for dev
+ await db.prepare(`INSERT OR IGNORE INTO workspaces (id, name, owner_id) VALUES ('default', 'Personal', 'dev-user')`).run();
+}
+
+// ── User operations ──
+
+export async function findOrCreateUser(
+ db: D1Database,
+ provider: string,
+ providerId: string,
+ email: string,
+ name: string,
+ avatarUrl: string,
+): Promise<{ id: string; email: string; name: string; avatar_url: string }> {
+ const id = `${provider}:${providerId}`;
+ const existing = await db
+ .prepare('SELECT id, email, name, avatar_url FROM users WHERE id = ?')
+ .bind(id)
+ .first<{ id: string; email: string; name: string; avatar_url: string }>();
+
+ if (existing) {
+ await db
+ .prepare("UPDATE users SET name = ?, avatar_url = ?, updated_at = datetime('now') WHERE id = ?")
+ .bind(name, avatarUrl, id)
+ .run();
+ return { ...existing, name, avatar_url: avatarUrl };
+ }
+
+ await db
+ .prepare('INSERT INTO users (id, email, name, avatar_url, provider, provider_id) VALUES (?, ?, ?, ?, ?, ?)')
+ .bind(id, email, name, avatarUrl, provider, providerId)
+ .run();
+
+ return { id, email, name, avatar_url: avatarUrl };
+}
+
+export async function getUserById(
+ db: D1Database,
+ userId: string,
+): Promise<{ id: string; email: string; name: string; avatar_url: string } | null> {
+ return db
+ .prepare('SELECT id, email, name, avatar_url FROM users WHERE id = ?')
+ .bind(userId)
+ .first();
+}
+
+// ── Task operations ──
+
+export async function listTasks(
+ db: D1Database,
+ userId: string,
+ options: { status?: string; project_id?: string; type?: string; includeClosed?: boolean } = {},
+): Promise {
+ let query = 'SELECT * FROM tasks WHERE user_id = ?';
+ const params: (string | number)[] = [userId];
+
+ if (options.status) {
+ query += ' AND status = ?';
+ params.push(options.status);
+ } else if (!options.includeClosed) {
+ query += " AND status NOT IN ('done', 'failed')";
+ }
+
+ if (options.project_id) {
+ query += ' AND project_id = ?';
+ params.push(options.project_id);
+ }
+
+ if (options.type) {
+ query += ' AND type = ?';
+ params.push(options.type);
+ }
+
+ query += ' ORDER BY updated_at DESC';
+
+ const result = await db.prepare(query).bind(...params).all();
+ return (result.results || []).map(rowToTask);
+}
+
+export async function getTask(db: D1Database, userId: string, taskId: number): Promise {
+ const row = await db
+ .prepare('SELECT * FROM tasks WHERE id = ? AND user_id = ?')
+ .bind(taskId, userId)
+ .first();
+ return row ? rowToTask(row) : null;
+}
+
+export async function createTask(
+ db: D1Database,
+ userId: string,
+ data: { title: string; body?: string; type?: string; project_id?: string; chat_id?: string },
+): Promise {
+ const result = await db
+ .prepare(
+ 'INSERT INTO tasks (user_id, title, body, type, project_id, chat_id) VALUES (?, ?, ?, ?, ?, ?) RETURNING *',
+ )
+ .bind(userId, data.title, data.body || '', data.type || 'code', data.project_id || null, data.chat_id || null)
+ .first();
+
+ return rowToTask(result!);
+}
+
+export async function updateTask(
+ db: D1Database,
+ userId: string,
+ taskId: number,
+ data: { title?: string; body?: string; status?: TaskStatus; type?: string; project_id?: string; output?: string; summary?: string; preview_url?: string },
+): Promise {
+ const sets: string[] = [];
+ const params: (string | number)[] = [];
+
+ if (data.title !== undefined) { sets.push('title = ?'); params.push(data.title); }
+ if (data.body !== undefined) { sets.push('body = ?'); params.push(data.body); }
+ if (data.status !== undefined) { sets.push('status = ?'); params.push(data.status); }
+ if (data.type !== undefined) { sets.push('type = ?'); params.push(data.type); }
+ if (data.project_id !== undefined) { sets.push('project_id = ?'); params.push(data.project_id); }
+ if (data.output !== undefined) { sets.push('output = ?'); params.push(data.output); }
+ if (data.summary !== undefined) { sets.push('summary = ?'); params.push(data.summary); }
+ if (data.preview_url !== undefined) { sets.push('preview_url = ?'); params.push(data.preview_url); }
+
+ if (sets.length === 0) return getTask(db, userId, taskId);
+
+ sets.push("updated_at = datetime('now')");
+
+ if (data.status === 'processing' || data.status === 'queued') {
+ sets.push("started_at = COALESCE(started_at, datetime('now'))");
+ }
+ if (data.status === 'done') {
+ sets.push("completed_at = datetime('now')");
+ }
+
+ params.push(taskId, userId);
+
+ const row = await db
+ .prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id = ? AND user_id = ? RETURNING *`)
+ .bind(...params)
+ .first();
+
+ return row ? rowToTask(row) : null;
+}
+
+export async function deleteTask(db: D1Database, userId: string, taskId: number): Promise {
+ const result = await db
+ .prepare('DELETE FROM tasks WHERE id = ? AND user_id = ?')
+ .bind(taskId, userId)
+ .run();
+ return (result.meta?.changes ?? 0) > 0;
+}
+
+// ── Chat operations ──
+
+export async function listChats(db: D1Database, userId: string): Promise {
+ const result = await db
+ .prepare('SELECT * FROM chats WHERE user_id = ? ORDER BY updated_at DESC')
+ .bind(userId)
+ .all();
+ return result.results || [];
+}
+
+export async function getChat(db: D1Database, userId: string, chatId: string): Promise {
+ return db
+ .prepare('SELECT * FROM chats WHERE id = ? AND user_id = ?')
+ .bind(chatId, userId)
+ .first();
+}
+
+export async function createChat(
+ db: D1Database,
+ userId: string,
+ data: { title?: string; model_id?: string; project_id?: string },
+): Promise {
+ const id = crypto.randomUUID();
+ const result = await db
+ .prepare('INSERT INTO chats (id, user_id, title, model_id, project_id) VALUES (?, ?, ?, ?, ?) RETURNING *')
+ .bind(id, userId, data.title || 'New Chat', data.model_id || 'claude-sonnet-4-5-20250929', data.project_id || null)
+ .first();
+ return result!;
+}
+
+export async function updateChat(
+ db: D1Database,
+ userId: string,
+ chatId: string,
+ data: { title?: string; model_id?: string },
+): Promise {
+ const sets: string[] = [];
+ const params: (string)[] = [];
+
+ if (data.title !== undefined) { sets.push('title = ?'); params.push(data.title); }
+ if (data.model_id !== undefined) { sets.push('model_id = ?'); params.push(data.model_id); }
+
+ if (sets.length === 0) return getChat(db, userId, chatId);
+
+ sets.push("updated_at = datetime('now')");
+ params.push(chatId, userId);
+
+ const row = await db
+ .prepare(`UPDATE chats SET ${sets.join(', ')} WHERE id = ? AND user_id = ? RETURNING *`)
+ .bind(...params)
+ .first();
+
+ return row;
+}
+
+export async function deleteChat(db: D1Database, userId: string, chatId: string): Promise {
+ const result = await db
+ .prepare('DELETE FROM chats WHERE id = ? AND user_id = ?')
+ .bind(chatId, userId)
+ .run();
+ return (result.meta?.changes ?? 0) > 0;
+}
+
+// ── Message operations ──
+
+export async function listMessages(db: D1Database, chatId: string): Promise {
+ const result = await db
+ .prepare('SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC')
+ .bind(chatId)
+ .all();
+ return result.results || [];
+}
+
+export async function createMessage(
+ db: D1Database,
+ data: { chat_id: string; role: string; content: string; model_id?: string; input_tokens?: number; output_tokens?: number },
+): Promise {
+ const id = crypto.randomUUID();
+ const result = await db
+ .prepare('INSERT INTO messages (id, chat_id, role, content, model_id, input_tokens, output_tokens) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *')
+ .bind(id, data.chat_id, data.role, data.content, data.model_id || null, data.input_tokens || 0, data.output_tokens || 0)
+ .first();
+ return result!;
+}
+
+// ── Model operations ──
+
+export async function listModels(db: D1Database): Promise {
+ const result = await db.prepare('SELECT * FROM models ORDER BY name').all();
+ return (result.results || []).map(m => ({
+ ...m,
+ supports_tools: Boolean(m.supports_tools),
+ supports_streaming: Boolean(m.supports_streaming),
+ }));
+}
+
+// ── Workspace operations ──
+
+export async function listWorkspaces(db: D1Database, userId: string): Promise {
+ const result = await db
+ .prepare(
+ `SELECT w.* FROM workspaces w
+ LEFT JOIN memberships m ON m.workspace_id = w.id AND m.user_id = ?
+ WHERE w.owner_id = ? OR m.user_id = ?
+ ORDER BY w.name`,
+ )
+ .bind(userId, userId, userId)
+ .all();
+ return result.results || [];
+}
+
+export async function getWorkspace(db: D1Database, id: string): Promise {
+ return db.prepare('SELECT * FROM workspaces WHERE id = ?').bind(id).first();
+}
+
+export async function createWorkspace(
+ db: D1Database,
+ ownerId: string,
+ data: { name: string },
+): Promise {
+ const id = crypto.randomUUID();
+ const result = await db
+ .prepare('INSERT INTO workspaces (id, name, owner_id) VALUES (?, ?, ?) RETURNING *')
+ .bind(id, data.name, ownerId)
+ .first();
+ await db
+ .prepare('INSERT INTO memberships (user_id, workspace_id, role) VALUES (?, ?, ?)')
+ .bind(ownerId, id, 'owner')
+ .run();
+ return result!;
+}
+
+export async function updateWorkspace(
+ db: D1Database,
+ id: string,
+ data: { name?: string; autonomous_enabled?: boolean; weekly_budget_cents?: number },
+): Promise {
+ const sets: string[] = [];
+ const params: (string | number)[] = [];
+
+ if (data.name !== undefined) { sets.push('name = ?'); params.push(data.name); }
+ if (data.autonomous_enabled !== undefined) { sets.push('autonomous_enabled = ?'); params.push(data.autonomous_enabled ? 1 : 0); }
+ if (data.weekly_budget_cents !== undefined) { sets.push('weekly_budget_cents = ?'); params.push(data.weekly_budget_cents); }
+
+ if (sets.length === 0) return null;
+
+ sets.push("updated_at = datetime('now')");
+ params.push(id);
+
+ return db
+ .prepare(`UPDATE workspaces SET ${sets.join(', ')} WHERE id = ? RETURNING *`)
+ .bind(...params)
+ .first();
+}
+
+export async function deleteWorkspace(db: D1Database, id: string): Promise {
+ const result = await db.prepare('DELETE FROM workspaces WHERE id = ?').bind(id).run();
+ return (result.meta?.changes ?? 0) > 0;
+}
+
+// ── Integration operations ──
+
+export async function listIntegrations(db: D1Database): Promise {
+ const result = await db
+ .prepare('SELECT id, workspace_id, provider, status, external_id, created_at, updated_at FROM integrations ORDER BY provider')
+ .all();
+ return result.results || [];
+}
+
+// ── Project operations ──
+
+export async function listProjects(db: D1Database, userId: string): Promise {
+ const result = await db
+ .prepare('SELECT * FROM projects WHERE user_id = ? ORDER BY created_at DESC')
+ .bind(userId)
+ .all();
+ return result.results || [];
+}
+
+export async function createProject(
+ db: D1Database,
+ userId: string,
+ data: { name?: string; instructions?: string; color?: string; github_repo?: string; github_branch?: string },
+): Promise {
+ const id = crypto.randomUUID();
+ const result = await db
+ .prepare('INSERT INTO projects (id, user_id, name, instructions, color, github_repo, github_branch) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *')
+ .bind(id, userId, data.name || 'Default', data.instructions || '', data.color || '#888888', data.github_repo || null, data.github_branch || null)
+ .first();
+ return result!;
+}
+
+export async function getProjectById(
+ db: D1Database,
+ projectId: string,
+): Promise {
+ return db
+ .prepare('SELECT * FROM projects WHERE id = ?')
+ .bind(projectId)
+ .first();
+}
+
+export async function updateProject(
+ db: D1Database,
+ projectId: string,
+ data: { name?: string; instructions?: string; color?: string; github_repo?: string; github_branch?: string },
+): Promise {
+ const sets: string[] = [];
+ const params: (string | number | null)[] = [];
+
+ if (data.name !== undefined) { sets.push('name = ?'); params.push(data.name); }
+ if (data.instructions !== undefined) { sets.push('instructions = ?'); params.push(data.instructions); }
+ if (data.color !== undefined) { sets.push('color = ?'); params.push(data.color); }
+ if (data.github_repo !== undefined) { sets.push('github_repo = ?'); params.push(data.github_repo || null); }
+ if (data.github_branch !== undefined) { sets.push('github_branch = ?'); params.push(data.github_branch || null); }
+
+ if (sets.length === 0) return getProjectById(db, projectId);
+
+ sets.push("updated_at = datetime('now')");
+ params.push(projectId);
+
+ return db
+ .prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ? RETURNING *`)
+ .bind(...params)
+ .first();
+}
+
+export async function deleteProject(db: D1Database, projectId: string): Promise {
+ const result = await db
+ .prepare('DELETE FROM projects WHERE id = ?')
+ .bind(projectId)
+ .run();
+ return (result.meta?.changes ?? 0) > 0;
+}
+
+// ── Task logs ──
+
+export async function getTaskLogs(
+ db: D1Database,
+ userId: string,
+ taskId: number,
+ limit = 200,
+): Promise {
+ const result = await db
+ .prepare(
+ `SELECT tl.* FROM task_logs tl
+ JOIN tasks t ON t.id = tl.task_id
+ WHERE tl.task_id = ? AND t.user_id = ?
+ ORDER BY tl.id DESC LIMIT ?`,
+ )
+ .bind(taskId, userId, limit)
+ .all();
+ return result.results || [];
+}
+
+export async function addTaskLog(
+ db: D1Database,
+ taskId: number,
+ lineType: string,
+ content: string,
+): Promise {
+ const result = await db
+ .prepare(
+ 'INSERT INTO task_logs (task_id, line_type, content) VALUES (?, ?, ?) RETURNING *',
+ )
+ .bind(taskId, lineType, content)
+ .first();
+ return result!;
+}
+
+// ── Settings ──
+
+export async function getSettings(db: D1Database, userId: string): Promise> {
+ const result = await db
+ .prepare('SELECT key, value FROM settings WHERE user_id = ?')
+ .bind(userId)
+ .all<{ key: string; value: string }>();
+
+ const settings: Record = {};
+ for (const row of result.results || []) {
+ settings[row.key] = row.value;
+ }
+ return settings;
+}
+
+export async function updateSettings(
+ db: D1Database,
+ userId: string,
+ data: Record,
+): Promise {
+ const stmts = Object.entries(data).map(([key, value]) =>
+ db
+ .prepare('INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, ?, ?)')
+ .bind(userId, key, value),
+ );
+ if (stmts.length > 0) {
+ await db.batch(stmts);
+ }
+}
+
+// ── Task files ──
+
+export async function addTaskFile(
+ db: D1Database,
+ taskId: number,
+ path: string,
+ mimeType: string,
+ sizeBytes: number,
+): Promise {
+ const result = await db
+ .prepare(
+ `INSERT INTO task_files (task_id, path, mime_type, size_bytes)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(task_id, path) DO UPDATE SET mime_type = excluded.mime_type, size_bytes = excluded.size_bytes
+ RETURNING *`,
+ )
+ .bind(taskId, path, mimeType, sizeBytes)
+ .first();
+ return result!;
+}
+
+export async function listTaskFiles(
+ db: D1Database,
+ userId: string,
+ taskId: number,
+): Promise {
+ const result = await db
+ .prepare(
+ `SELECT tf.* FROM task_files tf
+ JOIN tasks t ON t.id = tf.task_id
+ WHERE tf.task_id = ? AND t.user_id = ?
+ ORDER BY tf.path ASC`,
+ )
+ .bind(taskId, userId)
+ .all();
+ return result.results || [];
+}
+
+// ── Helpers ──
+
+function rowToTask(row: Task & { user_id?: string; dangerous_mode: number | boolean }): Task {
+ const { user_id, ...task } = row as Task & { user_id?: string };
+ return {
+ ...task,
+ dangerous_mode: Boolean(task.dangerous_mode),
+ };
+}
diff --git a/pilot/src/lib/server/workflow.ts b/pilot/src/lib/server/workflow.ts
new file mode 100644
index 00000000..32d6be4a
--- /dev/null
+++ b/pilot/src/lib/server/workflow.ts
@@ -0,0 +1,123 @@
+import { AgentWorkflow, type AgentWorkflowEvent, type AgentWorkflowStep } from "agents/workflows";
+import { generateText, stepCountIs } from "ai";
+import { createAnthropic } from "@ai-sdk/anthropic";
+import * as db from "./db";
+import { buildSandboxTools, buildTaskExecutionPrompt } from "./agent";
+import type { TaskYouAgent } from "./agent";
+import type { GitContext } from "./agent";
+
+// Worker env type
+interface WorkflowEnv {
+ DB: D1Database;
+ SANDBOX: DurableObjectNamespace;
+ SESSIONS?: KVNamespace;
+ STORAGE?: R2Bucket;
+ ANTHROPIC_API_KEY?: string;
+ [key: string]: unknown;
+}
+
+type TaskParams = { taskId: number; userId: string };
+
+type TaskProgress = {
+ step: string;
+ status: string;
+ taskId: number;
+ title?: string;
+};
+
+export class TaskExecutionWorkflow extends AgentWorkflow {
+ async run(event: AgentWorkflowEvent, step: AgentWorkflowStep) {
+ const { taskId, userId } = event.payload;
+ const env = this.env as unknown as WorkflowEnv;
+
+ // Step 1: Load and mark task as processing
+ const task = await step.do("mark-processing", async () => {
+ const t = await db.getTask(env.DB, userId, taskId);
+ if (!t) throw new Error(`Task ${taskId} not found`);
+ await db.updateTask(env.DB, userId, taskId, { status: "processing" });
+ return { id: t.id, title: t.title, body: t.body, type: t.type };
+ });
+
+ await this.reportProgress({ step: "processing", status: "running", taskId, title: task.title });
+
+ // Step 2: Execute with AI + sandbox
+ const result = await step.do("ai-execute", { retries: { limit: 2, delay: "5 seconds", backoff: "linear" } }, async () => {
+ const anthropic = createAnthropic({
+ apiKey: env.ANTHROPIC_API_KEY,
+ });
+
+ // Create sandbox for this task (dynamic import to avoid Vite bundling @cloudflare/sandbox)
+ const { getSandbox } = await import("@cloudflare/sandbox");
+ const sandbox = getSandbox(env.SANDBOX as any, `task-${taskId}`, {
+ normalizeId: true,
+ sleepAfter: "15m",
+ });
+
+ // Load project and check for GitHub repo
+ let gitContext: GitContext | undefined;
+ let project: import("$lib/types").Project | null = null;
+ const loadedTask = await db.getTask(env.DB, userId, taskId);
+ if (loadedTask?.project_id) {
+ project = await db.getProjectById(env.DB, loadedTask.project_id);
+ if (project?.github_repo && env.SESSIONS) {
+ const token = await env.SESSIONS.get(`github-token:${userId}`);
+ if (token) {
+ const branch = project.github_branch || "main";
+
+ // Clone repo
+ const cloneResult = await sandbox.exec(
+ `git clone --depth=50 --branch ${branch} https://x-access-token:${token}@github.com/${project.github_repo}.git /workspace`,
+ { cwd: "/" } as any,
+ );
+ if (cloneResult.success) {
+ await sandbox.exec('git config user.name "TaskYou Bot"', { cwd: "/workspace" } as any);
+ await sandbox.exec('git config user.email "bot@taskyou.dev"', { cwd: "/workspace" } as any);
+ await sandbox.exec(`git checkout -b taskyou/task-${taskId}`, { cwd: "/workspace" } as any);
+ await sandbox.exec(
+ `git config credential.helper '!f() { echo "username=x-access-token"; echo "password=${token}"; }; f'`,
+ { cwd: "/workspace" } as any,
+ );
+ gitContext = { token, repo: project.github_repo, defaultBranch: branch };
+ }
+ }
+ }
+ }
+
+ const sandboxTools = buildSandboxTools(sandbox, env.DB, taskId, undefined, env.STORAGE, gitContext);
+
+ const prompt = `Execute this task:\nTitle: ${task.title}\n${task.body ? `Details: ${task.body}` : ""}\n${task.type ? `Type: ${task.type}` : ""}\n\nComplete this task thoroughly.${gitContext ? " The repo is already cloned at /workspace. Read existing code before making changes. After making changes, commit, push, and create a PR." : " Use write_file to create output files. For web apps, use serve_app after writing files."}`;
+
+ const response = await generateText({
+ model: anthropic("claude-sonnet-4-5-20250929"),
+ system: buildTaskExecutionPrompt(project || undefined),
+ prompt,
+ tools: sandboxTools,
+ stopWhen: stepCountIs(15),
+ });
+
+ const usage = response.usage as { promptTokens?: number; completionTokens?: number } | undefined;
+ return {
+ output: response.text,
+ inputTokens: usage?.promptTokens || 0,
+ outputTokens: usage?.completionTokens || 0,
+ };
+ });
+
+ await this.reportProgress({ step: "saving", status: "running", taskId });
+
+ // Step 3: Save results and mark done
+ await step.do("save-results", async () => {
+ await db.updateTask(env.DB, userId, taskId, {
+ status: "done",
+ output: result.output,
+ summary: result.output.slice(0, 200),
+ });
+ await db.addTaskLog(env.DB, taskId, "output", result.output);
+ });
+
+ // Notify agent to sync state
+ await this.reportProgress({ step: "complete", status: "done", taskId });
+
+ return result;
+ }
+}
diff --git a/pilot/src/lib/stores/agent-ws.ts b/pilot/src/lib/stores/agent-ws.ts
new file mode 100644
index 00000000..5d422828
--- /dev/null
+++ b/pilot/src/lib/stores/agent-ws.ts
@@ -0,0 +1,49 @@
+// WebSocket send helpers for the agent connection.
+// The WebSocket itself is created in +page.svelte and stored on globalThis.__agentWs.
+
+import type { AgentChatMessage } from '$lib/types';
+
+function getWs(): WebSocket | null {
+ const G = globalThis as any;
+ return G.__agentWs?.ws ?? null;
+}
+
+export function sendChatViaWebSocket(messages: AgentChatMessage[]): string | null {
+ const ws = getWs();
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ console.error('[agent-ws] WebSocket not connected');
+ return null;
+ }
+
+ const requestId = crypto.randomUUID();
+
+ ws.send(JSON.stringify({
+ type: 'cf_agent_use_chat_request',
+ id: requestId,
+ init: {
+ method: 'POST',
+ body: JSON.stringify({
+ messages: messages.map(m => ({
+ id: m.id,
+ role: m.role,
+ content: m.content,
+ createdAt: m.createdAt,
+ })),
+ }),
+ },
+ }));
+
+ return requestId;
+}
+
+export function sendAgentMessage(message: unknown) {
+ const ws = getWs();
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify(message));
+ }
+}
+
+export function isConnected(): boolean {
+ const G = globalThis as any;
+ return G.__agentWs?.connected ?? false;
+}
diff --git a/pilot/src/lib/stores/agent.svelte.ts b/pilot/src/lib/stores/agent.svelte.ts
new file mode 100644
index 00000000..6331b828
--- /dev/null
+++ b/pilot/src/lib/stores/agent.svelte.ts
@@ -0,0 +1,195 @@
+// Svelte 5 reactive state for agent connection.
+// WebSocket logic lives in Dashboard.svelte (inline) and agent-ws.ts.
+// This file manages reactive state updates.
+
+import type { AgentChatMessage } from '$lib/types';
+
+// Messages restored from agent DO storage on WebSocket connect
+export let restoredMessages: AgentChatMessage[] = [];
+
+// Callback for when messages are restored (set by chat store to avoid circular dep)
+let onMessagesRestored: (() => void) | null = null;
+export function setOnMessagesRestored(cb: () => void) { onMessagesRestored = cb; }
+
+// Callback for when agent syncs tasks (triggers fetchTasks in tasks store)
+let onTasksUpdated: (() => void) | null = null;
+export function setOnTasksUpdated(cb: () => void) { onTasksUpdated = cb; }
+
+/** Extract text content from AI SDK UI message format (which uses `parts`) */
+function extractTextContent(msg: any): string {
+ // AI SDK UI messages have `parts` array with type/text entries
+ if (msg.parts && Array.isArray(msg.parts)) {
+ return msg.parts
+ .filter((p: any) => p.type === 'text')
+ .map((p: any) => p.text)
+ .join('');
+ }
+ // Fallback: plain content string
+ if (typeof msg.content === 'string') return msg.content;
+ return '';
+}
+
+export const agentState = $state({
+ connected: false,
+ tasks: [] as Array<{
+ id: number;
+ title: string;
+ status: string;
+ type: string;
+ project_id: string;
+ updated_at: string;
+ }>,
+ lastSync: '',
+});
+
+export const workflowState = $state({
+ activeWorkflows: {} as Record,
+});
+
+export const agentChatStream = $state({
+ streaming: false,
+ streamingContent: '',
+ lastError: null as string | null,
+ completedMessage: null as AgentChatMessage | null,
+});
+
+/**
+ * Handle incoming WebSocket messages and route to reactive state.
+ * Called by Dashboard.svelte's inline WebSocket handler.
+ */
+export function handleAgentMessage(data: any) {
+ if (data.type === '_connected') {
+ agentState.connected = true;
+ return;
+ }
+ if (data.type === '_disconnected') {
+ agentState.connected = false;
+ return;
+ }
+
+ // Handle state sync from agent
+ if (data.type === 'cf_agent_state' || data.type === 'cf_agent_state_update') {
+ const state = data.state || data;
+ if (state.tasks) {
+ agentState.tasks = state.tasks;
+ onTasksUpdated?.();
+ }
+ if (state.lastSync) {
+ agentState.lastSync = state.lastSync;
+ }
+ return;
+ }
+
+ // Handle chat streaming response (AIChatAgent protocol)
+ if (data.type === 'cf_agent_use_chat_response') {
+ handleChatResponse(data);
+ return;
+ }
+
+ // Handle workflow lifecycle messages
+ if (data.type === 'workflow-progress') {
+ const id = data.workflowId;
+ if (id) {
+ workflowState.activeWorkflows[id] = {
+ status: 'running',
+ progress: data.progress,
+ };
+ }
+ return;
+ }
+ if (data.type === 'workflow-complete') {
+ const id = data.workflowId;
+ if (id) {
+ workflowState.activeWorkflows[id] = {
+ status: 'complete',
+ result: data.result,
+ };
+ }
+ return;
+ }
+ if (data.type === 'workflow-error') {
+ const id = data.workflowId;
+ if (id) {
+ workflowState.activeWorkflows[id] = {
+ status: 'error',
+ error: data.error,
+ };
+ }
+ return;
+ }
+
+ // Restore persisted chat messages from the agent's DO storage
+ if (data.type === 'cf_agent_chat_messages') {
+ if (data.messages && Array.isArray(data.messages)) {
+ restoredMessages = data.messages
+ .map((m: any) => ({
+ id: m.id || crypto.randomUUID(),
+ role: m.role,
+ content: extractTextContent(m),
+ createdAt: m.createdAt || new Date().toISOString(),
+ }))
+ .filter((m: any) => m.content.trim() !== '');
+ onMessagesRestored?.();
+ }
+ return;
+ }
+
+ // Silently ignore protocol messages
+ if (data.type === 'cf_agent_identity' || data.type === 'cf_agent_mcp_servers') {
+ return;
+ }
+}
+
+/**
+ * Reset chat-related streaming state when switching chats.
+ * Called before connecting to a new agent instance.
+ */
+export function resetChatState() {
+ agentChatStream.streaming = false;
+ agentChatStream.streamingContent = '';
+ agentChatStream.completedMessage = null;
+ agentChatStream.lastError = null;
+ agentState.connected = false;
+ restoredMessages = [];
+}
+
+function handleChatResponse(data: { id: string; body: string; done: boolean; error?: boolean; continuation?: boolean }) {
+ if (data.error) {
+ agentChatStream.streaming = false;
+ agentChatStream.streamingContent = '';
+ agentChatStream.lastError = data.body || 'Unknown error';
+ return;
+ }
+
+ if (data.body) {
+ try {
+ const parsed = JSON.parse(data.body);
+ switch (parsed.type) {
+ case 'text-delta':
+ agentChatStream.streamingContent += parsed.delta;
+ break;
+ case 'error':
+ agentChatStream.lastError = parsed.errorText || 'Unknown error';
+ agentChatStream.streaming = false;
+ agentChatStream.streamingContent = '';
+ return;
+ // Ignore: start, start-step, text-start, text-end, finish-step, finish
+ }
+ } catch {
+ // Body is not JSON — might be empty string or old SSE format
+ }
+ }
+
+ if (data.done) {
+ if (agentChatStream.streamingContent) {
+ agentChatStream.completedMessage = {
+ id: crypto.randomUUID(),
+ role: 'assistant',
+ content: agentChatStream.streamingContent,
+ createdAt: new Date().toISOString(),
+ };
+ }
+ agentChatStream.streaming = false;
+ agentChatStream.streamingContent = '';
+ }
+}
diff --git a/pilot/src/lib/stores/auth.svelte.ts b/pilot/src/lib/stores/auth.svelte.ts
new file mode 100644
index 00000000..f3072fd3
--- /dev/null
+++ b/pilot/src/lib/stores/auth.svelte.ts
@@ -0,0 +1,27 @@
+import type { User } from '$lib/types';
+import { auth as authApi } from '$lib/api/client';
+
+export const authState = $state({
+ user: null as User | null,
+ loading: true,
+});
+
+export async function fetchUser() {
+ authState.loading = true;
+ try {
+ authState.user = await authApi.getMe();
+ } catch {
+ authState.user = null;
+ } finally {
+ authState.loading = false;
+ }
+}
+
+export async function logout() {
+ try {
+ await authApi.logout();
+ } catch {
+ // ignore
+ }
+ authState.user = null;
+}
diff --git a/pilot/src/lib/stores/chat.svelte.ts b/pilot/src/lib/stores/chat.svelte.ts
new file mode 100644
index 00000000..2bdc6e78
--- /dev/null
+++ b/pilot/src/lib/stores/chat.svelte.ts
@@ -0,0 +1,143 @@
+import type { Chat, Message, AgentChatMessage } from '$lib/types';
+import { chats as chatsApi, messages as messagesApi } from '$lib/api/client';
+import { sendChatViaWebSocket } from './agent-ws';
+import { agentState, agentChatStream, restoredMessages, setOnMessagesRestored } from './agent.svelte';
+
+export const chatState = $state({
+ chats: [] as Chat[],
+ activeChat: null as Chat | null,
+ messages: [] as Message[],
+ // Agent chat messages (from WebSocket)
+ agentMessages: [] as AgentChatMessage[],
+ loading: false,
+});
+
+// Derived streaming state from agent store (single source of truth)
+export function isStreaming() {
+ return agentChatStream.streaming;
+}
+
+export function getStreamingContent() {
+ return agentChatStream.streamingContent;
+}
+
+export async function fetchChats() {
+ try {
+ chatState.chats = await chatsApi.list();
+ } catch (e) {
+ console.error('Failed to fetch chats:', e);
+ }
+}
+
+export function selectChat(chat: Chat) {
+ chatState.activeChat = chat;
+ chatState.messages = [];
+ chatState.agentMessages = [];
+ // Agent DO handles persistence — messages restored via cf_agent_chat_messages on WS connect
+}
+
+export async function createNewChat(projectId?: string, modelId?: string): Promise {
+ const chat = await chatsApi.create({ model_id: modelId, project_id: projectId });
+ chatState.chats = [chat, ...chatState.chats];
+ chatState.activeChat = chat;
+ chatState.messages = [];
+ chatState.agentMessages = [];
+ return chat;
+}
+
+export function getChatsByProject(projectId: string): Chat[] {
+ return chatState.chats.filter(c => c.project_id === projectId);
+}
+
+export function getScratchChats(): Chat[] {
+ return chatState.chats.filter(c => !c.project_id);
+}
+
+export async function deleteChat(chatId: string) {
+ await chatsApi.delete(chatId);
+ chatState.chats = chatState.chats.filter(c => c.id !== chatId);
+ if (chatState.activeChat?.id === chatId) {
+ chatState.activeChat = null;
+ chatState.messages = [];
+ chatState.agentMessages = [];
+ }
+}
+
+/**
+ * Load persisted messages from the agent's DO storage.
+ * Called automatically when cf_agent_chat_messages is received via WebSocket.
+ */
+export function loadRestoredMessages() {
+ if (restoredMessages.length > 0) {
+ chatState.agentMessages = [...restoredMessages];
+ }
+}
+
+// NOTE: setOnMessagesRestored(loadRestoredMessages) must be called from +page.svelte
+// to survive Vite tree-shaking. See memory note on tree-shaking.
+
+/**
+ * Send a message to the agent via WebSocket (AIChatAgent protocol).
+ * The agent handles persistence, streaming, and tool calling.
+ */
+export async function sendAgentChatMessage(content: string, _userId: string) {
+ if (!content.trim() || agentChatStream.streaming) return;
+
+ if (!agentState.connected) {
+ const errorMsg: AgentChatMessage = {
+ id: crypto.randomUUID(),
+ role: 'assistant',
+ content: '**Error:** Not connected to agent. Please wait for connection...',
+ createdAt: new Date().toISOString(),
+ };
+ chatState.agentMessages = [...chatState.agentMessages, errorMsg];
+ return;
+ }
+
+ if (!chatState.activeChat) {
+ const errorMsg: AgentChatMessage = {
+ id: crypto.randomUUID(),
+ role: 'assistant',
+ content: '**Error:** No active chat. Please create or select a chat first.',
+ createdAt: new Date().toISOString(),
+ };
+ chatState.agentMessages = [...chatState.agentMessages, errorMsg];
+ return;
+ }
+
+ // Add user message optimistically
+ const userMsg: AgentChatMessage = {
+ id: crypto.randomUUID(),
+ role: 'user',
+ content: content.trim(),
+ createdAt: new Date().toISOString(),
+ };
+ chatState.agentMessages = [...chatState.agentMessages, userMsg];
+ if (chatState.agentMessages.filter(m => m.role === 'user').length === 1 && chatState.activeChat) {
+ const title = content.trim().slice(0, 60) + (content.length > 60 ? '...' : '');
+ chatState.activeChat = { ...chatState.activeChat, title };
+ chatState.chats = chatState.chats.map(c =>
+ c.id === chatState.activeChat!.id ? { ...c, title } : c
+ );
+ }
+
+ // Set streaming state before sending
+ agentChatStream.streaming = true;
+ agentChatStream.streamingContent = '';
+ agentChatStream.lastError = null;
+ agentChatStream.completedMessage = null;
+
+ // Send all messages via WebSocket using AIChatAgent protocol
+ const requestId = sendChatViaWebSocket(chatState.agentMessages);
+
+ if (!requestId) {
+ agentChatStream.streaming = false;
+ const errorMsg: AgentChatMessage = {
+ id: crypto.randomUUID(),
+ role: 'assistant',
+ content: '**Error:** Failed to send message. WebSocket not connected.',
+ createdAt: new Date().toISOString(),
+ };
+ chatState.agentMessages = [...chatState.agentMessages, errorMsg];
+ }
+}
diff --git a/pilot/src/lib/stores/nav.svelte.ts b/pilot/src/lib/stores/nav.svelte.ts
new file mode 100644
index 00000000..2ed8ed34
--- /dev/null
+++ b/pilot/src/lib/stores/nav.svelte.ts
@@ -0,0 +1,83 @@
+import type { NavView } from '$lib/types';
+
+function loadBool(key: string, fallback: boolean): boolean {
+ if (typeof localStorage === 'undefined') return fallback;
+ const v = localStorage.getItem(key);
+ if (v === null) return fallback;
+ return v === 'true';
+}
+
+function loadNumber(key: string, fallback: number): number {
+ if (typeof localStorage === 'undefined') return fallback;
+ const v = localStorage.getItem(key);
+ if (v === null) return fallback;
+ const n = parseFloat(v);
+ return isNaN(n) ? fallback : n;
+}
+
+function loadString(key: string, fallback: string): string {
+ if (typeof localStorage === 'undefined') return fallback;
+ return localStorage.getItem(key) ?? fallback;
+}
+
+export const navState = $state({
+ view: 'dashboard' as NavView,
+ activeProjectId: loadString('ui:active-project', '') || null as string | null,
+ sidebarCollapsed: loadBool('ui:sidebar-collapsed', false),
+ sidebarMobileOpen: false,
+ chatPanelOpen: loadBool('ui:chat-panel-open', true),
+ boardWidth: loadNumber('ui:board-width', 60),
+ focusedColumn: loadNumber('ui:focused-column', 0),
+ focusedRow: loadNumber('ui:focused-row', 0),
+});
+
+// Persist helpers — write on change
+function persist(key: string, value: string) {
+ if (typeof localStorage !== 'undefined') localStorage.setItem(key, value);
+}
+
+export function navigate(view: NavView) {
+ navState.view = view;
+ navState.sidebarMobileOpen = false;
+}
+
+export function setActiveProject(projectId: string | null) {
+ navState.activeProjectId = projectId;
+ navState.view = 'dashboard';
+ navState.sidebarMobileOpen = false;
+ if (projectId) {
+ persist('ui:active-project', projectId);
+ } else if (typeof localStorage !== 'undefined') {
+ localStorage.removeItem('ui:active-project');
+ }
+}
+
+export function toggleSidebar() {
+ navState.sidebarCollapsed = !navState.sidebarCollapsed;
+ persist('ui:sidebar-collapsed', String(navState.sidebarCollapsed));
+}
+
+export function toggleMobileSidebar() {
+ navState.sidebarMobileOpen = !navState.sidebarMobileOpen;
+}
+
+export function closeMobileSidebar() {
+ navState.sidebarMobileOpen = false;
+}
+
+export function toggleChatPanel() {
+ navState.chatPanelOpen = !navState.chatPanelOpen;
+ persist('ui:chat-panel-open', String(navState.chatPanelOpen));
+}
+
+export function setBoardWidth(width: number) {
+ navState.boardWidth = width;
+ persist('ui:board-width', String(width));
+}
+
+export function setFocus(column: number, row: number) {
+ navState.focusedColumn = column;
+ navState.focusedRow = row;
+ persist('ui:focused-column', String(column));
+ persist('ui:focused-row', String(row));
+}
diff --git a/pilot/src/lib/stores/projects.svelte.ts b/pilot/src/lib/stores/projects.svelte.ts
new file mode 100644
index 00000000..9d4e5dff
--- /dev/null
+++ b/pilot/src/lib/stores/projects.svelte.ts
@@ -0,0 +1,55 @@
+import type { Project, CreateProjectRequest, UpdateProjectRequest } from '$lib/types';
+import { projects as projectsApi } from '$lib/api/client';
+import { navState } from './nav.svelte';
+
+export const projectState = $state({
+ projects: [] as Project[],
+ loading: false,
+ expandedProjects: new Set(),
+});
+
+export async function fetchProjects() {
+ projectState.loading = true;
+ try {
+ projectState.projects = await projectsApi.list();
+ } catch (e) {
+ console.error('Failed to fetch projects:', e);
+ } finally {
+ projectState.loading = false;
+ }
+}
+
+export async function createProject(data: CreateProjectRequest): Promise {
+ const project = await projectsApi.create(data);
+ projectState.projects = [project, ...projectState.projects];
+ return project;
+}
+
+export async function updateProject(id: string, data: UpdateProjectRequest): Promise {
+ const updated = await projectsApi.update(id, data);
+ projectState.projects = projectState.projects.map(p => p.id === id ? updated : p);
+ return updated;
+}
+
+export async function deleteProject(id: string): Promise {
+ await projectsApi.delete(id);
+ projectState.projects = projectState.projects.filter(p => p.id !== id);
+ if (navState.activeProjectId === id) {
+ navState.activeProjectId = null;
+ }
+}
+
+export function toggleProjectExpanded(id: string) {
+ const next = new Set(projectState.expandedProjects);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ projectState.expandedProjects = next;
+}
+
+export function getActiveProject(): Project | null {
+ if (!navState.activeProjectId) return null;
+ return projectState.projects.find(p => p.id === navState.activeProjectId) ?? null;
+}
diff --git a/pilot/src/lib/stores/taskChat.svelte.ts b/pilot/src/lib/stores/taskChat.svelte.ts
new file mode 100644
index 00000000..af75efc5
--- /dev/null
+++ b/pilot/src/lib/stores/taskChat.svelte.ts
@@ -0,0 +1,258 @@
+// Per-task chat store with WebSocket connection management.
+// Independent from the main chat — uses module-level WebSocket (not globalThis)
+// because task chat is component-scoped (connect on TaskDetail open, disconnect on close).
+
+import type { AgentChatMessage } from '$lib/types';
+
+let taskWs: WebSocket | null = null;
+let reconnectTimer: ReturnType | null = null;
+
+// Track connection params for reconnect
+let currentUserId: string | null = null;
+let currentTaskId: number | null = null;
+
+export const taskChatState = $state({
+ taskId: null as number | null,
+ connected: false,
+ messages: [] as AgentChatMessage[],
+ streaming: false,
+ streamingContent: '',
+ error: null as string | null,
+ completedMessage: null as AgentChatMessage | null,
+});
+
+/** Extract text content from AI SDK UI message format (which uses `parts`) */
+function extractTextContent(msg: any): string {
+ if (msg.parts && Array.isArray(msg.parts)) {
+ return msg.parts
+ .filter((p: any) => p.type === 'text')
+ .map((p: any) => p.text)
+ .join('');
+ }
+ if (typeof msg.content === 'string') return msg.content;
+ return '';
+}
+
+/**
+ * Connect to a task-scoped agent DO instance via WebSocket.
+ * The agent instance name is `{userId}:task-{taskId}`.
+ */
+export function connectTaskChat(userId: string, taskId: number) {
+ // Already connected to this task
+ if (taskWs && taskWs.readyState === WebSocket.OPEN && currentTaskId === taskId) {
+ return;
+ }
+
+ // Clear messages if switching to a different task
+ const switchingTask = currentTaskId !== null && currentTaskId !== taskId;
+
+ // Clean up any existing connection
+ disconnectTaskChat();
+
+ if (switchingTask) {
+ taskChatState.messages = [];
+ }
+
+ currentUserId = userId;
+ currentTaskId = taskId;
+ taskChatState.taskId = taskId;
+ taskChatState.error = null;
+
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const agentName = encodeURIComponent(`${userId}:task-${taskId}`);
+ const url = `${protocol}//${location.host}/agents/taskyou-agent/${agentName}`;
+
+ const ws = new WebSocket(url);
+ taskWs = ws;
+
+ ws.addEventListener('open', () => {
+ if (taskWs !== ws) return; // Stale socket
+ taskChatState.connected = true;
+ taskChatState.error = null;
+ });
+
+ ws.addEventListener('message', (event) => {
+ if (taskWs !== ws) return; // Stale socket
+ try {
+ const data = JSON.parse(event.data);
+ handleTaskChatMessage(data);
+ } catch {
+ // Non-JSON message, ignore
+ }
+ });
+
+ ws.addEventListener('close', () => {
+ if (taskWs !== ws) return; // Stale socket — don't corrupt current state
+ taskChatState.connected = false;
+ taskWs = null;
+
+ // Auto-reconnect after 3s if we still have connection params
+ if (currentUserId && currentTaskId) {
+ reconnectTimer = setTimeout(() => {
+ if (currentUserId && currentTaskId) {
+ connectTaskChat(currentUserId, currentTaskId);
+ }
+ }, 3000);
+ }
+ });
+
+ ws.addEventListener('error', () => {
+ if (taskWs !== ws) return; // Stale socket
+ taskChatState.error = 'WebSocket connection error';
+ });
+}
+
+/**
+ * Disconnect from the task chat WebSocket and reset state.
+ */
+export function disconnectTaskChat() {
+ currentUserId = null;
+ currentTaskId = null;
+
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+ }
+
+ if (taskWs) {
+ const ws = taskWs;
+ taskWs = null; // Clear reference BEFORE closing so close handler ignores
+ ws.close();
+ }
+
+ taskChatState.taskId = null;
+ taskChatState.connected = false;
+ taskChatState.streaming = false;
+ taskChatState.streamingContent = '';
+ taskChatState.error = null;
+ taskChatState.completedMessage = null;
+ // Note: messages are NOT cleared here — they persist until a new task connects
+ // or the server restores them via cf_agent_chat_messages on reconnect
+}
+
+/**
+ * Handle incoming WebSocket messages for the task chat.
+ * Routes protocol messages to update reactive state.
+ */
+export function handleTaskChatMessage(data: any) {
+ // Chat streaming response (AIChatAgent protocol)
+ if (data.type === 'cf_agent_use_chat_response') {
+ handleChatResponse(data);
+ return;
+ }
+
+ // Restore persisted chat messages from the agent's DO storage
+ if (data.type === 'cf_agent_chat_messages') {
+ if (data.messages && Array.isArray(data.messages)) {
+ taskChatState.messages = data.messages
+ .map((m: any) => ({
+ id: m.id || crypto.randomUUID(),
+ role: m.role,
+ content: extractTextContent(m),
+ createdAt: m.createdAt || new Date().toISOString(),
+ }))
+ .filter((m: any) => m.content.trim() !== '');
+ }
+ return;
+ }
+
+ // Silently ignore other protocol messages
+ if (
+ data.type === 'cf_agent_identity' ||
+ data.type === 'cf_agent_mcp_servers' ||
+ data.type === 'cf_agent_state' ||
+ data.type === 'cf_agent_state_update'
+ ) {
+ return;
+ }
+}
+
+function handleChatResponse(data: { id: string; body: string; done: boolean; error?: boolean }) {
+ if (data.error) {
+ taskChatState.streaming = false;
+ taskChatState.streamingContent = '';
+ taskChatState.error = data.body || 'Unknown error';
+ return;
+ }
+
+ if (data.body) {
+ try {
+ const parsed = JSON.parse(data.body);
+ switch (parsed.type) {
+ case 'text-delta':
+ taskChatState.streamingContent += parsed.delta;
+ break;
+ case 'error':
+ taskChatState.error = parsed.errorText || 'Unknown error';
+ taskChatState.streaming = false;
+ taskChatState.streamingContent = '';
+ return;
+ }
+ } catch {
+ // Body is not JSON — ignore
+ }
+ }
+
+ if (data.done) {
+ if (taskChatState.streamingContent) {
+ taskChatState.completedMessage = {
+ id: crypto.randomUUID(),
+ role: 'assistant',
+ content: taskChatState.streamingContent,
+ createdAt: new Date().toISOString(),
+ };
+ }
+ taskChatState.streaming = false;
+ taskChatState.streamingContent = '';
+ }
+}
+
+/**
+ * Send a chat message to the task-scoped agent via WebSocket.
+ * Adds the user message optimistically and sends full history.
+ */
+export function sendTaskChatMessage(content: string): string | null {
+ if (!content.trim()) return null;
+
+ if (!taskWs || taskWs.readyState !== WebSocket.OPEN) {
+ taskChatState.error = 'Not connected to task agent';
+ return null;
+ }
+
+ // Add user message optimistically
+ const userMsg: AgentChatMessage = {
+ id: crypto.randomUUID(),
+ role: 'user',
+ content: content.trim(),
+ createdAt: new Date().toISOString(),
+ };
+ taskChatState.messages = [...taskChatState.messages, userMsg];
+
+ // Set streaming state before sending
+ taskChatState.streaming = true;
+ taskChatState.streamingContent = '';
+ taskChatState.error = null;
+ taskChatState.completedMessage = null;
+
+ // Send full message history via AIChatAgent protocol
+ const requestId = crypto.randomUUID();
+ taskWs.send(
+ JSON.stringify({
+ type: 'cf_agent_use_chat_request',
+ id: requestId,
+ init: {
+ method: 'POST',
+ body: JSON.stringify({
+ messages: taskChatState.messages.map((m) => ({
+ id: m.id,
+ role: m.role,
+ content: m.content,
+ createdAt: m.createdAt,
+ })),
+ }),
+ },
+ })
+ );
+
+ return requestId;
+}
diff --git a/pilot/src/lib/stores/tasks.svelte.ts b/pilot/src/lib/stores/tasks.svelte.ts
new file mode 100644
index 00000000..bbcccbf6
--- /dev/null
+++ b/pilot/src/lib/stores/tasks.svelte.ts
@@ -0,0 +1,89 @@
+import type { Task, CreateTaskRequest, UpdateTaskRequest } from '$lib/types';
+import { tasks as tasksApi } from '$lib/api/client';
+import { navState } from './nav.svelte';
+
+export const taskState = $state<{ tasks: Task[]; loading: boolean }>({
+ tasks: [],
+ loading: true,
+});
+
+// Derived getters for board columns
+function byUpdatedDesc(a: Task, b: Task) {
+ return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
+}
+
+function filterByProject(tasks: Task[]): Task[] {
+ const pid = navState.activeProjectId;
+ if (!pid) return tasks;
+ return tasks.filter(t => t.project_id === pid);
+}
+
+export function getBacklogTasks(): Task[] {
+ return filterByProject(taskState.tasks.filter((t) => t.status === 'backlog')).sort(byUpdatedDesc);
+}
+
+export function getInProgressTasks(): Task[] {
+ return filterByProject(taskState.tasks
+ .filter((t) => t.status === 'queued' || t.status === 'processing'))
+ .sort((a, b) => {
+ if (a.status === 'processing' && b.status !== 'processing') return -1;
+ if (b.status === 'processing' && a.status !== 'processing') return 1;
+ return byUpdatedDesc(a, b);
+ });
+}
+
+export function getBlockedTasks(): Task[] {
+ return filterByProject(taskState.tasks.filter((t) => t.status === 'blocked')).sort(byUpdatedDesc);
+}
+
+export function getDoneTasks(): Task[] {
+ return filterByProject(taskState.tasks.filter((t) => t.status === 'done')).sort(byUpdatedDesc);
+}
+
+export function getFailedTasks(): Task[] {
+ return filterByProject(taskState.tasks.filter((t) => t.status === 'failed')).sort(byUpdatedDesc);
+}
+
+export async function fetchTasks() {
+ taskState.loading = true;
+ try {
+ const data = await tasksApi.list({ all: true });
+ taskState.tasks = data;
+ } catch (e) {
+ console.error('Failed to fetch tasks:', e);
+ } finally {
+ taskState.loading = false;
+ }
+}
+
+export async function createTask(data: CreateTaskRequest): Promise {
+ const task = await tasksApi.create(data);
+ taskState.tasks = [...taskState.tasks, task];
+ return task;
+}
+
+export async function updateTask(id: number, data: UpdateTaskRequest): Promise {
+ const task = await tasksApi.update(id, data);
+ taskState.tasks = taskState.tasks.map((t) => (t.id === id ? task : t));
+ return task;
+}
+
+export async function deleteTask(id: number): Promise {
+ await tasksApi.delete(id);
+ taskState.tasks = taskState.tasks.filter((t) => t.id !== id);
+}
+
+// Periodic refresh (fallback when agent WebSocket is not connected)
+let pollInterval: ReturnType | null = null;
+
+export function startPolling(interval = 10000) {
+ stopPolling();
+ pollInterval = setInterval(fetchTasks, interval);
+}
+
+export function stopPolling() {
+ if (pollInterval) {
+ clearInterval(pollInterval);
+ pollInterval = null;
+ }
+}
diff --git a/pilot/src/lib/types.ts b/pilot/src/lib/types.ts
new file mode 100644
index 00000000..a55fb22b
--- /dev/null
+++ b/pilot/src/lib/types.ts
@@ -0,0 +1,191 @@
+// User and authentication types
+export interface User {
+ id: string;
+ email: string;
+ name: string;
+ avatar_url: string;
+}
+
+// Workspace types
+export interface Workspace {
+ id: string;
+ name: string;
+ owner_id: string;
+ autonomous_enabled: boolean;
+ weekly_budget_cents: number;
+ budget_spent_cents: number;
+ polling_interval: number;
+ brand_voice: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Membership {
+ id: number;
+ user_id: string;
+ workspace_id: string;
+ role: 'owner' | 'admin' | 'member';
+ created_at: string;
+}
+
+// Task types
+export type TaskStatus = 'backlog' | 'queued' | 'processing' | 'blocked' | 'done' | 'failed';
+
+export interface Task {
+ id: number;
+ title: string;
+ body: string;
+ status: TaskStatus;
+ type: string;
+ project_id: string;
+ chat_id?: string;
+ workspace_id: string;
+ parent_task_id?: number;
+ subtasks_json?: string;
+ cost_cents: number;
+ output?: string;
+ summary?: string;
+ preview_url?: string;
+ approval_status?: 'pending_review' | 'approved' | 'rejected';
+ dangerous_mode: boolean;
+ scheduled_at?: string;
+ recurrence?: string;
+ last_run_at?: string;
+ created_at: string;
+ updated_at: string;
+ started_at?: string;
+ completed_at?: string;
+}
+
+export interface TaskFile {
+ id: number;
+ task_id: number;
+ path: string;
+ mime_type: string;
+ size_bytes: number;
+ created_at: string;
+}
+
+export interface CreateTaskRequest {
+ title: string;
+ body?: string;
+ type?: string;
+ project_id?: string;
+ chat_id?: string;
+}
+
+export interface UpdateTaskRequest {
+ title?: string;
+ body?: string;
+ status?: TaskStatus;
+ type?: string;
+ project_id?: string;
+}
+
+// Project types — organizational containers for tasks
+export interface Project {
+ id: string;
+ workspace_id: string;
+ user_id: string;
+ name: string;
+ instructions: string;
+ color: string;
+ github_repo?: string;
+ github_branch?: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CreateProjectRequest {
+ name: string;
+ instructions?: string;
+ color?: string;
+ github_repo?: string;
+ github_branch?: string;
+}
+
+export interface UpdateProjectRequest {
+ name?: string;
+ instructions?: string;
+ color?: string;
+ github_repo?: string;
+ github_branch?: string;
+}
+
+// Chat types
+export interface Chat {
+ id: string;
+ workspace_id: string;
+ user_id: string;
+ project_id?: string;
+ title: string;
+ model_id: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
+
+export interface Message {
+ id: string;
+ chat_id: string;
+ role: MessageRole;
+ content: string;
+ input_tokens: number;
+ output_tokens: number;
+ model_id?: string;
+ created_at: string;
+}
+
+// Integration types
+export interface Integration {
+ id: number;
+ workspace_id: string;
+ provider: 'github' | 'gmail' | 'slack' | 'linear';
+ status: 'connected' | 'disconnected' | 'error';
+ external_id?: string;
+ created_at: string;
+ updated_at: string;
+}
+
+// Model types
+export interface Model {
+ id: string;
+ name: string;
+ provider: string;
+ api_id: string;
+ context_window: number;
+ input_price_per_million: number;
+ output_price_per_million: number;
+ supports_tools: boolean;
+ supports_streaming: boolean;
+}
+
+// Task log types
+export interface TaskLog {
+ id: number;
+ task_id: number;
+ line_type: 'system' | 'text' | 'tool' | 'error' | 'output';
+ content: string;
+ created_at: string;
+}
+
+// Agent chat message (from AI SDK)
+export interface AgentChatMessage {
+ id: string;
+ role: 'user' | 'assistant';
+ content: string;
+ createdAt?: string;
+ toolInvocations?: ToolInvocation[];
+}
+
+export interface ToolInvocation {
+ toolCallId: string;
+ toolName: string;
+ args: Record;
+ state: 'call' | 'result' | 'partial-call';
+ result?: unknown;
+}
+
+// Navigation
+export type NavView = 'dashboard' | 'workspaces' | 'integrations' | 'approvals' | 'settings';
diff --git a/pilot/src/routes/+layout.svelte b/pilot/src/routes/+layout.svelte
new file mode 100644
index 00000000..dcb1eb48
--- /dev/null
+++ b/pilot/src/routes/+layout.svelte
@@ -0,0 +1,7 @@
+
+
+{@render children()}
diff --git a/pilot/src/routes/+layout.ts b/pilot/src/routes/+layout.ts
new file mode 100644
index 00000000..ef995480
--- /dev/null
+++ b/pilot/src/routes/+layout.ts
@@ -0,0 +1,2 @@
+// Disable SSR - this is a client-side SPA that requires browser APIs and auth
+export const ssr = false;
diff --git a/pilot/src/routes/+page.svelte b/pilot/src/routes/+page.svelte
new file mode 100644
index 00000000..6c6f0704
--- /dev/null
+++ b/pilot/src/routes/+page.svelte
@@ -0,0 +1,187 @@
+
+
+{#if authState.loading}
+
+{:else if !authState.user}
+
+{:else}
+
+
+
+
+
+{/if}
diff --git a/pilot/src/routes/api/auth/+server.ts b/pilot/src/routes/api/auth/+server.ts
new file mode 100644
index 00000000..49ae1fd7
--- /dev/null
+++ b/pilot/src/routes/api/auth/+server.ts
@@ -0,0 +1,20 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { deleteSession } from '$lib/server/auth';
+
+// GET /api/auth - Get current user
+export const GET: RequestHandler = async ({ locals }) => {
+ if (!locals.user) {
+ return json({ error: 'Unauthorized' }, { status: 401 });
+ }
+ return json(locals.user);
+};
+
+// POST /api/auth/logout
+export const POST: RequestHandler = async ({ locals, cookies, platform }) => {
+ if (locals.sessionId && platform?.env?.SESSIONS) {
+ await deleteSession(platform.env.SESSIONS, locals.sessionId);
+ }
+ cookies.delete('session', { path: '/' });
+ return json({ success: true });
+};
diff --git a/pilot/src/routes/api/auth/github/+server.ts b/pilot/src/routes/api/auth/github/+server.ts
new file mode 100644
index 00000000..7747775e
--- /dev/null
+++ b/pilot/src/routes/api/auth/github/+server.ts
@@ -0,0 +1,41 @@
+import { redirect } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { handleGitHubCallback } from '$lib/server/auth';
+
+// GET /api/auth/github - Start GitHub OAuth flow
+export const GET: RequestHandler = async ({ url, platform, cookies }) => {
+ const env = platform?.env;
+ if (!env?.GITHUB_CLIENT_ID || !env?.GITHUB_CLIENT_SECRET) {
+ return new Response('GitHub OAuth not configured', { status: 500 });
+ }
+
+ const code = url.searchParams.get('code');
+
+ if (!code) {
+ // Redirect to GitHub OAuth
+ const authUrl = new URL('https://github.com/login/oauth/authorize');
+ authUrl.searchParams.set('client_id', env.GITHUB_CLIENT_ID);
+ authUrl.searchParams.set('redirect_uri', `${url.origin}/api/auth/github`);
+ authUrl.searchParams.set('scope', 'user:email');
+ throw redirect(302, authUrl.toString());
+ }
+
+ // Handle callback
+ const { sessionId } = await handleGitHubCallback(
+ env.DB,
+ env.SESSIONS,
+ code,
+ env.GITHUB_CLIENT_ID,
+ env.GITHUB_CLIENT_SECRET,
+ );
+
+ cookies.set('session', sessionId, {
+ path: '/',
+ httpOnly: true,
+ secure: url.protocol === 'https:',
+ sameSite: 'lax',
+ maxAge: 60 * 60 * 24 * 30,
+ });
+
+ throw redirect(302, '/');
+};
diff --git a/pilot/src/routes/api/auth/github/device/+server.ts b/pilot/src/routes/api/auth/github/device/+server.ts
new file mode 100644
index 00000000..318a86b1
--- /dev/null
+++ b/pilot/src/routes/api/auth/github/device/+server.ts
@@ -0,0 +1,43 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+
+// POST /api/auth/github/device - Start GitHub device flow
+export const POST: RequestHandler = async ({ locals, platform }) => {
+ if (!locals.user) throw error(401);
+
+ const clientId = platform?.env?.GITHUB_CLIENT_ID;
+ if (!clientId) throw error(500, 'GitHub OAuth not configured');
+
+ const response = await fetch('https://github.com/login/device/code', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ body: JSON.stringify({
+ client_id: clientId,
+ scope: 'repo',
+ }),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw error(502, `GitHub device flow failed: ${text}`);
+ }
+
+ const data = await response.json() as {
+ device_code: string;
+ user_code: string;
+ verification_uri: string;
+ expires_in: number;
+ interval: number;
+ };
+
+ return json({
+ device_code: data.device_code,
+ user_code: data.user_code,
+ verification_uri: data.verification_uri,
+ expires_in: data.expires_in,
+ interval: data.interval,
+ });
+};
diff --git a/pilot/src/routes/api/auth/github/device/poll/+server.ts b/pilot/src/routes/api/auth/github/device/poll/+server.ts
new file mode 100644
index 00000000..5f9f93f6
--- /dev/null
+++ b/pilot/src/routes/api/auth/github/device/poll/+server.ts
@@ -0,0 +1,61 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+
+// POST /api/auth/github/device/poll - Poll for device flow completion
+export const POST: RequestHandler = async ({ request, locals, platform }) => {
+ if (!locals.user) throw error(401);
+
+ const env = platform?.env;
+ const clientId = env?.GITHUB_CLIENT_ID;
+ if (!clientId) throw error(500, 'GitHub OAuth not configured');
+
+ const { device_code } = await request.json() as { device_code: string };
+ if (!device_code) throw error(400, 'device_code required');
+
+ const response = await fetch('https://github.com/login/oauth/access_token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ body: JSON.stringify({
+ client_id: clientId,
+ device_code,
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
+ }),
+ });
+
+ if (!response.ok) {
+ throw error(502, 'GitHub token exchange failed');
+ }
+
+ const data = await response.json() as {
+ access_token?: string;
+ token_type?: string;
+ scope?: string;
+ error?: string;
+ error_description?: string;
+ };
+
+ if (data.error) {
+ // authorization_pending and slow_down are expected during polling
+ if (data.error === 'authorization_pending' || data.error === 'slow_down') {
+ return json({ status: 'pending' });
+ }
+ return json({ status: 'error', error: data.error, error_description: data.error_description });
+ }
+
+ if (!data.access_token) {
+ return json({ status: 'error', error: 'no_token' });
+ }
+
+ // Store token in KV keyed by user ID
+ const sessions = env?.SESSIONS as KVNamespace | undefined;
+ if (!sessions) throw error(500, 'KV not available');
+
+ await sessions.put(`github-token:${locals.user.id}`, data.access_token, {
+ expirationTtl: 365 * 24 * 60 * 60, // 1 year
+ });
+
+ return json({ status: 'complete' });
+};
diff --git a/pilot/src/routes/api/auth/github/status/+server.ts b/pilot/src/routes/api/auth/github/status/+server.ts
new file mode 100644
index 00000000..35e1b59a
--- /dev/null
+++ b/pilot/src/routes/api/auth/github/status/+server.ts
@@ -0,0 +1,32 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+
+// GET /api/auth/github/status - Check if user has valid GitHub token
+export const GET: RequestHandler = async ({ locals, platform }) => {
+ if (!locals.user) throw error(401);
+
+ const sessions = platform?.env?.SESSIONS as KVNamespace | undefined;
+ if (!sessions) throw error(500, 'KV not available');
+
+ const token = await sessions.get(`github-token:${locals.user.id}`);
+ if (!token) {
+ return json({ connected: false });
+ }
+
+ // Validate token against GitHub API
+ const response = await fetch('https://api.github.com/user', {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'User-Agent': 'TaskYou-Pilot',
+ },
+ });
+
+ if (!response.ok) {
+ // Token is invalid or expired — clean up
+ await sessions.delete(`github-token:${locals.user.id}`);
+ return json({ connected: false });
+ }
+
+ const user = await response.json() as { login: string; avatar_url: string };
+ return json({ connected: true, login: user.login, avatar_url: user.avatar_url });
+};
diff --git a/pilot/src/routes/api/auth/google/+server.ts b/pilot/src/routes/api/auth/google/+server.ts
new file mode 100644
index 00000000..fd6839f9
--- /dev/null
+++ b/pilot/src/routes/api/auth/google/+server.ts
@@ -0,0 +1,45 @@
+import { redirect } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { handleGoogleCallback } from '$lib/server/auth';
+
+// GET /api/auth/google - Start Google OAuth flow
+export const GET: RequestHandler = async ({ url, platform, cookies }) => {
+ const env = platform?.env;
+ if (!env?.GOOGLE_CLIENT_ID || !env?.GOOGLE_CLIENT_SECRET) {
+ return new Response('Google OAuth not configured', { status: 500 });
+ }
+
+ const code = url.searchParams.get('code');
+ const redirectUri = `${url.origin}/api/auth/google`;
+
+ if (!code) {
+ // Redirect to Google OAuth
+ const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
+ authUrl.searchParams.set('client_id', env.GOOGLE_CLIENT_ID);
+ authUrl.searchParams.set('redirect_uri', redirectUri);
+ authUrl.searchParams.set('response_type', 'code');
+ authUrl.searchParams.set('scope', 'openid email profile');
+ authUrl.searchParams.set('access_type', 'offline');
+ throw redirect(302, authUrl.toString());
+ }
+
+ // Handle callback
+ const { sessionId } = await handleGoogleCallback(
+ env.DB,
+ env.SESSIONS,
+ code,
+ env.GOOGLE_CLIENT_ID,
+ env.GOOGLE_CLIENT_SECRET,
+ redirectUri,
+ );
+
+ cookies.set('session', sessionId, {
+ path: '/',
+ httpOnly: true,
+ secure: url.protocol === 'https:',
+ sameSite: 'lax',
+ maxAge: 60 * 60 * 24 * 30, // 30 days
+ });
+
+ throw redirect(302, '/');
+};
diff --git a/pilot/src/routes/api/chats/+server.ts b/pilot/src/routes/api/chats/+server.ts
new file mode 100644
index 00000000..caebb1d9
--- /dev/null
+++ b/pilot/src/routes/api/chats/+server.ts
@@ -0,0 +1,20 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { listChats, createChat } from '$lib/server/db';
+
+export const GET: RequestHandler = async ({ locals, platform }) => {
+ const user = locals.user;
+ if (!user || !platform?.env?.DB) return json([], { status: 401 });
+
+ const chats = await listChats(platform.env.DB, user.id);
+ return json(chats);
+};
+
+export const POST: RequestHandler = async ({ locals, platform, request }) => {
+ const user = locals.user;
+ if (!user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 });
+
+ const data = await request.json() as { title?: string; model_id?: string };
+ const chat = await createChat(platform.env.DB, user.id, data);
+ return json(chat, { status: 201 });
+};
diff --git a/pilot/src/routes/api/chats/[id]/+server.ts b/pilot/src/routes/api/chats/[id]/+server.ts
new file mode 100644
index 00000000..286ab0bc
--- /dev/null
+++ b/pilot/src/routes/api/chats/[id]/+server.ts
@@ -0,0 +1,30 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { getChat, updateChat, deleteChat } from '$lib/server/db';
+
+export const GET: RequestHandler = async ({ locals, platform, params }) => {
+ const user = locals.user;
+ if (!user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 });
+
+ const chat = await getChat(platform.env.DB, user.id, params.id);
+ if (!chat) return json({ error: 'Not found' }, { status: 404 });
+ return json(chat);
+};
+
+export const PUT: RequestHandler = async ({ locals, platform, params, request }) => {
+ const user = locals.user;
+ if (!user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 });
+
+ const data = await request.json() as { title?: string; model_id?: string };
+ const chat = await updateChat(platform.env.DB, user.id, params.id, data);
+ if (!chat) return json({ error: 'Not found' }, { status: 404 });
+ return json(chat);
+};
+
+export const DELETE: RequestHandler = async ({ locals, platform, params }) => {
+ const user = locals.user;
+ if (!user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 });
+
+ await deleteChat(platform.env.DB, user.id, params.id);
+ return new Response(null, { status: 204 });
+};
diff --git a/pilot/src/routes/api/chats/[id]/messages/+server.ts b/pilot/src/routes/api/chats/[id]/messages/+server.ts
new file mode 100644
index 00000000..b5bcbf8c
--- /dev/null
+++ b/pilot/src/routes/api/chats/[id]/messages/+server.ts
@@ -0,0 +1,15 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { getChat, listMessages } from '$lib/server/db';
+
+export const GET: RequestHandler = async ({ locals, platform, params }) => {
+ const user = locals.user;
+ if (!user || !platform?.env?.DB) return json([], { status: 401 });
+
+ // Verify chat belongs to user
+ const chat = await getChat(platform.env.DB, user.id, params.id);
+ if (!chat) return json({ error: 'Not found' }, { status: 404 });
+
+ const messages = await listMessages(platform.env.DB, params.id);
+ return json(messages);
+};
diff --git a/pilot/src/routes/api/integrations/+server.ts b/pilot/src/routes/api/integrations/+server.ts
new file mode 100644
index 00000000..30217bef
--- /dev/null
+++ b/pilot/src/routes/api/integrations/+server.ts
@@ -0,0 +1,10 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { listIntegrations } from '$lib/server/db';
+
+export const GET: RequestHandler = async ({ locals, platform }) => {
+ if (!locals.user || !platform?.env?.DB) return json([], { status: 401 });
+
+ const integrations = await listIntegrations(platform.env.DB);
+ return json(integrations);
+};
diff --git a/pilot/src/routes/api/models/+server.ts b/pilot/src/routes/api/models/+server.ts
new file mode 100644
index 00000000..aecb5138
--- /dev/null
+++ b/pilot/src/routes/api/models/+server.ts
@@ -0,0 +1,10 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { listModels } from '$lib/server/db';
+
+export const GET: RequestHandler = async ({ platform }) => {
+ if (!platform?.env?.DB) return json([]);
+
+ const models = await listModels(platform.env.DB);
+ return json(models);
+};
diff --git a/pilot/src/routes/api/projects/+server.ts b/pilot/src/routes/api/projects/+server.ts
new file mode 100644
index 00000000..a7544b9b
--- /dev/null
+++ b/pilot/src/routes/api/projects/+server.ts
@@ -0,0 +1,19 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { listProjects, createProject, updateProject } from '$lib/server/db';
+
+// GET /api/projects
+export const GET: RequestHandler = async ({ locals, platform }) => {
+ if (!locals.user || !platform?.env?.DB) return json([], { status: 401 });
+ const projects = await listProjects(platform.env.DB, locals.user.id);
+ return json(projects);
+};
+
+// POST /api/projects — create project record (user clicks Start to provision)
+export const POST: RequestHandler = async ({ locals, platform, request }) => {
+ if (!locals.user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 });
+
+ const data = (await request.json()) as { name?: string; instructions?: string; color?: string; github_repo?: string; github_branch?: string };
+ const project = await createProject(platform.env.DB, locals.user.id, data);
+ return json(project, { status: 201 });
+};
diff --git a/pilot/src/routes/api/projects/[id]/+server.ts b/pilot/src/routes/api/projects/[id]/+server.ts
new file mode 100644
index 00000000..4904b296
--- /dev/null
+++ b/pilot/src/routes/api/projects/[id]/+server.ts
@@ -0,0 +1,31 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { getProjectById, updateProject, deleteProject } from '$lib/server/db';
+
+// GET /api/projects/:id
+export const GET: RequestHandler = async ({ params, locals, platform }) => {
+ if (!locals.user || !platform?.env?.DB) throw error(401);
+
+ const project = await getProjectById(platform.env.DB, params.id);
+ if (!project) throw error(404, 'Project not found');
+ return json(project);
+};
+
+// PUT /api/projects/:id
+export const PUT: RequestHandler = async ({ params, request, locals, platform }) => {
+ if (!locals.user || !platform?.env?.DB) throw error(401);
+
+ const data = await request.json() as { name?: string; instructions?: string; color?: string; github_repo?: string; github_branch?: string };
+ const project = await updateProject(platform.env.DB, params.id, data);
+ if (!project) throw error(404, 'Project not found');
+ return json(project);
+};
+
+// DELETE /api/projects/:id
+export const DELETE: RequestHandler = async ({ params, locals, platform }) => {
+ if (!locals.user || !platform?.env?.DB) throw error(401);
+
+ const deleted = await deleteProject(platform.env.DB, params.id);
+ if (!deleted) throw error(404, 'Project not found');
+ return new Response(null, { status: 204 });
+};
diff --git a/pilot/src/routes/api/settings/+server.ts b/pilot/src/routes/api/settings/+server.ts
new file mode 100644
index 00000000..671f1bba
--- /dev/null
+++ b/pilot/src/routes/api/settings/+server.ts
@@ -0,0 +1,21 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { getSettings, updateSettings } from '$lib/server/db';
+
+// GET /api/settings
+export const GET: RequestHandler = async ({ locals, platform }) => {
+ const user = locals.user!;
+ const db = platform!.env.DB;
+ const settings = await getSettings(db, user.id);
+ return json(settings);
+};
+
+// PUT /api/settings
+export const PUT: RequestHandler = async ({ request, locals, platform }) => {
+ const user = locals.user!;
+ const db = platform!.env.DB;
+ const data = await request.json() as Record;
+
+ await updateSettings(db, user.id, data);
+ return json({ success: true });
+};
diff --git a/pilot/src/routes/api/tasks/+server.ts b/pilot/src/routes/api/tasks/+server.ts
new file mode 100644
index 00000000..bc3d4b3b
--- /dev/null
+++ b/pilot/src/routes/api/tasks/+server.ts
@@ -0,0 +1,46 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { listTasks, createTask } from '$lib/server/db';
+
+// GET /api/tasks
+export const GET: RequestHandler = async ({ url, locals, platform }) => {
+ const user = locals.user!;
+ const db = platform!.env.DB;
+
+ const options = {
+ status: url.searchParams.get('status') || undefined,
+ project_id: url.searchParams.get('project_id') || undefined,
+ type: url.searchParams.get('type') || undefined,
+ includeClosed: url.searchParams.get('all') === 'true',
+ };
+
+ const tasks = await listTasks(db, user.id, options);
+ return json(tasks);
+};
+
+// POST /api/tasks
+export const POST: RequestHandler = async ({ request, locals, platform }) => {
+ const user = locals.user!;
+ const db = platform!.env.DB;
+
+ const data = await request.json() as { title?: string; body?: string; type?: string; project_id?: string; chat_id?: string };
+
+ if (!data.title) {
+ return json({ error: 'Title is required' }, { status: 400 });
+ }
+
+ try {
+ const task = await createTask(db, user.id, {
+ title: data.title,
+ body: data.body,
+ type: data.type,
+ project_id: data.project_id,
+ chat_id: data.chat_id,
+ });
+
+ return json(task, { status: 201 });
+ } catch (e) {
+ console.error('Task creation failed:', e);
+ return json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 });
+ }
+};
diff --git a/pilot/src/routes/api/tasks/[id]/+server.ts b/pilot/src/routes/api/tasks/[id]/+server.ts
new file mode 100644
index 00000000..faa22aaf
--- /dev/null
+++ b/pilot/src/routes/api/tasks/[id]/+server.ts
@@ -0,0 +1,67 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { getTask, updateTask, deleteTask } from '$lib/server/db';
+
+// GET /api/tasks/:id
+export const GET: RequestHandler = async ({ params, locals, platform }) => {
+ const user = locals.user!;
+ const db = platform!.env.DB;
+ const taskId = parseInt(params.id);
+
+ const task = await getTask(db, user.id, taskId);
+ if (!task) {
+ return json({ error: 'Task not found' }, { status: 404 });
+ }
+
+ return json(task);
+};
+
+// PUT /api/tasks/:id
+export const PUT: RequestHandler = async ({ params, request, locals, platform }) => {
+ const user = locals.user!;
+ const db = platform!.env.DB;
+ const taskId = parseInt(params.id);
+
+ const data = await request.json() as { title?: string; body?: string; status?: import('$lib/types').TaskStatus; type?: string; project_id?: string };
+ const task = await updateTask(db, user.id, taskId, data);
+ if (!task) {
+ return json({ error: 'Task not found' }, { status: 404 });
+ }
+
+ // Trigger execution when task is queued
+ if (data.status === 'queued') {
+ const env = platform!.env as any;
+ const apiKey = env.ANTHROPIC_API_KEY as string;
+ if (apiKey) {
+ platform!.context.waitUntil(
+ import('$lib/server/agent').then(({ executeTask }) =>
+ executeTask({
+ db: env.DB,
+ sandbox: env.SANDBOX,
+ sessions: env.SESSIONS,
+ storage: env.STORAGE,
+ apiKey,
+ userId: user.id,
+ taskId,
+ })
+ )
+ );
+ }
+ }
+
+ return json(task);
+};
+
+// DELETE /api/tasks/:id
+export const DELETE: RequestHandler = async ({ params, locals, platform }) => {
+ const user = locals.user!;
+ const db = platform!.env.DB;
+ const taskId = parseInt(params.id);
+
+ const deleted = await deleteTask(db, user.id, taskId);
+ if (!deleted) {
+ return json({ error: 'Task not found' }, { status: 404 });
+ }
+
+ return new Response(null, { status: 204 });
+};
diff --git a/pilot/src/routes/api/tasks/[id]/file/+server.ts b/pilot/src/routes/api/tasks/[id]/file/+server.ts
new file mode 100644
index 00000000..3a252a2b
--- /dev/null
+++ b/pilot/src/routes/api/tasks/[id]/file/+server.ts
@@ -0,0 +1,42 @@
+import type { RequestHandler } from './$types';
+import { getTask } from '$lib/server/db';
+
+// GET /api/tasks/:id/file?path=foo.js — read file content from R2 storage
+export const GET: RequestHandler = async ({ params, url, locals, platform }) => {
+ const user = locals.user!;
+ const db = platform!.env.DB;
+ const taskId = parseInt(params.id);
+ const filePath = url.searchParams.get('path');
+
+ if (!filePath) {
+ return new Response(JSON.stringify({ error: 'path parameter required' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Verify task belongs to user
+ const task = await getTask(db, user.id, taskId);
+ if (!task) {
+ return new Response(JSON.stringify({ error: 'Task not found' }), {
+ status: 404,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
+ const storage = platform!.env.STORAGE as R2Bucket;
+ const r2Key = `tasks/${taskId}/${filePath}`;
+ const object = await storage.get(r2Key);
+
+ if (!object) {
+ return new Response(JSON.stringify({ error: 'File not found' }), {
+ status: 404,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
+ const contentType = object.httpMetadata?.contentType || 'text/plain';
+ return new Response(object.body, {
+ headers: { 'Content-Type': `${contentType}; charset=utf-8` },
+ });
+};
diff --git a/pilot/src/routes/api/tasks/[id]/files/+server.ts b/pilot/src/routes/api/tasks/[id]/files/+server.ts
new file mode 100644
index 00000000..af203339
--- /dev/null
+++ b/pilot/src/routes/api/tasks/[id]/files/+server.ts
@@ -0,0 +1,13 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { listTaskFiles } from '$lib/server/db';
+
+// GET /api/tasks/:id/files — list files for a task
+export const GET: RequestHandler = async ({ params, locals, platform }) => {
+ const user = locals.user!;
+ const db = platform!.env.DB;
+ const taskId = parseInt(params.id);
+
+ const files = await listTaskFiles(db, user.id, taskId);
+ return json(files);
+};
diff --git a/pilot/src/routes/api/tasks/[id]/logs/+server.ts b/pilot/src/routes/api/tasks/[id]/logs/+server.ts
new file mode 100644
index 00000000..95591610
--- /dev/null
+++ b/pilot/src/routes/api/tasks/[id]/logs/+server.ts
@@ -0,0 +1,14 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { getTaskLogs } from '$lib/server/db';
+
+// GET /api/tasks/:id/logs
+export const GET: RequestHandler = async ({ params, url, locals, platform }) => {
+ const user = locals.user!;
+ const db = platform!.env.DB;
+ const taskId = parseInt(params.id);
+ const limit = parseInt(url.searchParams.get('limit') || '200');
+
+ const logs = await getTaskLogs(db, user.id, taskId, limit);
+ return json(logs);
+};
diff --git a/pilot/src/routes/api/workspaces/+server.ts b/pilot/src/routes/api/workspaces/+server.ts
new file mode 100644
index 00000000..b159f1bb
--- /dev/null
+++ b/pilot/src/routes/api/workspaces/+server.ts
@@ -0,0 +1,23 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { listWorkspaces, createWorkspace } from '$lib/server/db';
+
+export const GET: RequestHandler = async ({ locals, platform }) => {
+ const user = locals.user!;
+ const db = platform!.env.DB;
+ const workspaces = await listWorkspaces(db, user.id);
+ return json(workspaces);
+};
+
+export const POST: RequestHandler = async ({ request, locals, platform }) => {
+ const user = locals.user!;
+ const db = platform!.env.DB;
+ const data = await request.json() as { name?: string };
+
+ if (!data.name) {
+ return json({ error: 'Name is required' }, { status: 400 });
+ }
+
+ const workspace = await createWorkspace(db, user.id, { name: data.name });
+ return json(workspace, { status: 201 });
+};
diff --git a/pilot/src/routes/api/workspaces/[id]/+server.ts b/pilot/src/routes/api/workspaces/[id]/+server.ts
new file mode 100644
index 00000000..9e1f5477
--- /dev/null
+++ b/pilot/src/routes/api/workspaces/[id]/+server.ts
@@ -0,0 +1,37 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { getWorkspace, updateWorkspace, deleteWorkspace } from '$lib/server/db';
+
+export const GET: RequestHandler = async ({ params, platform }) => {
+ const db = platform!.env.DB;
+ const workspace = await getWorkspace(db, params.id);
+ if (!workspace) {
+ return json({ error: 'Workspace not found' }, { status: 404 });
+ }
+ return json(workspace);
+};
+
+export const PUT: RequestHandler = async ({ params, request, platform }) => {
+ const db = platform!.env.DB;
+ const data = await request.json() as { name?: string; autonomous_enabled?: boolean; weekly_budget_cents?: number };
+
+ const workspace = await updateWorkspace(db, params.id, data);
+ if (!workspace) {
+ return json({ error: 'Workspace not found' }, { status: 404 });
+ }
+ return json(workspace);
+};
+
+export const DELETE: RequestHandler = async ({ params, platform }) => {
+ const db = platform!.env.DB;
+
+ if (params.id === 'default') {
+ return json({ error: 'Cannot delete the default workspace' }, { status: 400 });
+ }
+
+ const deleted = await deleteWorkspace(db, params.id);
+ if (!deleted) {
+ return json({ error: 'Workspace not found' }, { status: 404 });
+ }
+ return new Response(null, { status: 204 });
+};
diff --git a/pilot/src/routes/preview/tasks/[id]/[...path]/+server.ts b/pilot/src/routes/preview/tasks/[id]/[...path]/+server.ts
new file mode 100644
index 00000000..279270fd
--- /dev/null
+++ b/pilot/src/routes/preview/tasks/[id]/[...path]/+server.ts
@@ -0,0 +1,52 @@
+import type { RequestHandler } from './$types';
+
+// GET /preview/tasks/:id/* — serve task files from R2 with correct MIME types
+// This enables iframe previews where relative paths (style.css, script.js) just work.
+export const GET: RequestHandler = async ({ params, platform }) => {
+ const taskId = params.id;
+ const filePath = params.path || 'index.html';
+
+ const storage = platform!.env.STORAGE as R2Bucket;
+ const r2Key = `tasks/${taskId}/${filePath}`;
+ const object = await storage.get(r2Key);
+
+ if (!object) {
+ // Try index.html for directory-style requests
+ if (!filePath.includes('.')) {
+ const indexKey = `tasks/${taskId}/${filePath}/index.html`;
+ const indexObject = await storage.get(indexKey);
+ if (indexObject) {
+ return new Response(indexObject.body, {
+ headers: {
+ 'Content-Type': 'text/html; charset=utf-8',
+ 'Cache-Control': 'no-cache',
+ },
+ });
+ }
+ }
+ return new Response('Not found', { status: 404 });
+ }
+
+ const contentType = object.httpMetadata?.contentType || guessMime(filePath);
+ return new Response(object.body, {
+ headers: {
+ 'Content-Type': `${contentType}; charset=utf-8`,
+ 'Cache-Control': 'no-cache',
+ },
+ });
+};
+
+function guessMime(path: string): string {
+ const ext = path.split('.').pop()?.toLowerCase() || '';
+ const types: Record = {
+ html: 'text/html', htm: 'text/html', css: 'text/css',
+ js: 'application/javascript', mjs: 'application/javascript',
+ json: 'application/json', svg: 'image/svg+xml',
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
+ gif: 'image/gif', webp: 'image/webp', ico: 'image/x-icon',
+ woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf',
+ md: 'text/markdown', txt: 'text/plain', xml: 'application/xml',
+ py: 'text/plain', rb: 'text/plain', sh: 'text/plain',
+ };
+ return types[ext] || 'application/octet-stream';
+}
diff --git a/pilot/static/icon.svg b/pilot/static/icon.svg
new file mode 100644
index 00000000..30b2ef13
--- /dev/null
+++ b/pilot/static/icon.svg
@@ -0,0 +1,6 @@
+
diff --git a/pilot/svelte.config.js b/pilot/svelte.config.js
new file mode 100644
index 00000000..a7823b30
--- /dev/null
+++ b/pilot/svelte.config.js
@@ -0,0 +1,12 @@
+import adapter from '@sveltejs/adapter-cloudflare';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: vitePreprocess(),
+ kit: {
+ adapter: adapter()
+ }
+};
+
+export default config;
diff --git a/pilot/tsconfig.json b/pilot/tsconfig.json
new file mode 100644
index 00000000..a8f10c8e
--- /dev/null
+++ b/pilot/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+}
diff --git a/pilot/vite.config.ts b/pilot/vite.config.ts
new file mode 100644
index 00000000..6312f75b
--- /dev/null
+++ b/pilot/vite.config.ts
@@ -0,0 +1,16 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import tailwindcss from '@tailwindcss/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [tailwindcss(), sveltekit()],
+ resolve: {
+ conditions: ['browser', 'workerd', 'worker'],
+ },
+ ssr: {
+ resolve: {
+ conditions: ['workerd', 'worker', 'node'],
+ externalConditions: ['workerd', 'worker', 'node'],
+ },
+ },
+});
diff --git a/pilot/worker-entry.js b/pilot/worker-entry.js
new file mode 100644
index 00000000..57b212d2
--- /dev/null
+++ b/pilot/worker-entry.js
@@ -0,0 +1,145 @@
+
+import { routeAgentRequest } from "agents";
+import { proxyToSandbox } from "@cloudflare/sandbox";
+// src/worker.js
+import { Server } from "./.svelte-kit/output/server/index.js";
+import { manifest, prerendered, base_path } from "./.svelte-kit/cloudflare-tmp/manifest.js";
+import { env } from "cloudflare:workers";
+
+// ../../node_modules/.pnpm/worktop@0.8.0-next.18/node_modules/worktop/cache/index.mjs
+async function e(e3, t2) {
+ let n2 = "string" != typeof t2 && "HEAD" === t2.method;
+ n2 && (t2 = new Request(t2, { method: "GET" }));
+ let r3 = await e3.match(t2);
+ return n2 && r3 && (r3 = new Response(null, r3)), r3;
+}
+function t(e3, t2, n2, o2) {
+ return ("string" == typeof t2 || "GET" === t2.method) && r(n2) && (n2.headers.has("Set-Cookie") && (n2 = new Response(n2.body, n2)).headers.append("Cache-Control", "private=Set-Cookie"), o2.waitUntil(e3.put(t2, n2.clone()))), n2;
+}
+var n = /* @__PURE__ */ new Set([200, 203, 204, 300, 301, 404, 405, 410, 414, 501]);
+function r(e3) {
+ if (!n.has(e3.status)) return false;
+ if (~(e3.headers.get("Vary") || "").indexOf("*")) return false;
+ let t2 = e3.headers.get("Cache-Control") || "";
+ return !/(private|no-cache|no-store)/i.test(t2);
+}
+function o(n2) {
+ return async function(r3, o2) {
+ let a = await e(n2, r3);
+ if (a) return a;
+ o2.defer(((e3) => {
+ t(n2, r3, e3, o2);
+ }));
+ };
+}
+
+// ../../node_modules/.pnpm/worktop@0.8.0-next.18/node_modules/worktop/cfw.cache/index.mjs
+var s = caches.default;
+var c = t.bind(0, s);
+var r2 = e.bind(0, s);
+var e2 = o.bind(0, s);
+
+// src/worker.js
+var server = new Server(manifest);
+var app_path = `/${manifest.appPath}`;
+var immutable = `${app_path}/immutable/`;
+var version_file = `${app_path}/version.json`;
+var origin;
+var initialized = server.init({
+ // @ts-expect-error env contains environment variables and bindings
+ env,
+ read: async (file) => {
+ const url = `${origin}/${file}`;
+ const response = await /** @type {{ ASSETS: { fetch: typeof fetch } }} */
+ env.ASSETS.fetch(
+ url
+ );
+ if (!response.ok) {
+ throw new Error(
+ `read(...) failed: could not fetch ${url} (${response.status} ${response.statusText})`
+ );
+ }
+ return response.body;
+ }
+});
+var worker_default = {
+ /**
+ * @param {Request} req
+ * @param {{ ASSETS: { fetch: typeof fetch } }} env
+ * @param {ExecutionContext} ctx
+ * @returns {Promise}
+ */
+ async fetch(req, env2, ctx) {
+ // Route agent WebSocket/HTTP requests before SvelteKit
+ const agentResponse = await routeAgentRequest(req, env2);
+ if (agentResponse) return agentResponse;
+
+ // Proxy sandbox preview URLs (after agent routing)
+ // Only relevant with custom domains for preview URL subdomains
+ try {
+ const sandboxResponse = await proxyToSandbox(req, env2);
+ if (sandboxResponse && sandboxResponse.status !== 404) return sandboxResponse;
+ } catch (e) {
+ // Sandbox proxy not available — continue to SvelteKit
+ }
+ if (!origin) {
+ origin = new URL(req.url).origin;
+ }
+ await initialized;
+ let pragma = req.headers.get("cache-control") || "";
+ let res = !pragma.includes("no-cache") && await r2(req);
+ if (res) return res;
+ let { pathname, search } = new URL(req.url);
+ try {
+ pathname = decodeURIComponent(pathname);
+ } catch {
+ }
+ const stripped_pathname = pathname.replace(/\/$/, "");
+ let is_static_asset = false;
+ const filename = stripped_pathname.slice(base_path.length + 1);
+ if (filename) {
+ is_static_asset = manifest.assets.has(filename) || manifest.assets.has(filename + "/index.html") || filename in manifest._.server_assets || filename + "/index.html" in manifest._.server_assets;
+ }
+ let location = pathname.at(-1) === "/" ? stripped_pathname : pathname + "/";
+ if (is_static_asset || prerendered.has(pathname) || pathname === version_file || pathname.startsWith(immutable)) {
+ res = await env2.ASSETS.fetch(req);
+ } else if (location && prerendered.has(location)) {
+ if (search) location += search;
+ res = new Response("", {
+ status: 308,
+ headers: {
+ location
+ }
+ });
+ } else {
+ res = await server.respond(req, {
+ platform: {
+ env: env2,
+ ctx,
+ context: ctx,
+ // deprecated in favor of ctx
+ // @ts-expect-error webworker types from worktop are not compatible with Cloudflare Workers types
+ caches,
+ // @ts-expect-error the type is correct but ts is confused because platform.cf uses the type from index.ts while req.cf uses the type from index.d.ts
+ cf: req.cf
+ },
+ getClientAddress() {
+ return (
+ /** @type {string} */
+ req.headers.get("cf-connecting-ip")
+ );
+ }
+ });
+ }
+ pragma = res.headers.get("cache-control") || "";
+ return pragma && res.status < 400 ? c(req, res, ctx) : res;
+ }
+};
+export {
+ worker_default as default
+};
+
+// Re-export agent classes for Durable Object, Workflow, and Container bindings
+export { TaskYouAgent } from "./src/lib/server/agent.ts";
+export { TaskExecutionWorkflow } from "./src/lib/server/workflow.ts";
+export { Sandbox } from "@cloudflare/sandbox";
diff --git a/pilot/wrangler.jsonc b/pilot/wrangler.jsonc
new file mode 100644
index 00000000..0c61f43e
--- /dev/null
+++ b/pilot/wrangler.jsonc
@@ -0,0 +1,73 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "taskyou-pilot",
+ "main": "worker-entry.js",
+ "compatibility_date": "2025-12-01",
+ "compatibility_flags": ["nodejs_compat"],
+ "assets": {
+ "directory": ".svelte-kit/cloudflare",
+ "binding": "ASSETS"
+ },
+ "workflows": [
+ {
+ "name": "task-execution",
+ "binding": "TASK_WORKFLOW",
+ "class_name": "TaskExecutionWorkflow"
+ }
+ ],
+ "containers": [
+ {
+ "class_name": "Sandbox",
+ "image": "./Dockerfile.sandbox",
+ "instance_type": "lite",
+ "max_instances": 5
+ }
+ ],
+ "durable_objects": {
+ "bindings": [
+ {
+ "name": "TASKYOU_AGENT",
+ "class_name": "TaskYouAgent"
+ },
+ {
+ "name": "SANDBOX",
+ "class_name": "Sandbox"
+ }
+ ]
+ },
+ "migrations": [
+ {
+ "tag": "v1",
+ "new_sqlite_classes": ["Sandbox"]
+ },
+ {
+ "tag": "v2",
+ "new_sqlite_classes": ["TaskYouAgent"]
+ }
+ ],
+ "d1_databases": [
+ {
+ "binding": "DB",
+ "database_name": "taskyou-pilot-db",
+ "database_id": "00fd9e62-cb4c-411a-a1ca-6e3777cba0fd"
+ }
+ ],
+ "kv_namespaces": [
+ {
+ "binding": "SESSIONS",
+ "id": "97030b57bfd9439cad190747ad778b81"
+ }
+ ],
+ "vars": {
+ "ENVIRONMENT": "production"
+ },
+ // Secrets (set via `wrangler secret put`):
+ // ANTHROPIC_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET,
+ // GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET
+ "r2_buckets": [
+ {
+ "binding": "STORAGE",
+ "bucket_name": "taskyou-pilot-storage"
+ }
+ ]
+}