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.
Files changed (65) hide show
  1. package/README.md +0 -29
  2. package/docs/session-status-detection.md +258 -0
  3. package/next.config.ts +11 -0
  4. package/package.json +14 -1
  5. package/postcss.config.mjs +7 -0
  6. package/public/file.svg +1 -0
  7. package/public/globe.svg +1 -0
  8. package/public/next.svg +1 -0
  9. package/public/readme-cover.png +0 -0
  10. package/public/vercel.svg +1 -0
  11. package/public/window.svg +1 -0
  12. package/src/app/api/opencode-config/route.ts +304 -0
  13. package/src/app/api/opencode-config/status/route.ts +31 -0
  14. package/src/app/api/opencode-events/route.ts +86 -0
  15. package/src/app/api/opencode-models/route.test.ts +135 -0
  16. package/src/app/api/opencode-models/route.ts +58 -0
  17. package/src/app/api/profiles/[id]/apply/route.ts +49 -0
  18. package/src/app/api/profiles/[id]/route.ts +160 -0
  19. package/src/app/api/profiles/route.ts +107 -0
  20. package/src/app/api/sessions/[id]/archive/route.ts +35 -0
  21. package/src/app/api/sessions/[id]/delete/route.ts +26 -0
  22. package/src/app/api/sessions/[id]/route.ts +45 -0
  23. package/src/app/api/sessions/route.ts +596 -0
  24. package/src/app/favicon.ico +0 -0
  25. package/src/app/globals.css +66 -0
  26. package/src/app/layout.tsx +37 -0
  27. package/src/app/page.tsx +239 -0
  28. package/src/components/ErrorBoundary.tsx +72 -0
  29. package/src/components/KanbanBoard.tsx +442 -0
  30. package/src/components/LoadingState.tsx +37 -0
  31. package/src/components/ProjectCard.tsx +382 -0
  32. package/src/components/QueryProvider.tsx +25 -0
  33. package/src/components/SessionCard.tsx +291 -0
  34. package/src/components/SessionList.tsx +60 -0
  35. package/src/components/opencode-config/AgentConfigForm.test.tsx +66 -0
  36. package/src/components/opencode-config/AgentConfigForm.tsx +445 -0
  37. package/src/components/opencode-config/AgentModelSelector.tsx +284 -0
  38. package/src/components/opencode-config/AgentsConfigPanel.tsx +162 -0
  39. package/src/components/opencode-config/ConfigButton.tsx +43 -0
  40. package/src/components/opencode-config/ConfigPanel.tsx +91 -0
  41. package/src/components/opencode-config/FullscreenConfigPanel.tsx +360 -0
  42. package/src/components/opencode-config/categories/CategoriesList.tsx +328 -0
  43. package/src/components/opencode-config/categories/CategoriesManager.test.tsx +97 -0
  44. package/src/components/opencode-config/categories/CategoriesManager.tsx +174 -0
  45. package/src/components/opencode-config/categories/CategoryConfigForm.tsx +384 -0
  46. package/src/components/opencode-config/profiles/ProfileCard.tsx +140 -0
  47. package/src/components/opencode-config/profiles/ProfileEditor.tsx +446 -0
  48. package/src/components/opencode-config/profiles/ProfileList.tsx +398 -0
  49. package/src/components/opencode-config/profiles/ProfileManager.test.tsx +122 -0
  50. package/src/components/opencode-config/profiles/ProfileManager.tsx +293 -0
  51. package/src/components/ui/Tabs.tsx +59 -0
  52. package/src/hooks/useOpencodeSync.ts +378 -0
  53. package/src/index.ts +2 -0
  54. package/src/lib/notificationSound.ts +266 -0
  55. package/src/lib/opencodeConfig.test.ts +81 -0
  56. package/src/lib/opencodeConfig.ts +48 -0
  57. package/src/lib/opencodeDiscovery.ts +154 -0
  58. package/src/lib/profiles/storage.ts +264 -0
  59. package/src/lib/transform.ts +84 -0
  60. package/src/test/setup.ts +8 -0
  61. package/src/types/index.ts +89 -0
  62. package/src/types/opencodeConfig.ts +133 -0
  63. package/src/types/testing-library-vitest.d.ts +17 -0
  64. package/tsconfig.json +34 -0
  65. 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.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
  ],
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
@@ -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>
@@ -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>
@@ -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
+ }