@hachej/boring-agent 0.1.12 → 0.1.14
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/README.md +242 -42
- package/dist/DebugDrawer-MCYZ4AWZ.js +6 -0
- package/dist/chunk-F3CE5CNW.js +38 -0
- package/dist/chunk-MMJA3QON.js +295 -0
- package/dist/front/index.d.ts +48 -76
- package/dist/front/index.js +2396 -1795
- package/dist/front/styles.css +2242 -1267
- package/dist/{sandbox-handle-store-DCNEJJ6b.d.ts → sandbox-handle-store-hK76cTjn.d.ts} +2 -0
- package/dist/server/index.d.ts +35 -3
- package/dist/server/index.js +2079 -1577
- package/dist/shared/index.d.ts +11 -7
- package/dist/shared/index.js +10 -1
- package/dist/{harness-DT3ZzdAN.d.ts → tool-ui-DSmWuqGe.d.ts} +33 -4
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,82 +1,282 @@
|
|
|
1
|
-
# @boring
|
|
1
|
+
# @hachej/boring-agent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://www.npmjs.com/package/@hachej/boring-agent)
|
|
7
|
+
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
A pane-embeddable coding agent with three execution modes behind one interface. Ships as a standalone CLI (`npx @hachej/boring-agent`) and composes into any app shell.
|
|
4
11
|
|
|
5
12
|
```bash
|
|
6
|
-
|
|
13
|
+
npx @hachej/boring-agent
|
|
7
14
|
```
|
|
8
15
|
|
|
9
16
|
---
|
|
10
17
|
|
|
11
|
-
##
|
|
18
|
+
## TL;DR
|
|
19
|
+
|
|
20
|
+
**The Problem**: You want a coding agent in the browser — but also as a CLI — and you need it to run your code somewhere safe (or not). Existing solutions force you into one deployment model and one UI.
|
|
21
|
+
|
|
22
|
+
**The Solution**: One package that ships a full LLM agent loop, tool catalog, and chat UI — with swappable execution backends. Same agent, same tools, same UI. Three modes. Zero config to start.
|
|
23
|
+
|
|
24
|
+
### Why Use @hachej/boring-agent?
|
|
25
|
+
|
|
26
|
+
| Feature | What It Does |
|
|
27
|
+
|---------|--------------|
|
|
28
|
+
| **Three execution modes** | `direct` (no isolation, macOS dev) / `local` (bwrap sandbox) / `vercel-sandbox` (Firecracker microVM) |
|
|
29
|
+
| **CLI + embeddable** | `npx @hachej/boring-agent` works standalone; `<ChatPanel />` composes into any layout |
|
|
30
|
+
| **7 standard tools** | `bash`, `read`, `write`, `edit`, `find`, `grep`, `ls` — ported from pi-coding-agent |
|
|
31
|
+
| **Workspace-agnostic FS** | `Workspace` interface — agent tools and HTTP routes share the same filesystem view |
|
|
32
|
+
| **Session management** | List, create, switch, delete sessions with streamed history hydration |
|
|
33
|
+
| **UI bridge** | Agent opens files, panels, and surfaces in the workbench via typed commands |
|
|
34
|
+
| **Model picker + thinking toggle** | Inline in the composer — switch models and reasoning depth per message |
|
|
35
|
+
|
|
36
|
+
### Quick Example
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Start the agent in your current directory — zero setup
|
|
40
|
+
npx @hachej/boring-agent
|
|
41
|
+
|
|
42
|
+
# Or run with a specific workspace root
|
|
43
|
+
BORING_AGENT_WORKSPACE_ROOT=/path/to/project npx @hachej/boring-agent
|
|
44
|
+
|
|
45
|
+
# Set the API key
|
|
46
|
+
ANTHROPIC_API_KEY=sk-ant-... npx @hachej/boring-agent
|
|
47
|
+
|
|
48
|
+
# Run in local sandbox mode (Linux + bubblewrap)
|
|
49
|
+
BORING_AGENT_MODE=local npx @hachej/boring-agent
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
In the browser chat, try:
|
|
53
|
+
```
|
|
54
|
+
read the README and summarize it
|
|
55
|
+
find all TypeScript files that import "react"
|
|
56
|
+
write a test for src/utils.ts
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Architecture
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
┌─────────────────────────────────────┐
|
|
65
|
+
│ Chat UI (browser) │
|
|
66
|
+
│ Composer · Messages · SessionBar │
|
|
67
|
+
└──────────────────┬──────────────────┘
|
|
68
|
+
│ UIMessage stream (SSE)
|
|
69
|
+
┌──────────────────▼──────────────────┐
|
|
70
|
+
│ Agent Harness │
|
|
71
|
+
│ (pi-coding-agent loop) │
|
|
72
|
+
└──────────────────┬──────────────────┘
|
|
73
|
+
│ AgentTool[]
|
|
74
|
+
┌──────────────────▼──────────────────┐
|
|
75
|
+
│ Tool Catalog │
|
|
76
|
+
│ bash · read · write · edit │
|
|
77
|
+
│ find · grep · ls │
|
|
78
|
+
└──────┬──────────────┬───────────────┘
|
|
79
|
+
│ │
|
|
80
|
+
┌──────▼─────┐ ┌──────▼─────┐
|
|
81
|
+
│ Workspace │ │ Sandbox │
|
|
82
|
+
│ (fs ops) │ │ (exec) │
|
|
83
|
+
│ read/write │ │ bwrap │
|
|
84
|
+
│ readdir │ │ vercel │
|
|
85
|
+
└────────────┘ └────────────┘
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Two layers, clear boundary:**
|
|
89
|
+
|
|
90
|
+
- **Layer 1 (Core runtime):** `AgentHarness` · `Catalog` · `Workspace` · `Sandbox` — interfaces locked; adapters swap per mode.
|
|
91
|
+
- **Layer 2 (Integration):** `SessionStore` · `UiBridge` · `Provisioning` — replaceable plumbing, independent evolution.
|
|
92
|
+
|
|
93
|
+
### Execution Modes
|
|
94
|
+
|
|
95
|
+
| Mode | Workspace | Sandbox | Isolation | Use Case |
|
|
96
|
+
|------|-----------|---------|-----------|----------|
|
|
97
|
+
| `direct` | `NodeWorkspace` | `DirectSandbox` | None | macOS/Windows dev, quick tests |
|
|
98
|
+
| `local` | `NodeWorkspace` | `BwrapSandbox` | bwrap process jail | Linux deployments, safer default |
|
|
99
|
+
| `vercel-sandbox` | `VercelSandboxWorkspace` | `VercelSandboxExec` | Firecracker microVM | Multi-tenant, remote execution |
|
|
12
100
|
|
|
13
|
-
|
|
14
|
-
- **Tool catalog** — `bash`, `read`, `write`, `edit`, `grep`, `find`, `ls` and more
|
|
15
|
-
- **Chat UI** — embeddable `ChatPanel` React component
|
|
101
|
+
**Pairing invariant:** Workspace + Sandbox must target the same filesystem substrate. The adapter factory enforces this at construction — mismatched pairs are impossible.
|
|
16
102
|
|
|
17
103
|
---
|
|
18
104
|
|
|
19
|
-
##
|
|
105
|
+
## Installation
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# npm
|
|
109
|
+
npm install @hachej/boring-agent
|
|
110
|
+
|
|
111
|
+
# pnpm
|
|
112
|
+
pnpm add @hachej/boring-agent
|
|
113
|
+
|
|
114
|
+
# standalone (no install needed)
|
|
115
|
+
npx @hachej/boring-agent
|
|
116
|
+
```
|
|
20
117
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
118
|
+
### From Source
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
git clone https://github.com/hachej/boring-ui.git
|
|
122
|
+
cd boring-ui
|
|
123
|
+
pnpm install
|
|
124
|
+
pnpm --filter @hachej/boring-agent build
|
|
125
|
+
```
|
|
26
126
|
|
|
27
127
|
---
|
|
28
128
|
|
|
29
|
-
##
|
|
129
|
+
## Quick Start
|
|
130
|
+
|
|
131
|
+
### 1. As a CLI
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# Set your API key
|
|
135
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
136
|
+
|
|
137
|
+
# Run in your project directory
|
|
138
|
+
cd /path/to/project
|
|
139
|
+
npx @hachej/boring-agent
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Opens `http://localhost:5200` with a full agent workspace pointed at your cwd.
|
|
143
|
+
|
|
144
|
+
### 2. Embedded in an App
|
|
145
|
+
|
|
146
|
+
**Server:**
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { createAgentApp } from "@hachej/boring-agent/server"
|
|
150
|
+
|
|
151
|
+
const app = await createAgentApp({
|
|
152
|
+
mode: "local", // "direct" | "local" | "vercel-sandbox"
|
|
153
|
+
workspaceRoot: process.cwd(),
|
|
154
|
+
apiBaseUrl: "http://localhost:3000",
|
|
155
|
+
})
|
|
156
|
+
await app.listen({ port: 3001 })
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Frontend:**
|
|
30
160
|
|
|
31
161
|
```tsx
|
|
32
|
-
import { ChatPanel } from "@boring
|
|
33
|
-
import
|
|
162
|
+
import { ChatPanel, useAgentChat } from "@hachej/boring-agent"
|
|
163
|
+
import "@hachej/boring-agent/front/styles.css"
|
|
164
|
+
|
|
165
|
+
function App() {
|
|
166
|
+
return <ChatPanel apiBaseUrl="http://localhost:3000" />
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### 3. Composed with Workspace
|
|
34
171
|
|
|
35
|
-
|
|
172
|
+
```tsx
|
|
173
|
+
import { WorkspaceProvider, IdeLayout } from "@hachej/boring-workspace"
|
|
174
|
+
import { ChatPanel } from "@hachej/boring-agent"
|
|
175
|
+
|
|
176
|
+
function App() {
|
|
36
177
|
return (
|
|
37
|
-
<WorkspaceProvider chatPanel={ChatPanel}>
|
|
178
|
+
<WorkspaceProvider chatPanel={ChatPanel} workspaceId="proj-1">
|
|
38
179
|
<IdeLayout />
|
|
39
180
|
</WorkspaceProvider>
|
|
40
181
|
)
|
|
41
182
|
}
|
|
42
183
|
```
|
|
43
184
|
|
|
44
|
-
|
|
185
|
+
---
|
|
45
186
|
|
|
46
|
-
|
|
47
|
-
import { createAgentApp } from "@boring/agent/server"
|
|
187
|
+
## Package Surfaces
|
|
48
188
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
189
|
+
| Import | Environment | What You Get |
|
|
190
|
+
|--------|-------------|--------------|
|
|
191
|
+
| `@hachej/boring-agent` | Browser | `ChatPanel`, `SessionToolbar`, primitives, hooks, `theme.css` |
|
|
192
|
+
| `@hachej/boring-agent/server` | Node | `createAgentApp`, routes, harness, sandbox, workspace adapters |
|
|
193
|
+
| `@hachej/boring-agent/front` | Browser | Frontend-specific (same as top-level, explicit subpath) |
|
|
194
|
+
| `@hachej/boring-agent/shared` | Any | `AgentHarness`, `Workspace`, `Sandbox`, `AgentTool`, `SessionStore` interfaces |
|
|
195
|
+
| `@hachej/boring-agent/front/styles.css` | Browser | CSS custom properties for theming |
|
|
196
|
+
| `@hachej/boring-agent/eval` | Node | Evaluation toolkit for agent behavior |
|
|
52
197
|
|
|
53
198
|
---
|
|
54
199
|
|
|
55
|
-
##
|
|
200
|
+
## Configuration
|
|
56
201
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
202
|
+
### Environment Variables
|
|
203
|
+
|
|
204
|
+
| Variable | Required | Default | Description |
|
|
205
|
+
|----------|----------|---------|-------------|
|
|
206
|
+
| `ANTHROPIC_API_KEY` | Yes | — | Anthropic API key for Claude |
|
|
207
|
+
| `BORING_AGENT_MODE` | No | `direct` | `direct`, `local`, or `vercel-sandbox` |
|
|
208
|
+
| `BORING_AGENT_WORKSPACE_ROOT` | No | `.` | Root directory for workspace |
|
|
209
|
+
| `BORING_AGENT_DEFAULT_MODEL_PROVIDER` | No | `anthropic` | Default model provider |
|
|
210
|
+
| `BORING_AGENT_DEFAULT_MODEL_ID` | No | `claude-sonnet-4-6` | Default model ID |
|
|
211
|
+
| `VERCEL_OIDC_TOKEN` | Remote only | — | Required for `vercel-sandbox` mode |
|
|
212
|
+
| `PORT` | No | `5200` | Server port |
|
|
213
|
+
| `HOST` | No | `localhost` | Server host |
|
|
214
|
+
|
|
215
|
+
### Config File
|
|
216
|
+
|
|
217
|
+
`boring.app.toml` (optional, for embedded mode):
|
|
218
|
+
|
|
219
|
+
```toml
|
|
220
|
+
[runtime]
|
|
221
|
+
mode = "local" # direct | local | vercel-sandbox
|
|
222
|
+
|
|
223
|
+
[model]
|
|
224
|
+
default_provider = "anthropic"
|
|
225
|
+
default_id = "claude-sonnet-4-6"
|
|
61
226
|
```
|
|
62
227
|
|
|
63
228
|
---
|
|
64
229
|
|
|
65
|
-
##
|
|
230
|
+
## Troubleshooting
|
|
66
231
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
232
|
+
| Error | Cause | Fix |
|
|
233
|
+
|-------|-------|-----|
|
|
234
|
+
| `ANTHROPIC_API_KEY not set` | Missing API key | `export ANTHROPIC_API_KEY=sk-ant-...` |
|
|
235
|
+
| `bwrap not found` (local mode) | bubblewrap not installed | `sudo apt install bubblewrap` (Debian/Ubuntu) |
|
|
236
|
+
| `port already in use` | Port 5200 occupied | `PORT=5201 npx @hachej/boring-agent` |
|
|
237
|
+
| `workspace root not found` | Invalid `BORING_AGENT_WORKSPACE_ROOT` | Point to an existing directory |
|
|
238
|
+
| `Vercel sandbox auth failed` (remote mode) | Missing/invalid OIDC token | Set `VERCEL_OIDC_TOKEN` |
|
|
239
|
+
| `model provider not supported` | Unknown provider in config | Use `anthropic` (only supported provider in v1) |
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Limitations
|
|
244
|
+
|
|
245
|
+
- **Single model provider**: Only Anthropic (Claude) is supported in v1. The harness interface is designed to accept others, but only `anthropic` is wired.
|
|
246
|
+
- **No multi-user auth**: The agent is single-workspace-per-instance. Multi-user auth, billing, and workspace CRUD belong to `@hachej/boring-core`.
|
|
247
|
+
- **No git UI**: The agent runs git via `bash`, but there's no status bar, diff pane, or branch picker. When git UI lands, thin routes will be added.
|
|
248
|
+
- **Plugin loading is local-only**: Pi plugins load in the backend Node process. They're disabled in `vercel-sandbox` mode for security.
|
|
249
|
+
- **No browser-agent mode yet**: The `AgentHarness` interface has a `placement: "browser"` option, but no browser harness is implemented.
|
|
250
|
+
- **No MCP tool integration**: Not in scope for v1.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## FAQ
|
|
255
|
+
|
|
256
|
+
**Q: What's the difference between `direct` and `local` mode?**
|
|
257
|
+
A: `direct` runs bash commands with no sandbox — the agent has full access to your machine. `local` wraps commands in bubblewrap (`bwrap`), which provides filesystem and process isolation on Linux. Use `direct` for macOS dev; use `local` on Linux servers.
|
|
258
|
+
|
|
259
|
+
**Q: Can I use OpenAI or other model providers?**
|
|
260
|
+
A: Not in v1. Only Anthropic's Claude is wired up. The harness interface is provider-agnostic — community PRs for other providers are welcome.
|
|
261
|
+
|
|
262
|
+
**Q: How do sessions persist?**
|
|
263
|
+
A: Via pi-coding-agent's JSONL session files under `${workdir}/.pi/sessions/`. The `PiSessionStore` reads and manages lifecycle. SQLite and IndexedDB implementations are planned.
|
|
264
|
+
|
|
265
|
+
**Q: Can I add custom tools to the agent?**
|
|
266
|
+
A: Yes. Use the `CatalogDeps` pattern to build tools that bind to `Workspace` and `Sandbox`. For pi-native tools, register them via pi's extension system (`pi.extensions` in config). In `vercel-sandbox` mode, extensions are disabled.
|
|
267
|
+
|
|
268
|
+
**Q: What's the UI bridge for?**
|
|
269
|
+
A: It lets the agent programmatically open files, panels, and surfaces in the workbench. The agent calls `exec_ui({ kind: "openFile", params: { path: "src/index.ts" } })` and the panel opens. It's a typed pubsub bus between backend and frontend.
|
|
270
|
+
|
|
271
|
+
**Q: How does stream resumption work?**
|
|
272
|
+
A: The server wraps the harness's event stream in a per-turn ring buffer. Disconnected clients reconnect via `GET /api/v1/agent/chat/:sessionId/:turnId?cursor=<n>`. If the turn completed, it replays from `SessionStore`.
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
*About Contributions:* Please don't take this the wrong way, but I do not accept outside contributions for any of my projects. I simply don't have the mental bandwidth to review anything, and it's my name on the thing, so I'm responsible for any problems it causes; thus, the risk-reward is highly asymmetric from my perspective. I'd also have to worry about other "stakeholders," which seems unwise for tools I mostly make for myself for free. Feel free to submit issues, and even PRs if you want to illustrate a proposed fix, but know I won't merge them directly. Instead, I'll have Claude or Codex review submissions via `gh` and independently decide whether and how to address them. Bug reports in particular are welcome. Sorry if this offends, but I want to avoid wasted time and hurt feelings. I understand this isn't in sync with the prevailing open-source ethos that seeks community contributions, but it's the only way I can move at this velocity and keep my sanity.
|
|
73
277
|
|
|
74
278
|
---
|
|
75
279
|
|
|
76
|
-
##
|
|
280
|
+
## License
|
|
77
281
|
|
|
78
|
-
|
|
79
|
-
|---|---|
|
|
80
|
-
| `@boring/core` | DB, auth, app factory |
|
|
81
|
-
| `@boring/workspace` | Plugin system, layouts |
|
|
82
|
-
| `@boring/agent` | Agent runtime + tools |
|
|
282
|
+
MIT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// src/shared/tool-ui.ts
|
|
2
|
+
function isRecord(value) {
|
|
3
|
+
return typeof value === "object" && value !== null;
|
|
4
|
+
}
|
|
5
|
+
function isToolUiMetadata(value) {
|
|
6
|
+
if (!isRecord(value)) return false;
|
|
7
|
+
return (value.rendererId === void 0 || typeof value.rendererId === "string") && (value.displayGroup === void 0 || typeof value.displayGroup === "string") && (value.icon === void 0 || typeof value.icon === "string");
|
|
8
|
+
}
|
|
9
|
+
function extractToolUiMetadata(output) {
|
|
10
|
+
if (!isRecord(output)) return void 0;
|
|
11
|
+
const details = output.details;
|
|
12
|
+
if (!isRecord(details)) return void 0;
|
|
13
|
+
if (isToolUiMetadata(details.ui)) return details.ui;
|
|
14
|
+
if (typeof details.uiKind === "string") {
|
|
15
|
+
return {
|
|
16
|
+
rendererId: details.uiKind,
|
|
17
|
+
details
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
return void 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/shared/capabilities.ts
|
|
24
|
+
var DEFAULT_AGENT_RUNTIME_CAPABILITIES = {
|
|
25
|
+
nativeFollowUp: false,
|
|
26
|
+
aiSdkOwnsHistory: true
|
|
27
|
+
};
|
|
28
|
+
var PI_AGENT_RUNTIME_CAPABILITIES = {
|
|
29
|
+
nativeFollowUp: true,
|
|
30
|
+
aiSdkOwnsHistory: false
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
isToolUiMetadata,
|
|
35
|
+
extractToolUiMetadata,
|
|
36
|
+
DEFAULT_AGENT_RUNTIME_CAPABILITIES,
|
|
37
|
+
PI_AGENT_RUNTIME_CAPABILITIES
|
|
38
|
+
};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// src/front/DebugDrawer.tsx
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
// src/front/lib/index.ts
|
|
5
|
+
import { clsx } from "clsx";
|
|
6
|
+
import { twMerge } from "tailwind-merge";
|
|
7
|
+
function cn(...inputs) {
|
|
8
|
+
return twMerge(clsx(inputs));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/front/DebugDrawer.tsx
|
|
12
|
+
import { Button, IconButton, Tabs, TabsContent, TabsList, TabsTrigger } from "@hachej/boring-ui-kit";
|
|
13
|
+
import {
|
|
14
|
+
CheckIcon,
|
|
15
|
+
ChevronDownIcon,
|
|
16
|
+
ChevronRightIcon,
|
|
17
|
+
CopyIcon,
|
|
18
|
+
RefreshCwIcon
|
|
19
|
+
} from "lucide-react";
|
|
20
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
21
|
+
function CopyButton({ value, label }) {
|
|
22
|
+
const [copied, setCopied] = useState(false);
|
|
23
|
+
const onCopy = useCallback(() => {
|
|
24
|
+
const writeText = navigator.clipboard?.writeText?.bind(navigator.clipboard);
|
|
25
|
+
if (!writeText) return;
|
|
26
|
+
void writeText(value).then(() => {
|
|
27
|
+
setCopied(true);
|
|
28
|
+
window.setTimeout(() => setCopied(false), 1200);
|
|
29
|
+
});
|
|
30
|
+
}, [value]);
|
|
31
|
+
return /* @__PURE__ */ jsx(
|
|
32
|
+
IconButton,
|
|
33
|
+
{
|
|
34
|
+
type: "button",
|
|
35
|
+
variant: "ghost",
|
|
36
|
+
size: "icon-xs",
|
|
37
|
+
onClick: onCopy,
|
|
38
|
+
className: "text-muted-foreground/60",
|
|
39
|
+
"aria-label": label,
|
|
40
|
+
title: label,
|
|
41
|
+
children: copied ? /* @__PURE__ */ jsx(CheckIcon, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(CopyIcon, { className: "h-3 w-3" })
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
function DebugValue({ label, value }) {
|
|
46
|
+
return /* @__PURE__ */ jsxs("div", { className: "rounded-md border border-border/40 bg-muted/20 p-2", children: [
|
|
47
|
+
/* @__PURE__ */ jsxs("div", { className: "mb-1 flex items-center justify-between gap-2", children: [
|
|
48
|
+
/* @__PURE__ */ jsx("span", { className: "text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/80", children: label }),
|
|
49
|
+
/* @__PURE__ */ jsx(CopyButton, { value, label: `Copy ${label}` })
|
|
50
|
+
] }),
|
|
51
|
+
/* @__PURE__ */ jsx("code", { className: "block break-all font-mono text-[11px] leading-relaxed text-foreground dark:text-zinc-100", children: value })
|
|
52
|
+
] });
|
|
53
|
+
}
|
|
54
|
+
function SessionTab({ sessionId }) {
|
|
55
|
+
const resumeCommand = `pi --session ${sessionId}`;
|
|
56
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3 overflow-auto p-3 text-[11px] text-muted-foreground dark:text-zinc-300", children: [
|
|
57
|
+
/* @__PURE__ */ jsx("p", { className: "text-muted-foreground dark:text-zinc-300", children: "This web chat is backed by a pi session. Use the id below from the workspace root to resume the same conversation in a terminal." }),
|
|
58
|
+
/* @__PURE__ */ jsx(DebugValue, { label: "Pi session id", value: sessionId }),
|
|
59
|
+
/* @__PURE__ */ jsx(DebugValue, { label: "Resume command", value: resumeCommand }),
|
|
60
|
+
/* @__PURE__ */ jsxs("p", { className: "rounded-md border border-border/30 bg-muted/10 p-2 text-[10px] leading-relaxed text-muted-foreground/75 dark:text-zinc-300", children: [
|
|
61
|
+
"Tip: ",
|
|
62
|
+
/* @__PURE__ */ jsx("code", { className: "font-mono text-foreground/80", children: "pi --continue" }),
|
|
63
|
+
" ",
|
|
64
|
+
"opens the most recent session for the current working directory. The explicit command above targets this session directly."
|
|
65
|
+
] })
|
|
66
|
+
] });
|
|
67
|
+
}
|
|
68
|
+
var RETRY_DELAY_MS = 2500;
|
|
69
|
+
var MAX_RETRIES = 20;
|
|
70
|
+
function SystemPromptTab({
|
|
71
|
+
sessionId,
|
|
72
|
+
requestHeaders
|
|
73
|
+
}) {
|
|
74
|
+
const [state, setState] = useState({ kind: "loading" });
|
|
75
|
+
const [retryKey, setRetryKey] = useState(0);
|
|
76
|
+
const retryCount = useRef(0);
|
|
77
|
+
const refresh = useCallback(() => {
|
|
78
|
+
retryCount.current = 0;
|
|
79
|
+
setRetryKey((k) => k + 1);
|
|
80
|
+
}, []);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
let aborted = false;
|
|
83
|
+
let retryTimer = null;
|
|
84
|
+
setState({ kind: "loading" });
|
|
85
|
+
const opts = requestHeaders ? { headers: requestHeaders } : void 0;
|
|
86
|
+
fetch(`/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/system-prompt`, opts).then(async (res) => {
|
|
87
|
+
if (aborted) return;
|
|
88
|
+
if (res.ok) {
|
|
89
|
+
const payload2 = await res.json();
|
|
90
|
+
if (typeof payload2.systemPrompt === "string") {
|
|
91
|
+
retryCount.current = 0;
|
|
92
|
+
setState({ kind: "ok", text: payload2.systemPrompt });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const payload = await res.json().catch(() => null);
|
|
97
|
+
const reason = payload?.error?.message ?? `HTTP ${res.status}`;
|
|
98
|
+
if (res.status === 404 && retryCount.current < MAX_RETRIES) {
|
|
99
|
+
setState({ kind: "empty", reason });
|
|
100
|
+
retryTimer = setTimeout(() => {
|
|
101
|
+
if (!aborted) {
|
|
102
|
+
retryCount.current++;
|
|
103
|
+
setRetryKey((k) => k + 1);
|
|
104
|
+
}
|
|
105
|
+
}, RETRY_DELAY_MS);
|
|
106
|
+
} else {
|
|
107
|
+
setState(res.status === 404 ? { kind: "empty", reason } : { kind: "error", reason });
|
|
108
|
+
}
|
|
109
|
+
}).catch((err) => {
|
|
110
|
+
if (!aborted) setState({ kind: "error", reason: err instanceof Error ? err.message : String(err) });
|
|
111
|
+
});
|
|
112
|
+
return () => {
|
|
113
|
+
aborted = true;
|
|
114
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
115
|
+
};
|
|
116
|
+
}, [sessionId, requestHeaders, retryKey]);
|
|
117
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col h-full min-h-0", children: [
|
|
118
|
+
/* @__PURE__ */ jsxs("div", { className: "shrink-0 flex items-center justify-between px-3 pt-2 pb-1 border-b border-border/40", children: [
|
|
119
|
+
/* @__PURE__ */ jsx("span", { className: "text-[10px] text-muted-foreground/80 font-mono", children: state.kind === "ok" ? `${state.text.length.toLocaleString()} chars` : state.kind === "loading" ? "loading\u2026" : state.kind === "empty" ? `waiting for session \xB7 retry ${retryCount.current}/${MAX_RETRIES}` : "error" }),
|
|
120
|
+
state.kind !== "loading" && /* @__PURE__ */ jsx(
|
|
121
|
+
IconButton,
|
|
122
|
+
{
|
|
123
|
+
type: "button",
|
|
124
|
+
variant: "ghost",
|
|
125
|
+
size: "icon-xs",
|
|
126
|
+
onClick: refresh,
|
|
127
|
+
className: "text-muted-foreground/60",
|
|
128
|
+
"aria-label": "Refresh system prompt",
|
|
129
|
+
children: /* @__PURE__ */ jsx(RefreshCwIcon, { className: "h-3 w-3" })
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
] }),
|
|
133
|
+
(state.kind === "loading" || state.kind === "empty") && /* @__PURE__ */ jsx("p", { className: "text-[11px] text-muted-foreground p-3", children: state.kind === "loading" ? "Loading\u2026" : state.reason }),
|
|
134
|
+
state.kind === "error" && /* @__PURE__ */ jsx("p", { className: "text-[11px] text-destructive p-3", children: state.reason }),
|
|
135
|
+
state.kind === "ok" && /* @__PURE__ */ jsx("pre", { className: "flex-1 overflow-auto px-3 py-2 font-mono text-[11px] leading-relaxed text-foreground whitespace-pre-wrap break-words", children: state.text })
|
|
136
|
+
] });
|
|
137
|
+
}
|
|
138
|
+
function partSummary(part) {
|
|
139
|
+
const p = part;
|
|
140
|
+
const type = p?.type ?? "?";
|
|
141
|
+
if (type === "text") {
|
|
142
|
+
const text = String(p.text ?? "");
|
|
143
|
+
return text.slice(0, 80) + (text.length > 80 ? "\u2026" : "");
|
|
144
|
+
}
|
|
145
|
+
if (type === "tool-invocation") {
|
|
146
|
+
const inv = p.toolInvocation;
|
|
147
|
+
const state = String(inv?.state ?? "");
|
|
148
|
+
return `${inv?.toolName ?? "?"}() \xB7 ${state}`;
|
|
149
|
+
}
|
|
150
|
+
if (type === "reasoning") {
|
|
151
|
+
const text = String(p.text ?? p.reasoning ?? "");
|
|
152
|
+
return text.slice(0, 80) + (text.length > 80 ? "\u2026" : "");
|
|
153
|
+
}
|
|
154
|
+
return type;
|
|
155
|
+
}
|
|
156
|
+
function flattenMessages(messages) {
|
|
157
|
+
const out = [];
|
|
158
|
+
for (let mi = 0; mi < messages.length; mi++) {
|
|
159
|
+
const msg = messages[mi];
|
|
160
|
+
const msgAny = msg;
|
|
161
|
+
const time = msgAny.createdAt ? new Date(msgAny.createdAt).toISOString().slice(11, 23) : null;
|
|
162
|
+
for (let pi = 0; pi < msg.parts.length; pi++) {
|
|
163
|
+
const part = msg.parts[pi];
|
|
164
|
+
out.push({
|
|
165
|
+
msgId: msg.id,
|
|
166
|
+
msgIndex: mi,
|
|
167
|
+
role: msg.role,
|
|
168
|
+
time,
|
|
169
|
+
partIndex: pi,
|
|
170
|
+
partType: part?.type ?? "unknown",
|
|
171
|
+
part
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
function MessagesTab({ messages }) {
|
|
178
|
+
const parts = flattenMessages(messages);
|
|
179
|
+
const [expanded, setExpanded] = useState(null);
|
|
180
|
+
const bottomRef = useRef(null);
|
|
181
|
+
const prevCount = useRef(parts.length);
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (parts.length !== prevCount.current) {
|
|
184
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
185
|
+
prevCount.current = parts.length;
|
|
186
|
+
}
|
|
187
|
+
}, [parts.length]);
|
|
188
|
+
if (parts.length === 0) {
|
|
189
|
+
return /* @__PURE__ */ jsx("p", { className: "text-[11px] text-muted-foreground p-3", children: "No messages yet." });
|
|
190
|
+
}
|
|
191
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col h-full min-h-0", children: [
|
|
192
|
+
/* @__PURE__ */ jsxs("div", { className: "shrink-0 px-3 pt-2 pb-1 text-[10px] text-muted-foreground/80 font-mono border-b border-border/40", children: [
|
|
193
|
+
messages.length,
|
|
194
|
+
" msg \xB7 ",
|
|
195
|
+
parts.length,
|
|
196
|
+
" parts"
|
|
197
|
+
] }),
|
|
198
|
+
/* @__PURE__ */ jsxs("div", { className: "flex-1 overflow-auto", children: [
|
|
199
|
+
parts.map((fp) => {
|
|
200
|
+
const key = `${fp.msgId}:${fp.partIndex}`;
|
|
201
|
+
const open = expanded === key;
|
|
202
|
+
return /* @__PURE__ */ jsxs("div", { className: "border-b border-border/30 last:border-0", children: [
|
|
203
|
+
/* @__PURE__ */ jsxs(
|
|
204
|
+
Button,
|
|
205
|
+
{
|
|
206
|
+
type: "button",
|
|
207
|
+
variant: "ghost",
|
|
208
|
+
onClick: () => setExpanded(open ? null : key),
|
|
209
|
+
className: "h-auto w-full justify-start gap-1.5 rounded-none px-2 py-1.5 text-left",
|
|
210
|
+
children: [
|
|
211
|
+
/* @__PURE__ */ jsx("span", { className: "mt-0.5 shrink-0 text-muted-foreground/40", children: open ? /* @__PURE__ */ jsx(ChevronDownIcon, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(ChevronRightIcon, { className: "h-3 w-3" }) }),
|
|
212
|
+
/* @__PURE__ */ jsx("span", { className: "font-mono text-[10px] shrink-0 w-20", children: fp.time ? /* @__PURE__ */ jsx("span", { className: "text-muted-foreground/60", children: fp.time }) : /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground/30", children: [
|
|
213
|
+
"m",
|
|
214
|
+
fp.msgIndex
|
|
215
|
+
] }) }),
|
|
216
|
+
/* @__PURE__ */ jsx("span", { className: cn(
|
|
217
|
+
"shrink-0 font-mono text-[10px] w-14",
|
|
218
|
+
fp.role === "user" ? "text-accent" : "text-muted-foreground"
|
|
219
|
+
), children: fp.role }),
|
|
220
|
+
/* @__PURE__ */ jsx("span", { className: "shrink-0 font-mono text-[10px] text-muted-foreground/60 w-24", children: fp.partType }),
|
|
221
|
+
/* @__PURE__ */ jsx("span", { className: "flex-1 min-w-0 font-mono text-[11px] text-foreground truncate", children: partSummary(fp.part) })
|
|
222
|
+
]
|
|
223
|
+
}
|
|
224
|
+
),
|
|
225
|
+
open && /* @__PURE__ */ jsx("pre", { className: "px-3 pb-2 font-mono text-[10px] leading-relaxed text-foreground/80 whitespace-pre-wrap break-words bg-muted/20", children: JSON.stringify(fp.part, null, 2) })
|
|
226
|
+
] }, key);
|
|
227
|
+
}),
|
|
228
|
+
/* @__PURE__ */ jsx("div", { ref: bottomRef })
|
|
229
|
+
] })
|
|
230
|
+
] });
|
|
231
|
+
}
|
|
232
|
+
var TABS = [
|
|
233
|
+
{ id: "session", label: "Session" },
|
|
234
|
+
{ id: "prompt", label: "System prompt" },
|
|
235
|
+
{ id: "messages", label: "Messages" }
|
|
236
|
+
];
|
|
237
|
+
var MIN_WIDTH = 280;
|
|
238
|
+
var MAX_WIDTH = 800;
|
|
239
|
+
function DebugDrawer({ sessionId, messages, requestHeaders, width, onWidthChange }) {
|
|
240
|
+
const [tab, setTab] = useState("session");
|
|
241
|
+
const onDragStart = useCallback((e) => {
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
const startX = e.clientX;
|
|
244
|
+
const startW = width;
|
|
245
|
+
const onMove = (ev) => {
|
|
246
|
+
const delta = startX - ev.clientX;
|
|
247
|
+
onWidthChange(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startW + delta)));
|
|
248
|
+
};
|
|
249
|
+
const onUp = () => {
|
|
250
|
+
window.removeEventListener("mousemove", onMove);
|
|
251
|
+
window.removeEventListener("mouseup", onUp);
|
|
252
|
+
};
|
|
253
|
+
window.addEventListener("mousemove", onMove);
|
|
254
|
+
window.addEventListener("mouseup", onUp);
|
|
255
|
+
}, [width, onWidthChange]);
|
|
256
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
257
|
+
/* @__PURE__ */ jsx(
|
|
258
|
+
"div",
|
|
259
|
+
{
|
|
260
|
+
onMouseDown: onDragStart,
|
|
261
|
+
className: "w-1 shrink-0 cursor-col-resize hover:bg-accent/40 active:bg-accent/60 transition-colors",
|
|
262
|
+
"aria-hidden": true
|
|
263
|
+
}
|
|
264
|
+
),
|
|
265
|
+
/* @__PURE__ */ jsx(
|
|
266
|
+
"aside",
|
|
267
|
+
{
|
|
268
|
+
style: { width },
|
|
269
|
+
className: cn(
|
|
270
|
+
"flex h-full shrink-0 flex-col border-l border-border/60",
|
|
271
|
+
"bg-[oklch(from_var(--background)_calc(l-0.01)_c_h)]"
|
|
272
|
+
),
|
|
273
|
+
children: /* @__PURE__ */ jsxs(Tabs, { value: tab, onValueChange: (next) => setTab(next), className: "flex flex-col flex-1 min-h-0 overflow-hidden", children: [
|
|
274
|
+
/* @__PURE__ */ jsx("header", { className: "flex shrink-0 items-center gap-0 border-b border-border/60 px-1", children: /* @__PURE__ */ jsx(TabsList, { variant: "line", className: "h-auto gap-0 p-0 w-full", children: TABS.map(({ id, label }) => /* @__PURE__ */ jsx(
|
|
275
|
+
TabsTrigger,
|
|
276
|
+
{
|
|
277
|
+
value: id,
|
|
278
|
+
className: "h-8 flex-none px-3 py-2 text-[11px] font-medium data-[state=active]:after:bg-[color:var(--accent)]",
|
|
279
|
+
children: label
|
|
280
|
+
},
|
|
281
|
+
id
|
|
282
|
+
)) }) }),
|
|
283
|
+
/* @__PURE__ */ jsx(TabsContent, { value: "session", forceMount: true, className: "flex flex-col flex-1 min-h-0 overflow-hidden data-[state=inactive]:hidden", children: /* @__PURE__ */ jsx(SessionTab, { sessionId }) }),
|
|
284
|
+
/* @__PURE__ */ jsx(TabsContent, { value: "prompt", forceMount: true, className: "flex flex-col flex-1 min-h-0 overflow-hidden data-[state=inactive]:hidden", children: /* @__PURE__ */ jsx(SystemPromptTab, { sessionId, requestHeaders }) }),
|
|
285
|
+
/* @__PURE__ */ jsx(TabsContent, { value: "messages", forceMount: true, className: "flex flex-col flex-1 min-h-0 overflow-hidden data-[state=inactive]:hidden", children: /* @__PURE__ */ jsx(MessagesTab, { messages }) })
|
|
286
|
+
] })
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
] });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export {
|
|
293
|
+
cn,
|
|
294
|
+
DebugDrawer
|
|
295
|
+
};
|