From 3285ce29a2d5855337711b84768de75406e830df Mon Sep 17 00:00:00 2001 From: AIinWEB3 Date: Tue, 4 Aug 2026 18:15:06 -0700 Subject: [PATCH] feat: split Harvard demo into two applications Make the Cursor-style coding app the primary workshop demo and move LumaCare into a separate secondary npm workspace. Add independent setup guides, shared workspace commands, agent configuration, tests, and CI-compatible builds. --- .env.example | 1 + .gitignore | 2 +- CONTRIBUTING.md | 4 +- README.md | 72 ++++- demos/01-cursor-coding-app/.env.example | 3 + demos/01-cursor-coding-app/README.md | 51 +++ demos/01-cursor-coding-app/config/agents.json | 3 + demos/01-cursor-coding-app/index.html | 14 + demos/01-cursor-coding-app/package.json | 29 ++ .../01-cursor-coding-app/server}/auth.ts | 0 demos/01-cursor-coding-app/server/index.ts | 303 ++++++++++++++++++ .../01-cursor-coding-app/server}/store.ts | 0 .../01-cursor-coding-app/server}/types.ts | 0 demos/01-cursor-coding-app/src/App.tsx | 134 ++++++++ .../01-cursor-coding-app/src}/api.ts | 0 .../01-cursor-coding-app/src}/main.tsx | 0 demos/01-cursor-coding-app/src/styles.css | 5 + .../01-cursor-coding-app/src}/types.ts | 0 .../01-cursor-coding-app/tests}/sse.test.ts | 0 .../01-cursor-coding-app/tests/store.test.ts | 36 +++ .../01-cursor-coding-app/tsconfig.json | 0 .../01-cursor-coding-app/tsconfig.server.json | 0 .../01-cursor-coding-app/vite.config.ts | 0 demos/02-lumacare/.env.example | 2 + demos/02-lumacare/README.md | 43 +++ .../02-lumacare/config}/agents.json | 0 index.html => demos/02-lumacare/index.html | 0 demos/02-lumacare/package.json | 29 ++ demos/02-lumacare/server/auth.ts | 24 ++ {server => demos/02-lumacare/server}/index.ts | 4 +- demos/02-lumacare/server/store.ts | 46 +++ demos/02-lumacare/server/types.ts | 19 ++ {src => demos/02-lumacare/src}/App.tsx | 0 demos/02-lumacare/src/api.ts | 44 +++ demos/02-lumacare/src/main.tsx | 8 + {src => demos/02-lumacare/src}/styles.css | 0 demos/02-lumacare/src/types.ts | 19 ++ demos/02-lumacare/tests/sse.test.ts | 23 ++ .../02-lumacare/tests}/store.test.ts | 0 demos/02-lumacare/tsconfig.json | 20 ++ demos/02-lumacare/tsconfig.server.json | 12 + demos/02-lumacare/vite.config.ts | 8 + package-lock.json | 40 ++- package.json | 34 +- 44 files changed, 986 insertions(+), 46 deletions(-) create mode 100644 demos/01-cursor-coding-app/.env.example create mode 100644 demos/01-cursor-coding-app/README.md create mode 100644 demos/01-cursor-coding-app/config/agents.json create mode 100644 demos/01-cursor-coding-app/index.html create mode 100644 demos/01-cursor-coding-app/package.json rename {server => demos/01-cursor-coding-app/server}/auth.ts (100%) create mode 100644 demos/01-cursor-coding-app/server/index.ts rename {server => demos/01-cursor-coding-app/server}/store.ts (100%) rename {server => demos/01-cursor-coding-app/server}/types.ts (100%) create mode 100644 demos/01-cursor-coding-app/src/App.tsx rename {src => demos/01-cursor-coding-app/src}/api.ts (100%) rename {src => demos/01-cursor-coding-app/src}/main.tsx (100%) create mode 100644 demos/01-cursor-coding-app/src/styles.css rename {src => demos/01-cursor-coding-app/src}/types.ts (100%) rename {tests => demos/01-cursor-coding-app/tests}/sse.test.ts (100%) create mode 100644 demos/01-cursor-coding-app/tests/store.test.ts rename tsconfig.json => demos/01-cursor-coding-app/tsconfig.json (100%) rename tsconfig.server.json => demos/01-cursor-coding-app/tsconfig.server.json (100%) rename vite.config.ts => demos/01-cursor-coding-app/vite.config.ts (100%) create mode 100644 demos/02-lumacare/.env.example create mode 100644 demos/02-lumacare/README.md rename {config => demos/02-lumacare/config}/agents.json (100%) rename index.html => demos/02-lumacare/index.html (100%) create mode 100644 demos/02-lumacare/package.json create mode 100644 demos/02-lumacare/server/auth.ts rename {server => demos/02-lumacare/server}/index.ts (98%) create mode 100644 demos/02-lumacare/server/store.ts create mode 100644 demos/02-lumacare/server/types.ts rename {src => demos/02-lumacare/src}/App.tsx (100%) create mode 100644 demos/02-lumacare/src/api.ts create mode 100644 demos/02-lumacare/src/main.tsx rename {src => demos/02-lumacare/src}/styles.css (100%) create mode 100644 demos/02-lumacare/src/types.ts create mode 100644 demos/02-lumacare/tests/sse.test.ts rename {tests => demos/02-lumacare/tests}/store.test.ts (100%) create mode 100644 demos/02-lumacare/tsconfig.json create mode 100644 demos/02-lumacare/tsconfig.server.json create mode 100644 demos/02-lumacare/vite.config.ts diff --git a/.env.example b/.env.example index eb6f584..157f0a1 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ HR_API_KEY= +HR_CURSOR_AGENT_ID= PORT=3000 diff --git a/.gitignore b/.gitignore index 32bb174..0748d0e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ node_modules/ .next/ dist/ coverage/ -data/*.json +**/data/*.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe01543..b98481a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,10 @@ # Contributing -Thanks for helping improve Axiom. +Thanks for helping improve the HarnessRouter demo from the Harvard workshop. 1. Fork the repository and create a focused branch. 2. Keep HarnessRouter credentials in your local `.env` file only. 3. Run `npm test` and `npm run build` before opening a pull request. 4. Explain the user-facing behavior and security impact of your change. -Please keep pull requests small and avoid committing generated runtime data from `data/`. +Please keep pull requests small and avoid committing generated runtime data from `demos/*/data/`. diff --git a/README.md b/README.md index ab98a17..554b0e1 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,70 @@ -# LumaCare +# HarnessRouter Demo at Harvard -A compassionate family care companion for parents and caregivers of children receiving cancer care, powered by a purpose-configured HarnessRouter agent. +Open-source demo repository for the HarnessRouter workshop at Harvard. -## Run locally +This repository contains two separate applications. The **Cursor-style coding app is the primary workshop demo**; **LumaCare is a second reference demo** showing how the same HarnessRouter patterns transfer to a different domain. + +## Demos + +### 1. Cursor-style coding app — primary demo + +**Workshop:** Build a Cursor in 3 Steps + +A coding-agent interface that demonstrates project-aware tasks, streamed output, persistent sessions, follow-up work, cancellation, and generated-file downloads. + +[Open the Cursor-style demo guide](./demos/01-cursor-coding-app/README.md) ```bash npm install -npm run dev +cp .env.example .env +# Add HR_API_KEY and HR_CURSOR_AGENT_ID to .env +npm run dev:cursor +``` + +Then open [http://localhost:3000](http://localhost:3000). + +### 2. LumaCare — secondary reference demo + +A family-care companion that uses the same secure streaming and session architecture in a healthcare-support experience. + +[Open the LumaCare demo guide](./demos/02-lumacare/README.md) + +```bash +# Add HR_API_KEY to .env; LumaCare's agent mapping is checked in +npm run dev:lumacare +``` + +Then open [http://localhost:3000](http://localhost:3000). + +Run one demo at a time because both use port `3000` by default. + +## Repository structure + +```text +. +├── demos/ +│ ├── 01-cursor-coding-app/ # Primary Harvard workshop demo +│ └── 02-lumacare/ # Secondary reference demo +├── .env.example # Shared local configuration template +├── package.json # npm workspace commands +└── README.md # Repository and demo index ``` -Open `http://localhost:3000`. The server reads `HR_API_KEY` from the ignored `.env` file. +Each demo owns its frontend, server, tests, agent mapping, and documentation. This keeps the two products independent while making their shared HarnessRouter architecture easy to compare. -## What is included +## Prerequisites -- Calm, responsive family-facing care guidance experience -- Structured plain-language guides with urgent red flags, care-team questions, and practical support -- Data-only SSE parsing with immediate recovery-ID persistence -- Persistent Session Recall and Continue/Revise flows -- Product-level ownership checks for detail, continue, cancel, preview, and downloads -- Sandboxed HTML previews and authenticated artifact/archive proxying -- Demo identity switcher for exercising cross-user isolation +- Node.js 22 or newer +- A HarnessRouter API key +- A HarnessRouter coding-agent ID for the primary demo ## Commands ```bash -npm test # stream parser and ownership tests -npm run build # production frontend and server typecheck -npm run preview # serve the production build on port 3000 +npm run dev:cursor # Run the primary coding demo +npm run dev:lumacare # Run the secondary LumaCare demo +npm test # Test both workspaces +npm run build # Build and type-check both workspaces ``` -The committed feature mapping lives in `config/agents.json`. The HarnessRouter API key never reaches the browser. LumaCare provides supportive guidance only and never replaces the child's oncology team. +Credentials belong in an ignored `.env` file and never in source control. See each demo's README for its configuration and architecture. diff --git a/demos/01-cursor-coding-app/.env.example b/demos/01-cursor-coding-app/.env.example new file mode 100644 index 0000000..157f0a1 --- /dev/null +++ b/demos/01-cursor-coding-app/.env.example @@ -0,0 +1,3 @@ +HR_API_KEY= +HR_CURSOR_AGENT_ID= +PORT=3000 diff --git a/demos/01-cursor-coding-app/README.md b/demos/01-cursor-coding-app/README.md new file mode 100644 index 0000000..504b90b --- /dev/null +++ b/demos/01-cursor-coding-app/README.md @@ -0,0 +1,51 @@ +# Cursor-style Coding App + +The primary application for the **Build a Cursor in 3 Steps** HarnessRouter workshop at Harvard. + +This demo shows how to turn a purpose-configured HarnessRouter coding agent into a product experience with streamed output, recoverable sessions, follow-up tasks, cancellation, and artifact downloads. + +## Run it + +From the repository root: + +1. Install dependencies. + + ```bash + npm install + ``` + +2. Copy `.env.example` to the repository root as `.env`, then set both values. + + ```dotenv + HR_API_KEY=your_key_here + HR_CURSOR_AGENT_ID=your_coding_agent_id + PORT=3000 + ``` + +3. Start the primary demo. + + ```bash + npm run dev:cursor + ``` + +Open [http://localhost:3000](http://localhost:3000). + +## Architecture + +| Layer | Responsibility | +| --- | --- | +| `config/agents.json` | Declares the `coding_assistant` feature; `HR_CURSOR_AGENT_ID` supplies its HarnessRouter agent ID | +| `server/index.ts` | Keeps credentials server-side and proxies runs, sessions, cancellation, previews, and downloads | +| `src/api.ts` | Parses data-only server-sent events | +| `src/App.tsx` | Provides the Cursor-style coding-agent interface and session history | +| `tests/` | Covers stream parsing and cross-user session ownership | + +The demo intentionally does not include a production repository sandbox. Configure the HarnessRouter coding agent with the tools and repository access appropriate to your workshop environment. + +## Direct workspace commands + +```bash +npm run test --workspace=@harnessrouter/cursor-coding-app +npm run build --workspace=@harnessrouter/cursor-coding-app +npm run preview:cursor +``` diff --git a/demos/01-cursor-coding-app/config/agents.json b/demos/01-cursor-coding-app/config/agents.json new file mode 100644 index 0000000..5b5516e --- /dev/null +++ b/demos/01-cursor-coding-app/config/agents.json @@ -0,0 +1,3 @@ +{ + "coding_assistant": "" +} diff --git a/demos/01-cursor-coding-app/index.html b/demos/01-cursor-coding-app/index.html new file mode 100644 index 0000000..2c8416b --- /dev/null +++ b/demos/01-cursor-coding-app/index.html @@ -0,0 +1,14 @@ + + + + + + + + HarnessRouter Code — Cursor-style agent demo + + +
+ + + diff --git a/demos/01-cursor-coding-app/package.json b/demos/01-cursor-coding-app/package.json new file mode 100644 index 0000000..737ce04 --- /dev/null +++ b/demos/01-cursor-coding-app/package.json @@ -0,0 +1,29 @@ +{ + "name": "@harnessrouter/cursor-coding-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx server/index.ts", + "build": "vite build && tsc -p tsconfig.server.json --noEmit", + "preview": "NODE_ENV=production tsx server/index.ts", + "test": "node --import tsx --test tests/**/*.test.ts" + }, + "dependencies": { + "dotenv": "^16.4.7", + "express": "^5.1.0", + "lucide-react": "^0.468.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^22.10.5", + "@types/react": "^19.0.3", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^4.3.4", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vite": "^6.0.7" + } +} diff --git a/server/auth.ts b/demos/01-cursor-coding-app/server/auth.ts similarity index 100% rename from server/auth.ts rename to demos/01-cursor-coding-app/server/auth.ts diff --git a/demos/01-cursor-coding-app/server/index.ts b/demos/01-cursor-coding-app/server/index.ts new file mode 100644 index 0000000..964a0ba --- /dev/null +++ b/demos/01-cursor-coding-app/server/index.ts @@ -0,0 +1,303 @@ +import dotenv from 'dotenv'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import express, { type Request, type Response } from 'express'; +import { authenticate, DEMO_USERS } from './auth.js'; +import { OwnershipStore } from './store.js'; +import type { RunRecord } from './types.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +dotenv.config({ path: path.resolve(root, '../..', '.env') }); +dotenv.config({ path: path.join(root, '.env'), override: true }); +const agentMap = JSON.parse(fs.readFileSync(path.join(root, 'config/agents.json'), 'utf8')) as Record; +agentMap.coding_assistant = process.env.HR_CURSOR_AGENT_ID || agentMap.coding_assistant; +const store = new OwnershipStore(path.join(root, 'data/ownership.json')); +const apiBase = 'https://api.harnessrouter.ai'; +const apiKey = process.env.HR_API_KEY; + +if (!apiKey) throw new Error('HR_API_KEY is missing. Add it to the server-only .env file.'); + +export const app = express(); +app.disable('x-powered-by'); +app.use(express.json({ limit: '2mb' })); + +function upstreamHeaders(extra: Record = {}) { + return { Authorization: `Bearer ${apiKey}`, ...extra }; +} + +async function upstreamJson(urlPath: string, init: RequestInit = {}) { + const response = await fetch(`${apiBase}${urlPath}`, { + ...init, + headers: upstreamHeaders({ ...(init.headers as Record || {}) }), + }); + const data = await response.json().catch(() => ({ detail: 'HarnessRouter returned an unreadable response.' })); + if (!response.ok) { + const error = new Error(String((data as { detail?: string }).detail || `HarnessRouter request failed (${response.status})`)); + Object.assign(error, { status: response.status }); + throw error; + } + return data; +} + +function errorResponse(res: Response, error: unknown) { + const value = error as { status?: number; message?: string }; + const status = value.status && value.status >= 400 && value.status < 600 ? value.status : 502; + res.status(status).json({ error: value.message || 'The coding agent could not be reached.' }); +} + +function requireOwnership(req: Request, res: Response): RunRecord | null { + const record = store.getAuthorized(String(req.params.sessionId), req.productUser.id); + if (!record) { + res.status(404).json({ error: 'Session not found' }); + return null; + } + return record; +} + +app.get('/api/health', (_req, res) => res.json({ ok: true })); + +app.use('/api', authenticate); + +app.get('/api/config', (req, res) => { + res.json({ + user: req.productUser, + users: Object.values(DEMO_USERS), + features: Object.keys(agentMap), + agent: { name: 'HarnessRouter Coding Agent', model: 'Configured in HarnessRouter' }, + }); +}); + +app.get('/api/sessions', (req, res) => { + res.json({ sessions: store.list(req.productUser.id) }); +}); + +app.post('/api/runs', async (req, res) => { + const featureKey = String(req.body.featureKey || ''); + const input = String(req.body.input || '').trim(); + const previousResponseId = req.body.previousResponseId ? String(req.body.previousResponseId) : null; + const sessionId = req.body.sessionId ? String(req.body.sessionId) : null; + const harnessId = agentMap[featureKey]; + + if (!harnessId) return res.status(503).json({ error: 'Set HR_CURSOR_AGENT_ID in the root .env file before running the coding demo.' }); + if (!input || input.length > 20_000) return res.status(400).json({ error: 'Enter a task under 20,000 characters.' }); + if ((previousResponseId || sessionId) && !(previousResponseId && sessionId)) { + return res.status(400).json({ error: 'A continuation requires both recovery identifiers.' }); + } + if (sessionId && !store.getAuthorized(sessionId, req.productUser.id)) { + return res.status(404).json({ error: 'Session not found' }); + } + + const requestBody: Record = { input, stream: true }; + if (previousResponseId && sessionId) { + requestBody.previous_response_id = previousResponseId; + requestBody.metadata = { session_id: sessionId }; + } + + let upstream: globalThis.Response; + try { + upstream = await fetch(`${apiBase}/${harnessId}/v1/responses`, { + method: 'POST', + headers: upstreamHeaders({ + 'Content-Type': 'application/json', + 'Idempotency-Key': crypto.randomUUID(), + }), + body: JSON.stringify(requestBody), + }); + } catch { + return res.status(502).json({ error: 'HarnessRouter could not be reached.' }); + } + + if (!upstream.ok || !upstream.body) { + const detail = await upstream.json().catch(() => ({ detail: 'Agent request failed.' })) as { detail?: string }; + return res.status(upstream.status || 502).json({ error: detail.detail || 'Agent request failed.' }); + } + + res.status(200); + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const reader = upstream.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let recoveredSessionId = sessionId; + let recoveredResponseId = previousResponseId; + + const handleFrame = (frame: string) => { + const dataLines = frame.split(/\r?\n/).filter((line) => line.startsWith('data:')); + if (!dataLines.length) return; + const raw = dataLines.map((line) => line.slice(5).trimStart()).join('\n'); + if (raw === '[DONE]') return; + try { + const event = JSON.parse(raw) as any; + if (event.type === 'response.created') { + recoveredResponseId = event.response?.id || recoveredResponseId; + recoveredSessionId = event.response?.metadata?.session_id || recoveredSessionId; + if (recoveredResponseId && recoveredSessionId) { + const now = new Date().toISOString(); + const existing = store.getAuthorized(recoveredSessionId, req.productUser.id); + store.save({ + sessionId: recoveredSessionId, + responseId: recoveredResponseId, + userId: req.productUser.id, + featureKey, + title: existing?.title || (input.length > 62 ? `${input.slice(0, 62)}…` : input), + prompt: existing?.prompt || input, + createdAt: existing?.createdAt || now, + updatedAt: now, + status: 'running', + }); + } + } + const statusMap: Record = { + 'response.completed': 'completed', + 'response.incomplete': 'incomplete', + 'response.failed': event.response?.status === 'cancelled' ? 'cancelled' : 'failed', + }; + if (recoveredSessionId && statusMap[event.type]) { + store.update(recoveredSessionId, { + responseId: event.response?.id || recoveredResponseId || '', + status: statusMap[event.type], + }); + } + } catch { + // Unknown or malformed upstream frames are forwarded but never crash the stream. + } + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + if (!res.closed) res.write(text); + buffer += text; + let boundary = buffer.search(/\r?\n\r?\n/); + while (boundary >= 0) { + const frame = buffer.slice(0, boundary); + const separator = buffer.slice(boundary).match(/^\r?\n\r?\n/)?.[0].length || 2; + buffer = buffer.slice(boundary + separator); + handleFrame(frame); + boundary = buffer.search(/\r?\n\r?\n/); + } + } + if (buffer.trim()) handleFrame(buffer); + } catch { + if (!res.closed) { + res.write(`data: ${JSON.stringify({ type: 'coding.stream.disconnected', response: { id: recoveredResponseId, metadata: { session_id: recoveredSessionId } } })}\n\n`); + } + } finally { + if (!res.closed) res.end(); + } +}); + +app.get('/api/sessions/:sessionId', async (req, res) => { + const record = requireOwnership(req, res); + if (!record) return; + try { + const [session, turns, files] = await Promise.all([ + upstreamJson(`/v1/sessions/${encodeURIComponent(record.sessionId)}`), + upstreamJson(`/v1/sessions/${encodeURIComponent(record.sessionId)}/turns`), + upstreamJson(`/v1/sessions/${encodeURIComponent(record.sessionId)}/files?changed=true`), + ]); + const upstreamStatus = String((session as any).status || ''); + const mapped = upstreamStatus === 'done' ? 'completed' : upstreamStatus; + if (mapped && mapped !== 'running' && mapped !== 'starting') store.update(record.sessionId, { status: mapped }); + res.json({ record: { ...record, status: mapped || record.status }, session, turns, files }); + } catch (error) { + errorResponse(res, error); + } +}); + +app.post('/api/sessions/:sessionId/cancel', async (req, res) => { + const record = requireOwnership(req, res); + if (!record) return; + try { + const result = await upstreamJson(`/v1/sessions/${encodeURIComponent(record.sessionId)}/cancel`, { method: 'POST' }); + store.update(record.sessionId, { status: 'cancelled' }); + res.json(result); + } catch (error) { + errorResponse(res, error); + } +}); + +async function getAuthorizedFile(req: Request, res: Response) { + const record = requireOwnership(req, res); + if (!record) return null; + const listing = await upstreamJson(`/v1/sessions/${encodeURIComponent(record.sessionId)}/files`) as any; + const file = (listing.files || []).find((item: any) => item.file_id === req.params.fileId); + if (!file) { + res.status(404).json({ error: 'File not found' }); + return null; + } + return { record, file }; +} + +app.get('/api/sessions/:sessionId/files/:fileId/preview', async (req, res) => { + try { + const found = await getAuthorizedFile(req, res); + if (!found) return; + const officeTypes = new Set([ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ]); + const suffix = officeTypes.has(found.file.media_type) ? '/pdf' : '/content'; + const upstream = await fetch(`${apiBase}/v1/containers/${encodeURIComponent(found.record.sessionId)}/files/${encodeURIComponent(found.file.file_id)}${suffix}`, { headers: upstreamHeaders() }); + if (!upstream.ok || !upstream.body) return res.status(upstream.status).json({ error: 'Preview unavailable' }); + const mediaType = officeTypes.has(found.file.media_type) ? 'application/pdf' : found.file.media_type || 'application/octet-stream'; + res.setHeader('Content-Type', mediaType); + res.setHeader('Content-Disposition', `inline; filename="${path.basename(found.file.path).replace(/["\r\n]/g, '_')}"`); + const bytes = Buffer.from(await upstream.arrayBuffer()); + res.send(bytes); + } catch (error) { + errorResponse(res, error); + } +}); + +app.get('/api/sessions/:sessionId/files/:fileId/download', async (req, res) => { + try { + const found = await getAuthorizedFile(req, res); + if (!found) return; + const upstream = await fetch(`${apiBase}/v1/containers/${encodeURIComponent(found.record.sessionId)}/files/${encodeURIComponent(found.file.file_id)}/content`, { headers: upstreamHeaders() }); + if (!upstream.ok || !upstream.body) return res.status(upstream.status).json({ error: 'Download unavailable' }); + const filename = path.basename(found.file.path).replace(/["\r\n]/g, '_'); + res.setHeader('Content-Type', found.file.media_type || 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(Buffer.from(await upstream.arrayBuffer())); + } catch (error) { + errorResponse(res, error); + } +}); + +app.get('/api/sessions/:sessionId/archive', async (req, res) => { + const record = requireOwnership(req, res); + if (!record) return; + try { + const upstream = await fetch(`${apiBase}/v1/sessions/${encodeURIComponent(record.sessionId)}/files/archive?changed=true`, { headers: upstreamHeaders() }); + if (!upstream.ok || !upstream.body) return res.status(upstream.status).json({ error: 'Archive unavailable' }); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', 'attachment; filename="coding-session-files.zip"'); + res.send(Buffer.from(await upstream.arrayBuffer())); + } catch (error) { + errorResponse(res, error); + } +}); + +const port = Number(process.env.PORT || 3000); +if (process.env.NODE_ENV === 'production') { + app.use(express.static(path.join(root, 'dist'))); + app.get('*splat', (_req, res) => res.sendFile(path.join(root, 'dist/index.html'))); +} else { + const { createServer } = await import('vite'); + const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'spa' }); + app.use(vite.middlewares); +} + +if (process.env.NODE_ENV !== 'test') { + app.listen(port, () => console.log(`Cursor-style coding demo is running at http://localhost:${port}`)); +} diff --git a/server/store.ts b/demos/01-cursor-coding-app/server/store.ts similarity index 100% rename from server/store.ts rename to demos/01-cursor-coding-app/server/store.ts diff --git a/server/types.ts b/demos/01-cursor-coding-app/server/types.ts similarity index 100% rename from server/types.ts rename to demos/01-cursor-coding-app/server/types.ts diff --git a/demos/01-cursor-coding-app/src/App.tsx b/demos/01-cursor-coding-app/src/App.tsx new file mode 100644 index 0000000..45d323a --- /dev/null +++ b/demos/01-cursor-coding-app/src/App.tsx @@ -0,0 +1,134 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + ArrowUp, Bot, Braces, Check, ChevronRight, CircleStop, Clock3, Code2, + Download, FileCode2, Files, GitBranch, History, LoaderCircle, PanelLeft, + RefreshCw, Search, Sparkles, TerminalSquare, X, +} from 'lucide-react'; +import { api, apiHeaders, SSEParser, type StreamEvent } from './api'; +import type { AgentFile, SessionRecord } from './types'; + +type Config = { + user: { id: string; name: string; initials: string }; + users: { id: string; name: string; initials: string }[]; + agent: { name: string; model: string }; +}; + +const starterTasks = [ + { icon: Search, title: 'Understand a codebase', prompt: 'Inspect this project and explain its architecture, main data flow, and the best place to add a new feature.' }, + { icon: Braces, title: 'Implement a feature', prompt: 'Add a small, well-tested feature to this project. Explain the plan first, then make the change and verify it.' }, + { icon: TerminalSquare, title: 'Debug a failure', prompt: 'Investigate the current failing test or build error, identify the root cause, and propose the smallest safe fix.' }, +]; + +function extractTurnText(payload: any): string { + const turns = Array.isArray(payload) ? payload : payload?.turns || payload?.data || []; + const values: string[] = []; + const walk = (value: any) => { + if (!value || typeof value === 'string') return; + if (Array.isArray(value)) return value.forEach(walk); + if (typeof value === 'object') { + if ((value.type === 'output_text' || value.type === 'text') && typeof value.text === 'string') values.push(value.text); + Object.values(value).forEach(walk); + } + }; + turns.slice(-2).forEach(walk); + return values.at(-1) || ''; +} + +function AgentOutput({ text }: { text: string }) { + return
{text.split('\n').map((line, index) => { + const clean = line.trim(); + if (!clean) return
; + if (clean.startsWith('```')) return
code
; + if (clean.startsWith('## ')) return

{clean.slice(3)}

; + if (clean.startsWith('# ')) return

{clean.slice(2)}

; + if (/^[-*] /.test(clean)) return
{clean.slice(2).replace(/\*\*/g, '')}
; + if (/^\d+\. /.test(clean)) return
{clean.match(/^\d+/)?.[0]}{clean.replace(/^\d+\. /, '').replace(/\*\*/g, '')}
; + return

{clean.replace(/\*\*/g, '')}

; + })}
; +} + +export default function App() { + const [userId, setUserId] = useState(localStorage.getItem('coding-demo-user') || 'alice'); + const [config, setConfig] = useState(null); + const [sessions, setSessions] = useState([]); + const [activeSession, setActiveSession] = useState(null); + const [files, setFiles] = useState([]); + const [input, setInput] = useState(''); + const [answer, setAnswer] = useState(''); + const [status, setStatus] = useState<'idle' | 'running' | 'completed' | 'incomplete' | 'failed' | 'cancelled'>('idle'); + const [error, setError] = useState(''); + const [historyOpen, setHistoryOpen] = useState(false); + const composerRef = useRef(null); + + const loadSessions = useCallback(async () => { + const result = await api<{ sessions: SessionRecord[] }>('/api/sessions', userId); + setSessions(result.sessions); + }, [userId]); + + useEffect(() => { + localStorage.setItem('coding-demo-user', userId); + setActiveSession(null); setAnswer(''); setFiles([]); setStatus('idle'); + api('/api/config', userId).then(setConfig).catch((e) => setError(e.message)); + loadSessions().catch(() => {}); + }, [userId, loadSessions]); + + const refreshSession = useCallback(async (session: SessionRecord) => { + const data = await api(`/api/sessions/${session.sessionId}`, userId); + const record = data.record as SessionRecord; + setActiveSession(record); setStatus(record.status === 'done' ? 'completed' : record.status as any); + setFiles((data.files?.files || []) as AgentFile[]); + const recovered = extractTurnText(data.turns); if (recovered) setAnswer(recovered); + loadSessions().catch(() => {}); return record; + }, [loadSessions, userId]); + + const handleEvent = useCallback((event: StreamEvent, prompt: string) => { + if (event.type === 'response.created') setActiveSession({ sessionId: event.response?.metadata?.session_id, responseId: event.response?.id, userId, title: prompt.length > 64 ? `${prompt.slice(0, 64)}…` : prompt, prompt, status: 'running', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }); + else if (event.type === 'response.output_text.delta') setAnswer((value) => value + String(event.delta || '')); + else if (event.type === 'response.completed' || event.type === 'response.incomplete' || event.type === 'response.failed') setStatus(event.type === 'response.completed' ? 'completed' : event.type === 'response.incomplete' ? 'incomplete' : event.response?.status === 'cancelled' ? 'cancelled' : 'failed'); + }, [userId]); + + const runTask = async (continuation?: string) => { + const prompt = (continuation || input).trim(); + if (!prompt || status === 'running') return; + setError(''); setAnswer(''); setFiles([]); setStatus('running'); if (!continuation) setInput(''); + try { + const body: Record = { featureKey: 'coding_assistant', input: prompt }; + if (activeSession && continuation) { body.sessionId = activeSession.sessionId; body.previousResponseId = activeSession.responseId; } + const response = await fetch('/api/runs', { method: 'POST', headers: apiHeaders(userId, true), body: JSON.stringify(body) }); + if (!response.ok || !response.body) { const data = await response.json().catch(() => ({})); throw new Error(data.error || 'The coding agent could not start.'); } + const parser = new SSEParser(); const reader = response.body.getReader(); const decoder = new TextDecoder(); + while (true) { const { done, value } = await reader.read(); if (done) break; parser.push(decoder.decode(value, { stream: true })).forEach((event) => handleEvent(event, prompt)); } + parser.flush().forEach((event) => handleEvent(event, prompt)); + setTimeout(() => setActiveSession((current) => { if (current) refreshSession(current).catch(() => {}); return current; }), 700); + } catch (e) { setStatus('failed'); setError((e as Error).message); } + }; + + const openSession = async (session: SessionRecord) => { setHistoryOpen(false); setAnswer(''); setStatus('running'); try { await refreshSession(session); } catch (e) { setError((e as Error).message); setStatus('failed'); } }; + const cancelRun = async () => { if (!activeSession) return; await api(`/api/sessions/${activeSession.sessionId}/cancel`, userId, { method: 'POST' }); setStatus('cancelled'); }; + const usePrompt = (prompt: string) => { setInput(prompt); requestAnimationFrame(() => composerRef.current?.focus()); }; + const downloadFiles = async () => { + if (!activeSession) return; + const response = await fetch(`/api/sessions/${activeSession.sessionId}/archive`, { headers: apiHeaders(userId) }); + if (!response.ok) return setError('The session files could not be downloaded.'); + const url = URL.createObjectURL(await response.blob()); const link = document.createElement('a'); link.href = url; link.download = 'coding-session-files.zip'; link.click(); URL.revokeObjectURL(url); + }; + + const user = config?.users.find((item) => item.id === userId); + return
+ + +
+
HarnessRouter Agent
{status === 'running' ? 'Agent working' : 'Ready'}
+
+
Cursor-style coding agent{config?.agent.model || 'HarnessRouter'}
+
+ {!answer && status !== 'running' ?

HARNESSROUTER AT HARVARD

Build a Cursor in
3 steps.

A coding agent that can understand a project, plan changes, stream its work, and preserve the session for follow-up.

{starterTasks.map(({ icon: Icon, title, prompt }) => )}
:
HarnessRouter Agent{status === 'running' ? 'Working through the repository…' : 'Task response'}
{files.length > 0 && }
{status === 'running' && !answer ?
Reading context and planning the change…
: }{status === 'incomplete' && }
} +
+