helmd v2: Go-server som styr agent i tmux — REST+SSE, frågedetektor, fildelning, ntfy, CI-release
- 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:
211
helmd/question.go
Normal file
211
helmd/question.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user