vibepulse 0.1.1 → 0.1.2
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 +0 -29
- package/docs/session-status-detection.md +258 -0
- package/next.config.ts +11 -0
- package/package.json +14 -1
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/readme-cover.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/opencode-config/route.ts +304 -0
- package/src/app/api/opencode-config/status/route.ts +31 -0
- package/src/app/api/opencode-events/route.ts +86 -0
- package/src/app/api/opencode-models/route.test.ts +135 -0
- package/src/app/api/opencode-models/route.ts +58 -0
- package/src/app/api/profiles/[id]/apply/route.ts +49 -0
- package/src/app/api/profiles/[id]/route.ts +160 -0
- package/src/app/api/profiles/route.ts +107 -0
- package/src/app/api/sessions/[id]/archive/route.ts +35 -0
- package/src/app/api/sessions/[id]/delete/route.ts +26 -0
- package/src/app/api/sessions/[id]/route.ts +45 -0
- package/src/app/api/sessions/route.ts +596 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +66 -0
- package/src/app/layout.tsx +37 -0
- package/src/app/page.tsx +239 -0
- package/src/components/ErrorBoundary.tsx +72 -0
- package/src/components/KanbanBoard.tsx +442 -0
- package/src/components/LoadingState.tsx +37 -0
- package/src/components/ProjectCard.tsx +382 -0
- package/src/components/QueryProvider.tsx +25 -0
- package/src/components/SessionCard.tsx +291 -0
- package/src/components/SessionList.tsx +60 -0
- package/src/components/opencode-config/AgentConfigForm.test.tsx +66 -0
- package/src/components/opencode-config/AgentConfigForm.tsx +445 -0
- package/src/components/opencode-config/AgentModelSelector.tsx +284 -0
- package/src/components/opencode-config/AgentsConfigPanel.tsx +162 -0
- package/src/components/opencode-config/ConfigButton.tsx +43 -0
- package/src/components/opencode-config/ConfigPanel.tsx +91 -0
- package/src/components/opencode-config/FullscreenConfigPanel.tsx +360 -0
- package/src/components/opencode-config/categories/CategoriesList.tsx +328 -0
- package/src/components/opencode-config/categories/CategoriesManager.test.tsx +97 -0
- package/src/components/opencode-config/categories/CategoriesManager.tsx +174 -0
- package/src/components/opencode-config/categories/CategoryConfigForm.tsx +384 -0
- package/src/components/opencode-config/profiles/ProfileCard.tsx +140 -0
- package/src/components/opencode-config/profiles/ProfileEditor.tsx +446 -0
- package/src/components/opencode-config/profiles/ProfileList.tsx +398 -0
- package/src/components/opencode-config/profiles/ProfileManager.test.tsx +122 -0
- package/src/components/opencode-config/profiles/ProfileManager.tsx +293 -0
- package/src/components/ui/Tabs.tsx +59 -0
- package/src/hooks/useOpencodeSync.ts +378 -0
- package/src/index.ts +2 -0
- package/src/lib/notificationSound.ts +266 -0
- package/src/lib/opencodeConfig.test.ts +81 -0
- package/src/lib/opencodeConfig.ts +48 -0
- package/src/lib/opencodeDiscovery.ts +154 -0
- package/src/lib/profiles/storage.ts +264 -0
- package/src/lib/transform.ts +84 -0
- package/src/test/setup.ts +8 -0
- package/src/types/index.ts +89 -0
- package/src/types/opencodeConfig.ts +133 -0
- package/src/types/testing-library-vitest.d.ts +17 -0
- package/tsconfig.json +34 -0
- package/tsconfig.lib.json +17 -0
package/README.md
CHANGED
|
@@ -37,35 +37,6 @@ Real-time dashboard for monitoring and managing OpenCode sessions.
|
|
|
37
37
|
|
|
38
38
|
## Getting Started
|
|
39
39
|
|
|
40
|
-
### Quick Start (npx - Recommended)
|
|
41
|
-
|
|
42
|
-
The fastest way to run VibePulse without installation:
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
# Run directly with npx
|
|
46
|
-
npx vibepulse
|
|
47
|
-
|
|
48
|
-
# Or specify a custom port
|
|
49
|
-
PORT=8080 npx vibepulse
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
Open [http://localhost:3456](http://localhost:3456)
|
|
53
|
-
|
|
54
|
-
### Global Installation
|
|
55
|
-
|
|
56
|
-
Install VibePulse globally for easier access:
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
# Install globally
|
|
60
|
-
npm install -g vibepulse
|
|
61
|
-
|
|
62
|
-
# Run anytime
|
|
63
|
-
vibepulse
|
|
64
|
-
|
|
65
|
-
# With custom port
|
|
66
|
-
PORT=8080 vibepulse
|
|
67
|
-
```
|
|
68
|
-
|
|
69
40
|
### Development
|
|
70
41
|
|
|
71
42
|
Clone and run from source:
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# VibePulse Session Status Detection
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
VibePulse uses a multi-layer detection mechanism to determine the real-time status of OpenCode sessions (idle/busy/retry). Since OpenCode's native status reporting is sparse and delayed, the system combines multiple signals to improve status accuracy.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Core Concepts
|
|
10
|
+
|
|
11
|
+
### Status Definitions
|
|
12
|
+
|
|
13
|
+
| Status | Meaning | Column |
|
|
14
|
+
|--------|---------|--------|
|
|
15
|
+
| `idle` | Idle / Completed | Idle Column |
|
|
16
|
+
| `busy` | Actively Running | Busy Column |
|
|
17
|
+
| `retry` | Waiting for User Input / Retry | Needs Attention Column |
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Detection Architecture
|
|
22
|
+
|
|
23
|
+
```mermaid
|
|
24
|
+
flowchart TB
|
|
25
|
+
subgraph Input["Input Signal Layer"]
|
|
26
|
+
A1["OpenCode /session/status\nSparse, often empty"]
|
|
27
|
+
A2["Message Part Status\nrunning/completed/waiting"]
|
|
28
|
+
A3["Session Metadata\nupdated/created timestamps"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
subgraph Process["Status Processing Layer"]
|
|
32
|
+
B1["Part Status Analysis"]
|
|
33
|
+
B2["Sticky State Management\n25s one-way buffer"]
|
|
34
|
+
B3["Child Session Cascade"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
subgraph Output["Output Layer"]
|
|
38
|
+
C1["realTimeStatus"]
|
|
39
|
+
C2["waitingForUser"]
|
|
40
|
+
C3["Kanban Column Assignment"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
A1 --> B1
|
|
44
|
+
A2 --> B1
|
|
45
|
+
A3 --> B2
|
|
46
|
+
B1 --> B2
|
|
47
|
+
B2 --> C1
|
|
48
|
+
B2 --> B3
|
|
49
|
+
B3 --> C2
|
|
50
|
+
C1 --> C3
|
|
51
|
+
C2 --> C3
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Key Detection Mechanisms
|
|
57
|
+
|
|
58
|
+
### 1. Part Status Analysis
|
|
59
|
+
|
|
60
|
+
Extracts part status from recent session messages to determine activity:
|
|
61
|
+
|
|
62
|
+
```mermaid
|
|
63
|
+
flowchart TD
|
|
64
|
+
A["Fetch last 8 messages"] --> B{"Any running part?"}
|
|
65
|
+
B -->|Yes| C["Status = busy"]
|
|
66
|
+
B -->|No| D{"Any waiting part?"}
|
|
67
|
+
D -->|Yes| E["Status = retry\nwaitingForUser = true"]
|
|
68
|
+
D -->|No| F{"All parts completed?"}
|
|
69
|
+
F -->|Yes| G["Likely idle"]
|
|
70
|
+
F -->|No| H["Unknown state"]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Key Limitation**: Only checks the last 8 messages. Tasks with long periods of no output are misclassified as completed.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
### 2. One-Way Sticky State (Core Mechanism)
|
|
78
|
+
|
|
79
|
+
Prevents status jitter using a **one-way buffering strategy**:
|
|
80
|
+
|
|
81
|
+
```mermaid
|
|
82
|
+
flowchart LR
|
|
83
|
+
subgraph IdleToBusy["idle → busy"]
|
|
84
|
+
A1["Detected busy"] --> A2["Immediate effect"]
|
|
85
|
+
A2 --> A3["Update lastBusyAt = now"]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
subgraph BusyToIdle["busy → idle"]
|
|
89
|
+
B1["Detected idle"] --> B2{"lastBusyAt\nwithin 25s?"}
|
|
90
|
+
B2 -->|Yes| B3["Keep busy\n(sticky)"]
|
|
91
|
+
B2 -->|No| B4["Actually become idle"]
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Design Rationale**:
|
|
96
|
+
- `idle → busy`: **Immediate effect** (once busy is detected, it's truly running)
|
|
97
|
+
- `busy → idle`: **25-second buffer** (prevents misclassification from brief state loss)
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
### 3. Child Session Cascade
|
|
102
|
+
|
|
103
|
+
Parent session status is influenced by child sessions:
|
|
104
|
+
|
|
105
|
+
```mermaid
|
|
106
|
+
flowchart TB
|
|
107
|
+
subgraph Parent["Parent Session"]
|
|
108
|
+
P1["realTimeStatus: idle"]
|
|
109
|
+
P2["But has child sessions"]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
subgraph Children["Child Session States"]
|
|
113
|
+
C1["Child 1: busy"]
|
|
114
|
+
C2["Child 2: idle"]
|
|
115
|
+
C3["Child 3: retry"]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
P1 --> D{"Any child session active?"}
|
|
119
|
+
C1 --> D
|
|
120
|
+
C2 --> D
|
|
121
|
+
C3 --> D
|
|
122
|
+
|
|
123
|
+
D -->|Yes| E["effectiveStatus = busy\nDisplay in Busy Column"]
|
|
124
|
+
D -->|No| F["effectiveStatus = idle\nDisplay in Idle Column"]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Complete State Transition Flow
|
|
130
|
+
|
|
131
|
+
```mermaid
|
|
132
|
+
sequenceDiagram
|
|
133
|
+
participant OC as OpenCode
|
|
134
|
+
participant API as /api/sessions
|
|
135
|
+
participant Sticky as Sticky State Manager
|
|
136
|
+
participant UI as Kanban UI
|
|
137
|
+
|
|
138
|
+
Note over OC,UI: Scenario: Long-running task
|
|
139
|
+
|
|
140
|
+
OC->>OC: Task starts (busy)
|
|
141
|
+
OC->>API: /session/status = busy
|
|
142
|
+
API->>Sticky: Update lastBusyAt
|
|
143
|
+
Sticky->>UI: Display busy
|
|
144
|
+
|
|
145
|
+
Note over OC,UI: 10 seconds later, OpenCode stops sending status
|
|
146
|
+
|
|
147
|
+
OC->>API: /session/status = null (sparse)
|
|
148
|
+
API->>Sticky: Query lastBusyAt
|
|
149
|
+
Sticky-->>Sticky: now - lastBusyAt = 10s < 25s
|
|
150
|
+
Sticky->>UI: **Keep busy** (sticky)
|
|
151
|
+
|
|
152
|
+
Note over OC,UI: 30 seconds later
|
|
153
|
+
|
|
154
|
+
API->>Sticky: Query lastBusyAt
|
|
155
|
+
Sticky-->>Sticky: now - lastBusyAt = 30s > 25s
|
|
156
|
+
Sticky->>UI: **Become idle**
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Detection Limitations (Shortcomings)
|
|
162
|
+
|
|
163
|
+
### Root Cause: Unreliable Signal Source
|
|
164
|
+
|
|
165
|
+
```mermaid
|
|
166
|
+
flowchart TB
|
|
167
|
+
subgraph RootCause["Root Cause: Insufficient OpenCode Signals"]
|
|
168
|
+
R1["No heartbeat mechanism\nCannot prove 'I am computing'"]
|
|
169
|
+
R2["Sparse status reporting\n/session/status often empty"]
|
|
170
|
+
R3["Discrete part status\nOnly running/completed, no progress"]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
subgraph Workarounds["Current Workarounds"]
|
|
174
|
+
W1["Time buffer (25s sticky)"]
|
|
175
|
+
W2["Message history inference (8 messages)"]
|
|
176
|
+
W3["Child session cascade"]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
RootCause --> Workarounds
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Specific Shortcomings
|
|
183
|
+
|
|
184
|
+
| Shortcoming | Impact Scenario | User Experience |
|
|
185
|
+
|-------------|-----------------|-----------------|
|
|
186
|
+
| **1. Shallow message sampling** | Long computation without output | Misclassified as idle, user thinks task finished |
|
|
187
|
+
| **2. Fixed time window** | One-size-fits-all approach | 25s may be too short for some tasks, too long for others |
|
|
188
|
+
| **3. No CPU/IO monitoring** | Process hang or deadlock | Continuously shows busy, user waits in vain |
|
|
189
|
+
| **4. Cannot detect deep subtask nesting** | Nested agent calls | Grandchild task status not trackable |
|
|
190
|
+
| **5. Network jitter sensitive** | Brief disconnection | May trigger unnecessary stale state |
|
|
191
|
+
| **6. Audio-visual out of sync** | Rapid status switching | Sound plays before/after card movement, disjointed experience |
|
|
192
|
+
|
|
193
|
+
### Misclassification Example
|
|
194
|
+
|
|
195
|
+
```mermaid
|
|
196
|
+
sequenceDiagram
|
|
197
|
+
participant User as User
|
|
198
|
+
participant UI as VibePulse Kanban
|
|
199
|
+
participant Task as Background Task
|
|
200
|
+
|
|
201
|
+
Note over User,Task: Scenario: Data analysis task running
|
|
202
|
+
|
|
203
|
+
User->>UI: Check kanban
|
|
204
|
+
UI->>Task: Query status
|
|
205
|
+
Task-->>UI: busy (second 1)
|
|
206
|
+
UI-->>User: Display Busy ✓
|
|
207
|
+
|
|
208
|
+
Note over User,Task: Task continues, but no new messages
|
|
209
|
+
|
|
210
|
+
Task->>Task: Continuous computation (no output)
|
|
211
|
+
|
|
212
|
+
Note over User,Task: After 25 seconds...
|
|
213
|
+
|
|
214
|
+
UI->>Task: Query status
|
|
215
|
+
Task-->>UI: No status / completed parts
|
|
216
|
+
UI-->>UI: Sticky window expired
|
|
217
|
+
UI-->>User: Display Idle ✗ (Misclassification!)
|
|
218
|
+
|
|
219
|
+
User->>User: Confused: Did the task finish?
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Improvement Directions (Not Implemented)
|
|
223
|
+
|
|
224
|
+
| Improvement | Difficulty | Impact | Priority |
|
|
225
|
+
|-------------|------------|--------|----------|
|
|
226
|
+
| Increase message sampling depth (50 messages) | Low | Reduce misclassification | P2 |
|
|
227
|
+
| Process-level CPU monitoring | Medium | Detect deadlocks | P1 |
|
|
228
|
+
| Adaptive time window (by task type) | Medium | Precise judgment | P3 |
|
|
229
|
+
| MCP Progress Token integration | High | Accurate progress | P1 (requires OpenCode support) |
|
|
230
|
+
| Heartbeat keepalive mechanism | High | Real-time status | P2 (requires protocol change) |
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Key Time Parameters
|
|
235
|
+
|
|
236
|
+
| Parameter | Value | Purpose |
|
|
237
|
+
|-----------|-------|---------|
|
|
238
|
+
| `STATUS_STICKY_BUSY_WINDOW_MS` | 25 seconds | Buffer window for busy → idle transition |
|
|
239
|
+
| `CHILD_ACTIVE_WINDOW_MS` | 30 minutes | Child session activity determination window |
|
|
240
|
+
| `CHILD_UNKNOWN_STATE_BUSY_WINDOW_MS` | 2 minutes | Busy assumption for unknown states |
|
|
241
|
+
| `STALL_DETECTION_WINDOW_MS` | 30 seconds | Stall detection (if updated time is within this window) |
|
|
242
|
+
| `STATUS_STICKY_RETENTION_MS` | 24 hours | Sticky state memory retention time |
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Data Flow Summary
|
|
247
|
+
|
|
248
|
+
```mermaid
|
|
249
|
+
flowchart LR
|
|
250
|
+
A["OpenCode\nTrue State"] -->|Sparse signals| B("VibePulse\nMulti-layer detection")
|
|
251
|
+
B -->|Sticky buffering| C["Stable State"]
|
|
252
|
+
C -->|transform| D["Kanban Cards"]
|
|
253
|
+
D -->|Animation| E["UI Column Movement"]
|
|
254
|
+
|
|
255
|
+
F["Sound Alert"] -.->|Delayed 250ms| E
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**Core Design Philosophy**: Due to unreliable upstream (OpenCode) signals, the system provides **good enough** stability through **time buffering** and **multi-layer inference**, rather than absolute precision.
|
package/next.config.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { NextConfig } from "next";
|
|
2
|
+
|
|
3
|
+
const nextConfig: NextConfig = {
|
|
4
|
+
// Use a less common port to avoid conflicts
|
|
5
|
+
// Can be overridden with PORT env var
|
|
6
|
+
...(process.env.PORT && {
|
|
7
|
+
// Next.js doesn't support port in config, use env var instead
|
|
8
|
+
}),
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default nextConfig;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibepulse",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Real-time dashboard for monitoring and managing OpenCode sessions",
|
|
6
6
|
"repository": {
|
|
@@ -12,6 +12,19 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"dist",
|
|
14
14
|
"bin",
|
|
15
|
+
"src",
|
|
16
|
+
"app",
|
|
17
|
+
"public",
|
|
18
|
+
"components",
|
|
19
|
+
"lib",
|
|
20
|
+
"hooks",
|
|
21
|
+
"types",
|
|
22
|
+
"docs",
|
|
23
|
+
"next.config.ts",
|
|
24
|
+
"tsconfig.json",
|
|
25
|
+
"tsconfig.lib.json",
|
|
26
|
+
"tailwind.config.ts",
|
|
27
|
+
"postcss.config.mjs",
|
|
15
28
|
"README.md",
|
|
16
29
|
"LICENSE"
|
|
17
30
|
],
|
package/public/file.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
package/public/globe.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
package/public/next.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { readConfig, writeConfig } from '@/lib/opencodeConfig';
|
|
3
|
+
|
|
4
|
+
// Allowed fields to expose in the API
|
|
5
|
+
const ALLOWED_AGENT_FIELDS = ['model', 'temperature', 'top_p', 'variant', 'prompt_append'] as const;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Filters an agent config to only include allowed fields
|
|
9
|
+
*/
|
|
10
|
+
function filterAgentConfig(agent: Record<string, unknown>): Record<string, unknown> {
|
|
11
|
+
const filtered: Record<string, unknown> = {};
|
|
12
|
+
|
|
13
|
+
for (const field of ALLOWED_AGENT_FIELDS) {
|
|
14
|
+
if (agent[field] !== undefined) {
|
|
15
|
+
filtered[field] = agent[field];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return filtered;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* GET /api/opencode-config
|
|
24
|
+
* Returns filtered agent configuration
|
|
25
|
+
* Only exposes: model, temperature, top_p
|
|
26
|
+
* Filters out sensitive fields (apiKey, token, password, etc.)
|
|
27
|
+
*/
|
|
28
|
+
export async function GET() {
|
|
29
|
+
try {
|
|
30
|
+
const config = await readConfig();
|
|
31
|
+
const agents = config.agents || {};
|
|
32
|
+
const filteredAgents: Record<string, Record<string, unknown>> = {};
|
|
33
|
+
|
|
34
|
+
for (const [agentName, agentConfig] of Object.entries(agents)) {
|
|
35
|
+
if (typeof agentConfig === 'object' && agentConfig !== null) {
|
|
36
|
+
filteredAgents[agentName] = filterAgentConfig(agentConfig as Record<string, unknown>);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const categories = config.categories || {};
|
|
41
|
+
const filteredCategories: Record<string, Record<string, unknown>> = {};
|
|
42
|
+
|
|
43
|
+
for (const [catName, catConfig] of Object.entries(categories)) {
|
|
44
|
+
if (typeof catConfig === 'object' && catConfig !== null && !Array.isArray(catConfig)) {
|
|
45
|
+
filteredCategories[catName] = filterAgentConfig(catConfig as Record<string, unknown>);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return NextResponse.json({
|
|
50
|
+
agents: filteredAgents,
|
|
51
|
+
categories: filteredCategories
|
|
52
|
+
});
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error('Error reading config:', error);
|
|
55
|
+
return NextResponse.json(
|
|
56
|
+
{ error: 'Internal server error' },
|
|
57
|
+
{ status: 500 }
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* POST /api/opencode-config
|
|
64
|
+
* Updates agent configuration with validation
|
|
65
|
+
* Only allows: model, temperature, top_p
|
|
66
|
+
* Rejects sensitive fields (apiKey, token, password, secret, etc.)
|
|
67
|
+
*/
|
|
68
|
+
export async function POST(request: NextRequest) {
|
|
69
|
+
try {
|
|
70
|
+
const body = await request.json();
|
|
71
|
+
|
|
72
|
+
// Validate request structure
|
|
73
|
+
if (!body || typeof body !== 'object') {
|
|
74
|
+
return NextResponse.json(
|
|
75
|
+
{ error: 'Invalid request body' },
|
|
76
|
+
{ status: 400 }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { agents, categories } = body;
|
|
81
|
+
|
|
82
|
+
// If neither agents nor categories provided, nothing to update
|
|
83
|
+
if (agents === undefined && categories === undefined) {
|
|
84
|
+
return NextResponse.json(
|
|
85
|
+
{ error: 'Missing agents or categories field' },
|
|
86
|
+
{ status: 400 }
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Validate agents is an object (if provided)
|
|
91
|
+
if (agents !== undefined && (typeof agents !== 'object' || agents === null || Array.isArray(agents))) {
|
|
92
|
+
return NextResponse.json(
|
|
93
|
+
{ error: 'Agents must be an object' },
|
|
94
|
+
{ status: 400 }
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Validate categories is an object (if provided)
|
|
99
|
+
if (categories !== undefined && (typeof categories !== 'object' || categories === null || Array.isArray(categories))) {
|
|
100
|
+
return NextResponse.json(
|
|
101
|
+
{ error: 'Categories must be an object' },
|
|
102
|
+
{ status: 400 }
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Read current config
|
|
107
|
+
const currentConfig = await readConfig();
|
|
108
|
+
const currentAgents = currentConfig.agents || {};
|
|
109
|
+
|
|
110
|
+
// Validate and merge agent updates
|
|
111
|
+
const updatedAgents: Record<string, Record<string, unknown>> = {};
|
|
112
|
+
|
|
113
|
+
for (const [name, config] of Object.entries(currentAgents)) {
|
|
114
|
+
if (typeof config === 'object' && config !== null && !Array.isArray(config)) {
|
|
115
|
+
updatedAgents[name] = config as Record<string, unknown>;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (agents !== undefined) {
|
|
120
|
+
for (const [agentName, agentConfig] of Object.entries(agents)) {
|
|
121
|
+
if (typeof agentConfig !== 'object' || agentConfig === null || Array.isArray(agentConfig)) {
|
|
122
|
+
return NextResponse.json(
|
|
123
|
+
{ error: `Agent '${agentName}' config must be an object` },
|
|
124
|
+
{ status: 400 }
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const config = agentConfig as Record<string, unknown>;
|
|
129
|
+
const disallowedFields: string[] = [];
|
|
130
|
+
|
|
131
|
+
for (const key of Object.keys(config)) {
|
|
132
|
+
const lowerKey = key.toLowerCase();
|
|
133
|
+
if (
|
|
134
|
+
lowerKey.includes('api') ||
|
|
135
|
+
lowerKey.includes('key') ||
|
|
136
|
+
lowerKey.includes('token') ||
|
|
137
|
+
lowerKey.includes('secret') ||
|
|
138
|
+
lowerKey.includes('password') ||
|
|
139
|
+
lowerKey.includes('auth') ||
|
|
140
|
+
lowerKey.includes('credential') ||
|
|
141
|
+
lowerKey.includes('private') ||
|
|
142
|
+
lowerKey.includes('cert')
|
|
143
|
+
) {
|
|
144
|
+
disallowedFields.push(key);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (disallowedFields.length > 0) {
|
|
149
|
+
return NextResponse.json(
|
|
150
|
+
{
|
|
151
|
+
error: `Agent '${agentName}' contains disallowed fields: ${disallowedFields.join(', ')}`
|
|
152
|
+
},
|
|
153
|
+
{ status: 403 }
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const validatedConfig: Record<string, unknown> = {};
|
|
158
|
+
|
|
159
|
+
for (const [field, value] of Object.entries(config)) {
|
|
160
|
+
const lowerField = field.toLowerCase();
|
|
161
|
+
|
|
162
|
+
if (lowerField === 'model') {
|
|
163
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
164
|
+
return NextResponse.json(
|
|
165
|
+
{ error: `Agent '${agentName}': model must be a non-empty string` },
|
|
166
|
+
{ status: 400 }
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
validatedConfig[field] = value;
|
|
170
|
+
} else if (lowerField === 'temperature') {
|
|
171
|
+
const temp = Number(value);
|
|
172
|
+
if (isNaN(temp) || temp < 0 || temp > 2) {
|
|
173
|
+
return NextResponse.json(
|
|
174
|
+
{ error: `Agent '${agentName}': temperature must be a number between 0 and 2` },
|
|
175
|
+
{ status: 400 }
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
validatedConfig[field] = temp;
|
|
179
|
+
} else if (lowerField === 'top_p') {
|
|
180
|
+
const topP = Number(value);
|
|
181
|
+
if (isNaN(topP) || topP < 0 || topP > 1) {
|
|
182
|
+
return NextResponse.json(
|
|
183
|
+
{ error: `Agent '${agentName}': top_p must be a number between 0 and 1` },
|
|
184
|
+
{ status: 400 }
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
validatedConfig[field] = topP;
|
|
188
|
+
} else if (lowerField === 'variant') {
|
|
189
|
+
if (typeof value !== 'string') {
|
|
190
|
+
return NextResponse.json(
|
|
191
|
+
{ error: `Agent '${agentName}': variant must be a string` },
|
|
192
|
+
{ status: 400 }
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
validatedConfig[field] = value;
|
|
196
|
+
} else if (lowerField === 'prompt_append') {
|
|
197
|
+
if (typeof value !== 'string') {
|
|
198
|
+
return NextResponse.json(
|
|
199
|
+
{ error: `Agent '${agentName}': prompt_append must be a string` },
|
|
200
|
+
{ status: 400 }
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
validatedConfig[field] = value;
|
|
204
|
+
} else {
|
|
205
|
+
return NextResponse.json(
|
|
206
|
+
{
|
|
207
|
+
error: `Agent '${agentName}': unknown field '${field}'. Allowed fields: model, temperature, top_p, variant, prompt_append`
|
|
208
|
+
},
|
|
209
|
+
{ status: 400 }
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
updatedAgents[agentName] = {
|
|
215
|
+
...(currentAgents[agentName] as Record<string, unknown> || {}),
|
|
216
|
+
...validatedConfig
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Process categories updates if provided
|
|
222
|
+
const updatedCategories: Record<string, Record<string, unknown>> = {};
|
|
223
|
+
const currentCategories = (currentConfig.categories || {}) as Record<string, Record<string, unknown>>;
|
|
224
|
+
|
|
225
|
+
for (const [name, config] of Object.entries(currentCategories)) {
|
|
226
|
+
if (typeof config === 'object' && config !== null && !Array.isArray(config)) {
|
|
227
|
+
updatedCategories[name] = config as Record<string, unknown>;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (categories !== undefined) {
|
|
232
|
+
for (const [categoryName, categoryConfig] of Object.entries(categories)) {
|
|
233
|
+
if (typeof categoryConfig !== 'object' || categoryConfig === null || Array.isArray(categoryConfig)) {
|
|
234
|
+
return NextResponse.json(
|
|
235
|
+
{ error: `Category '${categoryName}' config must be an object` },
|
|
236
|
+
{ status: 400 }
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const configObj = categoryConfig as Record<string, unknown>;
|
|
241
|
+
const validatedCategoryConfig: Record<string, unknown> = {};
|
|
242
|
+
|
|
243
|
+
for (const [field, value] of Object.entries(configObj)) {
|
|
244
|
+
if (field === 'model' || field === 'variant' || field === 'prompt_append' || field === 'description') {
|
|
245
|
+
if (value !== undefined && typeof value !== 'string') {
|
|
246
|
+
return NextResponse.json(
|
|
247
|
+
{ error: `Category '${categoryName}': '${field}' must be a string` },
|
|
248
|
+
{ status: 400 }
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
validatedCategoryConfig[field] = value;
|
|
252
|
+
} else if (field === 'temperature' || field === 'top_p') {
|
|
253
|
+
if (value !== undefined && typeof value !== 'number') {
|
|
254
|
+
return NextResponse.json(
|
|
255
|
+
{ error: `Category '${categoryName}': '${field}' must be a number` },
|
|
256
|
+
{ status: 400 }
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const numValue = value as number;
|
|
261
|
+
const temp = field === 'temperature' ? Math.max(0, Math.min(2, numValue)) : numValue;
|
|
262
|
+
const topP = field === 'top_p' ? Math.max(0, Math.min(1, numValue)) : numValue;
|
|
263
|
+
|
|
264
|
+
validatedCategoryConfig[field] = field === 'temperature' ? temp : topP;
|
|
265
|
+
} else {
|
|
266
|
+
return NextResponse.json(
|
|
267
|
+
{ error: `Category '${categoryName}': unknown field '${field}'` },
|
|
268
|
+
{ status: 400 }
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
updatedCategories[categoryName] = {
|
|
274
|
+
...((currentCategories[categoryName] as Record<string, unknown>) || {}),
|
|
275
|
+
...validatedCategoryConfig
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Update config and save
|
|
281
|
+
const newConfig = { ...currentConfig } as Record<string, unknown>;
|
|
282
|
+
if (agents !== undefined) newConfig.agents = updatedAgents;
|
|
283
|
+
if (categories !== undefined) newConfig.categories = updatedCategories;
|
|
284
|
+
|
|
285
|
+
// writeConfig type doesn't natively expose categories yet, safely bypassing
|
|
286
|
+
await writeConfig(
|
|
287
|
+
newConfig as {
|
|
288
|
+
agents?: Record<string, Record<string, unknown>>;
|
|
289
|
+
categories?: Record<string, Record<string, unknown>>;
|
|
290
|
+
}
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
return NextResponse.json(
|
|
294
|
+
{ success: true, agents: updatedAgents, categories: updatedCategories },
|
|
295
|
+
{ status: 200 }
|
|
296
|
+
);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
console.error('Error updating config:', error);
|
|
299
|
+
return NextResponse.json(
|
|
300
|
+
{ error: 'Internal server error' },
|
|
301
|
+
{ status: 500 }
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|