- 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>
212 lines
5.2 KiB
Go
212 lines
5.2 KiB
Go
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
|
|
}
|