# Project export: Baton — Verified AI Handoffs

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Baton hands an unfinished coding task from one AI agent to the next, carrying full context so you never re-explain, then proving it's actually done with your tests.
- Devpost: https://devpost.com/software/baton-verified-ai-agent-handoffs
- GitHub: https://github.com/Unieggy/relay
- Video: https://www.youtube.com/embed/OPI_Llfst7g?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 4 GitHub contributor(s) — Syed Mohammad Husain (59 commits), Unieggy (18 commits), Claude Opus 4.8 (13 commits), jduhking (13 commits)

## Devpost submission (written by the team)

### Overview

Baton compiles the noisy, half-finished work of one AI agent into the smallest verified state another agent needs to continue. It transfers work between independent coding tools (Claude Code and Codex CLI) without re-explaining the task, and at no extra API cost.

### Inspiration

Anyone who codes with AI agents has hit the same wall. You spend forty minutes with Claude Code on a hard bug. It finally has the context: the repo layout, the failing test, the approaches you already ruled out. Then it hits a usage limit, and you are starting over in a fresh Codex window, retyping the whole story from memory. The industry has spent two years making each agent smarter, with larger context windows and better reasoning. No one built the part that lets one agent pass the work to another. As the number of usable models grows and rate limits stay real, the problem stops being "is the model smart enough" and becomes "can I keep working when this one runs out." That gap, continuity, is what we set out to close. What It Does When an agent hits a usage limit, crashes, or stalls mid-task, Baton does four things: Freeze. It snapshots the workspace from facts, not narration: git diff, test exit codes, terminal output, and a regex code skeleton. Compress. It distills that into a small, schema-validated handoff packet, roughly 95 percent smaller than the raw session. The packet keeps the one thing a summary throws away: failure memory, the "do not re-run this migration blindly, the first attempt half-succeeded" knowledge. Switch. It launches a different agent (Claude Code or Codex CLI) in the same repository, working from that packet alone. Verify. It runs your real verification command, such as npm test, and reports the actual exit code and final diff. It does not claim success. It proves it or it does not. The developer never re-explains the task during the transfer. And Baton drives your already-authenticated local CLIs (claude -p, codex exec) rather than metered REST APIs, so a handoff adds nothing to your bill. How this differs from what already exists Most of the market has been competing on the model axis. Baton works on a different one, continuity, and that axis becomes more valuable as the set of available models keeps splitting. How We Built It The architecture is provider-neutral, with shared contracts as the only dependency boundary. Shared contracts (packages/shared). Runtime-validated Zod schemas (RelayEvent, HandoffPacket). One definition produces both the validator and the TypeScript type, so no layer can emit a malformed packet. Node and TypeScript server. A guarded session state machine, a generic process runner built on node:child_process that turns any CLI's lifecycle into typed events, provider adapters for Claude and Codex, the orchestrator that runs detect, freeze, distill, launch, and verify, and a WebSocket broadcaster for the live timeline. The compressor. It gathers evidence deterministically (git, regex skeleton, exit codes), then gives the language model exactly one job: distill. The output is schema-validated, and on any failure a deterministic fallback packet is built from the raw evidence, so compression is never a single point of failure. The verifier. It runs the stored command through the shell and treats the exit code as the only source of truth. Event store. Every event streams to Redis so the timeline survives a refresh, or to an in-memory store with the same interface. The engine never imports Redis; adapters emit into a sink and do not know who is listening. Front end. A React and Vite dashboard, plus an Electron desktop companion that docks to a screen edge and adds a native folder picker. Built with: TypeScript, Node.js, React, Vite, Electron, Redis, WebSocket, Zod, Git, Claude, Codex. Challenges We Ran Into Deciding what not to store. The git diff is re-derivable because the next agent reads the repo itself. So the packet had to carry only the parts that cannot be recovered: intent, decisions, and failure memory. Drawing that line took several rewrites. Headless agents that would not act. Run with -p or exec, real Claude and Codex would hang waiting for a stdin EOF, and would refuse to edit files because headless mode denies writes with no human to approve them. The agent would describe the correct fix and change nothing. Closing stdin and adding two permission flags (--permission-mode acceptEdits, --sandbox workspace-write) turned a frozen screen into an agent that actually edits the repo. Trusting evidence over claims. We repeatedly caught agents reporting success on red tests. That pushed the design toward an exit-code-only verifier and toward compressing facts instead of summaries. Regex instead of an AST, on purpose. The next agent reads the real code, so we only need to point at the surface, not parse it. A line-anchored regex skeleton was the right cost-for-precision trade, and resisting the heavier option was its own discipline. Provider neutrality as a constraint. Keeping the engine from importing Redis, an adapter, or the UI meant continually refusing convenient shortcuts. Accomplishments That We're Proud Of A handoff that crosses both a vendor boundary and a usage limit: Claude's session compressed into a portable packet, Codex resuming the same repository from it, and a passing test confirming the result. No added cost. Reusing local CLI auth means switching models is free, with no API meter and no surprise bill. Failure memory. The packet's pitfalls field is what makes the second agent start ahead of a cold session rather than from zero. A closed loop. Most handoff tools stop at "here is the context." Baton stops at a real exit code. A clean contract spine. Because Zod schemas are the single boundary, adding a new provider is just another adapter. What We Learned As models commoditize, the scarce resource is not a smarter agent but uninterrupted work across agents. The connective layer is the product. Self-reported progress is unreliable; executable evidence is not. Designing around that made the whole system more robust. The value of compression is in what you can safely discard. A good packet is small because the repository on disk is the source of truth. The unglamorous layer decides everything. Stdin handling, permission flags, and path resolution were the difference between a good idea and a working handoff. What's Next for Baton Hardened real multi-CLI runs with authenticated Claude and Codex, streaming each step live instead of a single JSON blob. A context firewall: deterministic redaction of secrets (API keys, tokens, .env assignments, private keys) before any evidence is distilled, stored, or shown. More providers behind the same neutral adapter, including Gemini and local models, plus controlled multi-hop handoffs. RelayBench: measured comparisons of task completion with and without a clean handoff. A hosted, sandboxed demo so anyone can watch a handoff happen without installing anything. Session persistence across restarts, so a frozen session can be resumed later, on another machine, with a different model.

## README (from the GitHub repository)

<p align="center">
  <img src="docs/baton-logo.png" alt="Baton" width="180">
</p>

<h1 align="center">Baton</h1>

<p align="center">
  <strong>Evidence-backed handoffs between AI coding agents.</strong>
</p>

<p align="center">
  <a href="https://github.com/Myst1C13/Baton/actions/workflows/ci.yml"><img src="https://github.com/Myst1C13/Baton/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT License"></a>
  <a href="package.json"><img src="https://img.shields.io/badge/node-%E2%89%A522-3c873a.svg" alt="Node 22 or newer"></a>
  <a href="tsconfig.json"><img src="https://img.shields.io/badge/TypeScript-strict-3178c6.svg" alt="TypeScript strict mode"></a>
</p>

<p align="center"><em>Built at the UC Berkeley AI Hackathon, 2026.</em></p>

When an AI coding agent hits a usage limit, runs out of context, or crashes
mid-task, the next tool usually starts cold. Baton rebuilds the unfinished task
from evidence on disk — Git changes, command output, test results, and terminal
history — and compiles it into a compact, validated **handoff packet**. A
different agent resumes in the same repository, and Baton verifies the result
by running the project's real verification command.

Baton is not an editor or a Cursor clone. It transfers work *between* independent
tools (Claude Code ⇄ Codex CLI) through a visible, provider-neutral manifest.

![Baton demo — Claude reaches a limit, Baton hands off to Codex, and verification passes](docs/demo.gif)

*Claude Code hits a usage limit mid-fix → Baton compiles a verified handoff packet → Codex CLI resumes in the same repo → **Verify** runs the real tests and confirms the result.*

---

## Why Baton

Today, an AI coding agent is often a single point of failure. When a session
stops, the developer becomes the recovery mechanism: re-reading the diff,
reconstructing what the agent attempted, and prompting a fresh tool from
scratch. That recovery cost grows with the size of the change.

Baton removes the human from the recovery loop. It treats agent work as
**portable state**, not a disposable chat session. The state is rebuilt from
evidence the machine can verify — `git diff`, test exit codes, terminal output —
rather than from an agent's self-report, which may be wrong or optimistic. That
validated packet can be handed to another compatible tool, so work can survive
the session that started it.

## What works today

- Start Claude Code or Codex CLI against a local repository.
- Stream normalized process, terminal, file, and test events into the dashboard.
- Trigger a handoff manually or after a detected rate limit/context threshold.
- Rebuild state from Git, terminal evidence, and command exit codes.
- Distill that evidence into a small, runtime-validated handoff packet.
- Resume the other provider with the packet and repository already on disk.
- Run a user-selected verification command and decide pass/fail from its exit code.
- Persist event timelines and the latest packet in Redis when configured, with an
  in-memory implementation for local demos and tests.

The bundled demo uses deterministic fake agents so the complete flow is
repeatable without provider accounts. Real mode uses locally installed and
authenticated `claude` and `codex` CLIs.

## Trust boundary

Baton's server binds to `127.0.0.1`, keeps provider credentials in memory, and
does not require a hosted Baton service. Real agent runs still send prompts and
repository context to the provider selected by the user. The current Distiller
can include Git diffs and failure output in that provider request, so Baton
should not be described as keeping all code on-device.

Secret redaction, repository policies, signed audit logs, and self-hosted team
controls are roadmap work, not current guarantees.

## Current limitations

- Only Claude Code and Codex CLI have first-party adapters.
- Rate-limit and context-pressure detection exist; general provider-health and
  arbitrary-stall detection do not.
- Automatic handoffs are intentionally bounded to avoid provider ping-pong.
- Verification is one command and one exit-code verdict.
- Redis preserves events and packets, not the complete live process/session state.

---

## Quickstart

### Requirements

- Node.js 22 or newer
- npm
- Git

No provider account or Redis installation is required for the deterministic
demo.

```bash
git clone https://github.com/Myst1C13/Baton.git
cd Baton
npm ci
npm run demo
```

Open the printed dashboard URL (`http://127.0.0.1:4173/?api=…&ws=…`) and click
**Start Baton**. The demo runs deterministic fake agents end-to-end — no provider
CLI or auth required. Fake Claude reports a delayed usage limit, Baton
automatically hands the task to fake Codex, and **Verify** runs the real fixture
tests.

If those ports are already occupied, choose explicit alternatives:

```bash
PORT=4001 WEB_PORT=4174 npm run demo
```

Run the desktop app against the real subscription-authenticated CLIs:

```bash
claude                # complete Claude sign-in once, then exit
codex login           # complete Codex/ChatGPT sign-in once
npm run desktop:real  # leave API-key fields blank
```

### Docked sidebar (terminal companion)

Pin the rail beside your real terminal as a frameless desktop window:

```bash
npm run demo       # in one shell (server + UI)
npm run sidebar    # in another — opens the rail-only companion
```

Or open the rail-only view in any browser: `http://127.0.0.1:4173/?rail=1`.

### Desktop companion (Electron)

A native window that snaps to a screen edge — the "magnet" companion — and
adds a native folder picker for the workspace:

```bash
npm run desktop              # one-command safe demo; docks right
npm run desktop:real         # real locally authenticated CLIs
RELAY_DOCK=left  npm run desktop
RELAY_DOCK=float npm run desktop
RELAY_ONTOP=1    npm run desktop  # optional floating/always-on-top mode
```

The command starts the server, UI, and Electron shell together; closing Electron
stops the local stack. Inside the desktop app the Workspace field gains a
**Browse…** button (native OS folder dialog).

## The demo flow

1. An agent (Claude) starts fixing a real bug in `demo-repo/` — the `users.age`
   migration runs `ALTER TABLE` unconditionally, so the focused test fails.
2. The agent hits a usage limit with the test still red.
3. Baton freezes the workspace, distills a validated handoff packet, and launches
   the other agent (Codex) in the same repo from that packet alone.
4. Codex finishes the task; click **Verify** and Baton runs the real verification
   command, showing the exit code and verdict.

The user never re-explains the task during the transfer.

## Screens

| Ready | Handoff | Verified |
| --- | --- | --- |
| ![Baton ready](docs/devpost-1-ready.png) | ![Baton handoff](docs/devpost-2-handoff.png) | ![Baton verified](docs/devpost-3-verified.png) |

## Architecture

```text
┌─────────────────────────────────────────────────────────────┐
│  React / Vite dashboard (ui/)                                │
│  live terminal + Baton rail   ◀── WebSocket events           │
└───────────────┬─────────────────────────────────────────────┘
                │ HTTP (/api) + WS (/ws/sessions/:id)
┌───────────────▼─────────────────────────────────────────────┐
│  Node + TypeScript server (apps/server/src/)                 │
│  ┌────────────┐ ┌───────────┐ ┌────────────┐ ┌────────────┐ │
│  │ session    │ │ process   │ │ orchestr.  │ │ broadcaster│ │
│  │ manager    │ │ runner    │ │ + handoff  │ │ (WS)       │ │
│  └────────────┘ └───────────┘ └─────┬──────┘ └────────────┘ │
│  ┌────────────┐ ┌───────────┐       │  ┌──────────────────┐ │
│  │ adapters   │ │ verifier  │       └─▶│ event store      │ │
│  │ claude/cdx │ │           │          │ Redis | in-memory│ │
│  └─────┬──────┘ └───────────┘          └──────────────────┘ │
└────────┼─────────────────────────────────────────────────────┘
         ▼
   Local Git repository (

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 88 recognized source files, 408 KB.
- CSS (language) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- React (technology) — detected in the code
- TypeScript (language) — detected in the code
- Node.js (technology) — claimed on Devpost, not found in the code
- Redis (technology) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (104 of 104)

```
.env.example
.github/workflows/ci.yml
.gitignore
adapters/claude.ts
adapters/codex.ts
adapters/continuation.ts
apps/server/package.json
apps/server/src/adapters/__fixtures__/fake-agent.js
apps/server/src/adapters/claude.test.ts
apps/server/src/adapters/claude.ts
apps/server/src/adapters/codex.test.ts
apps/server/src/adapters/codex.ts
apps/server/src/adapters/fake.test.ts
apps/server/src/adapters/fake.ts
apps/server/src/adapters/index.ts
apps/server/src/adapters/process-agent.ts
apps/server/src/adapters/types.ts
apps/server/src/app.integration.test.ts
apps/server/src/app.ts
apps/server/src/broadcaster.test.ts
apps/server/src/broadcaster.ts
apps/server/src/demo-workspace.ts
apps/server/src/env.test.ts
apps/server/src/env.ts
apps/server/src/errors.ts
apps/server/src/event-store.test.ts
apps/server/src/event-store.ts
apps/server/src/evidence-collector.test.ts
apps/server/src/evidence-collector.ts
apps/server/src/health.test.ts
apps/server/src/index.ts
apps/server/src/orchestrator.test.ts
apps/server/src/orchestrator.ts
apps/server/src/process-runner.test.ts
apps/server/src/process-runner.ts
apps/server/src/routes.test.ts
apps/server/src/routes/control.test.ts
apps/server/src/routes/control.ts
apps/server/src/routes/index.ts
apps/server/src/routes/respond.ts
apps/server/src/routes/sessions.test.ts
apps/server/src/routes/sessions.ts
apps/server/src/session-manager.test.ts
apps/server/src/session-manager.ts
apps/server/src/verifier.test.ts
apps/server/src/verifier.ts
apps/server/src/ws-demo.ts
apps/server/tsconfig.json
chat-to-redis.ts
compressor.ts
contracts.ts
demo-repo/db.ts
demo-repo/migrate.ts
demo-repo/migration.test.ts
demo-repo/package.json
demo-repo/README.md
docs/PRESENTATION.md
electron/main.cjs
electron/preload.cjs
event-store.ts
evidence-collector.ts
extract.ts
INTEGRATION.md
LICENSE
monitor.ts
orchestrator.ts
package.json
packages/shared/common.ts
packages/shared/events.ts
packages/shared/evidence.ts
packages/shared/handoff.ts
packages/shared/index.ts
packages/shared/README.md
packages/shared/session.ts
README.md
redis-demo.ts
relay-mock/migrate.ts
relay-mock/mock-ask.txt
relay-mock/mock-stderr.log
resume.ts
scripts/demo.mjs
scripts/desktop.mjs
scripts/sidebar.mjs
test-detection.ts
tests/core.test.ts
tests/distill.test.ts
tests/live-stream.test.ts
tests/live-ui.test.ts
tests/orchestrator.test.ts
tests/ui-control-flow.test.ts
tsconfig.json
ui/index.html
ui/src/App.tsx
ui/src/controlFlow.ts
ui/src/demo.ts
ui/src/live.ts
ui/src/main.tsx
ui/src/styles.css
ui/src/useRelayStream.ts
ui/src/vite-env.d.ts
ui/tsconfig.json
ui/vite.config.ts
ws-test-client.test.ts
ws-test-client.ts
```

### Dependencies

- apps/server/package.json: @types/node@^26.0.0, @types/ws@^8.18.1, ioredis@^5.11.1, tsx@^4.22.4, typescript@^6.0.3, ws@^8.18.1, zod@^4.4.3
- package.json: @types/node@^26.0.0, @types/react@^19.2.17, @types/react-dom@^19.2.3, @types/ws@^8.18.1, @vitejs/plugin-react@^6.0.2, electron@^41.7.1, ioredis@^5.11.1, react@^19.2.7, react-dom@^19.2.7, ts-node@^10.9.2, tsx@^4.22.4, typescript@^6.0.3, vite@^8.0.16, ws@^8.18.1, zod@^4.4.3

### Recent commits (newest first)

- fix(ci): sync package-lock with @baton/server workspace rename
- docs: use full name for James Bodebiyi in credits
- polish: rebrand to Baton, honest README, MIT license, demo gif, UI states
- docs: expand README with user/enterprise value, outage resilience, roadmap
- Fix one-shot agent stdin warnings
- Finalize Baton desktop MVP and verified agent handoffs
- Merge: rail polish (logo, meter, scroll)
- Rail: Claude logo, interactive context meter, live WORKING-ON, no task box
- Merge: UI fixes (verification, scroll, desktop window)
- Verification in its card, drop workspace pills, fix rail scroll, calm desktop window
- Merge: real verification row
- Verification shows the real command + result, drop fake TypeScript row
- Merge: provider login (API keys)
- Provider login: API keys for Claude/Codex in Advanced
- Merge: Electron desktop companion
- Add Electron desktop companion: edge-dock + native folder picker
- Merge: drop goal field, keep workspace chooser
- Drop Goal field from start form; keep Workspace chooser
- Merge: docked sidebar mode + launcher
- Add docked sidebar mode (?rail=1) + launcher

## Key source files (fetched from GitHub, selected and truncated for size)

### INTEGRATION.md

```markdown
# RelayIDE Engine — Integration Contract

This is the contract for talking to the **engine** (Evidence Collector +
Distiller + Provider Adapters). The engine is a set of **pure functions the
orchestrator calls** — it never owns the loop, never touches Redis, never
touches the UI. This document specifies every input and output shape so other
scripts can integrate without reading the implementation.

All data shapes are **Zod schemas** in `packages/shared` — import the schema for
runtime validation (`.parse()`) and the inferred type for TypeScript. See
`packages/shared/README.md` for full field tables.

---

## The pipeline at a glance

```
RuntimeContext ─┐
                ├─► collectEvidence() ─► EvidenceBundle ─┐
git (pulled)  ──┘                                        ├─► distill() ─► HandoffPacket ─► adapter.launch() ─► LiveSession
                                          PacketMeta ─────┘
```

The orchestrator calls, in order: `collectEvidence` → `distill` →
`adapter.launch` (using `adapter.compress` as the distill backend under the
hood). Everything the engine produces is typed and Zod-validated.

---

## Public API

### 1. `collectEvidence(dir, runtime) → EvidenceBundle`

```ts
import { collectEvidence, RuntimeContext } from "./evidence-collector";

const evidence = collectEvidence(workspaceDir, runtimeContext);
```

- **`dir: string`** — absolute path to the workspace (a git repo).
- **`runtime: RuntimeContext`** — the ephemeral facts git can't provide (you supply these).
- **Returns `EvidenceBundle`** (validated). Throws only if the runtime context is malformed.

The collector pulls fresh git facts (branch, status, diff, changed files) and merges them with `runtime`.

### 2. `distill(evidence, meta) → Promise<HandoffPacket>`

```ts
import { distill, PacketMeta } from "./compressor";

const packet = await distill(evidence, meta);
```

- **`evidence: EvidenceBundle`** — from `collectEvidence`.
- **`meta: PacketMeta`** — the deterministic facts only you know (session, agents, trigger, live token count).
- **Returns `Promise<HandoffPacket>`** (validated). **Never throws** — on any failure (model down, bad JSON) it returns a deterministic fallback packet (`metrics.confidence = 0.3`).

For tests or embedded runtimes, an optional third argument can override the
compression backend, model, and working directory without changing global env:

```ts
await distill(evidence, meta, {
  backend: async () => JSON.stringify(claims),
  model: "test-model",
  cwd: workspaceDir,
});
```

### 3. `adapter.compress(prompt, opts) → Promise<string>`

The distillation backend. You normally don't call this directly — `distill` does. Selectable via env.

```ts
import { claudeAdapter } from "./adapters/claude";
const raw = await claudeAdapter.compress(prompt, { model, cwd });
```

### 4. `adapter.launch(opts) → Promise<LiveSession>`

Boots a fresh agent session seeded with a handoff packet (resumption).

```ts
import { claudeAdapter } from "./adapters/claude";
impor
[truncated — 6730 more characters]
```

### docs/PRESENTATION.md

```markdown
# Baton — Presentation Brief

> Source-of-truth doc for building a PowerPoint deck (via ChatGPT) and for
> talking through the project with a recruiter. Everything here is factual —
> backed by the codebase, not marketing fluff. Numbers that are *not yet
> measured* are flagged honestly so you never get caught overclaiming.

---

## 0. One-liner (memorize this)

**Baton keeps AI coding work alive when the agent dies.** When a coding agent
hits a usage limit, crashes, or the provider has an outage, Baton compiles the
in-progress work into a small, verified handoff packet and launches a *different*
agent to finish the job — so the developer never has to re-explain the task.

Elevator version (15 sec): *"Today an AI coding agent is a single point of
failure — if it stops, you lose all the context and have to restart from scratch
with another tool. Baton makes that work portable: it captures what the agent was
doing from real evidence — git diff, test results — hands it to a second agent,
and verifies the result with real tests. One provider's outage stops being your
blocked day."*

---

## 1. What it is

- A **provider-neutral handoff engine** for AI coding agents (Claude Code ⇄ Codex
  CLI today; built to add more).
- **Not** an editor or a Cursor clone. It moves work *between* independent tools
  through a visible, vendor-agnostic manifest.
- Ships as a local control server + React dashboard + a native Electron desktop
  companion that docks beside your real terminal.

## 2. The problem (the pain a recruiter will instantly get)

- AI coding agents fail constantly mid-task: **usage limits, crashes, context
  windows filling up, and provider-side outages.**
- When that happens, the session — and all its context — dies.
- The human becomes the recovery mechanism: re-read the diff, reconstruct what
  the agent meant, and re-prompt a fresh tool from zero. **That re-explanation
  tax is paid every single time, and grows with the size of the change.**
- You're also **locked to one vendor** — if Claude is down or rate-limited, work
  stops, even if Codex is sitting right there, healthy.

## 3. The solution (how Baton fixes it)

Baton treats agent work as **portable, verifiable state** — not a disposable chat
session. Four moves:

1. **Capture from evidence, not vibes.** It reconstructs the work from facts a
   machine can verify — `git diff`, `git status`, changed files, test exit codes,
   terminal output — never from the agent's own (possibly wrong) self-report.
2. **Compile a small handoff packet.** An LLM distills only the *reasoning* a
   fresh agent can't recover from disk (intent, decisions, what NOT to do);
   deterministic code fills the hard facts. The result is a tiny, schema-
   validated packet.
3. **Hand off to a different agent / provider.** It launches a second tool in the
   same repo, seeded only by that packet. Claude down → Codex continues, and
   vice versa.
4. **Verify with real tests.** Pass/fail is decided **solely by the command e
[truncated — 9134 more characters]
```

### package.json

```
{
  "name": "baton-agent-handoff",
  "version": "0.1.0",
  "private": true,
  "description": "Local, provider-neutral handoffs between AI coding agents",
  "main": "index.js",
  "workspaces": [
    "apps/*"
  ],
  "scripts": {
    "test": "node --import tsx --test tests/*.test.ts && node --import tsx test-detection.ts && npm run ws:test && npm test --workspaces --if-present",
    "typecheck": "tsc --noEmit && npm run typecheck --workspaces --if-present",
    "ui:dev": "vite --config ui/vite.config.ts",
    "ui:build": "tsc -p ui/tsconfig.json && vite build --config ui/vite.config.ts",
    "demo": "node scripts/demo.mjs",
    "sidebar": "node scripts/sidebar.mjs",
    "desktop": "node scripts/desktop.mjs",
    "desktop:real": "RELAY_FAKE_AGENTS=0 node scripts/desktop.mjs",
    "desktop:shell": "electron electron/main.cjs",
    "demo:redis": "tsx redis-demo.ts",
    "ws:client": "tsx ws-test-client.ts",
    "ws:test": "node --import tsx --test ws-test-client.test.ts"
  },
  "keywords": [
    "ai-agents",
    "developer-tools",
    "claude-code",
    "codex",
    "typescript"
  ],
  "author": "Syed Mohammad Husain",
  "license": "MIT",
  "type": "commonjs",
  "devDependencies": {
    "@types/node": "^26.0.0",
    "@types/react": "^19.2.17",
    "@types/react-dom": "^19.2.3",
    "@types/ws": "^8.18.1",
    "@vitejs/plugin-react": "^6.0.2",
    "electron": "^41.7.1",
    "ts-node": "^10.9.2",
    "tsx": "^4.22.4",
    "typescript": "^6.0.3",
    "vite": "^8.0.16"
  },
  "dependencies": {
    "ioredis": "^5.11.1",
    "react": "^19.2.7",
    "react-dom": "^19.2.7",
    "ws": "^8.18.1",
    "zod": "^4.4.3"
  }
}

```

### demo-repo/package.json

```
{
  "name": "baton-demo-repo",
  "version": "0.0.0",
  "private": true,
  "description": "Deterministic demo target: make the users.age migration safe to re-run.",
  "type": "commonjs",
  "scripts": {
    "test": "node --import tsx --test migration.test.ts"
  }
}

```

### apps/server/package.json

```
{
  "name": "@baton/server",
  "version": "0.1.0",
  "private": true,
  "description": "Baton orchestration server (Node + TypeScript).",
  "type": "commonjs",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "start": "tsx src/index.ts",
    "typecheck": "tsc --noEmit -p tsconfig.json",
    "test": "node --import tsx --test src/*.test.ts src/*/*.test.ts"
  },
  "dependencies": {
    "ioredis": "^5.11.1",
    "zod": "^4.4.3"
  },
  "devDependencies": {
    "@types/node": "^26.0.0",
    "@types/ws": "^8.18.1",
    "tsx": "^4.22.4",
    "typescript": "^6.0.3",
    "ws": "^8.18.1"
  }
}

```

### ui/src/main.tsx

```typescript
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./styles.css";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

```

### packages/shared/index.ts

```typescript
/**
 * RelayIDE — Shared contracts barrel.
 * The single import surface for evidence, handoff, and event schemas/types.
 *   import { HandoffPacket, EvidenceBundle, RelayEvent } from "@relay/shared";
 */

export * from "./common";
export * from "./evidence";
export * from "./handoff";
export * from "./events";
export * from "./session";

```

### apps/server/src/index.ts

```typescript
/**
 * Relay server — bootstrap
 * ------------------------
 * The only entry point that has side effects: validate env, build the app, bind
 * the port, and install graceful shutdown. Everything it uses is built and
 * tested in isolation (`env.ts`, `app.ts`), so this file stays thin.
 */

import { loadEnv } from "./env";
import { createAppRuntime, type AppRuntime } from "./app";

/** Drain in-flight requests, then exit. Idempotent + force-quits if stuck. */
function installGracefulShutdown(runtime: AppRuntime): void {
  let shuttingDown = false;

  const shutdown = async (signal: string): Promise<void> => {
    if (shuttingDown) return;
    shuttingDown = true;
    console.log(`[baton:server] ${signal} received — shutting down…`);

    const forceTimer = setTimeout(() => {
      console.error("[baton:server] shutdown timed out — forcing exit.");
      process.exit(1);
    }, 10_000);
    forceTimer.unref();

    try {
      await runtime.close();
      clearTimeout(forceTimer);
      console.log("[baton:server] closed cleanly.");
      process.exit(0);
    } catch (err) {
      clearTimeout(forceTimer);
      console.error("[baton:server] error during shutdown:", err);
      process.exit(1);
    }
  };

  process.on("SIGINT", () => void shutdown("SIGINT"));
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
}

function main(): void {
  const env = loadEnv();
  const runtime = createAppRuntime(env);
  const { server } = runtime;

  server.listen(env.PORT, env.HOST, () => {
    console.log(
      `[baton:server] listening on http://${env.HOST}:${env.PORT} (web=${env.WEB_URL})`
    );
  });

  installGracefulShutdown(runtime);
}

main();

```

### ui/src/App.tsx

```typescript
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { demoPacket } from "./demo";
import { useRelayStream, type StreamStatus } from "./useRelayStream";
import {
  activeAgent,
  activeSupportsInput,
  currentActivity,
  derivePhase,
  eventLine,
  latestHandoffPacket,
  migrationState,
  packetReady,
  type Line,
  type Phase,
} from "./live";
import type { HandoffPacket } from "../../packages/shared";
import {
  agentLabel,
  createSession as createRelaySession,
  modelFor,
  otherAgent,
  switchAgent as switchRelayAgent,
  type AgentId,
  type RelayApi,
} from "./controlFlow";

type IconName = "arrow" | "check" | "cross" | "spark" | "shield" | "file";

const icons: Record<IconName, ReactNode> = {
  arrow: <path d="M5 12h13m-5-6 6 6-6 6" />,
  check: <path d="m5 12 4 4L19 6" />,
  cross: <path d="M6 6l12 12M18 6 6 18" />,
  spark: <path d="m12 3 1.2 5L18 9l-4.8 1L12 15l-1.2-5L6 9l4.8-1z" />,
  shield: (
    <>
      <path d="M12 3 5 6v5c0 5 3 8 7 10 4-2 7-5 7-10V6z" />
      <path d="m9 12 2 2 4-4" />
    </>
  ),
  file: (
    <>
      <path d="M6 3h8l4 4v14H6z" />
      <path d="M14 3v5h5" />
    </>
  ),
};

function Icon({ name, size = 14 }: { name: IconName; size?: number }) {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 24 24"
      width={size}
      height={size}
      fill="none"
      stroke="currentColor"
      strokeWidth="1.8"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      {icons[name]}
    </svg>
  );
}

/** Claude's sunburst mark in its brand clay/orange. */
function ClaudeMark({ size = 22 }: { size?: number }) {
  const rays = Array.from({ length: 12 }, (_, i) => (i * 360) / 12);
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true">
      <g stroke="#d97757" strokeWidth="2.1" strokeLinecap="round">
        {rays.map((deg) => (
          <line
            key={deg}
            x1="12"
            y1="12"
            x2="12"
            y2="3.5"
            transform={`rotate(${deg} 12 12)`}
          />
        ))}
      </g>
    </svg>
  );
}

function BatonMark({ size = 18 }: { size?: number }) {
  return (
    <span
      className="baton-mark"
      style={{ width: size, height: size }}
      aria-hidden="true"
    >
      <i />
    </span>
  );
}

const readyLines: Line[] = [
  { kind: "relay", value: "↪ baton: control tower ready" },
  { kind: "muted", value: "Choose a workspace and starting agent, then start Baton." },
];

function Terminal({
  lines,
  phase,
  interactive = false,
  onInput,
}: {
  lines: Line[];
  phase: Phase;
  interactive?: boolean;
  onInput?: (text: string) => void;
}) {
  const bodyRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const [draft, setDraft] = useState("");

  useEffect(() => {
    const el = bodyRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, [lines, interactive]);

  function submit(event: FormEvent): void {
    event.preventDefault();
    const text = draft.trim();
    if (!text || !onInput) return;
    onInput(text);
    setDraft("");
  }

  return (
    <section className="terminal" aria-label="Live terminal">
      <header className="terminal-bar">
        <span className="lights">
          <i />
          <i />
          <i />
        </span>
        <span className="terminal-title">baton — zsh</span>
        <span className="terminal-branch">baton session</span>
      </header>
      <div
        className="terminal-body"
        ref={bodyRef}
        onClick={() => inputRef.current?.focus()}
      >
        {lines.map((line, i) => (
          <div className={`line ${line.kind}`} key={i}>
            {line.value || " "}
          </div>
        ))}
        {!interactive && phase !== "switching" && <span className="cursor" />}
      </div>
      {interactive && (
        <form className="chat-dock" onSubmit={submit}>
          <span className="chat-label">CHAT</span>
          <input
            ref={inputRef}
            className="chat-input"
            value={draft}
            onChange={(event) => setDraft(event.target.value)}
            placeholder="message the active agent…"
            spellCheck={false}
            autoComplete="off"
            autoFocus
          />
          <button type="submit" className="chat-send" disabled={!draft.trim()}>
            Send
          </button>
        </form>
      )}
    </section>
  );
}

function Rail({
  phase,
  handoffDone,
  live = false,
  agentName,
  migration,
  sessionLabel = "session 7f3a",
  controls,
  activity,
  activityLabel = "NOW",
  packet,
  streamStatus = "idle",
  verifyLabel,
  verifyEditable = false,
  onVerifyChange,
  failed = false,
  completed = false,
}: {
  phase: Phase;
  handoffDone: boolean;
  live?: boolean;
  agentName?: "claude" | "codex";
  migration?: "pass" | "fail" | "pending";
  sessionLabel?: string;
  controls?: ReactNode;
  activity: string;
  activityLabel?: string;
  packet: HandoffPacket | null;
  streamStatus?: StreamStatus;
  verifyLabel: string;
  verifyEditable?: boolean;
  onVerifyChange?: (value: string) => void;
  failed?: boolean;
  completed?: boolean;
}) {
  const isCodex = agentName ? agentName === "codex" : phase === "resumed";
  const agent = isCodex
    ? { name: "Codex", letter: "X", tone: "codex" }
    : { name: "Claude", letter: "C", tone: "claude" };

  const status =
    failed
      ? "failed"
      : completed
        ? "completed"
        : !live
          ? "ready"
          : phase === "switching"
            ? "relaying context…"
      : phase === "resumed"
        ? "resumed · working"
        : "running";

  // Verification: explicit override in live mode, else derived from phase.
  const migrationOk = migration ? migration === "pass" : phase === "resumed";

  return (
    <aside className="rail" aria-label="Baton">
      <header className="rail-head">
        <BatonMark />
        <strong>Baton</strong>
        <span className="se
[truncated — 19433 more characters]
```

### apps/server/src/app.ts

```typescript
/**
 * Relay server — HTTP app
 * -----------------------
 * Builds the `http.Server` and owns request routing + the centralized error
 * handler. Kept dependency-free (Node's built-in `http`) and side-effect-free:
 * it never calls `.listen()` — `index.ts` (bootstrap) does that. This keeps the
 * app importable in tests, which bind it to an ephemeral port themselves.
 *
 * The app mounts health/session HTTP routes and the session-scoped WebSocket
 * broadcaster. Runtime dependencies are exposed by `createAppRuntime()` so the
 * coordinator and event store can be added without hidden singletons.
 */

import * as http from "node:http";
import type { Env } from "./env";
import { methodNotAllowed, notFound, toErrorResponse } from "./errors";
import { SessionBroadcaster } from "./broadcaster";
import { SessionManager } from "./session-manager";
import {
  Orchestrator,
  InMemoryEventStore,
  compressorCreateHandoff,
  fallbackCreateHandoff,
  type EventStore,
} from "./orchestrator";
import { ClaudeAdapter, CodexAdapter, FakeAgentAdapter } from "./adapters";
import { RedisEventStore } from "./event-store";
import { createApiRouter, type ApiHandler } from "./routes";
import { completeBundledDemo } from "./demo-workspace";

export interface AppOptions {
  sessions?: SessionManager;
  broadcaster?: SessionBroadcaster;
  store?: EventStore;
  orchestrator?: Orchestrator;
}

export interface AppRuntime {
  server: http.Server;
  sessions: SessionManager;
  broadcaster: SessionBroadcaster;
  orchestrator: Orchestrator;
  /** Null only when a fully custom orchestrator was injected without its store. */
  store: EventStore | null;
  /** Stop agents, close sockets/server, and flush durable storage. */
  close(): Promise<void>;
}

function sendJson(
  res: http.ServerResponse,
  statusCode: number,
  body: unknown,
  headers: Record<string, string> = {}
): void {
  const payload = JSON.stringify(body);
  res.writeHead(statusCode, {
    "content-type": "application/json; charset=utf-8",
    "content-length": Buffer.byteLength(payload),
    ...headers,
  });
  res.end(payload);
}

function corsHeaders(
  req: http.IncomingMessage,
  env: Env
): Record<string, string> {
  const origin = req.headers.origin;
  if (!origin || origin !== env.WEB_URL) return {};
  return {
    "access-control-allow-origin": env.WEB_URL,
    "access-control-allow-methods": "GET,POST,OPTIONS",
    "access-control-allow-headers": "content-type",
    vary: "Origin",
  };
}

/** Route a single request. Throws on any error; the handler below catches it. */
async function route(
  req: http.IncomingMessage,
  res: http.ServerResponse,
  _env: Env,
  api: ApiHandler
): Promise<void> {
  if (req.method === "OPTIONS") {
    res.writeHead(204, corsHeaders(req, _env));
    res.end();
    return;
  }
  // Parse just the pathname so query strings don't break exact matches.
  const { pathname } = new URL(req.url ?? "/", "http://localhost");

  if (pathname === "/health") {
    if (req.method !== "GET") {
      throw methodNotAllowed(["GET"], `${req.method} not allowed on /health`);
    }
    sendJson(res, 200, {
      status: "ok",
      uptime: process.uptime(),
      timestamp: new Date().toISOString(),
    });
    return;
  }

  if (pathname.startsWith("/api/")) {
    await api(req, res);
    return;
  }

  throw notFound(`No route for ${req.method} ${pathname}`);
}

export function createAppRuntime(
  env: Env,
  opts: AppOptions = {}
): AppRuntime {
  const sessions = opts.sessions ?? new SessionManager();
  const broadcaster =
    opts.broadcaster ?? new SessionBroadcaster(env.WEB_URL);
  let store = opts.store ?? null;
  let orchestrator = opts.orchestrator;

  if (!orchestrator) {
    // Durable Redis store when REDIS_URL is set; in-memory otherwise (dev/tests).
    store ??= env.REDIS_URL
      ? new RedisEventStore(env.REDIS_URL)
      : new InMemoryEventStore();
    // Fake agents run the full loop deterministically (no provider CLI/auth);
    // real adapters spawn the actual Claude/Codex CLIs and are the default.
    const adapters = env.RELAY_FAKE_AGENTS
      ? {
          claude: () =>
            new FakeAgentAdapter({
              id: "claude",
              displayName: "Claude (fake)",
              models: ["claude-opus-4-8"],
              startupOutput:
                "migration test failed: duplicate column name: age\n" +
                "API error 429 — usage limit reached\n",
            }),
          codex: () =>
            new FakeAgentAdapter({
              id: "codex",
              displayName: "Codex (fake)",
              models: ["gpt-5-codex"],
              onStart: completeBundledDemo,
            }),
        }
      : {
          claude: () => new ClaudeAdapter(),
          codex: () => new CodexAdapter(),
        };
    orchestrator = new Orchestrator({
      sessions,
      store,
      adapters,
      // 0 = manual: the active agent keeps the baton until the user clicks
      // Switch. Default 1 preserves the bundled auto-handoff demo.
      maxAutomaticHandoffs: env.RELAY_AUTO_HANDOFF ? 1 : 0,
      // Demo mode must never invoke a real provider merely to distill context.
      createHandoff: env.RELAY_FAKE_AGENTS
        ? fallbackCreateHandoff
        : compressorCreateHandoff,
      // Live events flow to any WS clients subscribed to the session.
      onEvent: (event) => {
        try {
          broadcaster.broadcast(event);
        } catch {
          /* never let a broadcast failure break the run */
        }
      },
    });
  }
  const api = createApiRouter({ sessions, orchestrator });

  const server = http.createServer((req, res) => {
    for (const [name, value] of Object.entries(corsHeaders(req, env))) {
      res.setHeader(name, value);
    }
    route(req, res, env, api).catch((err) => {
      const { statusCode, body, headers, unexpected } = toErrorResponse(err);
      if (unexpected) {
        // Log the real error server-side; clients only ever see the envelope.
        conso
[truncated — 972 more characters]
```

[78 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]