|
| 1 | +# Agentree — Codex Handoff |
| 2 | + |
| 3 | +## What is this project? |
| 4 | + |
| 5 | +**Agentree** is an open-source, Figma-like infinite canvas for visualizing and controlling AI agent process trees in real-time. It connects to a running **opencode** instance (an AI agent execution engine) and renders its session hierarchy as an interactive node graph. |
| 6 | + |
| 7 | +Key concept: just as Figma shows multiple design objects on a shared canvas, Agentree shows opencode sessions (process → subprocess → thread) as nodes on an infinite canvas with zoom/pan, real-time status updates, and inline chat control per node. |
| 8 | + |
| 9 | +## Current state |
| 10 | + |
| 11 | +Phase 1 (scaffolding) and Phase 2 (core implementation) are complete. The app starts and runs. What remains is Phase 3 integration work listed below. |
| 12 | + |
| 13 | +### What works today |
| 14 | + |
| 15 | +- `pnpm run dev` — starts both Hono server (`:3001`) and Vite dev server (`:5173`) |
| 16 | +- `GET /api/health` → `{"ok":true}` |
| 17 | +- `GET /api/tree` → proxies to opencode, returns flat session list |
| 18 | +- `GET /api/events` → SSE stream re-broadcasting opencode global events |
| 19 | +- `POST /api/session/:id/prompt` — send message to session |
| 20 | +- `POST /api/session/:id/abort` — abort session |
| 21 | +- `POST /api/permission/:requestID/reply` — approve/deny tool permission |
| 22 | +- `POST /api/question/:requestID/reply` / `reject` — answer/reject question |
| 23 | +- React Flow canvas renders with dagre BT layout (tree grows upward) |
| 24 | +- Zustand store handles SSE events and updates node status + color |
| 25 | +- Right side panel appears on node click (placeholder text) |
| 26 | + |
| 27 | +### What is stubbed / not yet wired |
| 28 | + |
| 29 | +1. **`PATCH /api/canvas/:id`** — position save is a `console.log` stub; DB import is commented out with `TODO` |
| 30 | +2. **Side panel chat UI** — shows placeholder text, no real chat |
| 31 | +3. **Permission/question approval UI** — no inline buttons on nodes yet |
| 32 | +4. **DB migration on startup** — `drizzle-kit generate` has been run, but `db:migrate` has not |
| 33 | + |
| 34 | +--- |
| 35 | + |
| 36 | +## Tech stack |
| 37 | + |
| 38 | +| Layer | Package | Version | |
| 39 | +|-------|---------|---------| |
| 40 | +| Frontend | React | 19.2.4 | |
| 41 | +| Canvas | @xyflow/react | 12.10.2 | |
| 42 | +| State | zustand | 5.0.12 | |
| 43 | +| Layout | @dagrejs/dagre | 3.0.0 | |
| 44 | +| Build | Vite | 8.0.3 | |
| 45 | +| Backend | hono + @hono/node-server | 4.12.10 / 1.19.12 | |
| 46 | +| AI engine SDK | @opencode-ai/sdk | 1.3.13 | |
| 47 | +| DB driver | better-sqlite3 | 12.8.0 | |
| 48 | +| ORM | drizzle-orm + drizzle-kit | 0.45.2 / 0.31.10 | |
| 49 | +| Runtime | tsx (watch mode) | 4.21.0 | |
| 50 | +| TS | typescript | 6.0.2 | |
| 51 | + |
| 52 | +- **Package manager**: pnpm |
| 53 | +- **Module system**: `"type": "module"` — all server imports use `.js` extensions; Vite client files do not need extensions |
| 54 | +- **Ports**: Vite dev `:5173`, Hono `:3001`, Vite proxies `/api/*` → `:3001` |
| 55 | +- **opencode URL**: `OPENCODE_API_URL` env var, defaults to `http://localhost:6543` |
| 56 | +- **DB path**: `DB_PATH` env var, defaults to `./agentree.db` (project root) |
| 57 | + |
| 58 | +--- |
| 59 | + |
| 60 | +## File structure |
| 61 | + |
| 62 | +``` |
| 63 | +agentree/ |
| 64 | +├── src/ |
| 65 | +│ ├── client/ |
| 66 | +│ │ ├── main.tsx # React entry |
| 67 | +│ │ ├── App.tsx # Root layout (canvas + side panel) |
| 68 | +│ │ ├── vite-env.d.ts # Vite type declarations |
| 69 | +│ │ ├── canvas/ |
| 70 | +│ │ │ ├── AgentCanvas.tsx # ReactFlow canvas, fetches tree, subscribes SSE |
| 71 | +│ │ │ ├── AgentNode.tsx # Custom node (status dot + color border) |
| 72 | +│ │ │ └── AgentEdge.tsx # Custom edge (straight path) |
| 73 | +│ │ ├── store/ |
| 74 | +│ │ │ └── agentStore.ts # Zustand: nodes/edges/selectedSession + applyEvent |
| 75 | +│ │ └── panel/ # (empty) — chat panel goes here |
| 76 | +│ └── server/ |
| 77 | +│ ├── index.ts # Hono app, mounts all routes, starts SSE listener |
| 78 | +│ ├── opencode/ |
| 79 | +│ │ └── client.ts # Singleton opencode SDK client |
| 80 | +│ ├── routes/ |
| 81 | +│ │ ├── tree.ts # GET /api/tree |
| 82 | +│ │ ├── session.ts # GET|POST /api/session/:id |
| 83 | +│ │ ├── canvas.ts # PATCH /api/canvas/:id (STUB — see Task 1) |
| 84 | +│ │ └── approval.ts # POST /api/permission/:id/reply, question reply/reject |
| 85 | +│ ├── sse/ |
| 86 | +│ │ └── broadcaster.ts # opencode SSE → fan-out to browser clients |
| 87 | +│ └── db/ |
| 88 | +│ ├── schema.ts # Drizzle: canvas_node table definition |
| 89 | +│ └── index.ts # DB init (WAL), saveCanvasNode/getCanvasNode helpers |
| 90 | +├── drizzle/ |
| 91 | +│ └── 0000_cynical_sauron.sql # Generated migration (not yet applied) |
| 92 | +├── drizzle.config.ts # Drizzle Kit config |
| 93 | +├── package.json |
| 94 | +├── tsconfig.json # Server TS config (rootDir: src/server) |
| 95 | +├── tsconfig.client.json # Client TS config (noEmit, jsx: react-jsx) |
| 96 | +├── vite.config.ts # Vite config (proxy /api/* → :3001) |
| 97 | +└── index.html # Vite entry HTML |
| 98 | +``` |
| 99 | + |
| 100 | +--- |
| 101 | + |
| 102 | +## Data model |
| 103 | + |
| 104 | +Single SQLite table at `agentree.db` (project root): |
| 105 | + |
| 106 | +```sql |
| 107 | +CREATE TABLE canvas_node ( |
| 108 | + session_id TEXT PRIMARY KEY, -- opencode session ID |
| 109 | + label TEXT, -- user-defined label |
| 110 | + canvas_x REAL DEFAULT 0, -- canvas X position |
| 111 | + canvas_y REAL DEFAULT 0, -- canvas Y position |
| 112 | + pinned INTEGER DEFAULT 0, -- 0 = auto-layout by dagre, 1 = user-pinned |
| 113 | + updated_at TEXT -- ISO8601 timestamp |
| 114 | +) |
| 115 | +``` |
| 116 | + |
| 117 | +--- |
| 118 | + |
| 119 | +## opencode SDK usage |
| 120 | + |
| 121 | +```ts |
| 122 | +import { createOpencodeClient } from '@opencode-ai/sdk/v2/client' |
| 123 | +const client = createOpencodeClient({ baseUrl: 'http://localhost:6543' }) |
| 124 | + |
| 125 | +// Sessions |
| 126 | +client.session.list() // all sessions |
| 127 | +client.session.get({ sessionID }) // one session |
| 128 | +client.session.children({ sessionID }) // child sessions |
| 129 | +client.session.prompt({ sessionID, parts: [{ type: 'text', text }] }) |
| 130 | +client.session.abort({ sessionID }) |
| 131 | + |
| 132 | +// SSE — result.stream is an AsyncGenerator |
| 133 | +const result = await client.global.event() |
| 134 | +for await (const msg of result.stream) { |
| 135 | + const payload = msg.payload // type: Event |
| 136 | +} |
| 137 | + |
| 138 | +// Permissions |
| 139 | +client.permission.list() |
| 140 | +client.permission.reply({ requestID, reply: 'once' | 'always' | 'reject', message? }) |
| 141 | + |
| 142 | +// Questions |
| 143 | +client.question.list() |
| 144 | +client.question.reply({ requestID, answers: [{ questionID, value }] }) |
| 145 | +client.question.reject({ requestID }) |
| 146 | +``` |
| 147 | + |
| 148 | +### Key event types from SSE |
| 149 | + |
| 150 | +```ts |
| 151 | +// msg.payload.type can be: |
| 152 | +'session.created' // properties: { sessionID, info: Session } |
| 153 | +'session.updated' // properties: { sessionID, info: Session } |
| 154 | +'session.deleted' // properties: { sessionID, info: Session } |
| 155 | +'session.status' // properties: { sessionID, status: string } → node turns green |
| 156 | +'session.idle' // properties: { sessionID } → node turns blue |
| 157 | +'session.error' // properties: { sessionID, error: string } → node turns red |
| 158 | +'permission.asked' // properties: PermissionRequest → node turns yellow |
| 159 | +'permission.replied' // properties: ... → node turns green |
| 160 | +'question.asked' // properties: QuestionRequest → node turns orange |
| 161 | +'question.replied' // properties: ... → node turns green |
| 162 | +``` |
| 163 | + |
| 164 | +### Node status → color |
| 165 | + |
| 166 | +| Status | Color | Hex | |
| 167 | +|--------|-------|-----| |
| 168 | +| running | green | `#22c55e` | |
| 169 | +| needs-permission | yellow | `#eab308` | |
| 170 | +| needs-answer | orange | `#f97316` | |
| 171 | +| idle | blue | `#3b82f6` | |
| 172 | +| done | gray | `#6b7280` | |
| 173 | +| failed | red | `#ef4444` | |
| 174 | + |
| 175 | +--- |
| 176 | + |
| 177 | +## Remaining tasks (Phase 3) |
| 178 | + |
| 179 | +### Task 1 — Wire canvas position persistence (small, ~15 min) |
| 180 | + |
| 181 | +**File**: `src/server/routes/canvas.ts` |
| 182 | + |
| 183 | +Remove the stub and import the DB helper: |
| 184 | + |
| 185 | +```ts |
| 186 | +import { Hono } from 'hono' |
| 187 | +import { saveCanvasNode } from '../db/index.js' |
| 188 | + |
| 189 | +export const canvasRouter = new Hono() |
| 190 | + |
| 191 | +canvasRouter.patch('/api/canvas/:id', async (c) => { |
| 192 | + const sessionID = c.req.param('id') |
| 193 | + const body = await c.req.json<{ x?: number; y?: number; label?: string; pinned?: boolean }>() |
| 194 | + await saveCanvasNode(sessionID, body) |
| 195 | + return c.json({ ok: true, sessionID }) |
| 196 | +}) |
| 197 | +``` |
| 198 | + |
| 199 | +Also run DB migration on first start. In `src/server/index.ts`, add before `serve(...)`: |
| 200 | + |
| 201 | +```ts |
| 202 | +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' |
| 203 | +import { db } from './db/index.js' |
| 204 | +import { join, dirname } from 'path' |
| 205 | +import { fileURLToPath } from 'url' |
| 206 | + |
| 207 | +const __dirname = dirname(fileURLToPath(import.meta.url)) |
| 208 | +migrate(db, { migrationsFolder: join(__dirname, '..', '..', 'drizzle') }) |
| 209 | +``` |
| 210 | + |
| 211 | +Also: wire canvas position save on node drag in `AgentCanvas.tsx` — use ReactFlow's `onNodeDragStop` callback: |
| 212 | + |
| 213 | +```tsx |
| 214 | +<ReactFlow |
| 215 | + ... |
| 216 | + onNodeDragStop={(_, node) => { |
| 217 | + fetch(`/api/canvas/${node.id}`, { |
| 218 | + method: 'PATCH', |
| 219 | + headers: { 'Content-Type': 'application/json' }, |
| 220 | + body: JSON.stringify({ x: node.position.x, y: node.position.y, pinned: true }), |
| 221 | + }) |
| 222 | + // Also mark node as pinned in store so dagre doesn't override it |
| 223 | + }} |
| 224 | +/> |
| 225 | +``` |
| 226 | + |
| 227 | +Add `pinned` flag to `AgentNodeData` in `agentStore.ts` so dagre skips pinned nodes during re-layout. |
| 228 | + |
| 229 | +--- |
| 230 | + |
| 231 | +### Task 2 — Side panel chat UI (medium, ~1 hour) |
| 232 | + |
| 233 | +**Files to create**: `src/client/panel/SessionPanel.tsx` |
| 234 | + |
| 235 | +The panel shows: |
| 236 | +1. Session title + status badge at top |
| 237 | +2. Scrollable message history (fetch from `GET /api/session/:id` messages — opencode stores them) |
| 238 | +3. Text input at bottom to send a new prompt (`POST /api/session/:id/prompt`) |
| 239 | +4. Abort button |
| 240 | + |
| 241 | +Wire it in `App.tsx` — replace the placeholder `<div>` with `<SessionPanel sessionId={selectedSessionId} />`. |
| 242 | + |
| 243 | +For message history, opencode returns messages via `client.session.messages({ sessionID })` — expose this as `GET /api/session/:id/messages` in `src/server/routes/session.ts`. |
| 244 | + |
| 245 | +Style: dark theme (background `#111`), monospace font for message content, match the canvas color scheme. |
| 246 | + |
| 247 | +--- |
| 248 | + |
| 249 | +### Task 3 — Permission/question approval inline UI (medium, ~1 hour) |
| 250 | + |
| 251 | +When a node has status `needs-permission` or `needs-answer`, show inline action buttons inside the side panel (or as a floating overlay on the node). |
| 252 | + |
| 253 | +**Permission** (`needs-permission`): |
| 254 | +``` |
| 255 | +[Allow once] [Always allow] [Deny] |
| 256 | +``` |
| 257 | +Calls `POST /api/permission/:requestID/reply` with `{ reply: 'once' | 'always' | 'reject' }`. |
| 258 | + |
| 259 | +**Question** (`needs-answer`): |
| 260 | +Show the question text and an input field. |
| 261 | +Calls `POST /api/question/:requestID/reply` with `{ answers: [{ questionID, value }] }` or `POST /api/question/:requestID/reject`. |
| 262 | + |
| 263 | +To get the pending permission/question for a session: the SSE event `permission.asked` and `question.asked` carry the full request object. Store `pendingPermission` and `pendingQuestion` per session ID in `agentStore.ts`. |
| 264 | + |
| 265 | +--- |
| 266 | + |
| 267 | +### Task 4 — Edge animation for approval flows (small, ~30 min) |
| 268 | + |
| 269 | +When a node has `needs-permission` or `needs-answer`, animate its parent edge to show the request bubbling upward (child → parent direction). |
| 270 | + |
| 271 | +In `agentStore.ts`, after `updateNodeStatus`, also update the corresponding edges: |
| 272 | + |
| 273 | +```ts |
| 274 | +// When child needs permission: animate edge from child to parent |
| 275 | +set((state) => ({ |
| 276 | + edges: state.edges.map((e) => |
| 277 | + e.target === sessionId |
| 278 | + ? { ...e, animated: true, style: { stroke: '#eab308', strokeDasharray: '5 3' } } |
| 279 | + : e |
| 280 | + ), |
| 281 | +})) |
| 282 | +``` |
| 283 | + |
| 284 | +Reset edge animation when status changes back to `running`/`idle`. |
| 285 | + |
| 286 | +--- |
| 287 | + |
| 288 | +## How to run |
| 289 | + |
| 290 | +```bash |
| 291 | +cd /path/to/agentree |
| 292 | +pnpm install # if first time |
| 293 | +pnpm run dev # starts both Vite :5173 and Hono :3001 concurrently |
| 294 | + |
| 295 | +# opencode must be running separately on :6543 |
| 296 | +# or set OPENCODE_API_URL=http://your-opencode-host |
| 297 | +``` |
| 298 | + |
| 299 | +## How to verify |
| 300 | + |
| 301 | +```bash |
| 302 | +curl http://localhost:3001/api/health # → {"ok":true} |
| 303 | +curl http://localhost:3001/api/tree # → [] or session list (requires opencode running) |
| 304 | +``` |
| 305 | + |
| 306 | +Browser: `http://localhost:5173` — should render the canvas. If opencode is running, nodes will appear automatically. |
0 commit comments