@hienlh/ppm 0.1.3 → 0.1.6
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/CLAUDE.md +45 -0
- package/bun.lock +3 -0
- package/dist/ppm +0 -0
- package/dist/web/assets/api-client-BgVufYKf.js +1 -0
- package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +1 -0
- package/dist/web/assets/button-KIZetva8.js +41 -0
- package/dist/web/assets/chat-tab-CNXjLOhI.js +6 -0
- package/dist/web/assets/code-editor-tGMPwYNs.js +2 -0
- package/dist/web/assets/copy-B-kLwqzg.js +1 -0
- package/dist/web/assets/dialog-D8ulRTfX.js +5 -0
- package/dist/web/assets/diff-viewer-B4A8pPbo.js +4 -0
- package/dist/web/assets/dist-C4W3AGh3.js +1 -0
- package/dist/web/assets/dist-PA84y4Ga.js +1 -0
- package/dist/web/assets/external-link-Dim3NH6h.js +1 -0
- package/dist/web/assets/git-graph-ODjrGZOQ.js +1 -0
- package/dist/web/assets/git-status-panel-B0Im1hrU.js +1 -0
- package/dist/web/assets/index-BePIZMuy.css +2 -0
- package/dist/web/assets/index-D2STBl88.js +10 -0
- package/dist/web/assets/{jsx-runtime-BnxRlLMJ.js → jsx-runtime-BFALxl05.js} +1 -1
- package/dist/web/assets/marked.esm-Cv8mjgnt.js +59 -0
- package/dist/web/assets/project-list-VjQQcU3X.js +1 -0
- package/dist/web/assets/{react-Uzd0zARU.js → react-BSLFEYu8.js} +1 -1
- package/dist/web/assets/refresh-cw-DJSjl6Ev.js +1 -0
- package/dist/web/assets/settings-tab-ChhdL0EG.js +1 -0
- package/dist/web/assets/terminal-tab-DDf6S-Tu.js +36 -0
- package/dist/web/assets/trash-2-CjahwKg8.js +1 -0
- package/dist/web/assets/x-BxhOxZ5p.js +1 -0
- package/dist/web/index.html +11 -10
- package/dist/web/sw.js +1 -1
- package/docs/claude-agent-sdk-reference.md +780 -0
- package/docs/codebase-summary.md +11 -13
- package/docs/lessons-learned.md +58 -0
- package/docs/system-architecture.md +78 -2
- package/package.json +2 -1
- package/schemas/ppm-config.schema.json +87 -0
- package/src/providers/claude-agent-sdk.ts +84 -3
- package/src/providers/registry.ts +0 -2
- package/src/server/index.ts +7 -1
- package/src/server/routes/settings.ts +70 -0
- package/src/server/ws/chat.ts +23 -8
- package/src/services/git.service.ts +23 -1
- package/src/types/chat.ts +8 -1
- package/src/types/config.ts +50 -3
- package/src/web/app.tsx +8 -0
- package/src/web/components/chat/message-input.tsx +1 -1
- package/src/web/components/chat/message-list.tsx +112 -251
- package/src/web/components/chat/tool-cards.tsx +411 -0
- package/src/web/components/editor/code-editor.tsx +80 -20
- package/src/web/components/editor/diff-viewer.tsx +72 -7
- package/src/web/components/git/git-graph.tsx +3 -0
- package/src/web/components/git/git-status-panel.tsx +50 -1
- package/src/web/components/layout/command-palette.tsx +215 -0
- package/src/web/components/layout/mobile-drawer.tsx +143 -42
- package/src/web/components/layout/sidebar.tsx +103 -67
- package/src/web/components/layout/tab-bar.tsx +1 -2
- package/src/web/components/settings/ai-settings-section.tsx +166 -0
- package/src/web/components/settings/settings-tab.tsx +5 -0
- package/src/web/components/terminal/terminal-tab.tsx +45 -22
- package/src/web/components/ui/input.tsx +4 -3
- package/src/web/components/ui/label.tsx +24 -0
- package/src/web/components/ui/select.tsx +188 -0
- package/src/web/hooks/use-chat.ts +3 -0
- package/src/web/hooks/use-global-keybindings.ts +56 -0
- package/src/web/hooks/use-terminal.ts +14 -1
- package/src/web/lib/api-settings.ts +24 -0
- package/src/web/stores/project-store.ts +47 -2
- package/src/web/stores/tab-store.ts +1 -1
- package/src/web/styles/globals.css +16 -3
- package/test-tool.mjs +41 -0
- package/dist/web/assets/api-client-Bnf9LAt4.js +0 -1
- package/dist/web/assets/arrow-up-from-line-BXL5dtbG.js +0 -1
- package/dist/web/assets/button-DxRZgE8F.js +0 -1
- package/dist/web/assets/chat-tab-BkCV4ZC9.js +0 -61
- package/dist/web/assets/code-editor-f77XD8lZ.js +0 -2
- package/dist/web/assets/createLucideIcon-Dy1wlrF7.js +0 -1
- package/dist/web/assets/dialog-Db6prp1p.js +0 -45
- package/dist/web/assets/diff-viewer-BF7IYlm4.js +0 -4
- package/dist/web/assets/external-link-WSiY-639.js +0 -1
- package/dist/web/assets/git-graph-Ct1-XDz2.js +0 -1
- package/dist/web/assets/git-status-panel-D1rNmbrT.js +0 -1
- package/dist/web/assets/index-DYd_2slk.css +0 -2
- package/dist/web/assets/index-iwjbGjDp.js +0 -10
- package/dist/web/assets/project-list-DB85YVTT.js +0 -1
- package/dist/web/assets/refresh-cw-DtopuYJf.js +0 -1
- package/dist/web/assets/settings-tab-Ooz2h9Hu.js +0 -1
- package/dist/web/assets/terminal-tab-DHwn2LMT.js +0 -36
- package/dist/web/assets/trash-2-CHLebaNh.js +0 -1
- package/dist/web/assets/x-BISR7bpK.js +0 -1
- package/src/providers/claude-binary-finder.ts +0 -256
- package/src/providers/claude-code-cli.ts +0 -413
- package/src/providers/claude-process-registry.ts +0 -106
- /package/dist/web/assets/{dist-CSp7ir0r.js → dist-CBiGQxfr.js} +0 -0
- /package/dist/web/assets/{utils-CiBGfeHD.js → utils-DpJF9mAi.js} +0 -0
package/docs/codebase-summary.md
CHANGED
|
@@ -25,6 +25,7 @@ ppm/
|
|
|
25
25
|
│ │ ├── middleware/
|
|
26
26
|
│ │ │ └── auth.ts # Token validation middleware
|
|
27
27
|
│ │ ├── routes/
|
|
28
|
+
│ │ │ ├── settings.ts # GET/PUT /api/settings/ai (AI provider config)
|
|
28
29
|
│ │ │ ├── projects.ts # GET/POST /api/projects, DELETE /:name
|
|
29
30
|
│ │ │ ├── project-scoped.ts # Mount chat, git, files under /api/project/:name/*
|
|
30
31
|
│ │ │ ├── chat.ts # GET/POST/DELETE sessions, GET messages, usage, slash-items
|
|
@@ -36,13 +37,10 @@ ppm/
|
|
|
36
37
|
│ │ └── ws/
|
|
37
38
|
│ │ ├── chat.ts # WebSocket chat streaming (220 LOC)
|
|
38
39
|
│ │ └── terminal.ts # WebSocket terminal I/O (terminal.service.ts integration)
|
|
39
|
-
│ ├── providers/ # AI Provider adapters (
|
|
40
|
+
│ ├── providers/ # AI Provider adapters (3 files, 574 LOC)
|
|
40
41
|
│ │ ├── provider.interface.ts # AIProvider interface (createSession, sendMessage, onToolApproval)
|
|
41
|
-
│ │ ├── claude-agent-sdk.ts # Primary: @anthropic-ai/claude-agent-sdk
|
|
42
|
-
│ │ ├──
|
|
43
|
-
│ │ ├── mock-provider.ts # Test provider
|
|
44
|
-
│ │ ├── claude-binary-finder.ts # Find claude CLI in PATH
|
|
45
|
-
│ │ ├── claude-process-registry.ts # Track running claude processes
|
|
42
|
+
│ │ ├── claude-agent-sdk.ts # Primary: @anthropic-ai/claude-agent-sdk. Reads config from configService.
|
|
43
|
+
│ │ ├── mock-provider.ts # Test provider (ignores config)
|
|
46
44
|
│ │ └── registry.ts # ProviderRegistry (singleton, router to active provider)
|
|
47
45
|
│ ├── services/ # Business logic (9 files, 1561 LOC)
|
|
48
46
|
│ │ ├── chat.service.ts # Session lifecycle, message streaming, streaming to clients
|
|
@@ -74,8 +72,9 @@ ppm/
|
|
|
74
72
|
│ │ ├── use-websocket.ts # Generic WebSocket adapter
|
|
75
73
|
│ │ ├── use-terminal.ts # Terminal I/O over WebSocket
|
|
76
74
|
│ │ └── use-url-sync.ts # Sync state to URL (project, tab, file selections)
|
|
77
|
-
│ ├── lib/ # Utilities (
|
|
75
|
+
│ ├── lib/ # Utilities (5 files, 290 LOC)
|
|
78
76
|
│ │ ├── api-client.ts # Fetch wrapper with auth token
|
|
77
|
+
│ │ ├── api-settings.ts # AI settings API client (GET/PUT /api/settings/ai)
|
|
79
78
|
│ │ ├── ws-client.ts # WebSocket wrapper
|
|
80
79
|
│ │ ├── file-support.ts # File type detection (language -> icon)
|
|
81
80
|
│ │ └── utils.ts # Utility functions (clsx, classname merging)
|
|
@@ -108,7 +107,7 @@ ppm/
|
|
|
108
107
|
│ │ ├── mobile-nav.tsx # Mobile hamburger navigation
|
|
109
108
|
│ │ └── mobile-drawer.tsx # Offcanvas drawer
|
|
110
109
|
│ ├── projects/ # Project management (339 LOC, 2 files)
|
|
111
|
-
│ ├── settings/ # Settings panel (
|
|
110
|
+
│ ├── settings/ # Settings panel (theme + AI provider config UI)
|
|
112
111
|
│ ├── terminal/ # xterm.js wrapper (143 LOC, 2 files)
|
|
113
112
|
│ └── ui/ # Radix + shadcn primitives (1018 LOC, 10 files)
|
|
114
113
|
│ └── button.tsx, dialog.tsx, dropdown-menu.tsx, ... (base components)
|
|
@@ -176,13 +175,12 @@ ppm/
|
|
|
176
175
|
- **Pattern:** Singleton services, dependency injection via imports
|
|
177
176
|
|
|
178
177
|
### Provider Layer (src/providers/)
|
|
179
|
-
- **Responsibility:** AI model abstraction
|
|
178
|
+
- **Responsibility:** AI model abstraction, config-driven initialization
|
|
180
179
|
- **Providers:**
|
|
181
|
-
- **claude-agent-sdk** — Primary (official SDK, streaming, tool use)
|
|
182
|
-
- **
|
|
183
|
-
- **mock** — Test provider
|
|
180
|
+
- **claude-agent-sdk** — Primary (official SDK, streaming, tool use). Reads model/effort/maxTurns/budget/thinking from config.
|
|
181
|
+
- **mock** — Test provider (ignores config)
|
|
184
182
|
- **Interface:** Async generator streaming, tool approval callback
|
|
185
|
-
- **Pattern:** Registry pattern for pluggable AI providers
|
|
183
|
+
- **Pattern:** Registry pattern for pluggable AI providers. Config read fresh per query (configService integration).
|
|
186
184
|
|
|
187
185
|
### Frontend Layer (src/web/)
|
|
188
186
|
- **Responsibility:** React UI for project management, chat, terminal, editor
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Lessons Learned
|
|
2
|
+
|
|
3
|
+
Knowledge and gotchas discovered during PPM development.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Claude Agent SDK
|
|
8
|
+
|
|
9
|
+
### .env poisoning via project cwd
|
|
10
|
+
|
|
11
|
+
**Problem**: SDK spawns a CLI process in the project's `cwd`. The CLI auto-loads `.env` via dotenv. If the project has `ANTHROPIC_API_KEY=dummy` or `ANTHROPIC_BASE_URL=http://localhost:...`, the CLI uses those instead of the user's subscription → "Invalid API key" or empty responses with no tool execution.
|
|
12
|
+
|
|
13
|
+
**Symptoms**:
|
|
14
|
+
- Model returns empty response, no text or tool_use events
|
|
15
|
+
- `result.subtype === "error_during_execution"`
|
|
16
|
+
- Text response: "Invalid API key · Fix external API key"
|
|
17
|
+
- `totalCostUsd: 0` in usage
|
|
18
|
+
|
|
19
|
+
**Fix**: Neutralize `ANTHROPIC_*` env vars by setting them to empty string (not deleting — dotenv won't override existing vars):
|
|
20
|
+
```ts
|
|
21
|
+
env: {
|
|
22
|
+
...process.env,
|
|
23
|
+
ANTHROPIC_API_KEY: "",
|
|
24
|
+
ANTHROPIC_BASE_URL: "",
|
|
25
|
+
ANTHROPIC_AUTH_TOKEN: "",
|
|
26
|
+
},
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**File**: `src/providers/claude-agent-sdk.ts`
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
### Project-local Claude settings restrict tools
|
|
34
|
+
|
|
35
|
+
**Problem**: Projects may have `.claude/settings.local.json` with restrictive `permissions.allow` lists (e.g., only `Bash(python:*)`, `Bash(ls:*)`). Even with `permissionMode: "bypassPermissions"`, the SDK CLI still reads these and restricts available tools.
|
|
36
|
+
|
|
37
|
+
**Fix**: Override with explicit empty settings and no setting sources:
|
|
38
|
+
```ts
|
|
39
|
+
settings: { permissions: { allow: [], deny: [] } },
|
|
40
|
+
settingSources: [],
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## WebSocket Chat Architecture
|
|
46
|
+
|
|
47
|
+
### Event flow: SDK → Provider → WS → Frontend
|
|
48
|
+
|
|
49
|
+
1. SDK emits: `system` → `stream_event`* → `assistant` → `rate_limit_event` → `user` (tool_result) → `result`
|
|
50
|
+
2. Provider extracts text from `stream_event.event.delta.text` and tool_use from `assistant.message.content`
|
|
51
|
+
3. Provider yields: `text`, `tool_use`, `tool_result`, `usage`, `done`
|
|
52
|
+
4. WS handler sends JSON to frontend
|
|
53
|
+
|
|
54
|
+
Key: `stream_event` contains raw API events (`content_block_delta` with `text_delta`). The `assistant` event contains the full message with all content blocks.
|
|
55
|
+
|
|
56
|
+
### tool_result lives in `user` events
|
|
57
|
+
|
|
58
|
+
SDK returns tool results as `user` type messages (not `assistant`). Provider fetches them via `getSessionMessages()` after detecting `pendingToolCount > 0`.
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
│ │ ppm.yaml │ │ Git Repos │ │ Session Storage │ │
|
|
46
46
|
│ │ (projects list) │ │ (local disk) │ │ (in-memory only)│ │
|
|
47
47
|
│ │ (auth token) │ │ │ │ │ │
|
|
48
|
+
│ │ (AI provider │ │ │ │ │ │
|
|
49
|
+
│ │ settings) │ │ │ │ │ │
|
|
48
50
|
│ └──────────────────┘ └──────────────────┘ └─────────────────┘ │
|
|
49
51
|
└──────────────────────────────────────────────────────────────────────┘
|
|
50
52
|
↓↑
|
|
@@ -93,6 +95,8 @@
|
|
|
93
95
|
```
|
|
94
96
|
GET /api/health → Health check
|
|
95
97
|
GET /api/auth/check → Verify auth token
|
|
98
|
+
GET /api/settings/ai → Get AI provider settings
|
|
99
|
+
PUT /api/settings/ai → Update AI provider settings
|
|
96
100
|
POST /api/projects → Create project
|
|
97
101
|
GET /api/projects → List projects
|
|
98
102
|
DELETE /api/projects/:name → Delete project
|
|
@@ -157,9 +161,9 @@ interface AIProvider {
|
|
|
157
161
|
```
|
|
158
162
|
|
|
159
163
|
**Implementations:**
|
|
160
|
-
- **claude-agent-sdk** (Primary) — @anthropic-ai/claude-agent-sdk, streaming, tool use
|
|
161
|
-
- **claude-code-cli** (Fallback) — Claude CLI subprocess, for offline environments
|
|
164
|
+
- **claude-agent-sdk** (Primary) — @anthropic-ai/claude-agent-sdk, streaming, tool use. Reads model/effort/maxTurns/budget/thinking from `ppm.yaml` AI config. Settings refreshed per query.
|
|
162
165
|
- **mock-provider** (Testing) — Returns canned responses
|
|
166
|
+
- **Note:** CLI provider removed (v2); agent SDK now sole AI provider
|
|
163
167
|
|
|
164
168
|
---
|
|
165
169
|
|
|
@@ -279,6 +283,78 @@ For each API request:
|
|
|
279
283
|
|
|
280
284
|
---
|
|
281
285
|
|
|
286
|
+
## AI Provider Configuration
|
|
287
|
+
|
|
288
|
+
PPM exposes AI settings as global configuration (not per-session) via REST API and Settings UI. Configuration is stored in `ppm.yaml` and read fresh per query.
|
|
289
|
+
|
|
290
|
+
### Configuration Shape
|
|
291
|
+
```yaml
|
|
292
|
+
ai:
|
|
293
|
+
default_provider: claude
|
|
294
|
+
providers:
|
|
295
|
+
claude:
|
|
296
|
+
type: agent-sdk
|
|
297
|
+
api_key_env: ANTHROPIC_API_KEY
|
|
298
|
+
model: claude-sonnet-4-6
|
|
299
|
+
effort: high
|
|
300
|
+
max_turns: 100
|
|
301
|
+
max_budget_usd: 2.00
|
|
302
|
+
thinking_budget_tokens: 10000
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
**Fields:**
|
|
306
|
+
- `default_provider`: Active provider name (e.g., `claude`)
|
|
307
|
+
- `type`: Provider type (`agent-sdk` or `mock`)
|
|
308
|
+
- `api_key_env`: Environment variable containing API key
|
|
309
|
+
- `model`: Model ID (e.g., `claude-sonnet-4-6`, `claude-opus-4-6`)
|
|
310
|
+
- `effort`: Processing level (`low`, `medium`, `high`, `max`)
|
|
311
|
+
- `max_turns`: Maximum interaction turns (1-500, default 100)
|
|
312
|
+
- `max_budget_usd`: Spending limit in USD (optional)
|
|
313
|
+
- `thinking_budget_tokens`: Extended thinking budget in tokens (optional, 0=disabled)
|
|
314
|
+
|
|
315
|
+
### API Endpoints
|
|
316
|
+
|
|
317
|
+
**GET /api/settings/ai** — Fetch current AI config
|
|
318
|
+
```json
|
|
319
|
+
{
|
|
320
|
+
"ok": true,
|
|
321
|
+
"data": {
|
|
322
|
+
"default_provider": "claude",
|
|
323
|
+
"providers": { "claude": {...} }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
**PUT /api/settings/ai** — Update AI config (shallow merge per provider)
|
|
329
|
+
```json
|
|
330
|
+
{
|
|
331
|
+
"providers": {
|
|
332
|
+
"claude": {
|
|
333
|
+
"model": "claude-opus-4-6",
|
|
334
|
+
"max_turns": 50
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
Returns full updated config. Validates ranges/enums before writing.
|
|
340
|
+
|
|
341
|
+
### How Provider Uses Settings
|
|
342
|
+
|
|
343
|
+
1. **SDK Provider (`sendMessage`)**
|
|
344
|
+
- Calls `getProviderConfig()` to read fresh config from `configService`
|
|
345
|
+
- Maps snake_case config to camelCase SDK options
|
|
346
|
+
- Passes `model`, `effort`, `maxTurns`, `maxBudgetUsd`, `thinkingBudgetTokens` to `query()`
|
|
347
|
+
- Falls back to defaults if fields not set
|
|
348
|
+
|
|
349
|
+
2. **Mock Provider**
|
|
350
|
+
- Ignores AI settings (always returns canned responses for testing)
|
|
351
|
+
|
|
352
|
+
3. **Changes Take Effect**
|
|
353
|
+
- Immediately on next query (config read fresh each time)
|
|
354
|
+
- No active queries affected (config mid-flight not re-evaluated)
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
282
358
|
## Chat Streaming Flow
|
|
283
359
|
|
|
284
360
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hienlh/ppm",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Personal Project Manager — mobile-first web IDE with AI assistance",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"@xterm/addon-web-links": "^0.12.0",
|
|
49
49
|
"@xterm/xterm": "^6.0.0",
|
|
50
50
|
"ccburn": "^0.6.0",
|
|
51
|
+
"class-variance-authority": "^0.7.1",
|
|
51
52
|
"clsx": "^2.1.1",
|
|
52
53
|
"codemirror": "^6.0.2",
|
|
53
54
|
"commander": "^14.0.3",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "PPM Configuration",
|
|
4
|
+
"description": "Configuration file for PPM (Project & Process Manager)",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"port": {
|
|
8
|
+
"type": "integer",
|
|
9
|
+
"default": 8080,
|
|
10
|
+
"description": "Server port"
|
|
11
|
+
},
|
|
12
|
+
"host": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"default": "0.0.0.0",
|
|
15
|
+
"description": "Server bind address"
|
|
16
|
+
},
|
|
17
|
+
"auth": {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"properties": {
|
|
20
|
+
"enabled": { "type": "boolean", "default": true },
|
|
21
|
+
"token": { "type": "string" }
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"projects": {
|
|
25
|
+
"type": "array",
|
|
26
|
+
"items": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {
|
|
29
|
+
"path": { "type": "string" },
|
|
30
|
+
"name": { "type": "string" }
|
|
31
|
+
},
|
|
32
|
+
"required": ["path", "name"]
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"ai": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"default_provider": { "type": "string", "default": "claude" },
|
|
39
|
+
"providers": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"additionalProperties": {
|
|
42
|
+
"$ref": "#/$defs/AIProviderConfig"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"$defs": {
|
|
49
|
+
"AIProviderConfig": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"properties": {
|
|
52
|
+
"type": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"enum": ["agent-sdk", "mock"]
|
|
55
|
+
},
|
|
56
|
+
"api_key_env": { "type": "string" },
|
|
57
|
+
"model": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"description": "Model ID (e.g. claude-sonnet-4-6, claude-opus-4-6)",
|
|
60
|
+
"examples": ["claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5"]
|
|
61
|
+
},
|
|
62
|
+
"effort": {
|
|
63
|
+
"type": "string",
|
|
64
|
+
"enum": ["low", "medium", "high", "max"],
|
|
65
|
+
"default": "high"
|
|
66
|
+
},
|
|
67
|
+
"max_turns": {
|
|
68
|
+
"type": "integer",
|
|
69
|
+
"minimum": 1,
|
|
70
|
+
"maximum": 500,
|
|
71
|
+
"default": 100
|
|
72
|
+
},
|
|
73
|
+
"max_budget_usd": {
|
|
74
|
+
"type": "number",
|
|
75
|
+
"minimum": 0.01,
|
|
76
|
+
"maximum": 50
|
|
77
|
+
},
|
|
78
|
+
"thinking_budget_tokens": {
|
|
79
|
+
"type": "integer",
|
|
80
|
+
"minimum": 0,
|
|
81
|
+
"description": "0 = disabled"
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"required": ["type"]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
ChatMessage,
|
|
13
13
|
UsageInfo,
|
|
14
14
|
} from "./provider.interface.ts";
|
|
15
|
+
import { configService } from "../services/config.service.ts";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Pending approval: canUseTool callback creates a promise,
|
|
@@ -39,6 +40,13 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
39
40
|
/** Latest known usage/rate-limit info (shared across all sessions) */
|
|
40
41
|
private latestUsage: UsageInfo = {};
|
|
41
42
|
|
|
43
|
+
/** Read current provider config from yaml (fresh each call) */
|
|
44
|
+
private getProviderConfig(): Partial<import("../types/config.ts").AIProviderConfig> {
|
|
45
|
+
const ai = configService.get("ai");
|
|
46
|
+
const providerId = ai.default_provider ?? "claude";
|
|
47
|
+
return ai.providers[providerId] ?? {};
|
|
48
|
+
}
|
|
49
|
+
|
|
42
50
|
async createSession(config: SessionConfig): Promise<Session> {
|
|
43
51
|
const id = crypto.randomUUID();
|
|
44
52
|
const meta: Session = {
|
|
@@ -176,9 +184,17 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
176
184
|
|
|
177
185
|
const requestId = crypto.randomUUID();
|
|
178
186
|
|
|
187
|
+
const APPROVAL_TIMEOUT_MS = 60_000;
|
|
179
188
|
const approvalPromise = new Promise<{ approved: boolean; data?: unknown }>(
|
|
180
189
|
(resolve) => {
|
|
181
190
|
this.pendingApprovals.set(requestId, { resolve });
|
|
191
|
+
// Auto-deny after timeout if FE doesn't respond
|
|
192
|
+
setTimeout(() => {
|
|
193
|
+
if (this.pendingApprovals.has(requestId)) {
|
|
194
|
+
this.pendingApprovals.delete(requestId);
|
|
195
|
+
resolve({ approved: false });
|
|
196
|
+
}
|
|
197
|
+
}, APPROVAL_TIMEOUT_MS);
|
|
182
198
|
},
|
|
183
199
|
);
|
|
184
200
|
|
|
@@ -191,7 +207,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
191
207
|
});
|
|
192
208
|
approvalNotify?.();
|
|
193
209
|
|
|
194
|
-
// Wait for FE to send back answers
|
|
210
|
+
// Wait for FE to send back answers (or timeout)
|
|
195
211
|
const result = await approvalPromise;
|
|
196
212
|
|
|
197
213
|
if (result.approved && result.data) {
|
|
@@ -204,21 +220,46 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
204
220
|
};
|
|
205
221
|
|
|
206
222
|
let assistantContent = "";
|
|
223
|
+
let resultSubtype: string | undefined;
|
|
224
|
+
let resultNumTurns: number | undefined;
|
|
207
225
|
|
|
208
226
|
try {
|
|
227
|
+
const providerConfig = this.getProviderConfig();
|
|
228
|
+
|
|
209
229
|
const q = query({
|
|
210
230
|
prompt: message,
|
|
211
231
|
options: {
|
|
212
232
|
sessionId: isFirstMessage ? sessionId : undefined,
|
|
213
233
|
resume: isFirstMessage ? undefined : sessionId,
|
|
214
234
|
cwd: meta.projectPath,
|
|
215
|
-
|
|
235
|
+
// Use full Claude Code system prompt (coding guidelines, security, response style)
|
|
236
|
+
systemPrompt: { type: "preset", preset: "claude_code" },
|
|
237
|
+
// Load project CLAUDE.md, skills, and hooks from project directory
|
|
238
|
+
settingSources: ["project"],
|
|
239
|
+
// Neutralize Anthropic env vars so SDK uses subscription, not project .env keys.
|
|
240
|
+
env: {
|
|
241
|
+
...process.env,
|
|
242
|
+
ANTHROPIC_API_KEY: "",
|
|
243
|
+
ANTHROPIC_BASE_URL: "",
|
|
244
|
+
ANTHROPIC_AUTH_TOKEN: "",
|
|
245
|
+
},
|
|
246
|
+
// Override project-local Claude settings that may restrict tool permissions
|
|
247
|
+
settings: { permissions: { allow: [], deny: [] } },
|
|
216
248
|
allowedTools: [
|
|
217
249
|
"Read", "Write", "Edit", "Bash", "Glob", "Grep",
|
|
218
250
|
"WebSearch", "WebFetch", "AskUserQuestion",
|
|
251
|
+
"Agent", "Skill", "TodoWrite", "ToolSearch",
|
|
219
252
|
],
|
|
220
253
|
permissionMode: "bypassPermissions",
|
|
221
254
|
allowDangerouslySkipPermissions: true,
|
|
255
|
+
// Config-driven values from ppm.yaml
|
|
256
|
+
...(providerConfig.model && { model: providerConfig.model }),
|
|
257
|
+
...(providerConfig.effort && { effort: providerConfig.effort }),
|
|
258
|
+
maxTurns: providerConfig.max_turns ?? 100,
|
|
259
|
+
...(providerConfig.max_budget_usd && { maxBudgetUsd: providerConfig.max_budget_usd }),
|
|
260
|
+
...(providerConfig.thinking_budget_tokens != null && {
|
|
261
|
+
thinkingBudgetTokens: providerConfig.thinking_budget_tokens,
|
|
262
|
+
}),
|
|
222
263
|
canUseTool,
|
|
223
264
|
includePartialMessages: true,
|
|
224
265
|
} as any,
|
|
@@ -232,11 +273,27 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
232
273
|
let pendingToolCount = 0;
|
|
233
274
|
|
|
234
275
|
for await (const msg of q) {
|
|
276
|
+
// Debug: log all SDK events to understand flow
|
|
277
|
+
|
|
235
278
|
// Yield any queued approval events
|
|
236
279
|
while (approvalEvents.length > 0) {
|
|
237
280
|
yield approvalEvents.shift()!;
|
|
238
281
|
}
|
|
239
282
|
|
|
283
|
+
// Capture SDK session metadata from init message
|
|
284
|
+
if (msg.type === "system" && (msg as any).subtype === "init") {
|
|
285
|
+
const initMsg = msg as any;
|
|
286
|
+
// SDK may assign a different session_id than our UUID
|
|
287
|
+
if (initMsg.session_id && initMsg.session_id !== sessionId) {
|
|
288
|
+
// Update our mapping so resume works with SDK's actual ID
|
|
289
|
+
const oldMeta = this.activeSessions.get(sessionId);
|
|
290
|
+
if (oldMeta) {
|
|
291
|
+
this.activeSessions.set(initMsg.session_id, { ...oldMeta, id: initMsg.session_id });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
240
297
|
// When tools were pending and a new assistant/stream_event arrives,
|
|
241
298
|
// the SDK has finished executing tools. Fetch tool_results from session history.
|
|
242
299
|
if (pendingToolCount > 0 && (msg.type === "assistant" || (msg as any).type === "partial" || (msg as any).type === "stream_event")) {
|
|
@@ -369,12 +426,31 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
369
426
|
}
|
|
370
427
|
|
|
371
428
|
const result = msg as any;
|
|
429
|
+
const subtype = result.subtype as string | undefined;
|
|
430
|
+
|
|
372
431
|
// Yield final cost + any rate limit info from result
|
|
373
432
|
const usage: Record<string, unknown> = {};
|
|
374
433
|
if (result.total_cost_usd != null) usage.totalCostUsd = result.total_cost_usd;
|
|
375
434
|
if (Object.keys(usage).length > 0) {
|
|
376
435
|
yield { type: "usage", usage };
|
|
377
436
|
}
|
|
437
|
+
|
|
438
|
+
// Surface non-success subtypes as errors so FE can display them
|
|
439
|
+
if (subtype && subtype !== "success") {
|
|
440
|
+
const errorMessages: Record<string, string> = {
|
|
441
|
+
error_max_turns: "Agent reached maximum turn limit.",
|
|
442
|
+
error_max_budget_usd: "Agent reached budget limit.",
|
|
443
|
+
error_during_execution: "Agent encountered an error during execution.",
|
|
444
|
+
};
|
|
445
|
+
yield {
|
|
446
|
+
type: "error",
|
|
447
|
+
message: errorMessages[subtype] ?? `Agent stopped: ${subtype}`,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Store subtype and numTurns for the done event
|
|
452
|
+
resultSubtype = subtype;
|
|
453
|
+
resultNumTurns = result.num_turns as number | undefined;
|
|
378
454
|
break;
|
|
379
455
|
}
|
|
380
456
|
}
|
|
@@ -393,7 +469,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
393
469
|
this.activeQueries.delete(sessionId);
|
|
394
470
|
}
|
|
395
471
|
|
|
396
|
-
yield {
|
|
472
|
+
yield {
|
|
473
|
+
type: "done",
|
|
474
|
+
sessionId,
|
|
475
|
+
resultSubtype: resultSubtype as any,
|
|
476
|
+
numTurns: resultNumTurns,
|
|
477
|
+
};
|
|
397
478
|
}
|
|
398
479
|
|
|
399
480
|
/** Get latest cached usage/rate-limit info */
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { AIProvider } from "./provider.interface.ts";
|
|
2
2
|
import { MockProvider } from "./mock-provider.ts";
|
|
3
|
-
import { ClaudeCodeCliProvider } from "./claude-code-cli.ts";
|
|
4
3
|
import { ClaudeAgentSdkProvider } from "./claude-agent-sdk.ts";
|
|
5
4
|
|
|
6
5
|
export interface ProviderInfo {
|
|
@@ -41,5 +40,4 @@ class ProviderRegistry {
|
|
|
41
40
|
/** Singleton registry — first registered = default */
|
|
42
41
|
export const providerRegistry = new ProviderRegistry();
|
|
43
42
|
providerRegistry.register(new ClaudeAgentSdkProvider()); // default — real streaming, multi-turn
|
|
44
|
-
providerRegistry.register(new ClaudeCodeCliProvider()); // fallback — spawns claude CLI
|
|
45
43
|
providerRegistry.register(new MockProvider()); // testing only
|
package/src/server/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { cors } from "hono/cors";
|
|
|
3
3
|
import { configService } from "../services/config.service.ts";
|
|
4
4
|
import { authMiddleware } from "./middleware/auth.ts";
|
|
5
5
|
import { projectRoutes } from "./routes/projects.ts";
|
|
6
|
+
import { settingsRoutes } from "./routes/settings.ts";
|
|
6
7
|
import { staticRoutes } from "./routes/static.ts";
|
|
7
8
|
import { projectScopedRouter } from "./routes/project-scoped.ts";
|
|
8
9
|
import { terminalWebSocket } from "./ws/terminal.ts";
|
|
@@ -22,6 +23,7 @@ app.use("/api/*", authMiddleware);
|
|
|
22
23
|
app.get("/api/auth/check", (c) => c.json(ok(true)));
|
|
23
24
|
|
|
24
25
|
// API routes
|
|
26
|
+
app.route("/api/settings", settingsRoutes);
|
|
25
27
|
app.route("/api/projects", projectRoutes);
|
|
26
28
|
app.route("/api/project/:projectName", projectScopedRouter);
|
|
27
29
|
|
|
@@ -99,6 +101,8 @@ export async function startServer(options: {
|
|
|
99
101
|
return app.fetch(req, server);
|
|
100
102
|
},
|
|
101
103
|
websocket: {
|
|
104
|
+
idleTimeout: 960, // 16 minutes — keepalive ping handles liveness
|
|
105
|
+
sendPong: true,
|
|
102
106
|
open(ws: any) {
|
|
103
107
|
if (ws.data?.type === "chat") chatWebSocket.open(ws);
|
|
104
108
|
else terminalWebSocket.open(ws);
|
|
@@ -114,7 +118,7 @@ export async function startServer(options: {
|
|
|
114
118
|
} as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
|
|
115
119
|
});
|
|
116
120
|
|
|
117
|
-
console.log(`\n PPM v0.1.
|
|
121
|
+
console.log(`\n PPM v0.1.6 ready\n`);
|
|
118
122
|
console.log(` ➜ Local: http://localhost:${server.port}/`);
|
|
119
123
|
|
|
120
124
|
// List all network interfaces
|
|
@@ -178,6 +182,8 @@ if (process.argv.includes("__serve__")) {
|
|
|
178
182
|
return app.fetch(req, server);
|
|
179
183
|
},
|
|
180
184
|
websocket: {
|
|
185
|
+
idleTimeout: 960,
|
|
186
|
+
sendPong: true,
|
|
181
187
|
open(ws: any) {
|
|
182
188
|
if (ws.data?.type === "chat") chatWebSocket.open(ws);
|
|
183
189
|
else terminalWebSocket.open(ws);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { configService } from "../../services/config.service.ts";
|
|
3
|
+
import {
|
|
4
|
+
validateAIProviderConfig,
|
|
5
|
+
validateDefaultProvider,
|
|
6
|
+
type AIProviderConfig,
|
|
7
|
+
} from "../../types/config.ts";
|
|
8
|
+
import { ok, err } from "../../types/api.ts";
|
|
9
|
+
|
|
10
|
+
export const settingsRoutes = new Hono();
|
|
11
|
+
|
|
12
|
+
/** GET /settings/ai — return current AI config (strips api_key_env) */
|
|
13
|
+
settingsRoutes.get("/ai", (c) => {
|
|
14
|
+
const ai = structuredClone(configService.get("ai"));
|
|
15
|
+
// Strip sensitive env var names from response
|
|
16
|
+
for (const provider of Object.values(ai.providers)) {
|
|
17
|
+
delete (provider as unknown as Record<string, unknown>).api_key_env;
|
|
18
|
+
}
|
|
19
|
+
return c.json(ok(ai));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** PUT /settings/ai — update AI provider settings, writes to yaml */
|
|
23
|
+
settingsRoutes.put("/ai", async (c) => {
|
|
24
|
+
try {
|
|
25
|
+
const body = await c.req.json<{
|
|
26
|
+
default_provider?: string;
|
|
27
|
+
providers?: Record<string, Partial<AIProviderConfig>>;
|
|
28
|
+
}>();
|
|
29
|
+
|
|
30
|
+
const currentAi = configService.get("ai");
|
|
31
|
+
|
|
32
|
+
// Validate each provider config
|
|
33
|
+
if (body.providers) {
|
|
34
|
+
for (const [name, providerConfig] of Object.entries(body.providers)) {
|
|
35
|
+
const errors = validateAIProviderConfig(providerConfig);
|
|
36
|
+
if (errors.length > 0) {
|
|
37
|
+
return c.json(err(`Provider "${name}": ${errors.join(", ")}`), 400);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Merge: body overrides current values (shallow merge per provider)
|
|
43
|
+
const updated = {
|
|
44
|
+
...currentAi,
|
|
45
|
+
...(body.default_provider && { default_provider: body.default_provider }),
|
|
46
|
+
};
|
|
47
|
+
if (body.providers) {
|
|
48
|
+
updated.providers = { ...currentAi.providers };
|
|
49
|
+
for (const [name, config] of Object.entries(body.providers)) {
|
|
50
|
+
updated.providers[name] = {
|
|
51
|
+
...currentAi.providers[name],
|
|
52
|
+
...config,
|
|
53
|
+
} as AIProviderConfig;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Validate default_provider references existing provider
|
|
58
|
+
if (body.default_provider) {
|
|
59
|
+
const dpErr = validateDefaultProvider(updated.default_provider, updated.providers);
|
|
60
|
+
if (dpErr) return c.json(err(dpErr), 400);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
configService.set("ai", updated);
|
|
64
|
+
configService.save();
|
|
65
|
+
|
|
66
|
+
return c.json(ok(updated));
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return c.json(err((e as Error).message), 400);
|
|
69
|
+
}
|
|
70
|
+
});
|