iop/apps/node/internal/adapters/cli/persistent.go

548 lines
15 KiB
Go

package cli
import (
"context"
"fmt"
"io"
"regexp"
"strings"
"time"
"unicode"
"go.uber.org/zap"
"iop/apps/node/internal/adapters/cli/status"
"iop/apps/node/internal/runtime"
"iop/packages/go/config"
)
const (
terminalInputDelay = 2 * time.Millisecond
terminalRows = 720
terminalCols = 1024
)
func emitRuntimeError(ctx context.Context, sink runtime.EventSink, runID, msg string) error {
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: runID,
Type: runtime.EventTypeError,
Error: msg,
Timestamp: time.Now(),
})
return fmt.Errorf("cli adapter: %s", msg)
}
type completionMatcher struct {
line string
re *regexp.Regexp
}
func newCompletionMatcher(m config.CompletionMarkerConf) (completionMatcher, error) {
var cm completionMatcher
cm.line = m.Line
if m.Regex != "" {
re, err := regexp.Compile(m.Regex)
if err != nil {
return completionMatcher{}, fmt.Errorf("completion_marker regex: %w", err)
}
cm.re = re
}
return cm, nil
}
func (m completionMatcher) match(line string) bool {
if m.line != "" && line == m.line {
return true
}
if m.re != nil && m.re.MatchString(line) {
return true
}
return false
}
type persistentRun struct {
executor *persistentExecutor
ctx context.Context
spec runtime.ExecutionSpec
profile config.CLIProfileConf
sink runtime.EventSink
sess *profileSession
matcher completionMatcher
prompt string
idleTimeout time.Duration
outputFilter persistentOutputFilter
waitForFilteredMessage bool
baselineAssistant string
outputTokens int
markerBuf strings.Builder
claudePromptReplayCount int
idleTimer *time.Timer
idleC <-chan time.Time
}
func (r *persistentRun) setup() error {
r.prompt = extractPrompt(r.spec.Input)
r.idleTimeout = time.Duration(r.profile.ResponseIdleTimeoutMS) * time.Millisecond
if r.idleTimeout <= 0 {
r.idleTimeout = 1500 * time.Millisecond
}
targetName := cliTargetName(r.spec)
r.waitForFilteredMessage = r.profile.Terminal && isClaudeTerminalProfile(targetName, r.profile)
if r.waitForFilteredMessage {
r.baselineAssistant, _ = latestClaudeAssistantMessageFromCleanOutput(cleanClaudeTerminalOutput(r.sess.getTail()))
}
r.outputFilter = newPersistentOutputFilter(targetName, r.profile, r.prompt, r.baselineAssistant)
_ = r.sink.Emit(r.ctx, runtime.RuntimeEvent{
RunID: r.spec.RunID,
Type: runtime.EventTypeStart,
Timestamp: time.Now(),
})
if r.sess.core != nil {
if err := r.sess.core.WritePrompt(r.ctx, r.prompt); err != nil {
return fmt.Errorf("write prompt: %v", err)
}
} else {
if err := writePrompt(r.ctx, r.sess.input, r.prompt, r.profile); err != nil {
return fmt.Errorf("write prompt: %v", err)
}
}
return nil
}
func (r *persistentRun) handleCancel() error {
drainSessionUntilIdle(r.sess.output, r.idleTimeout, r.executor.cli.logger, r.sess.key)
_ = r.sink.Emit(context.Background(), runtime.RuntimeEvent{
RunID: r.spec.RunID,
Type: runtime.EventTypeCancelled,
Message: cancelEventForContext(r.ctx.Err()),
Timestamp: time.Now(),
})
return runtime.ErrRunCancelled
}
func (r *persistentRun) handleOutput(out cliOutput) (bool, error) {
if r.waitForFilteredMessage {
if msg, cancelled := claudeTerminalCancelMessage(r.sess.getTail()); cancelled {
r.executor.removePersistentSession(r.sess)
_ = closeProfileSession(context.Background(), r.sess)
_ = r.sink.Emit(r.ctx, runtime.RuntimeEvent{
RunID: r.spec.RunID,
Type: runtime.EventTypeCancelled,
Message: msg,
Timestamp: time.Now(),
})
return true, fmt.Errorf("%w: %s", runtime.ErrRunCancelled, msg)
}
}
if delta := r.outputFilter.Filter(out.text); delta != "" {
r.outputTokens += len(strings.Fields(delta))
_ = r.sink.Emit(r.ctx, runtime.RuntimeEvent{
RunID: r.spec.RunID,
Type: runtime.EventTypeDelta,
Delta: delta,
Timestamp: time.Now(),
})
}
var markerLines []string
if out.markerLine != "" {
markerLines = append(markerLines, out.markerLine)
} else {
markerLines = consumeCompleteLines(&r.markerBuf, out.text)
}
if r.matcher.matchAny(markerLines) {
if delta := r.outputFilter.Flush(); delta != "" {
r.outputTokens += len(strings.Fields(delta))
_ = r.sink.Emit(r.ctx, runtime.RuntimeEvent{
RunID: r.spec.RunID,
Type: runtime.EventTypeDelta,
Delta: delta,
Timestamp: time.Now(),
})
}
err := r.sink.Emit(r.ctx, runtime.RuntimeEvent{
RunID: r.spec.RunID,
Type: runtime.EventTypeComplete,
Message: "completion-marker",
Usage: &runtime.UsageStats{
InputTokens: len(strings.Fields(r.prompt)),
OutputTokens: r.outputTokens,
},
Timestamp: time.Now(),
})
return true, err
}
if r.waitForFilteredMessage || r.outputFilter.HasOutput() || !r.profile.Terminal {
if r.idleTimer == nil {
r.idleTimer = time.NewTimer(r.idleTimeout)
r.idleC = r.idleTimer.C
} else {
if !r.idleTimer.Stop() {
select {
case <-r.idleTimer.C:
default:
}
}
r.idleTimer.Reset(r.idleTimeout)
}
}
return false, nil
}
func (r *persistentRun) handleIdleTimeout() (bool, error) {
if delta := r.outputFilter.Flush(); delta != "" {
r.outputTokens += len(strings.Fields(delta))
_ = r.sink.Emit(r.ctx, runtime.RuntimeEvent{
RunID: r.spec.RunID,
Type: runtime.EventTypeDelta,
Delta: delta,
Timestamp: time.Now(),
})
} else if r.waitForFilteredMessage && !r.outputFilter.HasOutput() {
if r.claudePromptReplayCount == 0 && claudeTerminalReadyForInput(r.sess.getTail()) {
r.claudePromptReplayCount++
r.executor.cli.logger.Info("cli adapter: replaying claude prompt after ready screen",
zap.String("target", cliTargetName(r.spec)),
zap.String("session", r.sess.key.sessionID))
var err error
if r.sess.core != nil {
err = r.sess.core.WritePrompt(r.ctx, r.prompt)
} else {
err = writePrompt(r.ctx, r.sess.input, r.prompt, r.profile)
}
if err != nil {
return true, emitRuntimeError(r.ctx, r.sink, r.spec.RunID, fmt.Sprintf("replay prompt: %v", err))
}
}
r.idleTimer.Reset(r.idleTimeout)
return false, nil
}
if r.waitForFilteredMessage && r.outputFilter.HasOutput() && r.outputFilter.CompletionMessage() == "" {
r.idleTimer.Reset(r.idleTimeout)
return false, nil
}
completeMessage := "idle-timeout"
if msg := r.outputFilter.CompletionMessage(); msg != "" {
completeMessage = msg
}
err := r.sink.Emit(r.ctx, runtime.RuntimeEvent{
RunID: r.spec.RunID,
Type: runtime.EventTypeComplete,
Message: completeMessage,
Usage: &runtime.UsageStats{
InputTokens: len(strings.Fields(r.prompt)),
OutputTokens: r.outputTokens,
},
Timestamp: time.Now(),
})
return true, err
}
func (e *persistentExecutor) Execute(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error {
sess, err := e.resolveSession(ctx, spec, profile)
if err != nil {
return emitReturnedError(ctx, sink, spec.RunID, err)
}
matcher, err := newCompletionMatcher(profile.CompletionMarker)
if err != nil {
return emitRuntimeError(ctx, sink, spec.RunID, err.Error())
}
sess.mu.Lock()
defer sess.mu.Unlock()
run := &persistentRun{
executor: e,
ctx: ctx,
spec: spec,
profile: profile,
sink: sink,
sess: sess,
matcher: matcher,
}
if err := run.setup(); err != nil {
return emitRuntimeError(ctx, sink, spec.RunID, err.Error())
}
defer func() {
if run.idleTimer != nil {
run.idleTimer.Stop()
}
}()
for {
select {
case <-ctx.Done():
return run.handleCancel()
case out, ok := <-sess.output:
if !ok {
err := drainPersistentDone(sess)
return e.emitPersistentExit(ctx, sink, spec.RunID, cliTargetName(spec), profile, sess, err)
}
done, err := run.handleOutput(out)
if done {
return err
}
case <-run.idleC:
done, err := run.handleIdleTimeout()
if done {
return err
}
case err := <-sess.done:
return e.emitPersistentExit(ctx, sink, spec.RunID, cliTargetName(spec), profile, sess, err)
}
}
}
func (m completionMatcher) matchAny(lines []string) bool {
for _, line := range lines {
if m.match(line) {
return true
}
}
return false
}
func consumeCompleteLines(buf *strings.Builder, text string) []string {
if text == "" {
return nil
}
buf.WriteString(text)
raw := buf.String()
start := 0
var lines []string
for i, r := range raw {
if r != '\n' {
continue
}
lines = append(lines, strings.TrimRight(raw[start:i], "\r"))
start = i + 1
}
if start > 0 {
buf.Reset()
buf.WriteString(raw[start:])
} else if len(raw) > 8192 {
buf.Reset()
buf.WriteString(raw[len(raw)-8192:])
}
return lines
}
func promptTerminator(profile config.CLIProfileConf) string {
if profile.Terminal {
return "\r"
}
return "\n"
}
func writePrompt(ctx context.Context, input io.Writer, prompt string, profile config.CLIProfileConf) error {
if !profile.Terminal {
_, err := io.WriteString(input, prompt+promptTerminator(profile))
return err
}
for _, r := range prompt {
if _, err := io.WriteString(input, string(r)); err != nil {
return err
}
timer := time.NewTimer(terminalInputDelay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
_, err := io.WriteString(input, promptTerminator(profile))
return err
}
// resolveSession returns an existing session for the given key or creates one
// when SessionMode allows it.
func (e *persistentExecutor) resolveSession(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf) (*profileSession, error) {
target := cliTargetName(spec)
key := newSessionKey(spec)
e.mu.Lock()
defer e.mu.Unlock()
if sess, ok := e.sessions[key]; ok {
return sess, nil
}
if spec.SessionMode == runtime.SessionModeRequireExisting {
return nil, fmt.Errorf("cli adapter: no persistent session for target %q session %q", target, key.sessionID)
}
sess, err := startProfileSession(ctx, key, profile, spec.Workspace, e.cli.logger)
if err != nil {
return nil, err
}
e.sessions[key] = sess
return sess, nil
}
func appendBounded(buf *strings.Builder, s string, max int) {
buf.WriteString(s)
raw := buf.String()
if len(raw) <= max {
return
}
buf.Reset()
buf.WriteString(raw[len(raw)-max:])
}
func shouldAcceptClaudeWorkspaceTrust(raw string) bool {
compact := compactTerminalText(raw)
return strings.Contains(compact, "quicksafetycheckisthisaproject") &&
strings.Contains(compact, "yesitrustthisfolder")
}
func shouldAcceptClaudeBypassWarning(raw string) bool {
compact := compactTerminalText(raw)
return strings.Contains(compact, "claudecoderunninginbypasspermissionsmode") &&
strings.Contains(compact, "yesiaccept")
}
func claudeTerminalCancelMessage(raw string) (string, bool) {
compact := compactTerminalText(raw)
switch {
case strings.Contains(compact, "youvehityoursessionlimit"):
if line := findClaudeTerminalLine(raw, "session limit"); line != "" {
return line, true
}
return "Claude session limit reached", true
case strings.Contains(compact, "stopandwaitforlimittoreset") && strings.Contains(compact, "upgradeyourplan"):
return "Claude session limit reached; upgrade was not selected", true
default:
return "", false
}
}
func claudeTerminalReadyForInput(raw string) bool {
return claudeScreenHasInputPrompt(status.RenderVisibleScreen(raw, terminalRows, terminalCols))
}
func findClaudeTerminalLine(raw, needle string) string {
needle = strings.ToLower(needle)
for _, line := range strings.Split(cleanClaudeTerminalOutput(raw), "\n") {
line = strings.Join(strings.Fields(line), " ")
if strings.Contains(strings.ToLower(line), needle) {
return line
}
}
return ""
}
func compactTerminalText(s string) string {
s = cleanClaudeTerminalOutput(s)
var b strings.Builder
inEscape := false
for _, r := range s {
if r == '\x1b' {
inEscape = true
continue
}
if inEscape {
if r >= '@' && r <= '~' {
inEscape = false
}
continue
}
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(unicode.ToLower(r))
}
}
return b.String()
}
func (e *persistentExecutor) emitPersistentExit(ctx context.Context, sink runtime.EventSink, runID, targetName string, profile config.CLIProfileConf, sess *profileSession, err error) error {
e.removePersistentSession(sess)
cmdSummary := fmt.Sprintf("%s %s", profile.Command, strings.Join(profile.Args, " "))
tail := sess.getTail()
var msg string
if err != nil {
msg = fmt.Sprintf("persistent session process exited: %s, target=%s, session=%s, command=%q", err.Error(), targetName, sess.key.sessionID, cmdSummary)
} else {
msg = fmt.Sprintf("persistent session process exited unexpectedly: target=%s, session=%s, command=%q", targetName, sess.key.sessionID, cmdSummary)
}
if tail != "" {
msg = fmt.Sprintf("%s, recent output: %q", msg, tail)
}
return emitRuntimeError(ctx, sink, runID, msg)
}
func (e *persistentExecutor) removePersistentSession(sess *profileSession) {
e.mu.Lock()
if s, found := e.sessions[sess.key]; found && s == sess {
delete(e.sessions, sess.key)
}
e.mu.Unlock()
}
func (e *persistentExecutor) stopAllSessions(_ context.Context) error {
var firstErr error
for key, sess := range e.sessions {
if err := closeProfileSession(context.Background(), sess); err != nil && firstErr == nil {
firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.target, key.sessionID, err)
}
}
e.sessions = make(map[sessionKey]*profileSession)
return firstErr
}
func (e *persistentExecutor) Sessions() []sessionListEntry {
e.mu.Lock()
defer e.mu.Unlock()
snaps := make([]sessionListEntry, 0, len(e.sessions))
for k := range e.sessions {
snaps = append(snaps, sessionListEntry{"persistent", k.target, k.sessionID, k.workspace})
}
return snaps
}
// Terminate closes every workspace variant of the given target/sessionID. The
// terminate command carries no workspace, so it terminates all logical sessions
// that share the same target and session id regardless of bound workspace.
func (e *persistentExecutor) Terminate(ctx context.Context, target, sessionID string) (bool, error) {
sid := normalizeSessionID(sessionID)
e.mu.Lock()
var matched []*profileSession
for k, sess := range e.sessions {
if k.target == target && k.sessionID == sid {
matched = append(matched, sess)
delete(e.sessions, k)
}
}
e.mu.Unlock()
if len(matched) == 0 {
return false, nil
}
var firstErr error
for _, sess := range matched {
if err := closeProfileSession(ctx, sess); err != nil && firstErr == nil {
firstErr = err
}
}
return true, firstErr
}
func (e *persistentExecutor) Stop(ctx context.Context) error {
e.mu.Lock()
sessionsCopy := make(map[sessionKey]*profileSession, len(e.sessions))
for key, sess := range e.sessions {
sessionsCopy[key] = sess
}
e.sessions = make(map[sessionKey]*profileSession)
e.mu.Unlock()
var firstErr error
for key, sess := range sessionsCopy {
if err := closeProfileSession(context.Background(), sess); err != nil && firstErr == nil {
firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.target, key.sessionID, err)
}
}
return firstErr
}