From f9c79ac84e3813dadfd7c4e6b9ff6050f3d009e8 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 20:54:54 +0200 Subject: [PATCH] =?UTF-8?q?helmd=20v2:=20Go-server=20som=20styr=20agent=20?= =?UTF-8?q?i=20tmux=20=E2=80=94=20REST+SSE,=20fr=C3=A5gedetektor,=20fildel?= =?UTF-8?q?ning,=20ntfy,=20CI-release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tmux som sanningskälla (agentoberoende, överlever omstart via adoption) - API: sessions, prompt, question/answer, keys, mode, config, shares, notify, events - frågedetektor testad mot riktiga agy 1.1.9-dumpar + mock - integrationstest: 26 tester i isolerad debian-container (scripts/run-integration.sh) - e2e-verifierad mot riktig agy (trust-fråga -> svar -> prompt) - .gitea/workflows/helmd-release.yaml -> rullande helmd-latest (x64+arm64) Co-Authored-By: Claude Fable 5 --- .gitea/workflows/helmd-release.yaml | 65 +++++ README.md | 17 ++ doc/plan.md | 71 ++++++ helmd/README.md | 139 +++++++++++ helmd/api.go | 361 ++++++++++++++++++++++++++++ helmd/config.go | 135 +++++++++++ helmd/go.mod | 3 + helmd/main.go | 137 +++++++++++ helmd/ntfy.go | 68 ++++++ helmd/question.go | 211 ++++++++++++++++ helmd/question_test.go | 126 ++++++++++ helmd/scripts/integration-test.sh | 125 ++++++++++ helmd/scripts/run-integration.sh | 14 ++ helmd/session.go | 323 +++++++++++++++++++++++++ helmd/shares.go | 123 ++++++++++ helmd/testdata/mock-agent.sh | 64 +++++ helmd/tmux.go | 93 +++++++ 17 files changed, 2075 insertions(+) create mode 100644 .gitea/workflows/helmd-release.yaml create mode 100644 doc/plan.md create mode 100644 helmd/README.md create mode 100644 helmd/api.go create mode 100644 helmd/config.go create mode 100644 helmd/go.mod create mode 100644 helmd/main.go create mode 100644 helmd/ntfy.go create mode 100644 helmd/question.go create mode 100644 helmd/question_test.go create mode 100755 helmd/scripts/integration-test.sh create mode 100755 helmd/scripts/run-integration.sh create mode 100644 helmd/session.go create mode 100644 helmd/shares.go create mode 100755 helmd/testdata/mock-agent.sh create mode 100644 helmd/tmux.go diff --git a/.gitea/workflows/helmd-release.yaml b/.gitea/workflows/helmd-release.yaml new file mode 100644 index 0000000..13e7d95 --- /dev/null +++ b/.gitea/workflows/helmd-release.yaml @@ -0,0 +1,65 @@ +name: helmd-release + +# På push till master som rör helmd/: testa, bygg linux x64 (+arm64) +# och publicera på den rullande releasen "helmd-latest". +on: + push: + branches: [master, main] + paths: + - "helmd/**" + - ".gitea/workflows/helmd-release.yaml" + workflow_dispatch: {} + +jobs: + build-release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Installera Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + cache: false + + - name: Testa + bygg + run: | + set -e + cd helmd + VERSION="latest-$(git rev-parse --short HEAD)" + go vet ./... + go test ./... + mkdir -p build + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \ + -ldflags "-s -w -X main.version=$VERSION" -o build/helmd-linux-x64 . + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath \ + -ldflags "-s -w -X main.version=$VERSION" -o build/helmd-linux-arm64 . + (cd build && sha256sum helmd-linux-* > checksums.txt && ls -la) + + - name: Skapa/uppdatera release + ladda upp binärer + env: + API: http://gitea-d:3000/api/v1 + REPO: ${{ github.repository }} + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + TAG="helmd-latest" + BODY="helmd - agent-helm-servern (Go, enskild binär). Rullande bygge från senaste master. Commit: ${{ github.sha }}. Arkitekturer: linux x64 + arm64." + rid=$(curl -s -X POST "$API/repos/$REPO/releases" \ + -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"$BODY\"}" | jq -r '.id // empty') + [ -z "$rid" ] && rid=$(curl -s "$API/repos/$REPO/releases/tags/$TAG" \ + -H "Authorization: token $TOKEN" | jq -r '.id') + echo "release id $rid" + for f in helmd-linux-x64 helmd-linux-arm64 checksums.txt; do + aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" \ + -H "Authorization: token $TOKEN" | jq -r ".[] | select(.name==\"$f\") | .id") + if [ -n "$aid" ] && [ "$aid" != "null" ]; then + curl -s -X DELETE "$API/repos/$REPO/releases/$rid/assets/$aid" \ + -H "Authorization: token $TOKEN" -o /dev/null + fi + curl -s -X POST "$API/repos/$REPO/releases/$rid/assets?name=$f" \ + -H "Authorization: token $TOKEN" \ + -F "attachment=@helmd/build/$f" -o /dev/null -w " $f -> HTTP %{http_code}\n" + done diff --git a/README.md b/README.md index c24cc2e..52cedbf 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,23 @@ hoppa in i vilken som helst, var du än är. --- +## ⚡ v2 pågår: `helmd` + +Gemini CLI:s OAuth-nedläggning dödade v1-kedjan (daemon → server → +hooks). **v2** byter fundament: en enskild Go-binär, [`helmd/`](helmd/), +äger en tmux-session med agenten (**agy**) och exponerar REST+SSE — +tmux-skärmen är sanningskällan i stället för agent-specifika hooks. + +- Server: klar — se [`helmd/README.md`](helmd/README.md) (API, säkerhet, test) +- Plan (server → webbklient → Android): [`doc/plan.md`](doc/plan.md) +- Release: rullande [`helmd-latest`](https://gitea.brasse-pc.eu/brasse/agent-helm/releases) + (`helmd-linux-x64` + arm64) + +Allt nedanför denna linje beskriver **v1** (Node-stacken i +`packages/`), som ligger kvar tills webbklienten är portad. + +--- + ## Designprinciper 1. **Graceful degradation — terminalen funkar alltid.** diff --git a/doc/plan.md b/doc/plan.md new file mode 100644 index 0000000..47c2908 --- /dev/null +++ b/doc/plan.md @@ -0,0 +1,71 @@ +# agent-helm v2 — plan + +## Mål + +Fjärrstyra CLI-agenter (agy i första hand) som "claude-desktop-appen +fast för Googles agent": se sessionen, skicka prompts, svara på +frågor/godkännanden, byta läge, ändra inställningar och ta emot delade +filer — från webb och Android, utan att exponera något publikt. + +## Varför v2 + +v1 (Node: daemon→server-på-Pi5→web, Gemini-hooks) dog med Gemini +CLI:s OAuth-nedläggning, och hook-integrationen visade sig skör per +agent. v2 vänder på det: **tmux är sanningskällan**. Servern läser +skärmen och skickar tangenter — då spelar det ingen roll vilken agent +som kör eller hur dess hooks ser ut. + +## Arkitektur + +``` +Android (egen SSH-tunnel) ─┐ +webb-PWA (LAN/tunnel) ─────┼──► helmd :8788 (Go, REST+SSE, token) ──tmux──► agy + │ │ + └────────┴──► ntfy.brasse-pc.eu (topics: agent-helm, claude) +``` + +- **Ingen publik exponering**: bind 127.0.0.1 eller LAN; utifrån via + SSH-tunnel (mobilklienten sätter upp sin egen mot Pi5 → vidare till + värddatorn, alt. helmd på Pi5 direkt). +- v1-koden (packages/) ligger kvar tills webbklienten är portad. + +## Steg + +1. ✅ **helmd** (`helmd/`): Go-server, enskild statisk binär. + - tmux-hantering: skapa/adoptera/döda sessioner (`helm-*`), + paste+Enter för prompts, tangenter, skärmdump med/utan ANSI. + - Frågedetektor (`question.go`): agy-godkännanden, trust-prompts, + numrerade/onumrerade listor; stabil hash per fråga; testad mot + riktiga agy-dumpar + mock. + - REST+SSE-API, bearer-token, fildelning (`helmd share`), ntfy + (event-pushar + `POST /api/notify`, topics `agent-helm`/`claude`). + - Isolerat integrationstest i Docker (26 tester), CI-release + `helmd-latest` (x64 + arm64). +2. ⬜ **Webbklient v2** (`packages/web`): porta till helmd-API:t. + - Sessionslista, skärmvy (poll + SSE), promptfält, frågekort med + knappar, läges-/inställningsvy. + - Delningsflik: html/md renderas, png/jpg visas, md till html. + - Uppdaterad look. Serveras statiskt av helmd (embed) eller nginx. +3. ⬜ **Android-klient**: nytt projekt eller Capacitor-port av PWA:n. + - Egen SSH-tunnel (t.ex. sshj/JSch) → Pi5 → helmd; token + + värdprofil i appen; push via ntfy-appen eller UnifiedPush. + +## Beslut + +- **REST + SSE, inte GraphQL/WS** i v1 — enklast att testa med curl, + räcker för kort-UI. WS-terminal kan läggas till senare. +- **Go, statisk binär** — samma modell som agent-tools; trivial + cross-compile till Pi (arm64). +- **tmux-scraping i stället för hooks** — agentoberoende, överlever + omstarter (adoption), fallback är alltid rå tangentkanal. +- ntfy-topics: `agent-helm` (serverhändelser), `claude` (Claudes + direktnotiser). Allowlist i config. + +## Öppna frågor + +- Verifiera agy:s lägesbytes-tangent (Shift+Tab antas). +- Webbklient: behålla Vue 3 eller byta till ren Vite+TS utan ramverk? +- Android: Capacitor-wrap av PWA:n (snabbast) vs native Kotlin + (bäst tunnel-kontroll). Lutar åt Capacitor + termux-liknande + tunnel-lib. +- helmd på Pi5 i container med tmux? (Kör agenter direkt på Pi:n.) diff --git a/helmd/README.md b/helmd/README.md new file mode 100644 index 0000000..c121520 --- /dev/null +++ b/helmd/README.md @@ -0,0 +1,139 @@ +# helmd — agent-helm v2-servern + +En **enskild statisk Go-binär** som äger kommunikationen med en +tmux-session där en CLI-agent (agy, claude, …) kör, och exponerar ett +**REST-API + SSE** som webb- och mobilklienterna pratar med. + +Designen bygger på insikten från Gemini-OAuth-döden: att integrera mot +en specifik agents hook-mekanism är skört. `helmd` behandlar i stället +**tmux som sanningskälla** — den läser skärmen, känner igen frågor och +skickar tangenter. Fungerar därför med vilken TUI-agent som helst, och +överlever att helmd själv startas om (sessioner adopteras tillbaka). + +``` + webb-PWA / Android ──(LAN eller SSH-tunnel)──► helmd :8788 ──tmux──► agy + │ + └──► ntfy.brasse-pc.eu (pushar till mobilen) +``` + +## Säkerhetsmodell + +- **Ingen publik exponering.** Servern binder `127.0.0.1:8788` som + default. På det privata nätet kan man binda `0.0.0.0:8788` + (`helmd serve --listen 0.0.0.0:8788`). Utifrån: SSH-tunnel — + `ssh -L 8788:127.0.0.1:8788 ` — eller Android-klientens + inbyggda tunnel (steg 3 i planen). +- **Bearer-token** på alla endpoints. Genereras vid första start, + lagras `0600` i `~/.config/helmd/config.json`, visas med + `helmd token`. Skickas som `Authorization: Bearer `, + `X-Api-Token: ` eller `?token=` (för SSE/EventSource och + ``-länkar). +- Delad HTML serveras med `Content-Security-Policy: sandbox` så att + ett delat dokument aldrig kan anropa API:et med klientens token. + +## Kommandon + +``` +helmd serve [--listen host:port] [--config fil] starta servern +helmd share [--note text] [--session namn] dela en fil till klienterna +helmd token skriv ut API-token +helmd version +``` + +`helmd share` är **agentens verktyg för att dela filer** (html, md, +png, jpg, …) till klienterna: agenten kör kommandot i sin shell, filen +laddas upp till servern och dyker upp i klientens delningsflik. +Tillåtna filtyper styrs av `share_exts` i konfigen. + +## API + +Alla svar är JSON om inget annat sägs. Auth krävs överallt. + +| Metod & väg | Gör | +|---|---| +| `GET /api/health` | `{ok, version, sessions}` | +| `GET /api/sessions` | lista sessioner (namn, alive, seq, pending question) | +| `POST /api/sessions` | `{name, cmd?, cwd?}` startar agent i ny tmux-session `helm-`; `{name, adopt:true, tmux?}` adopterar befintlig | +| `GET /api/sessions/{n}` | en sessions tillstånd | +| `DELETE /api/sessions/{n}` | döda sessionen | +| `GET /api/sessions/{n}/screen` | pane-text; `?ansi=1` behåller färger, `?history=N` tar med N rader scrollback | +| `POST /api/sessions/{n}/prompt` | `{text, submit?:bool}` klistrar in text (radbrytningar ok) och trycker Enter | +| `GET /api/sessions/{n}/question` | `{question}` — detekterad väntande fråga eller `null` | +| `POST /api/sessions/{n}/answer` | `{option:N}` väljer alternativ N (1-baserat). Numrerade listor väljs med siffertangent, onumrerade navigeras med pil+Enter | +| `POST /api/sessions/{n}/keys` | `{keys:["Escape","Down","Enter"]}` råa tmux-tangenter | +| `POST /api/sessions/{n}/mode` | `{action:"cycle"}` skickar Shift+Tab (lägesbyte i agy/claude) | +| `GET /api/config` / `PUT /api/config` | läs/ändra agentkommando, args, workdir, poll-intervall, ntfy-inställningar (token ändras aldrig via API) | +| `GET /api/shares` / `POST /api/shares` | lista / ladda upp (multipart, fält `file`, `note`, `session`) | +| `GET /api/shares/{id}` / `…/raw` | metadata / själva filen | +| `DELETE /api/shares/{id}` | ta bort delning | +| `POST /api/notify` | `{topic?, title?, message, priority?}` → ntfy; topic måste finnas i `allowed_topics` (`agent-helm`, `claude`) | +| `GET /api/events` | SSE-ström: `screen` (seq), `question`, `session`, `share` | + +### Frågedetektering + +Pollern läser panen (default var 500:e ms), och `DetectQuestion` +känner igen agy:s frågeformer: godkännande-listor (`1. Yes … 4. No`), +trust-/val-listor med `>`-markör, och gamla Gemini-CLI:s inramade +pickers. Varje fråga får en stabil hash — servern ntfy:ar **en gång +per ny fråga** till topic `agent-helm` (kan stängas av med +`notify_on_question:false`). + +Verifierat mot riktiga agy 1.1.9-sessioner (trust-prompt, +kommandogodkännande) och mot mock i integrationstestet. + +## Konfiguration + +`~/.config/helmd/config.json` (skapas vid första start; override med +`HELMD_CONFIG` eller `--config`): + +```json +{ + "listen": "127.0.0.1:8788", + "token": "", + "agent_cmd": "agy", + "agent_args": [], + "workdir": "/home/brasse", + "share_dir": "~/.local/share/helmd/shares", + "share_exts": [".html", ".htm", ".md", ".txt", ".json", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".pdf"], + "poll_ms": 500, + "cols": 200, "rows": 50, + "ntfy": { + "server": "https://ntfy.brasse-pc.eu", + "topic": "agent-helm", + "allowed_topics": ["agent-helm", "claude"], + "enabled": true, + "notify_on_question": true + } +} +``` + +## Bygga & testa + +```bash +cd helmd +go test ./... # enhetstester (frågedetektorn m.m.) +CGO_ENABLED=0 go build -o build/helmd . +bash scripts/run-integration.sh # 26 API-tester i isolerad Docker-container +``` + +Integrationstestet startar helmd i en `debian:stable-slim`-container +med en mock-agent (`testdata/mock-agent.sh`) som härmar agy:s båda +frågetyper, och kör hela API-ytan med curl. + +## CI / release + +`.gitea/workflows/helmd-release.yaml` kör på push till master som rör +`helmd/`: `go vet` + `go test`, bygger **linux x64 + arm64** statiskt +och publicerar på den rullande releasen **`helmd-latest`** +(`helmd-linux-x64`, `helmd-linux-arm64`, `checksums.txt`). + +## Kända begränsningar (v1) + +- `mode cycle` skickar Shift+Tab (BTab) — verifierat tangentnamn i + tmux men inte bekräftat att agy byter läge på det; justera via + `/keys` om agy använder annan tangent. +- Ingen WebSocket-terminal ännu — klienterna pollar `screen` + + lyssnar på SSE. Räcker för kort-UI:t; rå terminal-vy kommer med + webbklienten (steg 2). +- En helmd-instans per maskin/användare; flera agenter = flera + sessioner i samma helmd. diff --git a/helmd/api.go b/helmd/api.go new file mode 100644 index 0000000..a552473 --- /dev/null +++ b/helmd/api.go @@ -0,0 +1,361 @@ +package main + +import ( + "crypto/subtle" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" +) + +type API struct { + cfg *Config + mgr *Manager + shares *ShareStore + ntfy *Ntfy +} + +func (a *API) auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tok := r.Header.Get("X-Api-Token") + if tok == "" { + if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { + tok = strings.TrimPrefix(h, "Bearer ") + } + } + if tok == "" { + tok = r.URL.Query().Get("token") // SSE/EventSource + links + } + if subtle.ConstantTimeCompare([]byte(tok), []byte(a.cfg.Token)) != 1 { + jsonErr(w, http.StatusUnauthorized, "ogiltig eller saknad token") + return + } + next(w, r) + } +} + +func jsonOut(w http.ResponseWriter, code int, v interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + json.NewEncoder(w).Encode(v) +} + +func jsonErr(w http.ResponseWriter, code int, msg string) { + jsonOut(w, code, map[string]string{"error": msg}) +} + +func readJSON(r *http.Request, v interface{}) error { + defer r.Body.Close() + return json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(v) +} + +func (a *API) routes() *http.ServeMux { + mux := http.NewServeMux() + + mux.HandleFunc("GET /api/health", a.auth(func(w http.ResponseWriter, r *http.Request) { + jsonOut(w, 200, map[string]interface{}{ + "ok": true, "version": version, "sessions": len(a.mgr.List()), + }) + })) + + mux.HandleFunc("GET /api/sessions", a.auth(func(w http.ResponseWriter, r *http.Request) { + jsonOut(w, 200, a.mgr.List()) + })) + + mux.HandleFunc("POST /api/sessions", a.auth(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Cmd string `json:"cmd"` + Cwd string `json:"cwd"` + Adopt bool `json:"adopt"` + Tmux string `json:"tmux"` + } + if err := readJSON(r, &req); err != nil { + jsonErr(w, 400, err.Error()) + return + } + var ( + s *SessionState + err error + ) + if req.Adopt { + s, err = a.mgr.Adopt(req.Name, req.Tmux) + } else { + s, err = a.mgr.Create(req.Name, req.Cmd, req.Cwd) + } + if err != nil { + jsonErr(w, 400, err.Error()) + return + } + jsonOut(w, 201, s) + })) + + mux.HandleFunc("GET /api/sessions/{name}", a.auth(func(w http.ResponseWriter, r *http.Request) { + s, ok := a.mgr.Get(r.PathValue("name")) + if !ok { + jsonErr(w, 404, "ingen sådan session") + return + } + jsonOut(w, 200, s) + })) + + mux.HandleFunc("DELETE /api/sessions/{name}", a.auth(func(w http.ResponseWriter, r *http.Request) { + if err := a.mgr.Kill(r.PathValue("name")); err != nil { + jsonErr(w, 404, err.Error()) + return + } + jsonOut(w, 200, map[string]bool{"killed": true}) + })) + + mux.HandleFunc("GET /api/sessions/{name}/screen", a.auth(func(w http.ResponseWriter, r *http.Request) { + ansi := r.URL.Query().Get("ansi") == "1" + history, _ := strconv.Atoi(r.URL.Query().Get("history")) + screen, err := a.mgr.Screen(r.PathValue("name"), ansi, history) + if err != nil { + jsonErr(w, 404, err.Error()) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + io.WriteString(w, screen) + })) + + mux.HandleFunc("POST /api/sessions/{name}/prompt", a.auth(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Text string `json:"text"` + Submit *bool `json:"submit"` + } + if err := readJSON(r, &req); err != nil || req.Text == "" { + jsonErr(w, 400, "JSON-kropp med \"text\" krävs") + return + } + submit := req.Submit == nil || *req.Submit + if err := a.mgr.Prompt(r.PathValue("name"), req.Text, submit); err != nil { + jsonErr(w, 400, err.Error()) + return + } + jsonOut(w, 200, map[string]bool{"sent": true}) + })) + + mux.HandleFunc("GET /api/sessions/{name}/question", a.auth(func(w http.ResponseWriter, r *http.Request) { + s, ok := a.mgr.Get(r.PathValue("name")) + if !ok { + jsonErr(w, 404, "ingen sådan session") + return + } + jsonOut(w, 200, map[string]interface{}{"question": s.Question}) + })) + + mux.HandleFunc("POST /api/sessions/{name}/answer", a.auth(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Option int `json:"option"` + } + if err := readJSON(r, &req); err != nil { + jsonErr(w, 400, err.Error()) + return + } + if err := a.mgr.Answer(r.PathValue("name"), req.Option); err != nil { + jsonErr(w, 400, err.Error()) + return + } + jsonOut(w, 200, map[string]bool{"answered": true}) + })) + + mux.HandleFunc("POST /api/sessions/{name}/keys", a.auth(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Keys []string `json:"keys"` + } + if err := readJSON(r, &req); err != nil || len(req.Keys) == 0 { + jsonErr(w, 400, "JSON-kropp med \"keys\": [\"Enter\", ...] krävs") + return + } + if err := a.mgr.Keys(r.PathValue("name"), req.Keys); err != nil { + jsonErr(w, 400, err.Error()) + return + } + jsonOut(w, 200, map[string]bool{"sent": true}) + })) + + // Mode: "cycle" sends the agent's mode-switch key (Shift+Tab in + // agy/Claude Code). Arbitrary key sequences via /keys. + mux.HandleFunc("POST /api/sessions/{name}/mode", a.auth(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Action string `json:"action"` + } + if err := readJSON(r, &req); err != nil { + jsonErr(w, 400, err.Error()) + return + } + if req.Action != "cycle" { + jsonErr(w, 400, "stödd action: \"cycle\"") + return + } + if err := a.mgr.Keys(r.PathValue("name"), []string{"BTab"}); err != nil { + jsonErr(w, 400, err.Error()) + return + } + jsonOut(w, 200, map[string]bool{"sent": true}) + })) + + mux.HandleFunc("GET /api/config", a.auth(func(w http.ResponseWriter, r *http.Request) { + c := *a.cfg + c.Token = "(dold — se konfigfilen)" + jsonOut(w, 200, c) + })) + + mux.HandleFunc("PUT /api/config", a.auth(func(w http.ResponseWriter, r *http.Request) { + var req struct { + AgentCmd *string `json:"agent_cmd"` + AgentArgs *[]string `json:"agent_args"` + Workdir *string `json:"workdir"` + PollMs *int `json:"poll_ms"` + Ntfy *NtfyConfig `json:"ntfy"` + } + if err := readJSON(r, &req); err != nil { + jsonErr(w, 400, err.Error()) + return + } + if req.AgentCmd != nil { + a.cfg.AgentCmd = *req.AgentCmd + } + if req.AgentArgs != nil { + a.cfg.AgentArgs = *req.AgentArgs + } + if req.Workdir != nil { + a.cfg.Workdir = *req.Workdir + } + if req.PollMs != nil && *req.PollMs >= 100 { + a.cfg.PollMs = *req.PollMs + } + if req.Ntfy != nil { + a.cfg.Ntfy = *req.Ntfy + } + if err := a.cfg.Save(); err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonOut(w, 200, map[string]bool{"saved": true}) + })) + + mux.HandleFunc("GET /api/shares", a.auth(func(w http.ResponseWriter, r *http.Request) { + jsonOut(w, 200, a.shares.List()) + })) + + mux.HandleFunc("POST /api/shares", a.auth(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(64 << 20); err != nil { + jsonErr(w, 400, "multipart-form med fältet \"file\" krävs: "+err.Error()) + return + } + f, hdr, err := r.FormFile("file") + if err != nil { + jsonErr(w, 400, "fältet \"file\" saknas") + return + } + defer f.Close() + sh, err := a.shares.Add(hdr.Filename, r.FormValue("note"), r.FormValue("session"), f) + if err != nil { + jsonErr(w, 400, err.Error()) + return + } + a.mgr.emit(Event{Type: "share", Data: sh}) + jsonOut(w, 201, sh) + })) + + mux.HandleFunc("GET /api/shares/{id}", a.auth(func(w http.ResponseWriter, r *http.Request) { + sh, err := a.shares.Get(r.PathValue("id")) + if err != nil { + jsonErr(w, 404, "ingen sådan delning") + return + } + jsonOut(w, 200, sh) + })) + + mux.HandleFunc("GET /api/shares/{id}/raw", a.auth(func(w http.ResponseWriter, r *http.Request) { + sh, err := a.shares.Get(r.PathValue("id")) + if err != nil { + jsonErr(w, 404, "ingen sådan delning") + return + } + if sh.Mime != "" { + w.Header().Set("Content-Type", sh.Mime) + } + w.Header().Set("Content-Security-Policy", "sandbox allow-scripts") // shared HTML must not reach the API with our token + http.ServeFile(w, r, a.shares.Path(sh)) + })) + + mux.HandleFunc("DELETE /api/shares/{id}", a.auth(func(w http.ResponseWriter, r *http.Request) { + if err := a.shares.Delete(r.PathValue("id")); err != nil { + jsonErr(w, 404, err.Error()) + return + } + jsonOut(w, 200, map[string]bool{"deleted": true}) + })) + + mux.HandleFunc("POST /api/notify", a.auth(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Topic string `json:"topic"` + Title string `json:"title"` + Message string `json:"message"` + Priority string `json:"priority"` + } + if err := readJSON(r, &req); err != nil || req.Message == "" { + jsonErr(w, 400, "JSON-kropp med \"message\" krävs") + return + } + if req.Topic == "" { + req.Topic = a.cfg.Ntfy.Topic + } + if !a.cfg.topicAllowed(req.Topic) { + jsonErr(w, 403, "topic inte i allowed_topics") + return + } + if err := a.ntfy.Publish(req.Topic, req.Title, req.Message, req.Priority); err != nil { + jsonErr(w, 502, err.Error()) + return + } + jsonOut(w, 200, map[string]bool{"published": true}) + })) + + mux.HandleFunc("GET /api/events", a.auth(func(w http.ResponseWriter, r *http.Request) { + fl, ok := w.(http.Flusher) + if !ok { + jsonErr(w, 500, "streaming stöds inte") + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + ch := a.mgr.Subscribe() + defer a.mgr.Unsubscribe(ch) + fmt.Fprintf(w, "event: hello\ndata: {\"version\":%q}\n\n", version) + fl.Flush() + for { + select { + case <-r.Context().Done(): + return + case ev := <-ch: + data, _ := json.Marshal(ev) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, data) + fl.Flush() + } + } + })) + + return mux +} + +func serve(cfg *Config) error { + if _, err := exec_LookPath("tmux"); err != nil { + return fmt.Errorf("tmux hittas inte i PATH — helmd kräver tmux") + } + ntfy := &Ntfy{cfg: &cfg.Ntfy} + mgr := NewManager(cfg, ntfy) + shares, err := NewShareStore(cfg) + if err != nil { + return err + } + api := &API{cfg: cfg, mgr: mgr, shares: shares, ntfy: ntfy} + log.Printf("helmd %s lyssnar på http://%s (token i %s)", version, cfg.Listen, cfg.path) + return http.ListenAndServe(cfg.Listen, api.routes()) +} diff --git a/helmd/config.go b/helmd/config.go new file mode 100644 index 0000000..88ff121 --- /dev/null +++ b/helmd/config.go @@ -0,0 +1,135 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type NtfyConfig struct { + Server string `json:"server"` + Topic string `json:"topic"` // server events (questions etc.) + AllowedTopics []string `json:"allowed_topics"` // POST /api/notify may only use these + Enabled bool `json:"enabled"` + NotifyOnQuestion bool `json:"notify_on_question"` +} + +type Config struct { + Listen string `json:"listen"` + Token string `json:"token"` + AgentCmd string `json:"agent_cmd"` + AgentArgs []string `json:"agent_args"` + Workdir string `json:"workdir"` + ShareDir string `json:"share_dir"` + ShareExts []string `json:"share_exts"` + PollMs int `json:"poll_ms"` + Cols int `json:"cols"` + Rows int `json:"rows"` + Ntfy NtfyConfig `json:"ntfy"` + + path string +} + +func defaultConfig() *Config { + home, _ := os.UserHomeDir() + return &Config{ + Listen: "127.0.0.1:8788", + AgentCmd: "agy", + AgentArgs: []string{}, + Workdir: home, + ShareDir: filepath.Join(home, ".local/share/helmd/shares"), + ShareExts: []string{".html", ".htm", ".md", ".txt", ".json", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".pdf"}, + PollMs: 500, + Cols: 200, + Rows: 50, + Ntfy: NtfyConfig{ + Server: "https://ntfy.brasse-pc.eu", + Topic: "agent-helm", + AllowedTopics: []string{"agent-helm", "claude"}, + Enabled: true, + NotifyOnQuestion: true, + }, + } +} + +func configPath() string { + if p := os.Getenv("HELMD_CONFIG"); p != "" { + return p + } + dir, err := os.UserConfigDir() + if err != nil { + dir = "." + } + return filepath.Join(dir, "helmd", "config.json") +} + +// loadConfig reads the config file, creating it with defaults (and a +// fresh token) on first run. +func loadConfig(path string) (*Config, error) { + cfg := defaultConfig() + cfg.path = path + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + cfg.Token = newToken() + if err := cfg.Save(); err != nil { + return nil, err + } + fmt.Fprintf(os.Stderr, "helmd: skapade %s (ny token genererad)\n", path) + return cfg, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + if cfg.Token == "" { + cfg.Token = newToken() + if err := cfg.Save(); err != nil { + return nil, err + } + } + return cfg, nil +} + +func (c *Config) Save() error { + if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + return os.WriteFile(c.path, append(data, '\n'), 0o600) +} + +func newToken() string { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} + +func (c *Config) extAllowed(name string) bool { + ext := strings.ToLower(filepath.Ext(name)) + for _, e := range c.ShareExts { + if ext == e { + return true + } + } + return false +} + +func (c *Config) topicAllowed(topic string) bool { + for _, t := range c.Ntfy.AllowedTopics { + if topic == t { + return true + } + } + return false +} diff --git a/helmd/go.mod b/helmd/go.mod new file mode 100644 index 0000000..453f36c --- /dev/null +++ b/helmd/go.mod @@ -0,0 +1,3 @@ +module gitea.brasse-pc.eu/brasse/agent-helm/helmd + +go 1.24 diff --git a/helmd/main.go b/helmd/main.go new file mode 100644 index 0000000..de98bfb --- /dev/null +++ b/helmd/main.go @@ -0,0 +1,137 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "os/exec" + "path/filepath" + "time" +) + +var version = "dev" // sätts av CI via -ldflags + +var exec_LookPath = exec.LookPath + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + switch os.Args[1] { + case "serve": + fs := flag.NewFlagSet("serve", flag.ExitOnError) + listen := fs.String("listen", "", "override listen-adress (t.ex. 0.0.0.0:8788)") + confPath := fs.String("config", configPath(), "sökväg till config.json") + fs.Parse(os.Args[2:]) + cfg, err := loadConfig(*confPath) + if err != nil { + fatal(err) + } + if *listen != "" { + cfg.Listen = *listen + } + if err := serve(cfg); err != nil { + fatal(err) + } + + case "token": + cfg, err := loadConfig(configPath()) + if err != nil { + fatal(err) + } + fmt.Println(cfg.Token) + + case "share": + fs := flag.NewFlagSet("share", flag.ExitOnError) + server := fs.String("server", "http://127.0.0.1:8788", "helmd-serverns adress") + note := fs.String("note", "", "beskrivning som visas i klienten") + session := fs.String("session", "", "session som delningen hör till") + fs.Parse(os.Args[2:]) + if fs.NArg() != 1 { + fatal(fmt.Errorf("användning: helmd share [flaggor] ")) + } + cfg, err := loadConfig(configPath()) + if err != nil { + fatal(err) + } + id, err := shareFile(*server, cfg.Token, fs.Arg(0), *note, *session) + if err != nil { + fatal(err) + } + fmt.Printf("delad: %s (id %s)\n%s/api/shares/%s/raw\n", filepath.Base(fs.Arg(0)), id, *server, id) + + case "version", "--version", "-v": + fmt.Println("helmd", version) + + default: + usage() + os.Exit(2) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, `helmd — agent-helm-server: styr en CLI-agent i tmux via REST-API + +användning: + helmd serve [--listen host:port] [--config fil] starta servern + helmd share [--note text] [--session namn] dela en fil till klienterna + helmd token skriv ut API-token + helmd version`) +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, "helmd:", err) + os.Exit(1) +} + +// shareFile uploads a file to a running helmd and returns the share id. +func shareFile(server, token, path, note, session string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, err := mw.CreateFormFile("file", filepath.Base(path)) + if err != nil { + return "", err + } + if _, err := io.Copy(fw, f); err != nil { + return "", err + } + mw.WriteField("note", note) + mw.WriteField("session", session) + mw.Close() + + req, err := http.NewRequest("POST", server+"/api/shares", &buf) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 300 { + return "", fmt.Errorf("servern svarade %s: %s", resp.Status, bytes.TrimSpace(body)) + } + var out struct { + ID string `json:"id"` + } + if err := json.Unmarshal(body, &out); err != nil { + return "", err + } + return out.ID, nil +} diff --git a/helmd/ntfy.go b/helmd/ntfy.go new file mode 100644 index 0000000..3df9aa7 --- /dev/null +++ b/helmd/ntfy.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "strings" + "time" +) + +type Ntfy struct { + cfg *NtfyConfig +} + +// Publish posts a message to the configured ntfy server. Errors are +// logged, never fatal — notifications must not take the server down. +func (n *Ntfy) Publish(topic, title, message, priority string) error { + if !n.cfg.Enabled || n.cfg.Server == "" { + return nil + } + if topic == "" { + topic = n.cfg.Topic + } + url := strings.TrimRight(n.cfg.Server, "/") + "/" + topic + req, err := http.NewRequest("POST", url, strings.NewReader(message)) + if err != nil { + return err + } + if title != "" { + req.Header.Set("X-Title", title) + } + if priority != "" { + req.Header.Set("X-Priority", priority) + } + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("ntfy: %v", err) + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + err := fmt.Errorf("ntfy: %s -> %s", topic, resp.Status) + log.Print(err) + return err + } + return nil +} + +func (n *Ntfy) Question(session string, q *Question) { + if !n.cfg.NotifyOnQuestion { + return + } + var b strings.Builder + b.WriteString(q.Text) + for i, o := range q.Options { + b.WriteString("\n") + if o.Num > 0 { + fmt.Fprintf(&b, "%d. %s", o.Num, o.Label) + } else { + fmt.Fprintf(&b, "- %s", o.Label) + } + if i == q.Selected { + b.WriteString(" ←") + } + } + n.Publish("", "helm: "+session+" väntar på svar", b.String(), "high") +} diff --git a/helmd/question.go b/helmd/question.go new file mode 100644 index 0000000..faede82 --- /dev/null +++ b/helmd/question.go @@ -0,0 +1,211 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "regexp" + "strings" +) + +// Question is an interactive prompt detected on the agent's screen: +// tool approval ("Do you want to proceed?"), folder trust, ask_question +// multiple choice, auth pickers etc. +type Question struct { + Text string `json:"text"` + Options []Option `json:"options"` + Selected int `json:"selected"` // index of the ">"-marked row, -1 if unknown + Numbered bool `json:"numbered"` // options can be picked with digit keys + Hash string `json:"hash"` // stable id: same question => same hash +} + +type Option struct { + Num int `json:"num"` // 1-based number if the list is numbered, else 0 + Label string `json:"label"` +} + +var ( + numberedRe = regexp.MustCompile(`^\s*(?:[>●○]\s*)?(\d+)\.\s+(.+?)\s*$`) + selectedRe = regexp.MustCompile(`^\s*>\s+(\S.*?)\s*$`) + indentedRe = regexp.MustCompile(`^\s{2,}(\S.*?)\s*$`) + hintRe = regexp.MustCompile(`↑/↓|↵|esc to|ctrl\+|tab Amend|enter Confirm|Navigate|\? for shortcuts|Use Enter to select`) + borderRe = regexp.MustCompile(`^[\s│╭╰╮╯]+|[\s│╭╰╮╯]+$`) +) + +func cleanLine(l string) string { + return borderRe.ReplaceAllString(l, "") +} + +func isHint(l string) bool { return hintRe.MatchString(l) } + +// optionish reports whether a raw line can belong to an option block. +func optionish(raw string) bool { + c := cleanLine(raw) + if c == "" || isHint(c) { + return false + } + return numberedRe.MatchString(c) || selectedRe.MatchString(c) || indentedRe.MatchString(raw) +} + +// DetectQuestion scans the tail of a pane capture for a pending +// interactive question. Returns nil when the agent is not asking. +func DetectQuestion(screen string) *Question { + lines := strings.Split(strings.TrimRight(screen, "\n"), "\n") + if len(lines) > 45 { + lines = lines[len(lines)-45:] + } + + // Anchor: the last line that is a numbered option or a ">" selection. + anchor := -1 + for i := len(lines) - 1; i >= 0; i-- { + c := cleanLine(lines[i]) + if c == "" || isHint(c) { + continue + } + if numberedRe.MatchString(c) || selectedRe.MatchString(c) { + anchor = i + break + } + } + if anchor < 0 { + return nil + } + + // Grow the block over contiguous option-ish lines. Long numbered + // labels wrap to column 0 in the TUI, so bridge over up to 3 + // non-matching lines when another option row lies beyond them. + start, end := anchor, anchor + for i := start - 1; i >= 0; i-- { + if optionish(lines[i]) { + start = i + continue + } + c := cleanLine(lines[i]) + if c == "" || isHint(c) { + break + } + bridged := false + for k := i - 1; k >= i-3 && k >= 0; k-- { + if numberedRe.MatchString(cleanLine(lines[k])) { + start, i, bridged = k, k, true + break + } + } + if !bridged { + break + } + } + for i := end + 1; i < len(lines); i++ { + if optionish(lines[i]) { + end = i + continue + } + c := cleanLine(lines[i]) + if c == "" || isHint(c) { + break + } + bridged := false + for k := i + 1; k <= i+3 && k < len(lines); k++ { + if numberedRe.MatchString(cleanLine(lines[k])) { + end, i, bridged = k, k, true + break + } + } + if !bridged { + break + } + } + + // Safety: a real prompt has a hint line within 3 rows below the + // block or a ">" marker in it — a numbered markdown list in normal + // agent output has neither. + hasMarker := false + for i := start; i <= end; i++ { + if selectedRe.MatchString(cleanLine(lines[i])) || strings.HasPrefix(strings.TrimSpace(lines[i]), ">") { + hasMarker = true + break + } + } + hintBelow := false + for i := end + 1; i <= end+3 && i < len(lines); i++ { + if isHint(cleanLine(lines[i])) { + hintBelow = true + break + } + } + if !hasMarker && !hintBelow { + return nil + } + + q := &Question{Selected: -1} + for i := start; i <= end; i++ { + c := cleanLine(lines[i]) + if c == "" || isHint(c) { + continue + } + if m := numberedRe.FindStringSubmatch(c); m != nil { + q.Options = append(q.Options, Option{Num: atoi(m[1]), Label: m[2]}) + q.Numbered = true + } else if m := selectedRe.FindStringSubmatch(c); m != nil { + q.Options = append(q.Options, Option{Label: m[1]}) + } else if q.Numbered { + continue // wrapped continuation of a long numbered label + } else if m := indentedRe.FindStringSubmatch(lines[i]); m != nil { + q.Options = append(q.Options, Option{Label: m[1]}) + } else { + continue + } + if strings.HasPrefix(strings.TrimSpace(lines[i]), ">") { + q.Selected = len(q.Options) - 1 + } + } + if len(q.Options) < 2 { + return nil + } + if q.Selected < 0 && !q.Numbered { + return nil + } + + // Question text: nearest lines above the block; a line ending in + // "?" anchors it. + var txt []string + blanks := 0 + for i := start - 1; i >= 0 && len(txt) < 3; i-- { + c := cleanLine(lines[i]) + if c == "" { + blanks++ + if blanks >= 2 && len(txt) > 0 { + break + } + continue + } + blanks = 0 + if isHint(c) { + continue + } + txt = append([]string{c}, txt...) + if strings.HasSuffix(c, "?") { + break + } + } + q.Text = strings.Join(txt, " ") + if q.Text == "" { + q.Text = "(fråga utan text)" + } + + h := sha256.New() + h.Write([]byte(q.Text)) + for _, o := range q.Options { + h.Write([]byte{0}) + h.Write([]byte(o.Label)) + } + q.Hash = hex.EncodeToString(h.Sum(nil))[:16] + return q +} + +func atoi(s string) int { + n := 0 + for _, r := range s { + n = n*10 + int(r-'0') + } + return n +} diff --git a/helmd/question_test.go b/helmd/question_test.go new file mode 100644 index 0000000..b7afb31 --- /dev/null +++ b/helmd/question_test.go @@ -0,0 +1,126 @@ +package main + +import "testing" + +// Real captures from a live agy 1.1.9 session (2026-08-05). + +const agyApproval = ` python3 -c ' + import json + with open("analys.ipynb", "r", encoding="utf-8") as f: + ⋯ (10 lines hidden) +Do you want to proceed? +> 1. Yes + 2. Yes, and always allow in this conversation for commands that start with 'python3 -c ' +import json +with open("analys.ipynb", "r", encoding="utf-8") as f:...' + 3. Yes, and always allow for commands that start with 'python3 -c ' +import json +with open("analys.ipynb", "r", encoding="utf-8") as f:...' (Persist to settings.json) + 4. No + ↑/↓ Navigate · tab Amend · ctrl+g edit/expand command +esc to cancel Gemini 3.6 Flash · high` + +const agyTrust = `Accessing workspace: +/tmp/scratch/gemini-arena +Do you trust the contents of this project? +Antigravity CLI requires permission to read, edit, and execute files here. +> Yes, I trust this folder + No, exit + ↑/↓ Navigate · enter Confirm + Gemini 3.6 Flash · high` + +const agyIdle = ` ▄▀▀▄ Antigravity CLI 1.1.9 + ▀▀▀▀▀▀ brasse.bb@gmail.com (Google AI Pro) +──────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────── +? for shortcuts Gemini 3.6 Flash · high` + +// Agent output containing a markdown numbered list must NOT trigger. +const agyMarkdownList = ` Here are 5 targeted CLI tools: + 1. ntfy: sends push notifications to your phone. + 2. tea: manages Gitea repositories from the command line. + 3. skopeo: inspects registry images. +──────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────── +? for shortcuts Gemini 3.6 Flash · high` + +const geminiAuthPicker = ` ╭──────────────────────────────────────────────╮ + │ ? Get started │ + │ │ + │ How would you like to authenticate? │ + │ │ + │ ● 1. Sign in with Google │ + │ 2. Use Gemini API Key │ + │ 3. Vertex AI │ + │ │ + │ (Use Enter to select) │ + ╰──────────────────────────────────────────────╯` + +func TestApprovalPrompt(t *testing.T) { + q := DetectQuestion(agyApproval) + if q == nil { + t.Fatal("expected question, got nil") + } + if !q.Numbered || len(q.Options) != 4 { + t.Fatalf("want 4 numbered options, got %+v", q.Options) + } + if q.Options[0].Label != "Yes" || q.Options[3].Label != "No" { + t.Errorf("bad labels: %+v", q.Options) + } + if q.Selected != 0 { + t.Errorf("want selected=0, got %d", q.Selected) + } + if q.Text != "Do you want to proceed?" { + t.Errorf("bad text: %q", q.Text) + } +} + +func TestTrustPrompt(t *testing.T) { + q := DetectQuestion(agyTrust) + if q == nil { + t.Fatal("expected question, got nil") + } + if q.Numbered || len(q.Options) != 2 { + t.Fatalf("want 2 unnumbered options, got %+v", q.Options) + } + if q.Selected != 0 { + t.Errorf("want selected=0, got %d", q.Selected) + } + if q.Options[1].Label != "No, exit" { + t.Errorf("bad option: %+v", q.Options[1]) + } +} + +func TestIdleScreenNoQuestion(t *testing.T) { + if q := DetectQuestion(agyIdle); q != nil { + t.Fatalf("idle screen misdetected as question: %+v", q) + } +} + +func TestMarkdownListNoQuestion(t *testing.T) { + if q := DetectQuestion(agyMarkdownList); q != nil { + t.Fatalf("markdown list misdetected as question: %+v", q) + } +} + +func TestBoxedNumberedPicker(t *testing.T) { + q := DetectQuestion(geminiAuthPicker) + if q == nil { + t.Fatal("expected question, got nil") + } + if len(q.Options) != 3 || !q.Numbered { + t.Fatalf("want 3 numbered options, got %+v", q.Options) + } +} + +func TestSameQuestionSameHash(t *testing.T) { + a, b := DetectQuestion(agyApproval), DetectQuestion(agyApproval) + if a.Hash != b.Hash { + t.Error("hash not stable") + } + if DetectQuestion(agyTrust).Hash == a.Hash { + t.Error("different questions share hash") + } +} diff --git a/helmd/scripts/integration-test.sh b/helmd/scripts/integration-test.sh new file mode 100755 index 0000000..6abd95e --- /dev/null +++ b/helmd/scripts/integration-test.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# Integrationstest för helmd. Körs i en isolerad container (se +# scripts/run-integration.sh) men fungerar var som helst där tmux, +# curl och binären finns och där HOME får smutsas ner. +set -eu + +BIN="${HELMD_BIN:-./build/helmd}" +export HOME="${TEST_HOME:-$(mktemp -d)}" +export HELMD_CONFIG="$HOME/helmd-config.json" +API=http://127.0.0.1:8788 +PASS=0; FAIL=0 + +say() { printf '\n== %s\n' "$*"; } +ok() { PASS=$((PASS+1)); echo " OK: $*"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $*" >&2; } + +expect_contains() { # haystackfil, nål, beskrivning + if grep -qF "$2" "$1"; then ok "$3"; else fail "$3 — hittade inte: $2"; cat "$1" | tail -20; fi +} + +curl_api() { curl -sS -H "Authorization: Bearer $TOKEN" "$@"; } + +wait_for() { # beskrivning, timeout-sek, kommando... + local desc="$1" t="$2"; shift 2 + for _ in $(seq 1 $((t*2))); do + if "$@" >/dev/null 2>&1; then ok "$desc"; return 0; fi + sleep 0.5 + done + fail "$desc (timeout ${t}s)"; return 1 +} + +say "startar helmd" +TOKEN=$("$BIN" token) +"$BIN" serve --listen 127.0.0.1:8788 & +HELMD_PID=$! +trap 'kill $HELMD_PID 2>/dev/null || true; tmux kill-server 2>/dev/null || true' EXIT +wait_for "servern svarar på /api/health" 10 \ + curl -fsS -H "Authorization: Bearer $TOKEN" $API/api/health + +say "auth" +CODE=$(curl -s -o /dev/null -w '%{http_code}' $API/api/sessions) +[ "$CODE" = 401 ] && ok "utan token => 401" || fail "utan token gav $CODE" +CODE=$(curl -s -o /dev/null -w '%{http_code}' "$API/api/sessions?token=$TOKEN") +[ "$CODE" = 200 ] && ok "query-token => 200" || fail "query-token gav $CODE" + +say "stäng av ntfy för testet (PUT /api/config)" +curl_api -X PUT -d '{"ntfy":{"server":"https://ntfy.brasse-pc.eu","topic":"agent-helm","allowed_topics":["agent-helm","claude"],"enabled":false,"notify_on_question":false}}' \ + $API/api/config > /tmp/cfg.json +expect_contains /tmp/cfg.json '"saved":true' "config sparad" +curl_api $API/api/config | grep -q '"enabled":false' && ok "ntfy avstängd" || fail "ntfy fortfarande på" + +say "session: skapa med mock-agent" +MOCK="$(cd "$(dirname "$0")/.." && pwd)/testdata/mock-agent.sh" +curl_api -X POST -d "{\"name\":\"prov\",\"cmd\":\"bash $MOCK\",\"cwd\":\"/tmp\"}" \ + $API/api/sessions > /tmp/sess.json +expect_contains /tmp/sess.json '"name":"prov"' "session skapad" + +say "fråga 1: onumrerad trust-fråga (pilnavigering)" +q_has_two_options() { curl_api $API/api/sessions/prov/question | grep -q '"No, exit"'; } +wait_for "trust-frågan detekterad" 10 q_has_two_options +curl_api $API/api/sessions/prov/question > /tmp/q1.json +expect_contains /tmp/q1.json '"selected":0' "markören står på första valet" +curl_api -X POST -d '{"option":2}' $API/api/sessions/prov/answer > /tmp/a1.json +expect_contains /tmp/a1.json '"answered":true' "svar skickat" +screen_has() { curl_api "$API/api/sessions/prov/screen" | grep -qF "$1"; } +wait_for "mocken fick svaret (ned+enter => val 1)" 10 screen_has "TRUST_ANSWER=1" + +say "prompt" +curl_api -X POST -d '{"text":"hej från testet"}' $API/api/sessions/prov/prompt > /dev/null +wait_for "prompten nådde agenten" 10 screen_has "ECHO: hej från testet" + +say "fråga 2: numrerad godkännandefråga (sifferval)" +curl_api -X POST -d '{"text":"ask-numbered"}' $API/api/sessions/prov/prompt > /dev/null +q2_detected() { curl_api $API/api/sessions/prov/question | grep -q '"numbered":true'; } +wait_for "numrerad fråga detekterad" 10 q2_detected +curl_api -X POST -d '{"option":3}' $API/api/sessions/prov/answer > /dev/null +wait_for "mocken fick siffran 3" 10 screen_has "PICKED=3" + +say "raw keys + mode" +curl_api -X POST -d '{"keys":["Enter"]}' $API/api/sessions/prov/keys | grep -q '"sent":true' \ + && ok "keys-endpoint" || fail "keys-endpoint" +curl_api -X POST -d '{"action":"cycle"}' $API/api/sessions/prov/mode | grep -q '"sent":true' \ + && ok "mode cycle (BTab)" || fail "mode cycle" + +say "SSE-events" +(curl_api -N --max-time 4 "$API/api/events" > /tmp/events.txt 2>/dev/null || true) & +SSE_PID=$! +sleep 0.5 +curl_api -X POST -d '{"text":"sse-test"}' $API/api/sessions/prov/prompt > /dev/null +wait $SSE_PID || true +expect_contains /tmp/events.txt 'event: hello' "SSE hello" +expect_contains /tmp/events.txt 'event: screen' "SSE screen-event vid ändring" + +say "fildelning" +printf '

rapport

' > /tmp/rapport.html +"$BIN" share --note "testrapport" --session prov /tmp/rapport.html > /tmp/share.out +expect_contains /tmp/share.out "delad: rapport.html" "helmd share-CLI" +curl_api $API/api/shares > /tmp/shares.json +expect_contains /tmp/shares.json '"note":"testrapport"' "delningen listas" +SHARE_ID=$(grep -o '"id":"[a-f0-9]*"' /tmp/shares.json | head -1 | cut -d'"' -f4) +curl_api "$API/api/shares/$SHARE_ID/raw" > /tmp/share.raw +expect_contains /tmp/share.raw '

rapport

' "raw-innehåll serveras" +printf 'x' > /tmp/otillaten.sh +CODE=$(curl_api -s -o /dev/null -w '%{http_code}' -F file=@/tmp/otillaten.sh $API/api/shares) +[ "$CODE" = 400 ] && ok "otillåten filtyp => 400" || fail "otillåten filtyp gav $CODE" +curl_api -X DELETE "$API/api/shares/$SHARE_ID" | grep -q '"deleted":true' \ + && ok "delning raderad" || fail "radering" + +say "notify-regler" +CODE=$(curl_api -s -o /dev/null -w '%{http_code}' -X POST -d '{"topic":"hemligt","message":"x"}' $API/api/notify) +[ "$CODE" = 403 ] && ok "otillåten topic => 403" || fail "otillåten topic gav $CODE" +curl_api -X POST -d '{"topic":"claude","message":"tyst (ntfy disabled)"}' $API/api/notify \ + | grep -q '"published":true' && ok "tillåten topic => ok (ntfy av => no-op)" || fail "tillåten topic" + +say "session: avsluta + döda" +curl_api -X POST -d '{"text":"exit"}' $API/api/sessions/prov/prompt > /dev/null +sess_dead() { curl_api $API/api/sessions/prov | grep -q '"alive":false'; } +wait_for "exit upptäcks (alive=false)" 10 sess_dead +curl_api -X DELETE $API/api/sessions/prov | grep -q '"killed":true' \ + && ok "session dödad" || fail "kill" + +echo +echo "================================" +echo "PASS: $PASS FAIL: $FAIL" +[ "$FAIL" = 0 ] && echo "ALLA TESTER GRÖNA" || exit 1 diff --git a/helmd/scripts/run-integration.sh b/helmd/scripts/run-integration.sh new file mode 100755 index 0000000..dc1e490 --- /dev/null +++ b/helmd/scripts/run-integration.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Kör integrationstestet i en isolerad Docker-container. +# Binären byggs statiskt på värden; containern får bara tmux+curl. +set -eu +cd "$(dirname "$0")/.." + +CGO_ENABLED=0 go build -o build/helmd . +docker run --rm \ + -v "$PWD":/work:ro \ + -e HELMD_BIN=/work/build/helmd \ + -e TEST_HOME=/tmp/home \ + -w /work \ + debian:stable-slim \ + bash -c 'apt-get update -qq >/dev/null && apt-get install -yqq tmux curl procps >/dev/null 2>&1 && mkdir -p /tmp/home && bash scripts/integration-test.sh' diff --git a/helmd/session.go b/helmd/session.go new file mode 100644 index 0000000..0bc4138 --- /dev/null +++ b/helmd/session.go @@ -0,0 +1,323 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "log" + "strings" + "sync" + "time" +) + +const sessPrefix = "helm-" + +// SessionState is what clients see for one managed tmux session. +type SessionState struct { + Name string `json:"name"` // API name (without tmux prefix) + Tmux string `json:"tmux"` // tmux session name + Cmd string `json:"cmd"` // command line launched in the pane + Alive bool `json:"alive"` // pane process still running + Seq uint64 `json:"seq"` // bumped on every screen change + Question *Question `json:"question"` // pending question, if any + Changed time.Time `json:"changed"` // last screen change + Created time.Time `json:"created"` +} + +type Event struct { + Type string `json:"type"` // screen | question | session | share + Session string `json:"session,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +type Manager struct { + cfg *Config + ntfy *Ntfy + + mu sync.Mutex + sessions map[string]*SessionState + notified map[string]string // session -> last question hash sent to ntfy + + subMu sync.Mutex + subs map[chan Event]struct{} +} + +func NewManager(cfg *Config, ntfy *Ntfy) *Manager { + m := &Manager{ + cfg: cfg, + ntfy: ntfy, + sessions: map[string]*SessionState{}, + notified: map[string]string{}, + subs: map[chan Event]struct{}{}, + } + m.adoptExisting() + go m.pollLoop() + return m +} + +// adoptExisting re-registers helm-* tmux sessions after a helmd restart: +// tmux is the source of truth, helmd only mirrors it. +func (m *Manager) adoptExisting() { + for _, t := range tmuxListSessions() { + if strings.HasPrefix(t, sessPrefix) { + name := strings.TrimPrefix(t, sessPrefix) + m.sessions[name] = &SessionState{ + Name: name, Tmux: t, Cmd: "(adopterad)", + Created: time.Now(), Changed: time.Now(), + } + log.Printf("adopterade befintlig tmux-session %s", t) + } + } +} + +func (m *Manager) Subscribe() chan Event { + ch := make(chan Event, 32) + m.subMu.Lock() + m.subs[ch] = struct{}{} + m.subMu.Unlock() + return ch +} + +func (m *Manager) Unsubscribe(ch chan Event) { + m.subMu.Lock() + delete(m.subs, ch) + m.subMu.Unlock() +} + +func (m *Manager) emit(ev Event) { + m.subMu.Lock() + for ch := range m.subs { + select { + case ch <- ev: + default: // slow client: drop rather than block the poller + } + } + m.subMu.Unlock() +} + +func (m *Manager) List() []*SessionState { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]*SessionState, 0, len(m.sessions)) + for _, s := range m.sessions { + out = append(out, s) + } + return out +} + +func (m *Manager) Get(name string) (*SessionState, bool) { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.sessions[name] + return s, ok +} + +// Create starts a new tmux session running the agent (or a custom +// command). An empty cmd uses the configured agent + args. +func (m *Manager) Create(name, cmdline, cwd string) (*SessionState, error) { + if name == "" || strings.ContainsAny(name, " \t/.:") { + return nil, fmt.Errorf("ogiltigt sessionsnamn %q", name) + } + m.mu.Lock() + defer m.mu.Unlock() + if _, exists := m.sessions[name]; exists { + return nil, fmt.Errorf("sessionen %s finns redan", name) + } + tmuxName := sessPrefix + name + if tmuxHasSession(tmuxName) { + return nil, fmt.Errorf("tmux-sessionen %s finns redan (adoptera med POST {\"adopt\":true})", tmuxName) + } + if cmdline == "" { + cmdline = m.cfg.AgentCmd + if len(m.cfg.AgentArgs) > 0 { + cmdline += " " + strings.Join(m.cfg.AgentArgs, " ") + } + } + if cwd == "" { + cwd = m.cfg.Workdir + } + if err := tmuxNewSession(tmuxName, cwd, m.cfg.Cols, m.cfg.Rows, cmdline); err != nil { + return nil, err + } + s := &SessionState{ + Name: name, Tmux: tmuxName, Cmd: cmdline, Alive: true, + Created: time.Now(), Changed: time.Now(), + } + m.sessions[name] = s + m.emit(Event{Type: "session", Session: name, Data: "created"}) + return s, nil +} + +// Adopt registers an already-running tmux session under helmd control. +func (m *Manager) Adopt(name, tmuxName string) (*SessionState, error) { + if tmuxName == "" { + tmuxName = sessPrefix + name + } + if !tmuxHasSession(tmuxName) { + return nil, fmt.Errorf("ingen tmux-session %q", tmuxName) + } + m.mu.Lock() + defer m.mu.Unlock() + if _, exists := m.sessions[name]; exists { + return nil, fmt.Errorf("sessionen %s finns redan", name) + } + s := &SessionState{ + Name: name, Tmux: tmuxName, Cmd: "(adopterad)", Alive: true, + Created: time.Now(), Changed: time.Now(), + } + m.sessions[name] = s + m.emit(Event{Type: "session", Session: name, Data: "adopted"}) + return s, nil +} + +// Kill terminates the tmux session and forgets it. +func (m *Manager) Kill(name string) error { + m.mu.Lock() + s, ok := m.sessions[name] + if ok { + delete(m.sessions, name) + delete(m.notified, name) + } + m.mu.Unlock() + if !ok { + return fmt.Errorf("ingen session %q", name) + } + m.emit(Event{Type: "session", Session: name, Data: "killed"}) + if tmuxHasSession(s.Tmux) { + return tmuxKillSession(s.Tmux) + } + return nil +} + +// Prompt pastes text into the session and (by default) submits it. +func (m *Manager) Prompt(name, text string, submit bool) error { + s, ok := m.Get(name) + if !ok { + return fmt.Errorf("ingen session %q", name) + } + if err := tmuxPasteText(s.Tmux, text); err != nil { + return err + } + if submit { + time.Sleep(150 * time.Millisecond) // let the TUI ingest the paste + return tmuxSendKeys(s.Tmux, "Enter") + } + return nil +} + +// Answer picks an option in the pending question. Numbered lists get +// the digit key; unnumbered lists are navigated relative to the +// current selection. +func (m *Manager) Answer(name string, option int) error { + s, ok := m.Get(name) + if !ok { + return fmt.Errorf("ingen session %q", name) + } + q := s.Question + if q == nil { + return fmt.Errorf("ingen väntande fråga i %s", name) + } + if option < 1 || option > len(q.Options) { + return fmt.Errorf("option %d utanför 1..%d", option, len(q.Options)) + } + idx := option - 1 + if q.Numbered { + num := q.Options[idx].Num + if err := tmuxSendKeys(s.Tmux, fmt.Sprint(num)); err != nil { + return err + } + time.Sleep(200 * time.Millisecond) + // Digit selection auto-confirms in agy; if the prompt is still + // up (pure highlight moved), Enter confirms. Extra Enter on an + // already-gone prompt lands in the input box and is harmless + // only if empty — so only confirm when the question persists. + if cur, err := tmuxCapture(s.Tmux, false, 0); err == nil { + if nq := DetectQuestion(cur); nq != nil && nq.Hash == q.Hash { + return tmuxSendKeys(s.Tmux, "Enter") + } + } + return nil + } + if q.Selected < 0 { + return fmt.Errorf("okänd markering — svara med raw keys i stället") + } + diff := idx - q.Selected + key, n := "Down", diff + if diff < 0 { + key, n = "Up", -diff + } + for i := 0; i < n; i++ { + if err := tmuxSendKeys(s.Tmux, key); err != nil { + return err + } + time.Sleep(60 * time.Millisecond) + } + return tmuxSendKeys(s.Tmux, "Enter") +} + +// Keys sends raw tmux key names (Escape, BTab, C-c, y, ...). +func (m *Manager) Keys(name string, keys []string) error { + s, ok := m.Get(name) + if !ok { + return fmt.Errorf("ingen session %q", name) + } + return tmuxSendKeys(s.Tmux, keys...) +} + +func (m *Manager) Screen(name string, ansi bool, history int) (string, error) { + s, ok := m.Get(name) + if !ok { + return "", fmt.Errorf("ingen session %q", name) + } + return tmuxCapture(s.Tmux, ansi, history) +} + +// pollLoop watches every session for screen changes and questions. +func (m *Manager) pollLoop() { + interval := time.Duration(m.cfg.PollMs) * time.Millisecond + if interval <= 0 { + interval = 500 * time.Millisecond + } + hashes := map[string]string{} + for { + time.Sleep(interval) + for _, s := range m.List() { + alive := tmuxHasSession(s.Tmux) && tmuxSessionAlive(s.Tmux) + if !alive { + if s.Alive { + s.Alive = false + m.emit(Event{Type: "session", Session: s.Name, Data: "exited"}) + m.ntfy.Publish("", "helm: "+s.Name+" avslutades", "Agentprocessen i "+s.Tmux+" kör inte längre.", "default") + } + continue + } + s.Alive = true + screen, err := tmuxCapture(s.Tmux, false, 0) + if err != nil { + continue + } + sum := sha256.Sum256([]byte(screen)) + h := hex.EncodeToString(sum[:8]) + if h == hashes[s.Name] { + continue + } + hashes[s.Name] = h + s.Seq++ + s.Changed = time.Now() + s.Question = DetectQuestion(screen) + m.emit(Event{Type: "screen", Session: s.Name, Data: s.Seq}) + if s.Question != nil { + m.emit(Event{Type: "question", Session: s.Name, Data: s.Question}) + m.mu.Lock() + already := m.notified[s.Name] == s.Question.Hash + if !already { + m.notified[s.Name] = s.Question.Hash + } + m.mu.Unlock() + if !already { + m.ntfy.Question(s.Name, s.Question) + } + } + } + } +} diff --git a/helmd/shares.go b/helmd/shares.go new file mode 100644 index 0000000..8710ff2 --- /dev/null +++ b/helmd/shares.go @@ -0,0 +1,123 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "mime" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// Share is a file the agent published for the clients to view +// (reports, screenshots, generated pages...). +type Share struct { + ID string `json:"id"` + Name string `json:"name"` + Note string `json:"note,omitempty"` + Session string `json:"session,omitempty"` + Size int64 `json:"size"` + Mime string `json:"mime"` + Created time.Time `json:"created"` +} + +type ShareStore struct { + dir string + cfg *Config +} + +func NewShareStore(cfg *Config) (*ShareStore, error) { + if err := os.MkdirAll(cfg.ShareDir, 0o755); err != nil { + return nil, err + } + return &ShareStore{dir: cfg.ShareDir, cfg: cfg}, nil +} + +func (s *ShareStore) metaPath(id string) string { return filepath.Join(s.dir, id+".meta.json") } +func (s *ShareStore) filePath(id, name string) string { + return filepath.Join(s.dir, id+"_"+filepath.Base(name)) +} + +// Add stores content under a fresh id and writes a metadata sidecar. +func (s *ShareStore) Add(name, note, session string, r io.Reader) (*Share, error) { + name = filepath.Base(name) + if !s.cfg.extAllowed(name) { + return nil, fmt.Errorf("filtyp tillåts inte: %s (tillåtna: %s)", + filepath.Ext(name), strings.Join(s.cfg.ShareExts, " ")) + } + idb := make([]byte, 6) + if _, err := rand.Read(idb); err != nil { + return nil, err + } + id := hex.EncodeToString(idb) + f, err := os.Create(s.filePath(id, name)) + if err != nil { + return nil, err + } + size, err := io.Copy(f, r) + f.Close() + if err != nil { + os.Remove(s.filePath(id, name)) + return nil, err + } + sh := &Share{ + ID: id, + Name: name, + Note: note, + Session: session, + Size: size, + Mime: mime.TypeByExtension(strings.ToLower(filepath.Ext(name))), + Created: time.Now(), + } + meta, _ := json.MarshalIndent(sh, "", " ") + if err := os.WriteFile(s.metaPath(id), meta, 0o644); err != nil { + return nil, err + } + return sh, nil +} + +func (s *ShareStore) Get(id string) (*Share, error) { + data, err := os.ReadFile(s.metaPath(id)) + if err != nil { + return nil, err + } + var sh Share + if err := json.Unmarshal(data, &sh); err != nil { + return nil, err + } + return &sh, nil +} + +func (s *ShareStore) List() []*Share { + matches, _ := filepath.Glob(filepath.Join(s.dir, "*.meta.json")) + var out []*Share + for _, m := range matches { + data, err := os.ReadFile(m) + if err != nil { + continue + } + var sh Share + if json.Unmarshal(data, &sh) == nil { + out = append(out, &sh) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) }) + return out +} + +func (s *ShareStore) Delete(id string) error { + sh, err := s.Get(id) + if err != nil { + return err + } + os.Remove(s.filePath(sh.ID, sh.Name)) + return os.Remove(s.metaPath(id)) +} + +// Path returns the on-disk path for serving. +func (s *ShareStore) Path(sh *Share) string { return s.filePath(sh.ID, sh.Name) } diff --git a/helmd/testdata/mock-agent.sh b/helmd/testdata/mock-agent.sh new file mode 100755 index 0000000..ab562cc --- /dev/null +++ b/helmd/testdata/mock-agent.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# mock-agent — låtsas-agent för helmd:s integrationstester. +# Härmar agy-TUI:ns två frågetyper: +# 1) onumrerad lista med ">"-markör som navigeras med pilarna + Enter +# 2) numrerad lista som väljs med sifferknapp +set -u + +echo "MOCK AGENT v1" +echo + +# --- Fas 1: trust-fråga (onumrerad, pil-navigerad) -------------------- +SEL=0 +draw_trust() { + echo + echo "Do you trust this test arena?" + if [ "$SEL" = 0 ]; then + echo "> Yes, run the test" + echo " No, exit" + else + echo " Yes, run the test" + echo "> No, exit" + fi + echo " ↑/↓ Navigate · enter Confirm" +} +draw_trust +while true; do + IFS= read -rsn1 c + if [ "$c" = $'\e' ]; then + read -rsn2 -t 1 rest || rest="" + case "$rest" in + "[B") SEL=1; draw_trust ;; + "[A") SEL=0; draw_trust ;; + esac + elif [ -z "$c" ]; then # Enter + break + fi +done +echo "TRUST_ANSWER=$SEL" +echo + +# --- Fas 2: promptloop ------------------------------------------------- +while true; do + printf "> " + IFS= read -r line || break + case "$line" in + ask-numbered) + echo + echo "Do you want to proceed?" + echo "> 1. Yes" + echo " 2. Yes, and always allow" + echo " 3. No" + echo " ↑/↓ Navigate · enter Confirm" + IFS= read -rsn1 d + echo "PICKED=$d" + ;; + exit) + echo "BYE" + exit 0 + ;; + *) + echo "ECHO: $line" + ;; + esac +done diff --git a/helmd/tmux.go b/helmd/tmux.go new file mode 100644 index 0000000..756b993 --- /dev/null +++ b/helmd/tmux.go @@ -0,0 +1,93 @@ +package main + +import ( + "fmt" + "os/exec" + "strings" +) + +// tmuxCmd runs a tmux command and returns its stdout. +func tmuxCmd(args ...string) (string, error) { + out, err := exec.Command("tmux", args...).CombinedOutput() + if err != nil { + return "", fmt.Errorf("tmux %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return string(out), nil +} + +func tmuxHasSession(name string) bool { + err := exec.Command("tmux", "has-session", "-t", "="+name).Run() + return err == nil +} + +func tmuxListSessions() []string { + out, err := tmuxCmd("list-sessions", "-F", "#{session_name}") + if err != nil { + return nil // no tmux server running = no sessions + } + var names []string + for _, l := range strings.Split(strings.TrimSpace(out), "\n") { + if l != "" { + names = append(names, l) + } + } + return names +} + +func tmuxNewSession(name, cwd string, width, height int, cmdline string) error { + args := []string{"new-session", "-d", "-s", name, + "-x", fmt.Sprint(width), "-y", fmt.Sprint(height)} + if cwd != "" { + args = append(args, "-c", cwd) + } + if cmdline != "" { + args = append(args, cmdline) + } + _, err := tmuxCmd(args...) + return err +} + +func tmuxKillSession(name string) error { + _, err := tmuxCmd("kill-session", "-t", "="+name) + return err +} + +// tmuxCapture returns the visible pane content. ansi keeps escape +// sequences (for terminal-faithful rendering in clients). +func tmuxCapture(name string, ansi bool, history int) (string, error) { + args := []string{"capture-pane", "-p", "-t", "=" + name + ":"} + if ansi { + args = append(args, "-e") + } + if history > 0 { + args = append(args, "-S", fmt.Sprintf("-%d", history)) + } + return tmuxCmd(args...) +} + +// tmuxSendKeys sends key names (Enter, Up, Down, BTab, C-c, ...). +func tmuxSendKeys(name string, keys ...string) error { + args := append([]string{"send-keys", "-t", "=" + name + ":"}, keys...) + _, err := tmuxCmd(args...) + return err +} + +// tmuxPasteText inserts literal text (multi-line safe) via a paste +// buffer, without submitting it. +func tmuxPasteText(name, text string) error { + cmd := exec.Command("tmux", "load-buffer", "-b", "helmd", "-") + cmd.Stdin = strings.NewReader(text) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("tmux load-buffer: %v: %s", err, strings.TrimSpace(string(out))) + } + _, err := tmuxCmd("paste-buffer", "-d", "-b", "helmd", "-t", "="+name+":") + return err +} + +func tmuxSessionAlive(name string) bool { + out, err := tmuxCmd("list-panes", "-t", "="+name+":", "-F", "#{pane_dead}") + if err != nil { + return false + } + return strings.TrimSpace(out) == "0" +}