Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
agent-helm
Fjärrstyr CLI-agent-sessioner (Gemini CLI först) från mobil eller webb — med en alltid-fungerande terminalkanal i botten och snygga, strukturerade frågekort som bonus ovanpå.
agent-helm låter dig köra en agent (Gemini CLI) på valfri maskin, strömma dess
session till en server på din Pi5, och styra/svara på den från en webbfrontend
(installbar PWA) i mobilen. Du ser alla anslutna instanser på ett ställe och kan
hoppa in i vilken som helst, var du än är.
Designprinciper
-
Graceful degradation — terminalen funkar alltid. Den råa terminalkanalen (tangenttryck → pty) är baslinjen och fungerar oavsett konfiguration. Hooks som ger snygga frågekort är progressive enhancement — om en
BeforeTool/Notification-hook saknas, är felkonfigurerad eller kraschar, kan du fortfarande styra sessionen fullt ut genom att skicka piltangenter + Enter (och all annan text) rakt in i terminalen. Se Fallback-navigering. -
Två tydligt åtskilda input-vägar. Fritext/tangenttryck går till pty:ns stdin. Strukturerade hook-svar (allow/deny) går tillbaka via hookens returvärde — aldrig genom att "skriva y i terminalen". Se Input-kanaler.
-
pty äger agenten, inte tmux. Appen kör Gemini i en
node-pty. Persistens är ett valfritt yttre lager (tmux eller Docker) — inte en del av integrationen. -
Servern äger sessionens minne. En scrollback-buffert per session gör att en mobil som återansluter ser var den är, inte en tom skärm.
Komponenter
| Komponent | Roll | Teknik |
|---|---|---|
daemon (packages/daemon) |
"Appen". Spawnar Gemini i en node-pty, strömmar pty-bytes utåt över WebSocket, tar emot input och skriver till pty:n. Levererar även hook-frågor och släpper dem när svar kommer. |
Node.js, node-pty, ws |
server (packages/server) |
Control-plane på Pi5. Session-registry (daemon ↔ session-id), WebSocket-mux mot både daemons och frontends, scrollback per session (headless xterm + SerializeAddon), routing av input till rätt session. |
Node.js, ws, @xterm/headless |
web (packages/web) |
PWA-frontend. xterm.js för konversationen, kort-vy för hook-frågor, input-toolbar för fallback-navigering, lista över alla anslutna instanser. |
Vue 3, Vite, @xterm/xterm, PWA |
hooks (hooks/) |
Gemini CLI-hooks (BeforeTool, Notification) som postar frågan till daemonen och long-pollar tills ett svar kommer. |
Shell/Node-script, ~/.gemini/settings.json |
Repo-struktur
agent-helm/
├── README.md
├── .gitignore
├── package.json # workspace-rot (npm/pnpm workspaces)
├── packages/
│ ├── daemon/ # node-pty-värd för Gemini + WS-klient mot servern
│ │ ├── src/
│ │ └── package.json
│ ├── server/ # Pi5 control-plane: WS-mux, registry, scrollback
│ │ ├── src/
│ │ └── package.json
│ └── web/ # Vue 3 PWA: xterm.js + kort-vy + fallback-input
│ ├── src/
│ └── package.json
├── hooks/
│ ├── before-tool.sh # postar verktygsförfrågan till daemonen, blockar
│ └── notification.sh # postar agent-notiser (t.ex. "väntar på input")
└── docker/
├── daemon.Dockerfile
├── server.Dockerfile # byggs och pushas till 192.168.0.19:5000
└── compose.pi5.yml # server + ntfy, restart: unless-stopped
Dataflöde
[daemon / "appen"] (körs valfritt i tmux eller Docker för persistens)
node-pty ── spawnar ── gemini (interaktivt TUI)
│ └─ BeforeTool/Notification-hook (körs HÄR, daemon-side)
│ pty-bytes (stdout) │ POST {tool_name, tool_input}, blockar tills svar
▼ ▼
───── WebSocket (utåtgående) ──────────────────► [Pi5-server / control-plane]
◄──── input (text / tangenter / hook-svar) ────── • session-registry: daemon ↔ session-id
• headless xterm + SerializeAddon → scrollback
• router:
text/tangenter → pty.write()
hook-svar → släpper blockad hook
│
WebSocket ▼
[Vue 3 PWA]
• lista över alla anslutna instanser
• xterm.js → konversationen (rå, alltid)
• kort-vy → hook-frågor (allow/deny)
• toolbar → ↑↓←→ Enter Esc Tab Ctrl-C
Steg för steg (en verktygsförfrågan):
- Gemini vill köra ett verktyg och fyrar
BeforeTool-hooken på daemon-sidan. - Hooken POST:ar
{tool_name, tool_input, session_id}till daemonen och blockar (long-poll) tills ett beslut kommer. - Daemonen vidarebefordrar frågan till servern → servern pushar till alla frontends som tittar på sessionen (och triggar en ntfy-push till mobilen).
- Du trycker Tillåt / Neka i kort-vyn → frontend → server → daemon →
hooken släpps och returnerar
{"decision":"allow"}→ Gemini fortsätter. - Om hooken inte är uppsatt/fungerar: inget kort dyker upp. Istället visar Gemini sin egen y/n-prompt i terminalen, och du svarar via fallback-navigeringen (se nedan).
Input-kanaler
Två vägar, alltid åtskilda:
| Input | Väg | Hamnar i |
|---|---|---|
| Fritext, piltangenter, Enter, Esc, Tab, Ctrl-C | frontend → server → daemon → pty.write() |
terminalens stdin |
| Svar på hook-fråga (allow / deny / val) | frontend → server → släpper den blockade hooken | hookens returvärde |
⚠️ Strukturerade godkännanden skickas aldrig genom att skriva i terminalen — det skapar race mot TUI:ns egen prompt. De går tillbaka via hookens returvärde.
Fallback-navigering
Detta är ett förstklassigt krav, inte en reservplan: frontenden ska alltid kunna styra sessionen även om ingen hook är inblandad.
- Frontenden har en input-toolbar med
↑ ↓ ← →,Enter,Esc,Tab,Ctrl-C(plus snabbknappary/n). Dessa skickar motsvarande escape-sekvenser in i pty:n viapty.write()(t.ex. piltangent upp =\x1b[A, Enter =\r). xterm.jsvidarebefordrar dessutom riktiga tangenttryck från ett fysiskt tangentbord rakt till samma kanal.- Resultat: när Gemini visar sin inbyggda TUI-prompt (verktygsgodkännande, val i en meny, bekräftelse) kan du navigera och svara helt utan hooks. Hooks gör bara frågorna snyggare — de är aldrig en förutsättning för att kunna styra sessionen.
Praktiskt innebär det att man kan börja använda systemet innan några hooks är konfigurerade, och att en trasig hook aldrig låser en session.
Persistens
- Process-persistens (valfritt): kör daemonen i
tmuxför ad-hoc ("överlever att jag stänger SSH/laptop"), eller som Docker-container medrestart: unless-stopped/ systemd-unit för alltid-på på Pi5. tmux är inte en del av integrationen — bara ett yttre skal. - Vy-persistens (alltid på): servern håller en scrollback-buffert per session via en
headless
xterm+SerializeAddon. En frontend som ansluter (eller en mobil som väcks ur viloläge) får en ögonblicksbild + live-svans, inte en tom skärm.
Åtkomst, auth & notiser (Pi5)
- Auth: ingen delad token. Webben loggar in med lokala konton eller
OIDC (Authentik/Keycloak/…) — väljs per server. Klienten kopplas via
device-flödet: den skriver ut en URL, du loggar in och godkänner i webben, och
får då ett klient-token som identifierar din användare. Sessioner är per användare;
klienter kan återkallas individuellt. Första admin seedas via
AGENT_HELM_ADMIN_USER/PASSWORD(engångs) eller en first-run-setup i UI:t. - Åtkomst: primärt via Tailscale (inget exponerat publikt). Alternativt via din
NPM-reverse-proxy på t.ex.
agent.brasse-pc.eu(TLS terminerar i NPM → Secure-cookie). - Notiser: self-hostad ntfy på Pi5 pushar till mobilen när en session väntar på input. Servern postar till ett ntfy-topic; PWA:n / ntfy-appen tar emot.
- Registry: server- och daemon-images byggs och pushas till
192.168.0.19:5000.
Teknikstack
- Node.js överallt på backend (
node-pty,ws,@xterm/headless). - Vue 3 + Vite för frontend,
@xterm/xtermför terminalrendering, PWA för mobil-installation och push. - Docker (compose) för Pi5-deploy, ntfy för push, Tailscale för åtkomst.
- Mål-agent: Gemini CLI (headless/hooks), men daemon-lagret är agent-agnostiskt och kan wrappa vilken pty-baserad CLI-agent som helst.
Kom igång (utveckling)
OBS: Repot låg tidigare på en NTFS-disk där
pnpmhängde påntfs3-drivern vid den tunga småfils-I/O:n. Sedan 2026-07-14 ligger alla repon på/home/brasse/repos/(btrfs) ochpnpm installfungerar utan specialflaggor. Flaggorna nedan behövs bara om projektet av någon anledning körs från NTFS igen:
# Installera på NTFS-workstationen (store + virtual-store på btrfs):
pnpm install \
--store-dir /home/brasse/.cache/agent-helm/store \
--virtual-store-dir /home/brasse/.cache/agent-helm/vstore
# (På vanlig Linux-fs räcker: pnpm install)
# Konfig: kopiera och sätt bootstrap-admin (lokalt läge)
cp .env.example .env # sätt AGENT_HELM_ADMIN_USER/PASSWORD (engångs)
# Kör de tre delarna i var sin terminal:
pnpm dev:server # control-plane på :8787
pnpm dev:web # Vite-dev-server (proxar /api + /ws → :8787)
# I en tredje terminal: logga in klienten + kör agenten
pnpm --filter @agent-helm/daemon start -- connect --server http://localhost:5173
- Öppna Vite-länken, logga in som admin (eller kör first-run-setupen).
- Daemonen skriver ut en URL — öppna den, godkänn klienten.
- Daemonen ansluter och dyker upp i din session-lista. Styr den i webben.
Sätt AGENT_CMD=bash för daemonen om du vill testa pipelinen utan Gemini-auth.
Status
✅ Tunn vertikal slice klar och verifierad. Token-auth, daemon-registrering,
session-lista, scrollback-snapshot och input → pty → output fungerar end-to-end
(rök-testat med server ↔ daemon ↔ klient). Alla paket typecheckar; frontenden bundlar.
Roadmap
- Monorepo-uppsättning (pnpm workspaces) + paket
daemon: node-pty spawnar agenten, strömmar pty över WS (auto-reconnect)server: session-registry + WS-mux + token-auth + scrollback (SerializeAddon)web: xterm.js-vy + input-toolbar (fallback-navigering) + instans-listahooks:before-toollong-poll → kort-vy i frontend (strukturerade godkännanden)- ntfy-push + Tailscale-/NPM-deploy på Pi5
- Docker-images →
192.168.0.19:5000