@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 +25 -0
- package/docs/project-changelog.md +31 -1
- package/docs/project-roadmap.md +7 -6
- package/docs/system-architecture.md +72 -4
- package/package.json +1 -1
- package/src/cli/commands/bot-cmd.ts +379 -6
- 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/services/autostart-generator.ts +8 -6
- package/src/services/ppmbot/ppmbot-service.ts +48 -8
- 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/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.
|
|
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
|
|
package/docs/project-roadmap.md
CHANGED
|
@@ -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).
|
|
46
|
-
| **
|
|
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
|
-
→
|
|
1632
|
-
→
|
|
1633
|
-
→
|
|
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
|
|
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
|
@@ -11,17 +11,44 @@ const C = {
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
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");
|