@hienlh/ppm 0.9.48 โ†’ 0.9.50

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.50] - 2026-04-07
4
+
5
+ ### Changed
6
+ - **/sessions redesigned**: Filters by current project, shows pinned sessions first with ๐Ÿ“Œ icon, displays session titles (not project names), supports pagination via `/sessions 2`. Matches PPM web UI behavior.
7
+
8
+ ## [0.9.49] - 2026-04-07
9
+
10
+ ### Fixed
11
+ - **/resume accepts session ID prefix**: `/resume fdc4ddaa` now works alongside `/resume 2` (index). Matches session by ID prefix from `/sessions` list.
12
+ - **/restart actually restarts**: Server now exits with code 42 (restart signal) instead of 0 (clean exit). Supervisor recognizes code 42 and respawns immediately without backoff.
13
+ - **Restart notification delivered**: Supervisor respawns after `/restart`, new server sends "PPM v0.9.49 restarted successfully." to all paired chats.
14
+ - **/project lists all projects**: `getProjectNames()` now merges config projects + unique project names from session history. Previously returned empty when no projects in config.
15
+
3
16
  ## [0.9.48] - 2026-04-06
4
17
 
5
18
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.9.48",
3
+ "version": "0.9.50",
4
4
  "description": "Personal Project Manager โ€” mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -1022,6 +1022,13 @@ export function getRecentPPMBotSessions(
1022
1022
  ).all(telegramChatId, limit) as PPMBotSessionRow[];
1023
1023
  }
1024
1024
 
1025
+ export function getDistinctPPMBotProjectNames(): string[] {
1026
+ const rows = getDb().query(
1027
+ "SELECT DISTINCT project_name FROM clawbot_sessions ORDER BY project_name",
1028
+ ).all() as { project_name: string }[];
1029
+ return rows.map((r) => r.project_name);
1030
+ }
1031
+
1025
1032
  // ---------------------------------------------------------------------------
1026
1033
  // PPMBot memory helpers
1027
1034
  // ---------------------------------------------------------------------------
@@ -5,6 +5,7 @@ import {
5
5
  getPairingByChatId,
6
6
  createPairingRequest,
7
7
  getSessionTitles,
8
+ getPinnedSessionIds,
8
9
  getApprovedPairedChats,
9
10
  } from "../db.service.ts";
10
11
  import { PPMBotTelegram } from "./ppmbot-telegram.ts";
@@ -159,7 +160,7 @@ class PPMBotService {
159
160
  case "start": await this.cmdStart(chatId); break;
160
161
  case "project": await this.cmdProject(chatId, cmd.args); break;
161
162
  case "new": await this.cmdNew(chatId); break;
162
- case "sessions": await this.cmdSessions(chatId); break;
163
+ case "sessions": await this.cmdSessions(chatId, cmd.args); break;
163
164
  case "resume": await this.cmdResume(chatId, cmd.args); break;
164
165
  case "status": await this.cmdStatus(chatId); break;
165
166
  case "stop": await this.cmdStop(chatId); break;
@@ -254,39 +255,90 @@ class PPMBotService {
254
255
  );
255
256
  }
256
257
 
257
- private async cmdSessions(chatId: string): Promise<void> {
258
- const sessions = this.sessions.listRecentSessions(chatId, 10);
259
- if (sessions.length === 0) {
260
- await this.telegram!.sendMessage(Number(chatId), "No recent sessions.");
258
+ private async cmdSessions(chatId: string, args: string): Promise<void> {
259
+ const PAGE_SIZE = 8;
260
+ const page = Math.max(1, parseInt(args, 10) || 1);
261
+
262
+ const active = this.sessions.getActiveSession(chatId);
263
+ const project = active?.projectName;
264
+
265
+ // Fetch all sessions for this chat (enough for pagination)
266
+ const allSessions = this.sessions.listRecentSessions(chatId, 50);
267
+ // Filter by current project if one is active
268
+ const filtered = project
269
+ ? allSessions.filter((s) => s.project_name === project)
270
+ : allSessions;
271
+
272
+ if (filtered.length === 0) {
273
+ await this.telegram!.sendMessage(Number(chatId), "No sessions yet. Send a message to start.");
261
274
  return;
262
275
  }
263
276
 
264
- // Fetch titles for all sessions
265
- const titles = getSessionTitles(sessions.map((s) => s.session_id));
277
+ // Enrich with titles and pin status
278
+ const titles = getSessionTitles(filtered.map((s) => s.session_id));
279
+ const pinnedIds = getPinnedSessionIds();
280
+
281
+ // Sort: pinned first, then by last_message_at desc
282
+ const sorted = [...filtered].sort((a, b) => {
283
+ const aPin = pinnedIds.has(a.session_id) ? 1 : 0;
284
+ const bPin = pinnedIds.has(b.session_id) ? 1 : 0;
285
+ if (aPin !== bPin) return bPin - aPin;
286
+ return b.last_message_at - a.last_message_at;
287
+ });
288
+
289
+ const totalPages = Math.ceil(sorted.length / PAGE_SIZE);
290
+ const start = (page - 1) * PAGE_SIZE;
291
+ const pageItems = sorted.slice(start, start + PAGE_SIZE);
292
+
293
+ if (pageItems.length === 0) {
294
+ await this.telegram!.sendMessage(Number(chatId), `No sessions on page ${page}.`);
295
+ return;
296
+ }
266
297
 
267
- let text = "<b>Recent Sessions</b>\n\n";
268
- sessions.forEach((s, i) => {
269
- const active = s.is_active ? " โฌค" : "";
270
- const title = titles[s.session_id]?.replace(/^\[PPM\]\s*/, "") || "";
271
- const preview = title ? ` โ€” ${escapeHtml(title.slice(0, 40))}` : "";
298
+ const header = project ? escapeHtml(project) : "All Projects";
299
+ let text = `<b>Sessions โ€” ${header}</b>`;
300
+ if (totalPages > 1) text += ` <i>(${page}/${totalPages})</i>`;
301
+ text += "\n\n";
302
+
303
+ pageItems.forEach((s, i) => {
304
+ const pin = pinnedIds.has(s.session_id) ? "๐Ÿ“Œ " : "";
305
+ const activeDot = s.is_active ? " โฌค" : "";
306
+ const rawTitle = titles[s.session_id]?.replace(/^\[PPM\]\s*/, "") || "";
307
+ const title = rawTitle
308
+ ? escapeHtml(rawTitle.slice(0, 45))
309
+ : "<i>untitled</i>";
272
310
  const date = new Date(s.last_message_at * 1000).toLocaleString(undefined, {
273
311
  month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
274
312
  });
275
313
  const sid = s.session_id.slice(0, 8);
276
- text += `${i + 1}. <b>${escapeHtml(s.project_name)}</b>${preview}${active}\n`;
314
+ const num = start + i + 1;
315
+
316
+ text += `${pin}${num}. ${title}${activeDot}\n`;
277
317
  text += ` <code>${sid}</code> ยท ${date}\n\n`;
278
318
  });
279
- text += "Resume: /resume &lt;number&gt;";
319
+
320
+ text += "Resume: /resume &lt;n&gt; or /resume &lt;id&gt;";
321
+ if (totalPages > 1 && page < totalPages) {
322
+ text += `\nNext: /sessions ${page + 1}`;
323
+ }
324
+
280
325
  await this.telegram!.sendMessage(Number(chatId), text);
281
326
  }
282
327
 
283
328
  private async cmdResume(chatId: string, args: string): Promise<void> {
284
- const index = parseInt(args, 10);
285
- if (!index || index < 1) {
286
- await this.telegram!.sendMessage(Number(chatId), "Usage: /resume &lt;number&gt;");
329
+ if (!args.trim()) {
330
+ await this.telegram!.sendMessage(Number(chatId), "Usage: /resume &lt;number or session-id&gt;");
287
331
  return;
288
332
  }
289
- const session = await this.sessions.resumeSessionById(chatId, index);
333
+
334
+ // Support both index (e.g. "2") and session ID prefix (e.g. "fdc4ddaa")
335
+ const index = parseInt(args, 10);
336
+ const isIndex = !isNaN(index) && index >= 1 && String(index) === args.trim();
337
+
338
+ const session = isIndex
339
+ ? await this.sessions.resumeSessionById(chatId, index)
340
+ : await this.sessions.resumeSessionByIdPrefix(chatId, args.trim());
341
+
290
342
  if (!session) {
291
343
  await this.telegram!.sendMessage(Number(chatId), "Session not found.");
292
344
  return;
@@ -368,8 +420,8 @@ class PPMBotService {
368
420
  const markerPath = join(homedir(), ".ppm", "restart-notify.json");
369
421
  writeFileSync(markerPath, JSON.stringify({ chatIds, ts: Date.now() }));
370
422
 
371
- console.log("[ppmbot] Restart requested via Telegram, exiting...");
372
- process.exit(0);
423
+ console.log("[ppmbot] Restart requested via Telegram, exiting with code 42...");
424
+ process.exit(42);
373
425
  }, 500);
374
426
  }
375
427
 
@@ -420,8 +472,8 @@ class PPMBotService {
420
472
  /start โ€” Greeting + list projects
421
473
  /project &lt;name&gt; โ€” Switch/list projects
422
474
  /new โ€” Fresh session (current project)
423
- /sessions โ€” List recent sessions
424
- /resume &lt;n&gt; โ€” Resume session #n
475
+ /sessions [page] โ€” List sessions (current project)
476
+ /resume &lt;n or id&gt; โ€” Resume session
425
477
  /status โ€” Current project/session info
426
478
  /stop โ€” End current session
427
479
  /memory โ€” Show project memories
@@ -9,6 +9,7 @@ import {
9
9
  deactivatePPMBotSession,
10
10
  touchPPMBotSession,
11
11
  getRecentPPMBotSessions,
12
+ getDistinctPPMBotProjectNames,
12
13
  setSessionTitle,
13
14
  } from "../db.service.ts";
14
15
  import type { PPMBotActiveSession, PPMBotSessionRow } from "../../types/ppmbot.ts";
@@ -93,6 +94,23 @@ export class PPMBotSessionManager {
93
94
  return this.resumeFromDb(chatId, target, project);
94
95
  }
95
96
 
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;
105
+
106
+ await this.closeSession(chatId);
107
+
108
+ const project = this.resolveProject(target.project_name);
109
+ if (!project) return null;
110
+
111
+ return this.resumeFromDb(chatId, target, project);
112
+ }
113
+
96
114
  /**
97
115
  * Resolve a project name against configured projects.
98
116
  * Case-insensitive, supports prefix matching.
@@ -119,10 +137,12 @@ export class PPMBotSessionManager {
119
137
  setSessionTitle(sessionId, title);
120
138
  }
121
139
 
122
- /** Get list of available project names (for /start greeting) */
140
+ /** Get list of available project names (config + sessions history) */
123
141
  getProjectNames(): string[] {
124
- const projects = configService.get("projects") as ProjectConfig[];
125
- return projects?.map((p) => p.name) ?? [];
142
+ const configured = (configService.get("projects") as ProjectConfig[])?.map((p) => p.name) ?? [];
143
+ const fromSessions = getDistinctPPMBotProjectNames();
144
+ const merged = new Set([...configured, ...fromSessions]);
145
+ return [...merged].sort();
126
146
  }
127
147
 
128
148
  // โ”€โ”€ Private โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -130,11 +130,17 @@ export async function spawnServer(
130
130
  const exitCode = await serverChild.exited;
131
131
  serverChild = null;
132
132
 
133
- if (exitCode === 0 || shuttingDown) {
133
+ if (exitCode === 0 && shuttingDown) {
134
134
  log("INFO", `Server exited cleanly (code ${exitCode})`);
135
135
  return;
136
136
  }
137
137
 
138
+ // Exit code 42 = restart requested (e.g. /restart from Telegram)
139
+ if (exitCode === 42 || (exitCode === 0 && !shuttingDown)) {
140
+ log("INFO", `Server restart requested (code ${exitCode}), respawning immediately`);
141
+ return spawnServer(serverArgs, logFd);
142
+ }
143
+
138
144
  // SIGUSR2 restart โ€” skip backoff, respawn immediately
139
145
  if (serverRestartRequested) {
140
146
  serverRestartRequested = false;