helmd v2: Go-server som styr agent i tmux — REST+SSE, frågedetektor, fildelning, ntfy, CI-release
Some checks failed
build-and-push / build (push) Successful in 42s
release-client / build-release (push) Has been cancelled
helmd-release / build-release (push) Successful in 1m6s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 20:54:54 +02:00
parent 3e7152a884
commit f9c79ac84e
17 changed files with 2075 additions and 0 deletions

211
helmd/question.go Normal file
View File

@@ -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
}