@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.
- package/CHANGELOG.md +24 -0
- package/dist/web/assets/{chat-tab-DvNEQYEe.js → chat-tab-C7LUU8uc.js} +1 -1
- package/dist/web/assets/{code-editor-CoT017Ah.js → code-editor-D8qEQfSv.js} +1 -1
- package/dist/web/assets/{database-viewer-C3wK7cDk.js → database-viewer-Duryc3Y0.js} +1 -1
- package/dist/web/assets/{diff-viewer-D0tuen4I.js → diff-viewer-UGOYufsF.js} +1 -1
- package/dist/web/assets/{extension-webview-Ba5aeo9r.js → extension-webview-fljR76zl.js} +1 -1
- package/dist/web/assets/{git-graph-BnJrVPxJ.js → git-graph-Ccyey8cC.js} +1 -1
- package/dist/web/assets/{index-DUQgLj0D.js → index-CgvhdpCl.js} +4 -4
- package/dist/web/assets/{index-BEfMoc_W.css → index-DVuSY0BZ.css} +1 -1
- package/dist/web/assets/keybindings-store-CGXc_Nqv.js +1 -0
- package/dist/web/assets/{markdown-renderer-BuGSrE3y.js → markdown-renderer-Fm0AKs27.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-DsbrWNUP.js → port-forwarding-tab-9XP7jh5d.js} +1 -1
- package/dist/web/assets/{postgres-viewer-Bh6YmZPq.js → postgres-viewer-BU2c67YN.js} +1 -1
- package/dist/web/assets/{settings-tab-BnzFtexC.js → settings-tab-kSrv-eTK.js} +1 -1
- package/dist/web/assets/sqlite-viewer-Ctlu51c-.js +1 -0
- package/dist/web/assets/{terminal-tab-fnZvscaH.js → terminal-tab-Cx6Wl0GQ.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-BdcKAZ69.js → use-monaco-theme-DQ-JnmSE.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +52 -13
- package/docs/project-changelog.md +75 -1
- package/docs/project-roadmap.md +8 -7
- package/docs/system-architecture.md +193 -13
- package/package.json +1 -1
- package/src/cli/commands/bot-cmd.ts +144 -240
- package/src/cli/commands/restart.ts +29 -0
- package/src/cli/commands/stop.ts +67 -6
- package/src/index.ts +10 -1
- package/src/server/index.ts +131 -19
- package/src/server/routes/database.ts +31 -0
- package/src/server/routes/settings.ts +13 -0
- package/src/server/routes/sqlite.ts +14 -0
- package/src/services/autostart-generator.ts +8 -6
- package/src/services/database/postgres-adapter.ts +8 -0
- package/src/services/database/sqlite-adapter.ts +5 -0
- package/src/services/db.service.ts +109 -1
- package/src/services/postgres.service.ts +12 -0
- package/src/services/ppmbot/ppmbot-delegation.ts +112 -0
- package/src/services/ppmbot/ppmbot-service.ts +194 -369
- package/src/services/ppmbot/ppmbot-session.ts +85 -108
- package/src/services/ppmbot/ppmbot-telegram.ts +5 -16
- package/src/services/sqlite.service.ts +10 -0
- package/src/services/supervisor-state.ts +100 -0
- package/src/services/supervisor-stopped-page.ts +73 -0
- package/src/services/supervisor.ts +144 -50
- package/src/types/config.ts +1 -3
- package/src/types/database.ts +3 -0
- package/src/types/ppmbot.ts +21 -0
- package/src/web/components/settings/ppmbot-settings-section.tsx +87 -26
- package/src/web/components/sqlite/sqlite-data-grid.tsx +55 -8
- package/src/web/components/sqlite/sqlite-viewer.tsx +1 -0
- package/src/web/components/sqlite/use-sqlite.ts +16 -1
- package/dist/web/assets/keybindings-store-CkGFjxkX.js +0 -1
- package/dist/web/assets/sqlite-viewer-Cu3_hf07.js +0 -1
- package/docs/streaming-input-guide.md +0 -267
- package/snapshot-state.md +0 -1526
- package/test-session-ops.mjs +0 -444
- 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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
}
|
|
33
|
+
## Coordination Tools (via Bash)
|
|
51
34
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
return this.activeSessions.get(chatId) ?? null;
|
|
73
|
-
}
|
|
42
|
+
### Get task result
|
|
43
|
+
ppm bot task-result <task-id>
|
|
74
44
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return getRecentPPMBotSessions(chatId, limit);
|
|
78
|
-
}
|
|
45
|
+
### List recent tasks
|
|
46
|
+
ppm bot tasks
|
|
79
47
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
98
|
-
async
|
|
99
|
-
chatId
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
98
|
+
return this.createCoordinatorSession(chatId);
|
|
99
|
+
}
|
|
110
100
|
|
|
111
|
-
|
|
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
|
|
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
|
|
168
|
-
|
|
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]
|
|
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.
|
|
168
|
+
this.coordinatorSessions.set(chatId, active);
|
|
190
169
|
return active;
|
|
191
170
|
}
|
|
192
171
|
|
|
193
|
-
private async resumeFromDb(
|
|
194
|
-
|
|
195
|
-
|
|
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.
|
|
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.
|
|
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", "
|
|
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: "
|
|
46
|
-
{ command: "
|
|
47
|
-
{ command: "
|
|
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
|
+
}
|