This commit is contained in:
2026-05-15 00:28:06 +01:00
parent 2f969fa215
commit 0d578d54f1
31 changed files with 3209 additions and 164 deletions

View File

@@ -378,7 +378,7 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
return out, nil
case "stream":
b, end := c.StreamRead(sinceOffset)
out.Content = stripANSI(string(b))
out.Content = string(stripANSIBytes(nil, b))
out.NewOffset = end
return out, nil
default:
@@ -409,10 +409,10 @@ func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err)
}
b, _ := c.StreamRead(0)
text := string(b)
if kind == "rendered" {
text = stripANSI(text)
b = stripANSIBytes(nil, b)
}
text := string(b)
lines := strings.Split(text, "\n")
matches := make([]mcp.SearchMatch, 0, limit)
truncated := false
@@ -440,10 +440,19 @@ func (h *toolHost) WaitForPattern(callerID, processID, pattern string, timeoutSe
if scope == "" {
scope = "grid"
}
if scope != "grid" && scope != "scrollback" {
return false, "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "unknown scope %q (want grid|scrollback)", scope)
}
deadline := time.Now().Add(time.Duration(timeoutSeconds * float64(time.Second)))
tick := time.NewTicker(50 * time.Millisecond)
defer tick.Stop()
for {
// chunkWake fires on every PTY chunk for the target child. The
// fallback timer guarantees we still re-check on grid-only sweeps
// where the cursor position changed without a fresh chunk landing.
wake := newChunkNotifier(c.ID)
h.sess.Subscribe(wake)
defer h.sess.Unsubscribe(wake)
check := func() (bool, string) {
text := ""
switch scope {
case "grid":
@@ -454,23 +463,75 @@ func (h *toolHost) WaitForPattern(callerID, processID, pattern string, timeoutSe
}
case "scrollback":
b, _ := c.StreamRead(0)
text = stripANSI(string(b))
default:
return false, "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "unknown scope %q (want grid|scrollback)", scope)
text = string(stripANSIBytes(nil, b))
}
if m := re.FindString(text); m != "" {
return true, m, nil
return true, m
}
if time.Now().After(deadline) {
return false, ""
}
if ok, m := check(); ok {
return true, m, nil
}
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return false, "", nil
}
<-tick.C
// Long fallback tick — the chunk notifier wakes us promptly
// on fresh PTY output; the timer is only there for cases
// where grid state shifted without a new chunk.
wait := 500 * time.Millisecond
if remaining < wait {
wait = remaining
}
select {
case <-wake.fired:
case <-time.After(wait):
}
if ok, m := check(); ok {
return true, m, nil
}
if !c.IsLive() && c.Status() != StatusStopped {
return false, "", nil
}
}
}
// chunkNotifier is a one-shot-per-chunk wake channel listener.
// Registers via Session.Subscribe; emits a non-blocking signal on
// `fired` for every PTY chunk emitted by the target child. Used by
// WaitForPattern to avoid 50ms-tick polling of the entire ring/grid.
type chunkNotifier struct {
childID string
fired chan struct{}
}
func newChunkNotifier(childID string) *chunkNotifier {
return &chunkNotifier{childID: childID, fired: make(chan struct{}, 1)}
}
func (n *chunkNotifier) OnChildSpawned(*Child) {}
func (n *chunkNotifier) OnChildExited(c *Child) {
if c.ID != n.childID {
return
}
select {
case n.fired <- struct{}{}:
default:
}
}
func (n *chunkNotifier) OnPTYOut(id string, chunk []byte) {
if id != n.childID {
return
}
select {
case n.fired <- struct{}{}:
default:
}
}
func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
c := h.sess.FindChild(processID)
if c == nil {
@@ -887,6 +948,74 @@ func stripANSI(s string) string {
return ansiRegexp.ReplaceAllString(s, "")
}
// stripANSIBytes is the byte-slice form of stripANSI. Skips the
// string conversion and the regex DFA — useful when the caller will
// itself walk the result line-by-line (SearchOutput) or feed it to a
// pattern match (WaitForPattern scrollback). Recognises the same
// shapes the regex did:
// - `\x1b[ <params> <intermediate> <final-byte>` (CSI / SGR)
// - `\x1b<final-byte>` for `@..._` (one-byte escapes)
// - `\x07` (BEL)
//
// The dst slice is reused if cap is sufficient; the returned slice
// is what callers should use.
func stripANSIBytes(dst, src []byte) []byte {
if cap(dst) < len(src) {
dst = make([]byte, 0, len(src))
} else {
dst = dst[:0]
}
for i := 0; i < len(src); {
b := src[i]
if b == 0x07 {
i++
continue
}
if b != 0x1b {
dst = append(dst, b)
i++
continue
}
// ESC-led sequence.
if i+1 >= len(src) {
// Stranded ESC at end of buffer — drop it.
i++
continue
}
next := src[i+1]
if next != '[' {
// One-byte ESC sequence (`\x1b<final>` where final is
// `@..._` per the regex; we drop anything that follows).
if next >= 0x40 && next <= 0x5f {
i += 2
continue
}
// Anything else after ESC: drop the ESC, keep walking.
i++
continue
}
// CSI: parameters [0x30..0x3f]*, intermediate [0x20..0x2f]*,
// final [0x40..0x7e].
j := i + 2
for j < len(src) && src[j] >= 0x30 && src[j] <= 0x3f {
j++
}
for j < len(src) && src[j] >= 0x20 && src[j] <= 0x2f {
j++
}
if j < len(src) && src[j] >= 0x40 && src[j] <= 0x7e {
i = j + 1
continue
}
// Incomplete CSI — the regex form falls back to its
// `\x1b<final>` rule and matches `\x1b[` (`[` is 0x5b, inside
// 0x40..0x5f), consuming the two-byte prefix and leaving the
// pending params/intermediate bytes intact. Match that.
i += 2
}
return dst
}
// availableToolsForRole — SPEC §7 whoami exposes the list a caller can
// invoke from its current role. Sub-agents lose `spawn_agent` (§8
// two-level-tree rule).