diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 39cd97e..4dd74d5 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -27,6 +27,9 @@ jobs:
api:
- 'apps/api/**'
- 'packages/**'
+ harness:
+ - 'apps/harness/**'
+ - 'packages/**'
lint-and-test:
runs-on: ubuntu-latest
diff --git a/apps/harness/Dockerfile b/apps/harness/Dockerfile
new file mode 100644
index 0000000..b470d6b
--- /dev/null
+++ b/apps/harness/Dockerfile
@@ -0,0 +1,37 @@
+FROM node:20-alpine AS base
+
+FROM base AS deps
+RUN apk add --no-cache libc6-compat
+WORKDIR /app
+COPY package.json ./
+RUN npm install
+
+FROM base AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+RUN npm run build
+
+FROM base AS runner
+WORKDIR /app
+ENV NODE_ENV=production
+
+# System tools needed by agent executors
+RUN apk add --no-cache git github-cli
+
+# Agent CLIs (installed globally before dropping to non-root)
+RUN npm install -g @anthropic-ai/claude-code @openai/codex opencode
+
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+# Workspace directory for git worktrees (ephemeral)
+RUN mkdir -p /data/harness && chown nextjs:nodejs /data/harness
+
+COPY --from=builder /app/public ./public
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+USER nextjs
+EXPOSE 3100
+ENV PORT=3100
+CMD ["node", "server.js"]
diff --git a/apps/harness/k8s/base/deployment.yaml b/apps/harness/k8s/base/deployment.yaml
new file mode 100644
index 0000000..a4d0af0
--- /dev/null
+++ b/apps/harness/k8s/base/deployment.yaml
@@ -0,0 +1,67 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: harness
+ labels:
+ app: harness
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: harness
+ template:
+ metadata:
+ labels:
+ app: harness
+ spec:
+ imagePullSecrets:
+ - name: ghcr-pull-secret
+ containers:
+ - name: harness
+ image: ghcr.io/lazorgurl/homelab-harness:latest
+ ports:
+ - containerPort: 3100
+ env:
+ - name: HARNESS_WORK_DIR
+ value: /data/harness
+ - name: CLAUDE_CONFIG_DIR
+ value: /secrets/claude
+ - name: OPENCODE_CONFIG_DIR
+ value: /secrets/opencode
+ volumeMounts:
+ - name: workspace
+ mountPath: /data/harness
+ - name: claude-credentials
+ mountPath: /secrets/claude
+ readOnly: true
+ - name: opencode-credentials
+ mountPath: /secrets/opencode
+ readOnly: true
+ resources:
+ requests:
+ memory: 256Mi
+ cpu: 100m
+ limits:
+ memory: 1Gi
+ readinessProbe:
+ httpGet:
+ path: /api/health
+ port: 3100
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ livenessProbe:
+ httpGet:
+ path: /api/health
+ port: 3100
+ initialDelaySeconds: 15
+ periodSeconds: 20
+ volumes:
+ - name: workspace
+ emptyDir:
+ sizeLimit: 2Gi
+ - name: claude-credentials
+ secret:
+ secretName: harness-claude-credentials
+ - name: opencode-credentials
+ secret:
+ secretName: harness-opencode-credentials
diff --git a/apps/harness/k8s/base/harness-claude-credentials-sealed.yaml b/apps/harness/k8s/base/harness-claude-credentials-sealed.yaml
new file mode 100644
index 0000000..d131fe5
--- /dev/null
+++ b/apps/harness/k8s/base/harness-claude-credentials-sealed.yaml
@@ -0,0 +1,13 @@
+---
+apiVersion: bitnami.com/v1alpha1
+kind: SealedSecret
+metadata:
+ name: harness-claude-credentials
+ namespace: apps
+spec:
+ encryptedData:
+ .credentials.json: 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
+ template:
+ metadata:
+ name: harness-claude-credentials
+ namespace: apps
diff --git a/apps/harness/k8s/base/harness-opencode-credentials-sealed.yaml b/apps/harness/k8s/base/harness-opencode-credentials-sealed.yaml
new file mode 100644
index 0000000..34a3178
--- /dev/null
+++ b/apps/harness/k8s/base/harness-opencode-credentials-sealed.yaml
@@ -0,0 +1,13 @@
+---
+apiVersion: bitnami.com/v1alpha1
+kind: SealedSecret
+metadata:
+ name: harness-opencode-credentials
+ namespace: apps
+spec:
+ encryptedData:
+ auth.json: AgBNDx8eNC6AiMRrZn0JZIJACWL7Wg/JhbeuPiNdsLOpnc3db7vrI+25AjIwk7f+EMh1XKDf9QMbPJAyC3/ZiO3hJ45JJjuAmb/QYH9c+Zgnsms/VhurMz5pYvaN04B5J6lzJusILjU2sqQjaHL5ARPh1jrqrXnk+pRY/WG4vZVGrVZ/J9rvswfQuXwPdpD2KBCia3rR44WgpBxRT+bIQso2FFWYCLTdRPz7HFH+jSuFTEA0MujWZj4vCyf8w+5kZ1fwWBze2pAuj3iTLl3+TX0TMJhS7G/wARbxEYxrSntBCK6LsAByn7Ul400rcbOLugPbe9QFJrnxyjvjVeoQrjP2x1yIYWo8UHy5iExCVR+wkTD8EDQceqkqZ4KGoIa5GZpqdRMl20PjhPXfvX2XgTJjyOL2uhszRD/8z/WPVEM2gDSdmI7KUGdxmnPcqEyS6cVwp0DuSoaCWmN3GxS8EvrQVnlLQNK6RWsibGmmYwt1O7PxE4T+8CEcRwfUkdXRtVqMURnr9aIvAhl+judMkxPAdh68s6L8WehHAbPyYBeA29FVKO3JsXhMoFfQGugCxxBvPH50GOHh0Ncxdvz2wzH/of+QP4vmkddV7JbQMMruLSEzF90pIk7pLDR0Vhd9OxehKeAeAHot7DqH21VG8UnqUn+NZstCCDtB57IY5JakSrcE1+pPSMR13a5PQ/lNYWjgFT5HVF/cGMYoUG74zo2BzgJK4k3S1yDvTANxqeQnO+ybxITVh4Azo1WE151t2Fsh2SmpKsADBwuNFQprJRz1OxZYphNMNnI7KlSua1+KlljkotFItLwEEmsLgO3/zm0HIRNPbZHzX4/d5+jSIb72QdVLiiVPSM4KtkbFVbuoHPdnsqwf9pzTIwSIGkTj6EBqECIBACaytwAQUf3ZmvAKXCTa34CxyXlfzQHgc7Hyzv+1u9csO49H+P7I8iMqr43NpUliDz9mcu+0964209DpBsaVNQBp9GUB3dnrraDG/bpVOzwljgZnwaV0WKfJSvk2uoMkKOZXEIqvX1A=
+ template:
+ metadata:
+ name: harness-opencode-credentials
+ namespace: apps
diff --git a/apps/harness/k8s/base/kustomization.yaml b/apps/harness/k8s/base/kustomization.yaml
new file mode 100644
index 0000000..ce0b673
--- /dev/null
+++ b/apps/harness/k8s/base/kustomization.yaml
@@ -0,0 +1,7 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - deployment.yaml
+ - service.yaml
+ - harness-claude-credentials-sealed.yaml
+ - harness-opencode-credentials-sealed.yaml
diff --git a/apps/harness/k8s/base/service.yaml b/apps/harness/k8s/base/service.yaml
new file mode 100644
index 0000000..626e160
--- /dev/null
+++ b/apps/harness/k8s/base/service.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: harness
+spec:
+ type: ClusterIP
+ ports:
+ - port: 80
+ targetPort: 3100
+ protocol: TCP
+ selector:
+ app: harness
diff --git a/apps/harness/k8s/overlays/preview/kustomization.yaml b/apps/harness/k8s/overlays/preview/kustomization.yaml
new file mode 100644
index 0000000..4b2a126
--- /dev/null
+++ b/apps/harness/k8s/overlays/preview/kustomization.yaml
@@ -0,0 +1,15 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - ../../base
+patches:
+ - target:
+ kind: Deployment
+ name: harness
+ patch: |
+ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+ name: harness
+ spec:
+ replicas: 1
diff --git a/apps/harness/k8s/overlays/production/kustomization.yaml b/apps/harness/k8s/overlays/production/kustomization.yaml
new file mode 100644
index 0000000..4a80628
--- /dev/null
+++ b/apps/harness/k8s/overlays/production/kustomization.yaml
@@ -0,0 +1,19 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+- ../../base
+patches:
+- patch: |
+ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+ name: harness
+ spec:
+ replicas: 1
+ target:
+ kind: Deployment
+ name: harness
+images:
+- name: ghcr.io/lazorgurl/homelab-harness
+ newName: ghcr.io/lazorgurl/homelab-harness
+ newTag: latest
diff --git a/apps/harness/next-env.d.ts b/apps/harness/next-env.d.ts
new file mode 100644
index 0000000..830fb59
--- /dev/null
+++ b/apps/harness/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/harness/next.config.js b/apps/harness/next.config.js
new file mode 100644
index 0000000..c10e07d
--- /dev/null
+++ b/apps/harness/next.config.js
@@ -0,0 +1,6 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ output: "standalone",
+};
+
+module.exports = nextConfig;
diff --git a/apps/harness/package.json b/apps/harness/package.json
new file mode 100644
index 0000000..09f0fd9
--- /dev/null
+++ b/apps/harness/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@homelab/harness",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev --port 3100",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "test": "echo \"no tests yet\""
+ },
+ "dependencies": {
+ "next": "^15.1.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "yaml": "^2.7.0"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/apps/harness/src/app/api/agents/route.ts b/apps/harness/src/app/api/agents/route.ts
new file mode 100644
index 0000000..4e8e720
--- /dev/null
+++ b/apps/harness/src/app/api/agents/route.ts
@@ -0,0 +1,52 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ getAllAgentConfigs,
+ upsertAgentConfig,
+ deleteAgentConfig,
+ AGENT_RUNTIMES,
+ AgentConfig,
+} from "@/lib/agents";
+
+export async function GET() {
+ return NextResponse.json({
+ configs: getAllAgentConfigs(),
+ runtimes: Object.values(AGENT_RUNTIMES),
+ });
+}
+
+export async function POST(request: NextRequest) {
+ const body = await request.json();
+
+ if (!body.runtime || !body.modelId || !body.provider) {
+ return NextResponse.json(
+ { error: "runtime, modelId, and provider are required" },
+ { status: 400 }
+ );
+ }
+
+ if (!AGENT_RUNTIMES[body.runtime as keyof typeof AGENT_RUNTIMES]) {
+ return NextResponse.json(
+ { error: `runtime must be one of: ${Object.keys(AGENT_RUNTIMES).join(", ")}` },
+ { status: 400 }
+ );
+ }
+
+ const config: AgentConfig = {
+ id: body.id || `agent-${Date.now()}`,
+ name: body.name || `${body.runtime} · ${body.modelId}`,
+ runtime: body.runtime,
+ modelId: body.modelId,
+ provider: body.provider,
+ maxTokens: body.maxTokens,
+ env: body.env,
+ };
+
+ return NextResponse.json(upsertAgentConfig(config), { status: 201 });
+}
+
+export async function DELETE(request: NextRequest) {
+ const id = request.nextUrl.searchParams.get("id");
+ if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
+ deleteAgentConfig(id);
+ return NextResponse.json({ ok: true });
+}
diff --git a/apps/harness/src/app/api/health/route.ts b/apps/harness/src/app/api/health/route.ts
new file mode 100644
index 0000000..bedd1e3
--- /dev/null
+++ b/apps/harness/src/app/api/health/route.ts
@@ -0,0 +1,9 @@
+import { NextResponse } from "next/server";
+
+export async function GET() {
+ return NextResponse.json({
+ status: "ok",
+ service: "harness",
+ timestamp: new Date().toISOString(),
+ });
+}
diff --git a/apps/harness/src/app/api/models/curated/route.ts b/apps/harness/src/app/api/models/curated/route.ts
new file mode 100644
index 0000000..e730773
--- /dev/null
+++ b/apps/harness/src/app/api/models/curated/route.ts
@@ -0,0 +1,54 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ getCuratedModels,
+ getEnabledModels,
+ upsertCuratedModel,
+ removeCuratedModel,
+ toggleModelEnabled,
+ updateModelCost,
+ CuratedModel,
+} from "@/lib/model-store";
+
+export async function GET(request: NextRequest) {
+ const enabledOnly = request.nextUrl.searchParams.get("enabled") === "true";
+ return NextResponse.json(enabledOnly ? getEnabledModels() : getCuratedModels());
+}
+
+export async function POST(request: NextRequest) {
+ const body = await request.json();
+
+ if (body.action === "toggle" && body.id) {
+ const result = toggleModelEnabled(body.id);
+ if (!result) return NextResponse.json({ error: "not found" }, { status: 404 });
+ return NextResponse.json(result);
+ }
+
+ if (body.action === "update-cost" && body.id) {
+ const result = updateModelCost(body.id, body.costPer1kInput, body.costPer1kOutput);
+ if (!result) return NextResponse.json({ error: "not found" }, { status: 404 });
+ return NextResponse.json(result);
+ }
+
+ if (!body.id || !body.provider) {
+ return NextResponse.json({ error: "id and provider are required" }, { status: 400 });
+ }
+
+ const model: CuratedModel = {
+ id: body.id,
+ name: body.name || body.id,
+ provider: body.provider,
+ enabled: body.enabled ?? true,
+ contextWindow: body.contextWindow,
+ costPer1kInput: body.costPer1kInput,
+ costPer1kOutput: body.costPer1kOutput,
+ };
+
+ return NextResponse.json(upsertCuratedModel(model), { status: 201 });
+}
+
+export async function DELETE(request: NextRequest) {
+ const id = request.nextUrl.searchParams.get("id");
+ if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
+ removeCuratedModel(id);
+ return NextResponse.json({ ok: true });
+}
diff --git a/apps/harness/src/app/api/models/route.ts b/apps/harness/src/app/api/models/route.ts
new file mode 100644
index 0000000..984bd2a
--- /dev/null
+++ b/apps/harness/src/app/api/models/route.ts
@@ -0,0 +1,7 @@
+import { NextResponse } from "next/server";
+import { fetchAllModels } from "@/lib/model-providers";
+
+export async function GET() {
+ const models = await fetchAllModels();
+ return NextResponse.json(models);
+}
diff --git a/apps/harness/src/app/api/models/usage/route.ts b/apps/harness/src/app/api/models/usage/route.ts
new file mode 100644
index 0000000..f05c5ea
--- /dev/null
+++ b/apps/harness/src/app/api/models/usage/route.ts
@@ -0,0 +1,9 @@
+import { NextResponse } from "next/server";
+import { getUsageSummary, getUsageLog } from "@/lib/model-store";
+
+export async function GET() {
+ return NextResponse.json({
+ summary: getUsageSummary(),
+ log: getUsageLog(),
+ });
+}
diff --git a/apps/harness/src/app/api/orchestrator/route.ts b/apps/harness/src/app/api/orchestrator/route.ts
new file mode 100644
index 0000000..a4779b4
--- /dev/null
+++ b/apps/harness/src/app/api/orchestrator/route.ts
@@ -0,0 +1,31 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ startOrchestrator,
+ stopOrchestrator,
+ isRunning,
+ currentRunningTaskId,
+} from "@/lib/orchestrator";
+
+export async function GET() {
+ return NextResponse.json({
+ running: isRunning(),
+ currentTaskId: currentRunningTaskId(),
+ });
+}
+
+export async function POST(request: NextRequest) {
+ const body = await request.json();
+ const action = body.action as string;
+
+ if (action === "start") {
+ startOrchestrator();
+ return NextResponse.json({ ok: true, running: true });
+ }
+
+ if (action === "stop") {
+ stopOrchestrator();
+ return NextResponse.json({ ok: true, running: false });
+ }
+
+ return NextResponse.json({ error: "Unknown action. Use 'start' or 'stop'" }, { status: 400 });
+}
diff --git a/apps/harness/src/app/api/repos/search/route.ts b/apps/harness/src/app/api/repos/search/route.ts
new file mode 100644
index 0000000..ba1e821
--- /dev/null
+++ b/apps/harness/src/app/api/repos/search/route.ts
@@ -0,0 +1,13 @@
+import { NextRequest, NextResponse } from "next/server";
+import { searchRepos } from "@/lib/repo-search";
+
+export async function GET(request: NextRequest) {
+ const query = request.nextUrl.searchParams.get("q") || "";
+
+ if (query.length < 2) {
+ return NextResponse.json([]);
+ }
+
+ const results = await searchRepos(query);
+ return NextResponse.json(results);
+}
diff --git a/apps/harness/src/app/api/settings/credentials/route.ts b/apps/harness/src/app/api/settings/credentials/route.ts
new file mode 100644
index 0000000..2f588c8
--- /dev/null
+++ b/apps/harness/src/app/api/settings/credentials/route.ts
@@ -0,0 +1,63 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ getAllCredentials,
+ getCredentialsByKind,
+ upsertCredential,
+ deleteCredential,
+ Credential,
+ GIT_PROVIDERS,
+ AI_PROVIDERS,
+} from "@/lib/credentials";
+
+const VALID_PROVIDERS = [...GIT_PROVIDERS, ...AI_PROVIDERS];
+
+export async function GET(request: NextRequest) {
+ const kind = request.nextUrl.searchParams.get("kind");
+ if (kind === "git" || kind === "ai") {
+ return NextResponse.json(getCredentialsByKind(kind));
+ }
+ return NextResponse.json(getAllCredentials());
+}
+
+export async function POST(request: NextRequest) {
+ const body = await request.json();
+
+ if (!body.provider || !body.token || !body.label) {
+ return NextResponse.json(
+ { error: "provider, label, and token are required" },
+ { status: 400 }
+ );
+ }
+
+ if (!VALID_PROVIDERS.includes(body.provider)) {
+ return NextResponse.json(
+ { error: `provider must be one of: ${VALID_PROVIDERS.join(", ")}` },
+ { status: 400 }
+ );
+ }
+
+ const cred: Credential = {
+ id: body.id || `cred-${Date.now()}`,
+ provider: body.provider,
+ label: body.label,
+ token: body.token,
+ baseUrl: body.baseUrl,
+ };
+
+ const saved = upsertCredential(cred);
+ return NextResponse.json(saved, { status: 201 });
+}
+
+export async function DELETE(request: NextRequest) {
+ const id = request.nextUrl.searchParams.get("id");
+ if (!id) {
+ return NextResponse.json({ error: "id is required" }, { status: 400 });
+ }
+
+ const deleted = deleteCredential(id);
+ if (!deleted) {
+ return NextResponse.json({ error: "not found" }, { status: 404 });
+ }
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/apps/harness/src/app/api/tasks/[id]/route.ts b/apps/harness/src/app/api/tasks/[id]/route.ts
new file mode 100644
index 0000000..bce0ec0
--- /dev/null
+++ b/apps/harness/src/app/api/tasks/[id]/route.ts
@@ -0,0 +1,27 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getTask, updateTask } from "@/lib/store";
+
+export async function GET(
+ _request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+ const task = getTask(id);
+ if (!task) {
+ return NextResponse.json({ error: "Task not found" }, { status: 404 });
+ }
+ return NextResponse.json(task);
+}
+
+export async function PATCH(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+ const body = await request.json();
+ const updated = updateTask(id, body);
+ if (!updated) {
+ return NextResponse.json({ error: "Task not found" }, { status: 404 });
+ }
+ return NextResponse.json(updated);
+}
diff --git a/apps/harness/src/app/api/tasks/[id]/start/route.ts b/apps/harness/src/app/api/tasks/[id]/start/route.ts
new file mode 100644
index 0000000..bccff74
--- /dev/null
+++ b/apps/harness/src/app/api/tasks/[id]/start/route.ts
@@ -0,0 +1,27 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getTask } from "@/lib/store";
+import { startOrchestrator } from "@/lib/orchestrator";
+
+export async function POST(
+ _request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+ const task = getTask(id);
+
+ if (!task) {
+ return NextResponse.json({ error: "Task not found" }, { status: 404 });
+ }
+
+ if (task.status !== "pending") {
+ return NextResponse.json(
+ { error: `Task is ${task.status}, not pending` },
+ { status: 400 },
+ );
+ }
+
+ // Ensure orchestrator is running — it will pick up this task
+ startOrchestrator();
+
+ return NextResponse.json({ ok: true, message: "Orchestrator started, task will be picked up" });
+}
diff --git a/apps/harness/src/app/api/tasks/[id]/stop/route.ts b/apps/harness/src/app/api/tasks/[id]/stop/route.ts
new file mode 100644
index 0000000..3aa5e7c
--- /dev/null
+++ b/apps/harness/src/app/api/tasks/[id]/stop/route.ts
@@ -0,0 +1,32 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getTask } from "@/lib/store";
+import { cancelTask } from "@/lib/orchestrator";
+
+export async function POST(
+ _request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+ const task = getTask(id);
+
+ if (!task) {
+ return NextResponse.json({ error: "Task not found" }, { status: 404 });
+ }
+
+ if (task.status !== "running") {
+ return NextResponse.json(
+ { error: `Task is ${task.status}, not running` },
+ { status: 400 },
+ );
+ }
+
+ const cancelled = cancelTask(id);
+ if (!cancelled) {
+ return NextResponse.json(
+ { error: "Task is not the currently executing task" },
+ { status: 400 },
+ );
+ }
+
+ return NextResponse.json({ ok: true, message: "Task cancellation requested" });
+}
diff --git a/apps/harness/src/app/api/tasks/route.ts b/apps/harness/src/app/api/tasks/route.ts
new file mode 100644
index 0000000..64cd949
--- /dev/null
+++ b/apps/harness/src/app/api/tasks/route.ts
@@ -0,0 +1,45 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getAllTasks, createTask } from "@/lib/store";
+import { getAgentConfig } from "@/lib/agents";
+import { Task, TaskSpec } from "@/lib/types";
+
+export async function GET() {
+ return NextResponse.json(getAllTasks());
+}
+
+export async function POST(request: NextRequest) {
+ const spec: TaskSpec = await request.json();
+
+ if (!spec.slug || !spec.goal) {
+ return NextResponse.json({ error: "slug and goal are required" }, { status: 400 });
+ }
+
+ if (!spec.agentId) {
+ return NextResponse.json({ error: "agentId is required" }, { status: 400 });
+ }
+
+ const agentConfig = getAgentConfig(spec.agentId);
+ if (!agentConfig) {
+ return NextResponse.json(
+ { error: `Agent config not found: ${spec.agentId}` },
+ { status: 400 },
+ );
+ }
+
+ const task: Task = {
+ id: `task-${Date.now()}`,
+ slug: spec.slug,
+ goal: spec.goal,
+ project: spec.project || "—",
+ status: "pending",
+ iteration: 0,
+ maxIterations: spec.maxIterations || 6,
+ startedAt: null,
+ evals: {},
+ iterations: [],
+ spec,
+ };
+
+ const created = createTask(task);
+ return NextResponse.json(created, { status: 201 });
+}
diff --git a/apps/harness/src/app/layout.tsx b/apps/harness/src/app/layout.tsx
new file mode 100644
index 0000000..38a0e43
--- /dev/null
+++ b/apps/harness/src/app/layout.tsx
@@ -0,0 +1,18 @@
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Harness — Agent Orchestrator",
+ description: "Autonomous coding agent loop orchestrator and dashboard",
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
{children}
+
+ );
+}
diff --git a/apps/harness/src/app/page.tsx b/apps/harness/src/app/page.tsx
new file mode 100644
index 0000000..4a188da
--- /dev/null
+++ b/apps/harness/src/app/page.tsx
@@ -0,0 +1,5 @@
+import HarnessDashboard from "@/components/harness-dashboard";
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/harness/src/components/harness-dashboard.tsx b/apps/harness/src/components/harness-dashboard.tsx
new file mode 100644
index 0000000..7cf4fcd
--- /dev/null
+++ b/apps/harness/src/components/harness-dashboard.tsx
@@ -0,0 +1,1889 @@
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+import {
+ tokens, STATUS, Label, Mono, Divider, StatusBadge, Panel, PanelHeader,
+ Btn, Input, Textarea, EvalPip, IterDot, PathCrumb, BackBtn,
+ SearchableDropdown, DropdownOption,
+} from "./harness-design-system";
+
+// ─── TYPES ──────────────────────────────────────────────────────────────────────
+
+interface Eval {
+ label: string;
+ value: number | string;
+ unit: string;
+ pass: boolean;
+ target: string;
+}
+
+interface Iteration {
+ n: number;
+ status: string;
+ diagnosis: string | null;
+}
+
+interface PR {
+ number: number;
+ title: string;
+ status: string;
+}
+
+interface Task {
+ id: string;
+ slug: string;
+ goal: string;
+ status: string;
+ iteration: number;
+ maxIterations: number;
+ startedAt: number | null;
+ completedAt?: number;
+ project: string;
+ evals: Record;
+ iterations: Iteration[];
+ pr?: PR;
+}
+
+interface KnowledgeDoc {
+ path: string;
+ title: string;
+ verificationStatus: string;
+ lastUpdated: string;
+ project: string;
+ preview: string;
+}
+
+interface Project {
+ id: string;
+ name: string;
+ workspaces: { name: string; repo: string }[];
+}
+
+// ─── RESPONSIVE HOOK ─────────────────────────────────────────────────────────
+
+function useIsMobile() {
+ const [mobile, setMobile] = useState(false);
+ useEffect(() => {
+ const fn = () => setMobile(window.innerWidth < 768);
+ fn();
+ window.addEventListener("resize", fn);
+ return () => window.removeEventListener("resize", fn);
+ }, []);
+ return mobile;
+}
+
+// ─── MOCK DATA ────────────────────────────────────────────────────────────────
+
+const MOCK_TASKS: Task[] = [
+ {
+ id: "task-001", slug: "pubsub-pipeline-migration",
+ goal: "Replace CDC replication with Pub/Sub → GCS → BigQuery pipeline",
+ status: "running", iteration: 3, maxIterations: 6,
+ startedAt: Date.now() - 1000 * 60 * 23, project: "Hypixel",
+ evals: {
+ cost: { value: -38, unit: "%", label: "Cost Δ", pass: false, target: "<-40%" },
+ latency: { value: 22, unit: "s", label: "P99 Latency", pass: true, target: "<30s" },
+ tests: { value: 97, unit: "%", label: "Test Pass", pass: true, target: "100%" },
+ },
+ iterations: [
+ { n: 1, status: "failed", diagnosis: "Schema mismatch on UGC event table — BQ partition column incompatible" },
+ { n: 2, status: "failed", diagnosis: "Cost reduction insufficient — Pub/Sub fan-out creating duplicate messages" },
+ { n: 3, status: "running", diagnosis: null },
+ ],
+ },
+ {
+ id: "task-002", slug: "haiku-moderation-tier2",
+ goal: "Implement tiered UGC image moderation with Haiku classifier for tier-2 content",
+ status: "completed", iteration: 4, maxIterations: 6,
+ startedAt: Date.now() - 1000 * 60 * 60 * 3, completedAt: Date.now() - 1000 * 60 * 40,
+ project: "Hypixel",
+ pr: { number: 1847, title: "feat: tiered UGC moderation with Haiku classifier", status: "open" },
+ evals: {
+ accuracy: { value: 94.2, unit: "%", label: "Accuracy", pass: true, target: ">92%" },
+ throughput: { value: 312, unit: "rps", label: "Throughput", pass: true, target: ">200rps" },
+ tests: { value: 100, unit: "%", label: "Test Pass", pass: true, target: "100%" },
+ },
+ iterations: [
+ { n: 1, status: "failed", diagnosis: "Classifier confidence threshold too low — 23% false positive rate" },
+ { n: 2, status: "failed", diagnosis: "Rate limiting on Haiku API at burst load — throughput degraded" },
+ { n: 3, status: "failed", diagnosis: "Accuracy marginal — prompt engineering needed for edge cases" },
+ { n: 4, status: "passed", diagnosis: null },
+ ],
+ },
+ {
+ id: "task-003", slug: "neurotype-job-cancellation",
+ goal: "Implement cancellable background jobs with rate limiting and dynamic prioritisation",
+ status: "pending", iteration: 0, maxIterations: 5,
+ startedAt: null, project: "Neurotype",
+ evals: {}, iterations: [],
+ },
+];
+
+const MOCK_KNOWLEDGE: KnowledgeDoc[] = [
+ { path: "docs/architecture/bigquery-pipeline.md", title: "BigQuery Pipeline Architecture", verificationStatus: "stale", lastUpdated: "2026-03-18", project: "Hypixel", preview: "Documents the Pub/Sub → GCS → BigQuery replacement for CDC replication. Original CDC pattern caused billing spike due to per-row streaming inserts at scale." },
+ { path: "docs/architecture/ugc-moderation.md", title: "UGC Moderation Tiering", verificationStatus: "verified", lastUpdated: "2026-03-20", project: "Hypixel", preview: "Three-tier classification: Haiku (fast, high-volume), Sonnet (complex cases), human review (appeals). Accuracy targets per tier defined." },
+ { path: "docs/architecture/neurotype-job-system.md", title: "Background Job Processing", verificationStatus: "verified", lastUpdated: "2026-03-19", project: "Neurotype", preview: "Postgres-native job queue with cancellation tokens, rate limiting per clinical workflow type, and dynamic priority lanes." },
+ { path: "docs/beliefs.md", title: "Core Beliefs", verificationStatus: "verified", lastUpdated: "2026-03-15", project: "Global", preview: "Invariants: no BQ streaming inserts, NHS audit trail on all clinical state mutations, Haiku only for non-PII classification." },
+ { path: "decisions/2026-03-20-haiku-tier2-iter3.md", title: "Haiku Tier-2: Iter 3 Failure", verificationStatus: "decision", lastUpdated: "2026-03-20", project: "Hypixel", preview: "Accuracy 92.1% — marginally below target. Root cause: edge cases in animated content. Fix: few-shot examples in system prompt." },
+ { path: "decisions/2026-03-19-pubsub-iter2.md", title: "Pub/Sub Migration: Iter 2 Failure", verificationStatus: "decision", lastUpdated: "2026-03-19", project: "Hypixel", preview: "Fan-out producing 2.3x message duplication on retry. Root cause: missing dedup window in Dataflow job." },
+];
+
+const MOCK_PROJECTS: Project[] = [
+ {
+ id: "proj-001", name: "Hypixel",
+ workspaces: [
+ { name: "hypixel-api", repo: "github.com/org/hypixel-api" },
+ { name: "hypixel-web", repo: "github.com/org/hypixel-web" },
+ { name: "hypixel-infra", repo: "github.com/org/hypixel-infra" },
+ ],
+ },
+ {
+ id: "proj-002", name: "Neurotype",
+ workspaces: [
+ { name: "neurotype-backend", repo: "github.com/org/neurotype-backend" },
+ { name: "neurotype-dashboard", repo: "github.com/org/neurotype-dashboard" },
+ ],
+ },
+ {
+ id: "proj-003", name: "Homelab",
+ workspaces: [
+ { name: "homelab", repo: "github.com/lazorgurl/homelab" },
+ ],
+ },
+];
+
+function elapsed(ms: number) {
+ const s = Math.floor(ms / 1000);
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ if (m < 60) return `${m}m ${s % 60}s`;
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
+}
+
+// ─── SHARED DETAIL VIEWS ─────────────────────────────────────────────────────
+
+function TaskDetail({ task, onStop, onStart, mobile, onBack }: {
+ task: Task; onStop: (id: string) => void; onStart: (id: string) => void; mobile: boolean; onBack?: () => void;
+}) {
+ const evalEntries = Object.values(task.evals);
+
+ return (
+
+ {/* Header */}
+
+ {mobile && onBack &&
}
+
+
+
+
+
+
+
{task.slug}
+
{task.goal}
+
+
+
+ {task.status === "running" && onStop(task.id)} style={{ flex: mobile ? 1 : "none" }}>STOP LOOP}
+ {task.status === "pending" && onStart(task.id)} style={{ flex: mobile ? 1 : "none" }}>START LOOP}
+ {task.status === "completed" && task.pr && APPROVE PR}
+
+
+
+
+ {/* Evals */}
+ {evalEntries.length > 0 && (
+
+
+
+ {evalEntries.map(e => )}
+
+
+ )}
+
+ {/* Iteration log */}
+
+
+
+ {task.iterations.length === 0
+ ?
No iterations yet.
+ : task.iterations.map(iter => (
+
+
+
+
+
+
+
+ {iter.diagnosis && {iter.diagnosis}}
+ {iter.status === "running" && !iter.diagnosis && In progress...}
+
+
+ ))
+ }
+
+
+
+ {/* PR */}
+ {task.pr && (
+
+
+
+
+
+
+ #{task.pr.number}
+ {task.pr.title}
+
+
+ VIEW DIFF
+ APPROVE
+
+
+
+
+ )}
+
+
+ );
+}
+
+function DocDetail({ doc, mobile, onBack }: { doc: KnowledgeDoc; mobile: boolean; onBack?: () => void }) {
+ return (
+
+
+ {mobile && onBack &&
}
+
+
+
+
+
+
+
{doc.title}
+
+
+
+ MARK STALE
+ EDIT
+
+
+
+
+ {doc.preview}
+ [ Full document content renders here ]
+
+
+
+ );
+}
+
+// ─── LOOPS TAB ────────────────────────────────────────────────────────────────
+
+function LoopsTab({ tasks, setTasks, mobile }: { tasks: Task[]; setTasks: React.Dispatch>; mobile: boolean }) {
+ const [selectedId, setSelectedId] = useState(null);
+ const selectedTask = tasks.find(t => t.id === selectedId);
+ const showDetail = mobile ? selectedId !== null : true;
+ const showList = mobile ? selectedId === null : true;
+
+ const handleStop = (id: string) => setTasks(prev => prev.map(t => t.id === id ? { ...t, status: "completed" } : t));
+ const handleStart = (id: string) => setTasks(prev => prev.map(t => t.id === id ? { ...t, status: "running", startedAt: Date.now() } : t));
+
+ const TaskRow = ({ task }: { task: Task }) => {
+ const s = STATUS[task.status] || STATUS.pending;
+ return (
+ setSelectedId(task.id)}
+ style={{ background: tokens.color.bg1, border: `1px solid ${tokens.color.border0}`, borderLeft: `3px solid ${s.color}`, cursor: "pointer", transition: tokens.transition.normal, marginBottom: 1 }}>
+
+
+
+ {task.slug}
+
+
+
+ {task.status === "running" && (
+ { e.stopPropagation(); handleStop(task.id); }} style={{ fontSize: tokens.size.xs, padding: "0 8px", minHeight: 28 }}>STOP
+ )}
+ {task.status === "pending" && (
+ { e.stopPropagation(); handleStart(task.id); }} style={{ fontSize: tokens.size.xs, padding: "0 8px", minHeight: 28 }}>START
+ )}
+ {mobile && ›}
+
+
+
+
+ {task.goal}
+
+
+
+ {Array.from({ length: task.maxIterations }).map((_, i) => (
+
+ ))}
+
+
+
+
+ {task.pr && (
+ <>
+
+
+
+ #{task.pr.number} — {task.pr.title}
+
+ >
+ )}
+
+ );
+ };
+
+ return (
+
+ {showList && (
+
+
+
+ {tasks.map(task => )}
+
+
+ )}
+
+ {!mobile && (
+ selectedTask
+ ?
+ :
+ )}
+ {mobile && showDetail && selectedTask && (
+
setSelectedId(null)} />
+ )}
+
+ );
+}
+
+// ─── KNOWLEDGE TAB ────────────────────────────────────────────────────────────
+
+function KnowledgeTab({ docs, mobile }: { docs: KnowledgeDoc[]; mobile: boolean }) {
+ const [selected, setSelected] = useState(null);
+ const [filter, setFilter] = useState("");
+ const [projectFilter, setProjectFilter] = useState("ALL");
+ const filtered = docs.filter(d => {
+ if (projectFilter !== "ALL" && d.project !== projectFilter) return false;
+ return [d.title, d.path, d.project].some(s => s.toLowerCase().includes(filter.toLowerCase()));
+ });
+ const selectedDoc = docs.find(d => d.path === selected);
+ const showDetail = mobile ? selected !== null : true;
+ const showList = mobile ? selected === null : true;
+
+ const projectNames = ["ALL", ...Array.from(new Set(docs.map(d => d.project)))];
+
+ return (
+
+ {showList && (
+
+
+
+
setFilter(e.target.value)} placeholder="filter..." />
+
+ {projectNames.map(name => (
+
+ ))}
+
+
+
+ {filtered.map(doc => {
+ const badge = STATUS[doc.verificationStatus] || STATUS.pending;
+ const isSel = selected === doc.path;
+ return (
+
setSelected(doc.path)}
+ style={{ padding: `${tokens.space[3]}px ${tokens.space[4]}px`, borderBottom: `1px solid ${tokens.color.border0}`, background: isSel ? tokens.color.bg2 : "transparent", cursor: "pointer", transition: tokens.transition.fast, borderLeft: `2px solid ${isSel ? badge.color : "transparent"}`, minHeight: tokens.touch.min, display: "flex", flexDirection: "column", justifyContent: "center", gap: tokens.space[1] }}>
+
+
+
+
+ {mobile && ›}
+
+
+
{doc.title}
+
+
+ );
+ })}
+
+
+ )}
+
+ {!mobile && (
+ selectedDoc
+ ?
+ :
+ )}
+ {mobile && showDetail && selectedDoc && (
+
setSelected(null)} />
+ )}
+
+ );
+}
+
+// ─── HISTORY TAB ─────────────────────────────────────────────────────────────
+
+function HistoryTab({ tasks, mobile }: { tasks: Task[]; mobile: boolean }) {
+ return (
+
+
+ {[...tasks].reverse().map(task => {
+ const s = STATUS[task.status] || STATUS.pending;
+ return (
+
+
+
+
+ {task.slug}
+
+
+
+
+ {Array.from({ length: task.maxIterations }).map((_, i) => (
+
+ ))}
+
+
+ {task.startedAt &&
}
+
+
+ {Object.keys(task.evals).length > 0 && (
+ <>
+
+
+ {Object.values(task.evals).map(e => (
+
+
+ {e.value}{e.unit}
+
+ ))}
+ {task.pr && (
+
+
+ #{task.pr.number}
+
+ )}
+
+ >
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+// ─── NEW TASK TAB ─────────────────────────────────────────────────────────────
+
+interface TaskForm {
+ slug: string;
+ goal: string;
+ projectId: string;
+ agentId: string;
+ maxIterations: string;
+ criteria: { label: string; target: string }[];
+ constraints: string[];
+ knowledgeRefs: string[];
+}
+
+const EMPTY: TaskForm = { slug: "", goal: "", projectId: "", agentId: "", maxIterations: "6", criteria: [{ label: "", target: "" }], constraints: [""], knowledgeRefs: [] };
+
+// ─── AGENT SELECTOR ──────────────────────────────────────────────────────────
+
+interface AgentConfigDisplay {
+ id: string;
+ name: string;
+ runtime: string;
+ modelId: string;
+ provider: string;
+}
+
+const RUNTIME_LABELS: Record = {
+ "claude-code": "Claude Code",
+ "codex": "Codex CLI",
+ "opencode": "OpenCode",
+};
+
+function AgentSelector({ value, onChange }: { value: string; onChange: (id: string) => void }) {
+ const [agents, setAgents] = useState([]);
+
+ useEffect(() => {
+ fetch("/api/agents")
+ .then(r => r.json())
+ .then(data => setAgents(data.configs || []))
+ .catch(() => {});
+ }, []);
+
+ const options: DropdownOption[] = agents.map(a => ({
+ value: a.id,
+ label: a.name,
+ detail: `${RUNTIME_LABELS[a.runtime] || a.runtime} · ${a.modelId}`,
+ }));
+
+ return (
+ onChange(v as string)}
+ placeholder={agents.length === 0 ? "No agents configured — set up in Models tab" : "Select agent..."}
+ />
+ );
+}
+
+function NewTaskTab({ onSubmit, mobile, projects, knowledgeDocs }: {
+ onSubmit: (form: TaskForm) => void;
+ mobile: boolean;
+ projects: Project[];
+ knowledgeDocs: KnowledgeDoc[];
+}) {
+ const [form, setForm] = useState(EMPTY);
+ const set = (k: keyof TaskForm, v: string) => setForm(f => ({ ...f, [k]: v }));
+ const setCrit = (i: number, k: "label" | "target", v: string) => setForm(f => { const c = [...f.criteria]; c[i] = { ...c[i], [k]: v }; return { ...f, criteria: c }; });
+ const setItem = (lk: "constraints", i: number, v: string) => setForm(f => { const a = [...f[lk]]; a[i] = v; return { ...f, [lk]: a }; });
+
+ const projectOptions: DropdownOption[] = projects.map(p => ({
+ value: p.id,
+ label: p.name,
+ detail: p.workspaces.map(w => w.name).join(", "),
+ }));
+
+ const selectedProject = projects.find(p => p.id === form.projectId);
+
+ const knowledgeOptions: DropdownOption[] = knowledgeDocs
+ .filter(d => !selectedProject || d.project === selectedProject.name || d.project === "Global")
+ .map(d => ({
+ value: d.path,
+ label: d.title,
+ detail: d.path,
+ }));
+
+ const Field = ({ label, children }: { label: string; children: React.ReactNode }) => (
+
+
+ {children}
+
+ );
+
+ return (
+
+
+
+
+
+
+
+
+ set("slug", e.target.value)} placeholder="my-task-slug" />
+
+
+ set("maxIterations", e.target.value)} placeholder="6" style={mobile ? {} : { width: 100 }} />
+
+
+
+
+
+
+ {
+ setForm(f => ({ ...f, projectId: v as string, knowledgeRefs: [] }));
+ }}
+ placeholder="Select project..."
+ />
+
+
+
+
+ set("agentId", v)} />
+
+
+
+
+ {selectedProject && selectedProject.workspaces.length > 0 && (
+
+
+
+ {selectedProject.workspaces.map(w => (
+
+ {w.name}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+ setForm(f => ({ ...f, criteria: [...f.criteria, { label: "", target: "" }] }))} style={{ fontSize: tokens.size.xs, minHeight: 32 }}>+ ADD
+
+
+
+
+
+
+
+ setForm(f => ({ ...f, constraints: [...f.constraints, ""] }))} style={{ fontSize: tokens.size.xs, minHeight: 32 }}>+ ADD
+
+
+ {form.constraints.map((c, i) => (
+ setItem("constraints", i, e.target.value)} placeholder="Do not modify the classifier interface" />
+ ))}
+
+
+
+
+ setForm(f => ({ ...f, knowledgeRefs: v as string[] }))}
+ placeholder={selectedProject ? `Search ${selectedProject.name} knowledge...` : "Select a project first..."}
+ />
+
+
+
+
+
+ setForm(EMPTY)} style={{ flex: mobile ? 1 : "none" }}>CLEAR
+ { if (!form.slug || !form.goal || !form.projectId || !form.agentId) return; onSubmit(form); setForm(EMPTY); }} disabled={!form.slug || !form.goal || !form.projectId || !form.agentId} style={{ flex: mobile ? 2 : "none" }}>QUEUE TASK
+
+
+
+ );
+}
+
+// ─── PROJECTS TAB ─────────────────────────────────────────────────────────────
+
+// ─── REPO SEARCH COMPONENT ────────────────────────────────────────────────────
+
+interface RepoResult {
+ provider: "github" | "gitlab";
+ fullName: string;
+ url: string;
+ description: string;
+ private: boolean;
+}
+
+function RepoSearch({ onSelect, mobile }: {
+ onSelect: (repo: RepoResult) => void;
+ mobile: boolean;
+}) {
+ const [query, setQuery] = useState("");
+ const [results, setResults] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [hasCredentials, setHasCredentials] = useState(null);
+ const debounceRef = useRef | null>(null);
+
+ useEffect(() => {
+ fetch("/api/settings/credentials")
+ .then(r => r.json())
+ .then((creds: unknown[]) => setHasCredentials(creds.length > 0))
+ .catch(() => setHasCredentials(false));
+ }, []);
+
+ useEffect(() => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ if (query.length < 2) { setResults([]); return; }
+
+ debounceRef.current = setTimeout(async () => {
+ setLoading(true);
+ try {
+ const res = await fetch(`/api/repos/search?q=${encodeURIComponent(query)}`);
+ const data = await res.json();
+ setResults(data);
+ } catch {
+ setResults([]);
+ }
+ setLoading(false);
+ }, 300);
+
+ return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
+ }, [query]);
+
+ if (hasCredentials === false) {
+ return (
+
+ No git credentials configured.
+ Add GitHub or GitLab tokens in the credentials section below to search repositories.
+
+ );
+ }
+
+ return (
+
+
setQuery(e.target.value)}
+ placeholder="Search GitHub / GitLab repos..."
+ />
+ {loading &&
Searching...}
+ {results.length > 0 && (
+
+ {results.map(repo => (
+
{ onSelect(repo); setQuery(""); setResults([]); }}
+ style={{
+ padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
+ cursor: "pointer", borderBottom: `1px solid ${tokens.color.border0}`,
+ display: "flex", flexDirection: "column", gap: 2,
+ background: tokens.color.bg1,
+ }}
+ >
+
+
+ {repo.provider === "github" ? "GH" : "GL"}
+
+ {repo.fullName}
+ {repo.private && (
+
+ PRIVATE
+
+ )}
+
+ {repo.description && (
+
{repo.description}
+ )}
+
+ ))}
+
+ )}
+ {query.length >= 2 && !loading && results.length === 0 && (
+
No repos found. Check credentials or try a different query.
+ )}
+
+ );
+}
+
+// ─── CREDENTIAL MANAGER ───────────────────────────────────────────────────────
+
+const PROVIDER_BADGE: Record = {
+ github: { short: "GH", bg: tokens.color.bg3, color: tokens.color.text1, border: tokens.color.border0 },
+ gitlab: { short: "GL", bg: tokens.color.purpleDim, color: tokens.color.purple, border: tokens.color.purpleDim },
+ anthropic: { short: "AN", bg: tokens.color.warnDim, color: tokens.color.warn, border: tokens.color.warnDim },
+ openai: { short: "OA", bg: tokens.color.passDim, color: tokens.color.pass, border: tokens.color.passDim },
+ openrouter: { short: "OR", bg: tokens.color.infoDim, color: tokens.color.info, border: tokens.color.infoDim },
+ google: { short: "GG", bg: tokens.color.infoDim, color: tokens.color.info, border: tokens.color.infoDim },
+ "opencode-zen": { short: "OZ", bg: tokens.color.purpleDim, color: tokens.color.purple, border: tokens.color.purpleDim },
+};
+
+function ProviderBadge({ provider }: { provider: string }) {
+ const b = PROVIDER_BADGE[provider] || { short: provider.slice(0, 2).toUpperCase(), bg: tokens.color.bg3, color: tokens.color.text1, border: tokens.color.border0 };
+ return (
+
+ {b.short}
+
+ );
+}
+
+interface CredentialDisplay {
+ id: string;
+ provider: string;
+ label: string;
+ token: string;
+ baseUrl?: string;
+}
+
+const TOKEN_PLACEHOLDERS: Record = {
+ github: "ghp_...",
+ gitlab: "glpat-...",
+ anthropic: "sk-ant-...",
+ openai: "sk-...",
+ openrouter: "sk-or-...",
+ google: "AIza...",
+ "opencode-zen": "ocz_...",
+};
+
+function CredentialManager({ kind, mobile }: { kind: "git" | "ai"; mobile: boolean }) {
+ const providers = kind === "git"
+ ? ["github", "gitlab"] as const
+ : ["anthropic", "openai", "openrouter", "google", "opencode-zen"] as const;
+ const title = kind === "git" ? "GIT CREDENTIALS" : "AI CREDENTIALS";
+ const emptyMsg = kind === "git"
+ ? "No git credentials configured. Add a GitHub or GitLab token to search repos."
+ : "No AI credentials configured. Add a provider key to fetch available models.";
+
+ const [credentials, setCredentials] = useState([]);
+ const [showAdd, setShowAdd] = useState(false);
+ const [provider, setProvider] = useState(providers[0]);
+ const [label, setLabel] = useState("");
+ const [token, setToken] = useState("");
+ const [baseUrl, setBaseUrl] = useState("");
+
+ const loadCredentials = () => {
+ fetch(`/api/settings/credentials?kind=${kind}`)
+ .then(r => r.json())
+ .then(setCredentials)
+ .catch(() => {});
+ };
+
+ useEffect(() => { loadCredentials(); }, [kind]);
+
+ const handleAdd = async () => {
+ if (!label.trim() || !token.trim()) return;
+ await fetch("/api/settings/credentials", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ provider,
+ label: label.trim(),
+ token: token.trim(),
+ baseUrl: (provider === "gitlab" || provider === "openai") && baseUrl.trim() ? baseUrl.trim() : undefined,
+ }),
+ });
+ setLabel(""); setToken(""); setBaseUrl(""); setShowAdd(false);
+ loadCredentials();
+ };
+
+ const handleDelete = async (id: string) => {
+ await fetch(`/api/settings/credentials?id=${id}`, { method: "DELETE" });
+ loadCredentials();
+ };
+
+ const showBaseUrl = provider === "gitlab" || provider === "openai";
+
+ return (
+
+
+
+ setShowAdd(!showAdd)}
+ style={{ fontSize: tokens.size.xs, minHeight: 32 }}>
+ {showAdd ? "CANCEL" : "+ ADD"}
+
+
+
+ {showAdd && (
+
+
+ {providers.map(p => (
+
+ ))}
+
+
+ {showBaseUrl && (
+
+
+ setBaseUrl(e.target.value)} placeholder={provider === "gitlab" ? "https://gitlab.example.com" : "https://api.openai.com"} />
+
+ )}
+
+ SAVE CREDENTIAL
+
+
+ )}
+
+
+ {credentials.length === 0 && !showAdd ? (
+
{emptyMsg}
+ ) : (
+ credentials.map(c => (
+
+
+
+
{c.label}
+
{c.token}
+
+
handleDelete(c.id)}
+ style={{ fontSize: tokens.size.xs, minHeight: 28, color: tokens.color.fail, flexShrink: 0 }}>
+ REMOVE
+
+
+ ))
+ )}
+
+
+ );
+}
+
+// ─── PROJECTS TAB (with repo search + credential management) ──────────────────
+
+function ProjectsTab({ projects, setProjects, mobile }: {
+ projects: Project[];
+ setProjects: React.Dispatch>;
+ mobile: boolean;
+}) {
+ const [selectedId, setSelectedId] = useState(null);
+ const [creating, setCreating] = useState(false);
+ const [newName, setNewName] = useState("");
+
+ const selectedProject = projects.find(p => p.id === selectedId);
+ const showDetail = mobile ? selectedId !== null || creating : true;
+ const showList = mobile ? selectedId === null && !creating : true;
+
+ const handleCreate = () => {
+ if (!newName.trim()) return;
+ const proj: Project = {
+ id: `proj-${Date.now()}`,
+ name: newName.trim(),
+ workspaces: [],
+ };
+ setProjects(prev => [...prev, proj]);
+ setSelectedId(proj.id);
+ setCreating(false);
+ setNewName("");
+ };
+
+ const handleDelete = (id: string) => {
+ setProjects(prev => prev.filter(p => p.id !== id));
+ setSelectedId(null);
+ };
+
+ const handleAddRepo = (repo: RepoResult) => {
+ if (!selectedId) return;
+ const name = repo.fullName.split("/").pop() || repo.fullName;
+ setProjects(prev => prev.map(p =>
+ p.id === selectedId
+ ? { ...p, workspaces: [...p.workspaces, { name, repo: repo.url }] }
+ : p
+ ));
+ };
+
+ const handleRemoveWorkspace = (projId: string, repoName: string) => {
+ setProjects(prev => prev.map(p =>
+ p.id === projId
+ ? { ...p, workspaces: p.workspaces.filter(w => w.name !== repoName) }
+ : p
+ ));
+ };
+
+ const ProjectRow = ({ project }: { project: Project }) => {
+ const isSel = selectedId === project.id;
+ return (
+ { setSelectedId(project.id); setCreating(false); }}
+ style={{
+ background: isSel ? tokens.color.bg2 : tokens.color.bg1,
+ border: `1px solid ${tokens.color.border0}`,
+ borderLeft: `3px solid ${isSel ? tokens.color.accent : tokens.color.border0}`,
+ cursor: "pointer", transition: tokens.transition.normal, marginBottom: 1,
+ }}>
+
+
+ {project.name}
+
+
+
+ {mobile && ›}
+
+
+ {project.workspaces.length > 0 && (
+ <>
+
+
+ {project.workspaces.map(w => (
+
+ {w.name}
+
+ ))}
+
+ >
+ )}
+
+ );
+ };
+
+ const DetailView = () => {
+ if (creating) {
+ return (
+
+
+ {mobile && setCreating(false)} label="PROJECTS" />}
+
+
+
+
+
+ setNewName(e.target.value)} placeholder="My Project" />
+
+
+ { setCreating(false); setNewName(""); }}>CANCEL
+ CREATE PROJECT
+
+
+
+ );
+ }
+
+ if (!selectedProject) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {mobile &&
setSelectedId(null)} label="PROJECTS" />}
+
+ {selectedProject.name}
+ handleDelete(selectedProject.id)} style={{ fontSize: tokens.size.xs, minHeight: 32 }}>DELETE PROJECT
+
+
+
+
+ {/* Workspaces list */}
+
+
+
+ {selectedProject.workspaces.length === 0 ? (
+
No workspaces configured. Search for a repository below.
+ ) : (
+ selectedProject.workspaces.map(w => (
+
+
+ {w.name}
+ {w.repo}
+
+
handleRemoveWorkspace(selectedProject.id, w.name)}
+ style={{ fontSize: tokens.size.xs, minHeight: 32, color: tokens.color.fail, flexShrink: 0 }}>
+ REMOVE
+
+
+ ))
+ )}
+
+
+
+ {/* Repo search */}
+
+
+
+
+
+
+
+ {/* Credentials management */}
+
+
+
+ );
+ };
+
+ return (
+
+ {showList && (
+
+
+ { setCreating(true); setSelectedId(null); }}
+ style={{ fontSize: tokens.size.xs, minHeight: 32, padding: `0 ${tokens.space[2]}px` }}>
+ + NEW
+
+
+
+ {projects.map(p =>
)}
+
+
+ )}
+
+ {!mobile &&
}
+ {mobile && showDetail &&
}
+
+ );
+}
+
+// ─── MODELS TAB ──────────────────────────────────────────────────────────────
+
+interface CuratedModelDisplay {
+ id: string;
+ name: string;
+ provider: string;
+ enabled: boolean;
+ contextWindow?: number;
+ costPer1kInput?: number;
+ costPer1kOutput?: number;
+}
+
+interface UsageSummaryDisplay {
+ modelId: string;
+ provider: string;
+ totalInputTokens: number;
+ totalOutputTokens: number;
+ totalCost: number;
+ totalRequests: number;
+ totalDurationMs: number;
+}
+
+interface UsageLogEntry {
+ modelId: string;
+ provider: string;
+ taskSlug: string;
+ iteration: number;
+ inputTokens: number;
+ outputTokens: number;
+ durationMs: number;
+ timestamp: number;
+}
+
+function formatTokens(n: number): string {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
+ return `${n}`;
+}
+
+function formatCost(n: number): string {
+ if (n >= 1) return `$${n.toFixed(2)}`;
+ if (n >= 0.01) return `$${n.toFixed(3)}`;
+ return `$${n.toFixed(4)}`;
+}
+
+interface AgentRuntimeDisplay {
+ id: string;
+ name: string;
+ description: string;
+ defaultProviders: string[];
+}
+
+function ModelsTab({ mobile }: { mobile: boolean }) {
+ const [models, setModels] = useState([]);
+ const [usage, setUsage] = useState<{ summary: UsageSummaryDisplay[]; log: UsageLogEntry[] }>({ summary: [], log: [] });
+ const [agents, setAgents] = useState([]);
+ const [runtimes, setRuntimes] = useState([]);
+ const [view, setView] = useState<"agents" | "catalog" | "usage">("agents");
+ const [providerFilter, setProviderFilter] = useState("ALL");
+ const [addingModel, setAddingModel] = useState(false);
+ const [newModelId, setNewModelId] = useState("");
+ const [newModelName, setNewModelName] = useState("");
+ const [newModelProvider, setNewModelProvider] = useState("anthropic");
+ const [newModelCtx, setNewModelCtx] = useState("");
+ const [newModelCostIn, setNewModelCostIn] = useState("");
+ const [newModelCostOut, setNewModelCostOut] = useState("");
+ // Agent creation state
+ const [addingAgent, setAddingAgent] = useState(false);
+ const [newAgentName, setNewAgentName] = useState("");
+ const [newAgentRuntime, setNewAgentRuntime] = useState("claude-code");
+ const [newAgentModel, setNewAgentModel] = useState("");
+ const [newAgentProvider, setNewAgentProvider] = useState("anthropic");
+
+ const loadModels = () => {
+ fetch("/api/models/curated").then(r => r.json()).then(setModels).catch(() => {});
+ };
+ const loadUsage = () => {
+ fetch("/api/models/usage").then(r => r.json()).then(setUsage).catch(() => {});
+ };
+ const loadAgents = () => {
+ fetch("/api/agents").then(r => r.json()).then(data => {
+ setAgents(data.configs || []);
+ setRuntimes(data.runtimes || []);
+ }).catch(() => {});
+ };
+
+ useEffect(() => { loadModels(); loadUsage(); loadAgents(); }, []);
+
+ const toggleModel = async (id: string) => {
+ await fetch("/api/models/curated", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ action: "toggle", id }),
+ });
+ loadModels();
+ };
+
+ const deleteModel = async (id: string) => {
+ await fetch(`/api/models/curated?id=${id}`, { method: "DELETE" });
+ loadModels();
+ };
+
+ const addModel = async () => {
+ if (!newModelId.trim() || !newModelProvider) return;
+ await fetch("/api/models/curated", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: newModelId.trim(),
+ name: newModelName.trim() || newModelId.trim(),
+ provider: newModelProvider,
+ contextWindow: newModelCtx ? parseInt(newModelCtx) : undefined,
+ costPer1kInput: newModelCostIn ? parseFloat(newModelCostIn) : undefined,
+ costPer1kOutput: newModelCostOut ? parseFloat(newModelCostOut) : undefined,
+ }),
+ });
+ setNewModelId(""); setNewModelName(""); setNewModelCtx("");
+ setNewModelCostIn(""); setNewModelCostOut(""); setAddingModel(false);
+ loadModels();
+ };
+
+ const providers = ["ALL", ...Array.from(new Set(models.map(m => m.provider)))];
+ const filtered = providerFilter === "ALL" ? models : models.filter(m => m.provider === providerFilter);
+
+ // Aggregate usage by provider
+ const providerTotals = usage.summary.reduce((acc, s) => {
+ const p = s.provider;
+ if (!acc[p]) acc[p] = { provider: p, totalCost: 0, totalInputTokens: 0, totalOutputTokens: 0, totalRequests: 0 };
+ acc[p].totalCost += s.totalCost;
+ acc[p].totalInputTokens += s.totalInputTokens;
+ acc[p].totalOutputTokens += s.totalOutputTokens;
+ acc[p].totalRequests += s.totalRequests;
+ return acc;
+ }, {} as Record);
+
+ const grandTotal = usage.summary.reduce((acc, s) => ({
+ cost: acc.cost + s.totalCost,
+ input: acc.input + s.totalInputTokens,
+ output: acc.output + s.totalOutputTokens,
+ requests: acc.requests + s.totalRequests,
+ }), { cost: 0, input: 0, output: 0, requests: 0 });
+
+ return (
+
+
+ {/* View toggle */}
+
+
+ {(["agents", "catalog", "usage"] as const).map(v => (
+
+ ))}
+
+ {view === "agents" && (
+
setAddingAgent(!addingAgent)}
+ style={{ fontSize: tokens.size.xs, minHeight: 32 }}>
+ {addingAgent ? "CANCEL" : "+ NEW AGENT"}
+
+ )}
+ {view === "catalog" && (
+
setAddingModel(!addingModel)}
+ style={{ fontSize: tokens.size.xs, minHeight: 32 }}>
+ {addingModel ? "CANCEL" : "+ ADD MODEL"}
+
+ )}
+
+
+ {/* ─── AGENTS VIEW ─── */}
+ {view === "agents" && (
+ <>
+ {/* Agent creation form */}
+ {addingAgent && (
+
+
+
+
+
+
+ {(["claude-code", "codex", "opencode"] as const).map(rt => {
+ const rtInfo = runtimes.find(r => r.id === rt);
+ return (
+
+ );
+ })}
+
+
+
+
+
+ setNewAgentName(e.target.value)} placeholder="e.g. Claude Code · Sonnet 4" />
+
+
+
+
+ {["anthropic", "openai", "openrouter", "google", "opencode-zen"].map(p => (
+
+ ))}
+
+
+
+
+
+ m.provider === newAgentProvider && m.enabled).map(m => ({
+ value: m.id,
+ label: m.name,
+ detail: `${m.provider}${m.contextWindow ? ` · ${formatTokens(m.contextWindow)} ctx` : ""}`,
+ }))}
+ value={newAgentModel}
+ onChange={(v) => setNewAgentModel(v as string)}
+ placeholder="Select a model from the catalog..."
+ />
+
+
{
+ await fetch("/api/agents", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: newAgentName.trim() || `${RUNTIME_LABELS[newAgentRuntime]} · ${newAgentModel}`,
+ runtime: newAgentRuntime,
+ modelId: newAgentModel,
+ provider: newAgentProvider,
+ }),
+ });
+ setNewAgentName(""); setNewAgentModel(""); setAddingAgent(false);
+ loadAgents();
+ }}
+ style={{ alignSelf: "flex-start" }}>
+ CREATE AGENT
+
+
+
+ )}
+
+ {/* Agent list grouped by runtime */}
+ {(["claude-code", "codex", "opencode"] as const).map(rt => {
+ const rtAgents = agents.filter(a => a.runtime === rt);
+ if (rtAgents.length === 0) return null;
+ return (
+
+
+
+
+
+
+ {rtAgents.map(a => (
+
+
+
+
{
+ await fetch(`/api/agents?id=${a.id}`, { method: "DELETE" });
+ loadAgents();
+ }} style={{ fontSize: tokens.size.xs, minHeight: 32, color: tokens.color.fail }}>
+ DELETE
+
+
+
+ ))}
+
+
+ );
+ })}
+
+ {agents.length === 0 && !addingAgent && (
+
+ No agents configured. Click + NEW AGENT to create one.
+
+ )}
+
+
+
+ >
+ )}
+
+ {/* ─── CATALOG VIEW ─── */}
+ {view === "catalog" && (
+ <>
+ {/* Add model form */}
+ {addingModel && (
+
+
+
+
+
+
+
+
+ {["anthropic", "openai", "openrouter", "google", "opencode-zen"].map(p => (
+
+ ))}
+
+
+
+
+ setNewModelCtx(e.target.value)} placeholder="200000" />
+
+
+
+ setNewModelCostIn(e.target.value)} placeholder="0.003" />
+
+
+
+ setNewModelCostOut(e.target.value)} placeholder="0.015" />
+
+
+
+ ADD TO CATALOG
+
+
+
+ )}
+
+ {/* Provider filter */}
+
+ {providers.map(p => (
+
+ ))}
+
+
+ {/* Model list */}
+
+ {filtered.map(m => (
+
+
+
+
+
+
{m.name}
+ {!m.enabled &&
}
+
+
{m.id}
+
+
+ {m.contextWindow && (
+
+ {formatTokens(m.contextWindow)}
+
+
+ )}
+ {m.costPer1kInput != null && (
+
+ ${m.costPer1kInput}
+
+
+ )}
+ {m.costPer1kOutput != null && (
+
+ ${m.costPer1kOutput}
+
+
+ )}
+
+ toggleModel(m.id)}
+ style={{ fontSize: tokens.size.xs, minHeight: 32 }}>
+ {m.enabled ? "DISABLE" : "ENABLE"}
+
+ deleteModel(m.id)}
+ style={{ fontSize: tokens.size.xs, minHeight: 32, color: tokens.color.fail }}>
+ DELETE
+
+
+
+
+
+ ))}
+
+
+ >
+ )}
+
+ {/* ─── USAGE VIEW ─── */}
+ {view === "usage" && (
+ <>
+ {/* Grand total */}
+
+
+
+
+
+
+ {formatCost(grandTotal.cost)}
+
+
+
+
+
+ {formatTokens(grandTotal.input + grandTotal.output)}
+
+
+
+
+
+ {grandTotal.requests}
+
+
+
+
+
+
+
+ {/* By provider */}
+
+
+
+ {Object.values(providerTotals).sort((a, b) => b.totalCost - a.totalCost).map(p => (
+
+
+
+
+
{p.provider.toUpperCase()}
+
+
+
+ {formatCost(p.totalCost)}
+
+
+
+ {formatTokens(p.totalInputTokens)} in
+ {formatTokens(p.totalOutputTokens)} out
+
+
+ {p.totalRequests} req
+
+
+
+
+ ))}
+
+
+
+ {/* By model */}
+
+
+
+ {usage.summary.map(s => {
+ const costPct = grandTotal.cost > 0 ? (s.totalCost / grandTotal.cost) * 100 : 0;
+ return (
+
+
+
+
+ {formatCost(s.totalCost)}
+ {formatTokens(s.totalInputTokens)} in · {formatTokens(s.totalOutputTokens)} out
+ {s.totalRequests} req
+ {costPct.toFixed(1)}%
+
+
+ {/* Cost bar */}
+
+
+ );
+ })}
+
+
+
+ {/* Recent activity */}
+
+
+
+ {[...usage.log].reverse().slice(0, 20).map((entry, i) => (
+
+
+
+
{entry.modelId}
+
·
+
{entry.taskSlug} iter {entry.iteration}
+
+
+ {formatTokens(entry.inputTokens)} in · {formatTokens(entry.outputTokens)} out
+ {(entry.durationMs / 1000).toFixed(0)}s
+
+
+ ))}
+
+
+ >
+ )}
+
+
+ );
+}
+
+// ─── BOTTOM NAV (mobile only) ─────────────────────────────────────────────────
+
+const NAV_ITEMS = [
+ { id: "LOOPS", icon: "⟳", label: "LOOPS" },
+ { id: "PROJECTS", icon: "◫", label: "PROJECTS" },
+ { id: "MODELS", icon: "◈", label: "MODELS" },
+ { id: "KNOWLEDGE", icon: "≡", label: "DOCS" },
+ { id: "NEW TASK", icon: "+", label: "NEW" },
+];
+
+function BottomNav({ activeTab, setActiveTab, tasks }: { activeTab: string; setActiveTab: (t: string) => void; tasks: Task[] }) {
+ const running = tasks.filter(t => t.status === "running").length;
+ return (
+
+ {NAV_ITEMS.map(item => {
+ const active = activeTab === item.id;
+ const showBadge = item.id === "LOOPS" && running > 0;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+// ─── TOP BAR ─────────────────────────────────────────────────────────────────
+
+function TopBar({ activeTab, setActiveTab, tasks, mobile }: { activeTab: string; setActiveTab: (t: string) => void; tasks: Task[]; mobile: boolean }) {
+ const running = tasks.filter(t => t.status === "running").length;
+ const pending = tasks.filter(t => t.status === "pending").length;
+ const tabs = ["LOOPS", "PROJECTS", "MODELS", "KNOWLEDGE", "HISTORY", "NEW TASK"];
+
+ return (
+
+
+
+
+ {!mobile &&
}
+
+
+ {!mobile && (
+ <>
+
+ {tabs.map(tab => (
+
+ ))}
+
+
+
+
+
+
+ >
+ )}
+
+ {mobile && (
+
+ {running > 0 && (
+
+ )}
+
+ )}
+
+ );
+}
+
+// ─── ROOT ─────────────────────────────────────────────────────────────────────
+
+export default function HarnessDashboard() {
+ const [activeTab, setActiveTab] = useState("LOOPS");
+ const [tasks, setTasks] = useState(MOCK_TASKS);
+ const mobile = useIsMobile();
+
+ const [projects, setProjects] = useState(MOCK_PROJECTS);
+
+ const handleNewTask = (form: TaskForm) => {
+ const proj = projects.find(p => p.id === form.projectId);
+ setTasks(prev => [...prev, {
+ id: `task-${Date.now()}`, slug: form.slug, goal: form.goal,
+ project: proj?.name || "—", status: "pending",
+ iteration: 0, maxIterations: parseInt(form.maxIterations) || 6,
+ startedAt: null, evals: {}, iterations: [],
+ }]);
+ setActiveTab("LOOPS");
+ };
+
+ return (
+
+
+
+
+
+
+ {activeTab === "LOOPS" &&
}
+ {activeTab === "PROJECTS" &&
}
+ {activeTab === "MODELS" &&
}
+ {activeTab === "KNOWLEDGE" &&
}
+ {activeTab === "HISTORY" &&
}
+ {activeTab === "NEW TASK" &&
}
+
+
+ {mobile &&
}
+
+ );
+}
diff --git a/apps/harness/src/components/harness-design-system.tsx b/apps/harness/src/components/harness-design-system.tsx
new file mode 100644
index 0000000..78486aa
--- /dev/null
+++ b/apps/harness/src/components/harness-design-system.tsx
@@ -0,0 +1,541 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+
+// ============================================================
+// HARNESS DESIGN SYSTEM
+// Import this file and destructure what you need.
+// ============================================================
+
+// ─── TOKENS ─────────────────────────────────────────────────
+
+export const tokens = {
+ // Colour palette
+ color: {
+ // Backgrounds — darkest to lightest
+ bg0: "#060a0f", // page root
+ bg1: "#0d1117", // card / panel
+ bg2: "#111827", // nested surface
+ bg3: "#1f2937", // hover state / divider fill
+
+ // Borders
+ border0: "#1f2937", // structural borders (topbar, panel edges)
+ border1: "#374151", // interactive borders (buttons, inputs)
+ border2: "#4b5563", // focus rings
+
+ // Text
+ text0: "#f9fafb", // primary — headings, active labels
+ text1: "#9ca3af", // secondary — body, descriptions
+ text2: "#4b5563", // muted — metadata, timestamps
+ text3: "#374151", // faintest — placeholders, dividers
+
+ // Semantic — signal colours
+ pass: "#00ff9f", // success, running, online
+ passDim: "#064e3b", // pass border / bg tint
+ fail: "#f87171", // failure, error
+ failDim: "#7f1d1d", // fail border / bg tint
+ warn: "#f59e0b", // stale, warning
+ warnDim: "#78350f", // warn border / bg tint
+ info: "#7dd3fc", // completed, informational
+ infoDim: "#0c4a6e", // info border / bg tint
+ purple: "#a78bfa", // decision records, AI-authored
+ purpleDim: "#3b0764", // purple border / bg tint
+ muted: "#6b7280", // pending, disabled, unknown
+
+ // Accent — brand
+ accent: "#00ff9f", // == pass, primary accent
+ accentGlow:"0 0 8px #00ff9f",
+ },
+
+ // Typography
+ font: {
+ mono: "'Courier New', 'Lucida Console', monospace",
+ sans: "'IBM Plex Sans', 'Helvetica Neue', sans-serif",
+ },
+
+ // Font sizes (px)
+ size: {
+ xs: 13,
+ sm: 14,
+ md: 15,
+ base:16,
+ lg: 18,
+ xl: 26,
+ xxl: 34,
+ },
+
+ // Letter spacing
+ tracking: {
+ tight: "0.02em",
+ normal: "0.08em",
+ wide: "0.12em",
+ wider: "0.15em",
+ },
+
+ // Spacing (px) — 4pt grid
+ space: {
+ 1: 4,
+ 2: 8,
+ 3: 12,
+ 4: 16,
+ 5: 20,
+ 6: 24,
+ 8: 32,
+ } as Record,
+
+ // Border radius — intentionally minimal (tool aesthetic)
+ radius: {
+ none: 0,
+ sm: 2,
+ },
+
+ // Transitions
+ transition: {
+ fast: "all 0.1s ease",
+ normal: "all 0.15s ease",
+ },
+
+ // Touch targets
+ touch: { min: 44 },
+};
+
+// ─── STATUS CONFIG ───────────────────────────────────────────
+// Single source of truth for all status variants
+
+export const STATUS: Record = {
+ running: { label: "RUNNING", color: tokens.color.pass, dim: tokens.color.passDim, dot: true },
+ completed: { label: "COMPLETED", color: tokens.color.info, dim: tokens.color.infoDim, dot: false },
+ pending: { label: "PENDING", color: tokens.color.muted, dim: tokens.color.bg3, dot: false },
+ failed: { label: "FAILED", color: tokens.color.fail, dim: tokens.color.failDim, dot: false },
+ passed: { label: "PASSED", color: tokens.color.pass, dim: tokens.color.passDim, dot: false },
+ stale: { label: "STALE", color: tokens.color.warn, dim: tokens.color.warnDim, dot: false },
+ verified: { label: "VERIFIED", color: tokens.color.pass, dim: tokens.color.passDim, dot: false },
+ decision: { label: "DECISION", color: tokens.color.purple, dim: tokens.color.purpleDim, dot: false },
+ open: { label: "OPEN", color: tokens.color.info, dim: tokens.color.infoDim, dot: false },
+};
+
+// ─── PRIMITIVE COMPONENTS ────────────────────────────────────
+
+// Label — all-caps mono metadata tag
+export function Label({ children, color, style }: { children: React.ReactNode; color?: string; style?: React.CSSProperties }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// Mono — inline monospace text, body weight
+export function Mono({ children, size, color, style }: { children: React.ReactNode; size?: number; color?: string; style?: React.CSSProperties }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// Divider — horizontal rule
+export function Divider({ style }: { style?: React.CSSProperties }) {
+ return (
+
+ );
+}
+
+// StatusBadge — RUNNING / FAILED / VERIFIED etc.
+export function StatusBadge({ status, style }: { status: string; style?: React.CSSProperties }) {
+ const s = STATUS[status] || { label: (status || "UNKNOWN").toUpperCase(), color: tokens.color.muted, dim: tokens.color.bg3, dot: false };
+ return (
+
+ {s.dot && (
+
+ )}
+ {s.label}
+
+ );
+}
+
+// Panel — surface container
+export function Panel({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// PanelHeader — labelled top edge of a panel
+export function PanelHeader({ label, children, style }: { label: string; children?: React.ReactNode; style?: React.CSSProperties }) {
+ return (
+
+
+ {children &&
{children}
}
+
+ );
+}
+
+// Btn — button with variants
+export function Btn({ children, variant = "default", onClick, style, disabled }: {
+ children: React.ReactNode;
+ variant?: "primary" | "danger" | "default" | "ghost";
+ onClick?: (e: React.MouseEvent) => void;
+ style?: React.CSSProperties;
+ disabled?: boolean;
+}) {
+ const [hov, setHov] = useState(false);
+ const v = {
+ primary: { border: tokens.color.accent, color: tokens.color.accent },
+ danger: { border: tokens.color.fail, color: tokens.color.fail },
+ default: { border: tokens.color.border1, color: tokens.color.text1 },
+ ghost: { border: "transparent", color: tokens.color.text2 },
+ }[variant];
+ return (
+
+ );
+}
+
+// Input — text input field
+export function Input({ value, onChange, placeholder, style }: {
+ value: string;
+ onChange: (e: React.ChangeEvent) => void;
+ placeholder?: string;
+ style?: React.CSSProperties;
+}) {
+ const [foc, setFoc] = useState(false);
+ return (
+ setFoc(true)} onBlur={() => setFoc(false)}
+ style={{
+ background: tokens.color.bg0,
+ border: `1px solid ${foc ? tokens.color.border2 : tokens.color.border0}`,
+ color: tokens.color.text0, fontFamily: tokens.font.mono,
+ fontSize: tokens.size.lg,
+ padding: `${tokens.space[3]}px ${tokens.space[3]}px`,
+ minHeight: tokens.touch.min,
+ outline: "none", transition: tokens.transition.fast,
+ borderRadius: 0, width: "100%", boxSizing: "border-box" as const, ...style,
+ }} />
+ );
+}
+
+// Textarea
+export function Textarea({ value, onChange, placeholder, rows = 4, style }: {
+ value: string;
+ onChange: (e: React.ChangeEvent) => void;
+ placeholder?: string;
+ rows?: number;
+ style?: React.CSSProperties;
+}) {
+ const [foc, setFoc] = useState(false);
+ return (
+