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 { // Writing the prompt aborts with the context error when the run is // cancelled or times out mid-setup. Emit a single cancelled event and // return ErrRunCancelled so cancellation is not reported as a setup error. if ctx.Err() != nil { _ = sink.Emit(context.Background(), runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeCancelled, Message: cancelEventForContext(ctx.Err()), Timestamp: time.Now(), }) return runtime.ErrRunCancelled } 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 }