wip
This commit is contained in:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user