package cli import ( "bufio" "context" "fmt" "io" "strings" "time" "go.uber.org/zap" "iop/apps/node/internal/terminal" "iop/packages/go/config" ) func startProfileSession(_ context.Context, key sessionKey, profile config.CLIProfileConf, workspace string, logger *zap.Logger) (*profileSession, error) { if profile.Command == "" { return nil, fmt.Errorf("target %q has no command", key.target) } dir, err := prepareWorkspaceDir(workspace) if err != nil { return nil, err } outputCh := make(chan cliOutput, 1024) doneCh := make(chan error, 1) sess := &profileSession{ key: key, name: key.target, profile: profile, output: outputCh, done: doneCh, } if profile.Terminal { err = startTerminalProfileSession(sess, profile, dir, outputCh, doneCh) } else { err = startNonTerminalProfileSession(sess, profile, dir, outputCh, doneCh) } if err != nil { return nil, err } if profile.StartupIdleTimeoutMS > 0 { drainUntilIdle(sess.output, sess.input, time.Duration(profile.StartupIdleTimeoutMS)*time.Millisecond, logger, key, profile) } if err := checkEarlyExit(sess); err != nil { return nil, err } return sess, nil } func startTerminalProfileSession(sess *profileSession, profile config.CLIProfileConf, dir string, outputCh chan cliOutput, doneCh chan error) error { opts := terminal.Options{ Command: profile.Command, Args: profile.Args, Env: profile.Env, Rows: terminalRows, Cols: terminalCols, Dir: dir, } core, err := terminal.StartSession(context.Background(), opts) if err != nil { return err } sess.core = core sess.input = sessionWriter{sess: core} sess.closeFn = core.Close go func() { for out := range core.Output() { outputCh <- cliOutput{text: out.Text} } err := <-core.Done() doneCh <- err close(outputCh) }() return nil } func startNonTerminalProfileSession(sess *profileSession, profile config.CLIProfileConf, dir string, outputCh chan cliOutput, doneCh chan error) error { cmd, err := buildCmdWithoutContext(profile.Command, profile.Args, profile.Env, dir) if err != nil { return err } stdin, err := cmd.StdinPipe() if err != nil { return fmt.Errorf("stdin pipe: %w", err) } stdout, err := cmd.StdoutPipe() if err != nil { _ = stdin.Close() return fmt.Errorf("stdout pipe: %w", err) } if err := cmd.Start(); err != nil { _ = stdin.Close() return fmt.Errorf("start: %w", err) } sess.cmd = cmd sess.input = stdin sess.closeFn = stdin.Close go func() { scanner := bufio.NewScanner(stdout) for scanner.Scan() { line := scanner.Text() text := line + "\n" sess.appendTail(text) outputCh <- cliOutput{text: text, markerLine: line} } doneCh <- cmd.Wait() close(outputCh) }() return nil } func checkEarlyExit(sess *profileSession) error { select { case err := <-sess.done: _ = sess.closeFn() if sess.cmd != nil && sess.cmd.Process != nil { _ = sess.cmd.Process.Kill() } tail := sess.getTail() if err == nil { if tail != "" { return fmt.Errorf("process exited during startup: %s", tail) } return fmt.Errorf("process exited during startup") } if tail != "" { return fmt.Errorf("process exited during startup: %w (recent output: %q)", err, tail) } return fmt.Errorf("process exited during startup: %w", err) default: return nil } } func drainUntilIdle(outputCh <-chan cliOutput, input io.Writer, timeout time.Duration, logger *zap.Logger, key sessionKey, profile config.CLIProfileConf) { timer := time.NewTimer(timeout) defer timer.Stop() var startupBuf strings.Builder acceptedClaudeWorkspaceTrust := false acceptedClaudeBypassWarning := false for { select { case out, ok := <-outputCh: if !ok { return } if profile.Terminal && out.text != "" { appendBounded(&startupBuf, out.text, 8192) rawStr := startupBuf.String() if !acceptedClaudeWorkspaceTrust && shouldAcceptClaudeWorkspaceTrust(rawStr) { if _, err := io.WriteString(input, "\r"); err != nil { logger.Warn("cli adapter: accept claude workspace trust", zap.String("target", key.target), zap.Error(err)) } else { acceptedClaudeWorkspaceTrust = true logger.Info("cli adapter: accepted claude workspace trust", zap.String("target", key.target)) startupBuf.Reset() } } else if !acceptedClaudeBypassWarning && shouldAcceptClaudeBypassWarning(rawStr) { if _, err := io.WriteString(input, "\x1b[B\r"); err != nil { logger.Warn("cli adapter: accept claude bypass warning", zap.String("target", key.target), zap.Error(err)) } else { acceptedClaudeBypassWarning = true logger.Info("cli adapter: accepted claude bypass warning", zap.String("target", key.target)) } } } if !timer.Stop() { select { case <-timer.C: default: } } timer.Reset(timeout) logger.Debug("cli adapter: startup drain", zap.String("target", key.target), zap.String("session", key.sessionID)) case <-timer.C: return } } } func drainSessionUntilIdle(outputCh <-chan cliOutput, timeout time.Duration, logger *zap.Logger, key sessionKey) { timer := time.NewTimer(timeout) defer timer.Stop() for { select { case _, ok := <-outputCh: if !ok { return } if !timer.Stop() { select { case <-timer.C: default: } } timer.Reset(timeout) logger.Debug("cli adapter: cancel drain", zap.String("target", key.target), zap.String("session", key.sessionID)) case <-timer.C: return } } } func drainPersistentDone(sess *profileSession) error { select { case err := <-sess.done: return err default: return nil } } type sessionWriter struct { sess terminal.Session } func (w sessionWriter) Write(p []byte) (n int, err error) { err = w.sess.WriteInput(context.Background(), p) if err != nil { return 0, err } return len(p), nil }