@hienlh/ppm 0.9.52 → 0.9.54

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 (58) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/web/assets/{chat-tab-DvNEQYEe.js → chat-tab-C7LUU8uc.js} +1 -1
  3. package/dist/web/assets/{code-editor-CoT017Ah.js → code-editor-D8qEQfSv.js} +1 -1
  4. package/dist/web/assets/{database-viewer-C3wK7cDk.js → database-viewer-Duryc3Y0.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-D0tuen4I.js → diff-viewer-UGOYufsF.js} +1 -1
  6. package/dist/web/assets/{extension-webview-Ba5aeo9r.js → extension-webview-fljR76zl.js} +1 -1
  7. package/dist/web/assets/{git-graph-BnJrVPxJ.js → git-graph-Ccyey8cC.js} +1 -1
  8. package/dist/web/assets/{index-DUQgLj0D.js → index-CgvhdpCl.js} +4 -4
  9. package/dist/web/assets/{index-BEfMoc_W.css → index-DVuSY0BZ.css} +1 -1
  10. package/dist/web/assets/keybindings-store-CGXc_Nqv.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-BuGSrE3y.js → markdown-renderer-Fm0AKs27.js} +1 -1
  12. package/dist/web/assets/{port-forwarding-tab-DsbrWNUP.js → port-forwarding-tab-9XP7jh5d.js} +1 -1
  13. package/dist/web/assets/{postgres-viewer-Bh6YmZPq.js → postgres-viewer-BU2c67YN.js} +1 -1
  14. package/dist/web/assets/{settings-tab-BnzFtexC.js → settings-tab-kSrv-eTK.js} +1 -1
  15. package/dist/web/assets/sqlite-viewer-Ctlu51c-.js +1 -0
  16. package/dist/web/assets/{terminal-tab-fnZvscaH.js → terminal-tab-Cx6Wl0GQ.js} +1 -1
  17. package/dist/web/assets/{use-monaco-theme-BdcKAZ69.js → use-monaco-theme-DQ-JnmSE.js} +1 -1
  18. package/dist/web/index.html +2 -2
  19. package/dist/web/sw.js +1 -1
  20. package/docs/codebase-summary.md +52 -13
  21. package/docs/project-changelog.md +75 -1
  22. package/docs/project-roadmap.md +8 -7
  23. package/docs/system-architecture.md +193 -13
  24. package/package.json +1 -1
  25. package/src/cli/commands/bot-cmd.ts +144 -240
  26. package/src/cli/commands/restart.ts +29 -0
  27. package/src/cli/commands/stop.ts +67 -6
  28. package/src/index.ts +10 -1
  29. package/src/server/index.ts +131 -19
  30. package/src/server/routes/database.ts +31 -0
  31. package/src/server/routes/settings.ts +13 -0
  32. package/src/server/routes/sqlite.ts +14 -0
  33. package/src/services/autostart-generator.ts +8 -6
  34. package/src/services/database/postgres-adapter.ts +8 -0
  35. package/src/services/database/sqlite-adapter.ts +5 -0
  36. package/src/services/db.service.ts +109 -1
  37. package/src/services/postgres.service.ts +12 -0
  38. package/src/services/ppmbot/ppmbot-delegation.ts +112 -0
  39. package/src/services/ppmbot/ppmbot-service.ts +194 -369
  40. package/src/services/ppmbot/ppmbot-session.ts +85 -108
  41. package/src/services/ppmbot/ppmbot-telegram.ts +5 -16
  42. package/src/services/sqlite.service.ts +10 -0
  43. package/src/services/supervisor-state.ts +100 -0
  44. package/src/services/supervisor-stopped-page.ts +73 -0
  45. package/src/services/supervisor.ts +144 -50
  46. package/src/types/config.ts +1 -3
  47. package/src/types/database.ts +3 -0
  48. package/src/types/ppmbot.ts +21 -0
  49. package/src/web/components/settings/ppmbot-settings-section.tsx +87 -26
  50. package/src/web/components/sqlite/sqlite-data-grid.tsx +55 -8
  51. package/src/web/components/sqlite/sqlite-viewer.tsx +1 -0
  52. package/src/web/components/sqlite/use-sqlite.ts +16 -1
  53. package/dist/web/assets/keybindings-store-CkGFjxkX.js +0 -1
  54. package/dist/web/assets/sqlite-viewer-Cu3_hf07.js +0 -1
  55. package/docs/streaming-input-guide.md +0 -267
  56. package/snapshot-state.md +0 -1526
  57. package/test-session-ops.mjs +0 -444
  58. package/test-tokens.mjs +0 -212
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync } from "node:fs";
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { chatService } from "../chat.service.ts";
@@ -8,107 +8,102 @@ import {
8
8
  createPPMBotSession,
9
9
  deactivatePPMBotSession,
10
10
  touchPPMBotSession,
11
- getRecentPPMBotSessions,
12
11
  getDistinctPPMBotProjectNames,
13
- setSessionTitle,
14
12
  } from "../db.service.ts";
15
13
  import type { PPMBotActiveSession, PPMBotSessionRow } from "../../types/ppmbot.ts";
16
14
  import type { PPMBotConfig, ProjectConfig } from "../../types/config.ts";
17
15
 
18
- export class PPMBotSessionManager {
19
- /** In-memory cache: telegramChatId → active session */
20
- private activeSessions = new Map<string, PPMBotActiveSession>();
16
+ export const DEFAULT_COORDINATOR_IDENTITY = `# PPMBot — AI Project Coordinator
21
17
 
22
- /**
23
- * Get active session for chatId. If none exists, create one for the
24
- * given project (or default project from config).
25
- */
26
- async getOrCreateSession(
27
- chatId: string,
28
- projectName?: string,
29
- ): Promise<PPMBotActiveSession> {
30
- const cached = this.activeSessions.get(chatId);
31
- if (cached && (!projectName || cached.projectName === projectName)) {
32
- touchPPMBotSession(cached.sessionId);
33
- return cached;
34
- }
18
+ You are PPMBot, a personal AI project coordinator and team leader. You communicate with users via Telegram.
35
19
 
36
- const input = projectName || this.getDefaultProject();
37
- const resolvedProject = input
38
- ? this.resolveProject(input)
39
- : this.getFallbackProject();
40
- if (!resolvedProject) {
41
- throw new Error(`Project not found: "${projectName || "(default)"}"`);
42
- }
20
+ ## Role
21
+ - Answer direct questions immediately (coding, general knowledge, quick advice)
22
+ - Delegate project-specific tasks to subagents using \`ppm bot delegate\`
23
+ - Track delegated task status and report results proactively
24
+ - Remember user preferences across conversations
25
+ - Act as a team leader coordinating work across multiple projects
43
26
 
44
- const dbSession = getActivePPMBotSession(chatId, resolvedProject.name);
45
- if (dbSession) {
46
- return this.resumeFromDb(chatId, dbSession, resolvedProject);
47
- }
27
+ ## Decision Framework
28
+ 1. Can I answer this directly without project context? → Answer now
29
+ 2. Does this reference a specific project or need file access? → Delegate
30
+ 3. Is this about PPM config or bot management? → Handle directly
31
+ 4. Ambiguous project? → Ask user to clarify
48
32
 
49
- return this.createNewSession(chatId, resolvedProject);
50
- }
33
+ ## Coordination Tools (via Bash)
51
34
 
52
- /** Switch to a different project. Deactivates current session. */
53
- async switchProject(
54
- chatId: string,
55
- projectName: string,
56
- ): Promise<PPMBotActiveSession> {
57
- await this.closeSession(chatId);
58
- return this.getOrCreateSession(chatId, projectName);
59
- }
35
+ ### Delegate a task to a project
36
+ ppm bot delegate --chat <chatId> --project <name> --prompt "<enriched task description>"
37
+ Returns task ID. Tell user you're working on it.
60
38
 
61
- /** Close (deactivate) the current session for a chatId */
62
- async closeSession(chatId: string): Promise<void> {
63
- const active = this.activeSessions.get(chatId);
64
- if (active) {
65
- deactivatePPMBotSession(active.sessionId);
66
- this.activeSessions.delete(chatId);
67
- }
68
- }
39
+ ### Check task status
40
+ ppm bot task-status <task-id>
69
41
 
70
- /** Get active session from cache (no DB hit) */
71
- getActiveSession(chatId: string): PPMBotActiveSession | null {
72
- return this.activeSessions.get(chatId) ?? null;
73
- }
42
+ ### Get task result
43
+ ppm bot task-result <task-id>
74
44
 
75
- /** List recent sessions for a chat (from DB) */
76
- listRecentSessions(chatId: string, limit = 10): PPMBotSessionRow[] {
77
- return getRecentPPMBotSessions(chatId, limit);
78
- }
45
+ ### List recent tasks
46
+ ppm bot tasks
79
47
 
80
- /** Resume a specific session by 1-indexed position in history */
81
- async resumeSessionById(
82
- chatId: string,
83
- sessionIndex: number,
84
- ): Promise<PPMBotActiveSession | null> {
85
- const sessions = getRecentPPMBotSessions(chatId, 20);
86
- const target = sessions[sessionIndex - 1];
87
- if (!target) return null;
48
+ ## Response Style
49
+ - Keep responses concise (Telegram context — mobile-friendly)
50
+ - Use short paragraphs, no walls of text
51
+ - When delegating: acknowledge immediately, notify on completion
52
+ - Support Vietnamese and English naturally
88
53
 
89
- await this.closeSession(chatId);
54
+ ## Important
55
+ - When delegating, write an enriched prompt with full context — not just the raw user message
56
+ - Include relevant details: what the user wants, which files/features, acceptance criteria
57
+ - Each delegation creates a fresh AI session in the target project workspace
58
+ `;
90
59
 
91
- const project = this.resolveProject(target.project_name);
92
- if (!project) return null;
60
+ /** Ensure ~/.ppm/bot/ workspace exists with coordinator.md */
61
+ export function ensureCoordinatorWorkspace(): void {
62
+ const botDir = join(homedir(), ".ppm", "bot");
63
+ const coordinatorMd = join(botDir, "coordinator.md");
64
+ const settingsDir = join(botDir, ".claude");
65
+ const settingsFile = join(settingsDir, "settings.local.json");
93
66
 
94
- return this.resumeFromDb(chatId, target, project);
67
+ mkdirSync(botDir, { recursive: true });
68
+ mkdirSync(settingsDir, { recursive: true });
69
+
70
+ if (!existsSync(coordinatorMd)) {
71
+ writeFileSync(coordinatorMd, DEFAULT_COORDINATOR_IDENTITY);
72
+ }
73
+ if (!existsSync(settingsFile)) {
74
+ writeFileSync(settingsFile, JSON.stringify({
75
+ permissions: { allow: ["Bash", "Read", "Write", "Edit", "Glob", "Grep"] },
76
+ }, null, 2));
95
77
  }
78
+ }
79
+
80
+ export class PPMBotSessionManager {
81
+ /** In-memory cache: telegramChatId → coordinator session */
82
+ private coordinatorSessions = new Map<string, PPMBotActiveSession>();
96
83
 
97
- /** Resume a session by session ID prefix match */
98
- async resumeSessionByIdPrefix(
99
- chatId: string,
100
- prefix: string,
101
- ): Promise<PPMBotActiveSession | null> {
102
- const sessions = getRecentPPMBotSessions(chatId, 20);
103
- const target = sessions.find((s) => s.session_id.startsWith(prefix));
104
- if (!target) return null;
84
+ /** Get or create coordinator session for chatId (always ~/.ppm/bot/) */
85
+ async getCoordinatorSession(chatId: string): Promise<PPMBotActiveSession> {
86
+ const cached = this.coordinatorSessions.get(chatId);
87
+ if (cached) {
88
+ touchPPMBotSession(cached.sessionId);
89
+ return cached;
90
+ }
105
91
 
106
- await this.closeSession(chatId);
92
+ // Check DB for existing coordinator session
93
+ const dbSession = getActivePPMBotSession(chatId, "bot");
94
+ if (dbSession) {
95
+ return this.resumeFromDb(chatId, dbSession);
96
+ }
107
97
 
108
- const project = this.resolveProject(target.project_name);
109
- if (!project) return null;
98
+ return this.createCoordinatorSession(chatId);
99
+ }
110
100
 
111
- return this.resumeFromDb(chatId, target, project);
101
+ /** Rotate coordinator session (context window near limit) */
102
+ async rotateCoordinatorSession(chatId: string): Promise<PPMBotActiveSession> {
103
+ const old = this.coordinatorSessions.get(chatId);
104
+ if (old) deactivatePPMBotSession(old.sessionId);
105
+ this.coordinatorSessions.delete(chatId);
106
+ return this.createCoordinatorSession(chatId);
112
107
  }
113
108
 
114
109
  /**
@@ -120,7 +115,6 @@ export class PPMBotSessionManager {
120
115
  if (!projects?.length) return null;
121
116
 
122
117
  const lower = input.toLowerCase();
123
-
124
118
  const exact = projects.find((p) => p.name.toLowerCase() === lower);
125
119
  if (exact) return { name: exact.name, path: exact.path };
126
120
 
@@ -130,13 +124,6 @@ export class PPMBotSessionManager {
130
124
  return null;
131
125
  }
132
126
 
133
- /** Update session title (e.g. after first message) */
134
- updateSessionTitle(sessionId: string, firstMessage: string): void {
135
- const preview = firstMessage.slice(0, 60).replace(/\n/g, " ");
136
- const title = `[PPM] ${preview}`;
137
- setSessionTitle(sessionId, title);
138
- }
139
-
140
127
  /** Get list of available project names (config + sessions history) */
141
128
  getProjectNames(): string[] {
142
129
  const configured = (configService.get("projects") as ProjectConfig[])?.map((p) => p.name) ?? [];
@@ -147,13 +134,7 @@ export class PPMBotSessionManager {
147
134
 
148
135
  // ── Private ─────────────────────────────────────────────────────
149
136
 
150
- private getDefaultProject(): string {
151
- const cfg = configService.get("clawbot") as PPMBotConfig | undefined;
152
- return cfg?.default_project || "";
153
- }
154
-
155
- /** Fallback project when nothing is configured: ~/.ppm/bot/ */
156
- private getFallbackProject(): { name: string; path: string } {
137
+ private getCoordinatorProject(): { name: string; path: string } {
157
138
  const botDir = join(homedir(), ".ppm", "bot");
158
139
  if (!existsSync(botDir)) mkdirSync(botDir, { recursive: true });
159
140
  return { name: "bot", path: botDir };
@@ -164,16 +145,14 @@ export class PPMBotSessionManager {
164
145
  return cfg?.default_provider || configService.get("ai").default_provider;
165
146
  }
166
147
 
167
- private async createNewSession(
168
- chatId: string,
169
- project: { name: string; path: string },
170
- ): Promise<PPMBotActiveSession> {
148
+ private async createCoordinatorSession(chatId: string): Promise<PPMBotActiveSession> {
149
+ const project = this.getCoordinatorProject();
171
150
  const providerId = this.getDefaultProvider();
172
151
 
173
152
  const session = await chatService.createSession(providerId, {
174
153
  projectName: project.name,
175
154
  projectPath: project.path,
176
- title: `[PPM] New session`,
155
+ title: `[PPM] Coordinator`,
177
156
  });
178
157
 
179
158
  createPPMBotSession(chatId, session.id, providerId, project.name, project.path);
@@ -186,21 +165,19 @@ export class PPMBotSessionManager {
186
165
  projectPath: project.path,
187
166
  };
188
167
 
189
- this.activeSessions.set(chatId, active);
168
+ this.coordinatorSessions.set(chatId, active);
190
169
  return active;
191
170
  }
192
171
 
193
- private async resumeFromDb(
194
- chatId: string,
195
- dbSession: PPMBotSessionRow,
196
- project: { name: string; path: string },
197
- ): Promise<PPMBotActiveSession> {
172
+ private async resumeFromDb(chatId: string, dbSession: PPMBotSessionRow): Promise<PPMBotActiveSession> {
173
+ const project = this.getCoordinatorProject();
174
+
198
175
  try {
199
176
  await chatService.resumeSession(dbSession.provider_id, dbSession.session_id);
200
177
  } catch {
201
178
  console.warn(`[ppmbot] Failed to resume session ${dbSession.session_id}, creating new`);
202
179
  deactivatePPMBotSession(dbSession.session_id);
203
- return this.createNewSession(chatId, project);
180
+ return this.createCoordinatorSession(chatId);
204
181
  }
205
182
 
206
183
  touchPPMBotSession(dbSession.session_id);
@@ -213,7 +190,7 @@ export class PPMBotSessionManager {
213
190
  projectPath: project.path,
214
191
  };
215
192
 
216
- this.activeSessions.set(chatId, active);
193
+ this.coordinatorSessions.set(chatId, active);
217
194
  return active;
218
195
  }
219
196
  }
@@ -10,10 +10,9 @@ const POLL_TIMEOUT = 25;
10
10
  const MIN_EDIT_INTERVAL = 1000;
11
11
  const BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{30,50}$/;
12
12
 
13
- /** Known PPMBot slash commands */
13
+ /** Known PPMBot slash commands (coordinator: 3 public + 1 hidden) */
14
14
  const COMMANDS = new Set([
15
- "start", "project", "new", "sessions", "resume",
16
- "status", "stop", "memory", "forget", "remember", "restart", "version", "help",
15
+ "start", "status", "help", "restart",
17
16
  ]);
18
17
 
19
18
  export type UpdateHandler = (update: TelegramUpdate) => Promise<void>;
@@ -42,19 +41,9 @@ export class PPMBotTelegram {
42
41
  try {
43
42
  await this.callApi("setMyCommands", {
44
43
  commands: [
45
- { command: "start", description: "Greeting + list projects" },
46
- { command: "project", description: "Switch/list projects" },
47
- { command: "new", description: "Fresh session (current project)" },
48
- { command: "sessions", description: "List recent sessions" },
49
- { command: "resume", description: "Resume a previous session" },
50
- { command: "status", description: "Current project/session info" },
51
- { command: "stop", description: "End current session" },
52
- { command: "memory", description: "Show project memories" },
53
- { command: "forget", description: "Remove matching memories" },
54
- { command: "remember", description: "Save a fact" },
55
- { command: "restart", description: "Restart PPM server" },
56
- { command: "version", description: "Show PPM version" },
57
- { command: "help", description: "Show all commands" },
44
+ { command: "start", description: "Welcome + list projects" },
45
+ { command: "status", description: "Running tasks + delegations" },
46
+ { command: "help", description: "Show commands" },
58
47
  ],
59
48
  });
60
49
  console.log("[ppmbot] Commands registered");
@@ -137,6 +137,16 @@ class SqliteService {
137
137
  db.run(`UPDATE "${table}" SET "${column}" = ? WHERE "${pkColumn}" = ?`, [value as never, rowid]);
138
138
  }
139
139
 
140
+ /** Delete a row by primary key */
141
+ deleteRow(
142
+ projectPath: string, dbPath: string, table: string,
143
+ pkValue: unknown, pkColumn = "rowid",
144
+ ): void {
145
+ const abs = this.resolvePath(projectPath, dbPath);
146
+ const db = this.open(abs);
147
+ db.run(`DELETE FROM "${table}" WHERE "${pkColumn}" = ?`, [pkValue as never]);
148
+ }
149
+
140
150
  /** Close all cached databases (for shutdown) */
141
151
  closeAll() {
142
152
  for (const absPath of this.cache.keys()) this.close(absPath);
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Supervisor state machine — state transitions, IPC command file, signal handling.
3
+ * Extracted from supervisor.ts to keep the orchestrator lean.
4
+ */
5
+ import { resolve } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import {
8
+ readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, openSync, closeSync,
9
+ } from "node:fs";
10
+ import { constants } from "node:fs";
11
+
12
+ const PPM_DIR = resolve(process.env.PPM_HOME || resolve(homedir(), ".ppm"));
13
+ export const CMD_FILE = resolve(PPM_DIR, ".supervisor-cmd");
14
+ export const STATUS_FILE = resolve(PPM_DIR, "status.json");
15
+ export const PID_FILE = resolve(PPM_DIR, "ppm.pid");
16
+ export const LOCK_FILE = resolve(PPM_DIR, ".start-lock");
17
+
18
+ // ─── State ─────────────────────────────────────────────────────────────
19
+ export type SupervisorState = "running" | "paused" | "stopped" | "upgrading";
20
+
21
+ let _state: SupervisorState = "running";
22
+ let _resumeResolve: (() => void) | null = null;
23
+
24
+ export function getState(): SupervisorState { return _state; }
25
+
26
+ export function setState(s: SupervisorState) { _state = s; }
27
+
28
+ export function waitForResume(): Promise<void> {
29
+ return new Promise((res) => { _resumeResolve = res; });
30
+ }
31
+
32
+ export function triggerResume(): void {
33
+ if (_resumeResolve) {
34
+ _resumeResolve();
35
+ _resumeResolve = null;
36
+ }
37
+ }
38
+
39
+ // ─── Status file helpers ───────────────────────────────────────────────
40
+ export function readStatus(): Record<string, unknown> {
41
+ try {
42
+ if (existsSync(STATUS_FILE)) return JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
43
+ } catch {}
44
+ return {};
45
+ }
46
+
47
+ export function updateStatus(patch: Record<string, unknown>) {
48
+ try {
49
+ const data = { ...readStatus(), ...patch };
50
+ writeFileSync(STATUS_FILE, JSON.stringify(data));
51
+ } catch {}
52
+ }
53
+
54
+ // ─── Command file protocol ─────────────────────────────────────────────
55
+ export type CmdAction = "soft_stop" | "resume";
56
+
57
+ /** Atomically claim + read command file (rename to .claimed, read, delete) */
58
+ export function readAndDeleteCmd(): { action: CmdAction } | null {
59
+ const claimed = CMD_FILE + ".claimed";
60
+ try {
61
+ renameSync(CMD_FILE, claimed); // atomic claim — second caller gets ENOENT
62
+ const cmd = JSON.parse(readFileSync(claimed, "utf-8"));
63
+ unlinkSync(claimed);
64
+ return cmd;
65
+ } catch {
66
+ // No command file or already claimed by another handler
67
+ try { unlinkSync(claimed); } catch {}
68
+ return null;
69
+ }
70
+ }
71
+
72
+ export function writeCmd(action: CmdAction) {
73
+ writeFileSync(CMD_FILE, JSON.stringify({ action }));
74
+ }
75
+
76
+ // ─── Lockfile ──────────────────────────────────────────────────────────
77
+ export function acquireLock(): boolean {
78
+ try {
79
+ // Try exclusive create — fails if file already exists (atomic)
80
+ const fd = openSync(LOCK_FILE, "wx");
81
+ writeFileSync(fd, String(process.pid));
82
+ closeSync(fd);
83
+ return true;
84
+ } catch {
85
+ // File exists — check if holding process is alive
86
+ try {
87
+ const pid = parseInt(readFileSync(LOCK_FILE, "utf-8").trim(), 10);
88
+ if (!isNaN(pid)) {
89
+ try { process.kill(pid, 0); return false; } catch {} // stale lock
90
+ }
91
+ // Stale lock — overwrite
92
+ writeFileSync(LOCK_FILE, String(process.pid));
93
+ return true;
94
+ } catch { return false; }
95
+ }
96
+ }
97
+
98
+ export function releaseLock() {
99
+ try { unlinkSync(LOCK_FILE); } catch {}
100
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Minimal HTTP server that serves a "stopped" page when the PPM server child is down.
3
+ * Binds to the same port so the tunnel URL still works.
4
+ */
5
+ import { appendFileSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+ import { homedir } from "node:os";
8
+
9
+ const LOG_FILE = resolve(process.env.PPM_HOME || resolve(homedir(), ".ppm"), "ppm.log");
10
+
11
+ function log(level: string, msg: string) {
12
+ const ts = new Date().toISOString();
13
+ try { appendFileSync(LOG_FILE, `[${ts}] [${level}] [stopped-page] ${msg}\n`); } catch {}
14
+ }
15
+
16
+ const STOPPED_HTML = `<!DOCTYPE html>
17
+ <html><head>
18
+ <meta charset="utf-8">
19
+ <meta name="viewport" content="width=device-width,initial-scale=1">
20
+ <title>PPM - Stopped</title>
21
+ <style>
22
+ body { font-family: system-ui; display: flex; justify-content: center;
23
+ align-items: center; min-height: 100vh; margin: 0;
24
+ background: #1a1a2e; color: #e0e0e0; }
25
+ .card { text-align: center; padding: 2rem; }
26
+ h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
27
+ p { color: #888; font-size: 0.9rem; }
28
+ .dot { display: inline-block; width: 10px; height: 10px;
29
+ border-radius: 50%; background: #f59e0b; margin-right: 8px; }
30
+ </style>
31
+ </head><body>
32
+ <div class="card">
33
+ <h1><span class="dot"></span>PPM Server Stopped</h1>
34
+ <p>The server is stopped but the supervisor is still running.</p>
35
+ <p>Use <code>ppm start</code> or Cloud dashboard to restart.</p>
36
+ </div>
37
+ </body></html>`;
38
+
39
+ let stoppedServer: ReturnType<typeof Bun.serve> | null = null;
40
+
41
+ export function startStoppedPage(port: number, host: string) {
42
+ if (stoppedServer) return;
43
+
44
+ try {
45
+ stoppedServer = Bun.serve({
46
+ port,
47
+ hostname: host,
48
+ fetch(req) {
49
+ const url = new URL(req.url);
50
+ if (url.pathname === "/api/health") {
51
+ return new Response(JSON.stringify({ status: "stopped" }), {
52
+ status: 503,
53
+ headers: { "Content-Type": "application/json" },
54
+ });
55
+ }
56
+ return new Response(STOPPED_HTML, {
57
+ headers: { "Content-Type": "text/html" },
58
+ });
59
+ },
60
+ });
61
+ log("INFO", `Stopped page serving on port ${port}`);
62
+ } catch (e) {
63
+ log("WARN", `Failed to start stopped page: ${e}`);
64
+ }
65
+ }
66
+
67
+ export function stopStoppedPage() {
68
+ if (stoppedServer) {
69
+ stoppedServer.stop();
70
+ stoppedServer = null;
71
+ log("INFO", "Stopped page server shut down");
72
+ }
73
+ }