@hienlh/ppm 0.17.52 → 0.17.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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.53] - 2026-09-02
4
+
5
+ ### Fixed
6
+ - **Starting the dev server no longer takes over the public URL** — since 0.17.50 the shared link is served by a small forwarder that looks up the running server's port in a file, and `bun dev:server` wrote its own port there, so the public URL quietly served the dev instance instead. Only the server the supervisor started publishes that now, and the supervisor puts it back if anything else overwrites it.
7
+ - **A background task finishing no longer ends your turn** — the notification was treated as the turn's result, so the assistant stopped mid-work.
8
+
3
9
  ## [0.17.52] - 2026-09-02
4
10
 
5
11
  ### Added
@@ -71,4 +71,4 @@ This skill covers the `ppm` CLI, its HTTP API, and its config DB. It does **not*
71
71
  - Third-party extensions (inspect via `ppm ext list`).
72
72
  - The Claude Agent SDK internals (separate skill).
73
73
 
74
- <!-- Generated for PPM v0.17.52 at build time. Re-run `ppm export skill --install` to refresh. -->
74
+ <!-- Generated for PPM v0.17.53 at build time. Re-run `ppm export skill --install` to refresh. -->
@@ -288,4 +288,4 @@ _Base URL: `http://localhost:8080` (default; override via `ppm config set port <
288
288
  - `ws://<host>/ws/terminal` — PTY terminal multiplexer
289
289
  - `ws://<host>/ws/extensions` — extension host channel
290
290
 
291
- <!-- Generated from src/server/routes/ for PPM v0.17.52 -->
291
+ <!-- Generated from src/server/routes/ for PPM v0.17.53 -->
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.17.52",
4
- "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
3
+ "version": "0.17.53",
4
+ "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
7
  "module": "src/index.ts",
@@ -1806,6 +1806,23 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1806
1806
  const result = msg as any;
1807
1807
  const subtype = result.subtype as string | undefined;
1808
1808
 
1809
+ // The SDK closes out background deliveries with a result of their own —
1810
+ // notably the orphaned-task notifications it replays on resume. Those carry
1811
+ // origin 'task-notification' with no turns, no usage and duration_api_ms 0:
1812
+ // no request ever reached the API. Treating one as the end of the user's turn
1813
+ // raised a bogus "Claude returned no response (0 turns)", yielded `done`, and
1814
+ // flipped the session to idle while the real turn was still streaming.
1815
+ // Only the empty ones are ignored: a scheduled-trigger delivery shares this
1816
+ // origin but does run a real turn, and must still be allowed to finish.
1817
+ if (
1818
+ result.origin?.kind === "task-notification"
1819
+ && (result.num_turns ?? 0) === 0
1820
+ && !assistantContent
1821
+ ) {
1822
+ console.log(`[sdk] session=${sessionId} ignoring empty task-notification result (no turn ran)`);
1823
+ continue;
1824
+ }
1825
+
1809
1826
  // Write cost to shared usage cache
1810
1827
  if (result.total_cost_usd != null) {
1811
1828
  updateFromSdkEvent(undefined, undefined, result.total_cost_usd);
@@ -942,15 +942,17 @@ if (process.argv.includes("__serve__")) {
942
942
  }, 200);
943
943
  }
944
944
 
945
- // Publish the port we actually bound. With `port: 0` the OS picks it, so this
946
- // file is the only way anything else can find the server — the edge forwarder
947
- // reads it to know where to send traffic, and the supervisor mirrors it into
948
- // status.json. The server is the single writer; see edge-target-resolver.ts
949
- // for why this is not status.json.
950
- try {
951
- writeFileSync(SERVER_PORT_FILE(), String(server.port));
952
- } catch (e) {
953
- console.error(`[serve] Failed to publish server port: ${e}`);
945
+ // Publish the port we actually bound so the edge forwarder knows where to
946
+ // send traffic. Only meaningful when the supervisor spawned us with port 0
947
+ // (OS-assigned); a dev server on a fixed port (e.g. `bun dev:server` on 8081)
948
+ // serves directly and must NOT overwrite this file — doing so redirects all
949
+ // production tunnel traffic to the dev instance.
950
+ if (port === 0) {
951
+ try {
952
+ writeFileSync(SERVER_PORT_FILE(), String(server.port));
953
+ } catch (e) {
954
+ console.error(`[serve] Failed to publish server port: ${e}`);
955
+ }
954
956
  }
955
957
 
956
958
  console.log(`Server child ready on port ${server.port}`);
@@ -63,6 +63,10 @@ let adoptedTunnelPid: number | null = null; // PID of tunnel kept alive across u
63
63
  // survives self-replace, so a new supervisor adopts it rather than respawning.
64
64
  let edgePid: number | null = null;
65
65
  let edgeProbeTimer: ReturnType<typeof setInterval> | null = null;
66
+ // The loopback port our own server child published. Cleared on every respawn so
67
+ // a stale value never fights the incoming generation. Once set, it makes the
68
+ // supervisor the authority on what `.server-port` should contain.
69
+ let serverPublishedPort: number | null = null;
66
70
  let shuttingDown = false;
67
71
  // Monotonic token for the authoritative tunnel loop. Every EXTERNAL (re)start
68
72
  // bumps it; a loop whose captured generation is stale must exit instead of
@@ -423,6 +427,7 @@ async function mirrorServerPort(): Promise<void> {
423
427
  _resetTargetCache(); // the memo is for the forwarder's hot path, not this poll
424
428
  const port = resolveTargetPort();
425
429
  if (port !== null) {
430
+ serverPublishedPort = port;
426
431
  updateStatus({ serverPort: port });
427
432
  log("INFO", `Server bound loopback port ${port}`);
428
433
  return;
@@ -432,6 +437,38 @@ async function mirrorServerPort(): Promise<void> {
432
437
  log("WARN", "Server never published its port — status.serverPort left stale");
433
438
  }
434
439
 
440
+ /**
441
+ * Repair `.server-port` when another process overwrites it.
442
+ *
443
+ * The file is the edge's routing table and lives in the shared `~/.ppm`, so
444
+ * ANY process running the `__serve__` entry can clobber it — most easily
445
+ * `bun dev:server`, which is not PPM_HOME-isolated and only differs by DB
446
+ * profile. When that happens the production tunnel silently serves the dev
447
+ * instance. The server-side guard (only a port-0, supervisor-spawned server
448
+ * publishes) stops new writes, but a value left behind by an older build would
449
+ * otherwise persist until the server restarts.
450
+ *
451
+ * After the initial handshake the supervisor knows the port its own child
452
+ * published, so it is the authority. It still does not write the file on the
453
+ * happy path — only to undo someone else's write.
454
+ */
455
+ function repairServerPortFile(): void {
456
+ if (serverPublishedPort === null || !serverChild) return;
457
+ _resetTargetCache();
458
+ const onDisk = resolveTargetPort();
459
+ if (onDisk === serverPublishedPort) return;
460
+ log(
461
+ "WARN",
462
+ `.server-port says ${onDisk ?? "nothing"} but our server child is on ${serverPublishedPort} — another process (a dev server?) hijacked the edge's target; restoring`,
463
+ );
464
+ try {
465
+ writeFileSync(SERVER_PORT_FILE(), String(serverPublishedPort));
466
+ _resetTargetCache();
467
+ } catch (e) {
468
+ log("ERROR", `Failed to restore .server-port: ${e}`);
469
+ }
470
+ }
471
+
435
472
  // ─── Server management ─────────────────────────────────────────────────
436
473
  export async function spawnServer(
437
474
  serverArgs: string[],
@@ -456,7 +493,9 @@ export async function spawnServer(
456
493
  // Zombie-port handling now applies only to the edge's public port.
457
494
  //
458
495
  // Clear the stale port file so the mirror below cannot publish the previous
459
- // generation's port to `ppm status`.
496
+ // generation's port to `ppm status`, and drop our record of it so
497
+ // repairServerPortFile does not restore a port that just died.
498
+ serverPublishedPort = null;
460
499
  try { unlinkSync(SERVER_PORT_FILE()); } catch {}
461
500
  const cmd = isCompiledBinary()
462
501
  ? [process.execPath, ...serverArgs]
@@ -784,6 +823,10 @@ function startServerHealthCheck() {
784
823
  return;
785
824
  }
786
825
  noServerChildCycles = 0;
826
+ // Undo any foreign write to `.server-port` before reading it, or the probe
827
+ // below would health-check a dev server and report our own as fine while
828
+ // the public tunnel serves the wrong instance.
829
+ repairServerPortFile();
787
830
  // Probe the SERVER's own loopback port, never `_opts.port` — that is the
788
831
  // public port and belongs to the edge. Probing through the edge would make
789
832
  // a dead edge look like a dead server and kill a perfectly healthy one
@@ -0,0 +1,74 @@
1
+ import { memo } from "react";
2
+ import { Check } from "lucide-react";
3
+ import {
4
+ DropdownMenu,
5
+ DropdownMenuTrigger,
6
+ DropdownMenuContent,
7
+ DropdownMenuLabel,
8
+ DropdownMenuItem,
9
+ } from "@/components/ui/dropdown-menu";
10
+ import { useSettingsStore } from "@/stores/settings-store";
11
+ import { THEME_MODE_OPTIONS } from "@/theme/theme-mode-options";
12
+ import { cn } from "@/lib/utils";
13
+
14
+ interface ThemeModeMenuProps {
15
+ /** Positioning / spacing for the trigger button. */
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * Icon-button dropdown for picking the theme mode (Light / Dark / System).
21
+ *
22
+ * Mode-only on purpose: this is used where the full theme *style* grid does not
23
+ * fit or is not yet reachable (the login screen runs pre-auth, so imported
24
+ * themes cannot be fetched). The trigger is a 44px touch target per the
25
+ * mobile-first UI rules.
26
+ */
27
+ export const ThemeModeMenu = memo(function ThemeModeMenu({ className }: ThemeModeMenuProps) {
28
+ const themeMode = useSettingsStore((s) => s.themeMode);
29
+ const setThemeMode = useSettingsStore((s) => s.setThemeMode);
30
+
31
+ const active = THEME_MODE_OPTIONS.find((o) => o.value === themeMode) ?? THEME_MODE_OPTIONS[2]!;
32
+ const ActiveIcon = active.icon;
33
+
34
+ return (
35
+ <DropdownMenu>
36
+ <DropdownMenuTrigger asChild>
37
+ <button
38
+ type="button"
39
+ title={`Appearance: ${active.label}`}
40
+ aria-label={`Appearance: ${active.label}`}
41
+ className={cn(
42
+ "flex size-11 items-center justify-center rounded-xl text-text-subtle",
43
+ "can-hover:hover:bg-surface-elevated can-hover:hover:text-foreground",
44
+ className,
45
+ )}
46
+ >
47
+ <ActiveIcon className="size-[18px]" />
48
+ </button>
49
+ </DropdownMenuTrigger>
50
+ <DropdownMenuContent align="end" className="w-44">
51
+ <DropdownMenuLabel>Appearance</DropdownMenuLabel>
52
+ {THEME_MODE_OPTIONS.map((opt) => {
53
+ const Icon = opt.icon;
54
+ return (
55
+ <DropdownMenuItem
56
+ key={opt.value}
57
+ onClick={() => setThemeMode(opt.value)}
58
+ className="gap-2"
59
+ >
60
+ <Icon className="size-4 shrink-0" />
61
+ <span className="flex-1 truncate">{opt.label}</span>
62
+ <Check
63
+ className={cn(
64
+ "size-4 shrink-0",
65
+ themeMode === opt.value ? "opacity-100" : "opacity-0",
66
+ )}
67
+ />
68
+ </DropdownMenuItem>
69
+ );
70
+ })}
71
+ </DropdownMenuContent>
72
+ </DropdownMenu>
73
+ );
74
+ });
@@ -0,0 +1,17 @@
1
+ import { Sun, Moon, Monitor } from "lucide-react";
2
+ import type { PpmThemeMode } from "./types";
3
+
4
+ /**
5
+ * The three selectable theme modes, in display order. Shared by every mode
6
+ * picker (settings tab, login screen) so the labels and icons cannot drift
7
+ * apart between them.
8
+ */
9
+ export const THEME_MODE_OPTIONS: {
10
+ value: PpmThemeMode;
11
+ label: string;
12
+ icon: React.ElementType;
13
+ }[] = [
14
+ { value: "light", label: "Light", icon: Sun },
15
+ { value: "dark", label: "Dark", icon: Moon },
16
+ { value: "system", label: "System", icon: Monitor },
17
+ ];