Let task agents read and type into the task's terminal

This commit is contained in:
Ian Gulliver
2026-10-18 15:05:28 -07:00
parent 2658dcbfc4
commit 1ef3594016
8 changed files with 480 additions and 201 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ var (
quoted = regexp.MustCompile(`"[^"\n]*"|“[^”\n]*”`)
blockquote = regexp.MustCompile(`(?m)^\s*>.*$`)
tableRow = regexp.MustCompile(`(?m)^\s*\|.*$`)
details = regexp.MustCompile(`(?s)<details>.*?</details>`)
details = regexp.MustCompile(`(?s)<details>.*?</details>`)
paragraphs = regexp.MustCompile(`\n\s*\n`)
endsQuestion = regexp.MustCompile(`\?[*_)\]]*(\s|$)`)
)
+5 -2
View File
@@ -20,6 +20,7 @@ import (
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
"g.fc.run/misc/byre/internal/terminal"
"g.fc.run/misc/byre/internal/workflow"
)
@@ -42,6 +43,7 @@ type Manager struct {
status func(context.Context) (api.Status, error)
addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error)
clients *clients.Registry
terminals *terminal.Terminals
mu sync.Mutex
live map[string]*live
@@ -123,7 +125,7 @@ func (l *live) implementer() bool {
return l.owner == "" && l.reviewID == ""
}
func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome string, status func(context.Context) (api.Status, error), addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error), clientRegistry *clients.Registry) *Manager {
func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome string, status func(context.Context) (api.Status, error), addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error), clientRegistry *clients.Registry, terminals *terminal.Terminals) *Manager {
m := &Manager{
store: st,
worktreeRoot: worktreeRoot,
@@ -133,6 +135,7 @@ func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome string, s
status: status,
addProject: addProject,
clients: clientRegistry,
terminals: terminals,
live: map[string]*live{},
starting: map[string]*start{},
deliverLocks: map[string]*sessionLock{},
@@ -732,7 +735,7 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api
if err != nil {
return nil, err
}
tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.takeSnapshotTool(l)}, append(m.subtaskTools(l), m.clientTools(l)...)...)
tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.takeSnapshotTool(l), m.terminalReadTool(l), m.terminalTypeTool(l)}, append(m.subtaskTools(l), m.clientTools(l)...)...)
lead := true
if sess.Role == api.Implementer {
g += longTitleGuidance(task)
+150
View File
@@ -0,0 +1,150 @@
package agents
import (
"bytes"
"context"
"encoding/json"
"fmt"
"sync/atomic"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/terminal"
)
const (
typeQuiet = 500 * time.Millisecond
typeMaxWait = 10 * time.Second
typeKeep = 256 << 10
)
var agentTerminalSize = api.TerminalSize{Cols: 120, Rows: 40}
func (m *Manager) terminalReadTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "terminal_read",
Description: "Read your task's terminal: the shell in this task's worktree that the human uses from the Terminal tab, and that terminal_type types into. " +
"Returns its recent output as plain text, escape sequences removed, at most the last 64 KiB. " +
"Full-screen programs (vim, top, less) come out garbled, since this is the output stream, not a picture of the screen.",
InputSchema: json.RawMessage(`{"type": "object", "properties": {}}`),
Handler: func(ctx context.Context, args json.RawMessage) (string, error) {
term := m.terminals.Get(l.taskID)
if term == nil {
return "No shell is running in this task's terminal.", nil
}
out := terminal.Text(term.Backlog())
exit, exited := term.ExitStatus()
if exited {
out += "\n[shell ended: " + exit + "]"
}
return out, nil
},
}
}
type terminalTypeArgs struct {
Text string `json:"text"`
}
func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "terminal_type",
Description: "Type into your task's terminal: the shell in this task's worktree that the human uses from the Terminal tab, starting it when none is running. " +
"text goes to the shell exactly as keystrokes, so end a command with \"\\n\" to run it, and send control characters for keys (\"\\u0003\" is Ctrl-C). " +
"The human sees everything you type and its output. " +
"Returns the output that followed, as terminal_read does, once the terminal has been quiet for half a second or after 10 seconds; read the rest of a longer command's output with terminal_read.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"text": {"type": "string", "description": "The keystrokes to send."}
},
"required": ["text"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
args := terminalTypeArgs{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed terminal_type call: %w", err)
}
task, err := m.store.GetTask(ctx, l.taskID)
if err != nil {
return "", err
}
if task.Status != api.TaskStatusActive {
return "", fmt.Errorf("task %s is %s", task.Id, task.Status)
}
term, err := m.terminals.Attach(task, agentTerminalSize)
if err != nil {
return "", err
}
return typeAndWait(ctx, term, args.Text)
},
}
}
func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (string, error) {
_, out := term.Subscribe()
if out == nil {
exit, _ := term.ExitStatus()
return "", fmt.Errorf("the shell ended (%s); type again to start a new one", exit)
}
defer term.Unsubscribe(out)
stop := atomic.Bool{}
defer stop.Store(true)
typed := atomic.Int64{}
written := make(chan error, 1)
go func() {
// One byte per write: a program that stops reading blocks a write, and the PTY can't be given a deadline, so this keeps what arrives after we give up to one byte.
for i := 0; i < len(text) && !stop.Load(); i++ {
err := term.Write([]byte{text[i]})
if err != nil {
written <- err
return
}
typed.Add(1)
}
written <- nil
}()
got := []byte{}
result := func() string {
res := terminal.Text(got)
exit, exited := term.ExitStatus()
if exited {
res += "\n[shell ended: " + exit + "]"
}
if n := typed.Load(); n < int64(len(text)) {
res += fmt.Sprintf("\n[only %d of %d bytes were typed: the terminal stopped reading input]", n, len(text))
}
return res
}
deadline := time.After(typeMaxWait)
quiet := time.NewTimer(typeQuiet)
defer quiet.Stop()
for {
select {
case <-ctx.Done():
return "", ctx.Err()
case err := <-written:
if err != nil {
return "", err
}
written = nil
case <-deadline:
return result(), nil
case <-quiet.C:
return result(), nil
case b, open := <-out:
if !open {
return result(), nil
}
got = append(got, b...)
if len(got) > 2*typeKeep {
got = bytes.Clone(got[len(got)-typeKeep:])
}
quiet.Reset(typeQuiet)
}
}
}
+6 -5
View File
@@ -19,6 +19,7 @@ import (
"g.fc.run/misc/byre/internal/clients"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/store"
"g.fc.run/misc/byre/internal/terminal"
"g.fc.run/misc/byre/internal/todo"
"g.fc.run/misc/byre/internal/workflow"
)
@@ -28,7 +29,7 @@ type Server struct {
agents *agents.Manager
hub *hub
clients *clients.Registry
terminals *terminals
terminals *terminal.Terminals
startedAt time.Time
usersMu sync.Mutex
@@ -45,7 +46,7 @@ func New(dataDir string, portFirst int) (*Server, error) {
s := &Server{
hub: newHub(),
terminals: newTerminals(),
terminals: terminal.New(),
startedAt: time.Now().UTC(),
users: map[string]string{},
}
@@ -68,7 +69,7 @@ func New(dataDir string, portFirst int) (*Server, error) {
}
}
s.agents = agents.New(s.store, filepath.Join(dataDir, "worktrees"), filepath.Join(dataDir, "leads"), filepath.Join(dataDir, "hands"), filepath.Join(dataDir, "codex"), s.status, s.createProject, s.clients)
s.agents = agents.New(s.store, filepath.Join(dataDir, "worktrees"), filepath.Join(dataDir, "leads"), filepath.Join(dataDir, "hands"), filepath.Join(dataDir, "codex"), s.status, s.createProject, s.clients, s.terminals)
err = s.agents.Recover(context.Background())
if err != nil {
s.store.Close()
@@ -86,14 +87,14 @@ func (s *Server) publish(events []api.Event) {
s.agents.Observe(events)
for _, ev := range events {
if (ev.Kind == "task.merged" || ev.Kind == "task.discarded") && ev.TaskId != nil {
s.terminals.close(*ev.TaskId)
s.terminals.Close(*ev.TaskId)
go s.agents.Unexpose(context.Background(), *ev.TaskId)
}
}
}
func (s *Server) Close() error {
s.terminals.closeAll()
s.terminals.CloseAll()
s.agents.Close()
return s.store.Close()
}
+13 -191
View File
@@ -1,207 +1,31 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"sync"
"syscall"
"github.com/coder/websocket"
"github.com/creack/pty"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/terminal"
)
const (
terminalBacklog = 256 << 10
terminalClientBuf = 256
terminalReadLimit = 1 << 20
terminalMaxSize = 1 << 15
terminalExited = websocket.StatusCode(4000)
)
type terminals struct {
mu sync.Mutex
byTask map[string]*terminal
}
type terminal struct {
ptmx *os.File
cmd *exec.Cmd
mu sync.Mutex
backlog []byte
clients map[chan []byte]bool
exit string
exited bool
}
func newTerminals() *terminals {
return &terminals{byTask: map[string]*terminal{}}
}
func (ts *terminals) attach(t api.Task, size api.TerminalSize) (*terminal, error) {
ts.mu.Lock()
defer ts.mu.Unlock()
term := ts.byTask[t.Id]
if term != nil {
return term, nil
}
shell := os.Getenv("SHELL")
if shell == "" {
return nil, errors.New("SHELL is not set in byre's environment")
}
cmd := exec.Command(shell, "-l")
cmd.Dir = t.WorktreePath
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", fmt.Sprintf("PORT=%d", t.PortBase))
ptmx, err := pty.StartWithSize(cmd, winsize(size))
if err != nil {
return nil, fmt.Errorf("start %s: %w", shell, err)
}
term = &terminal{ptmx: ptmx, cmd: cmd, clients: map[chan []byte]bool{}}
ts.byTask[t.Id] = term
slog.Info("terminal started", "task", t.Id, "shell", shell, "pid", cmd.Process.Pid)
go func() {
term.pump()
ts.mu.Lock()
if ts.byTask[t.Id] == term {
delete(ts.byTask, t.Id)
}
ts.mu.Unlock()
slog.Info("terminal ended", "task", t.Id, "exit", term.exit)
}()
return term, nil
}
func (ts *terminals) close(taskID string) {
ts.mu.Lock()
term := ts.byTask[taskID]
delete(ts.byTask, taskID)
ts.mu.Unlock()
if term != nil {
term.kill()
}
}
func (ts *terminals) closeAll() {
ts.mu.Lock()
all := ts.byTask
ts.byTask = map[string]*terminal{}
ts.mu.Unlock()
for _, term := range all {
term.kill()
}
}
func winsize(size api.TerminalSize) *pty.Winsize {
return &pty.Winsize{Cols: uint16(size.Cols), Rows: uint16(size.Rows)}
}
func validSize(size api.TerminalSize) bool {
return size.Cols >= 1 && size.Rows >= 1 && size.Cols <= terminalMaxSize && size.Rows <= terminalMaxSize
}
func (term *terminal) pump() {
buf := make([]byte, 32<<10)
for {
n, err := term.ptmx.Read(buf)
if n > 0 {
term.output(bytes.Clone(buf[:n]))
}
if err != nil {
break
}
}
err := term.cmd.Wait()
term.ptmx.Close()
exit := fmt.Sprint(err)
if term.cmd.ProcessState != nil {
exit = term.cmd.ProcessState.String()
}
term.mu.Lock()
defer term.mu.Unlock()
term.exit = exit
term.exited = true
for c := range term.clients {
close(c)
}
term.clients = nil
}
func (term *terminal) output(b []byte) {
term.mu.Lock()
defer term.mu.Unlock()
term.backlog = append(term.backlog, b...)
if len(term.backlog) > 2*terminalBacklog {
term.backlog = bytes.Clone(term.backlog[len(term.backlog)-terminalBacklog:])
}
for c := range term.clients {
select {
case c <- b:
default:
delete(term.clients, c)
close(c)
}
}
}
func (term *terminal) subscribe() ([]byte, chan []byte) {
term.mu.Lock()
defer term.mu.Unlock()
if term.exited {
return nil, nil
}
c := make(chan []byte, terminalClientBuf)
term.clients[c] = true
return bytes.Clone(term.backlog[max(0, len(term.backlog)-terminalBacklog):]), c
}
func (term *terminal) unsubscribe(c chan []byte) {
term.mu.Lock()
defer term.mu.Unlock()
if term.clients[c] {
delete(term.clients, c)
close(c)
}
}
func (term *terminal) exitStatus() (string, bool) {
term.mu.Lock()
defer term.mu.Unlock()
return term.exit, term.exited
}
func (term *terminal) kill() {
err := syscall.Kill(-term.cmd.Process.Pid, syscall.SIGHUP)
if err != nil && !errors.Is(err, syscall.ESRCH) {
slog.Warn("hang up terminal", "pid", term.cmd.Process.Pid, "err", err)
}
term.ptmx.Close()
}
func (s *Server) GetTaskTerminal(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskTerminalParams) {
t, _, ok := s.activeTask(w, r, taskID)
if !ok {
return
}
size := api.TerminalSize{Cols: params.Cols, Rows: params.Rows}
if !validSize(size) {
writeError(w, http.StatusBadRequest, fmt.Errorf("cols and rows must be between 1 and %d", terminalMaxSize))
if !terminal.ValidSize(size) {
writeError(w, http.StatusBadRequest, fmt.Errorf("cols and rows must be between 1 and %d", terminal.MaxSize))
return
}
@@ -213,20 +37,21 @@ func (s *Server) GetTaskTerminal(w http.ResponseWriter, r *http.Request, taskID
defer conn.CloseNow()
conn.SetReadLimit(terminalReadLimit)
term, err := s.terminals.attach(t, size)
term, err := s.terminals.Attach(t, size)
if err != nil {
slog.Error("start terminal", "task", taskID, "err", err)
conn.Close(websocket.StatusInternalError, "couldn't start the shell; see byre's log")
return
}
term.Resize(size)
backlog, out := term.subscribe()
backlog, out := term.Subscribe()
if out == nil {
exit, _ := term.exitStatus()
exit, _ := term.ExitStatus()
conn.Close(terminalExited, exit)
return
}
defer term.unsubscribe(out)
defer term.Unsubscribe(out)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
@@ -253,7 +78,7 @@ func (s *Server) GetTaskTerminal(w http.ResponseWriter, r *http.Request, taskID
}
continue
}
exit, exited := term.exitStatus()
exit, exited := term.ExitStatus()
if exited {
conn.Close(terminalExited, exit)
return
@@ -264,7 +89,7 @@ func (s *Server) GetTaskTerminal(w http.ResponseWriter, r *http.Request, taskID
}
}
func terminalInput(ctx context.Context, conn *websocket.Conn, term *terminal, acted func()) {
func terminalInput(ctx context.Context, conn *websocket.Conn, term *terminal.Terminal, acted func()) {
for {
typ, b, err := conn.Read(ctx)
if err != nil {
@@ -272,7 +97,7 @@ func terminalInput(ctx context.Context, conn *websocket.Conn, term *terminal, ac
}
if typ == websocket.MessageBinary {
acted()
_, err = term.ptmx.Write(b)
err = term.Write(b)
if err != nil {
slog.Debug("terminal input", "err", err)
}
@@ -280,13 +105,10 @@ func terminalInput(ctx context.Context, conn *websocket.Conn, term *terminal, ac
}
size := api.TerminalSize{}
err = json.Unmarshal(b, &size)
if err != nil || !validSize(size) {
if err != nil || !terminal.ValidSize(size) {
conn.Close(websocket.StatusUnsupportedData, "resize must be a TerminalSize")
return
}
err = pty.Setsize(term.ptmx, winsize(size))
if err != nil {
slog.Debug("terminal resize", "err", err)
}
term.Resize(size)
}
}
+213
View File
@@ -0,0 +1,213 @@
package terminal
import (
"bytes"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"sync"
"syscall"
"github.com/creack/pty"
"g.fc.run/misc/byre/internal/api"
)
const (
backlogSize = 256 << 10
clientBuf = 256
MaxSize = 1 << 15
)
type Terminals struct {
mu sync.Mutex
byTask map[string]*Terminal
}
type Terminal struct {
ptmx *os.File
cmd *exec.Cmd
mu sync.Mutex
backlog []byte
clients map[chan []byte]bool
exit string
exited bool
}
func New() *Terminals {
return &Terminals{byTask: map[string]*Terminal{}}
}
func (ts *Terminals) Get(taskID string) *Terminal {
ts.mu.Lock()
defer ts.mu.Unlock()
return ts.byTask[taskID]
}
func (ts *Terminals) Attach(t api.Task, size api.TerminalSize) (*Terminal, error) {
ts.mu.Lock()
defer ts.mu.Unlock()
term := ts.byTask[t.Id]
if term != nil {
return term, nil
}
shell := os.Getenv("SHELL")
if shell == "" {
return nil, errors.New("SHELL is not set in byre's environment")
}
cmd := exec.Command(shell, "-l")
cmd.Dir = t.WorktreePath
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", fmt.Sprintf("PORT=%d", t.PortBase))
ptmx, err := pty.StartWithSize(cmd, winsize(size))
if err != nil {
return nil, fmt.Errorf("start %s: %w", shell, err)
}
term = &Terminal{ptmx: ptmx, cmd: cmd, clients: map[chan []byte]bool{}}
ts.byTask[t.Id] = term
slog.Info("terminal started", "task", t.Id, "shell", shell, "pid", cmd.Process.Pid)
go func() {
term.pump()
ts.mu.Lock()
if ts.byTask[t.Id] == term {
delete(ts.byTask, t.Id)
}
ts.mu.Unlock()
slog.Info("terminal ended", "task", t.Id, "exit", term.exit)
}()
return term, nil
}
func (ts *Terminals) Close(taskID string) {
ts.mu.Lock()
term := ts.byTask[taskID]
delete(ts.byTask, taskID)
ts.mu.Unlock()
if term != nil {
term.kill()
}
}
func (ts *Terminals) CloseAll() {
ts.mu.Lock()
all := ts.byTask
ts.byTask = map[string]*Terminal{}
ts.mu.Unlock()
for _, term := range all {
term.kill()
}
}
func winsize(size api.TerminalSize) *pty.Winsize {
return &pty.Winsize{Cols: uint16(size.Cols), Rows: uint16(size.Rows)}
}
func ValidSize(size api.TerminalSize) bool {
return size.Cols >= 1 && size.Rows >= 1 && size.Cols <= MaxSize && size.Rows <= MaxSize
}
func (term *Terminal) pump() {
buf := make([]byte, 32<<10)
for {
n, err := term.ptmx.Read(buf)
if n > 0 {
term.output(bytes.Clone(buf[:n]))
}
if err != nil {
break
}
}
err := term.cmd.Wait()
term.ptmx.Close()
exit := fmt.Sprint(err)
if term.cmd.ProcessState != nil {
exit = term.cmd.ProcessState.String()
}
term.mu.Lock()
defer term.mu.Unlock()
term.exit = exit
term.exited = true
for c := range term.clients {
close(c)
}
term.clients = nil
}
func (term *Terminal) output(b []byte) {
term.mu.Lock()
defer term.mu.Unlock()
term.backlog = append(term.backlog, b...)
if len(term.backlog) > 2*backlogSize {
term.backlog = bytes.Clone(term.backlog[len(term.backlog)-backlogSize:])
}
for c := range term.clients {
select {
case c <- b:
default:
delete(term.clients, c)
close(c)
}
}
}
func (term *Terminal) Backlog() []byte {
term.mu.Lock()
defer term.mu.Unlock()
return bytes.Clone(term.backlog[max(0, len(term.backlog)-backlogSize):])
}
func (term *Terminal) Subscribe() ([]byte, chan []byte) {
term.mu.Lock()
defer term.mu.Unlock()
if term.exited {
return nil, nil
}
c := make(chan []byte, clientBuf)
term.clients[c] = true
return bytes.Clone(term.backlog[max(0, len(term.backlog)-backlogSize):]), c
}
func (term *Terminal) Unsubscribe(c chan []byte) {
term.mu.Lock()
defer term.mu.Unlock()
if term.clients[c] {
delete(term.clients, c)
close(c)
}
}
func (term *Terminal) ExitStatus() (string, bool) {
term.mu.Lock()
defer term.mu.Unlock()
return term.exit, term.exited
}
func (term *Terminal) Write(b []byte) error {
_, err := term.ptmx.Write(b)
return err
}
func (term *Terminal) Resize(size api.TerminalSize) {
err := pty.Setsize(term.ptmx, winsize(size))
if err != nil {
slog.Debug("terminal resize", "err", err)
}
}
func (term *Terminal) kill() {
err := syscall.Kill(-term.cmd.Process.Pid, syscall.SIGHUP)
if err != nil && !errors.Is(err, syscall.ESRCH) {
slog.Warn("hang up terminal", "pid", term.cmd.Process.Pid, "err", err)
}
term.ptmx.Close()
}
+87
View File
@@ -0,0 +1,87 @@
package terminal
import (
"strings"
"unicode/utf8"
)
const textLimit = 64 << 10
func Text(b []byte) string {
out := strings.Builder{}
line := []rune{}
col := 0
flush := func() {
out.WriteString(strings.TrimRight(string(line), " "))
out.WriteByte('\n')
line = line[:0]
col = 0
}
for i := 0; i < len(b); {
c := b[i]
switch {
case c == 0x1b:
end, final := skipEscape(b, i)
if final == 'K' {
line = line[:min(col, len(line))]
}
i = end + 1
continue
case c == '\n':
flush()
case c == '\r':
col = 0
case c == '\b':
col = max(0, col-1)
case c == '\t' || c >= 0x20 && c != 0x7f:
r, n := utf8.DecodeRune(b[i:])
for len(line) < col {
line = append(line, ' ')
}
if col < len(line) {
line[col] = r
} else {
line = append(line, r)
}
col++
i += n
continue
}
i++
}
s := out.String() + strings.TrimRight(string(line), " ")
if len(s) > textLimit {
s = s[len(s)-textLimit:]
s = s[strings.IndexByte(s, '\n')+1:]
}
return s
}
func skipEscape(b []byte, i int) (int, byte) {
if i+1 >= len(b) {
return i, 0
}
i++
switch b[i] {
case '[':
for i++; i < len(b); i++ {
if b[i] >= 0x40 && b[i] <= 0x7e {
return i, b[i]
}
}
return len(b) - 1, 0
case ']', 'P', '_', '^':
for i++; i < len(b); i++ {
if b[i] == 0x07 {
return i, 0
}
if b[i] == 0x1b && i+1 < len(b) && b[i+1] == '\\' {
return i + 1, 0
}
}
return len(b) - 1, 0
case '(', ')', '*', '+', '#', '%':
return min(i+1, len(b)-1), 0
}
return i, 0
}