@hienlh/ppm 0.13.0 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{audio-preview-J5neETTY.js → audio-preview--hRMnXRZ.js} +1 -1
  5. package/dist/web/assets/chat-tab-4kL3DNxf.js +12 -0
  6. package/dist/web/assets/{code-editor-tMfcFaQ5.js → code-editor-Caq5_BaF.js} +2 -2
  7. package/dist/web/assets/{conflict-editor-FydCxWTC.js → conflict-editor-Dlo25nmt.js} +1 -1
  8. package/dist/web/assets/{database-viewer-Celi1puH.js → database-viewer-DcBl6OkV.js} +1 -1
  9. package/dist/web/assets/{diff-viewer-NgDJLTk9.js → diff-viewer-CCzPq1o-.js} +1 -1
  10. package/dist/web/assets/{extension-webview-xWAdCj3q.js → extension-webview-D7bGVSEd.js} +1 -1
  11. package/dist/web/assets/{image-preview-C6bFkdZD.js → image-preview-CfkqnhXJ.js} +1 -1
  12. package/dist/web/assets/{index-DtbAoxyy.js → index-BGFG66Gh.js} +14 -10
  13. package/dist/web/assets/index-Bce0weeW.css +2 -0
  14. package/dist/web/assets/{markdown-renderer-BAnnk1pI.js → markdown-renderer-DyAm7zuA.js} +1 -1
  15. package/dist/web/assets/{pdf-preview-BNuFTSOL.js → pdf-preview-CZPcuy5c.js} +1 -1
  16. package/dist/web/assets/{port-forwarding-tab-BbDlGxAs.js → port-forwarding-tab-3RNozlZ5.js} +1 -1
  17. package/dist/web/assets/{postgres-viewer-Cman1YRO.js → postgres-viewer-CXJv4TXc.js} +1 -1
  18. package/dist/web/assets/{settings-tab-n5X_Dbu4.js → settings-tab-Cnav4g2u.js} +1 -1
  19. package/dist/web/assets/{sqlite-viewer-D6JT11uu.js → sqlite-viewer-C8WUEFhA.js} +1 -1
  20. package/dist/web/assets/{terminal-tab-B4kMthYo.js → terminal-tab-CaEsMxp8.js} +1 -1
  21. package/dist/web/assets/{video-preview-BftQOOzF.js → video-preview-Dfz71RGb.js} +1 -1
  22. package/dist/web/index.html +2 -2
  23. package/dist/web/sw.js +1 -1
  24. package/package.json +1 -1
  25. package/src/index.ts +0 -0
  26. package/src/server/index.ts +12 -4
  27. package/src/services/autostart-generator.ts +3 -1
  28. package/src/services/autostart-register.ts +17 -0
  29. package/src/services/sd-notify.ts +27 -0
  30. package/src/services/supervisor.ts +31 -5
  31. package/src/web/components/chat/chat-history-bar.tsx +2 -9
  32. package/src/web/components/chat/chat-history-panel.tsx +2 -9
  33. package/src/web/components/chat/chat-welcome.tsx +1 -18
  34. package/src/web/components/layout/draggable-tab.tsx +12 -5
  35. package/src/web/hooks/use-notification-badge.ts +7 -7
  36. package/src/web/lib/favicon.ts +37 -15
  37. package/src/web/lib/format-date.ts +21 -0
  38. package/src/web/styles/globals.css +12 -0
  39. package/bun.lock +0 -2062
  40. package/bunfig.toml +0 -2
  41. package/dist/web/assets/chat-tab-sVHRa1Fz.js +0 -12
  42. package/dist/web/assets/index-BMhiElt6.css +0 -2
@@ -0,0 +1,27 @@
1
+ /**
2
+ * sd_notify helper — forwards messages to systemd via the `systemd-notify` binary.
3
+ * No-op on non-systemd platforms (NOTIFY_SOCKET unset).
4
+ *
5
+ * Usage:
6
+ * await sdNotify("READY=1"); // mark unit active (Type=notify)
7
+ * await sdNotify(`MAINPID=${newPid}`); // handoff main process (NotifyAccess=all)
8
+ *
9
+ * Shelling out to `systemd-notify` avoids implementing AF_UNIX SOCK_DGRAM
10
+ * transport in Node/Bun (not supported by node:dgram). The binary ships with
11
+ * systemd itself, so availability matches systemd availability.
12
+ */
13
+ export async function sdNotify(state: string): Promise<void> {
14
+ if (!process.env.NOTIFY_SOCKET) return; // not running under systemd
15
+ try {
16
+ const proc = Bun.spawn({
17
+ cmd: ["systemd-notify", state],
18
+ stdio: ["ignore", "ignore", "ignore"],
19
+ env: process.env,
20
+ });
21
+ await proc.exited;
22
+ } catch {
23
+ // best-effort: if systemd-notify is missing, startup still proceeds
24
+ // (Type=notify units without READY=1 will time out, but that's already
25
+ // the failure mode — this helper doesn't make it worse).
26
+ }
27
+ }
@@ -19,6 +19,7 @@ import {
19
19
  STATUS_FILE, PID_FILE,
20
20
  } from "./supervisor-state.ts";
21
21
  import { startStoppedPage, stopStoppedPage } from "./supervisor-stopped-page.ts";
22
+ import { sdNotify } from "./sd-notify.ts";
22
23
 
23
24
  // ─── Constants ─────────────────────────────────────────────────────────
24
25
  const MAX_RESTARTS = 10;
@@ -228,10 +229,22 @@ export async function spawnTunnel(port: number): Promise<void> {
228
229
  return;
229
230
  }
230
231
 
231
- tunnelChild = Bun.spawn(
232
- [bin, "tunnel", "--url", `http://127.0.0.1:${port}`],
233
- { stderr: "pipe", stdout: "ignore", stdin: "ignore" },
234
- );
232
+ // Under systemd, wrap tunnel in a transient user scope so it lives in its
233
+ // own cgroup instead of ppm.service. This prevents systemd from SIGKILLing
234
+ // the tunnel when ppm.service cgroup is torn down during upgrade/restart,
235
+ // preserving the cloudflared trycloudflare URL across the new supervisor.
236
+ // INVOCATION_ID is set by systemd; absence means we're not under systemd.
237
+ const underSystemd = !!process.env.INVOCATION_ID && process.platform === "linux";
238
+ const tunnelCmd = underSystemd
239
+ ? [
240
+ "systemd-run", "--user", "--scope", "--quiet", "--collect",
241
+ "--",
242
+ bin, "tunnel", "--url", `http://127.0.0.1:${port}`,
243
+ ]
244
+ : [bin, "tunnel", "--url", `http://127.0.0.1:${port}`];
245
+
246
+ tunnelChild = Bun.spawn(tunnelCmd, { stderr: "pipe", stdout: "ignore", stdin: "ignore" });
247
+ if (underSystemd) log("INFO", "Tunnel spawned inside transient systemd-run scope (escapes ppm.service cgroup)");
235
248
 
236
249
  try {
237
250
  tunnelUrl = await extractUrlFromStderr(tunnelChild.stderr as ReadableStream<Uint8Array>);
@@ -481,7 +494,15 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
481
494
  try {
482
495
  const data = JSON.parse(readFileSync(STATUS_FILE(), "utf-8"));
483
496
  if (data.supervisorPid && data.supervisorPid !== currentSupervisorPid) {
484
- log("INFO", `New supervisor detected (PID: ${data.supervisorPid}), old exiting`);
497
+ log("INFO", `New supervisor detected (PID: ${data.supervisorPid}), handing off MainPID to systemd`);
498
+ // Tell systemd the new supervisor is now MainPID — required so that
499
+ // systemd does NOT tear down the ppm.service cgroup when this old
500
+ // supervisor exits 0. Needs NotifyAccess=all in unit file.
501
+ // No-op on non-systemd platforms (NOTIFY_SOCKET unset).
502
+ await sdNotify(`MAINPID=${data.supervisorPid}`);
503
+ // Small delay so systemd processes the datagram before our exit.
504
+ await Bun.sleep(300);
505
+ log("INFO", `Old supervisor exiting`);
485
506
  // Children already killed, just clear remaining timers and exit
486
507
  if (heartbeatTimer) clearInterval(heartbeatTimer);
487
508
  if (upgradeCheckTimer) clearInterval(upgradeCheckTimer);
@@ -919,6 +940,11 @@ export async function runSupervisor(opts: {
919
940
  connectCloud(opts, serverArgs, logFd);
920
941
  startCloudMonitor(opts, serverArgs, logFd);
921
942
 
943
+ // Signal readiness to systemd (Type=notify). No-op on non-systemd platforms.
944
+ // Must happen AFTER signal handlers + status.json are set up so systemd
945
+ // can race-freely promote us to MainPID and forward SIGUSR1/TERM.
946
+ await sdNotify("READY=1");
947
+
922
948
  // Spawn server + tunnel in parallel
923
949
  const promises: Promise<void>[] = [spawnServer(serverArgs, logFd)];
924
950
 
@@ -10,6 +10,7 @@ import { SessionContextMenu } from "./session-context-menu";
10
10
  import { UsageDetailPanel } from "./usage-badge";
11
11
  import { TeamActivityPanel } from "./team-activity-panel";
12
12
  import { ProviderBadge } from "./provider-selector";
13
+ import { formatRelativeDate } from "@/lib/format-date";
13
14
  import type { SessionInfo, SessionListResponse, ProjectTag } from "../../../types/chat";
14
15
  import type { UsageInfo } from "../../../types/chat";
15
16
  import type { TeamMessageItem } from "@/hooks/use-chat";
@@ -40,14 +41,6 @@ interface ChatHistoryBarProps {
40
41
  onTeamOpen?: () => void;
41
42
  }
42
43
 
43
- function formatDate(iso: string): string {
44
- try {
45
- return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
46
- } catch {
47
- return "";
48
- }
49
- }
50
-
51
44
  function relativeTime(iso: string): string {
52
45
  const secs = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
53
46
  if (secs < 5) return "now";
@@ -545,7 +538,7 @@ export function ChatHistoryBar({
545
538
  </>
546
539
  )}
547
540
  {editingId !== session.id && session.updatedAt && (
548
- <span className="text-[10px] text-text-subtle shrink-0 w-10 text-right">{formatDate(session.updatedAt)}</span>
541
+ <span className="text-[10px] text-text-subtle shrink-0 w-16 text-right">{formatRelativeDate(session.updatedAt)}</span>
549
542
  )}
550
543
  </div>
551
544
  </SessionContextMenu>
@@ -2,20 +2,13 @@ import { useEffect, useState, useCallback } from "react";
2
2
  import { MessageSquare, Loader2, RefreshCw } from "lucide-react";
3
3
  import { api, projectUrl } from "@/lib/api-client";
4
4
  import { useTabStore } from "@/stores/tab-store";
5
+ import { formatRelativeDate } from "@/lib/format-date";
5
6
  import type { SessionInfo } from "../../../types/chat";
6
7
 
7
8
  interface ChatHistoryPanelProps {
8
9
  projectName?: string;
9
10
  }
10
11
 
11
- function formatDate(iso: string): string {
12
- try {
13
- return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
14
- } catch {
15
- return "";
16
- }
17
- }
18
-
19
12
  export function ChatHistoryPanel({ projectName }: ChatHistoryPanelProps) {
20
13
  const [sessions, setSessions] = useState<SessionInfo[]>([]);
21
14
  const [loading, setLoading] = useState(false);
@@ -96,7 +89,7 @@ export function ChatHistoryPanel({ projectName }: ChatHistoryPanelProps) {
96
89
  <div className="flex-1 min-w-0">
97
90
  <p className="text-xs font-medium truncate text-text-primary">{session.title || "Untitled"}</p>
98
91
  {session.updatedAt && (
99
- <p className="text-[10px] text-text-subtle">{formatDate(session.updatedAt)}</p>
92
+ <p className="text-[10px] text-text-subtle">{formatRelativeDate(session.updatedAt)}</p>
100
93
  )}
101
94
  </div>
102
95
  </button>
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
2
  import { Bot, ChevronDown, ChevronUp, MessageSquare, Pin, PinOff } from "lucide-react";
3
3
  import { api, projectUrl } from "@/lib/api-client";
4
+ import { formatRelativeDate } from "@/lib/format-date";
4
5
  import { useProjectTags, TagChipBar } from "./tag-filter-chips";
5
6
  import { SessionContextMenu } from "./session-context-menu";
6
7
  import type { SessionInfo } from "../../../types/chat";
@@ -8,24 +9,6 @@ import type { SessionInfo } from "../../../types/chat";
8
9
  const MAX_RECENT_SESSIONS = 5;
9
10
  const FETCH_SESSIONS_LIMIT = 20;
10
11
 
11
- function formatRelativeDate(iso: string): string {
12
- try {
13
- const date = new Date(iso);
14
- const now = new Date();
15
- const diffMs = now.getTime() - date.getTime();
16
- const diffMin = Math.floor(diffMs / 60_000);
17
- if (diffMin < 1) return "just now";
18
- if (diffMin < 60) return `${diffMin}m ago`;
19
- const diffHr = Math.floor(diffMin / 60);
20
- if (diffHr < 24) return `${diffHr}h ago`;
21
- const diffDay = Math.floor(diffHr / 24);
22
- if (diffDay < 7) return `${diffDay}d ago`;
23
- return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
24
- } catch {
25
- return "";
26
- }
27
- }
28
-
29
12
  interface ChatWelcomeProps {
30
13
  projectName: string;
31
14
  onSelectSession: (session: SessionInfo) => void;
@@ -100,13 +100,20 @@ export function DraggableTab({
100
100
  colorStyle && "border-transparent",
101
101
  )}
102
102
  >
103
- {tagColor && (
104
- <span className="size-2 rounded-full shrink-0" style={{ backgroundColor: tagColor }} />
105
- )}
106
- <span className="relative">
103
+ <span
104
+ // No-tag: force neutral gray (overrides parent text-primary for active tabs) so untagged
105
+ // tabs don't look like they have a blue tag. Active state is still signaled via border + title color.
106
+ className={cn("relative", !tagColor && "text-text-secondary")}
107
+ style={tagColor ? { color: tagColor } : undefined}
108
+ >
107
109
  <Icon className="size-4" />
108
110
  {isStreaming ? (
109
- <span className="absolute -top-1 -right-1 size-2 rounded-full bg-emerald-500 animate-pulse motion-reduce:animate-none" />
111
+ // Messenger-style typing dots inside chat bubble inherits current icon color
112
+ <span aria-hidden className="absolute inset-0 flex items-center justify-center gap-[1.5px]">
113
+ <span className="tab-typing-dot size-[2px] rounded-full bg-current" />
114
+ <span className="tab-typing-dot size-[2px] rounded-full bg-current" style={{ animationDelay: "0.15s" }} />
115
+ <span className="tab-typing-dot size-[2px] rounded-full bg-current" style={{ animationDelay: "0.3s" }} />
116
+ </span>
110
117
  ) : notificationType && !isActive ? (
111
118
  <span className={cn("absolute -top-1 -right-1 size-2 rounded-full", notificationColor(notificationType))} />
112
119
  ) : null}
@@ -3,7 +3,7 @@ import { useNotificationStore, selectTotalUnread } from "@/stores/notification-s
3
3
  import { useProjectStore } from "@/stores/project-store";
4
4
  import { useSettingsStore } from "@/stores/settings-store";
5
5
  import { useStreamingStore, selectAnyStreaming } from "@/stores/streaming-store";
6
- import { setFavicon } from "@/lib/favicon";
6
+ import { setFavicon, STREAM_FRAME_COUNT } from "@/lib/favicon";
7
7
 
8
8
  function buildTitle(unread: number, projectName?: string, deviceName?: string): string {
9
9
  const parts = [projectName, deviceName || null, "PPM"].filter(Boolean).join(" - ");
@@ -30,13 +30,13 @@ export function useNotificationBadge(): void {
30
30
  updateTitle();
31
31
 
32
32
  if (anyStreaming) {
33
- // Alternate favicon between primary (blue) and streaming (amber) every 800ms
34
- let alt = false;
35
- setFavicon(getHasBadge(), false);
33
+ // Cycle through typing-dots frames (3 dots + 1 rest frame, Messenger style) every 300ms
34
+ let frame = 0;
35
+ setFavicon(getHasBadge(), frame);
36
36
  intervalRef.current = setInterval(() => {
37
- alt = !alt;
38
- setFavicon(getHasBadge(), alt);
39
- }, 800);
37
+ frame = (frame + 1) % STREAM_FRAME_COUNT;
38
+ setFavicon(getHasBadge(), frame);
39
+ }, 300);
40
40
  } else {
41
41
  setFavicon(getHasBadge());
42
42
  }
@@ -1,30 +1,52 @@
1
1
  const COLOR_PRIMARY = "#3b82f6";
2
- const COLOR_STREAMING = "#f59e0b"; // amber-500
2
+ const COLOR_STREAMING = "#f59e0b"; // amber-500 — high-attention bg for typing state
3
+ const COLOR_BADGE = "#ef4444";
3
4
 
4
- function buildSvg(bgColor: string, badgeDot: boolean): string {
5
+ /** Idle favicon — "PPM" text on primary background, optional red badge dot. */
6
+ function buildIdleSvg(badgeDot: boolean): string {
5
7
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
6
- <rect width="32" height="32" rx="6" fill="${bgColor}"/>
8
+ <rect width="32" height="32" rx="6" fill="${COLOR_PRIMARY}"/>
7
9
  <text x="16" y="22" text-anchor="middle" font-family="system-ui,sans-serif" font-weight="700" font-size="11" fill="white">PPM</text>
8
- ${badgeDot ? '<circle cx="26" cy="6" r="5" fill="#ef4444"/>' : ""}
10
+ ${badgeDot ? `<circle cx="26" cy="6" r="5" fill="${COLOR_BADGE}"/>` : ""}
9
11
  </svg>`;
10
12
  }
11
13
 
12
- // Pre-encode all 4 variants
13
- export const FAVICON_NORMAL = `data:image/svg+xml,${encodeURIComponent(buildSvg(COLOR_PRIMARY, false))}`;
14
- export const FAVICON_BADGE = `data:image/svg+xml,${encodeURIComponent(buildSvg(COLOR_PRIMARY, true))}`;
15
- const FAVICON_STREAMING = `data:image/svg+xml,${encodeURIComponent(buildSvg(COLOR_STREAMING, false))}`;
16
- const FAVICON_STREAMING_BADGE = `data:image/svg+xml,${encodeURIComponent(buildSvg(COLOR_STREAMING, true))}`;
14
+ /** Streaming favicon — 3 dots (Messenger typing style). `activeDot` (0-2) is raised, -1 = rest. */
15
+ function buildStreamingSvg(activeDot: number, badgeDot: boolean): string {
16
+ const dots = [10, 16, 22].map((cx, i) => {
17
+ const cy = i === activeDot ? 13 : 17;
18
+ return `<circle cx="${cx}" cy="${cy}" r="3" fill="white"/>`;
19
+ }).join("");
20
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
21
+ <rect width="32" height="32" rx="6" fill="${COLOR_STREAMING}"/>
22
+ ${dots}
23
+ ${badgeDot ? `<circle cx="26" cy="6" r="5" fill="${COLOR_BADGE}"/>` : ""}
24
+ </svg>`;
25
+ }
26
+
27
+ const encode = (svg: string) => `data:image/svg+xml,${encodeURIComponent(svg)}`;
28
+
29
+ // Pre-encode all variants: [no-badge, with-badge]
30
+ const FAVICON_IDLE: [string, string] = [encode(buildIdleSvg(false)), encode(buildIdleSvg(true))];
31
+ // 4 frames: dot0 up, dot1 up, dot2 up, all rest (-1) — rest frame makes cycle boundary perceptible
32
+ const FAVICON_STREAM: [string, string][] = [0, 1, 2, -1].map((f) =>
33
+ [encode(buildStreamingSvg(f, false)), encode(buildStreamingSvg(f, true))] as [string, string],
34
+ );
35
+ export const STREAM_FRAME_COUNT = FAVICON_STREAM.length;
17
36
 
18
37
  /**
19
- * Swap favicon. When `isStreamingAlt` is true, uses amber color to
20
- * create an alternation effect (caller toggles this on an interval).
38
+ * Swap favicon.
39
+ * @param hasBadge true if unread notifications exist
40
+ * @param streamingFrame — 0..STREAM_FRAME_COUNT-1 for typing-dots animation, null for idle
21
41
  */
22
- export function setFavicon(hasBadge: boolean, isStreamingAlt = false): void {
42
+ export function setFavicon(hasBadge: boolean, streamingFrame: number | null = null): void {
23
43
  const el = document.getElementById("ppm-favicon") as HTMLLinkElement | null;
24
44
  if (!el) return;
25
- if (isStreamingAlt) {
26
- el.href = hasBadge ? FAVICON_STREAMING_BADGE : FAVICON_STREAMING;
45
+ const badgeIdx = hasBadge ? 1 : 0;
46
+ if (streamingFrame === null) {
47
+ el.href = FAVICON_IDLE[badgeIdx];
27
48
  } else {
28
- el.href = hasBadge ? FAVICON_BADGE : FAVICON_NORMAL;
49
+ const frame = ((streamingFrame % STREAM_FRAME_COUNT) + STREAM_FRAME_COUNT) % STREAM_FRAME_COUNT;
50
+ el.href = FAVICON_STREAM[frame]![badgeIdx];
29
51
  }
30
52
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Format an ISO timestamp as a short English relative time string.
3
+ * Examples: "just now", "5m ago", "3h ago", "2d ago", "Apr 21".
4
+ */
5
+ export function formatRelativeDate(iso: string): string {
6
+ try {
7
+ const date = new Date(iso);
8
+ const now = new Date();
9
+ const diffMs = now.getTime() - date.getTime();
10
+ const diffMin = Math.floor(diffMs / 60_000);
11
+ if (diffMin < 1) return "just now";
12
+ if (diffMin < 60) return `${diffMin}m ago`;
13
+ const diffHr = Math.floor(diffMin / 60);
14
+ if (diffHr < 24) return `${diffHr}h ago`;
15
+ const diffDay = Math.floor(diffHr / 24);
16
+ if (diffDay < 7) return `${diffDay}d ago`;
17
+ return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
18
+ } catch {
19
+ return "";
20
+ }
21
+ }
@@ -294,3 +294,15 @@ html, body {
294
294
  @media (prefers-reduced-motion: reduce) {
295
295
  .markdown-content.is-streaming > *:last-child { animation: none; }
296
296
  }
297
+
298
+ /* Tab streaming indicator — 3 dots bouncing inside chat bubble icon (Messenger typing style) */
299
+ @keyframes tabTypingBounce {
300
+ 0%, 60%, 100% { transform: translateY(0); }
301
+ 30% { transform: translateY(-1.5px); }
302
+ }
303
+ .tab-typing-dot {
304
+ animation: tabTypingBounce 1s ease-in-out infinite;
305
+ }
306
+ @media (prefers-reduced-motion: reduce) {
307
+ .tab-typing-dot { animation: none; }
308
+ }