Files
cc-connect/core/engine.go
T
chenhg5andClaude b07b18e181 feat(engine): add project-level command disabling support
Implement disabled commands functionality for projects, including config parsing, engine integration, and i18n support. Allows disabling specific commands per-project via config.

generated by llmgit

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-05 14:43:51 +08:00

2903 lines
79 KiB
Go

package core
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
)
const maxPlatformMessageLen = 4000
const (
defaultThinkingMaxLen = 300
defaultToolMaxLen = 500
)
// Slow-operation thresholds. Operations exceeding these durations produce a
// slog.Warn so operators can quickly pinpoint bottlenecks.
const (
slowPlatformSend = 2 * time.Second // platform Reply / Send
slowAgentStart = 5 * time.Second // agent.StartSession
slowAgentClose = 3 * time.Second // agentSession.Close
slowAgentSend = 2 * time.Second // agentSession.Send
slowAgentFirstEvent = 15 * time.Second // time from send to first agent event
)
// VersionInfo is set by main at startup so that /version works.
var VersionInfo string
// CurrentVersion is the semver tag (e.g. "v1.2.0-beta.1"), set by main.
var CurrentVersion string
// RestartRequest carries info needed to send a post-restart notification.
type RestartRequest struct {
SessionKey string `json:"session_key"`
Platform string `json:"platform"`
}
// SaveRestartNotify persists restart info so the new process can send
// a "restart successful" message after startup.
func SaveRestartNotify(dataDir string, req RestartRequest) error {
dir := filepath.Join(dataDir, "run")
os.MkdirAll(dir, 0o755)
data, _ := json.Marshal(req)
return os.WriteFile(filepath.Join(dir, "restart_notify"), data, 0o644)
}
// ConsumeRestartNotify reads and deletes the restart notification file.
// Returns nil if no notification is pending.
func ConsumeRestartNotify(dataDir string) *RestartRequest {
p := filepath.Join(dataDir, "run", "restart_notify")
data, err := os.ReadFile(p)
if err != nil {
return nil
}
os.Remove(p)
var req RestartRequest
if json.Unmarshal(data, &req) != nil {
return nil
}
return &req
}
// SendRestartNotification sends a "restart successful" message to the
// platform/session that initiated the restart.
func (e *Engine) SendRestartNotification(platformName, sessionKey string) {
for _, p := range e.platforms {
if p.Name() != platformName {
continue
}
rc, ok := p.(ReplyContextReconstructor)
if !ok {
slog.Debug("restart notify: platform does not support ReconstructReplyCtx", "platform", platformName)
return
}
rctx, err := rc.ReconstructReplyCtx(sessionKey)
if err != nil {
slog.Debug("restart notify: reconstruct failed", "error", err)
return
}
msg := e.i18n.T(MsgRestartSuccess)
if err := p.Send(e.ctx, rctx, msg); err != nil {
slog.Debug("restart notify: send failed", "error", err)
}
return
}
}
// RestartCh is signaled when /restart is invoked. main listens on it
// to perform a graceful shutdown followed by syscall.Exec.
var RestartCh = make(chan RestartRequest, 1)
// DisplayCfg controls truncation of intermediate messages.
// A value of -1 means "use default", 0 means "no truncation".
type DisplayCfg struct {
ThinkingMaxLen int // max runes for thinking preview; 0 = no truncation
ToolMaxLen int // max runes for tool use preview; 0 = no truncation
}
// Engine routes messages between platforms and the agent for a single project.
type Engine struct {
name string
agent Agent
platforms []Platform
sessions *SessionManager
ctx context.Context
cancel context.CancelFunc
i18n *I18n
speech SpeechCfg
display DisplayCfg
defaultQuiet bool
startedAt time.Time
providerSaveFunc func(providerName string) error
providerAddSaveFunc func(p ProviderConfig) error
providerRemoveSaveFunc func(name string) error
commandSaveAddFunc func(name, description, prompt string) error
commandSaveDelFunc func(name string) error
displaySaveFunc func(thinkingMaxLen, toolMaxLen *int) error
configReloadFunc func() (*ConfigReloadResult, error)
cronScheduler *CronScheduler
commands *CommandRegistry
skills *SkillRegistry
aliases map[string]string // trigger → command (e.g. "帮助" → "/help")
aliasMu sync.RWMutex
aliasSaveAddFunc func(name, command string) error
aliasSaveDelFunc func(name string) error
bannedWords []string
bannedMu sync.RWMutex
disabledCmds map[string]bool
// Interactive agent session management
interactiveMu sync.Mutex
interactiveStates map[string]*interactiveState // key = sessionKey
}
// interactiveState tracks a running interactive agent session and its permission state.
type interactiveState struct {
agentSession AgentSession
platform Platform
replyCtx any
mu sync.Mutex
pending *pendingPermission
approveAll bool // when true, auto-approve all permission requests for this session
quiet bool // when true, suppress thinking and tool progress messages
}
// pendingPermission represents a permission request waiting for user response.
type pendingPermission struct {
RequestID string
ToolName string
ToolInput map[string]any
InputPreview string
Resolved chan struct{} // closed when user responds
resolveOnce sync.Once
}
// resolve safely closes the Resolved channel exactly once.
func (pp *pendingPermission) resolve() {
pp.resolveOnce.Do(func() { close(pp.Resolved) })
}
func NewEngine(name string, ag Agent, platforms []Platform, sessionStorePath string, lang Language) *Engine {
ctx, cancel := context.WithCancel(context.Background())
e := &Engine{
name: name,
agent: ag,
platforms: platforms,
sessions: NewSessionManager(sessionStorePath),
ctx: ctx,
cancel: cancel,
i18n: NewI18n(lang),
display: DisplayCfg{ThinkingMaxLen: defaultThinkingMaxLen, ToolMaxLen: defaultToolMaxLen},
commands: NewCommandRegistry(),
skills: NewSkillRegistry(),
aliases: make(map[string]string),
interactiveStates: make(map[string]*interactiveState),
startedAt: time.Now(),
}
if cp, ok := ag.(CommandProvider); ok {
e.commands.SetAgentDirs(cp.CommandDirs())
}
if sp, ok := ag.(SkillProvider); ok {
e.skills.SetDirs(sp.SkillDirs())
}
return e
}
// SetSpeechConfig configures the speech-to-text subsystem.
func (e *Engine) SetSpeechConfig(cfg SpeechCfg) {
e.speech = cfg
}
// SetDisplayConfig overrides the default truncation settings.
func (e *Engine) SetDisplayConfig(cfg DisplayCfg) {
e.display = cfg
}
// SetDefaultQuiet sets whether new sessions start in quiet mode.
func (e *Engine) SetDefaultQuiet(q bool) {
e.defaultQuiet = q
}
func (e *Engine) SetLanguageSaveFunc(fn func(Language) error) {
e.i18n.SetSaveFunc(fn)
}
func (e *Engine) SetProviderSaveFunc(fn func(providerName string) error) {
e.providerSaveFunc = fn
}
func (e *Engine) SetProviderAddSaveFunc(fn func(ProviderConfig) error) {
e.providerAddSaveFunc = fn
}
func (e *Engine) SetProviderRemoveSaveFunc(fn func(string) error) {
e.providerRemoveSaveFunc = fn
}
func (e *Engine) SetCronScheduler(cs *CronScheduler) {
e.cronScheduler = cs
}
func (e *Engine) SetCommandSaveAddFunc(fn func(name, description, prompt string) error) {
e.commandSaveAddFunc = fn
}
func (e *Engine) SetCommandSaveDelFunc(fn func(name string) error) {
e.commandSaveDelFunc = fn
}
func (e *Engine) SetDisplaySaveFunc(fn func(thinkingMaxLen, toolMaxLen *int) error) {
e.displaySaveFunc = fn
}
// ConfigReloadResult describes what was updated by a config reload.
type ConfigReloadResult struct {
DisplayUpdated bool
ProvidersUpdated int
CommandsUpdated int
}
func (e *Engine) SetConfigReloadFunc(fn func() (*ConfigReloadResult, error)) {
e.configReloadFunc = fn
}
// GetAgent returns the engine's agent (for type assertions like ProviderSwitcher).
func (e *Engine) GetAgent() Agent {
return e.agent
}
// AddCommand registers a custom slash command.
func (e *Engine) AddCommand(name, description, prompt, source string) {
e.commands.Add(name, description, prompt, source)
}
// ClearCommands removes all commands from the given source.
func (e *Engine) ClearCommands(source string) {
e.commands.ClearSource(source)
}
// AddAlias registers a command alias.
func (e *Engine) AddAlias(name, command string) {
e.aliasMu.Lock()
defer e.aliasMu.Unlock()
e.aliases[name] = command
}
func (e *Engine) SetAliasSaveAddFunc(fn func(name, command string) error) {
e.aliasSaveAddFunc = fn
}
func (e *Engine) SetAliasSaveDelFunc(fn func(name string) error) {
e.aliasSaveDelFunc = fn
}
// ClearAliases removes all aliases (for config reload).
func (e *Engine) ClearAliases() {
e.aliasMu.Lock()
defer e.aliasMu.Unlock()
e.aliases = make(map[string]string)
}
// SetDisabledCommands sets the list of command IDs that are disabled for this project.
func (e *Engine) SetDisabledCommands(cmds []string) {
m := make(map[string]bool, len(cmds))
for _, c := range cmds {
c = strings.ToLower(strings.TrimPrefix(c, "/"))
// Resolve alias names to canonical IDs
id := matchPrefix(c, builtinCommands)
if id != "" {
m[id] = true
} else {
m[c] = true
}
}
e.disabledCmds = m
}
// SetBannedWords replaces the banned words list.
func (e *Engine) SetBannedWords(words []string) {
e.bannedMu.Lock()
defer e.bannedMu.Unlock()
lower := make([]string, len(words))
for i, w := range words {
lower[i] = strings.ToLower(w)
}
e.bannedWords = lower
}
// RemoveCommand removes a custom command by name. Returns false if not found.
func (e *Engine) RemoveCommand(name string) bool {
return e.commands.Remove(name)
}
func (e *Engine) ProjectName() string {
return e.name
}
// ExecuteCronJob runs a cron job by injecting a synthetic message into the engine.
// It finds the platform that owns the session key, reconstructs a reply context,
// and processes the message as if the user sent it.
func (e *Engine) ExecuteCronJob(job *CronJob) error {
sessionKey := job.SessionKey
platformName := ""
if idx := strings.Index(sessionKey, ":"); idx > 0 {
platformName = sessionKey[:idx]
}
var targetPlatform Platform
for _, p := range e.platforms {
if p.Name() == platformName {
targetPlatform = p
break
}
}
if targetPlatform == nil {
return fmt.Errorf("platform %q not found for session %q", platformName, sessionKey)
}
rc, ok := targetPlatform.(ReplyContextReconstructor)
if !ok {
return fmt.Errorf("platform %q does not support proactive messaging (cron)", platformName)
}
replyCtx, err := rc.ReconstructReplyCtx(sessionKey)
if err != nil {
return fmt.Errorf("reconstruct reply context: %w", err)
}
// Notify user that a cron job is executing
desc := job.Description
if desc == "" {
desc = truncateStr(job.Prompt, 40)
}
e.send(targetPlatform, replyCtx, fmt.Sprintf("⏰ %s", desc))
msg := &Message{
SessionKey: sessionKey,
Platform: platformName,
UserID: "cron",
UserName: "cron",
Content: job.Prompt,
ReplyCtx: replyCtx,
}
session := e.sessions.GetOrCreateActive(sessionKey)
if !session.TryLock() {
return fmt.Errorf("session %q is busy", sessionKey)
}
e.processInteractiveMessage(targetPlatform, msg, session)
return nil
}
func (e *Engine) Start() error {
for _, p := range e.platforms {
if err := p.Start(e.handleMessage); err != nil {
return fmt.Errorf("[%s] start platform %s: %w", e.name, p.Name(), err)
}
slog.Info("platform started", "project", e.name, "platform", p.Name())
}
slog.Info("engine started", "project", e.name, "agent", e.agent.Name(), "platforms", len(e.platforms))
return nil
}
func (e *Engine) Stop() error {
e.cancel()
e.interactiveMu.Lock()
states := make(map[string]*interactiveState, len(e.interactiveStates))
for k, v := range e.interactiveStates {
states[k] = v
delete(e.interactiveStates, k)
}
e.interactiveMu.Unlock()
for key, state := range states {
if state.agentSession != nil {
slog.Debug("engine.Stop: closing agent session", "session", key)
state.agentSession.Close()
}
}
var errs []error
for _, p := range e.platforms {
if err := p.Stop(); err != nil {
errs = append(errs, fmt.Errorf("stop platform %s: %w", p.Name(), err))
}
}
if err := e.agent.Stop(); err != nil {
errs = append(errs, fmt.Errorf("stop agent %s: %w", e.agent.Name(), err))
}
if len(errs) > 0 {
return fmt.Errorf("engine stop errors: %v", errs)
}
return nil
}
// matchBannedWord returns the first banned word found in content, or "".
func (e *Engine) matchBannedWord(content string) string {
e.bannedMu.RLock()
defer e.bannedMu.RUnlock()
if len(e.bannedWords) == 0 {
return ""
}
lower := strings.ToLower(content)
for _, w := range e.bannedWords {
if strings.Contains(lower, w) {
return w
}
}
return ""
}
// resolveAlias checks if the content (or its first word) matches an alias and replaces it.
func (e *Engine) resolveAlias(content string) string {
e.aliasMu.RLock()
defer e.aliasMu.RUnlock()
if len(e.aliases) == 0 {
return content
}
// Exact match on full content
if cmd, ok := e.aliases[content]; ok {
return cmd
}
// Match first word, append remaining args
parts := strings.SplitN(content, " ", 2)
if cmd, ok := e.aliases[parts[0]]; ok {
if len(parts) > 1 {
return cmd + " " + parts[1]
}
return cmd
}
return content
}
func (e *Engine) handleMessage(p Platform, msg *Message) {
// Voice message: transcribe to text first
if msg.Audio != nil {
e.handleVoiceMessage(p, msg)
return
}
content := strings.TrimSpace(msg.Content)
if content == "" && len(msg.Images) == 0 {
return
}
// Resolve aliases: check if the first word (or whole content) matches an alias
content = e.resolveAlias(content)
msg.Content = content
// Banned words check (skip for slash commands)
if !strings.HasPrefix(content, "/") {
if word := e.matchBannedWord(content); word != "" {
slog.Info("message blocked by banned word", "word", word, "user", msg.UserName)
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgBannedWordBlocked))
return
}
}
if len(msg.Images) == 0 && strings.HasPrefix(content, "/") {
if e.handleCommand(p, msg, content) {
return
}
// Unrecognized slash command — fall through to agent as normal message
}
// Permission responses bypass the session lock
if e.handlePendingPermission(p, msg, content) {
return
}
session := e.sessions.GetOrCreateActive(msg.SessionKey)
if !session.TryLock() {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPreviousProcessing))
return
}
slog.Info("processing message",
"platform", msg.Platform,
"user", msg.UserName,
"session", session.ID,
)
go e.processInteractiveMessage(p, msg, session)
}
// ──────────────────────────────────────────────────────────────
// Voice message handling
// ──────────────────────────────────────────────────────────────
func (e *Engine) handleVoiceMessage(p Platform, msg *Message) {
if !e.speech.Enabled || e.speech.STT == nil {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgVoiceNotEnabled))
return
}
audio := msg.Audio
if NeedsConversion(audio.Format) && !HasFFmpeg() {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgVoiceNoFFmpeg))
return
}
slog.Info("transcribing voice message",
"platform", msg.Platform, "user", msg.UserName,
"format", audio.Format, "size", len(audio.Data),
)
e.send(p, msg.ReplyCtx, e.i18n.T(MsgVoiceTranscribing))
text, err := TranscribeAudio(e.ctx, e.speech.STT, audio, e.speech.Language)
if err != nil {
slog.Error("speech transcription failed", "error", err)
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgVoiceTranscribeFailed), err))
return
}
text = strings.TrimSpace(text)
if text == "" {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgVoiceEmpty))
return
}
slog.Info("voice transcribed", "text_len", len(text))
e.send(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgVoiceTranscribed), text))
// Replace audio with transcribed text and re-dispatch
msg.Audio = nil
msg.Content = text
e.handleMessage(p, msg)
}
// ──────────────────────────────────────────────────────────────
// Permission handling
// ──────────────────────────────────────────────────────────────
func (e *Engine) handlePendingPermission(p Platform, msg *Message, content string) bool {
e.interactiveMu.Lock()
state, ok := e.interactiveStates[msg.SessionKey]
e.interactiveMu.Unlock()
if !ok || state == nil {
return false
}
state.mu.Lock()
pending := state.pending
state.mu.Unlock()
if pending == nil {
return false
}
lower := strings.ToLower(strings.TrimSpace(content))
if isApproveAllResponse(lower) {
state.mu.Lock()
state.approveAll = true
state.mu.Unlock()
if err := state.agentSession.RespondPermission(pending.RequestID, PermissionResult{
Behavior: "allow",
UpdatedInput: pending.ToolInput,
}); err != nil {
slog.Error("failed to send permission response", "error", err)
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgError), err))
} else {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPermissionApproveAll))
}
} else if isAllowResponse(lower) {
if err := state.agentSession.RespondPermission(pending.RequestID, PermissionResult{
Behavior: "allow",
UpdatedInput: pending.ToolInput,
}); err != nil {
slog.Error("failed to send permission response", "error", err)
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgError), err))
} else {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPermissionAllowed))
}
} else if isDenyResponse(lower) {
if err := state.agentSession.RespondPermission(pending.RequestID, PermissionResult{
Behavior: "deny",
Message: "User denied this tool use.",
}); err != nil {
slog.Error("failed to send deny response", "error", err)
}
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPermissionDenied))
} else {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPermissionHint))
return true
}
state.mu.Lock()
state.pending = nil
state.mu.Unlock()
pending.resolve()
return true
}
func isApproveAllResponse(s string) bool {
for _, w := range []string{
"allow all", "allowall", "approve all", "yes all",
"允许所有", "允许全部", "全部允许", "所有允许", "都允许", "全部同意",
} {
if s == w {
return true
}
}
return false
}
func isAllowResponse(s string) bool {
for _, w := range []string{"allow", "yes", "y", "ok", "允许", "同意", "可以", "好", "好的", "是", "确认", "approve"} {
if s == w {
return true
}
}
return false
}
func isDenyResponse(s string) bool {
for _, w := range []string{"deny", "no", "n", "reject", "拒绝", "不允许", "不行", "不", "否", "取消", "cancel"} {
if s == w {
return true
}
}
return false
}
// ──────────────────────────────────────────────────────────────
// Interactive agent processing
// ──────────────────────────────────────────────────────────────
func (e *Engine) processInteractiveMessage(p Platform, msg *Message, session *Session) {
defer session.Unlock()
turnStart := time.Now()
e.i18n.DetectAndSet(msg.Content)
session.AddHistory("user", msg.Content)
state := e.getOrCreateInteractiveState(msg.SessionKey, p, msg.ReplyCtx, session)
// Update reply context for this turn
state.mu.Lock()
state.platform = p
state.replyCtx = msg.ReplyCtx
state.mu.Unlock()
if state.agentSession == nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgError), "failed to start agent session"))
return
}
sendStart := time.Now()
if err := state.agentSession.Send(msg.Content, msg.Images); err != nil {
slog.Error("failed to send prompt", "error", err)
if !state.agentSession.Alive() {
e.cleanupInteractiveState(msg.SessionKey)
e.send(p, msg.ReplyCtx, e.i18n.T(MsgSessionRestarting))
state = e.getOrCreateInteractiveState(msg.SessionKey, p, msg.ReplyCtx, session)
if state.agentSession == nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgError), "failed to restart agent session"))
return
}
sendStart = time.Now()
if err := state.agentSession.Send(msg.Content, msg.Images); err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgError), err))
return
}
} else {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgError), err))
return
}
}
if elapsed := time.Since(sendStart); elapsed >= slowAgentSend {
slog.Warn("slow agent send", "elapsed", elapsed, "session", msg.SessionKey, "content_len", len(msg.Content))
}
e.processInteractiveEvents(state, session, msg.SessionKey, turnStart)
}
func (e *Engine) getOrCreateInteractiveState(sessionKey string, p Platform, replyCtx any, session *Session) *interactiveState {
e.interactiveMu.Lock()
defer e.interactiveMu.Unlock()
state, ok := e.interactiveStates[sessionKey]
if ok && state.agentSession != nil && state.agentSession.Alive() {
return state
}
// Inject per-session env vars so the agent subprocess can call `cc-connect cron add` etc.
if inj, ok := e.agent.(SessionEnvInjector); ok {
envVars := []string{
"CC_PROJECT=" + e.name,
"CC_SESSION_KEY=" + sessionKey,
}
if exePath, err := os.Executable(); err == nil {
binDir := filepath.Dir(exePath)
if curPath := os.Getenv("PATH"); curPath != "" {
envVars = append(envVars, "PATH="+binDir+string(filepath.ListSeparator)+curPath)
} else {
envVars = append(envVars, "PATH="+binDir)
}
}
inj.SetSessionEnv(envVars)
}
startAt := time.Now()
agentSession, err := e.agent.StartSession(e.ctx, session.AgentSessionID)
startElapsed := time.Since(startAt)
if err != nil {
slog.Error("failed to start interactive session", "error", err, "elapsed", startElapsed)
state = &interactiveState{platform: p, replyCtx: replyCtx, quiet: e.defaultQuiet}
e.interactiveStates[sessionKey] = state
return state
}
if startElapsed >= slowAgentStart {
slog.Warn("slow agent session start", "elapsed", startElapsed, "agent", e.agent.Name(), "session_id", session.AgentSessionID)
}
state = &interactiveState{
agentSession: agentSession,
platform: p,
replyCtx: replyCtx,
quiet: e.defaultQuiet,
}
e.interactiveStates[sessionKey] = state
slog.Info("interactive session started", "session_key", sessionKey, "agent_session", session.AgentSessionID, "elapsed", startElapsed)
return state
}
func (e *Engine) cleanupInteractiveState(sessionKey string) {
e.interactiveMu.Lock()
state, ok := e.interactiveStates[sessionKey]
delete(e.interactiveStates, sessionKey)
e.interactiveMu.Unlock()
if ok && state != nil && state.agentSession != nil {
slog.Debug("cleanupInteractiveState: closing agent session", "session", sessionKey)
closeStart := time.Now()
done := make(chan struct{})
go func() {
state.agentSession.Close()
close(done)
}()
select {
case <-done:
if elapsed := time.Since(closeStart); elapsed >= slowAgentClose {
slog.Warn("slow agent session close", "elapsed", elapsed, "session", sessionKey)
}
case <-time.After(10 * time.Second):
slog.Error("agent session close timed out (10s), abandoning", "session", sessionKey)
}
}
}
func (e *Engine) processInteractiveEvents(state *interactiveState, session *Session, sessionKey string, turnStart time.Time) {
var textParts []string
toolCount := 0
waitStart := time.Now()
firstEventLogged := false
for event := range state.agentSession.Events() {
if e.ctx.Err() != nil {
return
}
if !firstEventLogged {
firstEventLogged = true
if elapsed := time.Since(waitStart); elapsed >= slowAgentFirstEvent {
slog.Warn("slow agent first event", "elapsed", elapsed, "session", sessionKey, "event_type", event.Type)
}
}
state.mu.Lock()
p := state.platform
replyCtx := state.replyCtx
quiet := state.quiet
state.mu.Unlock()
switch event.Type {
case EventThinking:
if !quiet && event.Content != "" {
preview := truncateIf(event.Content, e.display.ThinkingMaxLen)
e.send(p, replyCtx, fmt.Sprintf(e.i18n.T(MsgThinking), preview))
}
case EventToolUse:
toolCount++
if !quiet {
inputPreview := truncateIf(event.ToolInput, e.display.ToolMaxLen)
e.send(p, replyCtx, fmt.Sprintf(e.i18n.T(MsgTool), toolCount, event.ToolName, inputPreview))
}
case EventText:
if event.Content != "" {
textParts = append(textParts, event.Content)
}
if event.SessionID != "" {
session.mu.Lock()
if session.AgentSessionID == "" {
session.AgentSessionID = event.SessionID
pendingName := session.Name
session.mu.Unlock()
if pendingName != "" && pendingName != "session" && pendingName != "default" {
e.sessions.SetSessionName(event.SessionID, pendingName)
}
e.sessions.Save()
} else {
session.mu.Unlock()
}
}
case EventPermissionRequest:
state.mu.Lock()
autoApprove := state.approveAll
state.mu.Unlock()
if autoApprove {
slog.Debug("auto-approving (approve-all)", "request_id", event.RequestID, "tool", event.ToolName)
_ = state.agentSession.RespondPermission(event.RequestID, PermissionResult{
Behavior: "allow",
UpdatedInput: event.ToolInputRaw,
})
continue
}
slog.Info("permission request",
"request_id", event.RequestID,
"tool", event.ToolName,
)
permLimit := e.display.ToolMaxLen
if permLimit > 0 {
permLimit = permLimit * 8 / 5 // permission prompts get ~1.6x more room
}
prompt := fmt.Sprintf(e.i18n.T(MsgPermissionPrompt), event.ToolName, truncateIf(event.ToolInput, permLimit))
e.sendPermissionPrompt(p, replyCtx, prompt)
pending := &pendingPermission{
RequestID: event.RequestID,
ToolName: event.ToolName,
ToolInput: event.ToolInputRaw,
InputPreview: event.ToolInput,
Resolved: make(chan struct{}),
}
state.mu.Lock()
state.pending = pending
state.mu.Unlock()
<-pending.Resolved
slog.Info("permission resolved", "request_id", event.RequestID)
case EventResult:
if event.SessionID != "" {
session.mu.Lock()
session.AgentSessionID = event.SessionID
session.mu.Unlock()
}
fullResponse := event.Content
if fullResponse == "" && len(textParts) > 0 {
fullResponse = strings.Join(textParts, "")
}
if fullResponse == "" {
fullResponse = e.i18n.T(MsgEmptyResponse)
}
session.AddHistory("assistant", fullResponse)
e.sessions.Save()
turnDuration := time.Since(turnStart)
slog.Info("turn complete",
"session", session.ID,
"agent_session", session.AgentSessionID,
"tools", toolCount,
"response_len", len(fullResponse),
"turn_duration", turnDuration,
)
replyStart := time.Now()
for _, chunk := range splitMessage(fullResponse, maxPlatformMessageLen) {
if err := p.Send(e.ctx, replyCtx, chunk); err != nil {
slog.Error("failed to send reply", "error", err)
return
}
}
if elapsed := time.Since(replyStart); elapsed >= slowPlatformSend {
slog.Warn("slow final reply send", "platform", p.Name(), "elapsed", elapsed, "response_len", len(fullResponse))
}
return
case EventError:
if event.Error != nil {
slog.Error("agent error", "error", event.Error)
e.send(p, replyCtx, fmt.Sprintf(e.i18n.T(MsgError), event.Error))
}
return
}
}
// Channel closed - process exited unexpectedly
slog.Warn("agent process exited", "session_key", sessionKey)
e.cleanupInteractiveState(sessionKey)
if len(textParts) > 0 {
state.mu.Lock()
p := state.platform
replyCtx := state.replyCtx
state.mu.Unlock()
fullResponse := strings.Join(textParts, "")
session.AddHistory("assistant", fullResponse)
for _, chunk := range splitMessage(fullResponse, maxPlatformMessageLen) {
e.send(p, replyCtx, chunk)
}
}
}
// ──────────────────────────────────────────────────────────────
// Command handling
// ──────────────────────────────────────────────────────────────
// builtinCommands maps canonical command names to their aliases/full names.
// The first entry is the canonical name used for prefix matching.
var builtinCommands = []struct {
names []string
id string
}{
{[]string{"new"}, "new"},
{[]string{"list", "sessions"}, "list"},
{[]string{"switch"}, "switch"},
{[]string{"name", "rename"}, "name"},
{[]string{"current"}, "current"},
{[]string{"status"}, "status"},
{[]string{"history"}, "history"},
{[]string{"allow"}, "allow"},
{[]string{"model"}, "model"},
{[]string{"mode"}, "mode"},
{[]string{"lang"}, "lang"},
{[]string{"quiet"}, "quiet"},
{[]string{"provider"}, "provider"},
{[]string{"memory"}, "memory"},
{[]string{"cron"}, "cron"},
{[]string{"compress", "compact"}, "compress"},
{[]string{"stop"}, "stop"},
{[]string{"help"}, "help"},
{[]string{"version"}, "version"},
{[]string{"commands", "command", "cmd"}, "commands"},
{[]string{"skills", "skill"}, "skills"},
{[]string{"config"}, "config"},
{[]string{"doctor"}, "doctor"},
{[]string{"upgrade", "update"}, "upgrade"},
{[]string{"restart"}, "restart"},
{[]string{"alias"}, "alias"},
{[]string{"delete", "del", "rm"}, "delete"},
}
// matchPrefix finds a unique command matching the given prefix.
// Returns the command id or "" if no match / ambiguous.
func matchPrefix(prefix string, candidates []struct {
names []string
id string
}) string {
// Exact match first
for _, c := range candidates {
for _, n := range c.names {
if prefix == n {
return c.id
}
}
}
// Prefix match
var matched string
for _, c := range candidates {
for _, n := range c.names {
if strings.HasPrefix(n, prefix) {
if matched != "" && matched != c.id {
return "" // ambiguous
}
matched = c.id
break
}
}
}
return matched
}
// matchSubCommand does prefix matching against a flat list of subcommand names.
func matchSubCommand(input string, candidates []string) string {
for _, c := range candidates {
if input == c {
return c
}
}
var matched string
for _, c := range candidates {
if strings.HasPrefix(c, input) {
if matched != "" {
return input // ambiguous → return raw input (will hit default)
}
matched = c
}
}
if matched != "" {
return matched
}
return input
}
func (e *Engine) handleCommand(p Platform, msg *Message, raw string) bool {
parts := strings.Fields(raw)
cmd := strings.ToLower(strings.TrimPrefix(parts[0], "/"))
args := parts[1:]
cmdID := matchPrefix(cmd, builtinCommands)
if cmdID != "" && e.disabledCmds[cmdID] {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCommandDisabled), "/"+cmdID))
return true
}
switch cmdID {
case "new":
e.cmdNew(p, msg, args)
case "list":
e.cmdList(p, msg)
case "switch":
e.cmdSwitch(p, msg, args)
case "name":
e.cmdName(p, msg, args)
case "current":
e.cmdCurrent(p, msg)
case "status":
e.cmdStatus(p, msg)
case "history":
e.cmdHistory(p, msg, args)
case "allow":
e.cmdAllow(p, msg, args)
case "model":
e.cmdModel(p, msg, args)
case "mode":
e.cmdMode(p, msg, args)
case "lang":
e.cmdLang(p, msg, args)
case "quiet":
e.cmdQuiet(p, msg)
case "provider":
e.cmdProvider(p, msg, args)
case "memory":
e.cmdMemory(p, msg, args)
case "cron":
e.cmdCron(p, msg, args)
case "compress":
e.cmdCompress(p, msg)
case "stop":
e.cmdStop(p, msg)
case "help":
e.cmdHelp(p, msg)
case "version":
e.reply(p, msg.ReplyCtx, VersionInfo)
case "commands":
e.cmdCommands(p, msg, args)
case "skills":
e.cmdSkills(p, msg)
case "config":
e.cmdConfig(p, msg, args)
case "doctor":
e.cmdDoctor(p, msg)
case "upgrade":
e.cmdUpgrade(p, msg, args)
case "restart":
e.cmdRestart(p, msg)
case "alias":
e.cmdAlias(p, msg, args)
case "delete":
e.cmdDelete(p, msg, args)
default:
if custom, ok := e.commands.Resolve(cmd); ok {
e.executeCustomCommand(p, msg, custom, args)
return true
}
if skill := e.skills.Resolve(cmd); skill != nil {
e.executeSkill(p, msg, skill, args)
return true
}
// Not a cc-connect command — notify user, then fall through to agent
e.send(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgUnknownCommand), "/"+cmd))
return false
}
return true
}
func (e *Engine) cmdNew(p Platform, msg *Message, args []string) {
slog.Info("cmdNew: cleaning up old session", "session_key", msg.SessionKey)
e.cleanupInteractiveState(msg.SessionKey)
slog.Info("cmdNew: cleanup done, creating new session", "session_key", msg.SessionKey)
name := ""
if len(args) > 0 {
name = strings.Join(args, " ")
}
s := e.sessions.NewSession(msg.SessionKey, name)
if name != "" {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgNewSessionCreatedName), name))
} else {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgNewSessionCreated))
}
_ = s
}
func (e *Engine) cmdList(p Platform, msg *Message) {
agentSessions, err := e.agent.ListSessions(e.ctx)
if err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgListError), err))
return
}
if len(agentSessions) == 0 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgListEmpty))
return
}
agentName := e.agent.Name()
activeSession := e.sessions.GetOrCreateActive(msg.SessionKey)
activeAgentID := activeSession.AgentSessionID
limit := 20
if len(agentSessions) < limit {
limit = len(agentSessions)
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf(e.i18n.T(MsgListTitle), agentName, len(agentSessions)))
for i := 0; i < limit; i++ {
s := agentSessions[i]
marker := "◻"
if s.ID == activeAgentID {
marker = "▶"
}
displayName := e.sessions.GetSessionName(s.ID)
if displayName != "" {
displayName = "📌 " + displayName
} else {
displayName = strings.ReplaceAll(s.Summary, "\n", " ")
displayName = strings.Join(strings.Fields(displayName), " ")
if displayName == "" {
displayName = "(empty)"
}
if len([]rune(displayName)) > 40 {
displayName = string([]rune(displayName)[:40]) + "…"
}
}
sb.WriteString(fmt.Sprintf("%s **%d.** %s · **%d** msgs · %s\n",
marker, i+1, displayName, s.MessageCount, s.ModifiedAt.Format("01-02 15:04")))
}
if len(agentSessions) > limit {
sb.WriteString(fmt.Sprintf(e.i18n.T(MsgListMore), len(agentSessions)-limit))
}
sb.WriteString(e.i18n.T(MsgListSwitchHint))
e.reply(p, msg.ReplyCtx, sb.String())
}
func (e *Engine) cmdSwitch(p Platform, msg *Message, args []string) {
if len(args) == 0 {
e.reply(p, msg.ReplyCtx, "Usage: /switch <number or id_prefix>")
return
}
prefix := strings.TrimSpace(args[0])
slog.Info("cmdSwitch: listing agent sessions", "session_key", msg.SessionKey)
agentSessions, err := e.agent.ListSessions(e.ctx)
if err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err))
return
}
var matched *AgentSessionInfo
// Try numeric index first (1-based, matching /list output)
if idx, err := strconv.Atoi(prefix); err == nil && idx >= 1 && idx <= len(agentSessions) {
matched = &agentSessions[idx-1]
} else {
for i := range agentSessions {
if strings.HasPrefix(agentSessions[i].ID, prefix) {
matched = &agentSessions[i]
break
}
}
}
if matched == nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ No session matching %q", prefix))
return
}
slog.Info("cmdSwitch: cleaning up old session", "session_key", msg.SessionKey)
e.cleanupInteractiveState(msg.SessionKey)
slog.Info("cmdSwitch: cleanup done", "session_key", msg.SessionKey)
session := e.sessions.GetOrCreateActive(msg.SessionKey)
session.AgentSessionID = matched.ID
session.Name = matched.Summary
session.ClearHistory()
e.sessions.Save()
shortID := matched.ID
if len(shortID) > 12 {
shortID = shortID[:12]
}
displayName := e.sessions.GetSessionName(matched.ID)
if displayName == "" {
displayName = matched.Summary
}
e.reply(p, msg.ReplyCtx,
fmt.Sprintf("✅ Switched to: %s (%s, %d msgs)", displayName, shortID, matched.MessageCount))
}
func (e *Engine) cmdName(p Platform, msg *Message, args []string) {
if len(args) == 0 {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgNameUsage)))
return
}
// Check if first arg is a number → naming a specific session by list index
var targetID string
var name string
if idx, err := strconv.Atoi(args[0]); err == nil && idx >= 1 {
// /name <number> <name...>
if len(args) < 2 {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgNameUsage)))
return
}
agentSessions, err := e.agent.ListSessions(e.ctx)
if err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err))
return
}
if idx > len(agentSessions) {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ No session #%d", idx))
return
}
targetID = agentSessions[idx-1].ID
name = strings.Join(args[1:], " ")
} else {
// /name <name...> → current session
session := e.sessions.GetOrCreateActive(msg.SessionKey)
targetID = session.AgentSessionID
if targetID == "" {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgNameNoSession))
return
}
name = strings.Join(args, " ")
}
name = strings.TrimSpace(name)
if name == "" {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgNameUsage)))
return
}
e.sessions.SetSessionName(targetID, name)
shortID := targetID
if len(shortID) > 12 {
shortID = shortID[:12]
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgNameSet), name, shortID))
}
func (e *Engine) cmdCurrent(p Platform, msg *Message) {
s := e.sessions.GetOrCreateActive(msg.SessionKey)
agentID := s.AgentSessionID
if agentID == "" {
agentID = "(new — not yet started)"
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(
"📌 Current session\nName: %s\nClaude Session: %s\nLocal messages: %d",
s.Name, agentID, len(s.History)))
}
func (e *Engine) cmdStatus(p Platform, msg *Message) {
// Platforms
platNames := make([]string, len(e.platforms))
for i, pl := range e.platforms {
platNames[i] = pl.Name()
}
platformStr := strings.Join(platNames, ", ")
if len(platNames) == 0 {
platformStr = "-"
}
// Uptime
uptimeStr := formatDurationI18n(time.Since(e.startedAt), e.i18n.CurrentLang())
// Language
cur := e.i18n.CurrentLang()
langStr := fmt.Sprintf("%s (%s)", string(cur), langDisplayName(cur))
// Mode (optional)
var modeStr string
if ms, ok := e.agent.(ModeSwitcher); ok {
mode := ms.GetMode()
if mode != "" {
modeStr = e.i18n.Tf(MsgStatusMode, mode)
}
}
// Session info
s := e.sessions.GetOrCreateActive(msg.SessionKey)
sessionDisplayName := e.sessions.GetSessionName(s.AgentSessionID)
if sessionDisplayName == "" {
sessionDisplayName = s.Name
}
sessionStr := e.i18n.Tf(MsgStatusSession, sessionDisplayName, len(s.History))
// Cron jobs
var cronStr string
if e.cronScheduler != nil {
jobs := e.cronScheduler.Store().ListBySessionKey(msg.SessionKey)
if len(jobs) > 0 {
enabledCount := 0
for _, j := range jobs {
if j.Enabled {
enabledCount++
}
}
cronStr = e.i18n.Tf(MsgStatusCron, len(jobs), enabledCount)
}
}
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgStatusTitle,
e.name,
e.agent.Name(),
platformStr,
uptimeStr,
langStr,
modeStr,
sessionStr,
cronStr,
))
}
func cronTimeFormat(t, now time.Time) string {
if t.Year() != now.Year() {
return "2006-01-02 15:04"
}
return "01-02 15:04"
}
func formatDurationI18n(d time.Duration, lang Language) string {
d = d.Round(time.Second)
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
minutes := int(d.Minutes()) % 60
switch lang {
case LangChinese, LangTraditionalChinese:
if days > 0 {
return fmt.Sprintf("%d天 %d小时 %d分钟", days, hours, minutes)
}
if hours > 0 {
return fmt.Sprintf("%d小时 %d分钟", hours, minutes)
}
return fmt.Sprintf("%d分钟", minutes)
case LangJapanese:
if days > 0 {
return fmt.Sprintf("%d日 %d時間 %d分", days, hours, minutes)
}
if hours > 0 {
return fmt.Sprintf("%d時間 %d分", hours, minutes)
}
return fmt.Sprintf("%d分", minutes)
case LangSpanish:
if days > 0 {
return fmt.Sprintf("%d días %dh %dm", days, hours, minutes)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, minutes)
}
return fmt.Sprintf("%dm", minutes)
default:
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, minutes)
}
return fmt.Sprintf("%dm", minutes)
}
}
func (e *Engine) cmdHistory(p Platform, msg *Message, args []string) {
s := e.sessions.GetOrCreateActive(msg.SessionKey)
n := 10
if len(args) > 0 {
if v, err := strconv.Atoi(args[0]); err == nil && v > 0 {
n = v
}
}
entries := s.GetHistory(n)
// Fallback: load from agent backend if in-memory history is empty
if len(entries) == 0 && s.AgentSessionID != "" {
if hp, ok := e.agent.(HistoryProvider); ok {
if agentEntries, err := hp.GetSessionHistory(e.ctx, s.AgentSessionID, n); err == nil {
entries = agentEntries
}
}
}
if len(entries) == 0 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgHistoryEmpty))
return
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📜 History (last %d):\n\n", len(entries)))
for _, h := range entries {
icon := "👤"
if h.Role == "assistant" {
icon = "🤖"
}
content := h.Content
if len([]rune(content)) > 200 {
content = string([]rune(content)[:200]) + "..."
}
sb.WriteString(fmt.Sprintf("%s [%s]\n%s\n\n", icon, h.Timestamp.Format("15:04:05"), content))
}
e.reply(p, msg.ReplyCtx, sb.String())
}
func (e *Engine) cmdLang(p Platform, msg *Message, args []string) {
if len(args) == 0 {
cur := e.i18n.CurrentLang()
name := langDisplayName(cur)
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgLangCurrent, name))
return
}
target := strings.ToLower(strings.TrimSpace(args[0]))
var lang Language
switch target {
case "en", "english":
lang = LangEnglish
case "zh", "cn", "chinese", "中文":
lang = LangChinese
case "zh-tw", "zh_tw", "zhtw", "繁體", "繁体":
lang = LangTraditionalChinese
case "ja", "jp", "japanese", "日本語":
lang = LangJapanese
case "es", "spanish", "español":
lang = LangSpanish
case "auto":
lang = LangAuto
default:
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgLangInvalid))
return
}
e.i18n.SetLang(lang)
name := langDisplayName(lang)
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgLangChanged, name))
}
func langDisplayName(lang Language) string {
switch lang {
case LangEnglish:
return "English"
case LangChinese:
return "中文"
case LangTraditionalChinese:
return "繁體中文"
case LangJapanese:
return "日本語"
case LangSpanish:
return "Español"
default:
return "Auto"
}
}
func (e *Engine) cmdHelp(p Platform, msg *Message) {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgHelp))
}
func (e *Engine) cmdModel(p Platform, msg *Message, args []string) {
switcher, ok := e.agent.(ModelSwitcher)
if !ok {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgModelNotSupported))
return
}
fetchCtx, cancel := context.WithTimeout(e.ctx, 10*time.Second)
defer cancel()
models := switcher.AvailableModels(fetchCtx)
if len(args) == 0 {
var sb strings.Builder
current := switcher.GetModel()
if current == "" {
sb.WriteString(e.i18n.T(MsgModelDefault))
} else {
sb.WriteString(e.i18n.Tf(MsgModelCurrent, current))
sb.WriteString("\n")
}
sb.WriteString("\n")
sb.WriteString(e.i18n.T(MsgModelListTitle))
for i, m := range models {
marker := " "
if m.Name == current {
marker = "> "
}
desc := m.Desc
if desc != "" {
desc = " — " + desc
}
sb.WriteString(fmt.Sprintf("%s%d. %s%s\n", marker, i+1, m.Name, desc))
}
sb.WriteString("\n")
sb.WriteString(e.i18n.T(MsgModelUsage))
e.reply(p, msg.ReplyCtx, sb.String())
return
}
target := args[0]
if idx, err := strconv.Atoi(target); err == nil && idx >= 1 && idx <= len(models) {
target = models[idx-1].Name
}
switcher.SetModel(target)
e.cleanupInteractiveState(msg.SessionKey)
s := e.sessions.GetOrCreateActive(msg.SessionKey)
s.AgentSessionID = ""
s.ClearHistory()
e.sessions.Save()
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgModelChanged, target))
}
func (e *Engine) cmdMode(p Platform, msg *Message, args []string) {
switcher, ok := e.agent.(ModeSwitcher)
if !ok {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgModeNotSupported))
return
}
if len(args) == 0 {
current := switcher.GetMode()
modes := switcher.PermissionModes()
var sb strings.Builder
zhLike := e.i18n.IsZhLike()
for _, m := range modes {
marker := " "
if m.Key == current {
marker = "▶ "
}
if zhLike {
sb.WriteString(fmt.Sprintf("%s**%s** — %s\n", marker, m.NameZh, m.DescZh))
} else {
sb.WriteString(fmt.Sprintf("%s**%s** — %s\n", marker, m.Name, m.Desc))
}
}
sb.WriteString(e.i18n.T(MsgModeUsage))
e.reply(p, msg.ReplyCtx, sb.String())
return
}
target := strings.ToLower(args[0])
switcher.SetMode(target)
newMode := switcher.GetMode()
e.cleanupInteractiveState(msg.SessionKey)
modes := switcher.PermissionModes()
displayName := newMode
zhLike := e.i18n.IsZhLike()
for _, m := range modes {
if m.Key == newMode {
if zhLike {
displayName = m.NameZh
} else {
displayName = m.Name
}
break
}
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgModeChanged), displayName))
}
func (e *Engine) cmdQuiet(p Platform, msg *Message) {
e.interactiveMu.Lock()
state, ok := e.interactiveStates[msg.SessionKey]
e.interactiveMu.Unlock()
if !ok || state == nil {
// No state yet, create one so the flag persists
state = &interactiveState{platform: p, replyCtx: msg.ReplyCtx, quiet: true}
e.interactiveMu.Lock()
e.interactiveStates[msg.SessionKey] = state
e.interactiveMu.Unlock()
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgQuietOn))
return
}
state.mu.Lock()
state.quiet = !state.quiet
quiet := state.quiet
state.mu.Unlock()
if quiet {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgQuietOn))
} else {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgQuietOff))
}
}
func (e *Engine) cmdStop(p Platform, msg *Message) {
e.interactiveMu.Lock()
state, ok := e.interactiveStates[msg.SessionKey]
e.interactiveMu.Unlock()
if !ok || state == nil {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgNoExecution))
return
}
// Cancel pending permission if any
state.mu.Lock()
pending := state.pending
if pending != nil {
state.pending = nil
}
state.mu.Unlock()
if pending != nil {
pending.resolve()
}
e.cleanupInteractiveState(msg.SessionKey)
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgExecutionStopped))
}
func (e *Engine) cmdCompress(p Platform, msg *Message) {
compressor, ok := e.agent.(ContextCompressor)
if !ok || compressor.CompressCommand() == "" {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCompressNotSupported))
return
}
// Check for an active interactive session
e.interactiveMu.Lock()
state, hasState := e.interactiveStates[msg.SessionKey]
e.interactiveMu.Unlock()
if !hasState || state == nil || state.agentSession == nil || !state.agentSession.Alive() {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCompressNoSession))
return
}
session := e.sessions.GetOrCreateActive(msg.SessionKey)
if !session.TryLock() {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPreviousProcessing))
return
}
e.send(p, msg.ReplyCtx, e.i18n.T(MsgCompressing))
msg.Content = compressor.CompressCommand()
go e.processInteractiveMessage(p, msg, session)
}
func (e *Engine) cmdAllow(p Platform, msg *Message, args []string) {
if len(args) == 0 {
if auth, ok := e.agent.(ToolAuthorizer); ok {
tools := auth.GetAllowedTools()
if len(tools) == 0 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgNoToolsAllowed))
} else {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCurrentTools), strings.Join(tools, ", ")))
}
} else {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgToolAuthNotSupported))
}
return
}
toolName := strings.TrimSpace(args[0])
if auth, ok := e.agent.(ToolAuthorizer); ok {
if err := auth.AddAllowedTools(toolName); err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgToolAllowFailed), err))
return
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgToolAllowedNew), toolName))
} else {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgToolAuthNotSupported))
}
}
func (e *Engine) cmdProvider(p Platform, msg *Message, args []string) {
switcher, ok := e.agent.(ProviderSwitcher)
if !ok {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderNotSupported))
return
}
if len(args) == 0 {
current := switcher.GetActiveProvider()
providers := switcher.ListProviders()
if current == nil && len(providers) == 0 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderNone))
return
}
if current != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderCurrent), current.Name))
return
}
// Has providers but no active one — show the list
var sb strings.Builder
sb.WriteString(e.i18n.T(MsgProviderListTitle))
for _, prov := range providers {
marker := " "
if current != nil && prov.Name == current.Name {
marker = "▶ "
}
detail := prov.Name
if prov.BaseURL != "" {
detail += " (" + prov.BaseURL + ")"
}
if prov.Model != "" {
detail += " [" + prov.Model + "]"
}
sb.WriteString(fmt.Sprintf("%s%s\n", marker, detail))
}
sb.WriteString("\n" + e.i18n.T(MsgProviderSwitchHint))
e.reply(p, msg.ReplyCtx, sb.String())
return
}
sub := matchSubCommand(strings.ToLower(args[0]), []string{
"list", "add", "remove", "switch", "current", "clear", "reset", "none",
})
switch sub {
case "list":
providers := switcher.ListProviders()
if len(providers) == 0 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderListEmpty))
return
}
current := switcher.GetActiveProvider()
var sb strings.Builder
sb.WriteString(e.i18n.T(MsgProviderListTitle))
for _, prov := range providers {
marker := " "
if current != nil && prov.Name == current.Name {
marker = "▶ "
}
detail := prov.Name
if prov.BaseURL != "" {
detail += " (" + prov.BaseURL + ")"
}
if prov.Model != "" {
detail += " [" + prov.Model + "]"
}
sb.WriteString(fmt.Sprintf("%s%s\n", marker, detail))
}
sb.WriteString("\n" + e.i18n.T(MsgProviderSwitchHint))
e.reply(p, msg.ReplyCtx, sb.String())
case "add":
e.cmdProviderAdd(p, msg, switcher, args[1:])
case "remove", "rm", "delete":
e.cmdProviderRemove(p, msg, switcher, args[1:])
case "switch":
if len(args) < 2 {
e.reply(p, msg.ReplyCtx, "Usage: /provider switch <name>")
return
}
e.switchProvider(p, msg, switcher, args[1])
case "current":
current := switcher.GetActiveProvider()
if current == nil {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderNone))
return
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderCurrent), current.Name))
case "clear", "reset", "none":
switcher.SetActiveProvider("")
e.cleanupInteractiveState(msg.SessionKey)
if e.providerSaveFunc != nil {
if err := e.providerSaveFunc(""); err != nil {
slog.Error("failed to save provider", "error", err)
}
}
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderCleared))
default:
e.switchProvider(p, msg, switcher, args[0])
}
}
func (e *Engine) cmdProviderAdd(p Platform, msg *Message, switcher ProviderSwitcher, args []string) {
if len(args) == 0 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderAddUsage))
return
}
var prov ProviderConfig
// Join args back; detect JSON (starts with '{') vs positional
raw := strings.Join(args, " ")
raw = strings.TrimSpace(raw)
if strings.HasPrefix(raw, "{") {
// JSON format: /provider add {"name":"relay","api_key":"sk-xxx",...}
var jp struct {
Name string `json:"name"`
APIKey string `json:"api_key"`
BaseURL string `json:"base_url"`
Model string `json:"model"`
Env map[string]string `json:"env"`
}
if err := json.Unmarshal([]byte(raw), &jp); err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderAddFailed), "invalid JSON: "+err.Error()))
return
}
if jp.Name == "" {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderAddFailed), "\"name\" is required"))
return
}
prov = ProviderConfig{Name: jp.Name, APIKey: jp.APIKey, BaseURL: jp.BaseURL, Model: jp.Model, Env: jp.Env}
} else {
// Positional: /provider add <name> <api_key> [base_url] [model]
if len(args) < 2 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderAddUsage))
return
}
prov.Name = args[0]
prov.APIKey = args[1]
if len(args) > 2 {
prov.BaseURL = args[2]
}
if len(args) > 3 {
prov.Model = args[3]
}
}
// Check for duplicates
for _, existing := range switcher.ListProviders() {
if existing.Name == prov.Name {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderAddFailed), fmt.Sprintf("provider %q already exists", prov.Name)))
return
}
}
// Add to runtime
updated := append(switcher.ListProviders(), prov)
switcher.SetProviders(updated)
// Persist to config
if e.providerAddSaveFunc != nil {
if err := e.providerAddSaveFunc(prov); err != nil {
slog.Error("failed to persist provider", "error", err)
}
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderAdded), prov.Name, prov.Name))
}
func (e *Engine) cmdProviderRemove(p Platform, msg *Message, switcher ProviderSwitcher, args []string) {
if len(args) == 0 {
e.reply(p, msg.ReplyCtx, "Usage: /provider remove <name>")
return
}
name := args[0]
providers := switcher.ListProviders()
found := false
var remaining []ProviderConfig
for _, prov := range providers {
if prov.Name == name {
found = true
} else {
remaining = append(remaining, prov)
}
}
if !found {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderNotFound), name))
return
}
// If removing the active provider, clear it
active := switcher.GetActiveProvider()
switcher.SetProviders(remaining)
if active != nil && active.Name == name {
// No active provider after removal
slog.Info("removed active provider, clearing selection", "name", name)
}
// Persist
if e.providerRemoveSaveFunc != nil {
if err := e.providerRemoveSaveFunc(name); err != nil {
slog.Error("failed to persist provider removal", "error", err)
}
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderRemoved), name))
}
func (e *Engine) switchProvider(p Platform, msg *Message, switcher ProviderSwitcher, name string) {
if !switcher.SetActiveProvider(name) {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderNotFound), name))
return
}
e.cleanupInteractiveState(msg.SessionKey)
if e.providerSaveFunc != nil {
if err := e.providerSaveFunc(name); err != nil {
slog.Error("failed to save provider", "error", err)
}
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderSwitched), name))
}
// ──────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────
// SendToSession sends a message to an active session from an external caller (API/CLI).
// If sessionKey is empty, it picks the first active session.
func (e *Engine) SendToSession(sessionKey, message string) error {
e.interactiveMu.Lock()
defer e.interactiveMu.Unlock()
var state *interactiveState
if sessionKey != "" {
state = e.interactiveStates[sessionKey]
} else {
// Pick the first active session
for _, s := range e.interactiveStates {
state = s
break
}
}
if state == nil {
return fmt.Errorf("no active session found (key=%q)", sessionKey)
}
state.mu.Lock()
p := state.platform
replyCtx := state.replyCtx
state.mu.Unlock()
if p == nil {
return fmt.Errorf("no active session found (key=%q)", sessionKey)
}
return p.Send(e.ctx, replyCtx, message)
}
// sendPermissionPrompt sends a permission prompt, using inline buttons when the platform supports them.
func (e *Engine) sendPermissionPrompt(p Platform, replyCtx any, prompt string) {
if bs, ok := p.(InlineButtonSender); ok {
buttons := [][]ButtonOption{
{
{Text: e.i18n.T(MsgPermBtnAllow), Data: "perm:allow"},
{Text: e.i18n.T(MsgPermBtnDeny), Data: "perm:deny"},
},
{
{Text: e.i18n.T(MsgPermBtnAllowAll), Data: "perm:allow_all"},
},
}
if err := bs.SendWithButtons(e.ctx, replyCtx, prompt, buttons); err == nil {
return
}
slog.Warn("sendPermissionPrompt: inline buttons failed, falling back to text")
}
e.send(p, replyCtx, prompt)
}
// send wraps p.Send with error logging and slow-operation warnings.
func (e *Engine) send(p Platform, replyCtx any, content string) {
start := time.Now()
if err := p.Send(e.ctx, replyCtx, content); err != nil {
slog.Error("platform send failed", "platform", p.Name(), "error", err, "content_len", len(content))
}
if elapsed := time.Since(start); elapsed >= slowPlatformSend {
slog.Warn("slow platform send", "platform", p.Name(), "elapsed", elapsed, "content_len", len(content))
}
}
// reply wraps p.Reply with error logging and slow-operation warnings.
func (e *Engine) reply(p Platform, replyCtx any, content string) {
start := time.Now()
if err := p.Reply(e.ctx, replyCtx, content); err != nil {
slog.Error("platform reply failed", "platform", p.Name(), "error", err, "content_len", len(content))
}
if elapsed := time.Since(start); elapsed >= slowPlatformSend {
slog.Warn("slow platform reply", "platform", p.Name(), "elapsed", elapsed, "content_len", len(content))
}
}
// ──────────────────────────────────────────────────────────────
// /memory command
// ──────────────────────────────────────────────────────────────
func (e *Engine) cmdMemory(p Platform, msg *Message, args []string) {
mp, ok := e.agent.(MemoryFileProvider)
if !ok {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryNotSupported))
return
}
if len(args) == 0 {
// /memory — show project memory
e.showMemoryFile(p, msg, mp.ProjectMemoryFile(), false)
return
}
sub := matchSubCommand(strings.ToLower(args[0]), []string{"add", "global", "show", "help"})
switch sub {
case "add":
text := strings.TrimSpace(strings.Join(args[1:], " "))
if text == "" {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryAddUsage))
return
}
e.appendMemoryFile(p, msg, mp.ProjectMemoryFile(), text)
case "global":
if len(args) == 1 {
// /memory global — show global memory
e.showMemoryFile(p, msg, mp.GlobalMemoryFile(), true)
return
}
if strings.ToLower(args[1]) == "add" {
text := strings.TrimSpace(strings.Join(args[2:], " "))
if text == "" {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryAddUsage))
return
}
e.appendMemoryFile(p, msg, mp.GlobalMemoryFile(), text)
} else {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryAddUsage))
}
case "show":
e.showMemoryFile(p, msg, mp.ProjectMemoryFile(), false)
case "help", "--help", "-h":
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryAddUsage))
default:
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryAddUsage))
}
}
func (e *Engine) showMemoryFile(p Platform, msg *Message, filePath string, isGlobal bool) {
if filePath == "" {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryNotSupported))
return
}
data, err := os.ReadFile(filePath)
if err != nil || len(strings.TrimSpace(string(data))) == 0 {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgMemoryEmpty), filePath))
return
}
content := string(data)
if len([]rune(content)) > 2000 {
content = string([]rune(content)[:2000]) + "\n\n... (truncated)"
}
if isGlobal {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgMemoryShowGlobal), filePath, content))
} else {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgMemoryShowProject), filePath, content))
}
}
func (e *Engine) appendMemoryFile(p Platform, msg *Message, filePath, text string) {
if filePath == "" {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgMemoryNotSupported))
return
}
dir := filepath.Dir(filePath)
if err := os.MkdirAll(dir, 0o755); err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgMemoryAddFailed), err))
return
}
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgMemoryAddFailed), err))
return
}
defer f.Close()
entry := "\n- " + text + "\n"
if _, err := f.WriteString(entry); err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgMemoryAddFailed), err))
return
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgMemoryAdded), filePath))
}
// ──────────────────────────────────────────────────────────────
// /cron command
// ──────────────────────────────────────────────────────────────
func (e *Engine) cmdCron(p Platform, msg *Message, args []string) {
if e.cronScheduler == nil {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCronNotAvailable))
return
}
if len(args) == 0 {
e.cmdCronList(p, msg)
return
}
sub := matchSubCommand(strings.ToLower(args[0]), []string{
"add", "list", "del", "delete", "rm", "remove", "enable", "disable",
})
switch sub {
case "add":
e.cmdCronAdd(p, msg, args[1:])
case "list":
e.cmdCronList(p, msg)
case "del", "delete", "rm", "remove":
e.cmdCronDel(p, msg, args[1:])
case "enable":
e.cmdCronToggle(p, msg, args[1:], true)
case "disable":
e.cmdCronToggle(p, msg, args[1:], false)
default:
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCronUsage))
}
}
func (e *Engine) cmdCronAdd(p Platform, msg *Message, args []string) {
// /cron add <min> <hour> <day> <month> <weekday> <prompt...>
if len(args) < 6 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCronAddUsage))
return
}
cronExpr := strings.Join(args[:5], " ")
prompt := strings.Join(args[5:], " ")
job := &CronJob{
ID: GenerateCronID(),
Project: e.name,
SessionKey: msg.SessionKey,
CronExpr: cronExpr,
Prompt: prompt,
Enabled: true,
CreatedAt: time.Now(),
}
if err := e.cronScheduler.AddJob(job); err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err))
return
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCronAdded), job.ID, cronExpr, truncateStr(prompt, 60)))
}
func (e *Engine) cmdCronList(p Platform, msg *Message) {
jobs := e.cronScheduler.Store().ListBySessionKey(msg.SessionKey)
if len(jobs) == 0 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCronEmpty))
return
}
lang := e.i18n.CurrentLang()
now := time.Now()
var sb strings.Builder
sb.WriteString(fmt.Sprintf(e.i18n.T(MsgCronListTitle), len(jobs)))
sb.WriteString("\n")
sb.WriteString("\n")
for i, j := range jobs {
if i > 0 {
sb.WriteString("\n")
}
status := "✅"
if !j.Enabled {
status = "⏸"
}
desc := j.Description
if desc == "" {
desc = truncateStr(j.Prompt, 60)
}
sb.WriteString(fmt.Sprintf("%s %s\n", status, desc))
sb.WriteString(fmt.Sprintf("ID: %s\n", j.ID))
human := CronExprToHuman(j.CronExpr, lang)
sb.WriteString(e.i18n.Tf(MsgCronScheduleLabel, human, j.CronExpr))
nextRun := e.cronScheduler.NextRun(j.ID)
if !nextRun.IsZero() {
fmtStr := cronTimeFormat(nextRun, now)
sb.WriteString(e.i18n.Tf(MsgCronNextRunLabel, nextRun.Format(fmtStr)))
}
if !j.LastRun.IsZero() {
fmtStr := cronTimeFormat(j.LastRun, now)
sb.WriteString(e.i18n.Tf(MsgCronLastRunLabel, j.LastRun.Format(fmtStr)))
if j.LastError != "" {
sb.WriteString(fmt.Sprintf(" (failed: %s)", truncateStr(j.LastError, 40)))
}
sb.WriteString("\n")
}
}
sb.WriteString(fmt.Sprintf("\n%s", e.i18n.T(MsgCronListFooter)))
e.reply(p, msg.ReplyCtx, sb.String())
}
func (e *Engine) cmdCronDel(p Platform, msg *Message, args []string) {
if len(args) == 0 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCronDelUsage))
return
}
id := args[0]
if e.cronScheduler.RemoveJob(id) {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCronDeleted), id))
} else {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCronNotFound), id))
}
}
func (e *Engine) cmdCronToggle(p Platform, msg *Message, args []string, enable bool) {
if len(args) == 0 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCronDelUsage))
return
}
id := args[0]
var err error
if enable {
err = e.cronScheduler.EnableJob(id)
} else {
err = e.cronScheduler.DisableJob(id)
}
if err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err))
return
}
if enable {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCronEnabled), id))
} else {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCronDisabled), id))
}
}
// ──────────────────────────────────────────────────────────────
// Custom command execution & management
// ──────────────────────────────────────────────────────────────
func (e *Engine) executeCustomCommand(p Platform, msg *Message, cmd *CustomCommand, args []string) {
prompt := ExpandPrompt(cmd.Prompt, args)
session := e.sessions.GetOrCreateActive(msg.SessionKey)
if !session.TryLock() {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPreviousProcessing))
return
}
slog.Info("executing custom command",
"command", cmd.Name,
"source", cmd.Source,
"user", msg.UserName,
)
msg.Content = prompt
go e.processInteractiveMessage(p, msg, session)
}
func (e *Engine) cmdCommands(p Platform, msg *Message, args []string) {
if len(args) == 0 {
e.cmdCommandsList(p, msg)
return
}
sub := matchSubCommand(strings.ToLower(args[0]), []string{
"list", "add", "del", "delete", "rm", "remove",
})
switch sub {
case "list":
e.cmdCommandsList(p, msg)
case "add":
e.cmdCommandsAdd(p, msg, args[1:])
case "del", "delete", "rm", "remove":
e.cmdCommandsDel(p, msg, args[1:])
default:
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCommandsUsage))
}
}
func (e *Engine) cmdCommandsList(p Platform, msg *Message) {
cmds := e.commands.ListAll()
if len(cmds) == 0 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCommandsEmpty))
return
}
var sb strings.Builder
sb.WriteString(e.i18n.Tf(MsgCommandsTitle, len(cmds)))
for _, c := range cmds {
desc := c.Description
if desc == "" {
desc = truncateStr(c.Prompt, 50)
}
tag := ""
if c.Source == "agent" {
tag = " [agent]"
}
sb.WriteString(fmt.Sprintf(" /%s — %s%s\n", c.Name, desc, tag))
}
sb.WriteString("\n" + e.i18n.T(MsgCommandsHint))
e.reply(p, msg.ReplyCtx, sb.String())
}
func (e *Engine) cmdCommandsAdd(p Platform, msg *Message, args []string) {
// /commands add <name> <prompt...>
if len(args) < 2 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCommandsAddUsage))
return
}
name := strings.ToLower(args[0])
prompt := strings.Join(args[1:], " ")
if _, exists := e.commands.Resolve(name); exists {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCommandsAddExists), name, name))
return
}
e.commands.Add(name, "", prompt, "config")
if e.commandSaveAddFunc != nil {
if err := e.commandSaveAddFunc(name, "", prompt); err != nil {
slog.Error("failed to persist command", "error", err)
}
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCommandsAdded), name, truncateStr(prompt, 80)))
}
func (e *Engine) cmdCommandsDel(p Platform, msg *Message, args []string) {
if len(args) == 0 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgCommandsDelUsage))
return
}
name := strings.ToLower(args[0])
if !e.commands.Remove(name) {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCommandsNotFound), name))
return
}
if e.commandSaveDelFunc != nil {
if err := e.commandSaveDelFunc(name); err != nil {
slog.Error("failed to persist command removal", "error", err)
}
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCommandsDeleted), name))
}
// ──────────────────────────────────────────────────────────────
// Skill discovery & execution
// ──────────────────────────────────────────────────────────────
func (e *Engine) executeSkill(p Platform, msg *Message, skill *Skill, args []string) {
prompt := BuildSkillInvocationPrompt(skill, args)
session := e.sessions.GetOrCreateActive(msg.SessionKey)
if !session.TryLock() {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPreviousProcessing))
return
}
slog.Info("executing skill",
"skill", skill.Name,
"source", skill.Source,
"user", msg.UserName,
)
msg.Content = prompt
go e.processInteractiveMessage(p, msg, session)
}
func (e *Engine) cmdSkills(p Platform, msg *Message) {
skills := e.skills.ListAll()
if len(skills) == 0 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgSkillsEmpty))
return
}
var sb strings.Builder
sb.WriteString(e.i18n.Tf(MsgSkillsTitle, e.agent.Name(), len(skills)))
for _, s := range skills {
desc := s.Description
name := s.Name
sb.WriteString(fmt.Sprintf(" /%s — %s\n", name, desc))
}
sb.WriteString("\n" + e.i18n.T(MsgSkillsHint))
e.reply(p, msg.ReplyCtx, sb.String())
}
// ── /config command ──────────────────────────────────────────
// configItem describes a configurable runtime parameter.
type configItem struct {
key string
desc string // en description
descZh string // zh description
getFunc func() string
setFunc func(string) error
}
func (ci configItem) description(isZh bool) string {
if isZh && ci.descZh != "" {
return ci.descZh
}
return ci.desc
}
func (e *Engine) configItems() []configItem {
return []configItem{
{
key: "thinking_max_len",
desc: "Max chars for thinking messages (0=no truncation)",
descZh: "思考消息最大长度 (0=不截断)",
getFunc: func() string {
return fmt.Sprintf("%d", e.display.ThinkingMaxLen)
},
setFunc: func(v string) error {
n, err := strconv.Atoi(v)
if err != nil {
return fmt.Errorf("invalid integer: %s", v)
}
if n < 0 {
return fmt.Errorf("value must be >= 0")
}
e.display.ThinkingMaxLen = n
if e.displaySaveFunc != nil {
return e.displaySaveFunc(&n, nil)
}
return nil
},
},
{
key: "tool_max_len",
desc: "Max chars for tool use messages (0=no truncation)",
descZh: "工具消息最大长度 (0=不截断)",
getFunc: func() string {
return fmt.Sprintf("%d", e.display.ToolMaxLen)
},
setFunc: func(v string) error {
n, err := strconv.Atoi(v)
if err != nil {
return fmt.Errorf("invalid integer: %s", v)
}
if n < 0 {
return fmt.Errorf("value must be >= 0")
}
e.display.ToolMaxLen = n
if e.displaySaveFunc != nil {
return e.displaySaveFunc(nil, &n)
}
return nil
},
},
}
}
func (e *Engine) cmdConfig(p Platform, msg *Message, args []string) {
items := e.configItems()
isZh := e.i18n.IsZhLike()
if len(args) == 0 {
var sb strings.Builder
sb.WriteString(e.i18n.T(MsgConfigTitle))
for _, item := range items {
sb.WriteString(fmt.Sprintf("`%s` = `%s`\n %s\n\n", item.key, item.getFunc(), item.description(isZh)))
}
sb.WriteString(e.i18n.T(MsgConfigHint))
e.reply(p, msg.ReplyCtx, sb.String())
return
}
sub := matchSubCommand(strings.ToLower(args[0]), []string{"get", "set", "reload"})
switch sub {
case "reload":
e.cmdConfigReload(p, msg)
return
case "get":
if len(args) < 2 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgConfigGetUsage))
return
}
key := strings.ToLower(args[1])
for _, item := range items {
if item.key == key {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("`%s` = `%s`\n %s", key, item.getFunc(), item.description(isZh)))
return
}
}
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgConfigKeyNotFound, key))
case "set":
if len(args) < 3 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgConfigSetUsage))
return
}
key := strings.ToLower(args[1])
value := args[2]
for _, item := range items {
if item.key == key {
if err := item.setFunc(value); err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err))
return
}
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgConfigUpdated, key, item.getFunc()))
return
}
}
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgConfigKeyNotFound, key))
default:
key := strings.ToLower(sub)
for _, item := range items {
if item.key == key {
if len(args) >= 2 {
if err := item.setFunc(args[1]); err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err))
return
}
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgConfigUpdated, key, item.getFunc()))
} else {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("`%s` = `%s`\n %s", key, item.getFunc(), item.description(isZh)))
}
return
}
}
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgConfigKeyNotFound, key))
}
}
// ── /doctor command ─────────────────────────────────────────
func (e *Engine) cmdDoctor(p Platform, msg *Message) {
results := RunDoctorChecks(e.ctx, e.agent, e.platforms)
report := FormatDoctorResults(results, e.i18n)
e.reply(p, msg.ReplyCtx, report)
}
func (e *Engine) cmdUpgrade(p Platform, msg *Message, args []string) {
subCmd := ""
if len(args) > 0 {
subCmd = matchSubCommand(args[0], []string{"confirm", "check"})
}
if subCmd == "confirm" {
e.cmdUpgradeConfirm(p, msg)
return
}
// Default: check for updates
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgUpgradeChecking))
cur := CurrentVersion
if cur == "" || cur == "dev" {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgUpgradeDevBuild))
return
}
release, err := CheckForUpdate(cur)
if err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %s", err))
return
}
if release == nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgUpgradeUpToDate), cur))
return
}
body := release.Body
if len([]rune(body)) > 300 {
body = string([]rune(body)[:300]) + "…"
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgUpgradeAvailable), cur, release.TagName, body))
}
func (e *Engine) cmdUpgradeConfirm(p Platform, msg *Message) {
cur := CurrentVersion
if cur == "" || cur == "dev" {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgUpgradeDevBuild))
return
}
release, err := CheckForUpdate(cur)
if err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %s", err))
return
}
if release == nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgUpgradeUpToDate), cur))
return
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgUpgradeDownloading), release.TagName))
if err := SelfUpdate(release.TagName); err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %s", err))
return
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgUpgradeSuccess), release.TagName))
}
func (e *Engine) cmdConfigReload(p Platform, msg *Message) {
if e.configReloadFunc == nil {
e.reply(p, msg.ReplyCtx, "❌ Config reload not available")
return
}
result, err := e.configReloadFunc()
if err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err))
return
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgConfigReloaded),
result.DisplayUpdated, result.ProvidersUpdated, result.CommandsUpdated))
}
func (e *Engine) cmdRestart(p Platform, msg *Message) {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgRestarting))
select {
case RestartCh <- RestartRequest{
SessionKey: msg.SessionKey,
Platform: p.Name(),
}:
default:
}
}
func (e *Engine) cmdAlias(p Platform, msg *Message, args []string) {
if len(args) == 0 {
e.cmdAliasList(p, msg)
return
}
sub := matchSubCommand(strings.ToLower(args[0]), []string{"list", "add", "del", "delete", "remove"})
switch sub {
case "list":
e.cmdAliasList(p, msg)
case "add":
e.cmdAliasAdd(p, msg, args[1:])
case "del", "delete", "remove":
e.cmdAliasDel(p, msg, args[1:])
default:
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgAliasUsage)))
}
}
func (e *Engine) cmdAliasList(p Platform, msg *Message) {
e.aliasMu.RLock()
defer e.aliasMu.RUnlock()
if len(e.aliases) == 0 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgAliasEmpty))
return
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf(e.i18n.T(MsgAliasListHeader), len(e.aliases)))
sb.WriteString("\n")
names := make([]string, 0, len(e.aliases))
for n := range e.aliases {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
sb.WriteString(fmt.Sprintf(" %s → %s\n", n, e.aliases[n]))
}
e.reply(p, msg.ReplyCtx, strings.TrimRight(sb.String(), "\n"))
}
func (e *Engine) cmdAliasAdd(p Platform, msg *Message, args []string) {
if len(args) < 2 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgAliasUsage))
return
}
name := args[0]
command := strings.Join(args[1:], " ")
if !strings.HasPrefix(command, "/") {
command = "/" + command
}
e.aliasMu.Lock()
e.aliases[name] = command
e.aliasMu.Unlock()
if e.aliasSaveAddFunc != nil {
if err := e.aliasSaveAddFunc(name, command); err != nil {
slog.Error("alias: save failed", "error", err)
}
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgAliasAdded), name, command))
}
func (e *Engine) cmdAliasDel(p Platform, msg *Message, args []string) {
if len(args) < 1 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgAliasUsage))
return
}
name := args[0]
e.aliasMu.Lock()
_, exists := e.aliases[name]
if exists {
delete(e.aliases, name)
}
e.aliasMu.Unlock()
if !exists {
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgAliasNotFound), name))
return
}
if e.aliasSaveDelFunc != nil {
if err := e.aliasSaveDelFunc(name); err != nil {
slog.Error("alias: save failed", "error", err)
}
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgAliasDeleted), name))
}
func (e *Engine) cmdDelete(p Platform, msg *Message, args []string) {
deleter, ok := e.agent.(SessionDeleter)
if !ok {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgDeleteNotSupported))
return
}
if len(args) == 0 {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgDeleteUsage))
return
}
agentSessions, err := e.agent.ListSessions(e.ctx)
if err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err))
return
}
prefix := strings.TrimSpace(args[0])
var matched *AgentSessionInfo
if idx, err := strconv.Atoi(prefix); err == nil && idx >= 1 && idx <= len(agentSessions) {
matched = &agentSessions[idx-1]
} else {
for i := range agentSessions {
if strings.HasPrefix(agentSessions[i].ID, prefix) {
matched = &agentSessions[i]
break
}
}
}
if matched == nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ No session matching %q", prefix))
return
}
// Prevent deleting the currently active session
activeSession := e.sessions.GetOrCreateActive(msg.SessionKey)
if activeSession.AgentSessionID == matched.ID {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgDeleteActiveDenied))
return
}
displayName := e.sessions.GetSessionName(matched.ID)
if displayName == "" {
displayName = matched.Summary
}
if displayName == "" {
shortID := matched.ID
if len(shortID) > 12 {
shortID = shortID[:12]
}
displayName = shortID
}
if err := deleter.DeleteSession(e.ctx, matched.ID); err != nil {
e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err))
return
}
e.sessions.SetSessionName(matched.ID, "")
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgDeleteSuccess), displayName))
}
// truncateIf truncates s to maxLen runes. 0 means no truncation.
func truncateIf(s string, maxLen int) string {
if maxLen <= 0 {
return s
}
if utf8.RuneCountInString(s) <= maxLen {
return s
}
return string([]rune(s)[:maxLen]) + "..."
}
func splitMessage(text string, maxLen int) []string {
if len(text) <= maxLen {
return []string{text}
}
var chunks []string
for len(text) > 0 {
end := maxLen
if end > len(text) {
end = len(text)
}
if end < len(text) {
if idx := strings.LastIndex(text[:end], "\n"); idx > 0 {
end = idx + 1
}
}
chunks = append(chunks, text[:end])
text = text[end:]
}
return chunks
}