@hienlh/ppm 0.9.51 → 0.9.53

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 CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.53] - 2026-04-07
4
+
5
+ ### Added
6
+ - **Supervisor Always Alive**: `ppm stop` now does a soft stop — kills server only, supervisor stays alive with Cloud WS + tunnel. Use `ppm stop --kill` or `ppm down` for full shutdown.
7
+ - **`ppm down` command**: Alias for `ppm stop --kill` (full shutdown).
8
+ - **`ppm stop --kill` flag**: Full shutdown that kills supervisor + server + tunnel.
9
+ - **Stopped page**: When server is stopped, tunnel URL serves a minimal HTML status page + 503 on `/api/health`.
10
+ - **Supervisor detection**: `ppm start` detects existing supervisor and resumes/upgrades instead of spawning a new one.
11
+ - **Cloud WS commands**: `start` (resume from stopped), `shutdown` (full kill), `stop` (now soft stop).
12
+ - **Exception handlers**: Supervisor catches `uncaughtException`/`unhandledRejection` — never crashes.
13
+ - **Lockfile**: Prevents concurrent `ppm start` races (`~/.ppm/.start-lock`).
14
+ - **Windows command file polling**: Supervisor polls command file every 1s on Windows (no SIGUSR2).
15
+
16
+ ### Changed
17
+ - **BREAKING**: `ppm stop` default behavior changed from full shutdown to soft stop.
18
+ - **Autostart**: Generates `__supervise__` instead of `__serve__`. Existing users must run `ppm autostart disable && ppm autostart enable` to regenerate.
19
+ - **Supervisor modularized**: Split into `supervisor.ts` (orchestrator), `supervisor-state.ts` (state machine + IPC), `supervisor-stopped-page.ts` (stopped HTML server).
20
+
21
+ ## [0.9.52] - 2026-04-07
22
+
23
+ ### Added
24
+ - **Full `ppm bot` CLI**: All 13 Telegram commands now have CLI equivalents — `project switch/list/current`, `session new/list/resume/stop`, `memory save/list/forget`, `status`, `version`, `restart`, `help`. AI can invoke any command via Bash tool from natural language (e.g. "chuyển sang project ppm" → `ppm bot project switch ppm`).
25
+ - **Auto-detect chat ID**: `resolveChatId()` auto-detects single approved paired Telegram chat. Falls back to `--chat <id>` when multiple chats exist.
26
+ - **System prompt with natural language mapping**: AI receives full CLI reference + Vietnamese/English intent examples, executes commands directly instead of describing actions.
27
+
3
28
  ## [0.9.51] - 2026-04-07
4
29
 
5
30
  ### Added
@@ -2,7 +2,37 @@
2
2
 
3
3
  All notable changes to PPM are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/).
4
4
 
5
- **Current Version:** v0.9.9
5
+ **Current Version:** v0.9.10
6
+
7
+ ---
8
+
9
+ ## [0.9.11] — 2026-04-07
10
+
11
+ ### Added
12
+ - **Supervisor Always Alive Feature** — Distinguish between soft stop (server shutdown) and full shutdown (supervisor shutdown)
13
+ - `ppm stop` now performs SOFT STOP: kills server only, supervisor remains alive with Cloud WS + tunnel connectivity
14
+ - `ppm stop --kill` or `ppm down` performs FULL SHUTDOWN: kills everything (old `ppm stop` behavior)
15
+ - Supervisor now has new `stopped` state (in addition to running, paused, upgrading)
16
+ - When stopped, minimal HTML page served on the port (503 status on /api/health)
17
+ - `ppm start` detects existing supervisor and handles resume/upgrade scenarios
18
+ - Autostart now uses `__supervise__` instead of `__serve__` for consistency
19
+ - Cloud WS has new commands: `start`, `shutdown` (stop is now soft stop, separate from shutdown)
20
+ - Supervisor has uncaughtException/unhandledRejection handlers (never crashes)
21
+ - Supervisor logic modularized into 3 files: supervisor.ts (orchestrator), supervisor-state.ts (state machine), supervisor-stopped-page.ts (503 page)
22
+
23
+ ### Technical Details
24
+ - **Files Created:**
25
+ - `src/services/supervisor-state.ts` — State machine, IPC command file handling
26
+ - `src/services/supervisor-stopped-page.ts` — Minimal 503 HTML response
27
+ - Enhanced `src/services/supervisor.ts` — Orchestrator with stopped state support
28
+ - **Files Modified:**
29
+ - `src/cli/commands/stop.ts` — Added --kill flag, soft stop default, ppm down alias
30
+ - `src/cli/commands/start.ts` — Resume detection for existing supervisor
31
+ - `src/cli/autostart-generator.ts` — Uses __supervise__ entry point
32
+ - Cloud WS endpoints updated with new commands
33
+ - **Type Changes:** SupervisorState = "running" | "paused" | "stopped" | "upgrading"
34
+ - **API Changes:** GET /api/health returns 503 when server stopped (supervisor still running)
35
+ - **Breaking Changes:** None (backward compatible, graceful fallback)
6
36
 
7
37
  ---
8
38
 
@@ -38,12 +38,13 @@ PPM is the **lightest path from phone to code** — a self-hosted, BYOK, multi-d
38
38
 
39
39
  **Theme:** Multi-device access + AI chat improvements. Solve the "I can't reach my PPM from my phone" problem.
40
40
 
41
- | Feature | Priority | Description |
42
- |---------|----------|-------------|
43
- | **PPM Cloud** | Critical | Separate cloud service for device registry + tunnel URL sync. Google OAuth login. CLI `ppm cloud link` syncs tunnel URL. Open cloud dashboard on any device → see machines → tap to connect. NO code/data through cloud — only URLs + metadata. |
44
- | **Auto-start** | High | PPM starts on boot. macOS launchd, Linux systemd, Windows Task Scheduler. CLI: `ppm autostart enable/disable`. Required for "always accessible" story. |
45
- | **Auto-upgrade** | High | Supervisor checks npm registry every 15min. UI banner shows when update available. One-click upgrade via API or CLI. Supervisor self-replaces after install (no OS autostart dependency). **Completed in v0.8.54** |
46
- | **AI Chat enhancements** | High | Tool allow/deny config per session. Chat modes (plan/code/ask). Model selector (opus/sonnet/haiku). Effort level. Max turns. System prompt customization. Better streaming UX (collapsible tool calls). |
41
+ | Feature | Priority | Status | Description |
42
+ |---------|----------|--------|-------------|
43
+ | **PPM Cloud** | Critical | — | Separate cloud service for device registry + tunnel URL sync. Google OAuth login. CLI `ppm cloud link` syncs tunnel URL. Open cloud dashboard on any device → see machines → tap to connect. NO code/data through cloud — only URLs + metadata. |
44
+ | **Auto-start** | High | — | PPM starts on boot. macOS launchd, Linux systemd, Windows Task Scheduler. CLI: `ppm autostart enable/disable`. Required for "always accessible" story. |
45
+ | **Auto-upgrade** | High | ✅ Done | Supervisor checks npm registry every 15min. UI banner shows when update available. One-click upgrade via API or CLI. Supervisor self-replaces after install (no OS autostart dependency). **Completed in v0.8.54** |
46
+ | **Supervisor Always Alive** | High | Done | Soft stop (server shutdown, supervisor stays) vs full shutdown. New `stopped` state. Cloud WS + tunnel stay active when stopped. `ppm start` resumes without supervisor restart. Modularized: supervisor.ts, supervisor-state.ts, supervisor-stopped-page.ts. **Completed in v0.9.11** |
47
+ | **AI Chat enhancements** | High | — | Tool allow/deny config per session. Chat modes (plan/code/ask). Model selector (opus/sonnet/haiku). Effort level. Max turns. System prompt customization. Better streaming UX (collapsible tool calls). |
47
48
 
48
49
  **PPM Cloud — scope guard:**
49
50
  - Cloud is OPTIONAL convenience, never a dependency. PPM works 100% without it.
@@ -1628,13 +1628,81 @@ $ ppm upgrade
1628
1628
  → Works in headless environments (no OS autostart dependency)
1629
1629
 
1630
1630
  $ ppm stop
1631
- Reads ~/.ppm/status.json first (new format)
1632
- Falls back to ppm.pid (compat)
1633
- Sends SIGTERM to daemon
1631
+ SOFT STOP: kills server only, supervisor stays alive with Cloud WS + tunnel
1632
+ Supervisor transitions to "stopped" state
1633
+ Minimal HTML page served on port (503 status on /api/health)
1634
+ → Tunnel and Cloud connectivity remain active
1635
+ → `ppm start` resumes without restarting supervisor process
1636
+
1637
+ $ ppm stop --kill OR ppm down
1638
+ → FULL SHUTDOWN: kills everything (supervisor + server + tunnel)
1639
+ → Supervisor transitions to "upgrading" then terminates
1634
1640
  → Cleans up status.json and ppm.pid
1635
- → Graceful shutdown (close WS, cleanup PTY, stop tunnel)
1641
+ → Graceful cleanup (close WS, cleanup PTY, stop tunnel)
1636
1642
  ```
1637
1643
 
1644
+ ### Supervisor Architecture (v0.9.11+)
1645
+
1646
+ The supervisor is a long-lived parent process that manages server + tunnel children with resilience and state management.
1647
+
1648
+ **Architecture:**
1649
+ ```
1650
+ Supervisor Process (parent)
1651
+ ├── Server Child (Hono HTTP server)
1652
+ │ ├── Health checks every 30s (/api/health)
1653
+ │ ├── Auto-restart on crash (exponential backoff, max 10 restarts)
1654
+ │ └── If in "stopped" state, serves minimal 503 page instead of restarting
1655
+
1656
+ ├── Tunnel Child (Cloudflare Quick Tunnel, if --share)
1657
+ │ ├── URL probe every 2min
1658
+ │ ├── Auto-reconnect on failure
1659
+ │ └── URL persisted to status.json
1660
+
1661
+ ├── State Machine: "running" | "paused" | "stopped" | "upgrading"
1662
+ │ ├── running — Server spawned, tunnel optional, serving requests
1663
+ │ ├── paused — Supervisor paused (resume via signal)
1664
+ │ ├── stopped — Server stopped (soft stop), tunnel alive, Cloud WS active
1665
+ │ └── upgrading — Self-replace in progress
1666
+
1667
+ ├── Upgrade Check (every 15min)
1668
+ │ └── npm registry poll → availableVersion written to status.json
1669
+
1670
+ ├── Stopped Page Server
1671
+ │ ├── Lightweight HTTP handler on same port as server
1672
+ │ ├── Returns 503 on /api/health
1673
+ │ └── Tunnels Cloud WS calls through to PPM Cloud
1674
+
1675
+ └── Error Resilience
1676
+ ├── uncaughtException → log + exit gracefully
1677
+ ├── unhandledRejection → log + continue
1678
+ └── Signal handlers: SIGTERM (full shutdown), SIGUSR1 (self-replace), SIGUSR2 (restart skip backoff)
1679
+ ```
1680
+
1681
+ **Soft Stop vs Full Shutdown:**
1682
+ | Command | Server | Supervisor | Tunnel | Use Case |
1683
+ |---------|--------|------------|--------|----------|
1684
+ | `ppm stop` | Killed | Stays alive | Stays alive | Restart later with `ppm start` |
1685
+ | `ppm stop --kill` | Killed | Killed | Killed | Full cleanup, exit |
1686
+ | `ppm down` | Killed | Killed | Killed | Full cleanup, exit |
1687
+
1688
+ **State Persistence:**
1689
+ - Status file: `~/.ppm/status.json` — PID, port, host, shareUrl, supervisorPid, availableVersion, state
1690
+ - Lock file: `~/.ppm/.start-lock` — Prevent concurrent starts
1691
+ - Command file: `~/.ppm/.supervisor-cmd` — IPC for soft_stop, resume, self_replace
1692
+
1693
+ **Stopped Page Implementation:**
1694
+ - Minimal HTTP server on same port as main server
1695
+ - Serves `503 Service Unavailable` on /api/health
1696
+ - Proxies Cloud WS calls to PPM Cloud (if tunnel configured)
1697
+ - Allows `ppm start` to resume without supervisor restart
1698
+
1699
+ **Files (Modular Design):**
1700
+ - `src/services/supervisor.ts` — Main orchestrator (spawn, health checks, upgrade checks)
1701
+ - `src/services/supervisor-state.ts` — State machine, IPC command handling, signal routing
1702
+ - `src/services/supervisor-stopped-page.ts` — Minimal 503 page + Cloud WS proxy
1703
+
1704
+ ---
1705
+
1638
1706
  ### Future: Multi-Machine (Not in v2)
1639
1707
  Would require:
1640
1708
  - Central state server (Redis/Postgres)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.9.51",
3
+ "version": "0.9.53",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -11,17 +11,44 @@ const C = {
11
11
  };
12
12
 
13
13
  /**
14
- * `ppm bot memory` CLI allows AI (via Bash tool) to save/list/forget
15
- * cross-project memories in the _global scope of clawbot_memories table.
14
+ * Resolve the Telegram chatId for CLI operations.
15
+ * Auto-detects if exactly 1 approved paired chat exists.
16
+ * Otherwise requires --chat flag.
17
+ */
18
+ export async function resolveChatId(chatOpt?: string): Promise<string> {
19
+ if (chatOpt) return chatOpt;
20
+
21
+ const { getApprovedPairedChats } = await import("../../services/db.service.ts");
22
+ const approved = getApprovedPairedChats();
23
+
24
+ if (approved.length === 0) {
25
+ throw new Error("No paired Telegram chats. Pair a device in PPM Settings first.");
26
+ }
27
+ if (approved.length > 1) {
28
+ const ids = approved.map((c) => ` ${c.telegram_chat_id} (${c.display_name || "unknown"})`).join("\n");
29
+ throw new Error(`Multiple paired chats found. Use --chat <id> to specify:\n${ids}`);
30
+ }
31
+ return approved[0]!.telegram_chat_id;
32
+ }
33
+
34
+ /**
35
+ * `ppm bot` CLI — allows AI (via Bash tool) to manage PPMBot sessions,
36
+ * projects, memories, and server operations through natural language.
16
37
  *
17
- * Usage from AI:
18
- * ppm bot memory save "User prefers Vietnamese" --category preference
19
- * ppm bot memory list
20
- * ppm bot memory forget "Vietnamese"
38
+ * All session/project commands auto-detect the paired Telegram chat.
21
39
  */
22
40
  export function registerBotCommands(program: Command): void {
23
41
  const bot = program.command("bot").description("PPMBot utilities");
24
42
 
43
+ registerMemoryCommands(bot);
44
+ registerProjectCommands(bot);
45
+ registerSessionCommands(bot);
46
+ registerMiscCommands(bot);
47
+ }
48
+
49
+ // ── Memory ──────────────────────────────────────────────────────────
50
+
51
+ function registerMemoryCommands(bot: Command): void {
25
52
  const mem = bot.command("memory").description("Manage cross-project memories");
26
53
 
27
54
  mem
@@ -94,3 +121,349 @@ export function registerBotCommands(program: Command): void {
94
121
  }
95
122
  });
96
123
  }
124
+
125
+ // ── Project ─────────────────────────────────────────────────────────
126
+
127
+ function registerProjectCommands(bot: Command): void {
128
+ const proj = bot.command("project").description("Manage bot project context");
129
+
130
+ proj
131
+ .command("list")
132
+ .description("List available projects")
133
+ .option("--json", "Output as JSON")
134
+ .action(async (opts: { json?: boolean }) => {
135
+ try {
136
+ const { PPMBotSessionManager } = await import("../../services/ppmbot/ppmbot-session.ts");
137
+ const sessions = new PPMBotSessionManager();
138
+ const projects = sessions.getProjectNames();
139
+
140
+ if (opts.json) {
141
+ console.log(JSON.stringify(projects));
142
+ return;
143
+ }
144
+
145
+ if (projects.length === 0) {
146
+ console.log(`${C.dim}No projects configured.${C.reset}`);
147
+ return;
148
+ }
149
+
150
+ // Show current project if possible
151
+ let current = "";
152
+ try {
153
+ const chatId = await resolveChatId();
154
+ const active = sessions.getActiveSession(chatId);
155
+ current = active?.projectName ?? "";
156
+ } catch { /* no chat — skip marker */ }
157
+
158
+ for (const name of projects) {
159
+ const marker = name === current ? ` ${C.green}✓${C.reset}` : "";
160
+ console.log(` ${name}${marker}`);
161
+ }
162
+ } catch (e) {
163
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
164
+ process.exit(1);
165
+ }
166
+ });
167
+
168
+ proj
169
+ .command("switch <name>")
170
+ .description("Switch to a different project")
171
+ .option("--chat <id>", "Telegram chat ID (auto-detected if single)")
172
+ .action(async (name: string, opts: { chat?: string }) => {
173
+ try {
174
+ const chatId = await resolveChatId(opts.chat);
175
+ const { PPMBotSessionManager } = await import("../../services/ppmbot/ppmbot-session.ts");
176
+ const sessions = new PPMBotSessionManager();
177
+ const session = await sessions.switchProject(chatId, name);
178
+ console.log(`${C.green}✓${C.reset} Switched to ${C.bold}${session.projectName}${C.reset}`);
179
+ } catch (e) {
180
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
181
+ process.exit(1);
182
+ }
183
+ });
184
+
185
+ proj
186
+ .command("current")
187
+ .description("Show current project")
188
+ .option("--chat <id>", "Telegram chat ID (auto-detected if single)")
189
+ .action(async (opts: { chat?: string }) => {
190
+ try {
191
+ const chatId = await resolveChatId(opts.chat);
192
+ const { PPMBotSessionManager } = await import("../../services/ppmbot/ppmbot-session.ts");
193
+ const sessions = new PPMBotSessionManager();
194
+ const active = sessions.getActiveSession(chatId);
195
+
196
+ // Fallback: check DB for active session
197
+ if (!active) {
198
+ const { getActivePPMBotSession } = await import("../../services/db.service.ts");
199
+ const { configService } = await import("../../services/config.service.ts");
200
+ const projects = (configService.get("projects") as any[]) ?? [];
201
+ for (const p of projects) {
202
+ const dbSession = getActivePPMBotSession(chatId, p.name);
203
+ if (dbSession) {
204
+ console.log(dbSession.project_name);
205
+ return;
206
+ }
207
+ }
208
+ console.log(`${C.dim}No active project. Use: ppm bot project switch <name>${C.reset}`);
209
+ return;
210
+ }
211
+ console.log(active.projectName);
212
+ } catch (e) {
213
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
214
+ process.exit(1);
215
+ }
216
+ });
217
+ }
218
+
219
+ // ── Session ─────────────────────────────────────────────────────────
220
+
221
+ function registerSessionCommands(bot: Command): void {
222
+ const sess = bot.command("session").description("Manage chat sessions");
223
+
224
+ sess
225
+ .command("new")
226
+ .description("Start a fresh session (current project)")
227
+ .option("--chat <id>", "Telegram chat ID (auto-detected if single)")
228
+ .action(async (opts: { chat?: string }) => {
229
+ try {
230
+ const chatId = await resolveChatId(opts.chat);
231
+ const { PPMBotSessionManager } = await import("../../services/ppmbot/ppmbot-session.ts");
232
+ const sessions = new PPMBotSessionManager();
233
+
234
+ // Get current project before closing
235
+ const active = sessions.getActiveSession(chatId);
236
+ const projectName = active?.projectName;
237
+ await sessions.closeSession(chatId);
238
+ const session = await sessions.getOrCreateSession(chatId, projectName ?? undefined);
239
+ console.log(`${C.green}✓${C.reset} New session for ${C.bold}${session.projectName}${C.reset} (${session.sessionId.slice(0, 8)})`);
240
+ } catch (e) {
241
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
242
+ process.exit(1);
243
+ }
244
+ });
245
+
246
+ sess
247
+ .command("list")
248
+ .description("List recent sessions")
249
+ .option("--chat <id>", "Telegram chat ID (auto-detected if single)")
250
+ .option("-l, --limit <n>", "Max results", "20")
251
+ .option("--json", "Output as JSON")
252
+ .action(async (opts: { chat?: string; limit: string; json?: boolean }) => {
253
+ try {
254
+ const chatId = await resolveChatId(opts.chat);
255
+ const { getRecentPPMBotSessions, getSessionTitles, getPinnedSessionIds } = await import("../../services/db.service.ts");
256
+ const allSessions = getRecentPPMBotSessions(chatId, Number(opts.limit) || 20);
257
+
258
+ if (allSessions.length === 0) {
259
+ console.log(`${C.dim}No sessions found.${C.reset}`);
260
+ return;
261
+ }
262
+
263
+ const titles = getSessionTitles(allSessions.map((s) => s.session_id));
264
+ const pinnedIds = getPinnedSessionIds();
265
+
266
+ // Sort: pinned first, then by last_message_at desc
267
+ const sorted = [...allSessions].sort((a, b) => {
268
+ const aPin = pinnedIds.has(a.session_id) ? 1 : 0;
269
+ const bPin = pinnedIds.has(b.session_id) ? 1 : 0;
270
+ if (aPin !== bPin) return bPin - aPin;
271
+ return b.last_message_at - a.last_message_at;
272
+ });
273
+
274
+ if (opts.json) {
275
+ const jsonData = sorted.map((s, i) => ({
276
+ index: i + 1,
277
+ sessionId: s.session_id,
278
+ project: s.project_name,
279
+ title: titles[s.session_id]?.replace(/^\[PPM\]\s*/, "") || "",
280
+ pinned: pinnedIds.has(s.session_id),
281
+ active: !!s.is_active,
282
+ lastMessage: new Date(s.last_message_at * 1000).toISOString(),
283
+ }));
284
+ console.log(JSON.stringify(jsonData, null, 2));
285
+ return;
286
+ }
287
+
288
+ for (const [i, s] of sorted.entries()) {
289
+ const pin = pinnedIds.has(s.session_id) ? "📌 " : " ";
290
+ const activeDot = s.is_active ? ` ${C.green}⬤${C.reset}` : "";
291
+ const rawTitle = titles[s.session_id]?.replace(/^\[PPM\]\s*/, "") || "";
292
+ const title = rawTitle ? rawTitle.slice(0, 50) : `${C.dim}untitled${C.reset}`;
293
+ const sid = s.session_id.slice(0, 8);
294
+ const date = new Date(s.last_message_at * 1000).toLocaleString(undefined, {
295
+ month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
296
+ });
297
+
298
+ console.log(`${pin}${i + 1}. ${title}${activeDot}`);
299
+ console.log(` ${C.dim}${sid} · ${s.project_name} · ${date}${C.reset}`);
300
+ }
301
+ } catch (e) {
302
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
303
+ process.exit(1);
304
+ }
305
+ });
306
+
307
+ sess
308
+ .command("resume <target>")
309
+ .description("Resume a session by index number or session ID prefix")
310
+ .option("--chat <id>", "Telegram chat ID (auto-detected if single)")
311
+ .action(async (target: string, opts: { chat?: string }) => {
312
+ try {
313
+ const chatId = await resolveChatId(opts.chat);
314
+ const { PPMBotSessionManager } = await import("../../services/ppmbot/ppmbot-session.ts");
315
+ const sessions = new PPMBotSessionManager();
316
+
317
+ const index = parseInt(target, 10);
318
+ const isIndex = !isNaN(index) && index >= 1 && String(index) === target.trim();
319
+
320
+ const session = isIndex
321
+ ? await sessions.resumeSessionById(chatId, index)
322
+ : await sessions.resumeSessionByIdPrefix(chatId, target.trim());
323
+
324
+ if (!session) {
325
+ console.log(`${C.yellow}Session not found: ${target}${C.reset}`);
326
+ process.exit(1);
327
+ }
328
+ console.log(`${C.green}✓${C.reset} Resumed session ${C.dim}${session.sessionId.slice(0, 8)}${C.reset} (${C.bold}${session.projectName}${C.reset})`);
329
+ } catch (e) {
330
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
331
+ process.exit(1);
332
+ }
333
+ });
334
+
335
+ sess
336
+ .command("stop")
337
+ .description("End the current session")
338
+ .option("--chat <id>", "Telegram chat ID (auto-detected if single)")
339
+ .action(async (opts: { chat?: string }) => {
340
+ try {
341
+ const chatId = await resolveChatId(opts.chat);
342
+ const { PPMBotSessionManager } = await import("../../services/ppmbot/ppmbot-session.ts");
343
+ const sessions = new PPMBotSessionManager();
344
+ await sessions.closeSession(chatId);
345
+ console.log(`${C.green}✓${C.reset} Session ended`);
346
+ } catch (e) {
347
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
348
+ process.exit(1);
349
+ }
350
+ });
351
+ }
352
+
353
+ // ── Misc: status, version, restart, help ────────────────────────────
354
+
355
+ function registerMiscCommands(bot: Command): void {
356
+ bot
357
+ .command("status")
358
+ .description("Show current project and session info")
359
+ .option("--chat <id>", "Telegram chat ID (auto-detected if single)")
360
+ .option("--json", "Output as JSON")
361
+ .action(async (opts: { chat?: string; json?: boolean }) => {
362
+ try {
363
+ const chatId = await resolveChatId(opts.chat);
364
+ const { PPMBotSessionManager } = await import("../../services/ppmbot/ppmbot-session.ts");
365
+ const sessions = new PPMBotSessionManager();
366
+ const active = sessions.getActiveSession(chatId);
367
+
368
+ // Fallback: check DB for any active session
369
+ let project = active?.projectName ?? "";
370
+ let provider = active?.providerId ?? "";
371
+ let sessionId = active?.sessionId ?? "";
372
+
373
+ if (!active) {
374
+ const { getRecentPPMBotSessions } = await import("../../services/db.service.ts");
375
+ const recent = getRecentPPMBotSessions(chatId, 1);
376
+ if (recent.length > 0 && recent[0]!.is_active) {
377
+ project = recent[0]!.project_name;
378
+ provider = recent[0]!.provider_id;
379
+ sessionId = recent[0]!.session_id;
380
+ }
381
+ }
382
+
383
+ if (opts.json) {
384
+ console.log(JSON.stringify({ chatId, project, provider, sessionId }));
385
+ return;
386
+ }
387
+
388
+ if (!project) {
389
+ console.log(`${C.dim}No active session. Use: ppm bot project switch <name>${C.reset}`);
390
+ return;
391
+ }
392
+
393
+ console.log(`Project: ${C.bold}${project}${C.reset}`);
394
+ console.log(`Provider: ${provider}`);
395
+ console.log(`Session: ${C.dim}${sessionId.slice(0, 12)}…${C.reset}`);
396
+ console.log(`Chat: ${chatId}`);
397
+ } catch (e) {
398
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
399
+ process.exit(1);
400
+ }
401
+ });
402
+
403
+ bot
404
+ .command("version")
405
+ .description("Show PPM version")
406
+ .action(async () => {
407
+ try {
408
+ const { VERSION } = await import("../../version.ts");
409
+ console.log(`PPM v${VERSION}`);
410
+ } catch {
411
+ console.log("PPM version unknown");
412
+ }
413
+ });
414
+
415
+ bot
416
+ .command("restart")
417
+ .description("Restart the PPM server")
418
+ .action(async () => {
419
+ try {
420
+ const { join } = await import("node:path");
421
+ const { writeFileSync } = await import("node:fs");
422
+ const { homedir } = await import("node:os");
423
+ const { getApprovedPairedChats } = await import("../../services/db.service.ts");
424
+
425
+ const approvedChats = getApprovedPairedChats();
426
+ const chatIds = approvedChats.map((c) => c.telegram_chat_id);
427
+
428
+ const markerPath = join(homedir(), ".ppm", "restart-notify.json");
429
+ writeFileSync(markerPath, JSON.stringify({ chatIds, ts: Date.now() }));
430
+
431
+ console.log(`${C.green}✓${C.reset} Restart signal sent (exit code 42)`);
432
+ process.exit(42);
433
+ } catch (e) {
434
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
435
+ process.exit(1);
436
+ }
437
+ });
438
+
439
+ bot
440
+ .command("help")
441
+ .description("Show all bot CLI commands")
442
+ .action(() => {
443
+ console.log(`${C.bold}PPMBot CLI Commands${C.reset}
444
+
445
+ ${C.cyan}Project:${C.reset}
446
+ ppm bot project list List available projects
447
+ ppm bot project switch <name> Switch to a project
448
+ ppm bot project current Show current project
449
+
450
+ ${C.cyan}Session:${C.reset}
451
+ ppm bot session new Start fresh session
452
+ ppm bot session list List recent sessions
453
+ ppm bot session resume <n|id> Resume a session
454
+ ppm bot session stop End current session
455
+
456
+ ${C.cyan}Memory (cross-project):${C.reset}
457
+ ppm bot memory save "<text>" Save a memory (-c category)
458
+ ppm bot memory list List saved memories
459
+ ppm bot memory forget "<topic>" Delete matching memories
460
+
461
+ ${C.cyan}Server:${C.reset}
462
+ ppm bot status Current project/session info
463
+ ppm bot version Show PPM version
464
+ ppm bot restart Restart PPM server
465
+
466
+ ${C.dim}Session/project commands auto-detect your Telegram chat.
467
+ Use --chat <id> if multiple chats are paired.${C.reset}`);
468
+ });
469
+ }
@@ -42,6 +42,35 @@ export async function restartServer(options: { config?: string; force?: boolean
42
42
  process.exit(1);
43
43
  }
44
44
 
45
+ // Stopped state: treat restart as resume (send resume command)
46
+ if (state === "stopped") {
47
+ console.log("\n Server is stopped. Resuming via supervisor...\n");
48
+ const cmdFile = resolve(PPM_DIR, ".supervisor-cmd");
49
+ writeFileSync(cmdFile, JSON.stringify({ action: "resume" }));
50
+ // Signal supervisor (Windows: polling picks up command file)
51
+ if (process.platform !== "win32") {
52
+ try { process.kill(supervisorPid, "SIGUSR2"); } catch (e) {
53
+ console.error(` ✗ Failed to signal supervisor: ${e}`);
54
+ process.exit(1);
55
+ }
56
+ }
57
+ // Wait for state to change back to running
58
+ const rStart = Date.now();
59
+ while (Date.now() - rStart < 15_000) {
60
+ await Bun.sleep(500);
61
+ try {
62
+ const newStatus = JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
63
+ if (newStatus.state === "running" && newStatus.pid) {
64
+ console.log(` ✓ Server resumed (PID: ${newStatus.pid})`);
65
+ if (newStatus.shareUrl) console.log(` ➜ Share: ${newStatus.shareUrl}`);
66
+ process.exit(0);
67
+ }
68
+ } catch {}
69
+ }
70
+ console.error(" ⚠ Resume timed out. Check: ppm logs");
71
+ process.exit(1);
72
+ }
73
+
45
74
  const oldServerPid = status.pid as number | undefined;
46
75
  console.log("\n Restarting PPM server via supervisor...");
47
76
  console.log(" If you're using PPM terminal, wait a few seconds for auto-reconnect.\n");