Skip to content

Commit 4680084

Browse files
feat(portal): browser terminal into 24/7 tmux Claude Code session (#15)
1 parent 6b45c80 commit 4680084

28 files changed

Lines changed: 4023 additions & 0 deletions

docs/guides/BRAIN.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# StackMemory Brain — shared, compounding context
2+
3+
> Move your brain onto a server. Codex, Claude, OpenCode, and Hermes all connect
4+
> to it. Every experiment uploads a summary and conclusion, so your agents'
5+
> mutual thinking keeps compounding.
6+
7+
The **brain** is a shared knowledge layer scoped two ways:
8+
9+
- **per repo** (`projectId`) — what this codebase has tried and learned.
10+
- **per org** (`workspaceId`, from `stackmemory login`) — knowledge shared across
11+
every repo in your workspace.
12+
13+
Each entry is an **experiment / decision / insight / note** with a `title`, a
14+
`summary` (what was done) and the payload that compounds — the `conclusion`.
15+
Entries sync online so the same brain is available on every machine and to
16+
every agent.
17+
18+
```
19+
Codex ─┐
20+
Claude ─┼─► stackmemory brain record ──► brain_entries (local SQLite)
21+
OpenCode ─┤ │ brain sync
22+
Hermes ─┘ ▼
23+
Provenant API (per repo + per org)
24+
25+
any machine/agent ◄── stackmemory brain recall ◄── brain sync (pull)
26+
```
27+
28+
## How agents connect
29+
30+
Every tool connects the same way — by shelling out to the CLI (this is how the
31+
Codex / OpenCode / Hermes wrappers already integrate with StackMemory):
32+
33+
```bash
34+
# After an experiment, record the conclusion so others build on it:
35+
stackmemory brain record \
36+
--agent codex --kind experiment \
37+
--title "Retry with jitter cut 5xx" \
38+
--summary "Added exponential backoff + jitter to the sync client" \
39+
--conclusion "p99 errors dropped 60%; adopt as the default" \
40+
--tags sync,reliability --refs STA-412,abc1234
41+
42+
# Before planning, recall what's already been tried:
43+
stackmemory brain recall "retry" # this repo
44+
stackmemory brain recall "auth" --org # the whole org
45+
```
46+
47+
Drop the recall into an agent's planning preamble (a hook, a wrapper, or a
48+
prompt step) and every plan starts enriched by prior conclusions.
49+
50+
## CLI
51+
52+
```bash
53+
stackmemory brain record --title ... [--summary] [--conclusion] [--kind] \
54+
[--agent] [--tags a,b] [--refs x,y] [--confidence 0.8]
55+
stackmemory brain recall [query] [--org] [--agent] [--kind] [--limit] [--all]
56+
stackmemory brain list [--limit]
57+
stackmemory brain show <id>
58+
stackmemory brain sync [--push | --pull] # online push + pull
59+
stackmemory brain status
60+
```
61+
62+
`--json` is available on every subcommand for programmatic use.
63+
64+
### Kinds
65+
66+
| kind | use it for |
67+
|------|-----------|
68+
| `experiment` | something you tried + what happened (the compounding unit) |
69+
| `decision` | a choice made and the reasoning |
70+
| `insight` | a durable learning worth resurfacing |
71+
| `note` | free-form context |
72+
73+
## Scoping: repo vs org
74+
75+
- `recall` defaults to the **current repo**.
76+
- `recall --org` widens to the **whole workspace** — cross-pollinate learnings
77+
between repos (e.g. "we standardized on Zod for request validation").
78+
- An entry always carries both `projectId` and `workspaceId`, so the same row
79+
is reachable from either scope.
80+
81+
`projectId` and `workspaceId` come from `~/.stackmemory/config.json` (written by
82+
`stackmemory login`) or from `PROVENANT_PROJECT_ID` / `PROVENANT_WORKSPACE_ID` /
83+
`PROVENANT_API_KEY` env vars.
84+
85+
## Online sync
86+
87+
```bash
88+
stackmemory login you@example.com # provisions apiKey + workspaceId + projectId
89+
stackmemory brain sync # push local entries, pull the rest
90+
```
91+
92+
- **Transport:** `POST {endpoint}/v1/brain/push` and `/v1/brain/pull`, authed
93+
with the same Bearer API key as cloud sync. The endpoint defaults to the
94+
hosted Provenant API and is overridable with `PROVENANT_API_URL`.
95+
- **Conflict resolution:** newest-wins by `updatedAt`. Pulling never clobbers a
96+
locally-newer entry.
97+
- **Offline-safe:** if the server is unreachable, the brain stays fully usable
98+
locally and `sync` reports the error without throwing.
99+
- **Isolation:** brain sync is deliberately separate from the frame
100+
`CloudSyncEngine`, so it can never regress that path.
101+
102+
> The hosted `/v1/brain/*` endpoints live in the Provenant API
103+
> (`packages/provenant`). The client here speaks the documented contract above;
104+
> until the endpoints are deployed, the brain runs local-first and `brain sync`
105+
> reports the endpoint as unreachable.
106+
107+
## Storage
108+
109+
| | |
110+
|--|--|
111+
| Table | `brain_entries` (created lazily in the project's `.stackmemory/context.db`) |
112+
| Sync cursors | `brain_sync_meta(direction, cursor)` |
113+
| Columns | `entry_id, workspace_id, project_id, agent, kind, title, summary, conclusion, tags, refs, confidence, status, superseded_by, created_at, updated_at` |
114+
115+
## Files
116+
117+
| Path | Purpose |
118+
|------|---------|
119+
| `src/core/brain/brain-store.ts` | Local SQLite store (record / recall / supersede) |
120+
| `src/core/brain/brain-sync.ts` | Online push/pull client (newest-wins, offline-safe) |
121+
| `src/core/brain/index.ts` | Scope + config resolution, `openBrain()` |
122+
| `src/cli/commands/brain.ts` | `stackmemory brain` command |

docs/guides/PORTAL.md

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# StackMemory Portal — Run Claude Code 24/7
2+
3+
> A VPS, Claude Code in tmux, a Tailscale VPN, and a vibecoded web terminal.
4+
> Your agents run 24/7. You experience life.
5+
6+
The **portal** is a self-hosted, browser-based terminal into a persistent
7+
`tmux` session running Claude Code. Put it on a small VPS behind Tailscale and
8+
you get a private, always-on coding agent you can check on from your phone,
9+
laptop, or tablet — no exposed ports, no SaaS in the middle.
10+
11+
```
12+
┌── Hetzner CX22 (~€4.5/mo) ───────────────────────────┐
13+
│ │
14+
│ tmux session "claude" ──► claude (max plan) │
15+
│ ▲ │
16+
│ │ node-pty │
17+
│ stackmemory portal ──► :7799 (xterm.js + WS) │
18+
│ ▲ │
19+
└────────┼──────────────────────────────────────────────┘
20+
│ Tailscale (WireGuard, 100.x address)
21+
22+
Your browser → http://100.x.y.z:7799/?token=…
23+
```
24+
25+
**Why this shape?**
26+
27+
- **tmux** keeps the agent alive when you close the browser or the portal
28+
restarts. Reattach over SSH any time.
29+
- **Tailscale** gives you an encrypted private address with zero open ports —
30+
no nginx, no TLS certs, no firewall holes.
31+
- **node-pty + xterm.js** stream the real terminal, so Claude Code's TUI,
32+
permissions prompts, and colors all work exactly as they do locally.
33+
34+
---
35+
36+
## Quick start (Hetzner cloud-init)
37+
38+
The fastest path — the server provisions itself on first boot.
39+
40+
1. Create a Tailscale **auth key** at
41+
<https://login.tailscale.com/admin/settings/keys> (reusable, ephemeral off).
42+
2. In Hetzner Cloud, **Add Server** → Ubuntu 24.04 → type **CX22**.
43+
3. Expand **Cloud config** and paste
44+
[`scripts/portal/cloud-init.yaml`](../../scripts/portal/cloud-init.yaml).
45+
Set `TS_AUTHKEY=` to your key inside the pasted config.
46+
4. Create the server. After ~2 minutes it's on your tailnet.
47+
48+
Then finish the two interactive steps over SSH:
49+
50+
```bash
51+
ssh root@<hetzner-ip>
52+
53+
# Authenticate Claude Code (max plan) once — it caches credentials in ~/.claude
54+
tmux attach -t claude # log in, approve, then detach with: Ctrl-b d
55+
56+
# Grab your access URL + token
57+
journalctl -u stackmemory-portal --no-pager | grep -i token
58+
```
59+
60+
Open `http://100.x.y.z:7799/?token=…` (the `100.x` Tailscale address) from any
61+
device signed into your tailnet. You're now looking at your agent.
62+
63+
---
64+
65+
## Manual setup
66+
67+
Prefer to do it by hand, or installing on an existing box?
68+
69+
```bash
70+
# On the VPS (Debian/Ubuntu):
71+
curl -fsSL https://raw.githubusercontent.com/stackmemoryai/stackmemory/main/scripts/portal/setup.sh | bash
72+
73+
sudo tailscale up # join your tailnet (prints an auth URL)
74+
tmux new -s claude 'claude' # authenticate Claude, then Ctrl-b d to detach
75+
stackmemory portal start --cwd ~/work # start the portal (prints the URL + token)
76+
```
77+
78+
For 24/7 operation, install the service:
79+
80+
```bash
81+
sudo cp scripts/portal/stackmemory-portal.service /etc/systemd/system/
82+
sudo systemctl daemon-reload
83+
sudo systemctl enable --now stackmemory-portal
84+
journalctl -u stackmemory-portal -f # tail logs (the access URL is printed here)
85+
```
86+
87+
---
88+
89+
## The CLI
90+
91+
```bash
92+
stackmemory portal start # start the server (foreground; systemd runs this)
93+
stackmemory portal status # show status + the access URL for this machine
94+
stackmemory portal stop # stop a running portal
95+
stackmemory portal token # print the access token
96+
```
97+
98+
`start` options:
99+
100+
| Flag | Default | Description |
101+
|------|---------|-------------|
102+
| `--port <n>` | `7799` | Port to listen on |
103+
| `--host <h>` | `0.0.0.0` | Interface to bind (reachable over the tailnet) |
104+
| `--session <name>` | `claude` | tmux session name |
105+
| `--command <cmd>` | `claude` | Command tmux runs (`"claude --resume"`, a wrapper, etc.) |
106+
| `--cwd <dir>` | cwd | Working directory for the session |
107+
| `--no-auth` | off | Disable the token (rely on Tailscale alone) |
108+
109+
The portal runs `tmux new-session -A -s <session> <command>`: it **attaches** to
110+
the session if it already exists, otherwise creates it. Multiple browser tabs
111+
share the same live session. Closing a tab detaches but never kills the agent.
112+
113+
---
114+
115+
## Security model
116+
117+
- **Network:** binding to `0.0.0.0` is safe *because* the box only has a public
118+
IP plus its Tailscale address — keep the cloud firewall closed to `:7799` and
119+
reach it exclusively over the tailnet. (Hetzner's firewall: allow `22` from
120+
your IP, deny the rest.)
121+
- **Token:** a 48-char token is generated on first start and stored at
122+
`~/.stackmemory/portal/token` (`chmod 600`). It's required on both the page
123+
load (`?token=`) and the WebSocket handshake. Rotate it by deleting the file
124+
and restarting. `--no-auth` turns this off if you trust your tailnet ACLs.
125+
- **No inbound ports on the internet.** Tailscale is WireGuard point-to-point;
126+
there is nothing to port-scan.
127+
128+
> Treat the token like an SSH key — anyone with the URL gets a live shell as the
129+
> user running the portal.
130+
131+
---
132+
133+
## Troubleshooting
134+
135+
| Symptom | Fix |
136+
|---------|-----|
137+
| `tmux is not installed` | `sudo apt install tmux` |
138+
| Page loads but terminal is blank / "Cannot start session" | `node-pty` missing on the server: `npm install -g node-pty` (needs `build-essential` + `python3`) |
139+
| `401 Unauthorized` | Append `?token=<token>` to the URL (`stackmemory portal token`) |
140+
| Can't reach `100.x` address | `tailscale status` on both ends; make sure your client is logged into the same tailnet |
141+
| Claude asks to log in every time | Authenticate once inside the tmux session so credentials land in `~/.claude`; ensure systemd `HOME=` points at that user's home |
142+
| Agent died but portal is up | `tmux attach -t claude` to inspect; the portal recreates the session on next connect |
143+
144+
---
145+
146+
## Files
147+
148+
| Path | Purpose |
149+
|------|---------|
150+
| `src/features/portal/server.ts` | Express + Socket.io + node-pty bridge |
151+
| `src/features/portal/ui.ts` | Embedded xterm.js terminal UI |
152+
| `src/cli/commands/portal.ts` | `stackmemory portal` command |
153+
| `scripts/portal/setup.sh` | One-shot VPS installer |
154+
| `scripts/portal/cloud-init.yaml` | Hetzner first-boot provisioning |
155+
| `scripts/portal/stackmemory-portal.service` | systemd unit for 24/7 operation |

0 commit comments

Comments
 (0)