@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.
Files changed (93) hide show
  1. package/CLAUDE.md +45 -0
  2. package/bun.lock +3 -0
  3. package/dist/ppm +0 -0
  4. package/dist/web/assets/api-client-BgVufYKf.js +1 -0
  5. package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +1 -0
  6. package/dist/web/assets/button-KIZetva8.js +41 -0
  7. package/dist/web/assets/chat-tab-CNXjLOhI.js +6 -0
  8. package/dist/web/assets/code-editor-tGMPwYNs.js +2 -0
  9. package/dist/web/assets/copy-B-kLwqzg.js +1 -0
  10. package/dist/web/assets/dialog-D8ulRTfX.js +5 -0
  11. package/dist/web/assets/diff-viewer-B4A8pPbo.js +4 -0
  12. package/dist/web/assets/dist-C4W3AGh3.js +1 -0
  13. package/dist/web/assets/dist-PA84y4Ga.js +1 -0
  14. package/dist/web/assets/external-link-Dim3NH6h.js +1 -0
  15. package/dist/web/assets/git-graph-ODjrGZOQ.js +1 -0
  16. package/dist/web/assets/git-status-panel-B0Im1hrU.js +1 -0
  17. package/dist/web/assets/index-BePIZMuy.css +2 -0
  18. package/dist/web/assets/index-D2STBl88.js +10 -0
  19. package/dist/web/assets/{jsx-runtime-BnxRlLMJ.js → jsx-runtime-BFALxl05.js} +1 -1
  20. package/dist/web/assets/marked.esm-Cv8mjgnt.js +59 -0
  21. package/dist/web/assets/project-list-VjQQcU3X.js +1 -0
  22. package/dist/web/assets/{react-Uzd0zARU.js → react-BSLFEYu8.js} +1 -1
  23. package/dist/web/assets/refresh-cw-DJSjl6Ev.js +1 -0
  24. package/dist/web/assets/settings-tab-ChhdL0EG.js +1 -0
  25. package/dist/web/assets/terminal-tab-DDf6S-Tu.js +36 -0
  26. package/dist/web/assets/trash-2-CjahwKg8.js +1 -0
  27. package/dist/web/assets/x-BxhOxZ5p.js +1 -0
  28. package/dist/web/index.html +11 -10
  29. package/dist/web/sw.js +1 -1
  30. package/docs/claude-agent-sdk-reference.md +780 -0
  31. package/docs/codebase-summary.md +11 -13
  32. package/docs/lessons-learned.md +58 -0
  33. package/docs/system-architecture.md +78 -2
  34. package/package.json +2 -1
  35. package/schemas/ppm-config.schema.json +87 -0
  36. package/src/providers/claude-agent-sdk.ts +84 -3
  37. package/src/providers/registry.ts +0 -2
  38. package/src/server/index.ts +7 -1
  39. package/src/server/routes/settings.ts +70 -0
  40. package/src/server/ws/chat.ts +23 -8
  41. package/src/services/git.service.ts +23 -1
  42. package/src/types/chat.ts +8 -1
  43. package/src/types/config.ts +50 -3
  44. package/src/web/app.tsx +8 -0
  45. package/src/web/components/chat/message-input.tsx +1 -1
  46. package/src/web/components/chat/message-list.tsx +112 -251
  47. package/src/web/components/chat/tool-cards.tsx +411 -0
  48. package/src/web/components/editor/code-editor.tsx +80 -20
  49. package/src/web/components/editor/diff-viewer.tsx +72 -7
  50. package/src/web/components/git/git-graph.tsx +3 -0
  51. package/src/web/components/git/git-status-panel.tsx +50 -1
  52. package/src/web/components/layout/command-palette.tsx +215 -0
  53. package/src/web/components/layout/mobile-drawer.tsx +143 -42
  54. package/src/web/components/layout/sidebar.tsx +103 -67
  55. package/src/web/components/layout/tab-bar.tsx +1 -2
  56. package/src/web/components/settings/ai-settings-section.tsx +166 -0
  57. package/src/web/components/settings/settings-tab.tsx +5 -0
  58. package/src/web/components/terminal/terminal-tab.tsx +45 -22
  59. package/src/web/components/ui/input.tsx +4 -3
  60. package/src/web/components/ui/label.tsx +24 -0
  61. package/src/web/components/ui/select.tsx +188 -0
  62. package/src/web/hooks/use-chat.ts +3 -0
  63. package/src/web/hooks/use-global-keybindings.ts +56 -0
  64. package/src/web/hooks/use-terminal.ts +14 -1
  65. package/src/web/lib/api-settings.ts +24 -0
  66. package/src/web/stores/project-store.ts +47 -2
  67. package/src/web/stores/tab-store.ts +1 -1
  68. package/src/web/styles/globals.css +16 -3
  69. package/test-tool.mjs +41 -0
  70. package/dist/web/assets/api-client-Bnf9LAt4.js +0 -1
  71. package/dist/web/assets/arrow-up-from-line-BXL5dtbG.js +0 -1
  72. package/dist/web/assets/button-DxRZgE8F.js +0 -1
  73. package/dist/web/assets/chat-tab-BkCV4ZC9.js +0 -61
  74. package/dist/web/assets/code-editor-f77XD8lZ.js +0 -2
  75. package/dist/web/assets/createLucideIcon-Dy1wlrF7.js +0 -1
  76. package/dist/web/assets/dialog-Db6prp1p.js +0 -45
  77. package/dist/web/assets/diff-viewer-BF7IYlm4.js +0 -4
  78. package/dist/web/assets/external-link-WSiY-639.js +0 -1
  79. package/dist/web/assets/git-graph-Ct1-XDz2.js +0 -1
  80. package/dist/web/assets/git-status-panel-D1rNmbrT.js +0 -1
  81. package/dist/web/assets/index-DYd_2slk.css +0 -2
  82. package/dist/web/assets/index-iwjbGjDp.js +0 -10
  83. package/dist/web/assets/project-list-DB85YVTT.js +0 -1
  84. package/dist/web/assets/refresh-cw-DtopuYJf.js +0 -1
  85. package/dist/web/assets/settings-tab-Ooz2h9Hu.js +0 -1
  86. package/dist/web/assets/terminal-tab-DHwn2LMT.js +0 -36
  87. package/dist/web/assets/trash-2-CHLebaNh.js +0 -1
  88. package/dist/web/assets/x-BISR7bpK.js +0 -1
  89. package/src/providers/claude-binary-finder.ts +0 -256
  90. package/src/providers/claude-code-cli.ts +0 -413
  91. package/src/providers/claude-process-registry.ts +0 -106
  92. /package/dist/web/assets/{dist-CSp7ir0r.js → dist-CBiGQxfr.js} +0 -0
  93. /package/dist/web/assets/{utils-CiBGfeHD.js → utils-DpJF9mAi.js} +0 -0
@@ -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 (7 files, 1444 LOC)
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 (444 LOC)
42
- │ │ ├── claude-code-cli.ts # Fallback: claude CLI binary (412 LOC)
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 (4 files, 264 LOC)
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 (57 LOC)
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
- - **claude-code-cli** — Fallback (subprocess-based)
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",
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
- settingSources: meta.projectPath ? ["project"] : undefined,
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 { type: "done", sessionId };
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
@@ -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.3 ready\n`);
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
+ });