@hienlh/ppm 0.1.4 → 0.2.0

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 (102) hide show
  1. package/CLAUDE.md +45 -0
  2. package/bun.lock +55 -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-D7dR7kbZ.js +6 -0
  8. package/dist/web/assets/code-editor-r8P6Gk4M.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-vSvrem_i.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-Cn-s1k0-.js +1 -0
  16. package/dist/web/assets/git-status-panel-QjAQzNAi.js +1 -0
  17. package/dist/web/assets/index-DUBI96T5.css +2 -0
  18. package/dist/web/assets/index-nk1dAWff.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-DqiatpaH.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-iCGeFFdt.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/code-standards.md +74 -0
  32. package/docs/codebase-summary.md +22 -20
  33. package/docs/deployment-guide.md +81 -11
  34. package/docs/lessons-learned.md +58 -0
  35. package/docs/project-overview-pdr.md +62 -2
  36. package/docs/system-architecture.md +102 -10
  37. package/package.json +4 -1
  38. package/schemas/ppm-config.schema.json +87 -0
  39. package/src/cli/commands/init.ts +186 -43
  40. package/src/cli/commands/status.ts +73 -0
  41. package/src/cli/commands/stop.ts +24 -10
  42. package/src/index.ts +28 -5
  43. package/src/providers/claude-agent-sdk.ts +84 -3
  44. package/src/providers/registry.ts +0 -2
  45. package/src/server/index.ts +106 -15
  46. package/src/server/routes/settings.ts +70 -0
  47. package/src/server/ws/chat.ts +8 -6
  48. package/src/services/cloudflared.service.ts +99 -0
  49. package/src/services/git.service.ts +23 -1
  50. package/src/services/tunnel.service.ts +100 -0
  51. package/src/types/chat.ts +8 -1
  52. package/src/types/config.ts +50 -3
  53. package/src/web/app.tsx +10 -2
  54. package/src/web/components/auth/login-screen.tsx +1 -1
  55. package/src/web/components/chat/message-input.tsx +1 -1
  56. package/src/web/components/chat/message-list.tsx +112 -251
  57. package/src/web/components/chat/tool-cards.tsx +411 -0
  58. package/src/web/components/editor/code-editor.tsx +80 -20
  59. package/src/web/components/editor/diff-viewer.tsx +72 -7
  60. package/src/web/components/git/git-graph.tsx +3 -0
  61. package/src/web/components/git/git-status-panel.tsx +50 -1
  62. package/src/web/components/layout/command-palette.tsx +215 -0
  63. package/src/web/components/layout/mobile-drawer.tsx +143 -42
  64. package/src/web/components/layout/sidebar.tsx +103 -67
  65. package/src/web/components/layout/tab-bar.tsx +1 -2
  66. package/src/web/components/settings/ai-settings-section.tsx +166 -0
  67. package/src/web/components/settings/settings-tab.tsx +5 -0
  68. package/src/web/components/terminal/terminal-tab.tsx +45 -22
  69. package/src/web/components/ui/input.tsx +4 -3
  70. package/src/web/components/ui/label.tsx +24 -0
  71. package/src/web/components/ui/select.tsx +188 -0
  72. package/src/web/hooks/use-global-keybindings.ts +56 -0
  73. package/src/web/hooks/use-terminal.ts +14 -1
  74. package/src/web/lib/api-settings.ts +24 -0
  75. package/src/web/stores/project-store.ts +47 -2
  76. package/src/web/stores/tab-store.ts +1 -1
  77. package/src/web/styles/globals.css +20 -6
  78. package/test-tool.mjs +41 -0
  79. package/dist/web/assets/api-client-Bnf9LAt4.js +0 -1
  80. package/dist/web/assets/arrow-up-from-line-BXL5dtbG.js +0 -1
  81. package/dist/web/assets/button-DxRZgE8F.js +0 -1
  82. package/dist/web/assets/chat-tab-p2mwkdec.js +0 -61
  83. package/dist/web/assets/code-editor-vMRyRKV3.js +0 -2
  84. package/dist/web/assets/createLucideIcon-Dy1wlrF7.js +0 -1
  85. package/dist/web/assets/dialog-Db6prp1p.js +0 -45
  86. package/dist/web/assets/diff-viewer-BdDje3Wr.js +0 -4
  87. package/dist/web/assets/external-link-WSiY-639.js +0 -1
  88. package/dist/web/assets/git-graph-B-qwuFoO.js +0 -1
  89. package/dist/web/assets/git-status-panel-NkZFb5v1.js +0 -1
  90. package/dist/web/assets/index-BHEFCU01.js +0 -10
  91. package/dist/web/assets/index-DYd_2slk.css +0 -2
  92. package/dist/web/assets/project-list-NkR7IHT5.js +0 -1
  93. package/dist/web/assets/refresh-cw-DtopuYJf.js +0 -1
  94. package/dist/web/assets/settings-tab-DKx0s3Q1.js +0 -1
  95. package/dist/web/assets/terminal-tab-DHwn2LMT.js +0 -36
  96. package/dist/web/assets/trash-2-CHLebaNh.js +0 -1
  97. package/dist/web/assets/x-BISR7bpK.js +0 -1
  98. package/src/providers/claude-binary-finder.ts +0 -256
  99. package/src/providers/claude-code-cli.ts +0 -413
  100. package/src/providers/claude-process-registry.ts +0 -106
  101. /package/dist/web/assets/{dist-CSp7ir0r.js → dist-CBiGQxfr.js} +0 -0
  102. /package/dist/web/assets/{utils-CiBGfeHD.js → utils-DpJF9mAi.js} +0 -0
@@ -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
+ }
@@ -1,57 +1,200 @@
1
1
  import { resolve } from "node:path";
2
2
  import { homedir } from "node:os";
3
3
  import { existsSync } from "node:fs";
4
+ import { input, confirm, select, password } from "@inquirer/prompts";
4
5
  import { configService } from "../../services/config.service.ts";
5
6
  import { projectService } from "../../services/project.service.ts";
6
7
 
7
- export async function initProject() {
8
- const ppmDir = resolve(homedir(), ".ppm");
9
- const globalConfig = resolve(ppmDir, "config.yaml");
8
+ const DEFAULT_PORT = 3210;
10
9
 
11
- // Load existing or create default
12
- configService.load();
13
- console.log(`Config: ${configService.getConfigPath()}`);
14
-
15
- // Scan CWD for git repos
16
- const cwd = process.cwd();
17
- console.log(`\nScanning ${cwd} for git repositories...`);
18
- const repos = projectService.scanForGitRepos(cwd);
19
-
20
- if (repos.length === 0) {
21
- console.log("No git repositories found.");
22
- } else {
23
- console.log(`Found ${repos.length} git repo(s):\n`);
24
- const existing = configService.get("projects");
25
-
26
- let added = 0;
27
- for (const repoPath of repos) {
28
- const name = repoPath.split("/").pop() ?? "unknown";
29
- const alreadyRegistered = existing.some(
30
- (p) => resolve(p.path) === repoPath || p.name === name,
31
- );
32
-
33
- if (alreadyRegistered) {
34
- console.log(` [skip] ${name} (${repoPath}) already registered`);
35
- continue;
36
- }
37
-
38
- try {
39
- projectService.add(repoPath, name);
40
- console.log(` [added] ${name} (${repoPath})`);
41
- added++;
42
- } catch (e) {
43
- console.log(` [error] ${name}: ${(e as Error).message}`);
44
- }
10
+ export interface InitOptions {
11
+ port?: string;
12
+ scan?: string;
13
+ auth?: boolean;
14
+ password?: string;
15
+ share?: boolean;
16
+ /** Skip prompts, use defaults + flags only (for VPS/scripts) */
17
+ yes?: boolean;
18
+ }
19
+
20
+ /** Check if config already exists */
21
+ export function hasConfig(): boolean {
22
+ const globalConfig = resolve(homedir(), ".ppm", "config.yaml");
23
+ const localConfig = resolve(process.cwd(), "ppm.yaml");
24
+ return existsSync(globalConfig) || existsSync(localConfig);
25
+ }
26
+
27
+ export async function initProject(options: InitOptions = {}) {
28
+ const nonInteractive = options.yes ?? false;
29
+
30
+ // Check if already initialized
31
+ if (hasConfig() && !nonInteractive) {
32
+ const overwrite = await confirm({
33
+ message: "PPM is already configured. Re-initialize? (this will overwrite your config)",
34
+ default: false,
35
+ });
36
+ if (!overwrite) {
37
+ console.log(" Cancelled.");
38
+ return;
45
39
  }
40
+ }
41
+
42
+ console.log("\n 🔧 PPM Setup\n");
46
43
 
47
- console.log(`\nAdded ${added} project(s).`);
44
+ // 1. Port
45
+ const portValue = options.port
46
+ ? parseInt(options.port, 10)
47
+ : nonInteractive
48
+ ? DEFAULT_PORT
49
+ : parseInt(await input({
50
+ message: "Port:",
51
+ default: String(DEFAULT_PORT),
52
+ validate: (v) => /^\d+$/.test(v) && +v > 0 && +v < 65536 ? true : "Enter valid port (1-65535)",
53
+ }), 10);
54
+
55
+ // 2. Scan directory
56
+ const scanDir = options.scan
57
+ ?? (nonInteractive
58
+ ? homedir()
59
+ : await input({
60
+ message: "Projects directory to scan:",
61
+ default: homedir(),
62
+ }));
63
+
64
+ // 3. Auth
65
+ const authEnabled = options.auth
66
+ ?? (nonInteractive
67
+ ? true
68
+ : await confirm({
69
+ message: "Enable authentication?",
70
+ default: true,
71
+ }));
72
+
73
+ // 4. Password (if auth enabled)
74
+ let authToken = "";
75
+ if (authEnabled) {
76
+ authToken = options.password
77
+ ?? (nonInteractive
78
+ ? generateToken()
79
+ : await password({
80
+ message: "Set access password (leave empty to auto-generate):",
81
+ }));
82
+ if (!authToken) authToken = generateToken();
48
83
  }
49
84
 
50
- const auth = configService.get("auth");
51
- console.log(`\nAuth: ${auth.enabled ? "enabled" : "disabled"}`);
52
- if (auth.enabled) {
53
- console.log(`Token: ${auth.token}`);
85
+ // 5. Share (install cloudflared)
86
+ const wantShare = options.share
87
+ ?? (nonInteractive
88
+ ? false
89
+ : await confirm({
90
+ message: "Install cloudflared for public sharing (--share)?",
91
+ default: false,
92
+ }));
93
+
94
+ // 6. Advanced settings
95
+ let aiModel = "claude-sonnet-4-6";
96
+ let aiEffort: "low" | "medium" | "high" | "max" = "high";
97
+ let aiMaxTurns = 100;
98
+ let aiApiKeyEnv = "ANTHROPIC_API_KEY";
99
+
100
+ if (!nonInteractive) {
101
+ const wantAdvanced = await confirm({
102
+ message: "Configure advanced AI settings?",
103
+ default: false,
104
+ });
105
+
106
+ if (wantAdvanced) {
107
+ aiModel = await select({
108
+ message: "AI model:",
109
+ choices: [
110
+ { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (fast, recommended)" },
111
+ { value: "claude-opus-4-6", name: "Claude Opus 4.6 (powerful)" },
112
+ { value: "claude-haiku-4-5", name: "Claude Haiku 4.5 (cheap)" },
113
+ ],
114
+ default: "claude-sonnet-4-6",
115
+ });
116
+
117
+ aiEffort = await select({
118
+ message: "Thinking effort:",
119
+ choices: [
120
+ { value: "low" as const, name: "Low" },
121
+ { value: "medium" as const, name: "Medium" },
122
+ { value: "high" as const, name: "High (recommended)" },
123
+ { value: "max" as const, name: "Max" },
124
+ ],
125
+ default: "high" as const,
126
+ });
127
+
128
+ aiMaxTurns = parseInt(await input({
129
+ message: "Max turns per chat:",
130
+ default: "100",
131
+ validate: (v) => /^\d+$/.test(v) && +v >= 1 && +v <= 500 ? true : "Enter 1-500",
132
+ }), 10);
133
+
134
+ aiApiKeyEnv = await input({
135
+ message: "API key env variable:",
136
+ default: "ANTHROPIC_API_KEY",
137
+ });
138
+ }
139
+ }
140
+
141
+ // Apply config
142
+ configService.load();
143
+ configService.set("port", portValue);
144
+ configService.set("auth", { enabled: authEnabled, token: authToken });
145
+ configService.set("ai", {
146
+ default_provider: "claude",
147
+ providers: {
148
+ claude: {
149
+ type: "agent-sdk",
150
+ api_key_env: aiApiKeyEnv,
151
+ model: aiModel,
152
+ effort: aiEffort,
153
+ max_turns: aiMaxTurns,
154
+ },
155
+ },
156
+ });
157
+ configService.save();
158
+
159
+ // Scan for projects
160
+ console.log(`\n Scanning ${scanDir} for git repositories...`);
161
+ const repos = projectService.scanForGitRepos(scanDir);
162
+ const existing = configService.get("projects");
163
+ let added = 0;
164
+
165
+ for (const repoPath of repos) {
166
+ const name = repoPath.split("/").pop() ?? "unknown";
167
+ if (existing.some((p) => resolve(p.path) === repoPath || p.name === name)) continue;
168
+ try {
169
+ projectService.add(repoPath, name);
170
+ added++;
171
+ } catch {}
172
+ }
173
+ console.log(` Found ${repos.length} repo(s), added ${added} new project(s).`);
174
+
175
+ // Install cloudflared if requested
176
+ if (wantShare) {
177
+ console.log("\n Installing cloudflared...");
178
+ const { ensureCloudflared } = await import("../../services/cloudflared.service.ts");
179
+ await ensureCloudflared();
180
+ console.log(" ✓ cloudflared ready");
54
181
  }
55
182
 
56
- console.log(`\nRun "ppm start" to start the server.`);
183
+ // 8. Next steps
184
+ console.log(`\n ✓ Config saved to ${configService.getConfigPath()}\n`);
185
+ console.log(" Next steps:");
186
+ console.log(` ppm start # Start (daemon, port ${portValue})`);
187
+ console.log(` ppm start -f # Start in foreground`);
188
+ if (wantShare) {
189
+ console.log(` ppm start --share # Start + public URL`);
190
+ }
191
+ if (authEnabled) {
192
+ console.log(`\n Access password: ${authToken}`);
193
+ }
194
+ console.log();
195
+ }
196
+
197
+ function generateToken(): string {
198
+ const { randomBytes } = require("node:crypto");
199
+ return randomBytes(16).toString("hex");
57
200
  }
@@ -0,0 +1,73 @@
1
+ import { resolve } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { readFileSync, existsSync } from "node:fs";
4
+
5
+ const STATUS_FILE = resolve(homedir(), ".ppm", "status.json");
6
+ const PID_FILE = resolve(homedir(), ".ppm", "ppm.pid");
7
+
8
+ interface DaemonStatus {
9
+ running: boolean;
10
+ pid: number | null;
11
+ port: number | null;
12
+ host: string | null;
13
+ shareUrl: string | null;
14
+ }
15
+
16
+ function getDaemonStatus(): DaemonStatus {
17
+ const notRunning: DaemonStatus = { running: false, pid: null, port: null, host: null, shareUrl: null };
18
+
19
+ // Try status.json first
20
+ if (existsSync(STATUS_FILE)) {
21
+ try {
22
+ const data = JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
23
+ const pid = data.pid as number;
24
+ // Check if process is actually alive
25
+ try {
26
+ process.kill(pid, 0); // signal 0 = check existence
27
+ return { running: true, pid, port: data.port, host: data.host, shareUrl: data.shareUrl ?? null };
28
+ } catch {
29
+ return notRunning; // stale status file
30
+ }
31
+ } catch {
32
+ return notRunning;
33
+ }
34
+ }
35
+
36
+ // Fallback to ppm.pid
37
+ if (existsSync(PID_FILE)) {
38
+ try {
39
+ const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
40
+ process.kill(pid, 0);
41
+ return { running: true, pid, port: null, host: null, shareUrl: null };
42
+ } catch {
43
+ return notRunning;
44
+ }
45
+ }
46
+
47
+ return notRunning;
48
+ }
49
+
50
+ export async function showStatus(options: { json?: boolean }) {
51
+ const status = getDaemonStatus();
52
+
53
+ if (options.json) {
54
+ console.log(JSON.stringify(status));
55
+ return;
56
+ }
57
+
58
+ if (!status.running) {
59
+ console.log(" PPM is not running.");
60
+ return;
61
+ }
62
+
63
+ console.log(`\n PPM daemon is running\n`);
64
+ console.log(` PID: ${status.pid}`);
65
+ if (status.port) console.log(` Local: http://localhost:${status.port}/`);
66
+ if (status.shareUrl) {
67
+ console.log(` Share: ${status.shareUrl}`);
68
+ const qr = await import("qrcode-terminal");
69
+ console.log();
70
+ qr.generate(status.shareUrl, { small: true });
71
+ }
72
+ console.log();
73
+ }
@@ -3,29 +3,43 @@ import { homedir } from "node:os";
3
3
  import { readFileSync, unlinkSync, existsSync } from "node:fs";
4
4
 
5
5
  const PID_FILE = resolve(homedir(), ".ppm", "ppm.pid");
6
+ const STATUS_FILE = resolve(homedir(), ".ppm", "status.json");
6
7
 
7
8
  export async function stopServer() {
8
- if (!existsSync(PID_FILE)) {
9
- console.log("No PPM daemon running (PID file not found).");
10
- return;
9
+ let pid: number | null = null;
10
+
11
+ // Try status.json first (new format)
12
+ if (existsSync(STATUS_FILE)) {
13
+ try {
14
+ const status = JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
15
+ pid = status.pid;
16
+ } catch {}
17
+ }
18
+
19
+ // Fallback to ppm.pid (compat)
20
+ if (!pid && existsSync(PID_FILE)) {
21
+ pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
11
22
  }
12
23
 
13
- const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
14
- if (isNaN(pid)) {
15
- console.log("Invalid PID file. Removing it.");
16
- unlinkSync(PID_FILE);
24
+ if (!pid || isNaN(pid)) {
25
+ console.log("No PPM daemon running.");
26
+ // Cleanup stale files
27
+ if (existsSync(STATUS_FILE)) unlinkSync(STATUS_FILE);
28
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
17
29
  return;
18
30
  }
19
31
 
20
32
  try {
21
33
  process.kill(pid);
22
- unlinkSync(PID_FILE);
34
+ if (existsSync(STATUS_FILE)) unlinkSync(STATUS_FILE);
35
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
23
36
  console.log(`PPM daemon stopped (PID: ${pid}).`);
24
37
  } catch (e) {
25
38
  const error = e as NodeJS.ErrnoException;
26
39
  if (error.code === "ESRCH") {
27
- console.log(`Process ${pid} not found. Cleaning up PID file.`);
28
- unlinkSync(PID_FILE);
40
+ console.log(`Process ${pid} not found. Cleaning up stale files.`);
41
+ if (existsSync(STATUS_FILE)) unlinkSync(STATUS_FILE);
42
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
29
43
  } else {
30
44
  console.error(`Failed to stop process ${pid}: ${error.message}`);
31
45
  }
package/src/index.ts CHANGED
@@ -10,11 +10,18 @@ program
10
10
 
11
11
  program
12
12
  .command("start")
13
- .description("Start the PPM server")
13
+ .description("Start the PPM server (background by default)")
14
14
  .option("-p, --port <port>", "Port to listen on")
15
- .option("-d, --daemon", "Run as background daemon")
15
+ .option("-f, --foreground", "Run in foreground (default: background daemon)")
16
+ .option("-d, --daemon", "Run as background daemon (default, kept for compat)")
17
+ .option("-s, --share", "Share via public URL (Cloudflare tunnel)")
16
18
  .option("-c, --config <path>", "Path to config file")
17
19
  .action(async (options) => {
20
+ // Auto-init on first run
21
+ const { hasConfig, initProject } = await import("./cli/commands/init.ts");
22
+ if (!hasConfig()) {
23
+ await initProject();
24
+ }
18
25
  const { startServer } = await import("./server/index.ts");
19
26
  await startServer(options);
20
27
  });
@@ -27,6 +34,15 @@ program
27
34
  await stopServer();
28
35
  });
29
36
 
37
+ program
38
+ .command("status")
39
+ .description("Show PPM daemon status")
40
+ .option("--json", "Output as JSON")
41
+ .action(async (options) => {
42
+ const { showStatus } = await import("./cli/commands/status.ts");
43
+ await showStatus(options);
44
+ });
45
+
30
46
  program
31
47
  .command("open")
32
48
  .description("Open PPM in browser")
@@ -38,10 +54,17 @@ program
38
54
 
39
55
  program
40
56
  .command("init")
41
- .description("Initialize PPM configuration scan for git repos, create config")
42
- .action(async () => {
57
+ .description("Initialize PPM configuration (interactive or via flags)")
58
+ .option("-p, --port <port>", "Port to listen on")
59
+ .option("--scan <path>", "Directory to scan for git repos")
60
+ .option("--auth", "Enable authentication")
61
+ .option("--no-auth", "Disable authentication")
62
+ .option("--password <pw>", "Set access password")
63
+ .option("--share", "Pre-install cloudflared for sharing")
64
+ .option("-y, --yes", "Non-interactive mode (use defaults + flags)")
65
+ .action(async (options) => {
43
66
  const { initProject } = await import("./cli/commands/init.ts");
44
- await initProject();
67
+ await initProject(options);
45
68
  });
46
69
 
47
70
  const { registerProjectsCommands } = await import("./cli/commands/projects.ts");
@@ -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 */