@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 +6 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/package.json +2 -2
- package/src/providers/claude-agent-sdk.ts +17 -0
- package/src/server/index.ts +11 -9
- package/src/services/supervisor.ts +44 -1
- package/src/web/components/shared/theme-mode-menu.tsx +74 -0
- package/src/web/theme/theme-mode-options.ts +17 -0
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.
|
|
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.
|
|
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.
|
|
4
|
-
"description": "Personal Project Manager
|
|
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);
|
package/src/server/index.ts
CHANGED
|
@@ -942,15 +942,17 @@ if (process.argv.includes("__serve__")) {
|
|
|
942
942
|
}, 200);
|
|
943
943
|
}
|
|
944
944
|
|
|
945
|
-
// Publish the port we actually bound
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
//
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
+
];
|