@hachej/boring-agent 0.1.17 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{sandbox-handle-store-hK76cTjn.d.ts → agentPluginEvents-zyIvVjsA.d.ts} +28 -32
- package/dist/{chunk-F3CE5CNW.js → chunk-B5JECXMG.js} +5 -7
- package/dist/front/index.d.ts +23 -7
- package/dist/front/index.js +764 -421
- package/dist/front/styles.css +61 -0
- package/dist/{tool-ui-DSmWuqGe.d.ts → harness-DRrTn_5T.d.ts} +43 -24
- package/dist/server/index.d.ts +79 -10
- package/dist/server/index.js +243 -54
- package/dist/shared/index.d.ts +5 -3
- package/dist/shared/index.js +3 -1
- package/dist/tool-ui-DIFNGwYd.d.ts +20 -0
- package/docs/ACCESSIBILITY.md +55 -0
- package/docs/API.md +64 -0
- package/docs/CSP.md +40 -0
- package/docs/ERROR_CODES.md +54 -0
- package/docs/KNOWN_LIMITATIONS.md +100 -0
- package/docs/MIGRATION.md +50 -0
- package/docs/PERFORMANCE.md +68 -0
- package/docs/PLUGINS.md +108 -0
- package/docs/README.md +17 -0
- package/docs/RISKS-MULTI-TAB.md +46 -0
- package/docs/STYLING.md +71 -0
- package/docs/UI-SHADCN.md +56 -0
- package/docs/VERCEL_COSTS.md +103 -0
- package/docs/plans/AGENT_EVAL_FRAMEWORK.md +466 -0
- package/docs/plans/agent-package-spec.md +1777 -0
- package/docs/plans/harness-followup-capabilities.md +440 -0
- package/docs/plans/harness-tool-ui-capabilities.md +144 -0
- package/docs/plans/pi-followup-history-projection.md +229 -0
- package/docs/plans/pi-tools-migration.md +1061 -0
- package/docs/plans/reviews/pi-followup-history-codex-review.md +11 -0
- package/docs/plans/reviews/pi-followup-history-gpt-review.md +64 -0
- package/docs/plans/reviews/pi-followup-history-opus-review.md +43 -0
- package/docs/plans/reviews/pi-followup-history-xai-review.md +43 -0
- package/docs/plans/vercel-base-snapshot-template-plan.md +527 -0
- package/docs/plans/vercel-persistent-sandbox-adapter.md +553 -0
- package/docs/runtime.md +56 -0
- package/docs/tools.md +75 -0
- package/package.json +4 -3
|
@@ -0,0 +1,1777 @@
|
|
|
1
|
+
# @boring/agent — Package Spec
|
|
2
|
+
|
|
3
|
+
**Status:** draft — interview-driven, architecture locked 2026-04-22
|
|
4
|
+
**Path:** `boring-ui-v2/packages/agent/`
|
|
5
|
+
|
|
6
|
+
> **Note (2026-Q2):** The pi-tools migration
|
|
7
|
+
> (`docs/plans/pi-tools-migration.md`, epic `boring-ui-v2-uhwx`)
|
|
8
|
+
> supersedes the original hand-rolled catalog design. Decisions #4 and #6
|
|
9
|
+
> below are updated in place: standard tools now come from pi factories plus
|
|
10
|
+
> Operations adapters, and dedicated `find`/`grep`/`ls` tools are part of the
|
|
11
|
+
> baseline surface.
|
|
12
|
+
|
|
13
|
+
## Execution Tracker (2026-04-24)
|
|
14
|
+
|
|
15
|
+
Use this section as the handoff ledger for ongoing plan execution.
|
|
16
|
+
|
|
17
|
+
### Pass 2 — Full milestone verification + fixes (2026-04-24)
|
|
18
|
+
|
|
19
|
+
- Methodology: Three parallel verification agents compared every M0-M3b milestone item against actual source files, reading implementations line-by-line. M4/M5 checked manually.
|
|
20
|
+
- **Result: M0-M3b fully complete. M4 partially complete. M5 partially complete.**
|
|
21
|
+
- All milestone checkboxes updated in the roadmap section below.
|
|
22
|
+
- Fixes applied in this pass:
|
|
23
|
+
1. **`src/shared/index.ts` barrel export populated** — was empty (just a comment). Now re-exports all 12 shared interface modules (harness, workspace, sandbox, tool, catalog, session, message, ui-bridge, file-search, sandbox-handle-store, config-schema, error-codes).
|
|
24
|
+
2. **`src/shared/harness.ts` stale stubs replaced** — had local `type UIMessageChunk = unknown` and empty `interface SessionStore {}`. Now properly imports from `./message` and `./session`.
|
|
25
|
+
3. **Test mocks updated** — `chat.test.ts` and `sessionChanges.test.ts` mock harnesses updated to satisfy the now-concrete `SessionStore` type and cast chunks to `UIMessageChunk`.
|
|
26
|
+
- Verification: `tsc --noEmit` clean, 782/782 tests pass.
|
|
27
|
+
- Remaining work (M4/M5):
|
|
28
|
+
- M4: Plugin name-collision precedence documentation, community extension smoke tests, vercel-sandbox plugin doc
|
|
29
|
+
- M5: `registerTool()` runtime API, API docs, migration guide
|
|
30
|
+
|
|
31
|
+
### Pass 1 — qnxc bugfix (2026-04-24)
|
|
32
|
+
|
|
33
|
+
- `DONE` `boring-ui-v2-qnxc` (P1 bug): refresh/history hydration regression fixed and closed.
|
|
34
|
+
- Commit: `764eda6` (`fix(agent): hydrate refresh history via messages route (boring-ui-v2-qnxc)`).
|
|
35
|
+
- Cross-review: Claude verdict `ship`.
|
|
36
|
+
- Contract now enforced:
|
|
37
|
+
- `GET /api/v1/agent/chat/:sessionId/stream` resumes only active streams; returns `204` when none are active.
|
|
38
|
+
- `GET /api/v1/agent/chat/:sessionId/messages` hydrates full persisted `UIMessage[]` (user + assistant).
|
|
39
|
+
- `useAgentChat` hydrates from `/messages` on mount/session switch (cache fallback retained).
|
|
40
|
+
- Verification run: `pnpm --dir packages/agent test` + `pnpm --dir packages/agent lint`.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Goal
|
|
45
|
+
|
|
46
|
+
Host a Pi coding agent in a web app **and ship as a CLI**.
|
|
47
|
+
|
|
48
|
+
- **Ships as CLI (primary product shape).** `npx @boring/agent` in any directory → local backend + browser chat, zero setup, zero deploy. Same product surface as Claude Code, self-hostable, backed by pi-coding-agent.
|
|
49
|
+
- **Embeddable as a library.** The sibling `@boring/workspace` package composes `<ChatPanel />` into a full-layout repo. Custom apps import the primitives and build their own UX.
|
|
50
|
+
- **Location-agnostic workspace.** Files can live on the backend machine OR inside a remote Firecracker VM. Swapping is an adapter change — frontend, agent tools, harness, UI unchanged.
|
|
51
|
+
|
|
52
|
+
v1 ships **three execution modes** behind identical interfaces:
|
|
53
|
+
|
|
54
|
+
1. **Direct** — `NodeWorkspace` + `DirectSandbox`. Files on the backend machine; bash via `child_process.exec` with `cwd` pinned. **No isolation** — trust-the-user mode. For local dev on macOS (no bwrap), small self-host, or "just run it on my cwd" workflows. Zero Linux dependency.
|
|
55
|
+
2. **Local** — `NodeWorkspace` + `BwrapSandbox`. Files on the backend machine, bash inside bwrap. PaaS-deployable (Fly, Render, Railway, any Docker host with bubblewrap). Safer default for untrusted commands.
|
|
56
|
+
3. **Remote** — `VercelSandboxWorkspace` + `VercelSandboxExec`. Files and bash inside a Vercel Firecracker microVM. Backend is a thin orchestrator. Multi-tenant-safe, PaaS-backend-agnostic.
|
|
57
|
+
|
|
58
|
+
Each mode exposes **identical** HTTP endpoints, the **same** agent tool shapes, and the **same** chat UI. One config knob picks the mode per app.
|
|
59
|
+
|
|
60
|
+
## Non-goals (v1)
|
|
61
|
+
|
|
62
|
+
- **Multi-workspace management — owned by the future `@boring/cloud` package.** Agent runs **one workspace per instance**; `workspaceId` is a configured constant or env var. No workspace CRUD, no workspace switcher UI, no per-user provisioning in the agent package.
|
|
63
|
+
- Multi-user auth, billing, team features.
|
|
64
|
+
- Full session list sidebar UI — agent ships a lightweight toolbar (current-session + switch) and the headless `useSessions()` hook. Rich session UX (persistent sidebar, folder organization, search) is a workspace-package concern.
|
|
65
|
+
- FS panes, file tree, editor — those live in `@boring/workspace`. (The agent package *exposes* the HTTP endpoints that power them.) Git UI is **dropped from workspace v1**; when it returns in v1.x, agent will add `/api/v1/git/*` thin-wrapper routes.
|
|
66
|
+
- Cloud/edge deployment tooling.
|
|
67
|
+
- Browser-agent implementation — interface stays runtime-agnostic but no browser harness yet.
|
|
68
|
+
- Non-Anthropic model providers.
|
|
69
|
+
- MCP tool integration.
|
|
70
|
+
- Third sandbox options (Boxlite, @vercel/sandbox-only-for-isolated-code, Cloudflare Sandbox) — designed as seams, not shipped.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Central model — two layers
|
|
75
|
+
|
|
76
|
+
The package has **two layers**. Treat the boundary as load-bearing.
|
|
77
|
+
|
|
78
|
+
### Layer 1 — Core runtime (4 abstractions, interfaces locked in v1)
|
|
79
|
+
|
|
80
|
+
**Harness · Catalog · Workspace · Sandbox.** These define *how* the agent executes tools. Interfaces are stable; adapter implementations swap per mode (direct / local / vercel-sandbox). Changing a Layer-1 interface is a breaking change.
|
|
81
|
+
|
|
82
|
+
### Layer 2 — Integration services (3 replaceable plumbing pieces)
|
|
83
|
+
|
|
84
|
+
**SessionStore · UiBridge · WorkspaceProvisioning.** Plumbing that connects the runtime to a host app. Each is an interface in `shared/` with a default impl; consumers inject alternatives via `createAgentApp({ sessionStore, uiBridge, ... })`. Evolving a service impl (or adding a new one) doesn't touch Layer 1.
|
|
85
|
+
|
|
86
|
+
*(Pi plugins are a separate concern — user-installable catalog extensions that add tools at load time. They live alongside Layer 1 tools, not as their own layer. See the Plugin compat section.)*
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
The four Layer-1 abstractions are independent (Harness + Catalog swap freely; Workspace + Sandbox swap as a paired `RuntimeModeAdapter`); they connect only in the direction shown. (Environment provisioning — files and library tiers — is Layer-2 plumbing, not part of the runtime diagram.)
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
┌──────────────┐
|
|
94
|
+
│ Harness │ agent loop: LLM round-trip, tool-call protocol,
|
|
95
|
+
│ │ stream emission (UIMessage parts for useChat)
|
|
96
|
+
└──────┬───────┘
|
|
97
|
+
│ consumes AgentTool[] only
|
|
98
|
+
▼
|
|
99
|
+
┌──────────────┐
|
|
100
|
+
│ Catalog │ named, typed tool set visible to the LLM:
|
|
101
|
+
│ (Tools) │ bash, read, write, edit, find, grep, ls
|
|
102
|
+
└──────┬───────┘
|
|
103
|
+
│ each tool binds to EITHER Workspace or Sandbox
|
|
104
|
+
▼
|
|
105
|
+
┌──────────────┐ ┌──────────────┐
|
|
106
|
+
│ Workspace │ │ Sandbox │
|
|
107
|
+
│ │ │ │
|
|
108
|
+
│ path-bound │ │ shell exec │
|
|
109
|
+
│ fs ops │ │ only │
|
|
110
|
+
│ │ │ │
|
|
111
|
+
│ read/write/ │ │ exec(cmd) │
|
|
112
|
+
│ readdir/ │ │ │
|
|
113
|
+
│ stat │ │ │
|
|
114
|
+
└──────────────┘ └──────────────┘
|
|
115
|
+
read/write/edit bash
|
|
116
|
+
call into here calls into here
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Two implementation layers sit side by side under the catalog because they have different security profiles:
|
|
120
|
+
|
|
121
|
+
- **Workspace** = fs ops scoped to a project root. For local adapter: direct Node `fs` + strict path enforcement (follow nao's pattern: `validatePath` + `assertRealPathWithinWorkspace`). For remote adapter: API call to the sandbox's fs primitive with the same path checks. No VM overhead for local reads because caller is always our own code — user input never reaches a shell via these paths.
|
|
122
|
+
- **Sandbox** = shell exec only. Real isolation matters here; bound to bwrap or Vercel Firecracker VM.
|
|
123
|
+
|
|
124
|
+
**Key invariants:**
|
|
125
|
+
- **Harness is sandbox-agnostic AND workspace-agnostic.** It sees `AgentTool[]` only.
|
|
126
|
+
- **Sandbox is workspace-agnostic** from the catalog's perspective. Swap sandbox → fs tools unchanged.
|
|
127
|
+
- **Workspace is sandbox-agnostic** from the catalog's perspective. Swap workspace → bash tool unchanged.
|
|
128
|
+
- **Pairing rule:** when workspace is remote (e.g. `VercelSandboxWorkspace`), the sandbox MUST target the **same underlying VM instance** — otherwise the agent's `bash` would see a different fs than the workspace tools. Enforced at adapter construction (`createVercelSandboxExec(sandbox)` takes the same `sandbox` handle the workspace uses). Local pairings (`NodeWorkspace` + `BwrapSandbox`) naturally share the host fs via bind-mount.
|
|
129
|
+
- **Swappability**: Harness and Catalog swap independently. Workspace and Sandbox swap as a **validated pair via `RuntimeModeAdapter`** — they must share a filesystem substrate (agent writes via Workspace and reads via Sandbox.exec; mismatched pair ⇒ split-brain). Pair members stay interface-decoupled; construction is joint.
|
|
130
|
+
|
|
131
|
+
### In one snippet — local mode
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
const workspace = createNodeWorkspace({ root: resolveWorkspacePath(cfg.root, sessionWorkspaceId) })
|
|
135
|
+
const sandbox = createBwrapSandbox({ workspace })
|
|
136
|
+
const bundle = { workspace, sandbox }
|
|
137
|
+
const tools = [...buildHarnessAgentTools(bundle), ...buildFilesystemAgentTools(bundle)]
|
|
138
|
+
const harness = createPiCodingAgentHarness({ tools })
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### In one snippet — remote mode (Vercel)
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const vmHandle = await resolveSandboxHandle(sessionWorkspaceId) // Sandbox.get() or Sandbox.create()
|
|
145
|
+
const workspace = createVercelSandboxWorkspace(vmHandle)
|
|
146
|
+
const sandbox = createVercelSandboxExec(vmHandle) // same handle — pairing rule
|
|
147
|
+
const bundle = { workspace, sandbox }
|
|
148
|
+
const tools = [...buildHarnessAgentTools(bundle), ...buildFilesystemAgentTools(bundle)]
|
|
149
|
+
const harness = createPiCodingAgentHarness({ tools })
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Identical `tools` + `harness` construction. Only the workspace/sandbox pair differs.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Unified workspace access — one interface, two consumers
|
|
157
|
+
|
|
158
|
+
**This is the architectural keystone.** Both the HTTP routes that power the frontend (FileTree, Editor, GitChanges) and the agent tools (read/write/edit) consume the *same* `Workspace` interface. Neither has its own fs logic.
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
┌────────────────────────┐
|
|
162
|
+
│ Workspace interface │
|
|
163
|
+
│ readFile, writeFile, │
|
|
164
|
+
│ readdir, stat, unlink │
|
|
165
|
+
└───────────┬────────────┘
|
|
166
|
+
│
|
|
167
|
+
┌──────────────┼──────────────┐
|
|
168
|
+
│ │
|
|
169
|
+
▼ ▼
|
|
170
|
+
HTTP routes Agent tools
|
|
171
|
+
(frontend UI) (LLM-facing)
|
|
172
|
+
/api/v1/files GET readTool
|
|
173
|
+
/api/v1/files POST writeTool
|
|
174
|
+
/api/v1/tree GET editTool
|
|
175
|
+
/api/v1/stat GET
|
|
176
|
+
│ │
|
|
177
|
+
│ thin wrappers: │ thin wrappers:
|
|
178
|
+
│ auth + call workspace.X │ schema + call workspace.X
|
|
179
|
+
│ │
|
|
180
|
+
└──────────────┬──────────────┘
|
|
181
|
+
│
|
|
182
|
+
▼
|
|
183
|
+
┌─────────────────────────────┐
|
|
184
|
+
│ Workspace adapter │
|
|
185
|
+
│ (swappable, async) │
|
|
186
|
+
├─────────────────────────────┤
|
|
187
|
+
│ NodeWorkspace (local) │
|
|
188
|
+
│ VercelSandboxWorkspace │
|
|
189
|
+
└─────────────────────────────┘
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Why this matters
|
|
193
|
+
|
|
194
|
+
- **No split-brain:** the user cannot see a file the agent can't, or vice-versa. One fs, two interfaces onto it.
|
|
195
|
+
- **Location-agnostic migration:** switching `NodeWorkspace → VercelSandboxWorkspace` changes what the FileTree reads AND what the agent reads — simultaneously, from one config change.
|
|
196
|
+
- **One place to validate paths, check permissions, enforce boundaries** — the adapter. Routes and tools never reimplement.
|
|
197
|
+
|
|
198
|
+
### Example: identical handlers, different consumers
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
// HTTP route (used by FileTree / Editor in the frontend)
|
|
202
|
+
app.get('/api/v1/tree', async (req, reply) => {
|
|
203
|
+
const workspace = await resolveWorkspace(req.query.workspaceId, req.auth)
|
|
204
|
+
return workspace.readdir(req.query.path ?? '')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// Agent tool (used by LLM)
|
|
208
|
+
export const readTool = (workspace: Workspace): AgentTool => ({
|
|
209
|
+
name: 'read',
|
|
210
|
+
description: 'Read a file from the workspace',
|
|
211
|
+
parameters: { type: 'object', properties: { path: { type: 'string' } } },
|
|
212
|
+
async execute({ path }) {
|
|
213
|
+
return { content: [{ type: 'text', text: await workspace.readFile(path) }] }
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Both call `workspace.X()`. No fs-specific logic in either. **The adapter is where the location-choice lives.**
|
|
219
|
+
|
|
220
|
+
### HTTP API reference (canonical)
|
|
221
|
+
|
|
222
|
+
This table is the **single source of truth** for agent ↔ frontend HTTP contracts. Every route is a thin wrapper over a typed service call — no fs-specific logic in handlers. Other sections (Session management, UI bridge, Package layout) reference this table instead of restating routes.
|
|
223
|
+
|
|
224
|
+
| Route | Method | Handler body | Service call |
|
|
225
|
+
|---|---|---|---|
|
|
226
|
+
| **Files & tree** | | | |
|
|
227
|
+
| `/api/v1/tree` | GET | auth + validate | `workspace.readdir(path)` |
|
|
228
|
+
| `/api/v1/files` | GET | auth + validate | `workspace.readFile(path)` |
|
|
229
|
+
| `/api/v1/files` | POST | auth + validate + body | `workspace.writeFile(path, body)` |
|
|
230
|
+
| `/api/v1/files` | DELETE | auth + validate | `workspace.unlink(path)` (file or empty dir) |
|
|
231
|
+
| `/api/v1/files/search` | GET | auth + `?q=<glob>&limit=<n>` | `fileSearch.search(glob, limit)` — default impl uses `sandbox.exec('find . -name …')`; browser adapter does fs-walk |
|
|
232
|
+
| `/api/v1/files/move` | POST | auth + body `{from, to}` | `workspace.rename(from, to)` — handles drag-and-drop move AND in-place rename (same op; UI just differs). |
|
|
233
|
+
| `/api/v1/dirs` | POST | auth + body `{path, recursive?}` | `workspace.mkdir(path, {recursive})` — create new folder. |
|
|
234
|
+
| `/api/v1/stat` | GET | auth + validate | `workspace.stat(path)` |
|
|
235
|
+
| **Agent chat & sessions** | | | |
|
|
236
|
+
| `/api/v1/agent/chat` | POST | auth + body `{sessionId, message, …}` | resolves workspace + sandbox pair via `RuntimeModeAdapter`, runs harness with the catalog. Returns UIMessage stream. |
|
|
237
|
+
| `/api/v1/agent/chat/:sessionId/:turnId` | GET (SSE) | auth + `?cursor=<n>` | stream resume (transport-owned). Replays from ring buffer if in-flight, from `SessionStore` if turn completed. |
|
|
238
|
+
| `/api/v1/agent/sessions` | GET | auth | `SessionStore.list()` → `SessionSummary[]` (within configured workspace) |
|
|
239
|
+
| `/api/v1/agent/sessions` | POST | auth | `SessionStore.create()` → `SessionSummary` |
|
|
240
|
+
| `/api/v1/agent/sessions/:id` | GET | auth | `SessionStore.load(id)` → `SessionDetail` |
|
|
241
|
+
| `/api/v1/agent/sessions/:id` | DELETE | auth | `SessionStore.delete(id)` → 204 |
|
|
242
|
+
| `/api/v1/agent/sessions/:id/changes` | GET | auth | `{files: [{path, op, size}]}` since session start |
|
|
243
|
+
| **UI bridge** | | | |
|
|
244
|
+
| `/api/v1/ui/state` | GET/PUT | auth | read / write UI state blob (workspace-defined shape). Workspace PUTs with `causedBy` field. |
|
|
245
|
+
| `/api/v1/ui/state/latest` | GET | auth | cached state snapshot for short-poll fallback (same shape as GET /state, separate endpoint for polling clients) |
|
|
246
|
+
| `/api/v1/ui/commands` | POST | auth | agent posts command for UI. `{kind: 'openFile', params: {...}}` — camelCase `kind` field. Returns `{seq, status: 'ok'\|'error'}`. |
|
|
247
|
+
| `/api/v1/ui/commands/next` | GET (SSE or poll) | auth | Default: SSE; streams `event: command` with `{v:1, kind, params, seq}`. With `?poll=true`: returns a batch of pending commands as JSON `{commands: [...]}` — short-poll fallback (~2s cadence) for environments where SSE is unavailable. |
|
|
248
|
+
| **Explicitly not in v1** | | | |
|
|
249
|
+
| ~~`/api/v1/git/*`~~ | — | Dead code in v1 (no consumer) — see note below. Agent runs git internally via `bash`. | — |
|
|
250
|
+
| ~~`/api/v1/workspaces`~~ | — | Workspace CRUD / multi-workspace provisioning owned by `@boring/cloud`. Agent v1 is single-workspace-per-instance. | — |
|
|
251
|
+
| ~~Rename session~~ | — | Deferred to workspace package. Delete ships in v1. | — |
|
|
252
|
+
|
|
253
|
+
**Git routes deferred from v1.** The workspace plan explicitly drops all git UI ("Git UI in workspace: Dropped entirely. Agent owns all git UI."). Agent v1 also doesn't ship git UI components (no status bar, no diff pane — chat + session toolbar only). **With no consumer in v1, the routes would be dead code.** Agent runs git internally via the `bash` tool (`sandbox.exec('git status')`) when needed. When git UI lands (agent v1.x adding a status/diff pane, or workspace reviving git badges), add the routes as ~200 LOC of thin `sandbox.exec` wrappers with output parsing. Git in remote mode (credentials via token-in-URL, latency, VM persistence) is documented here so we're ready when we need it; not implemented in v1.
|
|
254
|
+
|
|
255
|
+
Every route's file-system behavior is determined entirely by the adapter chosen at `resolveWorkspace(workspaceId)`. Flip the adapter → all routes flip together. Zero duplicated fs logic.
|
|
256
|
+
|
|
257
|
+
### Invariants to preserve
|
|
258
|
+
|
|
259
|
+
1. **No `node:fs` / `node:child_process` imports in routes, catalog, or tools.** These Node APIs are allowed only inside adapter implementations (`server/workspace/**`, `server/harness/**`, `server/sandbox/**`, `server/runtime/**`). Grep-enforceable.
|
|
260
|
+
2. **Routes and tools both receive `Workspace` as a parameter** (not a path, not a root dir). Resolution to a specific adapter is the job of `resolveWorkspace(workspaceId)` — centralized.
|
|
261
|
+
3. **Per-request, agent tools and HTTP routes share the same adapter instance.** Ensures a read concurrent with a write sees each other's effects without cache-coherence games.
|
|
262
|
+
4. **Path validation is the adapter's responsibility.** Consumers pass user-supplied paths; the adapter rejects `../` / absolute / symlink-escape attacks internally.
|
|
263
|
+
|
|
264
|
+
This is what makes **"user workspace"** and **"agent workspace"** literally the same workspace — not by convention, but by construction.
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## The four abstractions, spelled out
|
|
269
|
+
|
|
270
|
+
### 1. Harness (`src/shared/harness.ts`)
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
export interface AgentHarness {
|
|
274
|
+
readonly id: string // 'pi-coding-agent' | 'pi-agent-core' | 'tool-loop'
|
|
275
|
+
readonly placement: 'server' | 'browser'
|
|
276
|
+
|
|
277
|
+
/** Send a user message. Yields AI SDK UIMessage stream chunks. */
|
|
278
|
+
sendMessage(
|
|
279
|
+
input: SendMessageInput,
|
|
280
|
+
ctx: RunContext,
|
|
281
|
+
): AsyncIterable<UIMessageChunk>
|
|
282
|
+
|
|
283
|
+
/** Session lifecycle; may delegate to an underlying runtime (e.g. pi's JSONL). */
|
|
284
|
+
sessions: SessionStore
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* Resume is NOT a harness concern — see "Stream resumption" section.
|
|
288
|
+
The HTTP route owns cursor buffering + replay; harness stays reconnect-unaware. */
|
|
289
|
+
|
|
290
|
+
export interface SendMessageInput {
|
|
291
|
+
sessionId: string
|
|
292
|
+
message: string // user's prompt
|
|
293
|
+
thinkingLevel?: 'off' | 'low' | 'medium' | 'high'
|
|
294
|
+
model?: { provider: string; id: string }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export interface RunContext {
|
|
298
|
+
abortSignal: AbortSignal
|
|
299
|
+
workdir: string
|
|
300
|
+
userId?: string
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### 2. Catalog & Tools (`src/shared/tool.ts`, `src/shared/catalog.ts`)
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
export interface AgentTool {
|
|
308
|
+
name: string
|
|
309
|
+
description: string
|
|
310
|
+
parameters: JSONSchema
|
|
311
|
+
execute(
|
|
312
|
+
params: Record<string, unknown>,
|
|
313
|
+
ctx: ToolExecContext,
|
|
314
|
+
): Promise<ToolResult>
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface ToolExecContext {
|
|
318
|
+
abortSignal: AbortSignal
|
|
319
|
+
toolCallId: string
|
|
320
|
+
onUpdate?: (partial: string) => void
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export interface ToolResult {
|
|
324
|
+
content: Array<{ type: 'text'; text: string }>
|
|
325
|
+
isError?: boolean
|
|
326
|
+
details?: unknown
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface CatalogDeps {
|
|
330
|
+
workspace: Workspace
|
|
331
|
+
sandbox: Sandbox
|
|
332
|
+
uiBridge?: UiBridge // optional; when present, catalog includes get_ui_state + exec_ui tools
|
|
333
|
+
fileSearch?: FileSearch // optional; used by routes but not exposed as an agent tool
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** A catalog binds to workspace, sandbox, and optional integration services. */
|
|
337
|
+
export type ToolCatalog = (deps: CatalogDeps) => AgentTool[]
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Shipped runtime tool bundle (pi factories + Operations adapters + capability-gated custom tools):
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
const tools: AgentTool[] = [
|
|
344
|
+
...buildHarnessAgentTools(bundle), // bash, plus execute_isolated_code when supported
|
|
345
|
+
...buildFilesystemAgentTools(bundle), // read, write, edit, find, grep, ls
|
|
346
|
+
]
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
- **7 standard pi tools always:** `bash`, `read`, `write`, `edit`, `find`, `grep`, `ls`.
|
|
350
|
+
- **Operations adapters own backend behavior:** local/direct use `boundFs` and local bash ops; Vercel mode swaps in Vercel-backed Operations.
|
|
351
|
+
- **Custom AgentTools stay exceptional:** `execute_isolated_code` is capability-gated; `vercelGrepTool` exists only because pi's grep Operations seam cannot redirect its `rg` spawn into the remote VM while keeping pi's name, description, and schema.
|
|
352
|
+
- **2 UI bridge tools when `uiBridge` is wired** (default in v1):
|
|
353
|
+
- `get_ui_state()` → returns current UI state blob
|
|
354
|
+
- `exec_ui({ kind, params })` → generic dispatcher; kinds enumerated in tool description (`openFile`, `openPanel`, `showNotification`, extensible)
|
|
355
|
+
|
|
356
|
+
**Why `exec_ui` instead of one tool per command:** one UI dispatcher keeps host integration extensible without adding a new tool for every UI action (new kind values = schema enum addition), and it matches how the underlying bridge `POST /api/v1/ui/commands` already works. The kind enum is published on the tool's `parameters` so the LLM knows valid commands.
|
|
357
|
+
|
|
358
|
+
### 3. Workspace (`src/shared/workspace.ts`)
|
|
359
|
+
|
|
360
|
+
Path-scoped fs ops. Interface is platform-agnostic so a future browser impl (OPFS / IndexedDB) can satisfy it.
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
export interface Workspace {
|
|
364
|
+
readonly root: string // absolute path or opaque URI (browser)
|
|
365
|
+
readFile(relPath: string): Promise<string>
|
|
366
|
+
writeFile(relPath: string, data: string): Promise<void>
|
|
367
|
+
unlink(relPath: string): Promise<void> // removes a file OR an empty directory
|
|
368
|
+
readdir(relPath: string): Promise<Entry[]>
|
|
369
|
+
stat(relPath: string): Promise<Stat>
|
|
370
|
+
mkdir(relPath: string, opts?: { recursive?: boolean }): Promise<void>
|
|
371
|
+
rename(fromRelPath: string, toRelPath: string): Promise<void> // handles drag-to-move and in-place rename
|
|
372
|
+
/** Throws if any relPath escapes the workspace root. */
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Separate interface — search can use exec (find) OR fs-walk, depending on environment.
|
|
376
|
+
// Uncouples "what the Workspace guarantees" (path-bound fs primitives) from "how we find files".
|
|
377
|
+
export interface FileSearch {
|
|
378
|
+
search(glob: string, limit?: number): Promise<string[]>
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export interface Entry { name: string; isDir: boolean }
|
|
382
|
+
export interface Stat { isDir: boolean; size: number; mtime: Date }
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Server impl (`createNodeWorkspace`) uses existing boring-ui helpers — port `validatePath`, `assertRealPathWithinWorkspace`, `ensureWritableWorkspacePath` from `packages/workspace/src/server/workspace/paths.ts` straight into `src/server/workspace/paths.ts` in this package.
|
|
386
|
+
|
|
387
|
+
Workspace creation accepts an optional `templatePath` — if set, contents are copied into the workspace root synchronously on first creation (see [Environment provisioning](#environment-provisioning)). No abstraction layer in v1; upgrade path documented.
|
|
388
|
+
|
|
389
|
+
### 4. Sandbox (`src/shared/sandbox.ts`)
|
|
390
|
+
|
|
391
|
+
Narrow interface for shell exec + optional richer capabilities advertised via `capabilities`.
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
export interface Sandbox {
|
|
395
|
+
readonly id: string // 'bwrap' | 'boxlite' | '@vercel/sandbox' | 'just-bash' | …
|
|
396
|
+
readonly placement: 'server' | 'browser'
|
|
397
|
+
readonly capabilities: readonly SandboxCapability[]
|
|
398
|
+
|
|
399
|
+
init(ctx: { workspace: Workspace; sessionId: string }): Promise<void>
|
|
400
|
+
|
|
401
|
+
// Always available (capability 'exec'):
|
|
402
|
+
exec(
|
|
403
|
+
cmd: string,
|
|
404
|
+
opts?: ExecOptions,
|
|
405
|
+
): Promise<ExecResult>
|
|
406
|
+
|
|
407
|
+
// Optional — present iff capabilities includes 'isolated-code':
|
|
408
|
+
executeIsolatedCode?(input: IsolatedCodeInput): Promise<IsolatedCodeOutput>
|
|
409
|
+
|
|
410
|
+
dispose?(): Promise<void>
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export type SandboxCapability = 'exec' | 'isolated-code'
|
|
414
|
+
|
|
415
|
+
export interface ExecOptions {
|
|
416
|
+
cwd?: string
|
|
417
|
+
env?: Record<string, string>
|
|
418
|
+
signal?: AbortSignal
|
|
419
|
+
timeoutMs?: number // hard timeout; kills the child process
|
|
420
|
+
maxOutputBytes?: number // per-stream cap; output beyond this is truncated
|
|
421
|
+
onHeartbeat?: (elapsedMs: number) => void // called every 2s during active exec
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export interface ExecResult {
|
|
425
|
+
stdout: Uint8Array // raw bytes; server adapter decodes
|
|
426
|
+
stderr: Uint8Array
|
|
427
|
+
exitCode: number
|
|
428
|
+
durationMs: number
|
|
429
|
+
truncated: boolean // true if maxOutputBytes was hit
|
|
430
|
+
stdoutEncoding?: 'utf-8' | 'binary' // best-effort classification for UI rendering
|
|
431
|
+
stderrEncoding?: 'utf-8' | 'binary'
|
|
432
|
+
}
|
|
433
|
+
// Decode helpers live in server adapters, not the shared contract.
|
|
434
|
+
|
|
435
|
+
export interface IsolatedCodeInput {
|
|
436
|
+
code: string
|
|
437
|
+
language: 'python' | 'shell'
|
|
438
|
+
image?: string // e.g. 'python:3.12-slim' — sandbox-defined set
|
|
439
|
+
packages?: string[] // installed before run (pip/npm/etc, sandbox decides)
|
|
440
|
+
sandboxId?: string // reuse a prior pooled VM (TTL-based)
|
|
441
|
+
vmSize?: 'xxs' | 'xs' | 's' | 'm' | 'l'
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export interface IsolatedCodeOutput {
|
|
445
|
+
sandboxId: string // for reuse
|
|
446
|
+
stdout: string
|
|
447
|
+
stderr: string
|
|
448
|
+
exitCode: number
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Sandbox takes a `Workspace` in `init()` so exec `cwd` defaults to the workspace root. Capabilities are declared statically so the catalog can conditionally include `execute_isolated_code` only when the sandbox supports it — **no runtime "not supported" errors, no dead tools in the agent's view.**
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
## Options we ship & code we own
|
|
457
|
+
|
|
458
|
+
| Abstraction | v1 ships | Reserved for future | Code we author |
|
|
459
|
+
|---|---|---|---|
|
|
460
|
+
| **Harness** | `PiCodingAgentHarness` (server) | `PiAgentCoreHarness` (browser), `ToolLoopAgentHarness` | Interface + 1 adapter (pi events → UIMessage stream) |
|
|
461
|
+
| **Catalog** | Pi factory bundle: `bash`, `read`, `write`, `edit`, `find`, `grep`, `ls` | App-registered extras via pi extensions; custom AgentTools only when pi has no equivalent or cannot accommodate the backend | Operations adapters + harness/filesystem bundle factories + renderers + fallback |
|
|
462
|
+
| **Workspace** | **2 adapters: `NodeWorkspace` (local host fs) + `VercelSandboxWorkspace` (remote VM)** — single workspace per instance. `NodeWorkspace` is shared by `direct` and `local` modes. | `OpfsWorkspace`/`LightningFsWorkspace` (browser), `CloudflareSandboxWorkspace` | Interface + 2 adapters + path helpers |
|
|
463
|
+
| **Sandbox** | **3 adapters: `DirectSandbox` (no isolation) + `BwrapSandbox` (Linux process isolation) + `VercelSandboxExec` (Firecracker VM)** — all declare `capabilities: ['exec']` | `VercelIsolatedCode` / `BoxliteSandbox` (add `'isolated-code'` capability), `JustBashSandbox` (browser) | Interface + capabilities + 3 adapters |
|
|
464
|
+
| **Mode** | `direct` / `local` / `vercel-sandbox` — each a self-contained `RuntimeModeAdapter` under `src/server/runtime/modes/`. `resolveMode(mode)` produces a `RuntimeBundle` ({ workspace, sandbox, fileSearch }). Pairing invariant enforced by construction. | Multi-workspace provisioning owned by future `@boring/cloud` | `[runtime].mode` config + factory |
|
|
465
|
+
|
|
466
|
+
### External dependencies (not ours)
|
|
467
|
+
|
|
468
|
+
| Dep | For | Owned by |
|
|
469
|
+
|---|---|---|
|
|
470
|
+
| `@mariozechner/pi-coding-agent` | Harness runtime + plugin loading | Mario Zechner |
|
|
471
|
+
| `ai`, `@ai-sdk/react` | UIMessage stream + `useChat` | Vercel |
|
|
472
|
+
| `@vercel/sandbox` | Remote-mode workspace + exec (Firecracker VM) | Vercel |
|
|
473
|
+
| `ai-elements` (copied) | Chat UI primitives | Vercel (we own copies) |
|
|
474
|
+
| `fastify`, `@fastify/cors` | Standalone server | — |
|
|
475
|
+
| System `bwrap` binary | Local-mode process sandbox (Linux) | — |
|
|
476
|
+
| `just-bash` (future) | Portable in-memory bash interpreter for browser-agent mode | Vercel Labs |
|
|
477
|
+
|
|
478
|
+
### Code we author (LOC ballpark)
|
|
479
|
+
|
|
480
|
+
| Piece | LOC |
|
|
481
|
+
|---|---|
|
|
482
|
+
| **CLI entrypoint** (`bin/boring-agent` — flag parser, API-key prompt, port picker, browser-open, server boot) | ~200 |
|
|
483
|
+
| `AgentHarness`, `Workspace`, `Sandbox`, `AgentTool`, `ToolCatalog` interfaces | ~150 |
|
|
484
|
+
| Pi → UIMessage stream adapter | ~150 |
|
|
485
|
+
| Operations adapters (bash + 6 file ops, mode-specific) + bundle factories | ~250 |
|
|
486
|
+
| Custom tools (`vercelGrepTool`, `executeIsolatedCodeTool`) | ~250 |
|
|
487
|
+
| Tool rendering — ai-elements `Tool` + `Terminal` + `CodeBlock` by default (no DiffView in v1) | ~60 |
|
|
488
|
+
| `NodeWorkspace` + ported path helpers | ~200 |
|
|
489
|
+
| `DirectSandbox` adapter | ~40 |
|
|
490
|
+
| `BwrapSandbox` adapter | ~120 |
|
|
491
|
+
| `VercelSandboxWorkspace` adapter | ~80 |
|
|
492
|
+
| `VercelSandboxExec` adapter | ~80 |
|
|
493
|
+
| `resolveSandboxHandle()` — Vercel lifecycle (create/get/resume) + in-process cache | ~100 |
|
|
494
|
+
| `SandboxHandleStore` interface + `FileHandleStore` default impl (~/.config/boring-agent/sandboxes.json) | ~60 |
|
|
495
|
+
| Fastify routes (`/api/v1/files` GET/POST/DELETE, `/files/move`, `/dirs`, `/tree`, `/stat`, `/files/search`, `/agent/chat`, `/agent/sessions` list+create+detail+delete, `/ui/*` bridge) | ~330 |
|
|
496
|
+
| `UiBridge` interface + in-memory impl + SSE fan-out | ~150 |
|
|
497
|
+
| UI agent tools (`get_ui_state`, `exec_ui` dispatcher) | ~60 |
|
|
498
|
+
| `<ChatPanel />`, `<SessionToolbar />`, `useAgentChat`, `useSessions` (list/create/switch/delete) | ~240 |
|
|
499
|
+
| `<ModelPicker />` + `<ThinkingToggle />` inside `<Composer />` | ~80 |
|
|
500
|
+
| ai-elements pieces copied into `primitives/` (Message, Composer, Tool, Terminal, CodeBlock, Reasoning) | ~400 (we own after shadcn install) |
|
|
501
|
+
| `theme.css` + defaults | ~100 |
|
|
502
|
+
| Standalone app (`packages/agent/app/` — thin dev affordance) | ~100 |
|
|
503
|
+
| Config / env / mode auto-detection | ~100 |
|
|
504
|
+
| `PiSessionStore` (read-over pi JSONL; list/create/switch/delete) | ~120 |
|
|
505
|
+
| Stream resumption (cursor tracking + replay route) | ~60 |
|
|
506
|
+
| Slash commands (parser + 5 built-ins + registry) | ~80 |
|
|
507
|
+
| Heartbeat events for long tool calls | ~20 |
|
|
508
|
+
| `/api/v1/agent/sessions/:id/changes` + tracker | ~80 |
|
|
509
|
+
| Snapshot retention (keep-last-2 policy) | ~15 |
|
|
510
|
+
| CLI SSH detection + auto-gitignore + logout flags | ~30 |
|
|
511
|
+
| **Total** | **≈ 3,370 LOC** |
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
515
|
+
## Package layout
|
|
516
|
+
|
|
517
|
+
```
|
|
518
|
+
packages/agent/
|
|
519
|
+
├── package.json
|
|
520
|
+
├── tsconfig.json
|
|
521
|
+
├── README.md
|
|
522
|
+
├── bin/ ← CLI entrypoint shipped via package.json "bin"
|
|
523
|
+
│ └── boring-agent.ts (flag parser, API-key flow, port picker, browser-open, boot)
|
|
524
|
+
├── app/ ← frontend + server factory (shared CLI + dev)
|
|
525
|
+
│ ├── index.html
|
|
526
|
+
│ ├── vite.config.ts
|
|
527
|
+
│ ├── server.ts (Fastify entry; serves built SPA in CLI mode, proxies Vite in dev)
|
|
528
|
+
│ └── src/main.tsx (mounts <ChatPanel />)
|
|
529
|
+
├── docs/plans/
|
|
530
|
+
│ └── agent-package-spec.md (this file)
|
|
531
|
+
└── src/
|
|
532
|
+
├── shared/
|
|
533
|
+
│ ├── harness.ts (AgentHarness interface)
|
|
534
|
+
│ ├── workspace.ts (Workspace interface — platform-agnostic)
|
|
535
|
+
│ ├── sandbox.ts (Sandbox interface — exec only)
|
|
536
|
+
│ ├── tool.ts (AgentTool interface)
|
|
537
|
+
│ ├── catalog.ts (ToolCatalog type + deps only)
|
|
538
|
+
│ ├── session.ts (SessionStore interface + types)
|
|
539
|
+
│ ├── sandbox-handle-store.ts (SandboxHandleStore interface — file default, swappable for DB)
|
|
540
|
+
│ ├── ui-bridge.ts (UiBridge interface: state KV + command queue)
|
|
541
|
+
│ ├── message.ts (UIMessage extension type)
|
|
542
|
+
│ └── index.ts
|
|
543
|
+
├── server/
|
|
544
|
+
│ ├── index.ts
|
|
545
|
+
│ ├── harness/
|
|
546
|
+
│ │ └── pi-coding-agent/
|
|
547
|
+
│ │ ├── createHarness.ts (wraps createAgentSession)
|
|
548
|
+
│ │ ├── stream-adapter.ts (pi event → UIMessageChunk)
|
|
549
|
+
│ │ └── sessions.ts (PiSessionStore over pi JSONL)
|
|
550
|
+
│ ├── runtime/
|
|
551
|
+
│ │ ├── mode.ts (RuntimeModeAdapter contract)
|
|
552
|
+
│ │ ├── resolveMode.ts (mode → RuntimeBundle)
|
|
553
|
+
│ │ └── modes/
|
|
554
|
+
│ │ ├── direct.ts (NodeWorkspace + DirectSandbox + HostFindSearch)
|
|
555
|
+
│ │ ├── local.ts (NodeWorkspace + BwrapSandbox + HostFindSearch)
|
|
556
|
+
│ │ └── vercel-sandbox.ts (VercelSandboxWorkspace + VercelSandboxExec + VmFindSearch)
|
|
557
|
+
│ ├── workspace/
|
|
558
|
+
│ │ ├── paths.ts (path-boundary helpers, ported from boring-ui)
|
|
559
|
+
│ │ ├── provision.ts (copyTemplate — minimal v1 seeding)
|
|
560
|
+
│ │ ├── node/
|
|
561
|
+
│ │ │ └── createNodeWorkspace.ts
|
|
562
|
+
│ │ └── vercel-sandbox/
|
|
563
|
+
│ │ ├── createVercelSandboxWorkspace.ts
|
|
564
|
+
│ │ └── sandboxHandles.ts (resolveSandboxHandle cache, consumes SandboxHandleStore)
|
|
565
|
+
│ ├── file-search/
|
|
566
|
+
│ │ ├── hostFindSearch.ts (default impl via sandbox.exec('find …'))
|
|
567
|
+
│ │ └── (future: browserFsWalkSearch.ts for browser-agent mode)
|
|
568
|
+
│ ├── sandbox/
|
|
569
|
+
│ │ ├── direct/ (no isolation — child_process.exec)
|
|
570
|
+
│ │ │ └── createDirectSandbox.ts
|
|
571
|
+
│ │ ├── bwrap/ (pairs with NodeWorkspace, Linux only)
|
|
572
|
+
│ │ │ ├── createBwrapSandbox.ts
|
|
573
|
+
│ │ │ └── exec.ts (bwrap command helper)
|
|
574
|
+
│ │ └── vercel-sandbox/ (pairs with VercelSandboxWorkspace)
|
|
575
|
+
│ │ └── createVercelSandboxExec.ts
|
|
576
|
+
│ ├── catalog/
|
|
577
|
+
│ │ ├── mergeTools.ts (host/custom tool merge + name collision guard)
|
|
578
|
+
│ │ └── tools/_shared.ts (shared helpers retained for migration/test coverage)
|
|
579
|
+
│ ├── tools/
|
|
580
|
+
│ │ ├── harness/index.ts (buildHarnessAgentTools: pi bash + isolated-code)
|
|
581
|
+
│ │ ├── filesystem/index.ts (buildFilesystemAgentTools: pi read/write/edit/find/grep/ls)
|
|
582
|
+
│ │ ├── operations/
|
|
583
|
+
│ │ │ ├── bound.ts (path-bounded local/direct fs Operations)
|
|
584
|
+
│ │ │ └── vercel.ts (Vercel Sandbox-backed fs/bash Operations)
|
|
585
|
+
│ │ └── vercelGrepTool.ts (custom AgentTool preserving pi grep schema)
|
|
586
|
+
│ ├── ui-bridge/
|
|
587
|
+
│ │ ├── createInMemoryBridge.ts (in-memory UiBridge impl)
|
|
588
|
+
│ │ └── sseCommandStream.ts (SSE fan-out helper)
|
|
589
|
+
│ ├── http/
|
|
590
|
+
│ │ └── routes/
|
|
591
|
+
│ │ ├── file.ts (GET/POST/DELETE /api/v1/files, /api/v1/stat, POST /api/v1/files/move, POST /api/v1/dirs)
|
|
592
|
+
│ │ ├── tree.ts (GET /api/v1/tree — lazy recursive listing)
|
|
593
|
+
│ │ ├── search.ts (GET /api/v1/files/search — filename glob)
|
|
594
|
+
│ │ ├── chat.ts (POST /api/v1/agent/chat)
|
|
595
|
+
│ │ ├── sessions.ts (GET/POST /api/v1/agent/sessions — within the single configured workspace)
|
|
596
|
+
│ │ ├── sessionChanges.ts (GET /api/v1/agent/sessions/:id/changes)
|
|
597
|
+
│ │ ├── catalog.ts (registered tool metadata)
|
|
598
|
+
│ │ ├── fsEvents.ts
|
|
599
|
+
│ │ ├── health.ts
|
|
600
|
+
│ │ ├── models.ts
|
|
601
|
+
│ │ ├── readyStatus.ts
|
|
602
|
+
│ │ └── systemPrompt.ts
|
|
603
|
+
│ │ /* NOTE: no /api/v1/git/* in v1 — no UI consumer yet; agent runs git via bash */
|
|
604
|
+
│ │ /* NOTE: no /api/v1/workspaces — multi-workspace is cloud-package territory */
|
|
605
|
+
│ └── config/
|
|
606
|
+
│ ├── env.ts
|
|
607
|
+
│ ├── loadEnv.ts
|
|
608
|
+
│ └── workspaceRoot.ts
|
|
609
|
+
└── front/
|
|
610
|
+
├── index.ts
|
|
611
|
+
├── ChatPanel.tsx (default chat — ready to mount; full-height, includes session toolbar + composer)
|
|
612
|
+
├── SessionToolbar.tsx (lightweight current-session + new-chat + dropdown — adapted from old PiSessionToolbar)
|
|
613
|
+
├── primitives/ (all reusable UI pieces; shadcn-style, we own the source)
|
|
614
|
+
│ ├── Message.tsx (adapted from ai-elements)
|
|
615
|
+
│ ├── MessageGroup.tsx
|
|
616
|
+
│ ├── Composer.tsx (adapted from ai-elements PromptInput + inline model/thinking controls)
|
|
617
|
+
│ ├── ModelPicker.tsx (small inline dropdown for model selection, used inside Composer)
|
|
618
|
+
│ ├── ThinkingToggle.tsx (off/low/med/high toggle, used inside Composer)
|
|
619
|
+
│ ├── Tool.tsx (adapted from ai-elements Tool — generic tool-call renderer)
|
|
620
|
+
│ ├── Terminal.tsx (adapted from ai-elements Terminal — bash output)
|
|
621
|
+
│ ├── CodeBlock.tsx (adapted from ai-elements)
|
|
622
|
+
│ └── Reasoning.tsx (adapted from ai-elements)
|
|
623
|
+
├── hooks/
|
|
624
|
+
│ ├── useAgentChat.ts (wraps @ai-sdk/react useChat; accepts { model, thinkingLevel } options)
|
|
625
|
+
│ └── useSessions.ts (list + create + switch + delete for current workspace; rename deferred)
|
|
626
|
+
└── theme.css
|
|
627
|
+
/* NOTE: rename dialog, full SessionList, useRegisterTool hook all deferred to workspace package / later */
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
---
|
|
631
|
+
|
|
632
|
+
## Key decisions (locked)
|
|
633
|
+
|
|
634
|
+
| # | Area | Choice | Rationale |
|
|
635
|
+
|---|---|---|---|
|
|
636
|
+
| 1 | Standalone shape | **CLI — ships as `bin` entry (`npx @boring/agent`)**. Local backend + browser, zero setup, default `direct` mode, workspace = cwd. | Primary product deliverable, same shape as Claude Code. Directly runnable by end users. |
|
|
637
|
+
| 2 | Chat UI | Vercel `ai-elements` + `@ai-sdk/react useChat` | Prebuilt, shadcn-style (we own the copies), restylable via CSS vars. |
|
|
638
|
+
| 3 | Wire protocol | AI SDK UIMessage stream end-to-end | One format on the wire; harness adapts on the server. |
|
|
639
|
+
| 4 | v1 harness | `@mariozechner/pi-coding-agent` | Pi's factory tools + `XxxOperations` adapters per mode. Our backends plug in via Operations; pi owns tool definitions, schemas, and prompt snippets. See `pi-tools-migration.md` (epic uhwx). |
|
|
640
|
+
| 5 | Harness interface | Generic, `placement: server \| browser` | Browser-agent future is a sibling harness, not a migration. |
|
|
641
|
+
| 6 | Catalog | Standard tools via pi factories: `bash`, `read`, `write`, `edit`, `find`, `grep`, `ls`. Custom tools: `execute_isolated_code` (capability-gated) and `vercelGrepTool` (Vercel-only grep execution while preserving pi schema). | Principle 1 keeps pi names/schemas/prompt snippets intact; Principle 2 routes backend behavior through Operations or spawn hooks; Principle 3 keeps custom AgentTools exceptional. |
|
|
642
|
+
| 7a | Workspace (local) | `NodeWorkspace` (Node fs + path-boundary enforcement) | Port `validatePath` / `assertRealPathWithinWorkspace` from existing boring-ui. |
|
|
643
|
+
| 7b | Workspace (remote) | `VercelSandboxWorkspace` — delegates fs ops to `sandbox.fs.*` / `sandbox.writeFiles` | Runs backend on any PaaS; files live in Firecracker VM; multi-tenant-safe. |
|
|
644
|
+
| 7c | Sandbox (local) | `BwrapSandbox` (`capabilities: ['exec']`) pairs with `NodeWorkspace` | Matches current boring-ui. Bash runs in bwrap, sees host workspace dir. |
|
|
645
|
+
| 7d | Sandbox (remote) | `VercelSandboxExec` (`capabilities: ['exec']`) pairs with `VercelSandboxWorkspace` | Bash runs in the SAME VM where files live. Coherent view, zero sync. |
|
|
646
|
+
| 7e | Pairing invariant | Workspace + Sandbox MUST target the same underlying execution context | Enforced at adapter construction. `local` mode uses shared host fs; `vercel-sandbox` mode shares a single `Sandbox` handle between both adapters. No mixed pairings. |
|
|
647
|
+
| 7f | Mode selection | `[runtime].mode = "direct" | "local" | "vercel-sandbox"` in boring.app.toml (or env override) | One knob picks the adapter pair. Everything else config-independent. |
|
|
648
|
+
| 8 | Plugins | Pi plugins coexist via pi's extension system in `direct`/`local` only; remote mode skips extension auto-load | Plugin tools run in the backend Node process, so we keep plugin loading out of `vercel-sandbox` mode. |
|
|
649
|
+
| 9 | Sessions | `SessionStore` interface + `PiSessionStore` (JSONL) in v1 | Pi owns writes via its loop; platform-agnostic read+lifecycle interface swaps to SQLite / IndexedDB later. |
|
|
650
|
+
| 10 | API key | `ANTHROPIC_API_KEY` env var only. `VERCEL_OIDC_TOKEN` required in `vercel-sandbox` mode. | 12-factor; Vercel auth matches @vercel/sandbox conventions. |
|
|
651
|
+
| 11 | Workspace scope | **Single workspace per agent instance.** `workspaceId` is a configured constant (env var or `boring.app.toml`). Local mode: `${workspaceRoot}/${workspaceId}`. Remote mode: a dedicated Vercel Sandbox. No runtime workspace CRUD, no switcher UI — that belongs to `@boring/cloud`. | Matches boring-macro's current pattern (one tenant = one Fly machine = one workspace). Simpler agent surface; complexity deferred to cloud. |
|
|
652
|
+
| 11b | Session surface | **Lightweight `<SessionToolbar />` (current session + dropdown + new-chat button) + headless `useSessions()` hook.** list + create + switch + **delete** in v1. Rename deferred. | Adapt from old `PiSessionToolbar.jsx`. Delete added to align with workspace plan's SessionList contract (~20 LOC). |
|
|
653
|
+
| 11c | Standalone app | **First-class CLI product.** `packages/agent/bin/boring-agent` (~200 LOC) launches a local Fastify backend + serves the built frontend + opens browser. Ships via `bin` in package.json. | `npx @boring/agent` is the primary consumer-facing entrypoint. Same product shape as Claude Code. Same codebase also boots via `pnpm --filter @boring/agent dev` for package development. |
|
|
654
|
+
| 11d | Model + thinking level UI | **Inline in the `<Composer />`** (chat input box) — small ModelPicker dropdown + ThinkingToggle. NOT in the session toolbar. | Model and thinking are message-level concerns, so they live with the compose flow. |
|
|
655
|
+
| 11e | Dev-friendly `direct` mode | Third mode alongside local + vercel-sandbox: `NodeWorkspace` + `DirectSandbox` (no isolation, `child_process.exec` with `cwd`). For macOS dev, quick tests, self-host without bwrap. | **Documented warning**: `direct` has no sandbox — agent can do anything the backend process can. Not for untrusted agents. |
|
|
656
|
+
| 11f | Settings | **env vars only** (`ANTHROPIC_API_KEY`, `BORING_AGENT_MODE`, etc.). No `/api/settings` route, no runtime prefs file. | Vault / consumer wires production secrets. Workspace package can add a settings UI on top of its own storage if needed. |
|
|
657
|
+
| 11g | AI SDK harness | **NOT shipped in v1.** pi-coding-agent only. ai-sdk harness is a design seam (`AgentHarness` interface accommodates it) but not implemented. | boring-macro's current ai-sdk usage will require migration to pi-coding-agent when it adopts v2 — deferred; v1 scope does not include parallel ai-sdk support. |
|
|
658
|
+
| 12 | Backend stack | Fastify + Node ESM | Matches existing boring-ui; pi-coding-agent is Node-native. |
|
|
659
|
+
| 13 | Styling contract | CSS custom properties (`--boring-agent-*`) + render-prop escape hatches | Same JSX, workspace sets vars at root scope. |
|
|
660
|
+
| 14 | UI export pattern | **Default component + reusable primitives** for every user-facing piece | Standalone mounts the defaults; workspace composes primitives into layout-native UIs. Same pattern for chat, sessions, future widgets. |
|
|
661
|
+
| 15 | Export surface | **Defaults:** `ChatPanel`, `SessionToolbar`. **Primitives:** `Message`, `MessageGroup`, `Composer`, `ModelPicker`, `ThinkingToggle`, `Tool`, `Terminal`, `CodeBlock`, `Reasoning`, `NewChatButton`. **Hooks:** `useAgentChat`, `useSessions` (list + create + switch + delete; rename deferred). **Plus** `theme.css`. | Single canonical name `ChatPanel` for the chat component; workspace plan aligned. Rename dialog, full SessionList, `useRegisterTool` hook, DiffView all deferred to workspace package / later. |
|
|
662
|
+
| 16 | Import convention | **Top-level barrel `@boring/agent` re-exports frontend + shared types** (browser-safe; no Node deps leak). **`@boring/agent/server` required for Node-only** (Fastify routes, harness impl). **`@boring/agent/shared` available** for type-only imports. | Workspace + apps import from `@boring/agent` for normal use; server wiring uses `@boring/agent/server`. Matches workspace plan's `import X from '@boring/agent'` examples. Enforced via `package.json` `exports` conditions. |
|
|
663
|
+
|
|
664
|
+
---
|
|
665
|
+
|
|
666
|
+
## Stream adapter (pi event → UIMessage)
|
|
667
|
+
|
|
668
|
+
The adapter in `src/server/harness/pi-coding-agent/stream-adapter.ts` converts pi's internal event stream into AI SDK UIMessage chunks consumed by `useChat`:
|
|
669
|
+
|
|
670
|
+
| pi event | AI SDK UIMessage part |
|
|
671
|
+
|---|---|
|
|
672
|
+
| `text.delta` | `text-delta` |
|
|
673
|
+
| `thinking.delta` | `reasoning-delta` |
|
|
674
|
+
| `tool.call` (start) | `tool-input-start` |
|
|
675
|
+
| `tool.call` (args streaming) | `tool-input-delta` |
|
|
676
|
+
| `tool.call` (complete) | `tool-input-available` |
|
|
677
|
+
| `tool.result` | `tool-output-available` |
|
|
678
|
+
| *heartbeat (synthetic, every 2s during tool exec)* | `data-status` (AI SDK custom data part: `{ toolCallId, elapsedMs }`) |
|
|
679
|
+
| `error` | `error` |
|
|
680
|
+
| `done` | `finish` + usage |
|
|
681
|
+
|
|
682
|
+
Heartbeats carry `{ toolCallId, elapsedMs }`. The `<Tool />` renderer surfaces elapsed time so the user sees "bash · 14s running" instead of an opaque freeze during long commands (npm install, tests, etc.). Emitted from a 2-second timer in the adapter while a tool is active; cleared on `tool.result`.
|
|
683
|
+
|
|
684
|
+
Route implementation (nao pattern):
|
|
685
|
+
|
|
686
|
+
```ts
|
|
687
|
+
app.post('/api/v1/agent/chat', async (req, reply) => {
|
|
688
|
+
const stream = createUIMessageStream<UIMessage>({
|
|
689
|
+
execute({ writer }) {
|
|
690
|
+
writeFromPi(harness.sendMessage(req.body, ctx), writer)
|
|
691
|
+
},
|
|
692
|
+
})
|
|
693
|
+
reply.headers({ 'X-Accel-Buffering': 'no' })
|
|
694
|
+
return reply.send(toUIMessageStreamResponse({ stream }))
|
|
695
|
+
})
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
---
|
|
699
|
+
|
|
700
|
+
## Session management (Layer 2 integration service)
|
|
701
|
+
|
|
702
|
+
Sessions are an **integration service** layered on top of Layer-1 runtime. Not a core runtime abstraction; evolves independently. They're read-only from the frontend's perspective — **the harness does the writing as a side effect of its loop; the `SessionStore` handles lifecycle and reads.**
|
|
703
|
+
|
|
704
|
+
### Interface (`src/shared/session.ts`)
|
|
705
|
+
|
|
706
|
+
```ts
|
|
707
|
+
export interface SessionSummary {
|
|
708
|
+
id: string
|
|
709
|
+
title: string
|
|
710
|
+
createdAt: string
|
|
711
|
+
lastModified: string
|
|
712
|
+
messageCount: number
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export interface SessionDetail extends SessionSummary {
|
|
716
|
+
messages: UIMessage[] // AI SDK canonical shape — same as on the wire
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export interface SessionStore {
|
|
720
|
+
list(ctx?: SessionCtx): Promise<SessionSummary[]>
|
|
721
|
+
create(ctx?: SessionCtx): Promise<SessionSummary>
|
|
722
|
+
load(id: string, ctx?: SessionCtx): Promise<SessionDetail>
|
|
723
|
+
delete(id: string, ctx?: SessionCtx): Promise<void>
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export interface SessionCtx {
|
|
727
|
+
userId?: string // unused in v1; workspace layer adds later
|
|
728
|
+
}
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
**Deliberately no `append()`.** The harness writes messages as they stream; appends don't go through `SessionStore`. This keeps the interface platform-agnostic (no "write a pi event" leak) and avoids concurrency footguns.
|
|
732
|
+
|
|
733
|
+
### v1 implementation — `PiSessionStore`
|
|
734
|
+
|
|
735
|
+
`src/server/harness/pi-coding-agent/sessions.ts` wraps pi's `SessionManager`:
|
|
736
|
+
|
|
737
|
+
- **Writes** happen inside pi's loop → JSONL files under `${workdir}/.pi/sessions/<id>.jsonl`. We do nothing.
|
|
738
|
+
- **Reads** (`list` / `load`) stream the JSONL, convert pi `SessionEntry` events → AI SDK `UIMessage` parts on the fly. This is a one-way transform using the same mapping as the live stream adapter.
|
|
739
|
+
- **Lifecycle** (`create` / `delete`) uses pi's SessionManager API where available; `delete` also removes the corresponding JSONL file on disk.
|
|
740
|
+
|
|
741
|
+
Canonical persisted shape the API returns is always `UIMessage[]`. Pi's JSONL is an implementation detail behind the interface.
|
|
742
|
+
|
|
743
|
+
### Stream resumption (v1, transport-owned)
|
|
744
|
+
|
|
745
|
+
Resume is **owned by the HTTP/stream layer, not the harness**. The server wraps the harness's event stream in a per-turn ring buffer keyed on `(sessionId, turnId)` with monotonic chunk indices. A disconnected client reconnects via `GET /api/v1/agent/chat/:sessionId/:turnId?cursor=<n>` and resumes from chunk `n+1`. If the turn already completed, the endpoint replays from `PiSessionStore` and closes. `useChat` wired with `experimental_resume: true`. ~60 LOC in routes + stream adapter. Harness adapters stay reconnect-unaware — any future harness gets resume for free. Critical UX — users will refresh mid-response and expect continuity.
|
|
746
|
+
|
|
747
|
+
### What v1 defers
|
|
748
|
+
|
|
749
|
+
- **Multi-user scoping** — `SessionCtx.userId` is in the signature but ignored. Workspace will pass it later.
|
|
750
|
+
- **LLM-generated titles** — v1 defaults to `"New chat {timestamp}"`. Background title generation lands in M3.
|
|
751
|
+
- **Rename session flow** — deferred to the workspace package (delete ships in v1; only rename is deferred).
|
|
752
|
+
|
|
753
|
+
### Future `SessionStore` implementations (each is one adapter)
|
|
754
|
+
|
|
755
|
+
| Implementation | When | Notes |
|
|
756
|
+
|---|---|---|
|
|
757
|
+
| `SqliteSessionStore` | Self-hosters wanting queryable history | Drizzle + local SQLite file; may replace pi's SessionManager entirely |
|
|
758
|
+
| `IndexedDBSessionStore` | Browser-agent mode | Pairs with pi-agent-core or ToolLoopAgent harness |
|
|
759
|
+
| `RemoteSessionStore` | Cloud-backed workspace deployments | Thin HTTP client against a central session service |
|
|
760
|
+
| `InMemorySessionStore` | Tests, ephemeral demos | Trivial; already implied by pi's inMemory manager |
|
|
761
|
+
|
|
762
|
+
### HTTP surface
|
|
763
|
+
|
|
764
|
+
Routes are defined in the canonical [HTTP API reference](#http-api-reference-canonical) — rows `/api/v1/agent/sessions*` and `/api/v1/agent/chat*`. All delegate to the configured `SessionStore`. `POST /api/v1/agent/chat` takes a `sessionId` in the body; the server resolves the workspace + session, constructs a `Workspace` + `Sandbox` via `RuntimeModeAdapter`, and invokes the harness. Harness appends messages into that session as it runs (via pi's loop).
|
|
765
|
+
|
|
766
|
+
**Invariants:** `workspaceId` is resolved from process config in v1 — never client-supplied. Workspace CRUD (list/create/delete) belongs to `@boring/cloud`, not agent v1. Session rename is deferred to the workspace package; delete ships in v1.
|
|
767
|
+
|
|
768
|
+
## UI bridge — workspace ↔ agent state + commands
|
|
769
|
+
|
|
770
|
+
The bridge carries two orthogonal flows between workspace (frontend) and agent (backend). **Distinct from the chat stream**, which is for agent message display only.
|
|
771
|
+
|
|
772
|
+
### The three flows
|
|
773
|
+
|
|
774
|
+
| Direction | What | Mechanism |
|
|
775
|
+
|---|---|---|
|
|
776
|
+
| Agent → UI display | Agent message content (text, tool calls, reasoning) | AI SDK UIMessage stream via `useChat` (existing) |
|
|
777
|
+
| UI → Agent state | "What does the user currently see?" | `PUT /api/v1/ui/state` → agent reads via `get_ui_state` tool |
|
|
778
|
+
| Agent → UI commands | "Open file X, open panel Y, show notification" | `POST /api/v1/ui/commands` → workspace subscribes via SSE on `/api/v1/ui/commands/next` |
|
|
779
|
+
|
|
780
|
+
### Interface (`src/shared/ui-bridge.ts`)
|
|
781
|
+
|
|
782
|
+
```ts
|
|
783
|
+
export interface UiBridge {
|
|
784
|
+
getState(): Promise<UiState | null>
|
|
785
|
+
setState(state: UiState): Promise<void>
|
|
786
|
+
postCommand(cmd: UiCommand): Promise<CommandResult>
|
|
787
|
+
subscribeCommands(handler: (cmd: UiCommand & { seq: number }) => void): () => void
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
export type UiState = Record<string, unknown> // workspace-defined shape
|
|
791
|
+
|
|
792
|
+
export type UiCommand =
|
|
793
|
+
| { kind: 'openFile'; params: { path: string; mode?: 'view' | 'edit' | 'diff' } }
|
|
794
|
+
| { kind: 'openPanel'; params: { id: string; component: string; params?: Record<string, unknown> } }
|
|
795
|
+
| { kind: 'closePanel'; params: { id: string } }
|
|
796
|
+
| { kind: 'showNotification'; params: { msg: string; level?: 'info' | 'warn' | 'error' } }
|
|
797
|
+
| { kind: 'navigateToLine'; params: { file: string; line: number } }
|
|
798
|
+
| { kind: 'expandToFile'; params: { path: string } }
|
|
799
|
+
| { kind: string; params: Record<string, unknown> } // extensible
|
|
800
|
+
|
|
801
|
+
export interface CommandResult {
|
|
802
|
+
seq: number // monotonic sequence for this workspace
|
|
803
|
+
status: 'ok' | 'error' // simple: validated+queued or rejected
|
|
804
|
+
error?: { code: string; message: string }
|
|
805
|
+
}
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
**CommandResult matches the workspace plan's bridge contract.** Agent's `exec_ui` tool awaits the result — it can see whether the agent server accepted the command. The POST returns `{seq, status: 'ok'}` on success or `{seq, status: 'error', error: {code, message}}` on validation failure. No ack loop — the agent treats `ok` as "command will be delivered via SSE." Workspace execution is fire-and-forget from the agent's perspective; workspace publishes resulting state via `PUT /api/v1/ui/state`.
|
|
809
|
+
|
|
810
|
+
### v1 implementation
|
|
811
|
+
|
|
812
|
+
- In-memory state store keyed by `workspaceId` (single entry in v1).
|
|
813
|
+
- Command queue per workspaceId; **delivered via SSE** on `GET /api/v1/ui/commands/next` (same transport we use for chat stream resumption — consistent, ~20 LOC). Commands dropped from queue after delivery.
|
|
814
|
+
- No persistence across backend restarts (matches accepted-risk posture for v1).
|
|
815
|
+
- No WebSocket — SSE covers the push case with less lifecycle complexity, works through any HTTP proxy/CDN, and auto-reconnects in the browser.
|
|
816
|
+
|
|
817
|
+
### Agent tools enabled by the bridge
|
|
818
|
+
|
|
819
|
+
Just two tools — kept minimal on purpose:
|
|
820
|
+
|
|
821
|
+
| Tool | Does | Bridge call |
|
|
822
|
+
|---|---|---|
|
|
823
|
+
| `get_ui_state` | Returns current UI state as JSON | `bridge.getState()` |
|
|
824
|
+
| `exec_ui({ kind, params })` | Generic dispatcher for UI commands | `bridge.postCommand({ kind, params })` |
|
|
825
|
+
|
|
826
|
+
The `exec_ui` tool has the command kinds enumerated in its `parameters.properties.kind.enum`:
|
|
827
|
+
```
|
|
828
|
+
['openFile', 'openPanel', 'closePanel', 'showNotification', 'navigateToLine', 'expandToFile']
|
|
829
|
+
```
|
|
830
|
+
Extending = adding a new enum value + a switch case in workspace's dispatcher. No new agent-side code.
|
|
831
|
+
|
|
832
|
+
Total agent-visible tools in v1: **6** (`bash, read, write, edit, get_ui_state, exec_ui`) plus the conditional `execute_isolated_code` if a VM sandbox is wired.
|
|
833
|
+
|
|
834
|
+
### Workspace side
|
|
835
|
+
|
|
836
|
+
- On layout changes (Zustand subscription) → debounced PUT to `/api/v1/ui/state`
|
|
837
|
+
- On mount → subscribe to `/api/v1/ui/commands/next` via SSE
|
|
838
|
+
- On command received → dispatch via Zustand store (opens file in editor, mounts panel, shows toast)
|
|
839
|
+
|
|
840
|
+
### Command transport — single dispatch channel
|
|
841
|
+
|
|
842
|
+
`UiBridge` is the **single command transport**. One queue, one ordering model, one ack model.
|
|
843
|
+
|
|
844
|
+
Chat-stream `data-ui-command` parts are **display-only derivatives** of bridge-dispatched commands — never their own dispatch source. Dispatch flows only through `UiBridge.postCommand` → SSE → workspace Zustand store.
|
|
845
|
+
|
|
846
|
+
Concretely, the `exec_ui` handler: (1) calls `bridge.postCommand(cmd)` and awaits `{ seq, status }`; (2) emits a `data-ui-command` part into the chat stream carrying that same `seq`. The frontend:
|
|
847
|
+
- On `data-ui-command` part: render a compact message card only (no dispatch). The `seq` ties the card to the already-delivered command.
|
|
848
|
+
- On SSE command: dispatch via Zustand (actually opens file, etc.).
|
|
849
|
+
|
|
850
|
+
Result: one queue to reason about, in-turn visual context preserved.
|
|
851
|
+
|
|
852
|
+
---
|
|
853
|
+
|
|
854
|
+
## Environment provisioning
|
|
855
|
+
|
|
856
|
+
One mental model, two axes: **what gets provisioned** (files vs. libraries) × **when** (pre-baked once vs. on-demand per sandbox). Neither layer does the other's job — `copyTemplate` never runs `pip install`; library tiers never seed project files.
|
|
857
|
+
|
|
858
|
+
### Files — `copyTemplate` (workspace-owned)
|
|
859
|
+
|
|
860
|
+
Fresh workspaces can optionally seed file contents from a template directory. **Deliberately minimal for v1 — no abstraction, no composition, no installers.** Matches the old boring-ui's "mkdir and done" default; adds *just* a template-copy escape hatch.
|
|
861
|
+
|
|
862
|
+
```ts
|
|
863
|
+
// src/server/workspace/provision.ts
|
|
864
|
+
export async function copyTemplate(
|
|
865
|
+
templatePath: string | undefined,
|
|
866
|
+
workspaceRoot: string,
|
|
867
|
+
): Promise<void> {
|
|
868
|
+
if (!templatePath) return
|
|
869
|
+
// recursive copy from templatePath into workspaceRoot
|
|
870
|
+
// errors abort workspace creation
|
|
871
|
+
}
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
Called **synchronously** during workspace creation, after `mkdir(workspaceRoot)`. For expected template sizes (skill files, README, a config or two) copy is milliseconds — workspace POST stays fast. No async state machine, no background tasks.
|
|
875
|
+
|
|
876
|
+
Resolution order (first wins):
|
|
877
|
+
1. `createAgentApp({ templatePath })` — primary API for embedders.
|
|
878
|
+
2. `BORING_AGENT_TEMPLATE_PATH` env — fallback for standalone use.
|
|
879
|
+
3. Unset → no seeding.
|
|
880
|
+
|
|
881
|
+
Idempotent: a marker file `${root}/.boring-agent/provisioned` is written after success; subsequent `copyTemplate` calls no-op. To re-seed: delete the workspace and recreate.
|
|
882
|
+
|
|
883
|
+
### Libraries — two-tier, per-mode (sandbox-owned)
|
|
884
|
+
|
|
885
|
+
Each mode supports pre-installed Python/system packages via a symmetric two-tier design. Tier 1 is pre-baked and fast. Tier 2 is agent-driven and ephemeral.
|
|
886
|
+
|
|
887
|
+
**Local mode (bwrap):**
|
|
888
|
+
|
|
889
|
+
| Tier | Where | How |
|
|
890
|
+
|---|---|---|
|
|
891
|
+
| 1 — shared, pre-installed | `/opt/venv` on the host container, bwrap bind-mounts read-only | Dockerfile builds it: `python3 -m venv /opt/venv && /opt/venv/bin/pip install …`. OR sandbox builds it on first boot from `[sandbox].python_packages` config, cached at `/var/cache/boring-agent/venvs/<hash>`. |
|
|
892
|
+
| 2 — per-workspace overlay | `${workspace}/.venv` with `--system-site-packages` | Created lazily on first agent `pip install`. Inherits Tier 1. Persists across sessions. |
|
|
893
|
+
|
|
894
|
+
**Remote mode (Vercel Sandbox):**
|
|
895
|
+
|
|
896
|
+
| Tier | Where | How |
|
|
897
|
+
|---|---|---|
|
|
898
|
+
| 1 — snapshot-based base | Vercel's snapshot storage, referenced by `snapshotId` | **One-time bake:** create seed sandbox → `pip install` → `sandbox.snapshot()` → store `snapshotId`. Every `Sandbox.create({ source: { type: 'snapshot', snapshotId } })` boots in ms with deps ready. |
|
|
899
|
+
| 2 — in-sandbox overlay | The sandbox's own fs (at `/vercel/sandbox`) | Agent runs `pip install <extra>` via bash. Persists for the sandbox's lifetime (TTL + hibernation = hours to days). Lost on full VM expiry. |
|
|
900
|
+
|
|
901
|
+
Vercel Sandbox ships `python3.13` as a first-class runtime. Agent calls `python3 script.py` via the `bash` tool; `VercelSandboxExec.exec(cmd)` delegates to `sandbox.runCommand('sh', ['-c', cmd])` running inside the VM. Same tool, different sandbox — agent prompt doesn't change.
|
|
902
|
+
|
|
903
|
+
### Config shape (one schema, mode-interpreted)
|
|
904
|
+
|
|
905
|
+
```toml
|
|
906
|
+
[runtime]
|
|
907
|
+
mode = "vercel-sandbox" # or "local"
|
|
908
|
+
|
|
909
|
+
[workspace]
|
|
910
|
+
template_path = "./templates/default" # optional; copied into workspace on create
|
|
911
|
+
|
|
912
|
+
[sandbox]
|
|
913
|
+
python_packages = ["pandas", "numpy", "scipy"]
|
|
914
|
+
system_packages = ["ripgrep", "jq"] # local: apt-get; remote: dnf during bake
|
|
915
|
+
|
|
916
|
+
# Remote-mode-specific:
|
|
917
|
+
[sandbox.vercel]
|
|
918
|
+
runtime = "python3.13" # one of: node24, node22, python3.13
|
|
919
|
+
snapshot_id = "snap_abc123..." # if set, use directly; overrides python_packages
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
If `snapshot_id` is set, skip the bake. Otherwise, v1 bootstrap creates the seed sandbox + installs + snapshots on first backend startup and caches the resulting snapshotId — done once per unique `python_packages` hash.
|
|
923
|
+
|
|
924
|
+
### v1 scope
|
|
925
|
+
|
|
926
|
+
- **M1 (files):** `copyTemplate` helper. ~30 LOC. Workspace-create synchronous.
|
|
927
|
+
- **M2 (remote libs):** snapshot-bake flow in `src/server/sandbox/vercel-sandbox/bake.ts`: takes `python_packages` + `system_packages` from config, creates seed sandbox, installs, snapshots, caches snapshotId. ~150 LOC. One-time per config hash.
|
|
928
|
+
- **M3 (local libs):** shared-venv + overlay flow for bwrap. `python_packages` → `uv pip install` into `/var/cache/boring-agent/venvs/<hash>`, bwrap binds it. ~80 LOC.
|
|
929
|
+
|
|
930
|
+
If neither `template_path`, `python_packages`, nor `snapshot_id` is configured, workspace starts empty; agent installs on demand via the `bash` tool (matches current boring-macro).
|
|
931
|
+
|
|
932
|
+
### Upgrade paths (explicitly deferred, not in v1)
|
|
933
|
+
|
|
934
|
+
When needs grow beyond this, the escape hatches are:
|
|
935
|
+
|
|
936
|
+
| Need | Layer / path | Trigger |
|
|
937
|
+
|---|---|---|
|
|
938
|
+
| Python `requirements.txt` install (project-specific) | Future `execute_isolated_code` tool (sandbox capability), or async `WorkspaceProvisioner` | First user with real Python workload |
|
|
939
|
+
| Git-clone seeding | `WorkspaceProvisioner` interface (`GitCloneProvisioner`) | When we port GitHub-connect flow |
|
|
940
|
+
| Skill library from remote source | `WorkspaceProvisioner` | When skills become shareable |
|
|
941
|
+
| Multi-step composed provisioning | `WorkspaceProvisioner` (`composedProvisioner`) | When we have >1 provisioner |
|
|
942
|
+
| Content-hash re-provisioning | `WorkspaceProvisioner` | Template-change UX need |
|
|
943
|
+
| Per-call ephemeral pooled VM deps (nao model) | `Sandbox` adapter declaring `'isolated-code'` capability | Multi-tenant data-analysis workloads |
|
|
944
|
+
|
|
945
|
+
Until then: one function (`copyTemplate`), one env var, a few config keys.
|
|
946
|
+
|
|
947
|
+
## Plugin compat
|
|
948
|
+
|
|
949
|
+
`PiCodingAgentHarness` passes only our `tools: [...]` to `createAgentSession`; pi's built-ins are skipped. Extensions loaded via pi's discovery (`~/.pi/agent/extensions/`, `.pi/extensions/`, npm, git) register their own tools through `pi.registerTool()`. Those end up in the catalog alongside ours.
|
|
950
|
+
|
|
951
|
+
Plugin tools' `execute()` bodies run in the Node host process (they use `node:fs`, `pi.exec()`, etc.) — **they bypass our Sandbox by design.** That's accepted: plugins are author-written code the user chose to install. Our sandbox knob only governs our default tools.
|
|
952
|
+
|
|
953
|
+
Open research (resolves in M4): precedence when a plugin registers a same-named tool as our default. We'll test and document, then either let last-in-wins or namespace ours (`fs.bash` vs `bash`).
|
|
954
|
+
|
|
955
|
+
---
|
|
956
|
+
|
|
957
|
+
## UI export pattern — "default + primitives"
|
|
958
|
+
|
|
959
|
+
Every user-facing feature follows the same shape:
|
|
960
|
+
|
|
961
|
+
1. **Default component** — a ready-to-mount implementation. `<ChatPanel />`, `<SessionToolbar />`. Standalone app uses these directly. Workspace *can* too.
|
|
962
|
+
2. **Primitives** — the smaller pieces the default is built from. Exported for consumers who want to compose their own UI. Examples: `<Message />`, `<MessageGroup />`, `<Composer />`, `<ModelPicker />`, `<ThinkingToggle />`, `<Tool />`, `<Terminal />`, `<CodeBlock />`, `<NewChatButton />`.
|
|
963
|
+
3. **Headless hook** — purely state + mutations, no JSX. `useAgentChat`, `useSessions`. For consumers who want zero imposed UI.
|
|
964
|
+
|
|
965
|
+
Consumers pick their level:
|
|
966
|
+
|
|
967
|
+
```tsx
|
|
968
|
+
// Level 1 — defaults (standalone app, quick embeds)
|
|
969
|
+
<ChatPanel />
|
|
970
|
+
|
|
971
|
+
// Level 2 — primitives (workspace composes its own chat shell)
|
|
972
|
+
const { sessions, activeSession, create, switch: switchTo, delete: remove } = useSessions()
|
|
973
|
+
<DockPanel title="Chats">
|
|
974
|
+
<NewChatButton onClick={create} />
|
|
975
|
+
{sessions.map(s => <button key={s.id} onClick={() => switchTo(s.id)}>{s.title}</button>)}
|
|
976
|
+
</DockPanel>
|
|
977
|
+
<ChatPanel />
|
|
978
|
+
|
|
979
|
+
// Level 3 — headless (custom everything)
|
|
980
|
+
const { messages, sendMessage, stop } = useAgentChat({ sessionId })
|
|
981
|
+
return <MyBespokeChat messages={messages} onSend={sendMessage} />
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
Feature widgets we add later (artifact viewer, diff pane, tool-result drawer) follow the same three-level contract. Rename dialog, full session-sidebar primitives (SessionRow, RenameDialog), and `useRegisterTool` hook are workspace-package territory — not shipped in agent v1.
|
|
985
|
+
|
|
986
|
+
This is the contract for *all* UI the package ships. When we add new features (artifact viewer, diff pane, tool-result drawer), they each come in these three flavors.
|
|
987
|
+
|
|
988
|
+
## Slash commands
|
|
989
|
+
|
|
990
|
+
Composer recognizes `/command [args]` as non-LLM input. Built-ins in v1:
|
|
991
|
+
|
|
992
|
+
| Command | Effect |
|
|
993
|
+
|---|---|
|
|
994
|
+
| `/clear` | Clear visible message list (doesn't delete the session) |
|
|
995
|
+
| `/reset` | Create a new session (same workspace) and switch to it |
|
|
996
|
+
| `/model <id>` | Switch active model for this session |
|
|
997
|
+
| `/help` | Show available commands |
|
|
998
|
+
| `/cost` | Show tokens used + estimated cost for this session |
|
|
999
|
+
|
|
1000
|
+
Pi extensions that register commands via `pi.registerCommand()` appear in `/help` alongside built-ins. Handler lives in `useAgentChat.handleSlashCommand(text)` and runs locally without hitting the LLM. Workspace consumers can call `useAgentChat.registerCommand({ name, handler, description })` to add their own. ~80 LOC total.
|
|
1001
|
+
|
|
1002
|
+
## Styling contract
|
|
1003
|
+
|
|
1004
|
+
`theme.css` declares defaults for every token:
|
|
1005
|
+
|
|
1006
|
+
```css
|
|
1007
|
+
:root {
|
|
1008
|
+
--boring-agent-bg: #0b0b0e;
|
|
1009
|
+
--boring-agent-fg: #eaeaea;
|
|
1010
|
+
--boring-agent-accent: #7c5cff;
|
|
1011
|
+
--boring-agent-radius: 12px;
|
|
1012
|
+
--boring-agent-font-body: system-ui, sans-serif;
|
|
1013
|
+
--boring-agent-font-mono: ui-monospace, monospace;
|
|
1014
|
+
/* … */
|
|
1015
|
+
}
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
ai-elements-adapted primitives are authored with Tailwind classes that reference the vars via Tailwind's arbitrary-value syntax. Workspace sets the same vars at its root → chat recolors without JSX changes.
|
|
1019
|
+
|
|
1020
|
+
**Worked example — the var/Tailwind bridge:**
|
|
1021
|
+
|
|
1022
|
+
```tsx
|
|
1023
|
+
// src/front/primitives/Message.tsx — authored pattern
|
|
1024
|
+
<div className="rounded-[var(--boring-agent-radius)] bg-[var(--boring-agent-bg)] text-[var(--boring-agent-fg)]">
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
Tailwind's `bg-[var(--…)]` / `text-[var(--…)]` syntax reads the CSS var at render time. Setting the var anywhere in the cascade above the primitive re-colors it without class overrides. **Contract:** primitives always use `bg-[var(...)]`, never `bg-slate-900`. No Tailwind theme-plugin is shipped. (Future lint rule: no hard-coded color classes in `primitives/`.)
|
|
1028
|
+
|
|
1029
|
+
Escape hatches on `<ChatPanel />`:
|
|
1030
|
+
- `toolRenderers={{ my_tool: MyRenderer }}` — override per-tool UI
|
|
1031
|
+
- `renderMessage`, `renderComposer` — replace blocks entirely
|
|
1032
|
+
|
|
1033
|
+
---
|
|
1034
|
+
|
|
1035
|
+
## Config / env
|
|
1036
|
+
|
|
1037
|
+
| Var | Required | Default | Purpose |
|
|
1038
|
+
|---|---|---|---|
|
|
1039
|
+
| `ANTHROPIC_API_KEY` | yes | — | LLM auth |
|
|
1040
|
+
| `BORING_AGENT_MODE` | no | `direct` (macOS/non-Linux) or `local` (Linux) | `direct` / `local` / `vercel-sandbox`. Overrides `[runtime].mode` in boring.app.toml. Auto-detects: direct on macOS, local on Linux with bwrap installed. |
|
|
1041
|
+
| `BORING_AGENT_WORKSPACE_ID` | yes | — | The single workspace this instance serves. Constant for the lifetime of the process. |
|
|
1042
|
+
| `BORING_AGENT_WORKSPACE_ROOT` | local mode | `/tmp/boring-agent/workspaces` | Base dir; the workspace lives at `${root}/${workspaceId}/`. |
|
|
1043
|
+
| `VERCEL_OIDC_TOKEN` | vercel-sandbox mode | — | Vercel auth (from `vercel env pull`). Or use `VERCEL_ACCESS_TOKEN` fallback. |
|
|
1044
|
+
| `VERCEL_TEAM_ID` | vercel-sandbox mode | — | Team/project scope for sandbox creation. |
|
|
1045
|
+
| `BORING_AGENT_TEMPLATE_PATH` | no | — | If set, copied into fresh workspaces (local mode) or packaged as tarball for `Sandbox.create({ source: { type: 'tarball' } })` (remote mode). |
|
|
1046
|
+
| `BORING_AGENT_PORT` | no | `8787` | Standalone Fastify port |
|
|
1047
|
+
| `BORING_AGENT_MODEL` | no | pi's default | Claude model override |
|
|
1048
|
+
| `BORING_AGENT_SANDBOX_TIMEOUT_MS` | no | `2700000` (45 min) | Vercel Sandbox max timeout. |
|
|
1049
|
+
| `BORING_AGENT_SNAPSHOT_KEEP` | no | `2` | How many recent Vercel snapshots to retain per workspace. |
|
|
1050
|
+
|
|
1051
|
+
---
|
|
1052
|
+
|
|
1053
|
+
## CLI product shape
|
|
1054
|
+
|
|
1055
|
+
The agent package ships as a CLI via `bin` in `package.json`. This is the primary end-user product.
|
|
1056
|
+
|
|
1057
|
+
### Usage
|
|
1058
|
+
|
|
1059
|
+
```bash
|
|
1060
|
+
# Zero-config: chat against cwd, direct mode
|
|
1061
|
+
npx @boring/agent
|
|
1062
|
+
|
|
1063
|
+
# Point at a specific folder
|
|
1064
|
+
npx @boring/agent /path/to/my-project
|
|
1065
|
+
|
|
1066
|
+
# With options
|
|
1067
|
+
npx @boring/agent --port 9000 --model claude-sonnet-4-5 --mode local
|
|
1068
|
+
|
|
1069
|
+
# First run prompts for ANTHROPIC_API_KEY; persisted to ~/.config/boring-agent/env
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
### What the CLI does on startup
|
|
1073
|
+
|
|
1074
|
+
1. **Resolve config** — flags > env > config file > defaults.
|
|
1075
|
+
2. **Auth check** — read `ANTHROPIC_API_KEY` from env; if missing, prompt once and persist to `~/.config/boring-agent/env`.
|
|
1076
|
+
3. **Gitignore hygiene** — if workspace has `.git/` and `.gitignore` doesn't list `.boring-agent/` and `.pi/`, append them with a comment. One-time silent write; skippable via `--no-gitignore`.
|
|
1077
|
+
4. **Mode auto-detect** — Linux+bwrap → `local`, else → `direct`. `vercel-sandbox` is opt-in via flag or env.
|
|
1078
|
+
5. **Workspace** — default root = cwd; workspaceId = stable hash of absolute path (so re-running in the same folder resumes sessions).
|
|
1079
|
+
6. **Pick a free port** — start Fastify on it (default `8787`, increment if taken).
|
|
1080
|
+
7. **Serve frontend** — pre-built SPA served from the same Fastify instance (no separate Vite dev server in CLI mode).
|
|
1081
|
+
8. **Open browser — conditionally.** Skip if `SSH_TTY`, `SSH_CONNECTION`, `CI`, or `--no-open` is set; also skip on Linux when `$DISPLAY` is empty. When skipped, print the URL prominently AND an SSH port-forward hint (`ssh -L <port>:localhost:<port> <host>`) for remote hosts.
|
|
1082
|
+
9. **Print endpoint + ctrl-c handler** — graceful shutdown (see below).
|
|
1083
|
+
|
|
1084
|
+
### Graceful shutdown (SIGINT / SIGTERM)
|
|
1085
|
+
|
|
1086
|
+
On Ctrl-C: stop pi stream (abort signal), snapshot the Vercel sandbox if in remote mode (save state), close Fastify, exit 0. 5-second max; falls through to hard exit if hangs.
|
|
1087
|
+
|
|
1088
|
+
### CLI flags
|
|
1089
|
+
|
|
1090
|
+
| Flag | Default | Purpose |
|
|
1091
|
+
|---|---|---|
|
|
1092
|
+
| `--port <n>` | `8787` (auto-increment if busy) | HTTP port |
|
|
1093
|
+
| `--mode <m>` | auto | `direct` / `local` / `vercel-sandbox` |
|
|
1094
|
+
| `--model <id>` | pi default | Claude model override |
|
|
1095
|
+
| `--no-open` | opens by default | Skip browser-open |
|
|
1096
|
+
| `--no-gitignore` | append by default | Skip gitignore hygiene |
|
|
1097
|
+
| `--workspace <path>` | cwd | Alternate way to set workspace folder |
|
|
1098
|
+
| `--config <file>` | `~/.config/boring-agent/config.toml` | Config file override |
|
|
1099
|
+
| `--logout` | — | Remove persisted API key at `~/.config/boring-agent/env` and exit |
|
|
1100
|
+
| `--reset-key` | — | Delete persisted key and re-prompt |
|
|
1101
|
+
|
|
1102
|
+
### Same code, two modes
|
|
1103
|
+
|
|
1104
|
+
- **CLI invocation** (`bin/boring-agent`): spawns production Fastify server with pre-built frontend assets bundled in the package.
|
|
1105
|
+
- **Package dev** (`pnpm --filter @boring/agent dev`): spawns Fastify + Vite dev server, hot-reload for frontend. Same entry, different flag (`--dev`).
|
|
1106
|
+
|
|
1107
|
+
### Product positioning
|
|
1108
|
+
|
|
1109
|
+
| Package | Ships as | User |
|
|
1110
|
+
|---|---|---|
|
|
1111
|
+
| `@boring/agent` | **CLI** (primary) + library | End users (`npx`), library consumers |
|
|
1112
|
+
| `@boring/workspace` | Library | App builders composing layouts |
|
|
1113
|
+
| `@boring/cloud` | SaaS / deploy tooling | Teams wanting multi-tenant or hosted |
|
|
1114
|
+
|
|
1115
|
+
The agent package alone is a legitimate standalone product — you can ship to users with "install Node, run `npx @boring/agent`, done."
|
|
1116
|
+
|
|
1117
|
+
---
|
|
1118
|
+
|
|
1119
|
+
## Runtime modes — local vs vercel-sandbox (both shipped in v1)
|
|
1120
|
+
|
|
1121
|
+
### `RuntimeModeAdapter` — one contract per mode
|
|
1122
|
+
|
|
1123
|
+
Each mode is a single module exporting a `RuntimeModeAdapter`. The adapter produces the full runtime bundle (workspace, sandbox, fileSearch, optional uiBridge pass-through) for that mode. Pairing invariant is enforced by construction — you can't build a mismatched pair because each adapter owns all pieces.
|
|
1124
|
+
|
|
1125
|
+
```ts
|
|
1126
|
+
// src/shared/runtime.ts
|
|
1127
|
+
export interface RuntimeModeAdapter {
|
|
1128
|
+
readonly id: 'direct' | 'local' | 'vercel-sandbox'
|
|
1129
|
+
create(ctx: ModeContext): Promise<RuntimeBundle>
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
export interface ModeContext {
|
|
1133
|
+
workspaceId: string
|
|
1134
|
+
workspaceRoot?: string // local/direct modes
|
|
1135
|
+
sandboxHandleStore?: SandboxHandleStore // vercel-sandbox mode
|
|
1136
|
+
// ... mode-specific config from boring.app.toml
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
export interface RuntimeBundle {
|
|
1140
|
+
workspace: Workspace
|
|
1141
|
+
sandbox: Sandbox
|
|
1142
|
+
fileSearch: FileSearch
|
|
1143
|
+
uiBridge?: UiBridge // pass-through; set by createAgentApp, same for all modes
|
|
1144
|
+
}
|
|
1145
|
+
```
|
|
1146
|
+
|
|
1147
|
+
Package layout: `src/server/runtime/` holds `mode.ts` (contract) + `resolveMode.ts` (mode → bundle) + `modes/{direct,local,vercel-sandbox}.ts` (the three adapters). Each `modes/*.ts` file is self-contained — you understand what `direct` mode is by reading `modes/direct.ts`, not by cross-referencing 3 other directories.
|
|
1148
|
+
|
|
1149
|
+
`resolveMode(mode)` replaces the earlier `resolveWorkspaceAndSandbox(mode)` factory.
|
|
1150
|
+
|
|
1151
|
+
---
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
### Direct mode (`mode: "direct"`)
|
|
1155
|
+
|
|
1156
|
+
- `NodeWorkspace` (host Node fs, path-boundary enforced)
|
|
1157
|
+
- `DirectSandbox` (`child_process.exec` with `cwd = workspace.root`, no isolation)
|
|
1158
|
+
- Files on the backend machine; bash runs as the backend process user
|
|
1159
|
+
- **No Linux/bwrap dependency** — works on macOS, Windows (WSL2), any Node host
|
|
1160
|
+
- **Windows native (non-WSL) is NOT supported in v1.** Path semantics, `cmd.exe` quoting, and bash absence make it a separate engineering effort. WSL2 is supported via the Linux path.
|
|
1161
|
+
|
|
1162
|
+
**Trust posture: the agent operator and the file owner are the same person.** Same model as running Claude Code on your laptop — no sandbox because you trust yourself + your prompts.
|
|
1163
|
+
|
|
1164
|
+
**When to use direct mode** (both dev AND production):
|
|
1165
|
+
- Local dev on any OS (primary use)
|
|
1166
|
+
- Single-user self-host on your own server
|
|
1167
|
+
- Personal-use deployments (one tenant per host)
|
|
1168
|
+
- Internal tools where the operator and user are the same team
|
|
1169
|
+
|
|
1170
|
+
**When NOT to use direct mode — use `local` or `vercel-sandbox` instead**:
|
|
1171
|
+
- Multi-tenant SaaS (users share backend processes)
|
|
1172
|
+
- Untrusted-input pipelines (agent ingests random URLs / user uploads)
|
|
1173
|
+
- Public-facing with untrusted users
|
|
1174
|
+
- Anywhere a prompt injection could lead to "arbitrary shell on the host" being a real concern
|
|
1175
|
+
|
|
1176
|
+
**Think of the three modes as a trust spectrum**, not a dev-vs-prod split:
|
|
1177
|
+
|
|
1178
|
+
```
|
|
1179
|
+
Less isolation More isolation
|
|
1180
|
+
◄─────────────────────────────────────────────────────────►
|
|
1181
|
+
|
|
1182
|
+
direct local (bwrap) vercel-sandbox
|
|
1183
|
+
(no sandbox) (process isolation, (VM isolation,
|
|
1184
|
+
same kernel) dedicated kernel)
|
|
1185
|
+
|
|
1186
|
+
"me on my "my backend, "any user,
|
|
1187
|
+
own stuff" one tenant per process" any prompt"
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
### Local mode (`mode: "local"`)
|
|
1191
|
+
|
|
1192
|
+
- `NodeWorkspace` (host Node fs, path-boundary enforced via `validatePath`)
|
|
1193
|
+
- `BwrapSandbox` (wraps bash in bubblewrap, binds the workspace dir)
|
|
1194
|
+
- Files live on the backend machine at `${workspaceRoot}/${workspaceId}/`
|
|
1195
|
+
- Deployable on any Linux host with `bubblewrap` installed (Fly, Render, Railway, Docker)
|
|
1196
|
+
- Zero external dependencies beyond pi-coding-agent + bwrap binary
|
|
1197
|
+
- **Security posture: process isolation within single tenant.** Workspace-level isolation via bwrap namespaces; multi-tenant safety relies on per-tenant host boundaries (e.g., Fly machine per tenant).
|
|
1198
|
+
|
|
1199
|
+
### Remote mode (`mode: "vercel-sandbox"`)
|
|
1200
|
+
|
|
1201
|
+
- `VercelSandboxWorkspace` (fs ops delegate to `sandbox.fs.readFile/writeFile/readdir/stat` + `sandbox.writeFiles`)
|
|
1202
|
+
- `VercelSandboxExec` (exec delegates to `sandbox.runCommand`)
|
|
1203
|
+
- Files live inside a Vercel Firecracker microVM at `/vercel/sandbox/`
|
|
1204
|
+
- **Pairing invariant**: both adapters share the same `Sandbox` handle per workspace
|
|
1205
|
+
- Requires Vercel account + `VERCEL_OIDC_TOKEN`
|
|
1206
|
+
- Backend can run on any PaaS including ones without Linux namespaces
|
|
1207
|
+
|
|
1208
|
+
### Vercel Sandbox — cold start + pricing (reference)
|
|
1209
|
+
|
|
1210
|
+
From Vercel docs, concrete numbers to inform design/operational decisions:
|
|
1211
|
+
|
|
1212
|
+
**Cold start times:**
|
|
1213
|
+
|
|
1214
|
+
| Creation method | Typical latency |
|
|
1215
|
+
|---|---|
|
|
1216
|
+
| `Sandbox.create()` empty | ~200–500 ms |
|
|
1217
|
+
| `Sandbox.create({ source: 'snapshot' })` | ~100–300 ms — fastest |
|
|
1218
|
+
| `Sandbox.create({ source: 'tarball' })` | 1–5 s (+ download) |
|
|
1219
|
+
| `Sandbox.create({ source: 'git' })` | 2–10 s (+ clone) |
|
|
1220
|
+
| Hibernation resume | ~200–500 ms (transparent on first op) |
|
|
1221
|
+
|
|
1222
|
+
Implication: **pre-baked snapshot is the production pattern.** First-ever workspace creation may take 1–5 s (one-time bake); every subsequent create is sub-second.
|
|
1223
|
+
|
|
1224
|
+
**Pricing (Pro/Enterprise list rates):**
|
|
1225
|
+
|
|
1226
|
+
| Metric | Rate |
|
|
1227
|
+
|---|---|
|
|
1228
|
+
| Active CPU | $0.128 / hour (CPU-active time only — I/O waits don't count) |
|
|
1229
|
+
| Provisioned Memory | $0.0212 / GB-hour (1-min minimum billing per lifecycle) |
|
|
1230
|
+
| Sandbox Creation | $0.60 / 1M (~$0 at realistic scale) |
|
|
1231
|
+
| Data Transfer | $0.15 / GB |
|
|
1232
|
+
| Snapshot Storage | $0.08 / GB-month |
|
|
1233
|
+
|
|
1234
|
+
**Hobby free allotment:** 5 hrs active CPU, 420 GB-hrs memory, 5k creations, 20 GB transfer, 15 GB storage lifetime. **10 concurrent sandboxes** (cap!), 45-min max runtime.
|
|
1235
|
+
|
|
1236
|
+
**Pro:** $20/month credit, 2000 concurrent, 5-hr max runtime.
|
|
1237
|
+
|
|
1238
|
+
**Realistic per-session cost** (30-min agent session, 2 vCPU, 4 GB, ~30% CPU active): **~$0.10**. Snapshot for a baked image (2–5 GB): $0.16–0.40/month.
|
|
1239
|
+
|
|
1240
|
+
**Region:** `iad1` only (US East). EU/APAC users will see +100–200 ms latency per fs op.
|
|
1241
|
+
|
|
1242
|
+
---
|
|
1243
|
+
|
|
1244
|
+
### Sandbox handle lifecycle (remote mode only)
|
|
1245
|
+
|
|
1246
|
+
In v1 agent, there is **one workspaceId per instance** (from env/config). Lifecycle is trivial:
|
|
1247
|
+
|
|
1248
|
+
```
|
|
1249
|
+
Backend starts
|
|
1250
|
+
│
|
|
1251
|
+
▼
|
|
1252
|
+
First request arrives
|
|
1253
|
+
│
|
|
1254
|
+
▼
|
|
1255
|
+
resolveSandboxHandle(workspaceId)
|
|
1256
|
+
│
|
|
1257
|
+
├── HandleStore has vercel_sandbox_id + sandbox running? → Sandbox.get({ id }) → cache → return
|
|
1258
|
+
├── HandleStore has vercel_snapshot_id but sandbox expired? → Sandbox.create({ source: { type: 'snapshot' } }) → update store → return
|
|
1259
|
+
└── No sandbox yet (first boot)? → Sandbox.create({ source: { tarball from template | empty } }) → store id → return
|
|
1260
|
+
|
|
1261
|
+
Inactivity → Vercel hibernates sandbox → transparent resume on next op (~200–500 ms)
|
|
1262
|
+
|
|
1263
|
+
TTL expiry (45 min Hobby / 5 h Pro) → Vercel deletes VM
|
|
1264
|
+
│
|
|
1265
|
+
▼
|
|
1266
|
+
Next access triggers recreate-from-snapshot
|
|
1267
|
+
(we take periodic snapshots as a safety net)
|
|
1268
|
+
```
|
|
1269
|
+
|
|
1270
|
+
Handle cached in-process keyed by `workspaceId` (single entry in v1). Backend restart rebuilds cache lazily from `SandboxHandleStore`.
|
|
1271
|
+
|
|
1272
|
+
**Swappable store — same pattern as `SessionStore`:**
|
|
1273
|
+
|
|
1274
|
+
```ts
|
|
1275
|
+
// Default (agent v1) — no DB dependency:
|
|
1276
|
+
const app = createAgentApp({}) // uses FileHandleStore at ~/.config/boring-agent/sandboxes.json
|
|
1277
|
+
|
|
1278
|
+
// Future — @boring/core ships reusable DB impls:
|
|
1279
|
+
import { createPostgresHandleStore } from '@boring/core/stores'
|
|
1280
|
+
const app = createAgentApp({
|
|
1281
|
+
sandboxHandleStore: createPostgresHandleStore(pgPool),
|
|
1282
|
+
})
|
|
1283
|
+
```
|
|
1284
|
+
|
|
1285
|
+
Agent internals call `store.get/set/delete` — the adapter is the swap point. Adding a new backend = 30–50 LOC implementing `SandboxHandleStore`. Agent code unchanged. Apps choose what they install; file default has zero deps.
|
|
1286
|
+
|
|
1287
|
+
**Circuit breaker on Vercel API:** exponential backoff (100 ms → 1.6 s, max 5 retries) when `Sandbox.create` / `Sandbox.get` / `sandbox.runCommand` throws a 5xx or network error. Subsequent calls within the breaker-open window fail fast with a clear error surfaced to the chat (`"Workspace backend temporarily unavailable"`). Prevents retry storms + frontend hangs when Vercel hiccups. ~30 LOC, wraps the Vercel SDK client.
|
|
1288
|
+
|
|
1289
|
+
**Snapshot retention:** each `sandbox.snapshot()` deletes all prior snapshots for the same `workspaceId` except the most recent previous one (keep-last-2 policy). Bounds storage at 2× snapshot-size per workspace. Configurable via `BORING_AGENT_SNAPSHOT_KEEP` (default 2). Without this, snapshot storage grows linearly with uptime and adds silent month-over-month cost.
|
|
1290
|
+
|
|
1291
|
+
**When multi-workspace arrives** (via `@boring/cloud`), the cloud package owns `Sandbox.create()` / `Sandbox.stop()` / snapshot lifecycle. The agent package just receives a `workspaceId` with its sandboxId already provisioned.
|
|
1292
|
+
|
|
1293
|
+
### What's shared across modes
|
|
1294
|
+
|
|
1295
|
+
- All 7 standard pi tools (`bash`, `read`, `write`, `edit`, `find`, `grep`, `ls`) — bound to the right adapter pair through Operations/spawn hooks
|
|
1296
|
+
- All HTTP endpoints (`/api/v1/files`, `/api/v1/tree`, `/api/v1/stat`, `/api/v1/agent/chat`, ...)
|
|
1297
|
+
- Chat UI, session handling, theme, primitives — totally mode-unaware
|
|
1298
|
+
- Plugin ecosystem (pi-coding-agent's extension system)
|
|
1299
|
+
- Template copy (copyTemplate works in both modes; remote mode packages into a tarball for `Sandbox.create()`)
|
|
1300
|
+
|
|
1301
|
+
### Future modes (design seams only — not shipped in v1)
|
|
1302
|
+
|
|
1303
|
+
- **Browser-agent:** `PiAgentCoreHarness` or `ToolLoopAgentHarness` + `JustBashSandbox` (in-browser Bash interpreter) + `OpfsWorkspace` (browser OPFS fs). All three exist as interface-compatible placeholders; no implementation.
|
|
1304
|
+
- **Cloudflare Sandbox:** `CloudflareSandboxWorkspace` + `CloudflareSandboxExec` — parallel to Vercel mode, Cloudflare infra.
|
|
1305
|
+
- **Self-hosted VM isolation:** `BoxliteSandbox` for bare-metal KVM deployments.
|
|
1306
|
+
|
|
1307
|
+
**Hard rule enforced throughout v1:** `AgentHarness`, `Workspace`, `Sandbox`, `AgentTool`, and all of `src/shared/*` must not leak Node-only APIs (`fs`, `child_process`, `process`, etc.) into their public shapes. Use `Uint8Array` (not `Buffer`) for binary data in shared contracts. Server-only code stays in `src/server/**`.
|
|
1308
|
+
|
|
1309
|
+
---
|
|
1310
|
+
|
|
1311
|
+
## Shipping roadmap
|
|
1312
|
+
|
|
1313
|
+
Each milestone leaves the tree green and the standalone app usable.
|
|
1314
|
+
|
|
1315
|
+
### M0 — Skeleton & contracts (≈ 1–2 days)
|
|
1316
|
+
|
|
1317
|
+
Pure scaffolding, no behavior.
|
|
1318
|
+
|
|
1319
|
+
- [x] `packages/agent/` with `package.json`, `tsconfig.json` (done)
|
|
1320
|
+
- [x] `src/shared/{harness,sandbox,tool,catalog,session,message}.ts` — **interfaces only**, no implementations
|
|
1321
|
+
- [x] Barrel `src/shared/index.ts` — re-exports all 12 shared modules
|
|
1322
|
+
- [x] Stub passing tests for each interface (type-level only) — 8 `.test-d.ts` files
|
|
1323
|
+
- [x] `app/` directory scaffolded (empty Vite + Fastify)
|
|
1324
|
+
|
|
1325
|
+
**Gate:** types compile; empty app boots with placeholder HTML.
|
|
1326
|
+
|
|
1327
|
+
### M1 — Local mode MVP (≈ 1 week) ← **ship first**
|
|
1328
|
+
|
|
1329
|
+
Smallest end-to-end thing where you type a message in the standalone app, agent runs `bash` once against a local workspace, response streams back. **No remote adapters yet.**
|
|
1330
|
+
|
|
1331
|
+
- [x] `createNodeWorkspace()` with ported path helpers (validatePath, realpath checks).
|
|
1332
|
+
- [x] `createDirectSandbox()` — `child_process.spawn` wrapper with `cwd = workspace.root`. ~175 LOC with process management.
|
|
1333
|
+
- [x] `createBwrapSandbox()` — `exec` wraps `bwrap --bind ${workspace.root} /workspace --chdir /workspace bash -c …`.
|
|
1334
|
+
- [x] `resolveMode(mode)` factory: auto-detect Linux+bwrap → `local`, else → `direct`. `vercel-sandbox` opt-in. Each mode module in `src/server/runtime/modes/*.ts` is self-contained.
|
|
1335
|
+
- [x] Catalog: pi factory tools `bash`, `read`, `write`, `edit`, `find`, `grep`, `ls` via `buildHarnessAgentTools()` and `buildFilesystemAgentTools()`, plus host/UI tools where provided.
|
|
1336
|
+
- [x] `createPiCodingAgentHarness()` — wraps `createAgentSession({ tools })`, in-memory session manager.
|
|
1337
|
+
- [x] Pi → UIMessage stream adapter (text-delta, tool-input-*, tool-output-available, finish).
|
|
1338
|
+
- [x] Fastify routes (versioned, `/api/v1/*`):
|
|
1339
|
+
- Files: `GET/POST/DELETE /api/v1/files`, `GET /api/v1/tree`, `GET /api/v1/stat`, `GET /api/v1/files/search` — thin wrappers over `workspace.X()`
|
|
1340
|
+
- Agent: `POST /api/v1/agent/chat`, `GET /api/v1/agent/sessions`, `POST /api/v1/agent/sessions`, `GET/DELETE /api/v1/agent/sessions/:id`
|
|
1341
|
+
- No `/api/v1/workspaces` in v1 — workspaceId comes from config
|
|
1342
|
+
- No `/api/v1/git/*` in v1 — no UI consumer; agent uses bash for git
|
|
1343
|
+
- [x] `<ChatPanel />` — `useChat`-based message list + composer with ai-elements primitives.
|
|
1344
|
+
- [x] `<Composer />` includes `<ModelPicker />` (dropdown: sonnet / haiku / opus) and `<ThinkingToggle />` (off/low/med/high). Values passed into `useAgentChat` options on each send.
|
|
1345
|
+
- [x] Standalone app wires frontend to backend. `pnpm dev` boots chat (Vite dev server + Fastify).
|
|
1346
|
+
- [x] **CLI `bin/boring-agent`**: flag parser, API-key prompt + persistence, port picker (auto-increment on conflict), **SSH/headless-aware browser-open**, **auto-gitignore hygiene**, graceful shutdown (SIGINT/SIGTERM → abort stream, close server). `--logout` / `--reset-key` / `--no-open` / `--no-gitignore` flags.
|
|
1347
|
+
- [x] Build pipeline: `pnpm build` produces `dist/frontend/` (Vite static build) + `dist/bin/boring-agent.js` (bundled CLI). `package.json` `bin` entry points at the compiled CLI.
|
|
1348
|
+
- [x] `ANTHROPIC_API_KEY` + `BORING_AGENT_WORKSPACE_ROOT` env parsing + CLI flag override.
|
|
1349
|
+
|
|
1350
|
+
**Gate:** user can say "list files in /tmp" and get a streamed reply with a `bash` tool call card + result. File tree route returns correct listings. No polish, no sessions persistence, no styling.
|
|
1351
|
+
|
|
1352
|
+
### M2 — Remote mode (Vercel Sandbox) + unified access proof (≈ 1 week)
|
|
1353
|
+
|
|
1354
|
+
Prove the core architectural claim: swapping adapters flips from local to remote with zero changes elsewhere.
|
|
1355
|
+
|
|
1356
|
+
- [x] `createVercelSandboxWorkspace(sandbox)` — adapter against `sandbox.fs.*` + caching layer. ~173 LOC.
|
|
1357
|
+
- [x] `createVercelSandboxExec(sandbox)` — adapter wrapping `sandbox.runCommand` + heartbeat/abort. ~122 LOC.
|
|
1358
|
+
- [x] `SandboxHandleStore` interface (`shared/sandbox-handle-store.ts`) + `FileHandleStore` default impl at `~/.config/boring-agent/sandboxes.json`. Atomic writes.
|
|
1359
|
+
- [x] `resolveSandboxHandle(workspaceId)` — create-or-get-or-resume-from-snapshot + in-process cache. ~333 LOC.
|
|
1360
|
+
- [x] `resolveMode('vercel-sandbox')` returns a `RuntimeBundle` whose workspace + sandbox are bound to the same Vercel sandbox handle.
|
|
1361
|
+
- [x] Config: `BORING_AGENT_MODE`, `VERCEL_OIDC_TOKEN`, `VERCEL_TEAM_ID`.
|
|
1362
|
+
- [x] Periodic snapshot for safety (`PeriodicSnapshotScheduler` with configurable interval, default 10min).
|
|
1363
|
+
- [x] Circuit breaker around the Vercel SDK client (exponential backoff, fast-fail when open). Full state machine (closed/open/half-open).
|
|
1364
|
+
- [x] Snapshot retention policy (keep-last-2 per workspace) — `applySnapshotRetention()` configurable via `BORING_AGENT_SNAPSHOT_KEEP`.
|
|
1365
|
+
- [x] Standalone app reads `BORING_AGENT_MODE` — same code path serves both modes.
|
|
1366
|
+
|
|
1367
|
+
**Gate:** `BORING_AGENT_MODE=vercel-sandbox pnpm dev` — same chat UI, same tools, files now living in a Firecracker VM. No other code changes. Switching modes mid-development is an env-var flip.
|
|
1368
|
+
|
|
1369
|
+
### M3a — Core UX contracts: sessions + resume + bridge (≈ 4 days)
|
|
1370
|
+
|
|
1371
|
+
Load-bearing contracts that must be reliable before polish. If these aren't solid, no amount of chat UX polish helps.
|
|
1372
|
+
|
|
1373
|
+
- [x] `PiSessionStore` implementing `SessionStore` over pi's JSONL. CRUD via HTTP routes.
|
|
1374
|
+
- [x] **Stream resumption:** `StreamBufferStore` tracks active turns; `GET /api/v1/agent/chat/:sessionId/stream?cursor=<n>` replays from cursor. X-Turn-Id header returned.
|
|
1375
|
+
- [x] Abort handling (`AbortController` in chat route → pi `abortSignal` → sandbox exec cancellation).
|
|
1376
|
+
- [x] SSE channel: backend emits `data-file-changed` events → frontend `useFileChangeStream` invalidates React Query cache.
|
|
1377
|
+
- [x] **UI bridge** (`UiBridge` interface + in-memory impl):
|
|
1378
|
+
- `GET/PUT /api/v1/ui/state` — opaque state KV keyed by workspaceId
|
|
1379
|
+
- `POST /api/v1/ui/commands` — agent posts UI command with seq numbering
|
|
1380
|
+
- `GET /api/v1/ui/commands/next` (SSE) — workspace subscribes to command stream (poll=true variant also supported)
|
|
1381
|
+
- Agent tools `get_ui_state` + `exec_ui` wired as host-provided tools when `uiBridge` is provided
|
|
1382
|
+
- [x] `copyTemplate()` — sync template copy on workspace create. `createAgentApp({ templatePath })` + `BORING_AGENT_TEMPLATE_PATH` env fallback. Idempotency via `.boring-agent/provisioned` marker.
|
|
1383
|
+
|
|
1384
|
+
**Gate:** session CRUD + resume + UI bridge are reliable in both modes. Reliability bar > polish bar for this milestone.
|
|
1385
|
+
|
|
1386
|
+
### M3b — Chat UX polish & operator ergonomics (≈ 3 days)
|
|
1387
|
+
|
|
1388
|
+
UX polish on top of the M3a contracts. Pure frontend + minor backend additions.
|
|
1389
|
+
|
|
1390
|
+
- [x] Tool rendering — `Tool`, `Terminal`, `CodeBlock` primitives + `DiffView` for edit tool. `toolRenderers` prop exposed on `<ChatPanel />`.
|
|
1391
|
+
- [x] ai-elements primitives in `src/front/primitives/`: Message, Composer, Tool, Terminal, CodeBlock, Reasoning.
|
|
1392
|
+
- [x] `<SessionToolbar />` — current session title + dropdown of recent (MAX_VISIBLE=10) + new chat button + per-session delete with confirmation.
|
|
1393
|
+
- [x] `theme.css` + CSS vars scoped to `[data-boring-agent]`. Default dark theme.
|
|
1394
|
+
- [x] Background title generation via `createSessionTitleScheduler`.
|
|
1395
|
+
- [x] **Slash commands** infrastructure + 5 built-ins (`/clear`, `/reset`, `/model`, `/help`, `/cost`). Registry + parser + builtins.
|
|
1396
|
+
- [x] **Heartbeat events** during long tool execution: `onHeartbeat` callback in `ExecOptions`; rendered in Tool component.
|
|
1397
|
+
- [x] **`/api/v1/agent/sessions/:id/changes`** endpoint: `InMemorySessionChangesTracker` tracks file ops; returns `{files: [{path, op, size, timestamp}]}`.
|
|
1398
|
+
|
|
1399
|
+
**Gate:** standalone app feels like a real chat in both modes; FileTree and Editor visibly update when the agent writes files.
|
|
1400
|
+
|
|
1401
|
+
### M4 — Plugin compat (≈ 2–3 days)
|
|
1402
|
+
|
|
1403
|
+
Pi plugin ecosystem compatibility. Local mode only (plugins are Node-native; irrelevant for remote VM).
|
|
1404
|
+
|
|
1405
|
+
- [x] Wire pi's extension discovery through `createAgentSession` options. Plugin loader at `pluginLoader.ts`.
|
|
1406
|
+
- [x] Surface plugin-registered tools in the catalog via `mergeTools.ts`.
|
|
1407
|
+
- [ ] Resolve name-collision precedence; document. *(deferred — last-registered-wins accepted risk)*
|
|
1408
|
+
- [ ] Smoke-test 2–3 community pi extensions.
|
|
1409
|
+
- [ ] Document that in `vercel-sandbox` mode, plugins are not auto-loaded.
|
|
1410
|
+
|
|
1411
|
+
**Gate:** dropping a file in `~/.pi/agent/extensions/hello.ts` surfaces a working tool in the chat (local mode).
|
|
1412
|
+
|
|
1413
|
+
### M5 — Workspace extension hooks + docs (≈ 2 days)
|
|
1414
|
+
|
|
1415
|
+
The public API that `@boring/workspace` will consume when we build it.
|
|
1416
|
+
|
|
1417
|
+
- [ ] `registerTool()` runtime API (alongside pi extensions). *(deferred)*
|
|
1418
|
+
- [x] `<ChatPanel toolRenderers={...}>` override prop. Implemented via `ToolRendererOverrides`.
|
|
1419
|
+
- [x] Per-panel CSS var scoping — `[data-boring-agent]` attribute scoping, all vars `--boring-agent-*`.
|
|
1420
|
+
- [ ] API docs under `docs/`. *(deferred)*
|
|
1421
|
+
- [ ] Migration guide: "your boring-macro, now with mode switch." *(deferred)*
|
|
1422
|
+
|
|
1423
|
+
**Gate:** a two-file example `examples/with-custom-tool/` demonstrates adding a tool + renderer + restyle from outside the package. `boring-macro` can boot in both modes by flipping one env var.
|
|
1424
|
+
|
|
1425
|
+
### M6+ — Optional expansions (on demand)
|
|
1426
|
+
|
|
1427
|
+
- `CloudflareSandboxWorkspace` + `CloudflareSandboxExec` (parallel to Vercel, Cloudflare infra)
|
|
1428
|
+
- `execute_isolated_code` tool (5th tool, nao-style — appears when a sandbox declares `'isolated-code'` capability)
|
|
1429
|
+
- Two-tier Python venv for local bwrap mode (shared `[sandbox].python_packages` + per-workspace overlay)
|
|
1430
|
+
- Browser-agent harness (`PiAgentCoreHarness` or `ToolLoopAgentHarness`) + `JustBashSandbox` + `OpfsWorkspace`
|
|
1431
|
+
|
|
1432
|
+
---
|
|
1433
|
+
|
|
1434
|
+
## Risks & accepted exposures
|
|
1435
|
+
|
|
1436
|
+
**v1 philosophy: ship fast, accept known risk.** We are deliberately NOT engineering defensive mitigations for the risks below. Each is a known exposure; if it bites, we react then. This section exists so the trade-offs are explicit, not accidental.
|
|
1437
|
+
|
|
1438
|
+
### Risks accepted with minimal / no mitigation in v1
|
|
1439
|
+
|
|
1440
|
+
| Risk | Accepted exposure | If it bites |
|
|
1441
|
+
|---|---|---|
|
|
1442
|
+
| **Pi SDK API instability** (v0.68.x, no semver) | Pin version in `package.json`. No contract test, no abstraction hedge, no fallback to pi-agent-core. | Pi ships a breaking change → v1 breaks on upgrade. Fix: revert pin, manual patch, or reluctantly implement `PiAgentCoreHarness` adapter at that point. |
|
|
1443
|
+
| **Pi event-shape drift** (undocumented internal events our stream adapter consumes) | Same as above — ride pi's stream shapes. No snapshot tests. | Silent stream-adapter breakage on pi upgrade → debug reactively. |
|
|
1444
|
+
| **Pi abort → bash child propagation** | Write the `sleep 60 → abort → dead in 2s` e2e test in M1. No process-group kill wrapper, no SIGKILL escalation. | Test passes → accept. If test fails → add process-group kill then. If it passes now but regresses later → user complaint triggers fix. |
|
|
1445
|
+
| **Vercel Sandbox cost** | No POC, no budget alerts, no hard caps, no aggressive auto-stop. Snapshot retention bounded (keep-last-2 policy). | Surprise bill → add snapshot+stop-on-idle and budget alert reactively. Exposure bounded by active workspaces × 45-min default TTL × 2 snapshots each. |
|
|
1446
|
+
| **Abandoned Vercel sandbox leaks** | No release-on-session-close hook. Vercel's own TTL eventually deletes. | Each abandoned session costs ~45 min × active-CPU-ms. Proportional to user count — small at first. Add release hook if bill hurts. |
|
|
1447
|
+
| **Multi-tab concurrent sessions on same workspace** | Allow N tabs; trust pi + fs to interleave. No lock, no read-only signal, no optimistic concurrency. | Codex flagged as "data-loss territory." We accept: silent last-write-wins on concurrent writes. Documented caveat: "one tab recommended." If users hit it, upgrade to `expectedMtime` 409-conflict (~30 LOC). |
|
|
1448
|
+
| **Plugin tool name collisions** | Last-registered wins. No reservation of built-in names. | Pi plugin registering a `bash` tool shadows ours. Documented: "plugin authors accept responsibility for naming." Codex recommended reservation + namespacing; deferred. |
|
|
1449
|
+
|
|
1450
|
+
### Risks addressed by design (already mitigated)
|
|
1451
|
+
|
|
1452
|
+
| Risk | Why it matters | Design-time mitigation |
|
|
1453
|
+
|---|---|---|
|
|
1454
|
+
| **Plugin × sandbox mismatch** (plugins bypass Sandbox in remote mode) | Plugin tools run in the backend Node process; in remote mode, that's NOT the Vercel VM. | Enforced: **plugin loading is local-mode-only.** Remote mode skips `pi.loadExtensions`. Documented rule + 3-line check at harness construction. |
|
|
1455
|
+
| **Vercel Sandbox fs latency on tight loops** | 50 readFile calls = 50 round-trips (2–5s). Bad UX. | Document: agent prefers bash `grep`/`find` for multi-file ops. No code change needed; pattern enforced by system prompt + tool availability. |
|
|
1456
|
+
| **Tailwind v4 consumer compatibility** | ai-elements requires Tailwind v4. | Documented consumer requirement. No vanilla-CSS variant in v1. |
|
|
1457
|
+
| **SSE long-lived connections on serverless** | Would break if backend moved to Vercel Functions. | Documented: backend assumes persistent-process host (Fly/Render/Railway). Not a concern today. |
|
|
1458
|
+
| **Backend process crash loses sandbox handle cache** | In-memory cache dies on restart. | Accepted tradeoff: lazy rebuild from DB is fast (~100 ms first-request penalty). No persistent cache needed in v1. |
|
|
1459
|
+
|
|
1460
|
+
### Risks requiring real mitigation in v1
|
|
1461
|
+
|
|
1462
|
+
These we **do** need to engineer, because they're load-bearing for core functionality:
|
|
1463
|
+
|
|
1464
|
+
| Risk | Mitigation |
|
|
1465
|
+
|---|---|
|
|
1466
|
+
| **Vercel cold start UX** (2–10 s on first `Sandbox.create({ source: 'git' })`) | M2 ships async workspace provisioning with polling state (`pending → ready`). Pre-baked template snapshot for fast subsequent creates. Required for M2 to feel usable. |
|
|
1467
|
+
| **Vercel OIDC token expiry** | M2: support both `VERCEL_OIDC_TOKEN` (dev) and `VERCEL_ACCESS_TOKEN` (long-running prod). Startup validation that at least one is present. |
|
|
1468
|
+
| **pi SessionEntry → UIMessage mapping** | M3: write mapping table as code comments tying pi event kinds to UIMessage parts. Conformance test covers all event kinds the adapter emits. Required for session-resume to work. |
|
|
1469
|
+
|
|
1470
|
+
### Why this posture
|
|
1471
|
+
|
|
1472
|
+
v1 is **prove the architecture works end-to-end**, not **productionize**. Each accepted exposure above is tracked; when the first user pain hits, the mitigation path is pre-identified. Engineering upfront defensive code for risks that may never fire is the wrong trade-off at this stage.
|
|
1473
|
+
|
|
1474
|
+
**Re-evaluate this posture at v1.5** — when we have real users and real workloads, some of the "accepted" risks will upgrade to "needs mitigation now."
|
|
1475
|
+
|
|
1476
|
+
---
|
|
1477
|
+
|
|
1478
|
+
## Test strategy (v1)
|
|
1479
|
+
|
|
1480
|
+
- **CI:** lint + typecheck (`tsc --noEmit`) on every PR. Lint rule in `primitives/`: no hard-coded color classes (`bg-slate-*`, etc.) — must use `bg-[var(--boring-agent-*)]`.
|
|
1481
|
+
- **Unit tests:** stream-adapter fixture-based (pi event → UIMessage mapping). `ExecOptions` timeout + maxOutputBytes + buffer handling. Path validator (`validatePath`, `assertRealPathWithinWorkspace`). Slash-command parser.
|
|
1482
|
+
- **One e2e smoke test per mode:** spawn CLI, send a message, assert streamed response contains expected bash output. Direct + local run in CI on every PR. `vercel-sandbox` skipped on PRs (costs money) and runs on release tags only.
|
|
1483
|
+
- **No UI snapshot tests in v1.** Accepted drift; easy to add when the UI stabilizes.
|
|
1484
|
+
|
|
1485
|
+
---
|
|
1486
|
+
|
|
1487
|
+
## Open research items (true unknowns only)
|
|
1488
|
+
|
|
1489
|
+
*Items previously in this list that have been resolved: TypeBox vs JSON Schema → JSON Schema; SSE vs polling → SSE; ai-elements adoption → copy into `primitives/`.*
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
1. **Pi built-in opt-out** — confirm `createAgentSession({ tools: [our...] })` fully replaces defaults. Docs imply yes; verify by test in M1.
|
|
1493
|
+
2. **Plugin name collision** — precedence when our `bash` and a plugin's `bash` coexist. Research + document in M4.
|
|
1494
|
+
3. **Pi abort propagation** — does pi's `abortSignal` reach child bash processes inside bwrap and Vercel Sandbox? Test in M1 (local) + M2 (remote).
|
|
1495
|
+
5. **Tailwind v4 conflict risk** — when workspace also uses Tailwind, do our classes and theirs compose cleanly? Test in M5.
|
|
1496
|
+
6. **Pi SessionEntry → UIMessage mapping** — finalize conversion for all pi event types in M3; write a conformance test.
|
|
1497
|
+
8. **Vercel Sandbox latency on tight fs loops** — measure real numbers for 100-file tree listing, 50-file grep, during M2. If painful, plan mitigations (sandbox-side grep tool, batched reads, caching).
|
|
1498
|
+
9. **Vercel Sandbox cold-start + snapshot UX** — how long does first workspace creation take with and without snapshots? What's the right "restoring workspace..." UX? Test in M2.
|
|
1499
|
+
10. **Template → Vercel tarball flow** — build pipeline that packages `BORING_AGENT_TEMPLATE_PATH` into a tarball, uploads to Vercel Blob, references in `Sandbox.create({ source: { type: 'tarball', url } })`. Decide at M3 whether this deserves dedicated tooling or a README.
|
|
1500
|
+
11. **Vercel billing model** — active-CPU-ms pricing vs Fly machine hours at realistic usage. POC-quality numbers before any migration claim.
|
|
1501
|
+
13. **Pairing invariant enforcement** — can the factory guarantee you can't construct a mismatched pair? Type-level vs runtime check.
|
|
1502
|
+
|
|
1503
|
+
---
|
|
1504
|
+
|
|
1505
|
+
## Out of scope (explicit)
|
|
1506
|
+
|
|
1507
|
+
### Owned by future packages
|
|
1508
|
+
|
|
1509
|
+
- **`@boring/cloud`:** multi-workspace provisioning, per-user/per-tenant workspace lifecycle, workspace creation UI, Fly Machine / Modal / Vercel account orchestration, billing integration, multi-tenant auth. The agent package receives a resolved `workspaceId` — it does not create, list, or switch workspaces at runtime.
|
|
1510
|
+
- **`@boring/workspace`:** file tree component, editor, layout (DockView), layout persistence, session-list sidebar (rename dialog in particular — delete ships in agent v1). Git UI is dropped from workspace v1 entirely; if it comes back in v1.x, agent will add `/api/v1/git/*` routes. Agent exposes the HTTP endpoints that power these; workspace builds the UI on top.
|
|
1511
|
+
|
|
1512
|
+
### Deferred (design seams exist; no implementation in v1)
|
|
1513
|
+
|
|
1514
|
+
- Browser-agent harness + `JustBashSandbox` + `OpfsWorkspace`.
|
|
1515
|
+
- Cloudflare Sandbox mode (parallel to Vercel mode).
|
|
1516
|
+
- `execute_isolated_code` tool (lights up when a sandbox declares `'isolated-code'` capability).
|
|
1517
|
+
- Two-tier Python venv infra (shared + overlay).
|
|
1518
|
+
- SQLite / IndexedDB / remote `SessionStore` implementations.
|
|
1519
|
+
- Rich session management UI (sidebar, folders, search, rename/delete flows).
|
|
1520
|
+
|
|
1521
|
+
### Not planned
|
|
1522
|
+
|
|
1523
|
+
- Multi-user auth, billing.
|
|
1524
|
+
- Non-Anthropic providers.
|
|
1525
|
+
- MCP tool integration.
|
|
1526
|
+
- Deployment tooling (Docker/Modal/Fly).
|
|
1527
|
+
|
|
1528
|
+
---
|
|
1529
|
+
|
|
1530
|
+
## Coordination with `@boring/workspace`
|
|
1531
|
+
|
|
1532
|
+
The workspace package (spec: `/home/ubuntu/projects/boring-ui-v2/packages/workspace/docs/plans/archive/WORKSPACE_V2_PLAN.md`) is designed to be composed with this agent package. Contracts agreed across the two plans:
|
|
1533
|
+
|
|
1534
|
+
### Backend ownership (single source of truth)
|
|
1535
|
+
|
|
1536
|
+
All backend HTTP routes live in the **agent** package: `/api/v1/files/*`, `/api/v1/tree`, `/api/v1/stat`, `/api/v1/ui/*`, `/api/v1/agent/*`. Git routes (`/api/v1/git/*`) are deferred from v1 since neither agent nor workspace has git UI in v1 — no consumer, dead code. Workspace is a **pure frontend package** — no server code. UI bridge at `/api/v1/ui/*` is also agent-hosted — workspace can't host it because workspace is frontend-only.
|
|
1537
|
+
|
|
1538
|
+
### UI bridge (agent-hosted, workspace-consumed)
|
|
1539
|
+
|
|
1540
|
+
The agent package hosts `/api/v1/ui/*` bridge endpoints — this is backend and agent owns all backend.
|
|
1541
|
+
|
|
1542
|
+
Three flows:
|
|
1543
|
+
|
|
1544
|
+
| Direction | Transport | Purpose |
|
|
1545
|
+
|---|---|---|
|
|
1546
|
+
| Agent → UI display | AI SDK UIMessage stream (chat) | Render agent messages in `<ChatPanel />` |
|
|
1547
|
+
| UI → Agent state | `PUT /api/v1/ui/state` | Workspace pushes layout state; agent tool `get_ui_state` reads |
|
|
1548
|
+
| Agent → UI commands | `POST /api/v1/ui/commands` + SSE `GET /api/v1/ui/commands/next` | Agent calls `exec_ui({ kind, params })`; workspace dispatches |
|
|
1549
|
+
|
|
1550
|
+
Agent sees 2 bridge tools: `get_ui_state` and `exec_ui({ kind, params })`. Workspace defines UI state shape + command kinds (`openFile`, `openPanel`, `showNotification`, extensible). Agent doesn't know about DockView panels; workspace doesn't know about sandbox details. Clean ownership split.
|
|
1551
|
+
|
|
1552
|
+
### Component exports — app-shell composition surface
|
|
1553
|
+
|
|
1554
|
+
**Integration pattern (updated 2026-04-24 to match new dep graph):** workspace **imports `ChatPanel` directly** from `@boring/agent` as a built-in pane. Agent stays the leaf — it has no knowledge of workspace, core, or any React layout engine. App shells can still inject an alternate `ChatPanel` via `WorkspaceProvider`'s `panels` prop to mix/match chat surfaces; the built-in is just the default. Earlier drafts said "workspace does NOT import agent" — that was v1 thinking and is superseded.
|
|
1555
|
+
|
|
1556
|
+
| Export | From agent | Consumed by | Purpose |
|
|
1557
|
+
|---|---|---|---|
|
|
1558
|
+
| `ChatPanel` | `@boring/agent` (top-level barrel) | **App shell** — passed as `panels[{id:'agent', component: ChatPanel, …}]` | The single chat component. Full-height, pane-friendly. Workspace renders it via its panel registry without knowing its internals. |
|
|
1559
|
+
| `useSessions()` | `@boring/agent` (or `@boring/agent/front`) | **App shell or Tier-3 headless consumers** | Powers custom session UI (sidebar, picker). Workspace v1 ships no session UI beyond SessionToolbar — apps that want richer session UX wire `useSessions()` in their own components. |
|
|
1560
|
+
| `useAgentChat()` | `@boring/agent` (top-level barrel) | **Tier-3 headless consumers** (rare) | For building custom chat shells; not used by workspace package or standard app shells. |
|
|
1561
|
+
|
|
1562
|
+
**Example app-shell wiring (what `examples/with-custom-tool/` demonstrates):**
|
|
1563
|
+
```tsx
|
|
1564
|
+
// App code — imports from BOTH packages:
|
|
1565
|
+
import { WorkspaceProvider, IdeLayout } from '@boring/workspace'
|
|
1566
|
+
import { ChatPanel } from '@boring/agent'
|
|
1567
|
+
|
|
1568
|
+
<WorkspaceProvider panels={[{ id: 'agent', component: ChatPanel, placement: 'right' }]}>
|
|
1569
|
+
<IdeLayout />
|
|
1570
|
+
</WorkspaceProvider>
|
|
1571
|
+
```
|
|
1572
|
+
|
|
1573
|
+
**Updated 2026-04-24:** the "workspace does not import agent directly" rule was superseded by the new dep chain (`agent (leaf) ← workspace ← core`). Workspace now imports `ChatPanel` directly as a built-in pane; the `panels` prop is kept as an override mechanism, not the primary wiring. Bundle-size concern is addressed by lazy-loading ChatPanel inside the workspace pane registry (dynamic import, only loaded when the agent pane is actually mounted). Agent remains the leaf — it imports nothing from workspace or core. Canonical graph: `packages/core/docs/CORE.md` §Dependency position.
|
|
1574
|
+
|
|
1575
|
+
### Session CRUD contract
|
|
1576
|
+
|
|
1577
|
+
Workspace plan expects `{ sessions, activeSessionId, switchSession, createSession, deleteSession }`. Agent v1 ships all five (delete added as +20 LOC; rename deferred — workspace plan accepts this):
|
|
1578
|
+
|
|
1579
|
+
```ts
|
|
1580
|
+
const { sessions, activeSession, create, switch: switchTo, delete: remove } = useSessions()
|
|
1581
|
+
```
|
|
1582
|
+
|
|
1583
|
+
### Cross-plan alignment status
|
|
1584
|
+
|
|
1585
|
+
As of the 2026-04-22 alignment pass, the workspace plan (`/home/ubuntu/projects/boring-ui-v2/packages/workspace/docs/plans/archive/WORKSPACE_V2_PLAN.md`) has been updated to match this spec. Specific edits applied to the workspace plan:
|
|
1586
|
+
|
|
1587
|
+
- Backend ownership: workspace-server-owns claims removed; server-side ownership table rewritten to show agent owns all routes; workspace is frontend-only.
|
|
1588
|
+
- File-tree endpoints: `/api/v1/files/list` → `/api/v1/tree`; added concrete `/api/v1/files/search` route (filename glob via `find`) matching workspace's search-input need.
|
|
1589
|
+
- UI bridge: transport expectations updated to match agent's SSE-based design (`GET /api/v1/ui/commands/next` for agent→workspace commands, `PUT /api/v1/ui/state` for workspace→agent state). No WebSocket.
|
|
1590
|
+
- Component names: `AgentPane` → `ChatPanel` across imports and examples.
|
|
1591
|
+
- Repo path: `v2/packages/*` → `boring-ui-v2/packages/*`.
|
|
1592
|
+
- Dynamic-pane resolved-questions marked `DEFERRED` (workspace plan's own decision table already flagged them out-of-scope for v2).
|
|
1593
|
+
- Data-layer decision wording clarified: workspace ships a thin `DataProvider` context (React Query + typed fetch) — not an abstraction for swappable backends. Resolves the self-contradiction in its own decisions table.
|
|
1594
|
+
|
|
1595
|
+
Remaining workspace-plan tasks not covered by this alignment pass (author discretion):
|
|
1596
|
+
|
|
1597
|
+
- Remove stale mention of workspace adding git ops via `simple-git` (superseded by agent's `sandbox.exec` approach).
|
|
1598
|
+
- If any example code still shows workspace-hosted server endpoints, trim those too.
|
|
1599
|
+
|
|
1600
|
+
---
|
|
1601
|
+
|
|
1602
|
+
## Review decisions
|
|
1603
|
+
|
|
1604
|
+
Two external reviews shaped this spec. Outcomes recorded here for traceability.
|
|
1605
|
+
|
|
1606
|
+
### External architecture review (Codex, 2026-04-22) — 12 findings
|
|
1607
|
+
|
|
1608
|
+
**Adopted (4):**
|
|
1609
|
+
|
|
1610
|
+
| # | Finding | Integration |
|
|
1611
|
+
|---|---|---|
|
|
1612
|
+
| 1 | Workspace CRUD inconsistency | Removed `/api/v1/agent/workspaces` from HTTP surface; session routes trimmed. |
|
|
1613
|
+
| 2 | Exec safety caps | `ExecOptions { timeoutMs, maxOutputBytes }`; `ExecResult` adds `durationMs`, `truncated`, Uint8Array-typed stdout/stderr. |
|
|
1614
|
+
| 6 (partial) | Vercel API resilience | Circuit breaker wrapping the Vercel SDK client. Skipped the lease/heartbeat/reconciler machinery (cloud-package territory). |
|
|
1615
|
+
| 11 | Export surface consistency | Decision #15 rewritten. `SessionToolbar` instead of `SessionList`; no rename/delete dialogs; no `useRegisterTool` hook. |
|
|
1616
|
+
|
|
1617
|
+
**Deferred/rejected (8)** — per the "ship fast, accept known risk" posture:
|
|
1618
|
+
|
|
1619
|
+
| # | Finding | Why deferred |
|
|
1620
|
+
|---|---|---|
|
|
1621
|
+
| 3 | Optimistic write concurrency | Accepted risk; silent last-write-wins on multi-tab. |
|
|
1622
|
+
| 4 | `CanonicalSessionStore` (SQLite) | Stay on pi JSONL. Drift risk accepted. |
|
|
1623
|
+
| 5 | Performance budgets + benchmarks | No numbers until users complain. |
|
|
1624
|
+
| 6 (majority) | Lease/heartbeat/reconciler | Cloud-package territory. |
|
|
1625
|
+
| 7 | Plugin name reservation + namespacing | Last-registered wins. |
|
|
1626
|
+
| 8 | Control plane (policy/audit/telemetry) | Retrofit when operationally needed. |
|
|
1627
|
+
| 9 | `ChangeReviewDrawer` (review-before-apply) | Workspace-package feature. |
|
|
1628
|
+
| 10 | M0.5 invariant-tests milestone | Invariants emerge via normal review. |
|
|
1629
|
+
|
|
1630
|
+
### Product/UX review (internal, 2026-04-22) — 12 findings
|
|
1631
|
+
|
|
1632
|
+
Focused on product-shaped gaps codex didn't cover. **All 11 feasible adopts integrated** into the spec sections above:
|
|
1633
|
+
|
|
1634
|
+
| # | Finding | Where integrated |
|
|
1635
|
+
|---|---|---|
|
|
1636
|
+
| 1 | Stream resumption in v1 (not deferred) | Session management section + M3 milestone |
|
|
1637
|
+
| 2 | Uint8Array-typed exec output (binary-safe) | `ExecResult` interface |
|
|
1638
|
+
| 3 | Slash commands (`/clear`, `/reset`, `/model`, `/help`, `/cost`) | New "Slash commands" section + M3 milestone |
|
|
1639
|
+
| 4 | CLI SSH/headless detection for browser-open | CLI startup sequence |
|
|
1640
|
+
| 5 | Auto-gitignore for workspace artifacts | CLI startup sequence |
|
|
1641
|
+
| 6 | Heartbeat events during long tool calls | Stream adapter table + M3 milestone |
|
|
1642
|
+
| 7 | Vercel snapshot retention (keep-last-2) | Sandbox lifecycle + env vars + M2 milestone |
|
|
1643
|
+
| 8 | CSS-var ↔ Tailwind contract worked example | Styling contract section |
|
|
1644
|
+
| 10 | CLI `--logout` / `--reset-key` flags | CLI flags table |
|
|
1645
|
+
| 11 | Windows-native support statement (WSL2 only) | Direct mode section |
|
|
1646
|
+
| 12 | Test strategy statement | New "Test strategy" section |
|
|
1647
|
+
|
|
1648
|
+
**Considered and rejected (1):**
|
|
1649
|
+
|
|
1650
|
+
| # | Finding | Why rejected |
|
|
1651
|
+
|---|---|---|
|
|
1652
|
+
| 9 | `/api/v1/agent/sessions/:id/changes` endpoint | Still integrated — was upgraded to adopt once scope was reviewed. (Kept for UX parity with Claude Code change-summary pattern.) |
|
|
1653
|
+
|
|
1654
|
+
All told: **11 of 12 product-UX findings adopted**; the two reviews complement each other with no conflicts. Net LOC impact: ~300 over the pre-review baseline (now ≈ 2,800).
|
|
1655
|
+
|
|
1656
|
+
---
|
|
1657
|
+
|
|
1658
|
+
## Migration gaps from current boring-ui
|
|
1659
|
+
|
|
1660
|
+
Existing apps in `/home/ubuntu/projects/boring-ui/apps/*` use features that v2 agent **does not support**. When migrating an app to v2:
|
|
1661
|
+
|
|
1662
|
+
| Current feature | v2 agent status | Migration path |
|
|
1663
|
+
|---|---|---|
|
|
1664
|
+
| `workspace.backend = "direct"` | ✅ Supported via `mode: "direct"` | Set `BORING_AGENT_MODE=direct` — no bwrap required. |
|
|
1665
|
+
| `workspace.backend = "bwrap"` | ✅ Supported via `mode: "local"` | Set `BORING_AGENT_MODE=local`. |
|
|
1666
|
+
| `workspace.backend = "lightningfs"` | ❌ Not in v2 (browser agent deferred) | App needs server-mode or await browser-agent harness. |
|
|
1667
|
+
| `workspace.backend = "justbash"` | ❌ Not in v2 (experimental, dropped) | Migrate to `direct` or `local`. |
|
|
1668
|
+
| `agent.placement = "browser"` | ❌ Not in v2 (design seam only) | Apps using browser pi must move to `placement: "server"`. |
|
|
1669
|
+
| `agent.runtime = "pi"` (pi-agent-core) | ⚠️ Migrated to pi-coding-agent | Mostly compatible — adapt any custom tool wiring to new harness API. |
|
|
1670
|
+
| `agent.runtime = "ai-sdk"` | ❌ Not in v2 (pi-coding-agent only) | boring-macro and agent-backend need rewrites on this path; defer migration until v2 is proven or add AiSdkHarness later. |
|
|
1671
|
+
| DockView IDE layout | ❌ Not in agent v2 (workspace package territory) | Build layouts in `@boring/workspace`. Agent ships panels, workspace arranges. |
|
|
1672
|
+
| Chat-centered layout (NavRail, BrowseDrawer, SurfaceDockview) | ❌ Not in agent v2 | Same — workspace package. |
|
|
1673
|
+
| Multi-workspace runtime (POST /workspaces, provisioning state machine) | ❌ Not in agent v2 | Future `@boring/cloud` package. Agent v2 takes a single workspaceId at boot. |
|
|
1674
|
+
| GitHub Connect + git UI panels | ❌ Not in agent v2 | Git ops via bash tool (`git status`, `git commit`); UI comes from workspace package. |
|
|
1675
|
+
| Settings management / API key UI | ❌ Not in agent v2 | env + Vault; workspace package can add settings UI on top if needed. |
|
|
1676
|
+
| Image uploads in chat | ⚠️ Not in v1 scope | Punt to workspace package or v1.x. ai-elements has an `Attachments` component — adapt when we need this. |
|
|
1677
|
+
| Artifact viewer (side panel) | ❌ Not in agent v2 | Workspace package. ai-elements has `Artifact` component. |
|
|
1678
|
+
| Model selector in session toolbar | ⚠️ Moved to composer | Retain as feature, relocate UX. |
|
|
1679
|
+
| Thinking level selector | ⚠️ Moved to composer | Same. |
|
|
1680
|
+
| Session delete | ✅ Ships in v1 | `DELETE /api/v1/agent/sessions/:id` + `SessionStore.delete`. |
|
|
1681
|
+
| Session rename | ❌ Not in v1 | Deferred — workspace package likely. |
|
|
1682
|
+
| Fly machine provisioning | ❌ Not in agent v2 | `@boring/cloud`. |
|
|
1683
|
+
|
|
1684
|
+
**Concrete migration advice for each current app:**
|
|
1685
|
+
|
|
1686
|
+
| App | Current mode | v2 migration |
|
|
1687
|
+
|---|---|---|
|
|
1688
|
+
| **apps/ide** (bwrap + pi browser) | Move pi to server; keep bwrap. Layout stays in workspace package. | |
|
|
1689
|
+
| **apps/chat** (direct + pi browser) | Move pi to server; use `direct` mode. Chat layout in workspace package. | |
|
|
1690
|
+
| **apps/custom-layout** (direct + pi browser) | Same as above. | |
|
|
1691
|
+
| **apps/minimal** (direct) | Use v2 standalone app + `mode: "direct"`. | |
|
|
1692
|
+
| **apps/agent-backend** (bwrap + ai-sdk server) | Migrate ai-sdk → pi-coding-agent. Otherwise compatible. | |
|
|
1693
|
+
| **apps/agent-frontend** (lightningfs + pi browser) | Blocked until browser-agent harness ships. | |
|
|
1694
|
+
| **boring-macro** (bwrap + ai-sdk server) | Migrate ai-sdk → pi-coding-agent. Template + system prompt transfer. | |
|
|
1695
|
+
|
|
1696
|
+
---
|
|
1697
|
+
|
|
1698
|
+
## Reference files from the old boring-ui (port / adapt, don't re-research)
|
|
1699
|
+
|
|
1700
|
+
Everything in this section is a **concrete starting point**. When you see a task in the roadmap, it maps to one or more of these files. Port and adapt — no blind research.
|
|
1701
|
+
|
|
1702
|
+
All paths are relative to `/home/ubuntu/projects/boring-ui/` (the existing monorepo).
|
|
1703
|
+
|
|
1704
|
+
### Path helpers & workspace resolution (M1 — NodeWorkspace)
|
|
1705
|
+
|
|
1706
|
+
| Port to | From | Notes |
|
|
1707
|
+
|---|---|---|
|
|
1708
|
+
| `src/server/workspace/paths.ts` | `packages/workspace/src/server/workspace/paths.ts` (~80 LOC) | `validatePath`, `assertRealPathWithinWorkspace`, `ensureExistingWorkspacePath`, `ensureWritableWorkspacePath`. Copy verbatim; tested and battle-hardened. |
|
|
1709
|
+
| `src/server/workspace/resolver.ts` (partial) | `packages/workspace/src/server/workspace/resolver.ts` | Take the `resolveWorkspacePath(workspaceRoot, workspaceId)` function. Drop `BACKEND_CAPABILITIES` (we use our own capabilities model). |
|
|
1710
|
+
| `src/server/workspace/node/createNodeWorkspace.ts` | New — compose using the ported `paths.ts` helpers | `readFile/writeFile/readdir/stat` via `node:fs/promises` wrapped by `validatePath` on every call. |
|
|
1711
|
+
|
|
1712
|
+
### Bwrap adapter (M1 — BwrapSandbox)
|
|
1713
|
+
|
|
1714
|
+
| Port to | From | Notes |
|
|
1715
|
+
|---|---|---|
|
|
1716
|
+
| `src/server/sandbox/bwrap/exec.ts` | `packages/workspace/src/server/adapters/bwrap.ts` | `buildBwrapArgs()` function (lines ~22–58). Flag order matters (security-critical). Adapt `BWRAP_TIMEOUT_SECONDS`, `KILL_GRACE_SECONDS`, `RO_BIND_DIRS`. |
|
|
1717
|
+
| `src/server/sandbox/bwrap/createBwrapSandbox.ts` | New — wraps `buildBwrapArgs` + `child_process.spawn` + our `Sandbox` interface | Takes `Workspace` in `init()`; `sandbox.exec(cmd)` spawns `bwrap ...args -- bash -c cmd`. |
|
|
1718
|
+
| Existing test shape | `packages/workspace/src/server/__tests__/execJob.test.ts` | Reference pattern for testing bwrap spawns (mocking `child_process.spawn`). |
|
|
1719
|
+
|
|
1720
|
+
### HTTP routes (M1 — unified /api/v1/files, /api/v1/tree)
|
|
1721
|
+
|
|
1722
|
+
| Port to | From | Notes |
|
|
1723
|
+
|---|---|---|
|
|
1724
|
+
| `src/server/routes/file.ts` | `packages/workspace/src/server/http/fileRoutes.ts` (~200 LOC) | GET/POST/DELETE file, GET stat. Adapt to use our `Workspace` interface instead of raw fs. Keep auth + validation. |
|
|
1725
|
+
| `src/server/routes/tree.ts` | `packages/workspace/src/server/http/fileRoutes.ts` (tree endpoint section) | Lazy directory listing. Adapt to `workspace.readdir()`. |
|
|
1726
|
+
| `src/server/routes/workspaces.ts` | `src/server/services/workspaces.ts` + `src/server/http/workspaceRoutes.ts` | CRUD for workspaces. Drop multi-tenancy details; v1 is single-tenant-per-container. Keep the `provisioning_step` state shape for remote mode. |
|
|
1727
|
+
|
|
1728
|
+
### Pi harness & stream adapter (M1 — PiCodingAgentHarness)
|
|
1729
|
+
|
|
1730
|
+
| Port to | From | Notes |
|
|
1731
|
+
|---|---|---|
|
|
1732
|
+
| `src/server/harness/pi-coding-agent/createHarness.ts` | `packages/agent/src/server/harnesses/pi/runtime.ts` | **IMPORTANT:** old code uses `pi-agent-core`. Our plan uses `pi-coding-agent` (wraps pi-agent-core + plugins + sessions). Adapt the model registry + auth init; replace `new Agent(...)` with `createAgentSession({ tools })`. |
|
|
1733
|
+
| `src/server/harness/pi-coding-agent/stream-adapter.ts` | `packages/agent/src/server/harnesses/pi/canonicalStream.ts` | Event-emission pattern (sendSseLegacy/sendCanonicalChunk). Rewrite to emit AI SDK `UIMessageChunk` shapes via `createUIMessageStream` instead of SSE directly. |
|
|
1734
|
+
| `src/server/harness/pi-coding-agent/tools.ts` | `packages/agent/src/server/harnesses/pi/tools-impl.mjs` | Existing tool impls to study for bash/read/write patterns. Our factories will be similar but bound to our `Workspace` + `Sandbox` interfaces. |
|
|
1735
|
+
| Session-context pattern | `packages/agent/src/server/harnesses/pi/sessionContext.ts` | How workspaceId + userId flow into agent. Our pattern is the same. |
|
|
1736
|
+
|
|
1737
|
+
### Frontend — chat UI (M3 — polish milestone)
|
|
1738
|
+
|
|
1739
|
+
| Port to | From | Notes |
|
|
1740
|
+
|---|---|---|
|
|
1741
|
+
| `src/front/ChatPanel.tsx` | `packages/agent/src/front/components/chat/AiChat.jsx` + `ChatMessage.jsx` | Reference structure only. v2 is `useChat`-based with ai-elements — will look different. Use the old file to understand the event flow. |
|
|
1742
|
+
| `src/front/primitives/Message.tsx` | `packages/agent/src/front/components/chat/ChatMessage.jsx` | Reference for role-based rendering (user/assistant/tool). ai-elements' `Message` is the canonical base; port any boring-specific tweaks. |
|
|
1743
|
+
| `src/front/primitives/Composer.tsx` | adapt ai-elements `PromptInput` | No direct port; ai-elements provides the base. |
|
|
1744
|
+
| Tool renderers (optional) | `packages/agent/src/front/components/chat/{Bash,Edit,Read,Write,Grep,Glob}ToolRenderer.jsx` | **If** ai-elements' generic `Tool` + `Terminal` + `CodeBlock` aren't enough, these are ready-to-adapt. Most likely only EditToolRenderer is worth porting (diff view). |
|
|
1745
|
+
|
|
1746
|
+
### Frontend — data provider pattern (M1 — useChat wiring)
|
|
1747
|
+
|
|
1748
|
+
| Port to | From | Notes |
|
|
1749
|
+
|---|---|---|
|
|
1750
|
+
| `src/front/hooks/useAgentChat.ts` | `packages/agent/src/front/providers/pi/useChatTransport.js` | The old `useChatTransport` is a custom transport adapter. v2 uses AI SDK's `useChat` natively — much simpler. Use old file to understand what state the chat view needs. |
|
|
1751
|
+
| Data provider concept | `packages/workspace/src/front/providers/data/httpProvider.js` | Pattern for typed HTTP calls against our routes. v2 uses TanStack Query directly on AI SDK-shaped endpoints. |
|
|
1752
|
+
|
|
1753
|
+
### Frontend — file tree (workspace package, reference only)
|
|
1754
|
+
|
|
1755
|
+
| Reference | File | Notes |
|
|
1756
|
+
|---|---|---|
|
|
1757
|
+
| Lazy-load pattern | `packages/workspace/src/front/components/FileTree.jsx` | Not ported here — lives in `@boring/workspace` in v2. But shows the HTTP-endpoint contract the agent package must serve (route shapes, query params, error codes). |
|
|
1758
|
+
|
|
1759
|
+
### boring.app.toml precedent
|
|
1760
|
+
|
|
1761
|
+
`/home/ubuntu/projects/boring-macro/boring.app.toml` is a complete example of a child app using boring-ui. Reference for:
|
|
1762
|
+
- `[workspace]` + `[agent]` + `[backend]` + `[frontend]` section shapes
|
|
1763
|
+
- `[deploy.secrets]` + `[deploy.env_vars]` Vault integration pattern
|
|
1764
|
+
- Agent `system_prompt` convention
|
|
1765
|
+
|
|
1766
|
+
### What NOT to port
|
|
1767
|
+
|
|
1768
|
+
- `uiStateRoutes.ts` — agent-UI-bridge infrastructure from v1; not in scope for agent v2 (workspace package will handle layout state later).
|
|
1769
|
+
- `workspaceBoundary.ts` — multi-app routing; out of scope for v2 agent (single-app per container).
|
|
1770
|
+
- Existing `pi-agent-core` wiring — we migrate to `pi-coding-agent` instead (different API, different session model).
|
|
1771
|
+
- Old `execRoutes.ts` — wrapped legacy exec; our `Sandbox` adapter replaces it.
|
|
1772
|
+
- `lightningFs` / `justBash` / `isomorphicGit` provider — browser-fs experiments; not in v2 scope.
|
|
1773
|
+
|
|
1774
|
+
### Templates & example integration
|
|
1775
|
+
|
|
1776
|
+
- `packages/agent/package.json` (old) — for reference exports structure.
|
|
1777
|
+
- `/home/ubuntu/projects/boring-macro/deploy/fly/Dockerfile` — shows how `bubblewrap` is installed in a PaaS deploy context. Copy the apt-get line.
|