@shumin13/claude-pet 0.1.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.
package/README.md ADDED
@@ -0,0 +1,274 @@
1
+ # Claude Pet
2
+
3
+ Claude Pet is a lightweight macOS desktop companion for Claude Code. It listens to local Claude Code hook events, renders a small always-on-top robot overlay, and shows session state without making any model calls.
4
+
5
+ The implementation is intentionally small:
6
+
7
+ - Native macOS overlay built with Swift, Cocoa, and WebKit
8
+ - Local Node.js event server bound to `127.0.0.1`
9
+ - No Electron runtime
10
+ - No runtime npm dependencies
11
+ - No telemetry
12
+ - No API keys, OAuth tokens, or credentials
13
+
14
+ ## Animated Demo
15
+
16
+ <img src="https://raw.githubusercontent.com/shumin13/claude-pet/master/docs/assets/demo.gif" alt="Claude Pet demo showing ready, permission, idle, job done, one waiting notification, and multiple notifications" width="300">
17
+
18
+ The animated demo cycles through ready, permission, idle, job done, one waiting notification, and multiple project notifications.
19
+
20
+ Static reference images are available in `docs/assets/screenshots/`.
21
+
22
+ ## Requirements
23
+
24
+ - macOS
25
+ - Node.js 18 or newer
26
+ - Claude Code with hook support
27
+
28
+ The npm package ships with a prebuilt macOS overlay. Xcode command line tools are only needed if you are building from source or the prebuilt overlay is missing.
29
+
30
+ ## Install
31
+
32
+ Install globally with npm:
33
+
34
+ ```sh
35
+ npm install -g @shumin13/claude-pet
36
+ ```
37
+
38
+ Then run setup:
39
+
40
+ ```sh
41
+ claude-pet
42
+ ```
43
+
44
+ That command asks where to install Claude Pet's app files, checks requirements, installs the native macOS overlay, and installs the Claude Code hooks. The default app location is `~/Library/Application Support/claude-pet/app`, which keeps hook paths stable even if your global npm directory changes. After setup, open a new Claude Code session and the pet will launch automatically.
45
+
46
+ You can also pass the app location explicitly:
47
+
48
+ ```sh
49
+ claude-pet --app-dir "$HOME/Applications/claude-pet"
50
+ ```
51
+
52
+ Choose a dedicated Claude Pet folder. Setup refuses to copy app files into common directories like your home, Documents, Downloads, or Applications folder directly.
53
+
54
+ Launch or preview it immediately:
55
+
56
+ ```sh
57
+ claude-pet launch
58
+ ```
59
+
60
+ Install or refresh only the Claude Code hooks:
61
+
62
+ ```sh
63
+ claude-pet install-hooks
64
+ ```
65
+
66
+ ## Development
67
+
68
+ Run tests:
69
+
70
+ ```sh
71
+ npm test
72
+ ```
73
+
74
+ Build the local native overlay used by this checkout:
75
+
76
+ ```sh
77
+ npm run build:overlay:local
78
+ ```
79
+
80
+ Build the prebuilt overlay that ships in the npm package:
81
+
82
+ ```sh
83
+ npm run build:overlay:package
84
+ ```
85
+
86
+ Create a source archive for sharing:
87
+
88
+ ```sh
89
+ npm run package:zip
90
+ ```
91
+
92
+ `package:zip` is optional. It writes `.build/claude-pet-source.zip` and is useful for sending the project as a single archive. GitHub users can ignore it and push the source tree directly.
93
+
94
+ ## Architecture
95
+
96
+ Claude Pet has three small runtime pieces:
97
+
98
+ | Layer | Files | Responsibility |
99
+ | -------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
100
+ | Event server | `server.js`, `lib/` | Serves the UI, accepts local hook POSTs, streams events to the overlay with SSE, and filters noisy notifications. |
101
+ | Native overlay | `macos/RobotPetOverlay.swift` | Creates the transparent always-on-top macOS window, hosts the WebKit view, supports native dragging, and cleans up PID files on close. |
102
+ | Web UI | `public/index.html`, `public/desktop.css`, `public/styles.css`, `public/app.js` | Renders the robot, notification bubbles, project grouping, collapsed badge, hover controls, preview controls, and resize behavior. |
103
+
104
+ The lifecycle scripts keep the overlay singleton across multiple Claude Code sessions:
105
+
106
+ | Hook | Script | Behavior |
107
+ | -------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
108
+ | `SessionStart` | `scripts/launch-desktop-if-needed.js` | Starts the server and overlay if needed, records the active session, and avoids duplicate windows with a file lock. |
109
+ | `Notification` | `hooks/claude-pet-notify.js` | Sends permission, idle, and other notifications to the local server with a project/session label. |
110
+ | `PostToolUse` | `hooks/claude-pet-clear.js` | Clears permission prompts once a tool completes. |
111
+ | `Stop` | `hooks/claude-pet-stop.js` | Shows the job-done state for the current session. |
112
+ | `SessionEnd` | `scripts/close-desktop-if-last-session.js` | Removes the active session and shuts down the overlay/server after the final session exits. |
113
+
114
+ ## Claude Code Hook Install
115
+
116
+ Install or refresh the hooks automatically:
117
+
118
+ ```sh
119
+ claude-pet install-hooks
120
+ ```
121
+
122
+ The installer updates `~/.claude/settings.json` and preserves a timestamped backup before writing.
123
+
124
+ Manual hook configuration is also supported. Replace `/path/to/claude-pet` with the absolute path to this package:
125
+
126
+ ```json
127
+ {
128
+ "hooks": {
129
+ "SessionStart": [
130
+ {
131
+ "hooks": [
132
+ {
133
+ "type": "command",
134
+ "command": "node /path/to/claude-pet/scripts/launch-desktop-if-needed.js"
135
+ }
136
+ ]
137
+ }
138
+ ],
139
+ "Notification": [
140
+ {
141
+ "hooks": [
142
+ {
143
+ "type": "command",
144
+ "command": "node /path/to/claude-pet/hooks/claude-pet-notify.js"
145
+ }
146
+ ]
147
+ }
148
+ ],
149
+ "PostToolUse": [
150
+ {
151
+ "hooks": [
152
+ {
153
+ "type": "command",
154
+ "command": "node /path/to/claude-pet/hooks/claude-pet-clear.js"
155
+ }
156
+ ]
157
+ }
158
+ ],
159
+ "Stop": [
160
+ {
161
+ "hooks": [
162
+ {
163
+ "type": "command",
164
+ "command": "node /path/to/claude-pet/hooks/claude-pet-stop.js"
165
+ }
166
+ ]
167
+ }
168
+ ],
169
+ "SessionEnd": [
170
+ {
171
+ "hooks": [
172
+ {
173
+ "type": "command",
174
+ "command": "node /path/to/claude-pet/scripts/close-desktop-if-last-session.js"
175
+ }
176
+ ]
177
+ }
178
+ ]
179
+ }
180
+ }
181
+ ```
182
+
183
+ ## Runtime Configuration
184
+
185
+ | Variable | Default | Purpose |
186
+ | ------------------------ | -------------------------------------------------- | ------------------------------------------------------------------------------------------ |
187
+ | `CLAUDE_PET_PORT` | `37421` | Local server port. |
188
+ | `CLAUDE_PET_ENDPOINT` | `http://127.0.0.1:${CLAUDE_PET_PORT}/events` | Hook POST target. |
189
+ | `CLAUDE_PET_APP_DIR` | `~/Library/Application Support/claude-pet/app` | Setup destination for stable app files and hook script paths. |
190
+ | `CLAUDE_PET_BUILD_DIR` | `~/Library/Application Support/claude-pet` | User-writable runtime directory for the native overlay, PID files, module cache, and logs. |
191
+ | `CLAUDE_PET_ROOT` | Repository root | Used by the native overlay for cleanup paths. |
192
+ | `CLAUDE_PET_DESKTOP_URL` | `http://127.0.0.1:${CLAUDE_PET_PORT}/desktop.html` | Web UI URL loaded by the native overlay. |
193
+
194
+ The server binds to `127.0.0.1` only.
195
+
196
+ ## UI Behavior
197
+
198
+ Claude Pet keeps one overlay window for all active Claude Code sessions. Multiple sessions do not create multiple pets.
199
+
200
+ Supported states:
201
+
202
+ - `ready`: quiet visible robot, no startup bubble, subtle blink
203
+ - `permission_prompt`: priority state with alert badge and permission bubble
204
+ - `idle_prompt`: waiting state with sleepy expression and blink
205
+ - `job_done`: completion state with smaller success smile
206
+ - multiple projects: one compact bubble per project
207
+ - collapsed notifications: compact count badge near the pet
208
+
209
+ Interaction behavior:
210
+
211
+ - Drag the pet body to move the native overlay
212
+ - Hover to reveal minimize, close, and resize controls
213
+ - Drag the lower-right resize handle for smooth manual resizing
214
+ - Click the collapsed badge or pet to expand notifications
215
+
216
+ ## Static Preview
217
+
218
+ The desktop UI can be opened directly for development:
219
+
220
+ ```text
221
+ /path/to/claude-pet/public/index.html
222
+ ```
223
+
224
+ Demo states can be rendered with query parameters when served through the local server:
225
+
226
+ ```text
227
+ http://127.0.0.1:37421/desktop.html?demo=ready
228
+ http://127.0.0.1:37421/desktop.html?demo=permission
229
+ http://127.0.0.1:37421/desktop.html?demo=idle
230
+ http://127.0.0.1:37421/desktop.html?demo=done
231
+ http://127.0.0.1:37421/desktop.html?demo=one
232
+ http://127.0.0.1:37421/desktop.html?demo=multi
233
+ http://127.0.0.1:37421/desktop.html?demo=multi&collapsed=true
234
+ ```
235
+
236
+ ## Privacy And Repository Safety
237
+
238
+ Claude Pet is local-only. It does not send prompts, transcripts, notifications, or usage data to a remote service.
239
+
240
+ The repository should not contain secrets. A push-readiness scan should only find documentation references to words such as "token" or "API key", not actual credentials.
241
+
242
+ Local/generated files are ignored:
243
+
244
+ - `.build/`
245
+ - `node_modules/`
246
+ - `.env*`
247
+ - log files
248
+ - `.DS_Store`
249
+ - coverage output
250
+ - local Claude settings such as `~/.claude/settings.json`
251
+
252
+ Recommended checks before publishing:
253
+
254
+ ```sh
255
+ rg -n -i "(api[_-]?key|secret|token|password|passwd|authorization|bearer|private[_-]?key|BEGIN (RSA|OPENSSH|PRIVATE)|sk-[A-Za-z0-9]|xox[baprs]-|gh[pousr]_[A-Za-z0-9]|AIza[0-9A-Za-z_-]|AKIA[0-9A-Z]{16})" . -g '!node_modules/**' -g '!.build/**' -g '!*.zip'
256
+ npm test
257
+ npm run build:overlay:package
258
+ npm pack --dry-run
259
+ ```
260
+
261
+ ## Project Layout
262
+
263
+ ```text
264
+ .
265
+ ├── hooks/ Claude Code event hooks
266
+ ├── lib/ Shared config, runtime helpers, locks, session labels
267
+ ├── macos/ Native Swift overlay
268
+ ├── public/ Desktop UI
269
+ ├── scripts/ Launch, shutdown, hook install, desktop runner
270
+ ├── tests/ Integration tests
271
+ ├── docs/assets/ README screenshots and demo media
272
+ ├── server.js Local event server
273
+ └── prebuilt/ Packaged macOS overlay binary
274
+ ```
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+ import { dirname, join } from "node:path";
8
+
9
+ const binDir = dirname(fileURLToPath(import.meta.url));
10
+ const root = join(binDir, "..");
11
+ const defaultAppDir = join(homedir(), "Library", "Application Support", "claude-pet", "app");
12
+
13
+ const commands = new Map([
14
+ ["setup", ["scripts/setup.js"]],
15
+ ["launch", ["scripts/launch-desktop-if-needed.js"]],
16
+ ["install-hooks", ["scripts/install-claude-hook.js"]],
17
+ ["server", ["server.js"]]
18
+ ]);
19
+
20
+ const aliases = new Map([
21
+ ["start", "launch"],
22
+ ["hooks", "install-hooks"]
23
+ ]);
24
+
25
+ function printHelp() {
26
+ console.log(`Claude Pet
27
+
28
+ Usage:
29
+ claude-pet Set up hooks and install the overlay
30
+ claude-pet setup Set up hooks and install the overlay
31
+ claude-pet --app-dir DIR Install stable app files in DIR during setup
32
+ claude-pet launch Launch or preview the pet now
33
+ claude-pet install-hooks Install or refresh Claude Code hooks
34
+ claude-pet server Run the local event server
35
+
36
+ Install:
37
+ npm install -g @shumin13/claude-pet`);
38
+ }
39
+
40
+ function run(script, args = []) {
41
+ const scriptRoot = script[0].startsWith(defaultAppDir) ? defaultAppDir : root;
42
+ const child = spawn(process.execPath, [...script, ...args], {
43
+ cwd: scriptRoot,
44
+ stdio: "inherit"
45
+ });
46
+ child.on("exit", code => {
47
+ process.exit(code || 0);
48
+ });
49
+ child.on("error", error => {
50
+ console.error(error?.message || error);
51
+ process.exit(1);
52
+ });
53
+ }
54
+
55
+ function commandScript(command) {
56
+ const script = commands.get(command);
57
+ if (!script || command === "setup") return script;
58
+
59
+ const stableScript = join(defaultAppDir, script[0]);
60
+ const marker = join(defaultAppDir, ".claude-pet-app");
61
+ if (existsSync(marker) && existsSync(stableScript)) {
62
+ return [stableScript];
63
+ }
64
+
65
+ return script;
66
+ }
67
+
68
+ const rawCommand = process.argv[2] || "setup";
69
+ const commandArgs = process.argv.slice(3);
70
+ const command = aliases.get(rawCommand) || rawCommand;
71
+
72
+ if (command === "help" || command === "--help" || command === "-h") {
73
+ printHelp();
74
+ process.exit(0);
75
+ }
76
+
77
+ const script = rawCommand.startsWith("-") ? commands.get("setup") : commandScript(command);
78
+ if (!script) {
79
+ console.error(`Unknown command: ${rawCommand}`);
80
+ console.error("");
81
+ printHelp();
82
+ process.exit(1);
83
+ }
84
+
85
+ run(script, rawCommand.startsWith("-") ? process.argv.slice(2) : commandArgs);
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { eventsUrl } from "../lib/config.js";
4
+ import { postJson } from "../lib/runtime.js";
5
+
6
+ const endpoint = process.env.CLAUDE_PET_ENDPOINT || eventsUrl;
7
+
8
+ try {
9
+ await postJson(endpoint, {
10
+ type: "ready",
11
+ title: "Claude Pet is awake",
12
+ message: "Waiting for Claude Code notifications.",
13
+ replay: true
14
+ });
15
+ } catch {
16
+ process.exitCode = 1;
17
+ }
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { labelForEvent, withSessionPrefix } from "../lib/session-labels.js";
4
+ import { eventsUrl } from "../lib/config.js";
5
+ import { postJson, readStdinJson } from "../lib/runtime.js";
6
+
7
+ const endpoint = process.env.CLAUDE_PET_ENDPOINT || eventsUrl;
8
+ const ignoredTypes = new Set(["auth_success"]);
9
+ const genericBashPermission = "Claude needs your permission to use Bash";
10
+
11
+ try {
12
+ const event = await readStdinJson();
13
+ const type = event.notification_type || event.type;
14
+ if (!ignoredTypes.has(type) && !(type === "permission_prompt" && String(event.message || "").trim() === genericBashPermission)) {
15
+ const label = await labelForEvent(event);
16
+ const response = await postJson(endpoint, {
17
+ ...event,
18
+ message: withSessionPrefix(event.message, label),
19
+ replay: false
20
+ });
21
+
22
+ if (!response.ok) process.exitCode = 1;
23
+ }
24
+ } catch {
25
+ process.exitCode = 1;
26
+ }
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { labelForEvent, withSessionPrefix } from "../lib/session-labels.js";
4
+ import { eventsUrl } from "../lib/config.js";
5
+ import { postJson, readStdinJson } from "../lib/runtime.js";
6
+
7
+ const endpoint = process.env.CLAUDE_PET_ENDPOINT || eventsUrl;
8
+
9
+ try {
10
+ const event = await readStdinJson();
11
+ const label = await labelForEvent(event);
12
+ const message = event.stop_hook_active
13
+ ? "Claude finished and skipped recursive stop hooks."
14
+ : "Claude finished the current response.";
15
+
16
+ await postJson(endpoint, {
17
+ type: "job_done",
18
+ title: "Job done",
19
+ message: withSessionPrefix(message, label),
20
+ replay: false
21
+ });
22
+ } catch {
23
+ process.exitCode = 1;
24
+ }
package/lib/config.js ADDED
@@ -0,0 +1,19 @@
1
+ import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export const root = fileURLToPath(new URL("..", import.meta.url));
6
+ export const port = process.env.CLAUDE_PET_PORT || process.env.PORT || "37421";
7
+ export const buildDir = process.env.CLAUDE_PET_BUILD_DIR || join(homedir(), "Library", "Application Support", "claude-pet");
8
+ export const swiftModuleCacheDir = join(buildDir, "module-cache");
9
+ export const logDir = join(buildDir, "logs");
10
+ export const overlayPath = join(buildDir, "robot-pet-overlay");
11
+ export const overlayPidFile = join(buildDir, "robot-pet-overlay.pid");
12
+ export const serverPidFile = join(buildDir, "claude-pet-server.pid");
13
+ export const sessionsFile = join(buildDir, "active-claude-sessions.json");
14
+ export const lifecycleLockDir = join(buildDir, "claude-pet-lifecycle.lock");
15
+ export const swiftSource = join(root, "macos", "RobotPetOverlay.swift");
16
+ export const prebuiltOverlayPath = join(root, "prebuilt", "macos", "robot-pet-overlay");
17
+ export const healthUrl = `http://127.0.0.1:${port}/health`;
18
+ export const eventsUrl = `http://127.0.0.1:${port}/events`;
19
+ export const desktopUrl = `http://127.0.0.1:${port}/desktop.html`;
package/lib/lock.js ADDED
@@ -0,0 +1,35 @@
1
+ import { mkdir, rmdir, stat } from "node:fs/promises";
2
+
3
+ export async function withFileLock(lockDir, fn, options = {}) {
4
+ const timeoutMs = options.timeoutMs ?? 5000;
5
+ const retryMs = options.retryMs ?? 50;
6
+ const staleMs = options.staleMs ?? 15000;
7
+ const deadline = Date.now() + timeoutMs;
8
+
9
+ while (true) {
10
+ try {
11
+ await mkdir(lockDir, { recursive: false });
12
+ break;
13
+ } catch (error) {
14
+ if (error?.code !== "EEXIST") throw error;
15
+ try {
16
+ const details = await stat(lockDir);
17
+ if (Date.now() - details.mtimeMs > staleMs) await rmdir(lockDir);
18
+ } catch {
19
+ // If the lock disappeared between checks, retry immediately.
20
+ }
21
+ if (Date.now() >= deadline) throw error;
22
+ await new Promise(resolve => setTimeout(resolve, retryMs));
23
+ }
24
+ }
25
+
26
+ try {
27
+ return await fn();
28
+ } finally {
29
+ try {
30
+ await rmdir(lockDir);
31
+ } catch {
32
+ // Another cleanup path may already have removed it.
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,104 @@
1
+ import { access, chmod, copyFile, mkdir, rm, stat } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import {
6
+ overlayPath,
7
+ prebuiltOverlayPath,
8
+ root,
9
+ swiftModuleCacheDir,
10
+ swiftSource
11
+ } from "./config.js";
12
+
13
+ export async function commandExists(command) {
14
+ try {
15
+ await run(command, ["--version"], { stdio: "ignore" });
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ export async function executable(path) {
23
+ try {
24
+ await access(path, constants.X_OK);
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ export async function hasPrebuiltOverlay() {
32
+ return executable(prebuiltOverlayPath);
33
+ }
34
+
35
+ export function run(command, args, options = {}) {
36
+ return new Promise((resolve, reject) => {
37
+ const child = spawn(command, args, {
38
+ cwd: root,
39
+ stdio: options.stdio || "inherit",
40
+ env: {
41
+ ...process.env,
42
+ CLANG_MODULE_CACHE_PATH: swiftModuleCacheDir,
43
+ ...options.env
44
+ }
45
+ });
46
+ child.on("error", reject);
47
+ child.on("exit", code => {
48
+ if (code === 0) resolve();
49
+ else reject(new Error(`${command} exited with ${code}`));
50
+ });
51
+ });
52
+ }
53
+
54
+ async function targetIsCurrentWithPrebuilt() {
55
+ if (!(await executable(overlayPath))) return false;
56
+ const [prebuiltStat, overlayStat] = await Promise.all([stat(prebuiltOverlayPath), stat(overlayPath)]);
57
+ return overlayStat.size === prebuiltStat.size && overlayStat.mtimeMs >= prebuiltStat.mtimeMs;
58
+ }
59
+
60
+ async function targetIsCurrentWithSource() {
61
+ if (!(await executable(overlayPath))) return false;
62
+ const [sourceStat, overlayStat] = await Promise.all([stat(swiftSource), stat(overlayPath)]);
63
+ return overlayStat.mtimeMs >= sourceStat.mtimeMs;
64
+ }
65
+
66
+ export async function ensureOverlayBinary({ quiet = false } = {}) {
67
+ if (await hasPrebuiltOverlay()) {
68
+ if (await targetIsCurrentWithPrebuilt()) {
69
+ if (!quiet) console.log("Native overlay is already installed.");
70
+ return "current";
71
+ }
72
+
73
+ if (!quiet) console.log("Installing prebuilt native macOS overlay...");
74
+ await mkdir(dirname(overlayPath), { recursive: true });
75
+ await copyFile(prebuiltOverlayPath, overlayPath);
76
+ await chmod(overlayPath, 0o755);
77
+ return "prebuilt";
78
+ }
79
+
80
+ if (await targetIsCurrentWithSource()) {
81
+ if (!quiet) console.log("Native overlay is already built.");
82
+ return "current";
83
+ }
84
+
85
+ if (!(await commandExists("swiftc"))) {
86
+ throw new Error("Xcode command line tools are required because no prebuilt overlay was found and swiftc is not available.");
87
+ }
88
+
89
+ if (!quiet) console.log("Building native macOS overlay...");
90
+ await rm(swiftModuleCacheDir, { recursive: true, force: true });
91
+ await mkdir(swiftModuleCacheDir, { recursive: true });
92
+ await mkdir(dirname(overlayPath), { recursive: true });
93
+ await run("swiftc", [
94
+ "macos/RobotPetOverlay.swift",
95
+ "-framework",
96
+ "Cocoa",
97
+ "-framework",
98
+ "WebKit",
99
+ "-o",
100
+ overlayPath
101
+ ]);
102
+ await rm(swiftModuleCacheDir, { recursive: true, force: true });
103
+ return "built";
104
+ }
package/lib/runtime.js ADDED
@@ -0,0 +1,49 @@
1
+ import { readFile, unlink } from "node:fs/promises";
2
+
3
+ export async function readStdinJson() {
4
+ if (process.stdin.isTTY) return {};
5
+
6
+ let input = "";
7
+ process.stdin.setEncoding("utf8");
8
+ for await (const chunk of process.stdin) input += chunk;
9
+ try {
10
+ return input.trim() ? JSON.parse(input) : {};
11
+ } catch {
12
+ return {};
13
+ }
14
+ }
15
+
16
+ export async function postJson(endpoint, payload) {
17
+ return fetch(endpoint, {
18
+ method: "POST",
19
+ headers: { "content-type": "application/json" },
20
+ body: JSON.stringify(payload)
21
+ });
22
+ }
23
+
24
+ export async function readPid(path) {
25
+ try {
26
+ const pid = Number((await readFile(path, "utf8")).trim());
27
+ return pid || undefined;
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ export function isAlive(pid) {
34
+ if (!pid) return false;
35
+ try {
36
+ process.kill(pid, 0);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ export async function removeFile(path) {
44
+ try {
45
+ await unlink(path);
46
+ } catch {
47
+ // Already gone.
48
+ }
49
+ }