Skip to content

Commit 9897c67

Browse files
committed
feat: Phase 1+2 complete — canvas, session panel, relation overlay, tests
- Infinite canvas with dagre auto-layout (React Flow, rankdir BT) - Real-time SSE event handling for 12 event types - Session panel: full message history, approval flows, subtask/fork actions - Generalized session_relation overlay (linked / merged-view / detached / fork) - Relation edges rendered on canvas with type-specific color and dash style - Session.diff hint and command.executed refresh handler - Vitest test suite: DB functions, API routes, store logic (22 tests) - Apache-2.0 license, .gitignore, README cleanup
1 parent 922613a commit 9897c67

61 files changed

Lines changed: 7852 additions & 101 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
node_modules/
2+
dist/
3+
*.tsbuildinfo
4+
5+
# Local SQLite overlay DB — not part of repo state
6+
*.db
7+
*.db-shm
8+
*.db-wal
9+
10+
# Environment files — may contain passwords / secrets
11+
.env
12+
.env.*

.npmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
allow-build=better-sqlite3

AGENTS.md

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
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

Comments
 (0)