An open API service indexing awesome lists of open source software.

https://github.com/juspay/shooter

Turn your phone into a remote control for AI coding sessions running on your dev machine.
https://github.com/juspay/shooter

ai claude-code development open-code productivity remote-control

Last synced: 28 days ago
JSON representation

Turn your phone into a remote control for AI coding sessions running on your dev machine.

Awesome Lists containing this project

README

          

# Shooter

**Mobile push notifications and remote terminal access for AI coding sessions.**

[![SvelteKit](https://img.shields.io/badge/SvelteKit-FF3E00?logo=svelte&logoColor=white)](https://kit.svelte.dev/)
[![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Node.js](https://img.shields.io/badge/Node.js_20+-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
[![WebSocket](https://img.shields.io/badge/WebSocket-010101?logo=socket.io&logoColor=white)](#websocket-channels)
[![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)](#docker)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

---

## What is Shooter?

Shooter turns your phone into a remote control for AI coding sessions running on your dev machine. It delivers push notifications to iOS and Android when Claude Code or OpenCode events occur -- tool usage, permission requests, session completions -- and lets you approve or deny permission prompts directly from a notification. You can also launch remote terminal sessions, stream output in real time, and browse structured AI conversation history, all from a mobile-optimized web interface accessible anywhere through a Cloudflare Tunnel.

## Features

- **Push notifications** -- Real-time alerts for tool usage, permission requests, session starts/stops, errors, and task completions (iOS via APNs, Android via FCM)
- **Bidirectional permissions** -- Approve or deny Claude Code permission prompts from your phone; the hook blocks until you respond
- **Remote terminal** -- Launch shell, Claude Code, or OpenCode sessions from your phone with full xterm.js rendering
- **Terminal persistence** -- PTY processes run in holder processes that survive server restarts; metadata persisted in SQLite
- **Structured Chat view** -- AI conversations rendered as message bubbles with tool-use cards and thinking indicators, parsed live from JSONL session files
- **Session browser** -- Browse coding session history across all projects
- **QR code pairing** -- Scan a QR code from the `/config` page to connect mobile apps to the server
- **WebSocket streaming** -- Three multiplexed channels: terminal I/O, session updates, and global events
- **Quick keys** -- Mobile-optimized touch bar for Ctrl+C, Tab, arrow keys, Esc, and other special characters
- **Claude Code hooks** -- Lifecycle hooks for 13 event types with context-aware notification categorization
- **Docker support** -- Multi-stage Dockerfile with arm64 and amd64 support

---

## Quick Start

**One-command install** (recommended):

```bash
curl -fsSL https://raw.githubusercontent.com/juspay/shooter/release/scripts/install.sh | sh
```

This clones to `~/.shooter/repo`, auto-generates an API key, installs dependencies, builds, offers to install cloudflared for remote access, enables autostart on login, and starts the server.

**Or clone and set up manually:**

```bash
git clone https://github.com/juspay/shooter.git
cd shooter
pnpm install
pnpm setup # interactive wizard: generates .env, builds, runs health check
pnpm start # start the server on http://localhost:54007
```

Open [http://localhost:54007](http://localhost:54007) in your browser. Visit `/config` to enter your API key for the web UI.

---

## All Setup Methods

| Method | Command | Notes |
| ------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| One-command install | `curl -fsSL https://raw.githubusercontent.com/juspay/shooter/release/scripts/install.sh \| sh` | Recommended. Clones to `~/.shooter/repo`, auto-generates API key, builds, installs cloudflared, starts server |
| Interactive wizard | `pnpm setup` | Walks through env config, builds, and verifies. Pass `--auto` for non-interactive mode. |
| CLI (npx) | `npx @juspay/shooter setup` | No clone needed -- runs the setup wizard directly from npm |
| Docker | `docker compose up -d` | See [Docker](#docker) |
| Manual | See [Manual Setup](#manual-setup) | For advanced users |

### Manual Setup

```bash
git clone https://github.com/juspay/shooter.git
cd shooter
pnpm install
pnpm setup # generates ~/.shooter/.env with API key, builds the project
pnpm start
```

Or without the wizard:

```bash
git clone https://github.com/juspay/shooter.git
cd shooter
pnpm install
mkdir -p ~/.shooter && echo "API_KEY=$(openssl rand -hex 32)" > ~/.shooter/.env
pnpm build
pnpm start
```

> **Note:** Configuration lives in `~/.shooter/.env` (not the repo root). The hook notifier reads `API_KEY` from this file automatically.

---

## Architecture

```
+----------------------------------------------------------+
| Dev Machine |
| |
| SvelteKit Server (adapter-node, port 54007) |
| +-- REST API (/api/terminals, /api/notify, ...) |
| +-- WebSocket Server (ws, noServer mode) |
| +-- PTY Manager (node-pty + holder processes) |
| +-- Terminal Store (SQLite persistence) |
| +-- Session Watcher (chokidar file watching) |
| +-- APNs Client (iOS push via @parse/node-apn) |
| +-- FCM Client (Android push via firebase-admin) |
+------------------------------+---------------------------+
|
Cloudflare Tunnel
shooter.yourdomain.com
|
+----------------------+----------------------+
| | |
+-------+--------+ +--------+-------+ +----------+------+
| Mobile Browser | | iOS App | | Android App |
| (web UI) | | (APNs push + | | (FCM push + |
| Terminal, Chat, | | permission | | WebView) |
| Session viewer | | responses) | | |
+-----------------+ +----------------+ +-----------------+
```

**Server entry point:** `server.ts` creates an HTTP server wrapping the SvelteKit handler, attaches a WebSocket server in `noServer` mode, and handles upgrade requests with ticket-based authentication.

**Terminal persistence:** PTY processes run inside separate holder processes (`pty-holder.cjs`) that survive server restarts. Terminal metadata (ID, PID, command, cwd) is persisted in SQLite so the server can reattach on restart.

**Three WebSocket channels:**

| Channel | Path | Purpose |
| -------------- | ------------------ | ---------------------------------------------------- |
| Terminal I/O | `/ws/terminal/:id` | Raw PTY byte stream (xterm.js) |
| Session stream | `/ws/session/:id` | Structured AI conversation updates |
| Global events | `/ws/events` | Server broadcasts (new sessions, exits, permissions) |

---

## Configuration

Configuration is stored in `~/.shooter/.env`. The `pnpm setup` wizard generates this file interactively. Only `API_KEY` is required to start -- push notification config can be added later with `shooter setup --push`.

| Variable | Required | Default | Description |
| ---------------------- | -------- | ------- | ---------------------------------------------------------------- |
| `API_KEY` | **Yes** | -- | Bearer token for authenticating all API and hook requests |
| `PORT` | No | `54007` | HTTP server port |
| `DEVICE_PLATFORM` | No | `ios` | Push notification target: `ios` or `android` |
| `APNS_KEY` | No | -- | APNs private key (`.p8` file contents, newlines escaped as `\n`) |
| `APNS_KEY_ID` | No | -- | 10-character APNs key identifier from Apple Developer portal |
| `APNS_TEAM_ID` | No | -- | 10-character Apple Team ID |
| `APNS_BUNDLE_ID` | No | -- | iOS app bundle identifier (must match Xcode project) |
| `APNS_PRODUCTION` | No | `false` | Set `true` for TestFlight / App Store builds |
| `DEVICE_TOKEN` | No | -- | Target iOS device token (64-character hex) |
| `FCM_PROJECT_ID` | No | -- | Firebase project ID |
| `FCM_CLIENT_EMAIL` | No | -- | Firebase service account email |
| `FCM_PRIVATE_KEY` | No | -- | Firebase service account private key (PEM format) |
| `ANDROID_DEVICE_TOKEN` | No | -- | Target Android FCM device token |

---

## iOS Setup

### Prerequisites

- macOS with Xcode installed
- Apple Developer account with Push Notifications capability
- Physical iOS device (push notifications do not work in the simulator)

### APNs Key Setup

1. Go to [Apple Developer > Keys](https://developer.apple.com/account/resources/authkeys/list) and create a new key with **Apple Push Notifications service (APNs)** enabled
2. Download the `.p8` file
3. Note the **Key ID** (10 characters) shown after creation
4. Find your **Team ID** in [Membership Details](https://developer.apple.com/account/#/membership)

Add these to your `.env`:

```
APNS_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
APNS_KEY_ID=ABC123DEFG
APNS_TEAM_ID=XYZ789KLMN
APNS_BUNDLE_ID=com.yourcompany.shooter
DEVICE_TOKEN=<64-char-hex-from-device>
```

### Building the iOS App

```bash
cd ios/Shooter
open Shooter.xcodeproj
```

1. Select your signing team in **Signing & Capabilities**
2. Ensure the **Push Notifications** capability is enabled
3. Build and run on a physical device
4. The device token is printed to the Xcode console on first launch

For TestFlight or App Store builds, set `APNS_PRODUCTION=true` in your server `.env` to route through the production APNs gateway.

---

## Android Setup

### Prerequisites

- Android Studio
- Gradle 8.12+ (for generating the wrapper)
- Firebase project with Cloud Messaging enabled

### Firebase Setup

1. Create a project in the [Firebase Console](https://console.firebase.google.com/)
2. Add an Android app with application ID `com.shooter.android`
3. Download `google-services.json` and place it in `android/app/`
4. Go to **Project Settings > Service Accounts** and generate a new private key
5. Copy `project_id`, `client_email`, and `private_key` from the downloaded JSON into your `.env`:

```
FCM_PROJECT_ID=your-firebase-project-id
FCM_CLIENT_EMAIL=firebase-adminsdk-xxxxx@your-project.iam.gserviceaccount.com
FCM_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
ANDROID_DEVICE_TOKEN=
DEVICE_PLATFORM=android
```

### Building the Android App

```bash
cd android
chmod +x setup.sh
./setup.sh # generates Gradle wrapper
./gradlew assembleDebug
```

The app targets SDK 35 (min SDK 26) and uses a WebView that connects to your Shooter server URL.

---

## Claude Code Hooks

Shooter integrates with Claude Code through lifecycle hooks defined in `.claude/settings.json`. A unified notifier script (`.claude/hooks/notifier.cjs`) handles all hook events.

### Captured Events

| Hook | Description |
| -------------------- | --------------------------------------------------------------- |
| `PreToolUse` | Before a tool executes (file edit, bash command, etc.) |
| `PostToolUse` | After a tool completes successfully |
| `PostToolUseFailure` | After a tool fails |
| `PermissionRequest` | Claude Code asks for permission -- **blocks until you respond** |
| `SessionStart` | A new coding session begins |
| `SessionEnd` | A coding session ends |
| `Stop` | Claude Code stops execution |
| `Notification` | General notification from Claude Code |
| `SubagentStart` | A subagent is spawned |
| `SubagentStop` | A subagent completes |
| `UserPromptSubmit` | User submits a prompt |
| `TeammateIdle` | A teammate agent becomes idle |
| `TaskCompleted` | A task finishes |
| `PreCompact` | Before context compaction |

### Permission Flow

1. Claude Code triggers `PermissionRequest` hook
2. Notifier sends a push notification with the tool name and details to your phone
3. You tap **Allow** or **Deny** on the interactive notification (iOS) or in the app
4. Notifier polls `GET /api/response?requestId=...` until your decision arrives
5. The hook returns the decision to Claude Code, which proceeds or aborts

The `PermissionRequest` hook has a 180-second timeout in `.claude/settings.json`. The notifier's internal poll timeout is 120 seconds, providing a 60-second safety buffer.

### Hook Environment Variables

| Variable | Default | Description |
| ---------------------------- | ------- | ----------------------------------------------------------- |
| `SHOOTER_USE_LOCAL` | -- | Set `true` to connect to local server instead of remote URL |
| `SHOOTER_LOCAL_PORT` | `54007` | Local server port when using `SHOOTER_USE_LOCAL` |
| `SHOOTER_API_URL` | -- | Remote server URL (when not using local) |
| `SHOOTER_PERMISSION_TIMEOUT` | `120` | Seconds to wait for a permission response |
| `API_KEY` | -- | Bearer token (must match the server's `API_KEY`) |

---

## Docker

### Quick Start

```bash
# Minimal — just set API_KEY:
echo "API_KEY=$(openssl rand -hex 32)" > .env
docker compose up -d
```

Or with a Cloudflare Tunnel for remote access:

```bash
docker compose --profile tunnel up -d
```

### Manual Build and Run

```bash
docker build -t shooter .

docker run -d \
--name shooter \
-e API_KEY=your-secret-key-here \
-p 54007:54007 \
-v shooter-data:/root/.shooter \
--restart unless-stopped \
shooter
```

> **Required:** Set `API_KEY` either via `-e API_KEY=...`, `--env-file .env`, or in `docker-compose.yml`. Without it, all authenticated endpoints return 401.

The multi-stage Dockerfile uses `node:20-slim` with native addon binaries copied from the build stage (no build tools in the production image). SQLite data is persisted in the `shooter-data` volume. The `.env` file is injected at runtime and never baked into the image.

A separate `Dockerfile.test` is provided for verifying the fresh-user install experience in an isolated container.

### docker-compose.yml

```yaml
services:
shooter:
build: .
ports:
- '54007:54007'
env_file:
- path: .env
required: false
# Set API_KEY in .env or uncomment below:
# environment:
# - API_KEY=your-secret-key-here
volumes:
- shooter-data:/root/.shooter
restart: unless-stopped

# Optional: Cloudflare Tunnel for remote access
# Start with: docker compose --profile tunnel up -d
tunnel:
image: cloudflare/cloudflared:latest
command: tunnel --no-autoupdate --url http://shooter:54007
depends_on:
- shooter
restart: unless-stopped
profiles:
- tunnel

volumes:
shooter-data:
```

---

## API Reference

All endpoints require the `Authorization: Bearer ` header.

| Method | Path | Description |
| -------- | --------------------------- | ---------------------------------------------------- |
| `GET` | `/api/health` | Health check with server status |
| `GET` | `/api/terminals` | List all active and recently exited terminals |
| `POST` | `/api/terminals` | Create a new terminal session |
| `GET` | `/api/terminals/:id` | Get details for a specific terminal |
| `DELETE` | `/api/terminals/:id` | Kill and remove a terminal session |
| `POST` | `/api/terminals/:id/resize` | Resize a terminal (cols, rows) |
| `POST` | `/api/ws-ticket` | Generate a short-lived WebSocket auth ticket |
| `GET` | `/api/ws-status` | Get connected WebSocket client count |
| `POST` | `/api/notify` | Send a push notification via APNs or FCM |
| `GET` | `/api/notify` | Check notification status and history |
| `POST` | `/api/response` | Submit a permission allow/deny decision |
| `GET` | `/api/response` | Poll for a pending permission decision |
| `GET` | `/api/sessions` | List sessions across all projects |
| `POST` | `/api/webhook` | Stub — returns 501 (not yet implemented) |
| `GET` | `/api/qr-config` | Generate QR code for mobile app pairing |
| `POST` | `/api/device-token` | Register a device token (iOS or Android) |
| `GET` | `/api/debug` | Debug information (APNs config, device token status) |

### WebSocket Authentication

WebSocket connections use ticket-based auth. First call `POST /api/ws-ticket` with your Bearer token to receive a single-use ticket (valid 30 seconds), then connect with `?ticket=TICKET` in the query string.

### Example: Create Terminal

```bash
curl -X POST http://localhost:54007/api/terminals \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"command": "claude", "cwd": "/Users/me/project", "cols": 80, "rows": 24}'
```

Response:

```json
{
"id": "term_a1b2c3",
"pid": 45231,
"command": "claude",
"cwd": "/Users/me/project",
"ws": "/ws/terminal/term_a1b2c3",
"sessionWs": "/ws/session/term_a1b2c3",
"createdAt": "2026-03-17T10:00:00Z"
}
```

---

## Development

```bash
pnpm dev # Vite dev server with hot reload (no WebSocket server)
pnpm build # Production build (outputs to build/)
pnpm start # Production server with WebSocket support (tsx server.ts)
pnpm preview # Preview production build via Vite
pnpm check # TypeScript type checking
pnpm run gen:types # Generate types from YAML specs (specs/types/)
pnpm lint # ESLint
pnpm lint:fix # ESLint with auto-fix
pnpm format # Prettier formatting
pnpm format:check # Check formatting without writing
```

**Note:** `pnpm dev` runs the Vite dev server, which does not include the WebSocket server or PTY manager. For full functionality (terminal sessions, live streaming), use `pnpm build && pnpm start`.

### CLI Commands

The `shooter` command (via `bin/shooter.cjs` or the global `shooter` symlink) supports:

| Command | Description |
| ----------------------- | ------------------------------------------------------------------------------------------- |
| `shooter start` | Start the server (default if no command given) |
| `shooter stop` | Stop the running server gracefully (SIGTERM, then SIGKILL after 5s) |
| `shooter status` | Show PID, URL, autostart state, log path |
| `shooter autostart on` | Enable autostart on login (LaunchAgent on macOS, systemd on Linux) |
| `shooter autostart off` | Disable autostart and remove the service definition |
| `shooter logs` | Tail server logs (log file on macOS, journalctl on Linux) |
| `shooter setup` | Quick setup (~60s): API key + build. `--auto` for non-interactive, `--push` for push config |
| `shooter version` | Print version number |
| `shooter help` | Show all available commands |

Process state is tracked via a PID file at `~/.shooter/shooter.pid`. Logs are written to `~/.shooter/logs/shooter.log` when running via autostart.

### Type System

Types are auto-generated from YAML specifications in `specs/types/` using [type-crafter](https://github.com/nicktaf/type-crafter). Never edit files in `src/lib/types/generated/` directly -- edit the YAML specs and run `pnpm run gen:types`.

---

## Project Structure

```
shooter/
server.ts # HTTP + WebSocket server entry point (build guard check)
package.json # Dependencies and scripts (pnpm only)
Dockerfile # Multi-stage Docker build
Dockerfile.test # Test image for fresh-user install verification
docker-compose.yml # Docker Compose config
.env.example # Environment variable template
svelte.config.js # SvelteKit config (adapter-node)
vite.config.ts # Vite config (node-pty external)
bin/
shooter.cjs # CLI entry point (start|stop|status|autostart|logs|setup|help)
scripts/
setup.cjs # Interactive setup wizard (--auto for non-interactive)
install.sh # One-command installer (full auto setup + cloudflared)
.claude/
hooks/notifier.cjs # Unified hook notifier (Node.js)
settings.json # Hook configuration (13 event types)
src/
lib/
types/
generated/ # Auto-generated TypeScript types (DO NOT EDIT)
modules/
server/
apn/ # APNs push notification service
auth.ts # Shared authentication helper
cli/ # CLI command utilities
terminal/
pty-manager.ts # PTY lifecycle, scrollback, cleanup
pty-holder.cjs # Standalone holder process for persistence
terminal-store.ts # SQLite persistence for terminal metadata
session-watcher.ts # JSONL file watcher (chokidar)
opencode-watcher.ts # OpenCode session watcher
ws/
server.ts # WebSocket upgrade routing
terminal-handler.ts # Terminal I/O channel
session-handler.ts # Session stream channel
events-handler.ts # Global event bus channel
ticket-store.ts # One-time auth ticket store
keepalive.ts # Ping/pong heartbeat
sessions/
jsonl-reader.ts # Parse JSONL session files
opencode-reader.ts # Parse OpenCode sessions
client/
common/ # Reusable UI components
activity/ # Activity feed components
dashboard/ # Dashboard components
neurolink/ # Neurolink integration components
terminal/
ChatView.svelte # Structured AI conversation view
LaunchSheet.svelte # Terminal launch dialog
QuickKeys.svelte # Mobile quick key bar
ConnectionStatus.svelte # Connection state indicator
xterm-wrapper.ts # Async xterm.js initialization
routes/
api/ # REST API endpoints (17 endpoints)
terminals/ # Terminal list and detail pages
project/ # Project dashboard
session/[id]/ # Session viewer
config/ # Settings page with QR pairing
specs/types/ # Type-crafter YAML specifications
ios/Shooter/ # Swift iOS app (Xcode project)
android/ # Kotlin Android app (Gradle project)
docs/ # Documentation
plans/ # Architecture plans and roadmap
```

---

## Updating

If you installed via the one-command installer:

```bash
cd ~/.shooter/repo
git pull origin release
pnpm install
pnpm build
shooter stop && shooter start -d
```

Or re-run the installer -- it detects the existing installation and offers to update:

```bash
curl -fsSL https://raw.githubusercontent.com/juspay/shooter/release/scripts/install.sh | sh
```

If you installed via npm:

```bash
npm update -g @juspay/shooter
```

---

## Uninstall

```bash
# 1. Stop the server and disable autostart
shooter stop
shooter autostart off

# 2. Remove the data directory (config, logs, SQLite database)
rm -rf ~/.shooter

# 3. Remove the global command symlink
rm -f ~/.local/bin/shooter

# 4. Remove the repo (if installed via one-command installer)
rm -rf ~/.shooter/repo
```

If you installed via npm: `npm uninstall -g @juspay/shooter`

To also remove Claude Code hooks, delete the `hooks` section from `.claude/settings.json` in each project that uses Shooter.

---

## Reset

To reset Shooter to a clean state without reinstalling:

```bash
shooter stop
rm ~/.shooter/.env # Remove config (re-run shooter setup to regenerate)
rm ~/.shooter/shooter.db # Remove terminal history database
rm -rf ~/.shooter/logs # Remove log files
shooter setup # Regenerate config
shooter start -d # Restart
```

---

## Troubleshooting

### Server does not start

- Verify Node.js 20+ is installed: `node --version`
- Ensure pnpm is used (npm and yarn are blocked): `pnpm --version`
- Check that `pnpm build` completed without errors before running `pnpm start` -- `server.ts` has a build guard that exits with a clear error if `build/handler.js` is missing
- Confirm `.env` exists and `API_KEY` is set (the server also checks `~/.shooter/.env` as a fallback)
- On Linux, ensure build tools are installed: `python3`, `make`, `g++` (needed for native modules)

### WebSocket connections fail

- `pnpm dev` does **not** run the WebSocket server. Use `pnpm build && pnpm start` for full functionality.
- Ensure you are obtaining a ticket via `POST /api/ws-ticket` before connecting
- Tickets expire after 30 seconds and are single-use

### Push notifications not arriving

- **iOS:** Verify `APNS_KEY`, `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_BUNDLE_ID`, and `DEVICE_TOKEN` are all set in `.env`
- **iOS (TestFlight/App Store):** Set `APNS_PRODUCTION=true` -- sandbox tokens do not work with the production gateway and vice versa
- **Android:** Ensure `google-services.json` is in `android/app/` and FCM credentials are in `.env`
- Check `GET /api/debug` for APNs configuration status and device token validity
- Check server logs for APNs or FCM error responses

### Hooks not sending notifications

- The notifier reads `API_KEY` from `~/.shooter/.env` automatically. If that file is missing or empty, run `shooter setup`.
- Verify the hooks are configured in `.claude/settings.json`
- Test connectivity: `curl -H "Authorization: Bearer $(grep API_KEY ~/.shooter/.env | cut -d= -f2 | tr -d '\"')" http://localhost:54007/api/health`

### Terminal sessions lost after restart

- Terminal metadata is persisted in SQLite and PTY holder processes survive restarts, so running terminals are reattached automatically
- In-memory state (WebSocket connections, auth tickets, pending permission requests) is lost on restart

### node-pty build errors

- Ensure Python 3, make, and a C++ compiler are installed
- On macOS, install Xcode Command Line Tools: `xcode-select --install`
- Try rebuilding: `pnpm rebuild node-pty`

### Port already in use

- `shooter start` detects port conflicts automatically and prints a clear error
- Default port is 54007. Set `PORT=` in `~/.shooter/.env` to use a different port
- To find what's using the port: `lsof -i :54007` (macOS) or `ss -tlnp | grep 54007` (Linux)

---

## Security

- **Command allowlist** -- Only `zsh`, `bash`, `sh`, `fish`, `claude`, and `opencode` can be launched as terminal commands
- **Ticket-based WebSocket auth** -- Short-lived, single-use tickets (30-second expiry) keep API keys out of WebSocket URLs
- **Bearer token on all REST endpoints** -- Every request requires `Authorization: Bearer `
- **Working directory validation** -- The `cwd` parameter is validated against the user's home directory; symlink traversal is blocked
- **No credentials in code** -- All secrets loaded from `.env` at runtime; `.env` is gitignored
- **APNs JWT rotation** -- Push notification tokens are generated with short expiry and rotated automatically

---

## License

MIT