@hienlh/ppm 0.17.50 → 0.17.52

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 (49) hide show
  1. package/.rollout-mark +2 -0
  2. package/CHANGELOG.md +11 -0
  3. package/assets/skills/ppm/SKILL.md +1 -1
  4. package/assets/skills/ppm/references/http-api.md +1 -1
  5. package/dist/web/assets/{ai-resource-editor-x8J0omsj.js → ai-resource-editor-D12van6q.js} +1 -1
  6. package/dist/web/assets/{audio-preview-Bdf_SZHY.js → audio-preview-C3YJNKjD.js} +1 -1
  7. package/dist/web/assets/{chat-tab-CT6SP5D3.js → chat-tab-B6Elb3uK.js} +3 -3
  8. package/dist/web/assets/{code-editor-BgsJkCtK.js → code-editor-DM-ay_BF.js} +2 -2
  9. package/dist/web/assets/{conflict-editor-DdSe9RCG.js → conflict-editor-4rb0oS2i.js} +1 -1
  10. package/dist/web/assets/{csv-preview-DrUfLPRE.js → csv-preview-Bqfk0OeJ.js} +1 -1
  11. package/dist/web/assets/{database-viewer-1NLuRknj.js → database-viewer-CrBOZBE4.js} +1 -1
  12. package/dist/web/assets/{diff-viewer-DPn8QJXG.js → diff-viewer-BX02Fg5_.js} +1 -1
  13. package/dist/web/assets/{docx-preview-BUVtMDQF.js → docx-preview-rR_dLI4P.js} +1 -1
  14. package/dist/web/assets/{extension-webview-CEdJP2XP.js → extension-webview-D4rHB1h7.js} +1 -1
  15. package/dist/web/assets/{git-log-panel-ClLf6-as.js → git-log-panel-BryqAkz2.js} +1 -1
  16. package/dist/web/assets/{glide-data-grid-CBhv99Ii.js → glide-data-grid-CcGfgB3d.js} +1 -1
  17. package/dist/web/assets/{group-chat-tab-KTKtMy6L.js → group-chat-tab-rB8JuZIe.js} +1 -1
  18. package/dist/web/assets/{image-preview-DYyo-Twv.js → image-preview-i-jTDsjR.js} +1 -1
  19. package/dist/web/assets/{index-BiBAhdYZ.js → index-C4m82SWg.js} +22 -22
  20. package/dist/web/assets/{keybindings-store-C1yAVIlX.js → keybindings-store-C3dkxGGX.js} +1 -1
  21. package/dist/web/assets/{markdown-renderer-JNQd7xG0.js → markdown-renderer-DEXzjjQ8.js} +1 -1
  22. package/dist/web/assets/{markdown-renderer-CCBPLJC7.js → markdown-renderer-PbB8SlkU.js} +1 -1
  23. package/dist/web/assets/{notification-store-lQg17iIv.js → notification-store-6-9K7U26.js} +1 -1
  24. package/dist/web/assets/{pdf-preview-CWnXgjqc.js → pdf-preview-k7xI6wlU.js} +1 -1
  25. package/dist/web/assets/{postgres-viewer-BIXfNiGy.js → postgres-viewer-5zQgJlQz.js} +1 -1
  26. package/dist/web/assets/{settings-tab-CpltHPv5.js → settings-tab-DDNHT-XP.js} +1 -1
  27. package/dist/web/assets/{sql-query-editor-BDTzbvEM.js → sql-query-editor-CYLWqNpT.js} +1 -1
  28. package/dist/web/assets/{sqlite-viewer-BkOrBWZP.js → sqlite-viewer-DANvopw7.js} +1 -1
  29. package/dist/web/assets/{system-monitor-tab-s0Jm9zah.js → system-monitor-tab-Cfs1hq4q.js} +1 -1
  30. package/dist/web/assets/{terminal-tab-D3S_kz4Y.js → terminal-tab-CTB-qo9d.js} +1 -1
  31. package/dist/web/assets/{tool-cards-DbhWrJ_o.js → tool-cards-BBr3Pix3.js} +2 -2
  32. package/dist/web/assets/{use-monaco-theme-DKGvvHk1.js → use-monaco-theme-BJHYbrpI.js} +1 -1
  33. package/dist/web/assets/{video-preview-BCU0PfT1.js → video-preview-Dv1PD_Rj.js} +1 -1
  34. package/dist/web/index.html +1 -1
  35. package/dist/web/sw.js +1 -1
  36. package/docker-compose.test.yml +3 -0
  37. package/docs/lessons-learned.md +62 -0
  38. package/package.json +1 -1
  39. package/src/cli/commands/init.ts +2 -10
  40. package/src/providers/claude-agent-sdk.ts +2 -10
  41. package/src/services/file-watcher/recreated-dir-poller.ts +149 -0
  42. package/src/services/file-watcher/watch-tree.ts +58 -3
  43. package/src/services/proc-table-linux.ts +183 -0
  44. package/src/services/resource-monitor.service.ts +33 -9
  45. package/src/services/tunnel-registry.service.ts +18 -0
  46. package/src/services/windows-process-tree.ts +351 -335
  47. package/src/types/claude-models.ts +41 -0
  48. package/src/types/config.ts +3 -1
  49. package/src/web/components/settings/proxy-test-section.tsx +4 -8
@@ -1,6 +1,7 @@
1
1
  import { lstatSync, readdirSync, watch, type FSWatcher } from "node:fs";
2
2
  import { join, relative, sep } from "node:path";
3
3
  import { hasIgnoredDirSegment, isIgnoredDirName, isIgnoredPath } from "./ignore-rules.ts";
4
+ import { RecreatedDirPoller } from "./recreated-dir-poller.ts";
4
5
 
5
6
  /**
6
7
  * Watches a project directory while keeping the number of watched directories
@@ -54,18 +55,36 @@ export interface WatchTreeStats {
54
55
  dirs: number;
55
56
  /** `fs.watch` handles held. */
56
57
  watchers: number;
57
- /** Coverage was cut short by `maxDirs`, so part of the tree is unwatched. */
58
+ /** Coverage was cut short by a budget, so part of the tree is unwatched. */
58
59
  truncated: boolean;
60
+ /**
61
+ * Directories covered by the polling fallback instead of a watcher, because
62
+ * the runtime cannot re-watch a recreated path. Linux only; 0 elsewhere.
63
+ */
64
+ polledDirs: number;
59
65
  }
60
66
 
61
67
  export class WatchTree {
62
68
  private readonly attached = new Map<string, AttachedWatcher>();
63
69
  private readonly rebuildTimers = new Map<string, ReturnType<typeof setTimeout>>();
70
+ /**
71
+ * Every path we have ever handed to `fs.watch`. On Bun + Linux a second watch
72
+ * on the same path after a delete/recreate is silent forever, so this set is
73
+ * what tells us a watcher cannot be trusted and the poller has to stand in.
74
+ */
75
+ private readonly everAttached = new Set<string>();
76
+ private readonly poller: RecreatedDirPoller | null;
64
77
  private covered = 0;
65
78
  private truncated = false;
66
79
  private closed = false;
67
80
 
68
- constructor(private readonly options: WatchTreeOptions) {}
81
+ constructor(private readonly options: WatchTreeOptions) {
82
+ // Only Bun on Linux has the stale-watch defect; elsewhere re-watching works
83
+ // and paying for polling would be pure waste.
84
+ this.poller = process.platform === "linux"
85
+ ? new RecreatedDirPoller({ onChange: (abs) => this.reportAbs(abs) })
86
+ : null;
87
+ }
69
88
 
70
89
  start(): void {
71
90
  this.cover(this.options.root);
@@ -79,12 +98,19 @@ export class WatchTree {
79
98
  try { watcher.close(); } catch { /* already gone */ }
80
99
  }
81
100
  this.attached.clear();
101
+ this.everAttached.clear();
102
+ this.poller?.close();
82
103
  this.covered = 0;
83
104
  this.truncated = false;
84
105
  }
85
106
 
86
107
  stats(): WatchTreeStats {
87
- return { dirs: this.covered, watchers: this.attached.size, truncated: this.truncated };
108
+ return {
109
+ dirs: this.covered,
110
+ watchers: this.attached.size,
111
+ truncated: this.truncated || (this.poller?.truncated ?? false),
112
+ polledDirs: this.poller?.size ?? 0,
113
+ };
88
114
  }
89
115
 
90
116
  /** Walk `absDir` and attach the fewest watchers that cover it without touching ignored dirs. */
@@ -134,6 +160,22 @@ export class WatchTree {
134
160
 
135
161
  private attach(node: ScanNode): void {
136
162
  const fitsWholeSubtree = this.covered + node.size <= this.options.maxDirs;
163
+
164
+ // A path we have watched before is being re-attached, so this directory was
165
+ // deleted and recreated. On Bun + Linux its watcher will never fire again:
166
+ // poll its own entries, and cover the subtree non-recursively so each child
167
+ // gets a watcher on a path the runtime still honours.
168
+ if (this.poller && this.everAttached.has(node.path)) {
169
+ this.poller.add(node.path);
170
+ if (this.covered + 1 > this.options.maxDirs) {
171
+ this.truncated = true;
172
+ return;
173
+ }
174
+ this.addWatcher(node.path, false, 1);
175
+ for (const child of node.dirs) this.attach(child);
176
+ return;
177
+ }
178
+
137
179
  // One recursive watch for a subtree the runtime can safely expand on its own.
138
180
  if (!node.hasIgnored && fitsWholeSubtree && this.addWatcher(node.path, true, node.size)) return;
139
181
 
@@ -158,6 +200,7 @@ export class WatchTree {
158
200
  // server down; drop the handle instead and record the lost coverage.
159
201
  watcher.on("error", () => this.dropWatcher(absDir));
160
202
  this.attached.set(absDir, { watcher, covers, recursive });
203
+ this.everAttached.add(absDir);
161
204
  this.covered += covers;
162
205
  return true;
163
206
  } catch {
@@ -203,6 +246,17 @@ export class WatchTree {
203
246
  if (!isIgnoredPath(relPath)) this.options.onChange(relPath);
204
247
  }
205
248
 
249
+ /**
250
+ * Report an absolute path the poller noticed, applying the same root-scoping
251
+ * and ignore rules a watcher event goes through.
252
+ */
253
+ private reportAbs(abs: string): void {
254
+ if (this.closed) return;
255
+ const relPath = relative(this.options.root, abs).replaceAll("\\", "/");
256
+ if (!relPath || relPath === ".." || relPath.startsWith("../")) return;
257
+ if (!isIgnoredPath(relPath)) this.options.onChange(relPath);
258
+ }
259
+
206
260
  /** Extend or release coverage after a directory under a non-recursive watch appeared or vanished. */
207
261
  private syncChildDir(abs: string): void {
208
262
  let isDir = false;
@@ -233,6 +287,7 @@ export class WatchTree {
233
287
  }
234
288
 
235
289
  private closeSubtree(absPath: string): void {
290
+ this.poller?.remove(absPath);
236
291
  const prefix = absPath + sep;
237
292
  for (const [dir, entry] of this.attached) {
238
293
  if (dir !== absPath && !dir.startsWith(prefix)) continue;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Process table straight from `/proc` — Linux only, no subprocess.
3
+ *
4
+ * Every caller in PPM used to shell out to `ps`, which lives in `procps` and is
5
+ * simply absent from slim Debian images (including the one PPM's own test suite
6
+ * runs in). Each call site swallowed the spawn error and carried on with empty
7
+ * data, so a missing binary silently disabled orphan reaping, resource graphs
8
+ * and cloudflared discovery instead of failing loudly.
9
+ *
10
+ * Callers keep `ps` as the macOS path; there is no `/proc` there.
11
+ */
12
+ import { readFileSync, readdirSync } from "node:fs";
13
+
14
+ export const PROC_AVAILABLE = process.platform === "linux";
15
+
16
+ /**
17
+ * Kernel clock ticks per second. `sysconf(_SC_CLK_TCK)` is not reachable from
18
+ * JS; 100 is the value Linux has shipped on every mainstream configuration for
19
+ * decades, and it only scales CPU-time maths, so a wrong guess would skew a
20
+ * percentage rather than break anything.
21
+ */
22
+ const CLOCK_TICKS_PER_SEC = 100;
23
+ /** `/proc/<pid>/stat` reports RSS in pages. */
24
+ const PAGE_SIZE_KB = 4;
25
+
26
+ export interface ProcEntry {
27
+ pid: number;
28
+ ppid: number;
29
+ /** Average CPU% over the process lifetime — the same figure `ps %cpu` prints. */
30
+ cpuPercent: number;
31
+ rssKB: number;
32
+ /** Seconds since the process started. */
33
+ elapsedSec: number;
34
+ /** Wall-clock start time, epoch ms. Stable identity for PID-reuse checks. */
35
+ startedAtMs: number;
36
+ /** Executable name from `comm` (truncated to 15 chars by the kernel). */
37
+ comm: string;
38
+ /** Full argv joined by spaces. Empty for kernel threads. */
39
+ args: string;
40
+ }
41
+
42
+ /** Lowercased argv of `pid`, or null when /proc is unavailable/unreadable. */
43
+ export function readProcCmdline(pid: number): string | null {
44
+ if (!PROC_AVAILABLE) return null;
45
+ try {
46
+ // NUL-separated argv; join with spaces so substring checks behave like `ps`.
47
+ return readFileSync(`/proc/${pid}/cmdline`, "utf-8").split("\0").join(" ").toLowerCase();
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** Kernel `comm` for `pid`, or null when /proc is unavailable/unreadable. */
54
+ export function readProcComm(pid: number): string | null {
55
+ if (!PROC_AVAILABLE) return null;
56
+ try {
57
+ return readFileSync(`/proc/${pid}/comm`, "utf-8").trim();
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /** pid → ppid for every visible process, or null when /proc is unavailable. */
64
+ export function readProcPpidMap(): Map<number, number> | null {
65
+ if (!PROC_AVAILABLE) return null;
66
+ let names: string[];
67
+ try {
68
+ names = readdirSync("/proc");
69
+ } catch {
70
+ return null;
71
+ }
72
+ const ppidOf = new Map<number, number>();
73
+ for (const name of names) {
74
+ if (!isPidDir(name)) continue;
75
+ const fields = readStatFields(Number(name));
76
+ if (fields) ppidOf.set(Number(name), fields.ppid);
77
+ }
78
+ return ppidOf.size > 0 ? ppidOf : null;
79
+ }
80
+
81
+ /**
82
+ * Full process table, or null when /proc is unavailable. Reads `/proc/uptime`
83
+ * and `/proc/stat` once and reuses them for every process, so a table of a few
84
+ * hundred processes costs a few hundred small reads and no process spawns.
85
+ */
86
+ export function readProcTable(): ProcEntry[] | null {
87
+ if (!PROC_AVAILABLE) return null;
88
+
89
+ let names: string[];
90
+ let uptimeSec: number;
91
+ let bootTimeMs: number;
92
+ try {
93
+ names = readdirSync("/proc");
94
+ uptimeSec = parseFloat(readFileSync("/proc/uptime", "utf-8").split(/\s+/)[0] ?? "");
95
+ const btime = readFileSync("/proc/stat", "utf-8").match(/^btime\s+(\d+)/m);
96
+ bootTimeMs = btime ? Number(btime[1]) * 1000 : Date.now() - uptimeSec * 1000;
97
+ } catch {
98
+ return null;
99
+ }
100
+ if (!Number.isFinite(uptimeSec)) return null;
101
+
102
+ const out: ProcEntry[] = [];
103
+ for (const name of names) {
104
+ if (!isPidDir(name)) continue;
105
+ const pid = Number(name);
106
+ const f = readStatFields(pid);
107
+ if (!f) continue;
108
+
109
+ const startSec = f.startTicks / CLOCK_TICKS_PER_SEC;
110
+ // Clamp: a process started in the same tick as the uptime read can compute
111
+ // a tiny negative elapsed, which would produce an absurd CPU percentage.
112
+ const elapsedSec = Math.max(uptimeSec - startSec, 0.001);
113
+ const cpuSec = (f.utime + f.stime) / CLOCK_TICKS_PER_SEC;
114
+
115
+ let args = "";
116
+ try {
117
+ args = readFileSync(`/proc/${pid}/cmdline`, "utf-8").split("\0").filter(Boolean).join(" ");
118
+ } catch { /* exited between readdir and read */ }
119
+
120
+ out.push({
121
+ pid,
122
+ ppid: f.ppid,
123
+ cpuPercent: Math.round((cpuSec / elapsedSec) * 1000) / 10,
124
+ rssKB: f.rssPages * PAGE_SIZE_KB,
125
+ elapsedSec,
126
+ startedAtMs: bootTimeMs + startSec * 1000,
127
+ comm: f.comm,
128
+ args,
129
+ });
130
+ }
131
+ return out.length > 0 ? out : null;
132
+ }
133
+
134
+ const isPidDir = (name: string): boolean => /^\d+$/.test(name);
135
+
136
+ interface StatFields {
137
+ comm: string;
138
+ ppid: number;
139
+ utime: number;
140
+ stime: number;
141
+ startTicks: number;
142
+ rssPages: number;
143
+ }
144
+
145
+ /**
146
+ * Parse the fields we need out of `/proc/<pid>/stat`.
147
+ *
148
+ * The format is `pid (comm) state ppid …` and `comm` may contain spaces AND
149
+ * parentheses, so the split has to anchor on the LAST ')' — a naive
150
+ * whitespace split silently shifts every field for any process whose name
151
+ * contains a space.
152
+ */
153
+ function readStatFields(pid: number): StatFields | null {
154
+ let stat: string;
155
+ try {
156
+ stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
157
+ } catch {
158
+ return null; // process exited, or not permitted
159
+ }
160
+ const close = stat.lastIndexOf(")");
161
+ const open = stat.indexOf("(");
162
+ if (close < 0 || open < 0 || close < open) return null;
163
+
164
+ const comm = stat.slice(open + 1, close);
165
+ // After the last ')' the fields are: state ppid pgrp … i.e. proc(5) field N
166
+ // sits at index N-3.
167
+ const f = stat.slice(close + 1).trim().split(/\s+/);
168
+ const num = (i: number): number => {
169
+ const v = parseInt(f[i] ?? "", 10);
170
+ return isNaN(v) ? 0 : v;
171
+ };
172
+ const ppid = parseInt(f[1] ?? "", 10);
173
+ if (isNaN(ppid)) return null;
174
+
175
+ return {
176
+ comm,
177
+ ppid,
178
+ utime: num(11), // field 14
179
+ stime: num(12), // field 15
180
+ startTicks: num(19), // field 22
181
+ rssPages: num(21), // field 24
182
+ };
183
+ }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { parseProcessList, buildTree, groupProcesses } from "./resource-monitor-utils.ts";
8
+ import { readProcTable } from "./proc-table-linux.ts";
8
9
 
9
10
  // ── Types ──────────────────────────────────────────────────────────────
10
11
 
@@ -76,17 +77,40 @@ class ResourceMonitorService {
76
77
  }
77
78
  }
78
79
 
80
+ /**
81
+ * Prefer `/proc` and fall back to `ps`. `ps` is not installed on slim Linux
82
+ * images, and the old code let the failed spawn surface as an empty process
83
+ * list — the resource panel just showed zeros with no hint why.
84
+ */
85
+ private async listProcesses(): Promise<ProcessEntry[]> {
86
+ const table = readProcTable();
87
+ if (table) {
88
+ const now = Date.now();
89
+ return table
90
+ .filter((p) => p.pid !== 0 && p.args)
91
+ .map((p) => ({
92
+ pid: p.pid,
93
+ ppid: p.ppid,
94
+ cpu: Math.round(p.cpuPercent * 10) / 10,
95
+ ramMB: Math.round((p.rssKB / 1024) * 10) / 10,
96
+ startedAt: Math.min(p.startedAtMs, now),
97
+ command: p.args,
98
+ }));
99
+ }
100
+
101
+ const proc = Bun.spawn({
102
+ cmd: ["ps", "-e", "-o", "pid,ppid,%cpu,rss,etimes,args"],
103
+ stdout: "pipe",
104
+ stderr: "ignore",
105
+ });
106
+ const stdout = await new Response(proc.stdout).text();
107
+ await proc.exited;
108
+ return parseProcessList(stdout);
109
+ }
110
+
79
111
  private async poll() {
80
112
  try {
81
- const proc = Bun.spawn({
82
- cmd: ["ps", "-e", "-o", "pid,ppid,%cpu,rss,etimes,args"],
83
- stdout: "pipe",
84
- stderr: "ignore",
85
- });
86
- const stdout = await new Response(proc.stdout).text();
87
- await proc.exited;
88
-
89
- const entries = parseProcessList(stdout);
113
+ const entries = await this.listProcesses();
90
114
  const rootPid = process.pid;
91
115
  const serverEntry = entries.find((e) => e.pid === rootPid);
92
116
  const children = buildTree(entries, rootPid);
@@ -14,6 +14,7 @@ import { basename } from "node:path";
14
14
  import { resolve } from "node:path";
15
15
  import { readFileSync, existsSync } from "node:fs";
16
16
  import { getPpmDir } from "./ppm-dir.ts";
17
+ import { readProcTable, readProcComm } from "./proc-table-linux.ts";
17
18
  import { configService } from "./config.service.ts";
18
19
  import {
19
20
  parseCloudflaredCmdline,
@@ -74,6 +75,21 @@ function enumerateWindows(): RawProc[] {
74
75
  }
75
76
 
76
77
  function enumerateUnix(): RawProc[] {
78
+ // /proc first: `ps` lives in `procps` and slim Linux images omit it, which
79
+ // used to make cloudflared discovery come back empty with no error.
80
+ const table = readProcTable();
81
+ if (table) {
82
+ return table
83
+ .filter((p) => /(^|\/)cloudflared(\s|$)/.test(p.args))
84
+ .map((p) => ({
85
+ pid: p.pid,
86
+ // Same role as ps `lstart`: a start-time identity that survives PID reuse.
87
+ identity: new Date(p.startedAtMs).toISOString(),
88
+ imagePath: p.args.split(/\s+/)[0] ?? "",
89
+ cmdline: p.args,
90
+ }));
91
+ }
92
+
77
93
  // pid, lstart (identity), full args. Filter to cloudflared, excluding the grep.
78
94
  const out = execFileSync("ps", ["-eo", "pid=,lstart=,args="], { encoding: "utf-8", timeout: 6000 });
79
95
  const procs: RawProc[] = [];
@@ -190,6 +206,8 @@ export function isCloudflaredPid(pid: number): boolean {
190
206
  );
191
207
  return basename(out.trim()).toLowerCase() === "cloudflared.exe";
192
208
  }
209
+ const comm = readProcComm(pid);
210
+ if (comm !== null) return basename(comm) === "cloudflared";
193
211
  const out = execFileSync("ps", ["-o", "comm=", "-p", String(pid)], { encoding: "utf-8", timeout: 5000 });
194
212
  return basename(out.trim()) === "cloudflared";
195
213
  } catch {