@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 +274 -0
- package/bin/claude-pet.js +85 -0
- package/hooks/claude-pet-clear.js +17 -0
- package/hooks/claude-pet-notify.js +26 -0
- package/hooks/claude-pet-stop.js +24 -0
- package/lib/config.js +19 -0
- package/lib/lock.js +35 -0
- package/lib/overlay-binary.js +104 -0
- package/lib/runtime.js +49 -0
- package/lib/session-labels.js +86 -0
- package/macos/RobotPetOverlay.swift +101 -0
- package/package.json +36 -0
- package/prebuilt/macos/robot-pet-overlay +0 -0
- package/public/app.js +516 -0
- package/public/desktop.css +719 -0
- package/public/index.html +103 -0
- package/public/styles.css +34 -0
- package/scripts/close-desktop-if-last-session.js +73 -0
- package/scripts/install-claude-hook.js +78 -0
- package/scripts/launch-desktop-if-needed.js +77 -0
- package/scripts/launch-desktop-if-needed.sh +7 -0
- package/scripts/run-desktop.sh +7 -0
- package/scripts/setup.js +198 -0
- package/server.js +139 -0
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
|
+
}
|