{"id":51739602,"url":"https://github.com/pigri/code-remote","last_synced_at":"2026-07-18T11:40:48.176Z","repository":{"id":364986859,"uuid":"1270011204","full_name":"pigri/code-remote","owner":"pigri","description":"REST API + crctl CLI to launch and manage detached Claude Code sessions over GNU screen — bearer auth, UUID-pinned sessions, systemd hardening","archived":false,"fork":false,"pushed_at":"2026-07-14T09:34:21.000Z","size":114,"stargazers_count":2,"open_issues_count":0,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-14T10:27:35.821Z","etag":null,"topics":["claude","claude-code","cli","devtools","go","golang","remote-control","rest-api","screen","session-manager","systemd"],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/pigri.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-15T09:48:58.000Z","updated_at":"2026-07-14T09:32:17.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/pigri/code-remote","commit_stats":null,"previous_names":["pigri/code-remote"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/pigri/code-remote","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pigri%2Fcode-remote","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pigri%2Fcode-remote/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pigri%2Fcode-remote/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pigri%2Fcode-remote/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/pigri","download_url":"https://codeload.github.com/pigri/code-remote/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pigri%2Fcode-remote/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35616737,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-18T02:00:07.223Z","response_time":61,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["claude","claude-code","cli","devtools","go","golang","remote-control","rest-api","screen","session-manager","systemd"],"created_at":"2026-07-18T11:40:47.659Z","updated_at":"2026-07-18T11:40:48.171Z","avatar_url":"https://github.com/pigri.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# code-remote\n\nA small REST API (Go) that launches detached [Claude Code](https://claude.com/claude-code)\nsessions inside GNU `screen` and lets you drive them remotely — plus `crctl`, a\nlocal CLI to list/start/stop them. The API and `crctl` are dependency-free\n(stdlib only); the optional `ngrok-forward` helper uses the ngrok Go SDK.\n\nEach session is pinned to a UUID passed as both `--session-id` and\n`--remote-control`, and that same UUID is the `screen` session name suffix. So\nthe listing can always join **screen ↔ Claude session ↔ title** and show the\nlive display name you set inside Claude.\n\n## How it works\n\n`POST /sessions` runs, roughly:\n\n```sh\nscreen -dmS \u003cprefix\u003e-\u003cuuid\u003e claude --session-id \u003cuuid\u003e --remote-control \u003cuuid\u003e\n```\n\n- `\u003cuuid\u003e` is the Claude session id (the file `~/.claude/projects/*/\u003cuuid\u003e.jsonl`)\n  and the Remote Control name.\n- The `title` is read live from that session log's latest `custom-title` record,\n  so renaming the session **inside Claude** is reflected automatically — no\n  second rename needed.\n- Stopping/restarting the API does **not** kill running sessions (the systemd\n  unit uses `KillMode=process`); they're rediscovered via `screen -ls`.\n\n## Requirements\n\n- Go 1.26+\n- `screen`\n- `claude` (Claude Code) on the host\n\n## Build\n\n```sh\ngo build -o claude-remote-api .                       # the API server\ngo build -o crctl ./cmd/crctl                         # the local CLI\ngo build -o ngrok-forward ./cmd/ngrok-forward         # optional: ngrok tunnel (Go SDK)\n```\n\n## Install (Debian/Ubuntu)\n\nOne-liner (latest release):\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/pigri/code-remote/main/install.sh | sh\n```\n\nOr grab the `.deb` from [Releases](https://github.com/pigri/code-remote/releases)\nand `sudo dpkg -i code-remote_\u003cversion\u003e_\u003carch\u003e.deb`. It installs the three\nbinaries to `/usr/bin`, per-user systemd units to `/usr/lib/systemd/user`, and\nSynapse templates + `env.example` to `/usr/share/code-remote`. `crctl` then\nworks immediately; the postinst prints the steps to enable the API/WAF/ngrok\nservices.\n\nBuild a `.deb` locally: `make deb` (output in `./dist`).\n\n## Releasing\n\nPush a `vX.Y.Z` tag; CI (`.github/workflows/release.yaml`) cross-builds the\nbinaries for amd64/arm64, packages a `.deb` and a tarball per arch, and\npublishes a GitHub release with `SHA256SUMS.txt` and generated notes.\n\n```sh\ngit tag v0.1.0 \u0026\u0026 git push origin v0.1.0\n```\n\n## Run the server\n\n```sh\nexport CLAUDE_REMOTE_API_TOKEN=$(openssl rand -hex 24)\n./claude-remote-api\n```\n\n### Server configuration (env)\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `CLAUDE_REMOTE_API_TOKEN` | — (**required**) | Bearer token; the server refuses to start without it |\n| `CLAUDE_REMOTE_API_ADDR` | `:8080` | Listen address |\n| `CLAUDE_REMOTE_SESSION_PREFIX` | `pigri-dev-remote` | Screen session name prefix (ownership scope) |\n| `CLAUDE_BIN` | `claude` | Path/name of the claude binary |\n| `SCREEN_BIN` | `screen` | Path/name of the screen binary |\n| `CLAUDE_HOME` | `~/.claude` | Where session logs (titles) are read from |\n| `CLAUDE_REMOTE_LOG_FORMAT` | `text` | Audit log format: `text` (key=value) or `json` |\n| `CLAUDE_REMOTE_SESSION_SYNC` | `on` | Poll the server for archived sessions and quit their screens (`off` to disable) |\n| `CLAUDE_REMOTE_SYNC_INTERVAL` | `30s` | How often to reconcile (Go duration, e.g. `30s`, `1m`) |\n| `CLAUDE_REMOTE_ARCHIVE_GRACE` | `15m` | A session must stay archived this long before its screen is quit (`0` = immediate) |\n| `CLAUDE_REMOTE_CREDENTIALS` | `$CLAUDE_HOME/.credentials.json` | OAuth credentials file (token source for the Sessions API) |\n| `CLAUDE_REMOTE_CLOUD_BASE` | `https://api.anthropic.com` | Sessions API base URL |\n| `CLAUDE_REMOTE_MATCH_TITLE` | `off` | Also match by title+cwd for sessions with no bridge id (titles are mutable — opt-in) |\n| `CLAUDE_REMOTE_DB` | `$XDG_DATA_HOME/code-remote/code-remote.db` | SQLite session mirror + ledger path |\n\n## API\n\nAll routes except `/healthz` require `Authorization: Bearer \u003ctoken\u003e`.\n\n| Method | Path | Description |\n| --- | --- | --- |\n| `GET` | `/healthz` | Liveness (no auth) |\n| `POST` | `/sessions` | Start a session → `201` |\n| `POST` | `/sessions/{id}/resume` | Resume a stopped session by id → `201` |\n| `GET` | `/sessions` | List running + resumable (stopped) sessions |\n| `GET` | `/sessions/{id}` | One session (`404` if gone) |\n| `DELETE` | `/sessions/{id}` | Stop a session (`screen -X quit`) |\n\n`POST /sessions/{id}/resume` relaunches an existing session whose `screen` was\nstopped but whose on-disk Claude log still exists — it runs `claude --resume\n\u003cid\u003e` in the session's original working directory (read from the log). Returns\n`404` if there's no log to resume, `409` if the session is already running.\n\n`{id}` is the Claude session UUID. Session shape:\n\n```json\n{\n  \"id\": \"6fd0b321-a454-4b40-9aed-131afe120d36\",\n  \"screen\": \"pigri-dev-remote-6fd0b321-a454-4b40-9aed-131afe120d36\",\n  \"title\": \"Synapse - platform - k8s\",\n  \"pid\": \"3993347\",\n  \"status\": \"Detached\",\n  \"created_at\": \"06/15/2026 08:41:26 AM\"\n}\n```\n\nExample:\n\n```sh\ncurl -s -X POST -H \"Authorization: Bearer $TOKEN\" localhost:8080/sessions\ncurl -s        -H \"Authorization: Bearer $TOKEN\" localhost:8080/sessions\n```\n\nAttach to a session from a shell on the host: `screen -r \u003cscreen\u003e`.\n\n### From iPhone / Apple Watch (Shortcuts)\n\nYou can start, list, and stop sessions from the Apple **Shortcuts** app — each is\na one-action `Get Contents of URL` call to the public endpoint with the bearer\ntoken. See [`docs/apple-shortcut.md`](docs/apple-shortcut.md).\n\n## Audit log\n\nThe server writes a structured audit line to **stdout** for every request —\nincluding rejected ones — plus explicit events when a session is created or\nstopped. The bearer token is **never** logged.\n\n```\nmsg=request        method=POST   path=/sessions  status=201 dur_ms=18 remote=127.0.0.1 auth=ok forwarded_for=203.0.113.9\nmsg=request        method=GET    path=/sessions  status=401 dur_ms=0  remote=127.0.0.1 auth=denied\nmsg=session_create remote=127.0.0.1 id=\u003cuuid\u003e screen=\u003cprefix\u003e-\u003cuuid\u003e\nmsg=session_delete remote=127.0.0.1 id=\u003cuuid\u003e existed=true\n```\n\n- `auth` is `ok` / `denied` / `n/a` (the latter for the unauthenticated\n  `/healthz`).\n- `remote` is the TCP peer (behind ngrok + Synapse that's `127.0.0.1`);\n  `forwarded_for` carries the `X-Forwarded-For` client IP when present — only as\n  trustworthy as the upstream that set it.\n- Set `CLAUDE_REMOTE_LOG_FORMAT=json` for one JSON object per line (log\n  shippers). Under systemd the trail is captured by journald:\n  `journalctl --user -u code-remote-api`.\n\n## Session sync (auto-archive)\n\n\"Archived\" is a **server-side** state — it isn't written into `~/.claude` on the\nhost running the screens — so the server is the only source of truth. Every\n`CLAUDE_REMOTE_SYNC_INTERVAL` (default **30s**) the server polls the Anthropic\nSessions API (`GET /v1/sessions`), and for any session reporting\n`session_status: archived` it quits the matching local `screen`. A **deleted**\nsession is handled too: it drops out of the list entirely, so for any owned\nsession missing from the page the reconciler confirms via `GET /v1/sessions/{id}`\n— a **404** means deleted (quit it; `reason=deleted_on_server`), a **200** uses\nthe real status (this also catches archived sessions beyond the list's 100-item\npage). It only ever touches screens this service owns (prefix-scoped); unrelated\nscreens are never affected. Each action is audit-logged:\n\n```\nmsg=session_sync_enabled interval=30s credentials=/home/you/.claude/.credentials.json\nmsg=auto_archive id=\u003cuuid\u003e screen=\u003cprefix\u003e-\u003cuuid\u003e reason=archived_on_server\n```\n\n- A **grace period** (`CLAUDE_REMOTE_ARCHIVE_GRACE`, default **15m**) protects\n  against accidental archives: a screen is only quit after its session has been\n  *continuously observed* archived for that long. Unarchiving within the window\n  resets the clock (the API exposes no archive timestamp, so this is clocked\n  locally). A pending quit logs `archive_pending` with the time remaining.\n- Auth uses the local OAuth token from `CLAUDE_REMOTE_CREDENTIALS` (the same one\n  `claude` uses). The token is **never logged**, and the file is re-read each\n  cycle so refreshed tokens are picked up automatically.\n- It **degrades gracefully**: on a host with no credentials file (e.g. a\n  headless server where you've never logged in `claude`), sync logs\n  `session_sync_disabled` once and the rest of the API runs normally.\n- Disable with `CLAUDE_REMOTE_SESSION_SYNC=off`.\n\nState is persisted in a **SQLite store** (`CLAUDE_REMOTE_DB`, pure-Go\n`modernc.org/sqlite` so binaries still build with `CGO_ENABLED=0`). Each cycle it\nmirrors every owned session — `uuid, screen, title, cwd, local + cloud status,\nconnection status, bridgeSessionId, created_at` — plus the ledger columns\n(`first_seen_archived` grace clock, `archived_at`), and writes an `events` row\nper auto-archive. This makes the grace clock survive restarts and gives a durable\nview/history even after a screen is gone. If the DB can't be opened the\nreconciler logs `session_store_disabled` and falls back to an in-memory grace\nclock (no mirror).\n\n\u003e The server never exposes our `--session-id` UUID, so local screens are joined\n\u003e to server sessions by the registry's **`bridgeSessionId` == server `id`** — a\n\u003e stable, rename-proof key (a session's `bridgeSessionId` doesn't change when you\n\u003e rename it). Sessions that never bridged have a `null` bridge id; those are\n\u003e matched only if you opt in with `CLAUDE_REMOTE_MATCH_TITLE=on`, which falls\n\u003e back to **title + cwd** (mutable, so off by default) and only fires when\n\u003e *every* server session sharing that title+cwd is archived. The API shape\n\u003e (`session_status`, `session_context`, `bridgeSessionId`) isn't a documented\n\u003e contract and may change across versions.\n\n## crctl (local CLI)\n\n```sh\ncrctl ls            # list running sessions (default)\ncrctl new           # start a new session\ncrctl resume \u003cid\u003e   # relaunch a stopped session by id\ncrctl rm \u003cid\u003e       # stop a session\n```\n\nBy default `crctl` runs **locally** — it drives `screen`/`claude` directly, with\nno API process, token, or URL. Set `CLAUDE_REMOTE_API_URL` to talk to a remote\nAPI instead (then `CLAUDE_REMOTE_API_TOKEN` is required).\n\n| Variable | Mode | Purpose |\n| --- | --- | --- |\n| _(none)_ | local (default) | drive `screen`/`claude` directly on this host |\n| `CLAUDE_REMOTE_API_URL` | remote | API base URL (e.g. `http://127.0.0.1:9000`) |\n| `CLAUDE_REMOTE_API_TOKEN` | remote | bearer token (required when the URL is set) |\n| `CLAUDE_BIN` · `SCREEN_BIN` · `CLAUDE_HOME` · `CLAUDE_REMOTE_SESSION_PREFIX` | local | optional overrides |\n| `CLAUDE_REMOTE_DB` | both | path to the SQLite mirror (default `$XDG_DATA_HOME/code-remote/code-remote.db`) |\n\n`crctl ls` lists **running** sessions plus **resumable** ones — sessions\ncode-remote previously started whose `screen` is gone (killed, host reboot,\nauto-archived) but whose on-disk Claude log survives. Resumable rows show\n`Stopped` and a `crctl resume \u003cid\u003e` hint instead of a `screen -r` attach command.\n\nResumable tracking is backed by the shared SQLite mirror (`CLAUDE_REMOTE_DB`):\nsessions are recorded there on `new`/`resume`, so both the API server and a local\n`crctl` see the same set. Without a writable store, `ls` falls back to\nrunning-only.\n\nThe `LAST ACTIVE` column is the age of the session's on-disk Claude log (which\nclaude appends to as the conversation runs), so it reflects real activity — not\njust when the screen was started.\n\n```\n$ crctl ls\nID                                    TITLE                     STATUS    LAST ACTIVE  ACTION\n6fd0b321-a454-4b40-9aed-131afe120d36  Synapse - platform - k8s  Detached  4m ago       screen -r pigri-dev-remote-6fd0b321-...\na9c1cf1e-ce20-4833-9eeb-7acf5c327506  old refactor              Stopped   2d ago       crctl resume a9c1cf1e-ce20-4833-9eeb-...\n```\n\n## Deploy (systemd)\n\nA hardened system unit and env template are in [`deploy/`](deploy/):\n\n```sh\ngo build -o claude-remote-api .\nsudo install -m0755 claude-remote-api /usr/local/bin/\n\nsudo install -m0600 -o \"$USER\" -g \"$USER\" deploy/claude-remote-api.env.example /etc/claude-remote-api.env\nsudo sed -i \"s/replace-with-a-long-random-token/$(openssl rand -hex 24)/\" /etc/claude-remote-api.env\n\nsudo install -m0644 deploy/claude-remote-api.service /etc/systemd/system/\nsudo systemctl daemon-reload \u0026\u0026 sudo systemctl enable --now claude-remote-api\n```\n\nThe unit runs as your user (it needs your `claude` install, `~/.claude`\ncredentials, and the per-user `screen` sockets), with `ProtectSystem=strict`,\nno capabilities, a syscall filter, and `ReadWritePaths` limited to `~` and\n`/run/screen`. `MemoryDenyWriteExecute` is intentionally **off** — the claude\nbinary's JIT needs writable+executable memory.\n\n## Remote access: ngrok + Synapse WAF\n\nExpose the API publicly at a reserved domain with a WAF in front. ngrok\nterminates TLS and forwards to a single local port (8080), where\n[Synapse](https://gen0sec.com) runs as an L7 reverse proxy + WAF and proxies to\nthe API. The API itself binds `127.0.0.1` only — it is never directly reachable.\n\n```\nInternet\n  → ngrok  https://your-domain.ngrok.dev   (TLS terminated here)\n  → :8080  Synapse (WAF: SQLi/XSS/traversal/oversize/method allow-list + rate limit)\n  → 127.0.0.1:9000  claude-remote-api  (bearer-token auth)\n```\n\n| Port | Bound | Role |\n| --- | --- | --- |\n| `your-domain.ngrok.dev:443` | ngrok edge | public HTTPS |\n| `8080` | localhost | Synapse WAF (the only port ngrok forwards to) |\n| `9000` | `127.0.0.1` | the API (Synapse is its only client) |\n\nSynapse configs live in [`deploy/synapse/`](deploy/synapse/) (`config.yaml`,\n`upstreams.yaml`, `security_rules.yaml`). The ngrok tunnel is the `ngrok-forward`\nhelper (ngrok Go SDK) — it forwards the reserved domain to Synapse, so the WAF\nstays in the path.\n\nConfig goes in a gitignored `.env` (secrets + your private domain); only\n`deploy/.env.example` is committed:\n\n```sh\ncp deploy/.env.example .env \u0026\u0026 \"$EDITOR\" .env   # tokens + NGROK_DOMAIN\nset -a; . ./.env; set +a\n```\n\nRun (three processes):\n\n```sh\n# Synapse routes by Host header, so render your real domain into the\n# upstreams host (the repo keeps a placeholder):\nsed \"s/your-domain.ngrok.dev/$NGROK_DOMAIN/\" deploy/synapse/upstreams.yaml \\\n  | sudo tee /etc/synapse/upstreams.yaml \u003e/dev/null\n\n# 1) API on an internal port (Synapse is the only thing that reaches it)\n./claude-remote-api \u0026\n\n# 2) Synapse WAF on :8080 -\u003e API\nsynapse --mode proxy -c deploy/synapse/config.yaml \\\n        --security-rules-config deploy/synapse/security_rules.yaml \u0026\n\n# 3) ngrok tunnel (Go SDK). Default upstream http://localhost:8080, so\n#    traffic goes THROUGH the WAF.\n./ngrok-forward \u0026\n```\n\nThe WAF blocks SQLi/XSS markers, path traversal/dotfile probes, oversized POSTs,\nand non-API HTTP methods, and rate-limits `/sessions`. The API's bearer token\nremains the primary access control (defense in depth). Behind an ngrok HTTP\ntunnel all requests arrive from the local agent, so `ip.src`/threat-intel WAF\nrules see `127.0.0.1` — the content/method/path rules carry the protection.\n\n\u003e The Synapse configs were authored against its documented schema but not run\n\u003e here; validate them against your installed Synapse version.\n\n### Persistent deploy (systemd --user)\n\nFor a durable, boot-surviving deploy, three `--user` units in\n[`deploy/systemd/`](deploy/systemd/) run the stack (API → Synapse → ngrok),\nall reading `.env` via `EnvironmentFile`:\n\n```sh\ngo build -o ~/.local/share/code-remote/claude-remote-api .\ngo build -o ~/.local/share/code-remote/ngrok-forward ./cmd/ngrok-forward\nchmod +x deploy/render-config.sh\n\nsudo loginctl enable-linger \"$USER\"          # run without an active login\ncp deploy/systemd/code-remote-*.service ~/.config/systemd/user/\nsystemctl --user daemon-reload\nsystemctl --user enable --now code-remote-{api,synapse,ngrok}.service\n```\n\nThe synapse unit's `ExecStartPre` runs `render-config.sh`, which substitutes\n`NGROK_DOMAIN` into the upstreams host at start. Note `CLAUDE_BIN` must be an\nabsolute path in `.env` — systemd's PATH doesn't include `~/.local/bin`.\n\n## Security notes\n\n- The token is checked in constant time; the server is fail-closed (won't start\n  without a token).\n- The default bind is `:8080` — put it behind TLS (reverse proxy / tunnel), or\n  bind `127.0.0.1`, since the bearer token rides the wire.\n- The manager only ever lists/kills `screen` sessions matching the prefix, and\n  session ids are UUID-validated, so it can't touch unrelated screens.\n\n## Caveat\n\nThe `title` is read from Claude's internal `~/.claude/.../\u003cid\u003e.jsonl`\n(`type:custom-title`) format, which is **not a stable public API** and may\nchange across Claude versions. It's best-effort; everything else works without\nit.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpigri%2Fcode-remote","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpigri%2Fcode-remote","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpigri%2Fcode-remote/lists"}