From 36b48d60276bc18e26305d09a35886a0362823b1 Mon Sep 17 00:00:00 2001 From: Joe Skerratt Date: Mon, 6 Apr 2026 20:51:27 +0100 Subject: [PATCH] feat(codex): add telegram planner-executor flow --- pkg/agent/codex_executor.go | 603 ++++++++++++ pkg/agent/codex_runs.go | 932 ++++++++++++++++++ pkg/agent/codex_runs_test.go | 273 ++++++ pkg/agent/codex_sessions.go | 747 +++++++++++++++ pkg/agent/instance.go | 156 ++- pkg/agent/loop.go | 928 +++++++++++++++++- pkg/agent/loop_test.go | 598 +++++++++++- pkg/agent/model_resolution.go | 22 + pkg/commands/builtin.go | 4 + pkg/commands/cmd_codex.go | 1103 ++++++++++++++++++++++ pkg/commands/cmd_codex_test.go | 976 +++++++++++++++++++ pkg/commands/cmd_mode.go | 277 +++++- pkg/commands/cmd_mode_test.go | 180 +++- pkg/commands/executor.go | 8 + pkg/commands/runtime.go | 73 +- pkg/config/config.go | 91 +- pkg/config/config_test.go | 134 ++- pkg/config/defaults.go | 89 +- pkg/config/model_config_test.go | 89 ++ pkg/providers/codex_cli_provider.go | 59 +- pkg/providers/codex_cli_provider_test.go | 33 + pkg/routing/classifier.go | 4 + pkg/routing/features.go | 58 +- pkg/routing/router.go | 69 +- pkg/routing/router_test.go | 119 ++- pkg/tools/git.go | 576 +++++++++++ pkg/tools/github.go | 840 ++++++++++++++++ pkg/tools/github_test.go | 175 ++++ 28 files changed, 9041 insertions(+), 175 deletions(-) create mode 100644 pkg/agent/codex_executor.go create mode 100644 pkg/agent/codex_runs.go create mode 100644 pkg/agent/codex_runs_test.go create mode 100644 pkg/agent/codex_sessions.go create mode 100644 pkg/commands/cmd_codex.go create mode 100644 pkg/commands/cmd_codex_test.go create mode 100644 pkg/tools/git.go create mode 100644 pkg/tools/github.go create mode 100644 pkg/tools/github_test.go diff --git a/pkg/agent/codex_executor.go b/pkg/agent/codex_executor.go new file mode 100644 index 000000000..600268b35 --- /dev/null +++ b/pkg/agent/codex_executor.go @@ -0,0 +1,603 @@ +package agent + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +const codexPromptHistoryLimit = 24 + +func (al *AgentLoop) findCodexPlannerModelName(cfg *config.Config) string { + if cfg == nil { + return "" + } + + if mc, err := cfg.GetModelConfig("gpt-5.4-mini"); err == nil && mc != nil { + proto, _ := providers.ExtractProtocol(mc.Model) + if !strings.EqualFold(proto, "codex-cli") && !strings.EqualFold(proto, "codexcli") { + return "gpt-5.4-mini" + } + } + + defaultName := strings.TrimSpace(cfg.Agents.Defaults.ModelName) + if defaultName != "" { + if mc, err := cfg.GetModelConfig(defaultName); err == nil && mc != nil { + proto, _ := providers.ExtractProtocol(mc.Model) + if !strings.EqualFold(proto, "codex-cli") && !strings.EqualFold(proto, "codexcli") { + return defaultName + } + } + } + + for _, mc := range cfg.ModelList { + if mc == nil || strings.TrimSpace(mc.ModelName) == "" { + continue + } + proto, _ := providers.ExtractProtocol(mc.Model) + if !strings.EqualFold(proto, "codex-cli") && !strings.EqualFold(proto, "codexcli") { + return strings.TrimSpace(mc.ModelName) + } + } + return "" +} + +func resolveCodexCLIModelArg(cfg *config.Config, modelName string) string { + modelName = strings.TrimSpace(modelName) + if modelName == "" { + return "" + } + + if cfg != nil { + if mc, err := cfg.GetModelConfig(modelName); err == nil && mc != nil { + proto, modelID := providers.ExtractProtocol(mc.Model) + if strings.EqualFold(proto, "codex-cli") || strings.EqualFold(proto, "codexcli") { + return strings.TrimSpace(modelID) + } + } + } + + proto, modelID := providers.ExtractProtocol(modelName) + if strings.EqualFold(proto, "codex-cli") || strings.EqualFold(proto, "codexcli") { + return strings.TrimSpace(modelID) + } + + return modelName +} + +func (al *AgentLoop) codexPlannerStatusRuntimeInfo(sessionKey string) (*commands.CodexPlannerStatusInfo, bool) { + if al == nil || al.codexStore == nil { + return nil, false + } + sessionRec, ok := al.codexStore.Active(sessionKey) + if !ok || sessionRec == nil { + return nil, false + } + runtime, _ := al.codexStore.SessionRuntime(sessionKey) + phase := "planning" + switch { + case runtime.DeployConfirmPending: + phase = "awaiting deploy approval" + case runtime.ApprovalPending: + phase = "awaiting approval" + } + if active, ok := al.codexStore.ActiveRun(sessionKey); ok && active != nil { + switch strings.ToLower(strings.TrimSpace(active.Status)) { + case codexRunStatusQueued: + phase = "queued" + case codexRunStatusRunning: + phase = "executing" + default: + phase = strings.TrimSpace(active.Status) + } + } + info := &commands.CodexPlannerStatusInfo{ + Phase: phase, + Model: strings.TrimSpace(runtime.PlannerModel), + SessionID: sessionRec.ID, + RepoSlug: sessionRec.Slug, + RepoPath: sessionRec.RepoPath, + RepoURL: sessionRec.RepoURL, + ApprovalPending: runtime.ApprovalPending, + } + if info.Model == "" { + info.Model = strings.TrimSpace(al.findCodexPlannerModelName(al.GetConfig())) + } + return info, true +} + +func (al *AgentLoop) codexRunStatusRuntimeInfo(sessionKey string) (*commands.CodexRunInfo, bool) { + if al == nil || al.codexStore == nil { + return nil, false + } + if rec, ok := al.codexStore.ActiveRun(sessionKey); ok && rec != nil { + info := codexRunRecordToInfo(rec, true) + return &info, true + } + runs := al.codexStore.ListRuns(sessionKey) + if len(runs) == 0 { + return nil, false + } + info := codexRunRecordToInfo(&runs[0], false) + return &info, true +} + +func (al *AgentLoop) codexRunListRuntimeInfo(sessionKey string) []commands.CodexRunInfo { + if al == nil || al.codexStore == nil { + return nil + } + runs := al.codexStore.ListRuns(sessionKey) + out := make([]commands.CodexRunInfo, 0, len(runs)) + activeID := "" + if active, ok := al.codexStore.ActiveRun(sessionKey); ok && active != nil { + activeID = active.ID + } + for i := range runs { + rec := runs[i] + out = append(out, codexRunRecordToInfo(&rec, rec.ID == activeID)) + } + return out +} + +func codexRunRecordToInfo(rec *codexRunRecord, active bool) commands.CodexRunInfo { + if rec == nil { + return commands.CodexRunInfo{} + } + return commands.CodexRunInfo{ + ID: rec.ID, + SessionID: rec.SessionID, + RepoSlug: rec.RepoSlug, + RepoPath: rec.RepoPath, + RepoURL: rec.RepoURL, + Branch: rec.BranchName, + Worktree: rec.WorktreePath, + Model: rec.ExecutorModel, + Status: rec.Status, + PID: rec.PID, + ExitCode: rec.ExitCode, + Active: active, + StartedAt: rec.StartedAt, + UpdatedAt: rec.UpdatedAt, + FinishedAt: rec.FinishedAt, + } +} + +func (al *AgentLoop) codexRunTail(sessionKey, runID string, lines int) (string, error) { + if al == nil || al.codexStore == nil { + return "", fmt.Errorf("codex runs are not initialized") + } + runID = strings.TrimSpace(runID) + if runID == "" { + if active, ok := al.codexStore.ActiveRun(sessionKey); ok && active != nil { + runID = active.ID + } else { + runs := al.codexStore.ListRuns(sessionKey) + if len(runs) == 0 { + return "", fmt.Errorf("no codex run is active yet") + } + runID = runs[0].ID + } + } + run, ok := al.codexStore.GetRun(runID) + if !ok || run == nil { + return "", fmt.Errorf("codex run %q not found", runID) + } + if strings.TrimSpace(run.LogPath) == "" { + return "", nil + } + return tailFileLines(run.LogPath, lines) +} + +func (al *AgentLoop) startApprovedCodexRun( + _ context.Context, + agent *AgentInstance, + opts *processOptions, + userMessage string, +) (string, error) { + if al == nil || al.codexStore == nil { + return "", fmt.Errorf("codex runs are not initialized") + } + if agent == nil || opts == nil { + return "", fmt.Errorf("codex planner context is incomplete") + } + sessionRec, ok := al.codexStore.Active(opts.SessionKey) + if !ok || sessionRec == nil { + return "", fmt.Errorf("no active codex session in this chat") + } + runtime, _ := al.codexStore.SessionRuntime(opts.SessionKey) + if !runtime.ApprovalPending { + return "", fmt.Errorf("no codex plan is awaiting approval yet") + } + + plannerModel := strings.TrimSpace(runtime.PlannerModel) + if plannerModel == "" { + plannerModel = strings.TrimSpace(al.getSessionModelOverride(opts.SessionKey)) + } + if plannerModel == "" { + plannerModel = strings.TrimSpace(al.findCodexPlannerModelName(al.GetConfig())) + } + + executorModel := strings.TrimSpace(runtime.ExecutorModel) + if executorModel == "" { + executorModel = strings.TrimSpace(al.findCodexModelName(al.GetConfig())) + } + if executorModel == "" { + return "", fmt.Errorf("no codex-cli model configured in model_list") + } + + run, err := al.codexStore.CreateRun(opts.SessionKey, codexRunCreateOptions{ + PlannerModel: plannerModel, + ExecutorModel: executorModel, + Mode: "autonomous", + PlanID: runtime.PendingPlanID, + PlanHash: runtime.PendingPlanHash, + InitiatedBy: strings.TrimSpace(opts.SenderID), + }) + if err != nil { + return "", err + } + + worktree, err := al.codexStore.PrepareRunWorktree(run.ID) + if err != nil { + _ = al.codexStore.MarkRunFailed(run.ID, -1, err.Error()) + return "", err + } + + workspace := "" + if agent != nil { + workspace = agent.Workspace + } + logDir := filepath.Join(workspace, "logs", "codex") + if err := os.MkdirAll(logDir, 0o700); err != nil { + _ = al.codexStore.MarkRunFailed(run.ID, -1, err.Error()) + return "", err + } + logPath := filepath.Join(logDir, run.ID+".log") + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + _ = al.codexStore.MarkRunFailed(run.ID, -1, err.Error()) + return "", err + } + + history := agent.Sessions.GetHistory(opts.SessionKey) + summary := agent.Sessions.GetSummary(opts.SessionKey) + planText := latestAssistantMessage(history) + promptMessages := buildCodexExecutionPromptMessages(sessionRec, run, summary, history, planText, userMessage) + prompt := providers.BuildCodexCLIPrompt(promptMessages, nil) + + cliModel := resolveCodexCLIModelArg(al.GetConfig(), executorModel) + args := providers.BuildCodexCLIArgs(cliModel, worktree) + cmd := exec.Command("codex", args...) + cmd.Stdin = strings.NewReader(prompt) + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + _ = logFile.Close() + _ = al.codexStore.MarkRunFailed(run.ID, -1, err.Error()) + return "", fmt.Errorf("failed to start codex run: %w", err) + } + + if err := al.codexStore.MarkRunStarted(run.ID, cmd.Process.Pid, logPath); err != nil { + _ = killCodexProcess(cmd.Process.Pid) + _ = logFile.Close() + return "", err + } + _ = al.codexStore.UpdateSessionRuntime(opts.SessionKey, func(runtime *codexSessionRuntimeState) { + runtime.WorkMode = "codex-plan" + runtime.ApprovalPending = false + runtime.PendingPlanID = "" + runtime.PendingPlanHash = "" + runtime.ActiveRunID = run.ID + runtime.LastRunID = run.ID + runtime.PlannerModel = plannerModel + runtime.ExecutorModel = executorModel + }) + + go al.waitForCodexRun(cmd, logFile, run.ID, opts.Channel, opts.ChatID) + + return strings.Join([]string{ + fmt.Sprintf("Codex run started: %s", run.ID), + fmt.Sprintf("Repo: %s", sessionRec.Slug), + fmt.Sprintf("Planner: %s", plannerModel), + fmt.Sprintf("Executor: %s", executorModel), + fmt.Sprintf("Worktree: %s", worktree), + "Use /codex status or /codex tail to inspect progress.", + }, "\n"), nil +} + +func (al *AgentLoop) waitForCodexRun(cmd *exec.Cmd, logFile *os.File, runID, channel, chatID string) { + err := cmd.Wait() + _ = logFile.Close() + + run, ok := al.codexStore.GetRun(runID) + if !ok || run == nil { + return + } + + exitCode := 0 + status := codexRunStatusSucceeded + if err != nil { + status = codexRunStatusFailed + exitCode = -1 + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + } + if status == codexRunStatusSucceeded { + _ = al.codexStore.MarkRunFinished(runID, codexRunStatusSucceeded, exitCode, "") + } else { + _ = al.codexStore.MarkRunFailed(runID, exitCode, err.Error()) + } + + if finished, ok := al.codexStore.GetRun(runID); ok && finished != nil { + if status == codexRunStatusSucceeded && isPicoClawRun(finished) { + _ = al.codexStore.UpdateSessionRuntime(finished.ScopeKey, func(runtime *codexSessionRuntimeState) { + runtime.DeployConfirmPending = true + runtime.LastRunID = finished.ID + runtime.ActiveRunID = "" + }) + } + al.sendCodexRunNotification(channel, chatID, finished) + } +} + +func (al *AgentLoop) startApprovedPicoClawDeploy(scopeKey, channel, chatID string) (string, error) { + if al == nil || al.codexStore == nil { + return "", fmt.Errorf("codex runs are not initialized") + } + sessionRec, ok := al.codexStore.Active(scopeKey) + if !ok || sessionRec == nil { + return "", fmt.Errorf("no active codex session in this chat") + } + runtime, ok := al.codexStore.SessionRuntime(scopeKey) + if !ok || !runtime.DeployConfirmPending || strings.TrimSpace(runtime.LastRunID) == "" { + return "", fmt.Errorf("no PicoClaw deploy is awaiting confirmation") + } + baseRun, ok := al.codexStore.GetRun(runtime.LastRunID) + if !ok || baseRun == nil { + return "", fmt.Errorf("the last approved PicoClaw run could not be found") + } + if strings.TrimSpace(baseRun.WorktreePath) == "" { + return "", fmt.Errorf("the approved PicoClaw run does not have a worktree to deploy") + } + + plannerModel := strings.TrimSpace(runtime.PlannerModel) + executorModel := "deploy-script" + run, err := al.codexStore.CreateRun(scopeKey, codexRunCreateOptions{ + PlannerModel: plannerModel, + ExecutorModel: executorModel, + Mode: "deploy", + InitiatedBy: "deploy-approval", + }) + if err != nil { + return "", err + } + + workspace := filepath.Dir(filepath.Dir(al.codexStore.stateFile)) + logDir := filepath.Join(workspace, "logs", "codex") + if err := os.MkdirAll(logDir, 0o700); err != nil { + _ = al.codexStore.MarkRunFailed(run.ID, -1, err.Error()) + return "", err + } + logPath := filepath.Join(logDir, run.ID+".log") + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + _ = al.codexStore.MarkRunFailed(run.ID, -1, err.Error()) + return "", err + } + + scriptPath := filepath.Join(workspace, "scripts", "picoclaw_deploy_local.sh") + cmd := exec.Command("bash", scriptPath) + cmd.Env = append(os.Environ(), + "SRC_DIR="+baseRun.WorktreePath, + ) + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + _ = logFile.Close() + _ = al.codexStore.MarkRunFailed(run.ID, -1, err.Error()) + return "", fmt.Errorf("failed to start deploy run: %w", err) + } + + if err := al.codexStore.MarkRunStarted(run.ID, cmd.Process.Pid, logPath); err != nil { + _ = killCodexProcess(cmd.Process.Pid) + _ = logFile.Close() + return "", err + } + _ = al.codexStore.UpdateSessionRuntime(scopeKey, func(runtime *codexSessionRuntimeState) { + runtime.DeployConfirmPending = false + runtime.WorkMode = "codex-plan" + runtime.ActiveRunID = run.ID + runtime.LastRunID = run.ID + }) + + go al.waitForCodexRun(cmd, logFile, run.ID, channel, chatID) + + return strings.Join([]string{ + fmt.Sprintf("PicoClaw deploy started: %s", run.ID), + fmt.Sprintf("Source worktree: %s", baseRun.WorktreePath), + "Use /codex status or /codex tail to inspect progress.", + }, "\n"), nil +} + +func (al *AgentLoop) stopActiveCodexRun(scopeKey string) error { + if al == nil || al.codexStore == nil { + return fmt.Errorf("codex runs are not initialized") + } + run, ok := al.codexStore.ActiveRun(scopeKey) + if !ok || run == nil { + return nil + } + if run.PID <= 0 { + return al.codexStore.MarkRunStopped(run.ID, "run stopped without a live pid") + } + if err := killCodexProcess(run.PID); err != nil { + return err + } + return al.codexStore.MarkRunStopped(run.ID, "run stopped by user") +} + +func latestAssistantMessage(history []providers.Message) string { + for i := len(history) - 1; i >= 0; i-- { + if history[i].Role == "assistant" && strings.TrimSpace(history[i].Content) != "" { + return strings.TrimSpace(history[i].Content) + } + } + return "" +} + +func buildCodexExecutionPromptMessages( + sessionRec *codexSessionRecord, + run *codexRunRecord, + summary string, + history []providers.Message, + planText string, + userMessage string, +) []providers.Message { + systemLines := []string{ + "You are the Codex executor for a managed Telegram /codex session.", + "Execute the approved plan inside the active repo worktree.", + "Make concrete progress autonomously and validate changes with focused commands where practical.", + "Do not deploy, restart services, or mutate system-wide state in this phase.", + } + if sessionRec != nil { + systemLines = append(systemLines, + "Repo slug: "+strings.TrimSpace(sessionRec.Slug), + "Canonical repo path: "+strings.TrimSpace(sessionRec.RepoPath), + ) + if strings.TrimSpace(sessionRec.RepoURL) != "" { + systemLines = append(systemLines, "Repo remote: "+strings.TrimSpace(sessionRec.RepoURL)) + } + } + if run != nil && strings.TrimSpace(run.WorktreePath) != "" { + systemLines = append(systemLines, "Execution worktree: "+strings.TrimSpace(run.WorktreePath)) + } + + msgs := []providers.Message{{Role: "system", Content: strings.Join(systemLines, "\n")}} + if strings.TrimSpace(summary) != "" { + msgs = append(msgs, providers.Message{ + Role: "system", + Content: "Conversation summary:\n" + strings.TrimSpace(summary), + }) + } + if len(history) > codexPromptHistoryLimit { + history = history[len(history)-codexPromptHistoryLimit:] + } + for _, msg := range history { + if strings.TrimSpace(msg.Content) == "" { + continue + } + msgs = append(msgs, providers.Message{ + Role: msg.Role, + Content: msg.Content, + }) + } + + approvalText := "The user explicitly approved the latest plan. Execute it now in the active repo worktree." + if strings.TrimSpace(planText) != "" { + approvalText += "\nApproved plan:\n" + strings.TrimSpace(planText) + } + if strings.TrimSpace(userMessage) != "" { + approvalText += "\nApproval message: " + strings.TrimSpace(userMessage) + } + msgs = append(msgs, providers.Message{Role: "user", Content: approvalText}) + return msgs +} + +func (al *AgentLoop) sendCodexRunNotification(channel, chatID string, run *codexRunRecord) { + if al == nil || al.bus == nil || strings.TrimSpace(channel) == "" || strings.TrimSpace(chatID) == "" || run == nil { + return + } + + text := fmt.Sprintf("Codex run %s finished with status: %s.", run.ID, run.Status) + if strings.TrimSpace(run.LogPath) != "" { + if summary, err := tailFileLines(run.LogPath, 20); err == nil && strings.TrimSpace(summary) != "" { + if parsed, err := providers.ParseCodexCLIJSONLEvents(summary); err == nil && parsed != nil && strings.TrimSpace(parsed.Content) != "" { + text += "\n" + strings.TrimSpace(parsed.Content) + } else { + text += "\nLog tail:\n" + strings.TrimSpace(summary) + } + } + } + if isPicoClawRun(run) && run.Status == codexRunStatusSucceeded && strings.TrimSpace(run.Mode) != "deploy" { + text += "\nReply `deploy` to apply and restart PicoClaw from this worktree." + } + + pubCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: text, + }) +} + +func isPicoClawRun(run *codexRunRecord) bool { + if run == nil { + return false + } + all := strings.ToLower(strings.Join([]string{run.RepoSlug, run.RepoPath, run.RepoURL}, " ")) + return strings.Contains(all, "picoclaw") +} + +func killCodexProcess(pid int) error { + if pid <= 0 { + return nil + } + if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil && err != syscall.ESRCH { + return err + } + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if err := syscall.Kill(pid, 0); err != nil { + return nil + } + time.Sleep(150 * time.Millisecond) + } + if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil && err != syscall.ESRCH { + return err + } + return nil +} + +func codexPlanIdentity(planText string) (string, string) { + planText = strings.TrimSpace(planText) + if planText == "" { + return "", "" + } + sum := sha256.Sum256([]byte(planText)) + return "plan-" + newCodexRunID(), hex.EncodeToString(sum[:8]) +} + +func tailFileLines(path string, lines int) (string, error) { + if lines <= 0 { + lines = 120 + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + text := strings.ReplaceAll(string(data), "\r\n", "\n") + parts := strings.Split(text, "\n") + if len(parts) > 0 && parts[len(parts)-1] == "" { + parts = parts[:len(parts)-1] + } + if len(parts) > lines { + parts = parts[len(parts)-lines:] + } + return strings.Join(parts, "\n"), nil +} diff --git a/pkg/agent/codex_runs.go b/pkg/agent/codex_runs.go new file mode 100644 index 000000000..9245276e0 --- /dev/null +++ b/pkg/agent/codex_runs.go @@ -0,0 +1,932 @@ +package agent + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "syscall" + "time" +) + +const ( + codexRunStatusQueued = "queued" + codexRunStatusRunning = "running" + codexRunStatusSucceeded = "succeeded" + codexRunStatusFailed = "failed" + codexRunStatusStopped = "stopped" + codexRunStatusUnknown = "unknown" +) + +type codexSessionRuntimeState struct { + PlannerModel string `json:"planner_model,omitempty"` + ExecutorModel string `json:"executor_model,omitempty"` + WorkMode string `json:"work_mode,omitempty"` + ApprovalPending bool `json:"approval_pending,omitempty"` + DeployConfirmPending bool `json:"deploy_confirm_pending,omitempty"` + PendingPlanID string `json:"pending_plan_id,omitempty"` + PendingPlanHash string `json:"pending_plan_hash,omitempty"` + ActiveRunID string `json:"active_run_id,omitempty"` + LastRunID string `json:"last_run_id,omitempty"` +} + +type codexRunRecord struct { + ID string `json:"id"` + ScopeKey string `json:"scope_key,omitempty"` + SessionID string `json:"session_id"` + RepoSlug string `json:"repo_slug"` + RepoPath string `json:"repo_path"` + RepoURL string `json:"repo_url,omitempty"` + WorktreePath string `json:"worktree_path"` + BranchName string `json:"branch_name"` + PlannerModel string `json:"planner_model,omitempty"` + ExecutorModel string `json:"executor_model,omitempty"` + Mode string `json:"mode,omitempty"` + Status string `json:"status"` + PID int `json:"pid,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + LogPath string `json:"log_path,omitempty"` + PlanID string `json:"plan_id,omitempty"` + PlanHash string `json:"plan_hash,omitempty"` + InitiatedBy string `json:"initiated_by,omitempty"` + DeployConfirmPending bool `json:"deploy_confirm_pending,omitempty"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` + StartedAt time.Time `json:"started_at,omitempty"` + FinishedAt time.Time `json:"finished_at,omitempty"` + LastHeartbeatAt time.Time `json:"last_heartbeat_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type codexRunSnapshot struct { + Version int `json:"version"` + Runs map[string]*codexRunRecord `json:"runs"` + ActiveBySession map[string]string `json:"active_by_session"` + ActiveByRepo map[string]string `json:"active_by_repo"` +} + +type codexRunCreateOptions struct { + PlannerModel string + ExecutorModel string + Mode string + PlanID string + PlanHash string + InitiatedBy string + DeployConfirmPending bool +} + +func (s *codexSessionStore) SessionRuntime(scopeKey string) (codexSessionRuntimeState, bool) { + scopeKey = strings.TrimSpace(scopeKey) + if s == nil || scopeKey == "" { + return codexSessionRuntimeState{}, false + } + + s.mu.RLock() + defer s.mu.RUnlock() + + rec := s.sessionRecordByScopeLocked(scopeKey) + if rec == nil { + return codexSessionRuntimeState{}, false + } + return sessionRuntimeFromRecord(rec), true +} + +func (s *codexSessionStore) SetSessionRuntime(scopeKey string, runtime codexSessionRuntimeState) error { + scopeKey = strings.TrimSpace(scopeKey) + if s == nil || scopeKey == "" { + return fmt.Errorf("codex sessions require a valid session scope") + } + + s.mu.Lock() + defer s.mu.Unlock() + + rec := s.sessionRecordByScopeLocked(scopeKey) + if rec == nil { + return fmt.Errorf("codex session not found for scope %q", scopeKey) + } + applySessionRuntimeLocked(rec, runtime) + rec.UpdatedAt = time.Now().UTC() + return s.saveLocked() +} + +func (s *codexSessionStore) UpdateSessionRuntime(scopeKey string, fn func(*codexSessionRuntimeState)) error { + scopeKey = strings.TrimSpace(scopeKey) + if s == nil || scopeKey == "" { + return fmt.Errorf("codex sessions require a valid session scope") + } + if fn == nil { + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + rec := s.sessionRecordByScopeLocked(scopeKey) + if rec == nil { + return fmt.Errorf("codex session not found for scope %q", scopeKey) + } + + runtime := sessionRuntimeFromRecord(rec) + fn(&runtime) + applySessionRuntimeLocked(rec, runtime) + rec.UpdatedAt = time.Now().UTC() + return s.saveLocked() +} + +func (s *codexSessionStore) CreateRun(scopeKey string, opts codexRunCreateOptions) (*codexRunRecord, error) { + scopeKey = strings.TrimSpace(scopeKey) + if s == nil || scopeKey == "" { + return nil, fmt.Errorf("codex sessions require a valid session scope") + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.normalizeSessionStateLocked() + s.normalizeRunStateLocked() + + sessionRec := s.sessionRecordByScopeLocked(scopeKey) + if sessionRec == nil { + return nil, fmt.Errorf("codex session not found for scope %q", scopeKey) + } + if active := s.activeRunForSessionLocked(scopeKey); active != nil { + return nil, fmt.Errorf("codex session %q already has an active run (%s)", scopeKey, active.ID) + } + if active := s.activeRunForRepoLocked(sessionRec.RepoPath, sessionRec.Slug); active != nil { + return nil, fmt.Errorf("repo %q already has an active run (%s)", sessionRec.Slug, active.ID) + } + + runID := newCodexRunID() + branchName := fmt.Sprintf("pc/%s/%s", sessionRec.ID, runID) + worktreePath, err := s.worktreePathForRun(sessionRec.Slug, runID) + if err != nil { + return nil, err + } + + now := time.Now().UTC() + rec := &codexRunRecord{ + ID: runID, + ScopeKey: scopeKey, + SessionID: sessionRec.ID, + RepoSlug: sessionRec.Slug, + RepoPath: sessionRec.RepoPath, + RepoURL: sanitizeRepoRemote(sessionRec.RepoURL), + WorktreePath: worktreePath, + BranchName: branchName, + PlannerModel: strings.TrimSpace(opts.PlannerModel), + ExecutorModel: strings.TrimSpace(opts.ExecutorModel), + Mode: strings.TrimSpace(opts.Mode), + Status: codexRunStatusQueued, + PlanID: strings.TrimSpace(opts.PlanID), + PlanHash: strings.TrimSpace(opts.PlanHash), + InitiatedBy: strings.TrimSpace(opts.InitiatedBy), + DeployConfirmPending: opts.DeployConfirmPending, + CreatedAt: now, + UpdatedAt: now, + } + if rec.Mode == "" { + rec.Mode = "autonomous" + } + if rec.ExecutorModel == "" { + rec.ExecutorModel = rec.PlannerModel + } + + if s.runs.Runs == nil { + s.runs.Runs = make(map[string]*codexRunRecord) + } + if s.runs.ActiveBySession == nil { + s.runs.ActiveBySession = make(map[string]string) + } + if s.runs.ActiveByRepo == nil { + s.runs.ActiveByRepo = make(map[string]string) + } + + s.runs.Runs[rec.ID] = rec + s.runs.ActiveBySession[scopeKey] = rec.ID + s.runs.ActiveByRepo[sessionRec.RepoPath] = rec.ID + sessionRec.ActiveRunID = rec.ID + sessionRec.PlannerModel = rec.PlannerModel + sessionRec.ExecutorModel = rec.ExecutorModel + sessionRec.WorkMode = "codex-plan" + sessionRec.ApprovalPending = false + sessionRec.DeployConfirmPending = opts.DeployConfirmPending + sessionRec.PendingPlanID = rec.PlanID + sessionRec.PendingPlanHash = rec.PlanHash + sessionRec.LastRunID = rec.ID + sessionRec.UpdatedAt = now + + if err := s.saveLocked(); err != nil { + return nil, err + } + if err := s.saveRunsLocked(); err != nil { + return nil, err + } + + return cloneCodexRunRecord(rec), nil +} + +func (s *codexSessionStore) GetRun(runID string) (*codexRunRecord, bool) { + runID = strings.TrimSpace(runID) + if s == nil || runID == "" { + return nil, false + } + + s.mu.RLock() + defer s.mu.RUnlock() + + rec := s.runs.Runs[runID] + if rec == nil { + return nil, false + } + return cloneCodexRunRecord(rec), true +} + +func (s *codexSessionStore) ListRuns(scopeKey string) []codexRunRecord { + scopeKey = strings.TrimSpace(scopeKey) + if s == nil { + return nil + } + + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]codexRunRecord, 0, len(s.runs.Runs)) + for _, rec := range s.runs.Runs { + if rec == nil { + continue + } + if scopeKey != "" && rec.ScopeKey != scopeKey { + continue + } + result = append(result, *rec) + } + sort.Slice(result, func(i, j int) bool { + if result[i].UpdatedAt.Equal(result[j].UpdatedAt) { + return result[i].CreatedAt.After(result[j].CreatedAt) + } + return result[i].UpdatedAt.After(result[j].UpdatedAt) + }) + return result +} + +func (s *codexSessionStore) ActiveRun(scopeKey string) (*codexRunRecord, bool) { + scopeKey = strings.TrimSpace(scopeKey) + if s == nil || scopeKey == "" { + return nil, false + } + + s.mu.RLock() + defer s.mu.RUnlock() + + rec := s.activeRunForSessionLocked(scopeKey) + if rec == nil { + return nil, false + } + return rec, true +} + +func (s *codexSessionStore) ActiveRunForRepo(repoPath, repoSlug string) (*codexRunRecord, bool) { + if s == nil { + return nil, false + } + + s.mu.RLock() + defer s.mu.RUnlock() + + rec := s.activeRunForRepoLocked(repoPath, repoSlug) + if rec == nil { + return nil, false + } + return rec, true +} + +func (s *codexSessionStore) MarkRunStarted(runID string, pid int, logPath string) error { + runID = strings.TrimSpace(runID) + if s == nil || runID == "" { + return fmt.Errorf("run id is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + rec := s.runs.Runs[runID] + if rec == nil { + return fmt.Errorf("run %q not found", runID) + } + rec.PID = pid + rec.LogPath = strings.TrimSpace(logPath) + if rec.Status == "" || rec.Status == codexRunStatusQueued || rec.Status == codexRunStatusUnknown { + rec.Status = codexRunStatusRunning + } + if rec.StartedAt.IsZero() { + rec.StartedAt = time.Now().UTC() + } + rec.LastHeartbeatAt = time.Now().UTC() + rec.UpdatedAt = rec.LastHeartbeatAt + + sessionRec := s.sessionRecordByIDLocked(rec.SessionID) + if sessionRec != nil { + sessionRec.ActiveRunID = rec.ID + sessionRec.UpdatedAt = rec.UpdatedAt + } + s.runs.ActiveBySession[rec.ScopeKey] = rec.ID + s.runs.ActiveByRepo[rec.RepoPath] = rec.ID + if err := s.saveLocked(); err != nil { + return err + } + return s.saveRunsLocked() +} + +func (s *codexSessionStore) MarkRunHeartbeat(runID string) error { + runID = strings.TrimSpace(runID) + if s == nil || runID == "" { + return fmt.Errorf("run id is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + rec := s.runs.Runs[runID] + if rec == nil { + return fmt.Errorf("run %q not found", runID) + } + rec.LastHeartbeatAt = time.Now().UTC() + rec.UpdatedAt = rec.LastHeartbeatAt + return s.saveRunsLocked() +} + +func (s *codexSessionStore) MarkRunFinished(runID, status string, exitCode int, errMsg string) error { + runID = strings.TrimSpace(runID) + if s == nil || runID == "" { + return fmt.Errorf("run id is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + rec := s.runs.Runs[runID] + if rec == nil { + return fmt.Errorf("run %q not found", runID) + } + status = strings.TrimSpace(status) + if status == "" { + status = codexRunStatusSucceeded + } + rec.Status = status + rec.ExitCode = exitCode + rec.Error = strings.TrimSpace(errMsg) + now := time.Now().UTC() + rec.FinishedAt = now + rec.UpdatedAt = now + + if activeID := s.runs.ActiveBySession[rec.ScopeKey]; activeID == rec.ID { + delete(s.runs.ActiveBySession, rec.ScopeKey) + } + if activeID := s.runs.ActiveByRepo[rec.RepoPath]; activeID == rec.ID { + delete(s.runs.ActiveByRepo, rec.RepoPath) + } + if sessionRec := s.sessionRecordByIDLocked(rec.SessionID); sessionRec != nil { + sessionRec.LastRunID = rec.ID + if strings.TrimSpace(sessionRec.ActiveRunID) == rec.ID { + sessionRec.ActiveRunID = "" + } + sessionRec.DeployConfirmPending = false + sessionRec.UpdatedAt = now + } + if err := s.saveLocked(); err != nil { + return err + } + return s.saveRunsLocked() +} + +func (s *codexSessionStore) MarkRunFailed(runID string, exitCode int, errMsg string) error { + return s.MarkRunFinished(runID, codexRunStatusFailed, exitCode, errMsg) +} + +func (s *codexSessionStore) MarkRunStopped(runID string, errMsg string) error { + return s.MarkRunFinished(runID, codexRunStatusStopped, -1, errMsg) +} + +func (s *codexSessionStore) ReconcileRuns() ([]codexRunRecord, error) { + if s == nil { + return nil, fmt.Errorf("codex sessions are not initialized") + } + + s.mu.Lock() + defer s.mu.Unlock() + + changed := make([]codexRunRecord, 0) + for _, rec := range s.runs.Runs { + if rec == nil { + continue + } + if isTerminalRunStatus(rec.Status) { + continue + } + if rec.PID <= 0 { + if rec.Status != codexRunStatusUnknown { + rec.Status = codexRunStatusUnknown + rec.ExitCode = -1 + rec.FinishedAt = time.Now().UTC() + rec.UpdatedAt = rec.FinishedAt + changed = append(changed, *rec) + } + continue + } + if !processLooksAlive(rec.PID) { + outcome, ok := detectCodexRunOutcomeFromLog(rec.LogPath) + if ok { + rec.Status = outcome.Status + rec.ExitCode = outcome.ExitCode + rec.Error = outcome.Error + } else { + rec.Status = codexRunStatusUnknown + rec.ExitCode = -1 + rec.Error = "process not found during reconciliation" + } + rec.FinishedAt = time.Now().UTC() + rec.UpdatedAt = rec.FinishedAt + if sessionRec := s.sessionRecordByIDLocked(rec.SessionID); sessionRec != nil && strings.TrimSpace(sessionRec.ActiveRunID) == rec.ID { + sessionRec.ActiveRunID = "" + sessionRec.UpdatedAt = rec.UpdatedAt + } + delete(s.runs.ActiveBySession, rec.ScopeKey) + delete(s.runs.ActiveByRepo, rec.RepoPath) + changed = append(changed, *rec) + } else { + rec.LastHeartbeatAt = time.Now().UTC() + rec.UpdatedAt = rec.LastHeartbeatAt + s.runs.ActiveBySession[rec.ScopeKey] = rec.ID + s.runs.ActiveByRepo[rec.RepoPath] = rec.ID + if sessionRec := s.sessionRecordByIDLocked(rec.SessionID); sessionRec != nil { + sessionRec.ActiveRunID = rec.ID + sessionRec.UpdatedAt = rec.UpdatedAt + } + } + } + + s.normalizeRunStateLocked() + if len(changed) > 0 { + if err := s.saveLocked(); err != nil { + return changed, err + } + if err := s.saveRunsLocked(); err != nil { + return changed, err + } + } + return changed, nil +} + +type codexRunLogEvent struct { + Type string `json:"type"` + Message string `json:"message,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Error *struct { + Message string `json:"message,omitempty"` + } `json:"error,omitempty"` +} + +type codexRunLogOutcome struct { + Status string + ExitCode int + Error string +} + +func detectCodexRunOutcomeFromLog(logPath string) (codexRunLogOutcome, bool) { + logPath = strings.TrimSpace(logPath) + if logPath == "" { + return codexRunLogOutcome{}, false + } + + f, err := os.Open(logPath) + if err != nil { + return codexRunLogOutcome{}, false + } + defer f.Close() + + var ( + completed bool + failed bool + exitCode = -1 + lastError string + ) + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || !strings.Contains(line, `"type"`) { + continue + } + + var evt codexRunLogEvent + if err := json.Unmarshal([]byte(line), &evt); err != nil { + continue + } + + switch evt.Type { + case "turn.completed": + completed = true + if evt.ExitCode != nil { + exitCode = *evt.ExitCode + } else if exitCode < 0 { + exitCode = 0 + } + case "turn.failed": + failed = true + if evt.ExitCode != nil { + exitCode = *evt.ExitCode + } + if evt.Error != nil && strings.TrimSpace(evt.Error.Message) != "" { + lastError = strings.TrimSpace(evt.Error.Message) + } + case "error": + if strings.TrimSpace(evt.Message) != "" { + lastError = strings.TrimSpace(evt.Message) + } + } + } + if err := scanner.Err(); err != nil { + return codexRunLogOutcome{}, false + } + + if completed { + if exitCode < 0 { + exitCode = 0 + } + return codexRunLogOutcome{ + Status: codexRunStatusSucceeded, + ExitCode: exitCode, + }, true + } + if failed || lastError != "" { + return codexRunLogOutcome{ + Status: codexRunStatusFailed, + ExitCode: exitCode, + Error: lastError, + }, true + } + return codexRunLogOutcome{}, false +} + +func (s *codexSessionStore) PrepareRunWorktree(runID string) (string, error) { + runID = strings.TrimSpace(runID) + if s == nil || runID == "" { + return "", fmt.Errorf("run id is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + rec := s.runs.Runs[runID] + if rec == nil { + return "", fmt.Errorf("run %q not found", runID) + } + if strings.TrimSpace(rec.RepoPath) == "" { + return "", fmt.Errorf("run %q has no repo path", runID) + } + if !isGitRepo(rec.RepoPath) { + return "", fmt.Errorf("repo path %q is not a git repository", rec.RepoPath) + } + + if strings.TrimSpace(rec.WorktreePath) == "" { + worktreePath, err := s.worktreePathForRun(rec.RepoSlug, rec.ID) + if err != nil { + return "", err + } + rec.WorktreePath = worktreePath + } + + if err := os.MkdirAll(filepath.Dir(rec.WorktreePath), 0o700); err != nil { + return "", err + } + + if _, err := os.Stat(rec.WorktreePath); err == nil { + if isGitRepo(rec.WorktreePath) { + return rec.WorktreePath, nil + } + return "", fmt.Errorf("worktree path %q already exists and is not a git repository", rec.WorktreePath) + } else if !os.IsNotExist(err) { + return "", err + } + + branch := strings.TrimSpace(rec.BranchName) + if branch == "" { + branch = fmt.Sprintf("pc/%s/%s", rec.SessionID, rec.ID) + rec.BranchName = branch + } + + baseRef := "HEAD" + if out, err := exec.Command("git", "-C", rec.RepoPath, "branch", "--show-current").Output(); err == nil { + if current := strings.TrimSpace(string(out)); current != "" { + baseRef = current + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + args := []string{"-C", rec.RepoPath, "worktree", "add"} + if !branchExists(rec.RepoPath, branch) { + args = append(args, "-b", branch, rec.WorktreePath, baseRef) + } else { + args = append(args, rec.WorktreePath, branch) + } + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git worktree add failed: %w: %s", err, strings.TrimSpace(string(output))) + } + + rec.UpdatedAt = time.Now().UTC() + if err := s.saveRunsLocked(); err != nil { + return "", err + } + return rec.WorktreePath, nil +} + +func (s *codexSessionStore) worktreePathForRun(repoSlug, runID string) (string, error) { + repoSlug = strings.TrimSpace(repoSlug) + runID = strings.TrimSpace(runID) + if repoSlug == "" || runID == "" { + return "", fmt.Errorf("repo slug and run id are required") + } + + candidate := filepath.Clean(filepath.Join(s.worktreesRoot, repoSlug, runID)) + rel, err := filepath.Rel(s.worktreesRoot, candidate) + if err != nil { + return "", err + } + if strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + return "", fmt.Errorf("invalid worktree path for repo %q", repoSlug) + } + return candidate, nil +} + +func (s *codexSessionStore) sessionRecordByScopeLocked(scopeKey string) *codexSessionRecord { + scopeKey = strings.TrimSpace(scopeKey) + if scopeKey == "" { + return nil + } + id := strings.TrimSpace(s.state.Bindings[scopeKey]) + if id == "" { + return nil + } + return s.state.Sessions[id] +} + +func (s *codexSessionStore) sessionRecordByIDLocked(sessionID string) *codexSessionRecord { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return nil + } + return s.state.Sessions[sessionID] +} + +func (s *codexSessionStore) activeRunForSessionLocked(scopeKey string) *codexRunRecord { + scopeKey = strings.TrimSpace(scopeKey) + if scopeKey == "" { + return nil + } + if runID := strings.TrimSpace(s.runs.ActiveBySession[scopeKey]); runID != "" { + if rec := s.runs.Runs[runID]; rec != nil { + return rec + } + } + for _, rec := range s.runs.Runs { + if rec == nil { + continue + } + if rec.ScopeKey == scopeKey && isRunActiveStatus(rec.Status) { + return rec + } + } + return nil +} + +func (s *codexSessionStore) activeRunForRepoLocked(repoPath, repoSlug string) *codexRunRecord { + repoPath = strings.TrimSpace(repoPath) + repoSlug = strings.TrimSpace(repoSlug) + if repoPath == "" && repoSlug == "" { + return nil + } + if runID := strings.TrimSpace(s.runs.ActiveByRepo[repoPath]); runID != "" { + if rec := s.runs.Runs[runID]; rec != nil { + return rec + } + } + for _, rec := range s.runs.Runs { + if rec == nil { + continue + } + if !isRunActiveStatus(rec.Status) { + continue + } + if repoPath != "" && rec.RepoPath == repoPath { + return rec + } + if repoSlug != "" && rec.RepoSlug == repoSlug { + return rec + } + } + return nil +} + +func (s *codexSessionStore) normalizeRunStateLocked() { + if s.runs.Runs == nil { + s.runs.Runs = make(map[string]*codexRunRecord) + } + if s.runs.ActiveBySession == nil { + s.runs.ActiveBySession = make(map[string]string) + } + if s.runs.ActiveByRepo == nil { + s.runs.ActiveByRepo = make(map[string]string) + } + + for id, rec := range s.runs.Runs { + if rec == nil { + delete(s.runs.Runs, id) + continue + } + rec.RepoURL = sanitizeRepoRemote(rec.RepoURL) + if strings.TrimSpace(rec.BranchName) == "" && strings.TrimSpace(rec.SessionID) != "" && strings.TrimSpace(rec.ID) != "" { + rec.BranchName = fmt.Sprintf("pc/%s/%s", rec.SessionID, rec.ID) + } + if strings.TrimSpace(rec.WorktreePath) == "" && strings.TrimSpace(rec.RepoSlug) != "" && strings.TrimSpace(rec.ID) != "" { + if worktreePath, err := s.worktreePathForRun(rec.RepoSlug, rec.ID); err == nil { + rec.WorktreePath = worktreePath + } + } + } + + for key, runID := range s.runs.ActiveBySession { + runID = strings.TrimSpace(runID) + rec := s.runs.Runs[runID] + if rec == nil || !isRunActiveStatus(rec.Status) { + delete(s.runs.ActiveBySession, key) + continue + } + if rec.ScopeKey == "" { + rec.ScopeKey = key + } + } + + for key, runID := range s.runs.ActiveByRepo { + runID = strings.TrimSpace(runID) + rec := s.runs.Runs[runID] + if rec == nil || !isRunActiveStatus(rec.Status) { + delete(s.runs.ActiveByRepo, key) + continue + } + if key != rec.RepoPath && key != rec.RepoSlug { + delete(s.runs.ActiveByRepo, key) + continue + } + } + + for _, rec := range s.runs.Runs { + if rec == nil { + continue + } + if !isRunActiveStatus(rec.Status) { + if sessionRec := s.sessionRecordByIDLocked(rec.SessionID); sessionRec != nil && sessionRec.ActiveRunID == rec.ID { + sessionRec.ActiveRunID = "" + } + continue + } + if rec.ScopeKey != "" { + s.runs.ActiveBySession[rec.ScopeKey] = rec.ID + } + if rec.RepoPath != "" { + s.runs.ActiveByRepo[rec.RepoPath] = rec.ID + } + if sessionRec := s.sessionRecordByIDLocked(rec.SessionID); sessionRec != nil { + sessionRec.ActiveRunID = rec.ID + if sessionRec.PlannerModel == "" { + sessionRec.PlannerModel = rec.PlannerModel + } + if sessionRec.ExecutorModel == "" { + sessionRec.ExecutorModel = rec.ExecutorModel + } + if sessionRec.WorkMode == "" { + sessionRec.WorkMode = "codex-plan" + } + } + } +} + +func applySessionRuntimeLocked(rec *codexSessionRecord, runtime codexSessionRuntimeState) { + if rec == nil { + return + } + rec.PlannerModel = strings.TrimSpace(runtime.PlannerModel) + rec.ExecutorModel = strings.TrimSpace(runtime.ExecutorModel) + rec.WorkMode = strings.TrimSpace(runtime.WorkMode) + rec.ApprovalPending = runtime.ApprovalPending + rec.DeployConfirmPending = runtime.DeployConfirmPending + rec.PendingPlanID = strings.TrimSpace(runtime.PendingPlanID) + rec.PendingPlanHash = strings.TrimSpace(runtime.PendingPlanHash) + rec.ActiveRunID = strings.TrimSpace(runtime.ActiveRunID) + rec.LastRunID = strings.TrimSpace(runtime.LastRunID) +} + +func sessionRuntimeFromRecord(rec *codexSessionRecord) codexSessionRuntimeState { + if rec == nil { + return codexSessionRuntimeState{} + } + return codexSessionRuntimeState{ + PlannerModel: strings.TrimSpace(rec.PlannerModel), + ExecutorModel: strings.TrimSpace(rec.ExecutorModel), + WorkMode: strings.TrimSpace(rec.WorkMode), + ApprovalPending: rec.ApprovalPending, + DeployConfirmPending: rec.DeployConfirmPending, + PendingPlanID: strings.TrimSpace(rec.PendingPlanID), + PendingPlanHash: strings.TrimSpace(rec.PendingPlanHash), + ActiveRunID: strings.TrimSpace(rec.ActiveRunID), + LastRunID: strings.TrimSpace(rec.LastRunID), + } +} + +func cloneCodexRunRecord(rec *codexRunRecord) *codexRunRecord { + if rec == nil { + return nil + } + cp := *rec + return &cp +} + +func isRunActiveStatus(status string) bool { + switch strings.ToLower(strings.TrimSpace(status)) { + case codexRunStatusQueued, codexRunStatusRunning: + return true + default: + return false + } +} + +func isTerminalRunStatus(status string) bool { + switch strings.ToLower(strings.TrimSpace(status)) { + case codexRunStatusSucceeded, codexRunStatusFailed, codexRunStatusStopped: + return true + default: + return false + } +} + +func processLooksAlive(pid int) bool { + if pid <= 0 { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + if err := proc.Signal(syscall.Signal(0)); err != nil { + return false + } + return true +} + +func branchExists(repoPath, branch string) bool { + repoPath = strings.TrimSpace(repoPath) + branch = strings.TrimSpace(branch) + if repoPath == "" || branch == "" { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "rev-parse", "--verify", "--quiet", branch) + if err := cmd.Run(); err != nil { + return false + } + return true +} + +func newCodexRunID() string { + return newCodexSessionID() +} + +func sanitizeRepoRemote(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.HasPrefix(raw, "git@") { + return raw + } + + parsed, err := url.Parse(raw) + if err != nil { + return raw + } + if parsed.User != nil { + parsed.User = nil + } + return parsed.String() +} diff --git a/pkg/agent/codex_runs_test.go b/pkg/agent/codex_runs_test.go new file mode 100644 index 000000000..7430714d8 --- /dev/null +++ b/pkg/agent/codex_runs_test.go @@ -0,0 +1,273 @@ +package agent + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestCodexSessionStore_RunLifecycleAndPersistence(t *testing.T) { + workspace := t.TempDir() + store := newCodexSessionStore(workspace) + if store == nil { + t.Fatal("expected codex session store") + } + + scopeKey := "agent:test:main" + slug := "picoclaw-demo" + repoPath, err := store.repoPathForSlug(slug) + if err != nil { + t.Fatalf("repoPathForSlug() error = %v", err) + } + if err := initGitRepo(t, repoPath); err != nil { + t.Fatalf("initGitRepo() error = %v", err) + } + + session, err := store.CreateOrActivate(scopeKey, slug, "") + if err != nil { + t.Fatalf("CreateOrActivate() error = %v", err) + } + if session == nil { + t.Fatal("CreateOrActivate() returned nil session") + } + + runtime := codexSessionRuntimeState{ + PlannerModel: "gpt-5.4-mini", + ExecutorModel: "codex-cli-local", + WorkMode: "codex-plan", + ApprovalPending: true, + DeployConfirmPending: true, + PendingPlanID: "plan-1", + PendingPlanHash: "hash-1", + } + if err := store.SetSessionRuntime(scopeKey, runtime); err != nil { + t.Fatalf("SetSessionRuntime() error = %v", err) + } + + gotRuntime, ok := store.SessionRuntime(scopeKey) + if !ok { + t.Fatal("SessionRuntime() not found") + } + if gotRuntime.PlannerModel != runtime.PlannerModel || gotRuntime.ExecutorModel != runtime.ExecutorModel { + t.Fatalf("runtime=%+v, want planner/executor models preserved", gotRuntime) + } + if !gotRuntime.ApprovalPending || !gotRuntime.DeployConfirmPending { + t.Fatalf("runtime=%+v, want approval/deploy pending", gotRuntime) + } + + run, err := store.CreateRun(scopeKey, codexRunCreateOptions{ + PlannerModel: runtime.PlannerModel, + ExecutorModel: runtime.ExecutorModel, + Mode: "autonomous", + PlanID: runtime.PendingPlanID, + PlanHash: runtime.PendingPlanHash, + InitiatedBy: "telegram:123", + DeployConfirmPending: true, + }) + if err != nil { + t.Fatalf("CreateRun() error = %v", err) + } + if run == nil { + t.Fatal("CreateRun() returned nil run") + } + if !strings.Contains(run.WorktreePath, filepath.Join("worktrees", slug, run.ID)) { + t.Fatalf("worktree path=%q, want repo/run hierarchy", run.WorktreePath) + } + if run.BranchName != "pc/"+session.ID+"/"+run.ID { + t.Fatalf("branch=%q, want deterministic branch name", run.BranchName) + } + + if _, err := store.CreateRun(scopeKey, codexRunCreateOptions{PlannerModel: runtime.PlannerModel}); err == nil { + t.Fatal("CreateRun() on active session should fail") + } + + scopeKey2 := "agent:test:other" + if _, err := store.CreateOrActivate(scopeKey2, slug, ""); err != nil { + t.Fatalf("CreateOrActivate() for second scope error = %v", err) + } + if _, err := store.CreateRun(scopeKey2, codexRunCreateOptions{PlannerModel: runtime.PlannerModel}); err == nil { + t.Fatal("CreateRun() on same repo from another scope should fail") + } + + if _, err := store.PrepareRunWorktree(run.ID); err != nil { + t.Fatalf("PrepareRunWorktree() error = %v", err) + } + if stat, err := os.Stat(run.WorktreePath); err != nil || !stat.IsDir() { + t.Fatalf("worktree path missing after prepare: stat=%v err=%v", stat, err) + } + + if err := store.MarkRunStarted(run.ID, 999999, filepath.Join(workspace, "run.log")); err != nil { + t.Fatalf("MarkRunStarted() error = %v", err) + } + if err := store.MarkRunFinished(run.ID, codexRunStatusSucceeded, 0, ""); err != nil { + t.Fatalf("MarkRunFinished() error = %v", err) + } + + loaded := newCodexSessionStore(workspace) + if loaded == nil { + t.Fatal("expected reloaded store") + } + + loadedRun, ok := loaded.GetRun(run.ID) + if !ok { + t.Fatal("reloaded run missing") + } + if loadedRun.Status != codexRunStatusSucceeded { + t.Fatalf("reloaded run status=%q, want %q", loadedRun.Status, codexRunStatusSucceeded) + } + loadedRuntime, ok := loaded.SessionRuntime(scopeKey) + if !ok { + t.Fatal("reloaded session runtime missing") + } + if loadedRuntime.ActiveRunID != "" { + t.Fatalf("reloaded active run id=%q, want cleared after finish", loadedRuntime.ActiveRunID) + } +} + +func TestCodexSessionStore_ReconcileRunsMarksDeadPidUnknown(t *testing.T) { + workspace := t.TempDir() + store := newCodexSessionStore(workspace) + if store == nil { + t.Fatal("expected codex session store") + } + + scopeKey := "agent:test:main" + slug := "picoclaw-demo" + repoPath, err := store.repoPathForSlug(slug) + if err != nil { + t.Fatalf("repoPathForSlug() error = %v", err) + } + if err := initGitRepo(t, repoPath); err != nil { + t.Fatalf("initGitRepo() error = %v", err) + } + if _, err := store.CreateOrActivate(scopeKey, slug, ""); err != nil { + t.Fatalf("CreateOrActivate() error = %v", err) + } + + run, err := store.CreateRun(scopeKey, codexRunCreateOptions{PlannerModel: "gpt-5.4-mini", Mode: "autonomous"}) + if err != nil { + t.Fatalf("CreateRun() error = %v", err) + } + if err := store.MarkRunStarted(run.ID, 999999, ""); err != nil { + t.Fatalf("MarkRunStarted() error = %v", err) + } + + changed, err := store.ReconcileRuns() + if err != nil { + t.Fatalf("ReconcileRuns() error = %v", err) + } + if len(changed) == 0 { + t.Fatal("expected reconciliation to report a changed run") + } + + updated, ok := store.GetRun(run.ID) + if !ok { + t.Fatal("updated run missing") + } + if updated.Status != codexRunStatusUnknown { + t.Fatalf("updated status=%q, want %q", updated.Status, codexRunStatusUnknown) + } + if runtime, ok := store.SessionRuntime(scopeKey); !ok || runtime.ActiveRunID != "" { + t.Fatalf("session runtime after reconcile = %+v, want cleared active run", runtime) + } +} + +func TestCodexSessionStore_ReconcileRunsMarksCompletedLogSucceeded(t *testing.T) { + workspace := t.TempDir() + store := newCodexSessionStore(workspace) + if store == nil { + t.Fatal("expected codex session store") + } + + scopeKey := "agent:test:main" + slug := "picoclaw-demo" + repoPath, err := store.repoPathForSlug(slug) + if err != nil { + t.Fatalf("repoPathForSlug() error = %v", err) + } + if err := initGitRepo(t, repoPath); err != nil { + t.Fatalf("initGitRepo() error = %v", err) + } + if _, err := store.CreateOrActivate(scopeKey, slug, ""); err != nil { + t.Fatalf("CreateOrActivate() error = %v", err) + } + + run, err := store.CreateRun(scopeKey, codexRunCreateOptions{PlannerModel: "gpt-5.4-mini", Mode: "autonomous"}) + if err != nil { + t.Fatalf("CreateRun() error = %v", err) + } + logPath := filepath.Join(workspace, "codex.log") + if err := os.WriteFile(logPath, []byte("{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"done\"}}\n{\"type\":\"turn.completed\"}\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := store.MarkRunStarted(run.ID, 999999, logPath); err != nil { + t.Fatalf("MarkRunStarted() error = %v", err) + } + + changed, err := store.ReconcileRuns() + if err != nil { + t.Fatalf("ReconcileRuns() error = %v", err) + } + if len(changed) == 0 { + t.Fatal("expected reconciliation to report a changed run") + } + + updated, ok := store.GetRun(run.ID) + if !ok { + t.Fatal("updated run missing") + } + if updated.Status != codexRunStatusSucceeded { + t.Fatalf("updated status=%q, want %q", updated.Status, codexRunStatusSucceeded) + } + if updated.ExitCode != 0 { + t.Fatalf("updated exit code=%d, want 0", updated.ExitCode) + } +} + +func TestSanitizeRepoRemoteStripsCredentials(t *testing.T) { + got := sanitizeRepoRemote("https://user:token@example.com/org/repo.git") + if got != "https://example.com/org/repo.git" { + t.Fatalf("sanitizeRepoRemote() = %q, want %q", got, "https://example.com/org/repo.git") + } +} + +func initGitRepo(t *testing.T, dir string) error { + t.Helper() + + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + if err := runGit(dir, "init"); err != nil { + return err + } + if err := runGit(dir, "config", "user.email", "test@example.com"); err != nil { + return err + } + if err := runGit(dir, "config", "user.name", "Test User"); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# test\n"), 0o644); err != nil { + return err + } + if err := runGit(dir, "add", "README.md"); err != nil { + return err + } + if err := runGit(dir, "commit", "-m", "initial commit"); err != nil { + return err + } + return nil +} + +func runGit(dir string, args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git %s failed: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) + } + return nil +} diff --git a/pkg/agent/codex_sessions.go b/pkg/agent/codex_sessions.go new file mode 100644 index 000000000..82260fe58 --- /dev/null +++ b/pkg/agent/codex_sessions.go @@ -0,0 +1,747 @@ +package agent + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/fileutil" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +const maxCodexRepoDiscoveryResults = 30 + +type codexSessionRecord struct { + ID string `json:"id"` + Slug string `json:"slug"` + RepoPath string `json:"repo_path"` + RepoURL string `json:"repo_url,omitempty"` + PlannerModel string `json:"planner_model,omitempty"` + ExecutorModel string `json:"executor_model,omitempty"` + WorkMode string `json:"work_mode,omitempty"` + ApprovalPending bool `json:"approval_pending,omitempty"` + DeployConfirmPending bool `json:"deploy_confirm_pending,omitempty"` + PendingPlanID string `json:"pending_plan_id,omitempty"` + PendingPlanHash string `json:"pending_plan_hash,omitempty"` + ActiveRunID string `json:"active_run_id,omitempty"` + LastRunID string `json:"last_run_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type codexSessionSnapshot struct { + Version int `json:"version"` + Sessions map[string]*codexSessionRecord `json:"sessions"` + Bindings map[string]string `json:"bindings"` +} + +type codexSessionStore struct { + stateFile string + runsFile string + reposRoot string + worktreesRoot string + + mu sync.RWMutex + state codexSessionSnapshot + runs codexRunSnapshot +} + +func newCodexSessionStore(workspace string) *codexSessionStore { + workspace = strings.TrimSpace(workspace) + if workspace == "" { + return nil + } + + store := &codexSessionStore{ + stateFile: filepath.Join(workspace, "state", "codex_sessions.json"), + runsFile: filepath.Join(workspace, "state", "codex_runs.json"), + reposRoot: filepath.Join(workspace, "repos"), + worktreesRoot: filepath.Join(workspace, "worktrees"), + state: codexSessionSnapshot{ + Version: 1, + Sessions: make(map[string]*codexSessionRecord), + Bindings: make(map[string]string), + }, + runs: codexRunSnapshot{ + Version: 1, + Runs: make(map[string]*codexRunRecord), + ActiveBySession: make(map[string]string), + ActiveByRepo: make(map[string]string), + }, + } + + if err := os.MkdirAll(filepath.Dir(store.stateFile), 0o700); err != nil { + logger.WarnCF("agent", "Failed to create codex state directory", map[string]any{"error": err.Error()}) + return nil + } + if err := os.MkdirAll(store.reposRoot, 0o700); err != nil { + logger.WarnCF("agent", "Failed to create codex repos directory", map[string]any{"error": err.Error()}) + return nil + } + if err := os.MkdirAll(store.worktreesRoot, 0o700); err != nil { + logger.WarnCF("agent", "Failed to create codex worktrees directory", map[string]any{"error": err.Error()}) + return nil + } + if err := store.load(); err != nil { + logger.WarnCF("agent", "Failed to load codex session state", map[string]any{"error": err.Error()}) + } + if err := store.loadRuns(); err != nil { + logger.WarnCF("agent", "Failed to load codex run state", map[string]any{"error": err.Error()}) + } + + return store +} + +func (s *codexSessionStore) load() error { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := os.ReadFile(s.stateFile) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + var snapshot codexSessionSnapshot + if err := json.Unmarshal(data, &snapshot); err != nil { + return err + } + if snapshot.Sessions == nil { + snapshot.Sessions = make(map[string]*codexSessionRecord) + } + if snapshot.Bindings == nil { + snapshot.Bindings = make(map[string]string) + } + if snapshot.Version == 0 { + snapshot.Version = 1 + } + s.state = snapshot + s.normalizeSessionStateLocked() + return nil +} + +func (s *codexSessionStore) loadRuns() error { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := os.ReadFile(s.runsFile) + if err != nil { + if os.IsNotExist(err) { + s.normalizeRunStateLocked() + return nil + } + return err + } + + var snapshot codexRunSnapshot + if err := json.Unmarshal(data, &snapshot); err != nil { + return err + } + if snapshot.Runs == nil { + snapshot.Runs = make(map[string]*codexRunRecord) + } + if snapshot.ActiveBySession == nil { + snapshot.ActiveBySession = make(map[string]string) + } + if snapshot.ActiveByRepo == nil { + snapshot.ActiveByRepo = make(map[string]string) + } + if snapshot.Version == 0 { + snapshot.Version = 1 + } + s.runs = snapshot + s.normalizeRunStateLocked() + return nil +} + +func (s *codexSessionStore) saveLocked() error { + data, err := json.MarshalIndent(s.state, "", " ") + if err != nil { + return err + } + return fileutil.WriteFileAtomic(s.stateFile, data, 0o600) +} + +func (s *codexSessionStore) saveRunsLocked() error { + data, err := json.MarshalIndent(s.runs, "", " ") + if err != nil { + return err + } + return fileutil.WriteFileAtomic(s.runsFile, data, 0o600) +} + +func (s *codexSessionStore) CreateOrActivate(scopeKey, slug, source string) (*codexSessionRecord, error) { + scopeKey = strings.TrimSpace(scopeKey) + if scopeKey == "" { + return nil, fmt.Errorf("codex sessions require a valid session scope") + } + + slug, err := sanitizeCodexSlug(slug) + if err != nil { + return nil, err + } + + repoURL, err := normalizeRepoSource(source) + if err != nil { + return nil, err + } + + s.mu.Lock() + rec := s.findBySlugLocked(slug) + if rec == nil { + repoPath, pathErr := s.repoPathForSlug(slug) + if pathErr != nil { + s.mu.Unlock() + return nil, pathErr + } + now := time.Now().UTC() + rec = &codexSessionRecord{ + ID: newCodexSessionID(), + Slug: slug, + RepoPath: repoPath, + CreatedAt: now, + UpdatedAt: now, + } + s.state.Sessions[rec.ID] = rec + } else if repoURL != "" && rec.RepoURL != "" && !strings.EqualFold(rec.RepoURL, repoURL) { + s.mu.Unlock() + return nil, fmt.Errorf("repo source does not match existing session remote") + } + repoPath := rec.RepoPath + recID := rec.ID + s.mu.Unlock() + + if err := s.prepareRepo(repoPath, repoURL); err != nil { + return nil, err + } + + s.mu.Lock() + defer s.mu.Unlock() + current, ok := s.state.Sessions[recID] + if !ok || current == nil { + return nil, fmt.Errorf("codex session state changed while preparing repo") + } + if current.RepoURL == "" && repoURL != "" { + current.RepoURL = sanitizeRepoRemote(repoURL) + } + current.UpdatedAt = time.Now().UTC() + s.state.Bindings[scopeKey] = current.ID + + if err := s.saveLocked(); err != nil { + return nil, err + } + return cloneCodexRecord(current), nil +} + +func (s *codexSessionStore) Attach(scopeKey, ref string) (*codexSessionRecord, error) { + scopeKey = strings.TrimSpace(scopeKey) + if scopeKey == "" { + return nil, fmt.Errorf("codex sessions require a valid session scope") + } + ref = strings.TrimSpace(ref) + if ref == "" { + return nil, fmt.Errorf("session id or repo slug is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + rec := s.state.Sessions[ref] + if rec == nil { + normalizedRef, err := sanitizeCodexSlug(ref) + if err == nil { + rec = s.findBySlugLocked(normalizedRef) + } + } + if rec == nil { + return nil, fmt.Errorf("codex session %q not found", ref) + } + + rec.UpdatedAt = time.Now().UTC() + if rec.RepoURL != "" { + rec.RepoURL = sanitizeRepoRemote(rec.RepoURL) + } + s.state.Bindings[scopeKey] = rec.ID + if err := s.saveLocked(); err != nil { + return nil, err + } + return cloneCodexRecord(rec), nil +} + +func (s *codexSessionStore) Active(scopeKey string) (*codexSessionRecord, bool) { + scopeKey = strings.TrimSpace(scopeKey) + if scopeKey == "" { + return nil, false + } + + s.mu.RLock() + defer s.mu.RUnlock() + + id := strings.TrimSpace(s.state.Bindings[scopeKey]) + if id == "" { + return nil, false + } + rec := s.state.Sessions[id] + if rec == nil { + return nil, false + } + return cloneCodexRecord(rec), true +} + +func (s *codexSessionStore) List(scopeKey string) []codexSessionRecord { + scopeKey = strings.TrimSpace(scopeKey) + + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]codexSessionRecord, 0, len(s.state.Sessions)) + for _, rec := range s.state.Sessions { + if rec == nil { + continue + } + result = append(result, *rec) + } + sort.Slice(result, func(i, j int) bool { + if result[i].UpdatedAt.Equal(result[j].UpdatedAt) { + return result[i].CreatedAt.After(result[j].CreatedAt) + } + return result[i].UpdatedAt.After(result[j].UpdatedAt) + }) + return result +} + +func (s *codexSessionStore) Stop(scopeKey string) error { + scopeKey = strings.TrimSpace(scopeKey) + if scopeKey == "" { + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.state.Bindings[scopeKey]; !ok { + return nil + } + delete(s.state.Bindings, scopeKey) + return s.saveLocked() +} + +func (s *codexSessionStore) findBySlugLocked(slug string) *codexSessionRecord { + for _, rec := range s.state.Sessions { + if rec != nil && rec.Slug == slug { + return rec + } + } + return nil +} + +func (s *codexSessionStore) repoPathForSlug(slug string) (string, error) { + candidate := filepath.Clean(filepath.Join(s.reposRoot, slug)) + rel, err := filepath.Rel(s.reposRoot, candidate) + if err != nil { + return "", err + } + if strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + return "", fmt.Errorf("invalid repo path for slug %q", slug) + } + return candidate, nil +} + +func (s *codexSessionStore) prepareRepo(repoPath, repoURL string) error { + repoPath = filepath.Clean(strings.TrimSpace(repoPath)) + if repoPath == "" { + return fmt.Errorf("repo path is required") + } + + _, statErr := os.Stat(repoPath) + if statErr != nil && !os.IsNotExist(statErr) { + return statErr + } + + if os.IsNotExist(statErr) { + if repoURL == "" { + return os.MkdirAll(repoPath, 0o755) + } + return cloneRepo(repoURL, repoPath) + } + + if repoURL == "" { + return nil + } + + entries, err := os.ReadDir(repoPath) + if err != nil { + return err + } + if len(entries) == 0 { + if err := os.Remove(repoPath); err != nil { + return err + } + return cloneRepo(repoURL, repoPath) + } + + if !isGitRepo(repoPath) { + return fmt.Errorf("repo path already exists and is not a git repository") + } + return nil +} + +func sanitizeCodexSlug(raw string) (string, error) { + raw = strings.TrimSpace(strings.ToLower(raw)) + if raw == "" { + return "", fmt.Errorf("repo slug is required") + } + + var b strings.Builder + lastDash := false + for _, r := range raw { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + lastDash = false + case r >= '0' && r <= '9': + b.WriteRune(r) + lastDash = false + case r == '-' || r == '_' || r == '.': + b.WriteRune(r) + lastDash = false + case r == '/' || r == ' ' || r == ':' || r == '@': + if !lastDash { + b.WriteRune('-') + lastDash = true + } + default: + if !lastDash { + b.WriteRune('-') + lastDash = true + } + } + } + + slug := strings.Trim(b.String(), "-._") + if slug == "" { + return "", fmt.Errorf("repo slug %q is invalid", raw) + } + if len(slug) > 80 { + slug = strings.Trim(slug[:80], "-._") + } + if slug == "" { + return "", fmt.Errorf("repo slug %q is invalid", raw) + } + return slug, nil +} + +func normalizeRepoSource(source string) (string, error) { + source = strings.TrimSpace(source) + if source == "" { + return "", nil + } + + if strings.HasPrefix(source, "https://") || strings.HasPrefix(source, "ssh://") || strings.HasPrefix(source, "git@") { + return source, nil + } + + if strings.Count(source, "/") == 1 && !strings.ContainsAny(source, " \t:") { + return "https://github.com/" + source + ".git", nil + } + + return "", fmt.Errorf("repo source must be https://, ssh://, git@, or owner/repo") +} + +func newCodexSessionID() string { + buf := make([]byte, 4) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("cx-%d", time.Now().UnixNano()) + } + return hex.EncodeToString(buf) +} + +func cloneRepo(repoURL, repoPath string) error { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + cmd := exec.CommandContext(ctx, "git", "clone", repoURL, repoPath) + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("git clone timed out") + } + return fmt.Errorf("git clone failed (check repository access/PAT): %w", err) + } + return nil +} + +func isGitRepo(path string) bool { + _, err := os.Stat(filepath.Join(path, ".git")) + return err == nil +} + +func cloneCodexRecord(rec *codexSessionRecord) *codexSessionRecord { + if rec == nil { + return nil + } + cp := *rec + return &cp +} + +func (s *codexSessionStore) normalizeSessionStateLocked() { + if s == nil { + return + } + if s.state.Sessions == nil { + s.state.Sessions = make(map[string]*codexSessionRecord) + } + if s.state.Bindings == nil { + s.state.Bindings = make(map[string]string) + } + for _, rec := range s.state.Sessions { + if rec == nil { + continue + } + rec.RepoURL = sanitizeRepoRemote(rec.RepoURL) + } +} + +func codexRecordToInfo(rec *codexSessionRecord, active bool) commands.CodexSessionInfo { + if rec == nil { + return commands.CodexSessionInfo{} + } + return commands.CodexSessionInfo{ + ID: rec.ID, + Slug: rec.Slug, + RepoPath: rec.RepoPath, + RepoURL: rec.RepoURL, + Updated: rec.UpdatedAt, + Active: active, + } +} + +func (al *AgentLoop) findCodexModelName(cfg *config.Config) string { + if cfg == nil { + return "" + } + + defaultName := strings.TrimSpace(cfg.Agents.Defaults.ModelName) + if defaultName != "" { + if mc, err := cfg.GetModelConfig(defaultName); err == nil && mc != nil { + proto, _ := providers.ExtractProtocol(mc.Model) + if strings.EqualFold(proto, "codex-cli") || strings.EqualFold(proto, "codexcli") { + return defaultName + } + } + } + + for _, mc := range cfg.ModelList { + if mc == nil || strings.TrimSpace(mc.ModelName) == "" { + continue + } + proto, _ := providers.ExtractProtocol(mc.Model) + if strings.EqualFold(proto, "codex-cli") || strings.EqualFold(proto, "codexcli") { + return strings.TrimSpace(mc.ModelName) + } + } + + return "" +} + +func (al *AgentLoop) codexDelegateTargets(cfg *config.Config) []string { + if cfg == nil { + return nil + } + + seen := make(map[string]struct{}, len(cfg.ModelList)+3) + targets := make([]string, 0, len(cfg.ModelList)+3) + add := func(raw string) { + name := strings.TrimSpace(raw) + if name == "" { + return + } + if _, ok := seen[name]; ok { + return + } + seen[name] = struct{}{} + targets = append(targets, name) + } + + // Keep the active codex model prominent, then include the configured defaults. + add(al.findCodexModelName(cfg)) + add(cfg.Agents.Defaults.ModelName) + if cfg.Agents.Defaults.Routing != nil { + for _, label := range routingTierPrimaryLabels(cfg.Agents.Defaults.Routing) { + add(label) + } + if len(cfg.Agents.Defaults.Routing.Tiers) == 0 { + add(cfg.Agents.Defaults.Routing.LightModel) + } + } + + for _, mc := range cfg.ModelList { + if mc == nil { + continue + } + add(mc.ModelName) + } + + return targets +} + +func routingTierPrimaryLabels(rc *config.RoutingConfig) []string { + if rc == nil { + return nil + } + seen := make(map[string]struct{}, len(rc.Tiers)) + out := make([]string, 0, len(rc.Tiers)) + for _, tier := range rc.Tiers { + if tier.Model == nil { + continue + } + name := strings.TrimSpace(tier.Model.Primary) + if name == "" { + continue + } + key := strings.ToLower(name) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, name) + } + return out +} + +func (al *AgentLoop) codexWorkspaceOverride(sessionKey string, modelCfg *config.ModelConfig) string { + if al == nil || al.codexStore == nil || modelCfg == nil { + return "" + } + + proto, _ := providers.ExtractProtocol(modelCfg.Model) + if !strings.EqualFold(proto, "codex-cli") && !strings.EqualFold(proto, "codexcli") { + return "" + } + + active, ok := al.codexStore.Active(sessionKey) + if !ok || active == nil { + return "" + } + return strings.TrimSpace(active.RepoPath) +} + +func (al *AgentLoop) codexActiveRuntimeInfo(sessionKey string) (*commands.CodexSessionInfo, bool) { + if al == nil || al.codexStore == nil { + return nil, false + } + + rec, ok := al.codexStore.Active(sessionKey) + if !ok || rec == nil { + return nil, false + } + + info := codexRecordToInfo(rec, true) + return &info, true +} + +func (al *AgentLoop) codexListRuntimeInfo(sessionKey string) []commands.CodexSessionInfo { + if al == nil || al.codexStore == nil { + return nil + } + + activeID := "" + if active, ok := al.codexStore.Active(sessionKey); ok && active != nil { + activeID = active.ID + } + + records := al.codexStore.List(sessionKey) + result := make([]commands.CodexSessionInfo, 0, len(records)) + for i := range records { + rec := records[i] + active := activeID != "" && rec.ID == activeID + result = append(result, commands.CodexSessionInfo{ + ID: rec.ID, + Slug: rec.Slug, + RepoPath: rec.RepoPath, + RepoURL: rec.RepoURL, + Updated: rec.UpdatedAt, + Active: active, + }) + } + return result +} + +func (al *AgentLoop) codexGitHubRepos(limit int) ([]string, error) { + if limit <= 0 { + limit = 10 + } + if limit > maxCodexRepoDiscoveryResults { + limit = maxCodexRepoDiscoveryResults + } + + if _, err := exec.LookPath("gh"); err != nil { + return nil, fmt.Errorf("gh CLI not found on host; install GitHub CLI first") + } + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + authCmd := exec.CommandContext(ctx, "gh", "auth", "status", "--hostname", "github.com") + authCmd.Env = os.Environ() + if err := authCmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("gh repo discovery timed out") + } + return nil, fmt.Errorf("gh CLI is not authenticated to github.com") + } + + repoCmd := exec.CommandContext( + ctx, + "gh", + "repo", + "list", + "--limit", + fmt.Sprintf("%d", limit), + "--json", + "nameWithOwner", + "--jq", + ".[].nameWithOwner", + ) + repoCmd.Env = os.Environ() + output, err := repoCmd.Output() + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("gh repo discovery timed out") + } + return nil, fmt.Errorf("gh repo discovery failed") + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + repos := make([]string, 0, len(lines)) + seen := make(map[string]struct{}, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if _, ok := seen[line]; ok { + continue + } + seen[line] = struct{}{} + repos = append(repos, line) + if len(repos) >= limit { + break + } + } + return repos, nil +} diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index bacfa49c5..cae48d6ec 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -42,17 +42,27 @@ type AgentInstance struct { Candidates []providers.FallbackCandidate // Router is non-nil when model routing is configured and the light model - // was successfully resolved. It scores each incoming message and decides - // whether to route to LightCandidates or stay with Candidates. + // or tier chain was successfully resolved. It scores each incoming message + // and decides whether to route to a named tier or stay with Candidates. Router *routing.Router + // RouteTiers holds pre-resolved named routing tiers keyed by tier name. + RouteTiers map[string]*ResolvedRouteTier // LightCandidates holds the resolved provider candidates for the light model. - // Pre-computed at agent creation to avoid repeated model_list lookups at runtime. + // Legacy compatibility field for binary routing configs. LightCandidates []providers.FallbackCandidate // LightProvider is the concrete provider instance for the configured light model. - // It is only used when routing selects the light tier for a turn. + // Legacy compatibility field for binary routing configs. LightProvider providers.LLMProvider } +type ResolvedRouteTier struct { + Name string + Primary string + Candidates []providers.FallbackCandidate + Provider providers.LLMProvider + ThinkingLevel ThinkingLevel +} + // NewAgentInstance creates an agent instance from config. func NewAgentInstance( agentCfg *config.AgentConfig, @@ -99,6 +109,30 @@ func NewAgentInstance( toolsRegistry.Register(execTool) } } + if cfg.Tools.IsToolEnabled("git") { + allowGitPaths := compilePatterns(cfg.Tools.AllowWritePaths) + gitTool, err := tools.NewGitTool(workspace, restrict, cfg.Tools.Git.TimeoutSeconds, allowGitPaths) + if err != nil { + logger.ErrorCF("agent", "Failed to initialize git tool; continuing without git", + map[string]any{"error": err.Error()}) + } else { + toolsRegistry.Register(gitTool) + } + } + if cfg.Tools.IsToolEnabled("github") { + githubTool, err := tools.NewGitHubTool( + cfg.Tools.Github.Token.String(), + cfg.Tools.Github.BaseURL, + cfg.Tools.Github.Proxy, + cfg.Tools.Github.TimeoutSeconds, + ) + if err != nil { + logger.ErrorCF("agent", "Failed to initialize github tool; continuing without github", + map[string]any{"error": err.Error()}) + } else { + toolsRegistry.Register(githubTool) + } + } if cfg.Tools.IsToolEnabled("edit_file") { toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths)) @@ -175,35 +209,29 @@ func NewAgentInstance( // Resolve fallback candidates candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks) - // Model routing setup: pre-resolve light model candidates at creation time - // to avoid repeated model_list lookups on every incoming message. + // Model routing setup: pre-resolve routing tiers at creation time to avoid + // repeated model_list lookups on every incoming message. var router *routing.Router + var routeTiers map[string]*ResolvedRouteTier var lightCandidates []providers.FallbackCandidate var lightProvider providers.LLMProvider - if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" { - resolved := resolveModelCandidates(cfg, defaults.Provider, rc.LightModel, nil) - if len(resolved) > 0 { - lightModelCfg, err := resolvedModelConfig(cfg, rc.LightModel, workspace) - if err != nil { - logger.WarnCF("agent", "Routing light model config invalid; routing disabled", - map[string]any{"light_model": rc.LightModel, "agent_id": agentID, "error": err.Error()}) - } else { - lp, _, err := providers.CreateProviderFromConfig(lightModelCfg) - if err != nil { - logger.WarnCF("agent", "Routing light model provider init failed; routing disabled", - map[string]any{"light_model": rc.LightModel, "agent_id": agentID, "error": err.Error()}) - } else { - router = routing.New(routing.RouterConfig{ - LightModel: rc.LightModel, - Threshold: rc.Threshold, - }) - lightCandidates = resolved - lightProvider = lp - } + if rc := defaults.Routing; rc != nil && rc.Enabled { + routeTiers = resolveRouteTiers(cfg, defaults, workspace, agentID, rc) + if len(routeTiers) > 0 || rc.LightModel != "" { + router = routing.New(routing.RouterConfig{ + LightModel: rc.LightModel, + Threshold: rc.Threshold, + Tiers: rc.Tiers, + }) + } + if legacyLight := routeTiers["light"]; legacyLight != nil { + lightCandidates = legacyLight.Candidates + lightProvider = legacyLight.Provider + } else if freeTier := strings.TrimSpace(defaults.Routing.FreeTier); freeTier != "" { + if resolved := routeTiers[freeTier]; resolved != nil { + lightCandidates = resolved.Candidates + lightProvider = resolved.Provider } - } else { - logger.WarnCF("agent", "Routing light model not found; routing disabled", - map[string]any{"light_model": rc.LightModel, "agent_id": agentID}) } } @@ -228,11 +256,81 @@ func NewAgentInstance( SkillsFilter: skillsFilter, Candidates: candidates, Router: router, + RouteTiers: routeTiers, LightCandidates: lightCandidates, LightProvider: lightProvider, } } +func resolveRouteTiers( + cfg *config.Config, + defaults *config.AgentDefaults, + workspace, agentID string, + rc *config.RoutingConfig, +) map[string]*ResolvedRouteTier { + if cfg == nil || defaults == nil || rc == nil || !rc.Enabled { + return nil + } + + result := map[string]*ResolvedRouteTier{} + addTier := func(name, primary string, fallbacks []string) { + name = strings.TrimSpace(name) + primary = strings.TrimSpace(primary) + if name == "" || primary == "" { + return + } + + modelCfg, err := resolvedModelConfig(cfg, primary, workspace) + if err != nil { + logger.WarnCF("agent", "Routing tier model config invalid; tier disabled", + map[string]any{"tier": name, "model": primary, "agent_id": agentID, "error": err.Error()}) + return + } + provider, _, err := providers.CreateProviderFromConfig(modelCfg) + if err != nil { + logger.WarnCF("agent", "Routing tier provider init failed; tier disabled", + map[string]any{"tier": name, "model": primary, "agent_id": agentID, "error": err.Error()}) + return + } + resolvedFallbacks := fallbacks + if len(resolvedFallbacks) == 0 { + if mc := lookupModelConfigByRef(cfg, primary); mc != nil && len(mc.Fallbacks) > 0 { + resolvedFallbacks = mc.Fallbacks + } + } + candidates := resolveModelCandidates(cfg, defaults.Provider, primary, resolvedFallbacks) + if len(candidates) == 0 { + logger.WarnCF("agent", "Routing tier model did not resolve; tier disabled", + map[string]any{"tier": name, "model": primary, "agent_id": agentID}) + if stateful, ok := provider.(providers.StatefulProvider); ok { + stateful.Close() + } + return + } + + result[name] = &ResolvedRouteTier{ + Name: name, + Primary: primary, + Candidates: candidates, + Provider: provider, + ThinkingLevel: parseThinkingLevel(modelCfg.ThinkingLevel), + } + } + + for _, tier := range rc.Tiers { + if tier.Model == nil { + continue + } + addTier(tier.Name, tier.Model.Primary, tier.Model.Fallbacks) + } + + if len(result) == 0 && strings.TrimSpace(rc.LightModel) != "" { + addTier("light", rc.LightModel, nil) + } + + return result +} + // resolveAgentWorkspace determines the workspace directory for an agent. func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 7baab12a1..46dcec196 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "os/exec" "path/filepath" "regexp" "strings" @@ -47,20 +48,23 @@ type AgentLoop struct { hooks *HookManager // Runtime state - running atomic.Bool - contextManager ContextManager - fallback *providers.FallbackChain - channelManager *channels.Manager - mediaStore media.MediaStore - transcriber asr.Transcriber - cmdRegistry *commands.Registry - mcp mcpRuntime - hookRuntime hookRuntime - steering *steeringQueue - pendingSkills sync.Map - pendingModelOverrides sync.Map - sessionModelOverrides sync.Map - mu sync.RWMutex + running atomic.Bool + contextManager ContextManager + fallback *providers.FallbackChain + channelManager *channels.Manager + mediaStore media.MediaStore + transcriber asr.Transcriber + cmdRegistry *commands.Registry + mcp mcpRuntime + hookRuntime hookRuntime + steering *steeringQueue + codexStore *codexSessionStore + pendingSkills sync.Map + pendingModelOverrides sync.Map + sessionModelOverrides sync.Map + sessionWorkModeOverrides sync.Map + sessionCodexApproval sync.Map + mu sync.RWMutex // Concurrent turn management (from HEAD) activeTurnStates sync.Map // key: sessionKey (string), value: *turnState @@ -107,12 +111,14 @@ const ( toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps." handledToolResponseSummary = "Requested output delivered via tool attachment." sessionKeyAgentPrefix = "agent:" + sessionKeyExplicitPrefix = "session:" metadataKeyAccountID = "account_id" metadataKeyGuildID = "guild_id" metadataKeyTeamID = "team_id" metadataKeyReplyToMessage = "reply_to_message_id" metadataKeyParentPeerKind = "parent_peer_kind" metadataKeyParentPeerID = "parent_peer_id" + codexApprovalReadyMarker = "[CODEX_APPROVAL_READY]" ) func NewAgentLoop( @@ -131,6 +137,9 @@ func NewAgentLoop( if agent, ok := registry.GetAgent(agentID); ok { rl.RegisterCandidates(agent.Candidates) rl.RegisterCandidates(agent.LightCandidates) + for _, tier := range agent.RouteTiers { + rl.RegisterCandidates(tier.Candidates) + } } } fallbackChain := providers.NewFallbackChain(cooldown, rl) @@ -156,6 +165,12 @@ func NewAgentLoop( al.hooks = NewHookManager(eventBus) configureHookManagerFromConfig(al.hooks, cfg) al.contextManager = al.resolveContextManager() + if defaultAgent != nil { + al.codexStore = newCodexSessionStore(defaultAgent.Workspace) + if al.codexStore != nil { + _, _ = al.codexStore.ReconcileRuns() + } + } // Register shared tools to all agents (now that al is created) registerSharedTools(al, cfg, msgBus, registry, provider) @@ -1050,6 +1065,9 @@ func (al *AgentLoop) ReloadProviderAndConfig( if agent, ok := registry.GetAgent(agentID); ok { newRL.RegisterCandidates(agent.Candidates) newRL.RegisterCandidates(agent.LightCandidates) + for _, tier := range agent.RouteTiers { + newRL.RegisterCandidates(tier.Candidates) + } } } al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker(), newRL) @@ -1386,7 +1404,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } } - // Resolve session key from route, while preserving explicit agent-scoped keys. + // Resolve session key from route while preserving explicit caller session keys. scopeKey := resolveScopeKey(route, msg.SessionKey) sessionKey := scopeKey @@ -1421,6 +1439,32 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) return response, nil } + workMode := al.getSessionWorkMode(opts.SessionKey) + if isCodexPlanningMode(workMode) { + if _, approved := codexDeployApprovalMessage(msg.Content); approved { + if runtime, ok := al.codexStore.SessionRuntime(opts.SessionKey); !ok || !runtime.DeployConfirmPending { + return "No PicoClaw deploy is awaiting confirmation yet. Finish an approved PicoClaw run first.", nil + } + response, err := al.startApprovedPicoClawDeploy(opts.SessionKey, opts.Channel, opts.ChatID) + if err != nil { + return "", err + } + return response, nil + } + if approvalMessage, approved := codexExecutionApprovalMessage(msg.Content); approved { + if !al.hasCodexApprovalPending(opts.SessionKey) { + return "No codex plan is awaiting approval yet. Keep chatting in /codex until I ask you to reply `proceed`.", nil + } + response, err := al.startApprovedCodexRun(ctx, agent, &opts, approvalMessage) + if err != nil { + return "", err + } + return response, nil + } else if strings.TrimSpace(msg.Content) != "" { + al.clearCodexApprovalPending(opts.SessionKey) + } + } + if pendingModel := al.takePendingModelOverride(opts.SessionKey); pendingModel != "" { opts.ForcedModel = pendingModel logger.InfoCF("agent", "Applying pending model override", @@ -1472,10 +1516,31 @@ func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.Resolv } func resolveScopeKey(route routing.ResolvedRoute, msgSessionKey string) string { - if msgSessionKey != "" && strings.HasPrefix(msgSessionKey, sessionKeyAgentPrefix) { + msgSessionKey = strings.TrimSpace(msgSessionKey) + if msgSessionKey == "" { + return route.SessionKey + } + if strings.HasPrefix(msgSessionKey, sessionKeyAgentPrefix) { return msgSessionKey } - return route.SessionKey + + agentID := strings.TrimSpace(route.AgentID) + if agentID == "" { + if parsed := routing.ParseAgentSessionKey(route.SessionKey); parsed != nil { + agentID = parsed.AgentID + } + } + if agentID == "" { + agentID = routing.DefaultAgentID + } + + return fmt.Sprintf( + "%s%s:%s%s", + sessionKeyAgentPrefix, + routing.NormalizeAgentID(agentID), + sessionKeyExplicitPrefix, + msgSessionKey, + ) } func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) { @@ -1752,6 +1817,22 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er cfg := al.GetConfig() maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize() messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) + workMode := al.getSessionWorkMode(ts.sessionKey) + var activeCodex *commands.CodexSessionInfo + var codexDelegateTargets []string + var codexRepoTargets []string + if workMode != "" { + if isCodexWorkMode(workMode) { + if info, ok := al.codexActiveRuntimeInfo(ts.sessionKey); ok { + activeCodex = info + } + codexDelegateTargets = al.codexDelegateTargets(cfg) + if repos, err := al.codexGitHubRepos(10); err == nil { + codexRepoTargets = repos + } + } + messages = injectWorkModePrompt(messages, workMode, ts.agent.Workspace, activeCodex, codexDelegateTargets, codexRepoTargets) + } if !ts.opts.NoHistory { toolDefs := ts.agent.Tools.ToProviderDefs() @@ -1784,6 +1865,9 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er activeSkillNames(ts.agent, ts.opts)..., ) messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) + if workMode != "" { + messages = injectWorkModePrompt(messages, workMode, ts.agent.Workspace, activeCodex, codexDelegateTargets, codexRepoTargets) + } } } @@ -1807,9 +1891,8 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er activeModel := resolvedCandidateModel(ts.agent.Candidates, ts.agent.Model) activeProvider := ts.agent.Provider activeThinkingLevel := ts.agent.ThinkingLevel - var usedLight bool if forcedModel := strings.TrimSpace(ts.opts.ForcedModel); forcedModel != "" { - forcedCfg, forcedProvider, forcedCandidates, err := al.resolveModelSelection(cfg, ts.agent, forcedModel) + forcedCfg, forcedProvider, forcedCandidates, err := al.resolveModelSelection(cfg, ts.agent, forcedModel, ts.sessionKey) if err != nil { turnStatus = TurnEndStatusError return turnResult{}, err @@ -1827,10 +1910,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er "model": activeModel, }) } else { - activeCandidates, activeModel, usedLight = al.selectCandidates(ts.agent, ts.userMessage, messages) - if usedLight && ts.agent.LightProvider != nil { - activeProvider = ts.agent.LightProvider - } + activeCandidates, activeModel, activeProvider, activeThinkingLevel, _ = al.selectCandidates(ts.agent, ts.userMessage, messages) } pendingMessages := append([]providers.Message(nil), ts.opts.InitialSteeringMessages...) var finalContent string @@ -1929,6 +2009,10 @@ turnLoop: gracefulTerminal, _ := ts.gracefulInterruptRequested() providerToolDefs := ts.agent.Tools.ToProviderDefs() + if isCodexPlanningMode(workMode) { + // Planning mode is intentionally conversation-only. + providerToolDefs = nil + } // Native web search support (from HEAD) _, hasWebSearch := ts.agent.Tools.Get("web_search") @@ -2082,7 +2166,7 @@ turnLoop: var response *providers.LLMResponse var err error - maxRetries := 2 + maxRetries := 3 for retry := 0; retry <= maxRetries; retry++ { response, err = callLLM(callMessages, providerToolDefs) if err == nil { @@ -2140,6 +2224,35 @@ turnLoop: continue } + if isRateLimitRetryError(err) && retry < maxRetries { + backoff := rateLimitBackoff(retry) + al.emitEvent( + EventKindLLMRetry, + ts.eventMeta("runTurn", "turn.llm.retry"), + LLMRetryPayload{ + Attempt: retry + 1, + MaxRetries: maxRetries, + Reason: "rate_limit", + Error: err.Error(), + Backoff: backoff, + }, + ) + logger.WarnCF("agent", "Rate limit error, retrying after backoff", map[string]any{ + "error": err.Error(), + "retry": retry, + "backoff": backoff.String(), + }) + if sleepErr := sleepWithContext(turnCtx, backoff); sleepErr != nil { + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + err = sleepErr + break + } + continue + } + if isContextError && retry < maxRetries && !ts.opts.NoHistory { al.emitEvent( EventKindLLMRetry, @@ -2864,6 +2977,13 @@ turnLoop: finalContent = ts.opts.DefaultResponse } } + if isCodexPlanningMode(workMode) { + var approvalPending bool + finalContent, approvalPending = normalizeCodexPlanningReply(finalContent) + al.setCodexApprovalState(ts.sessionKey, approvalPending, finalContent) + } else { + al.clearCodexApprovalPending(ts.sessionKey) + } ts.setPhase(TurnPhaseFinalizing) ts.setFinalContent(finalContent) @@ -2934,6 +3054,304 @@ func sleepWithContext(ctx context.Context, d time.Duration) error { } } +func workModePrompt( + workMode, workspace string, + activeCodex *commands.CodexSessionInfo, + delegateTargets []string, + repoTargets []string, +) string { + switch strings.ToLower(strings.TrimSpace(workMode)) { + case "code": + return strings.TrimSpace(fmt.Sprintf(`## Code Mode +You are in code mode. The user wants PicoClaw improved through repository changes, config updates, or new integrations. + +Prioritize implementation over discussion when the request is actionable. +- Prefer using tools to inspect the repository, edit files, run tests, and inspect git state. +- If the task can benefit from independent work, spawn a paid subagent for the implementation or verification step. +- Keep changes scoped and explain the exact files, commands, and validation steps you used. +- When editing config or adding integrations, prefer explicit, reviewable changes in the workspace. +- If the request is risky or ambiguous, surface the risk and ask for confirmation before making destructive changes. + +Workspace root: %s`, workspace)) + case "codex": + repoPath := workspace + repoSlug := "" + repoURL := "" + if activeCodex != nil { + if strings.TrimSpace(activeCodex.RepoPath) != "" { + repoPath = strings.TrimSpace(activeCodex.RepoPath) + } + repoSlug = strings.TrimSpace(activeCodex.Slug) + repoURL = strings.TrimSpace(activeCodex.RepoURL) + } + + extra := "" + if repoSlug != "" { + extra += "\nCodex Session: " + repoSlug + } + if repoURL != "" { + extra += "\nRepo Remote: " + repoURL + } + if len(delegateTargets) > 0 { + limit := len(delegateTargets) + if limit > 12 { + limit = 12 + } + extra += "\nDelegation targets: " + strings.Join(delegateTargets[:limit], ", ") + } + if len(repoTargets) > 0 { + limit := len(repoTargets) + if limit > 12 { + limit = 12 + } + extra += "\nGitHub repos available: " + strings.Join(repoTargets[:limit], ", ") + } + return strings.TrimSpace(fmt.Sprintf(`## Codex Mode +You are in codex mode. Treat this as a long-running repository session and keep execution conversational. + +Prioritize implementation and verification. +- Work inside the active repo path for all file/tool operations unless the user explicitly asks otherwise. +- Filesystem tools are workspace-scoped; never write to absolute system paths like /tmp, /etc, or /usr. +- Put transient artifacts under the active repo (for example ./.tmp/) instead of system temp directories. +- Keep commits scoped and reversible; do not rewrite history unless the user asks. +- If auth is needed for git operations, ask for setup steps without exposing any secrets. +- For PicoClaw self-updates, run focused tests/build first, then restart the user service and verify health. +- Keep a short running summary of what changed so the user can resume the session later. + +Active repo path: %s%s`, repoPath, extra)) + case "codex-plan": + repoPath := workspace + repoSlug := "" + repoURL := "" + if activeCodex != nil { + if strings.TrimSpace(activeCodex.RepoPath) != "" { + repoPath = strings.TrimSpace(activeCodex.RepoPath) + } + repoSlug = strings.TrimSpace(activeCodex.Slug) + repoURL = strings.TrimSpace(activeCodex.RepoURL) + } + + extra := "" + if repoSlug != "" { + extra += "\nCodex Session: " + repoSlug + } + if repoURL != "" { + extra += "\nRepo Remote: " + repoURL + } + if len(delegateTargets) > 0 { + limit := len(delegateTargets) + if limit > 12 { + limit = 12 + } + extra += "\nDelegation targets: " + strings.Join(delegateTargets[:limit], ", ") + } + if len(repoTargets) > 0 { + limit := len(repoTargets) + if limit > 12 { + limit = 12 + } + extra += "\nGitHub repos available: " + strings.Join(repoTargets[:limit], ", ") + } + return strings.TrimSpace(fmt.Sprintf(`## Codex Planning Mode +You are in codex planning mode. Treat this as conversational pre-planning for a repository session. + +Planning-only behavior (strict): +- Do not execute tools, shell commands, git operations, or file edits in this mode. +- Ask clarifying questions, identify risks, and refine scope until the user approves execution. +- Produce structured plans with concrete steps, validation checks, and rollback notes. +- Keep all proposed paths within the repo workspace; avoid absolute system paths. +- When the plan is complete and ready for approval, end your response with the exact standalone marker %s. + +Execution handoff: +- After you output the approval marker, the user can reply with a machine-checked approval phrase such as "proceed". +- After that, switch to implementation with test/build/deploy evidence. + +Active repo path: %s%s`, codexApprovalReadyMarker, repoPath, extra)) + case "ops": + return strings.TrimSpace(fmt.Sprintf(`## Ops Mode +You are in ops mode. The user wants the PicoClaw instance itself adjusted. + +Focus on safe, reviewable changes. +- Prefer config changes over ad hoc mutations. +- Restart or reload only after the change is validated. +- Summarize the operational effect, not just the code diff. + +Workspace root: %s`, workspace)) + default: + return "" + } +} + +func isCodexWorkMode(mode string) bool { + mode = strings.ToLower(strings.TrimSpace(mode)) + return mode == "codex" || mode == "codex-plan" +} + +func isCodexPlanningMode(mode string) bool { + return strings.EqualFold(strings.TrimSpace(mode), "codex-plan") +} + +func codexExecutionApprovalMessage(raw string) (string, bool) { + switch normalizeCodexApprovalPhrase(raw) { + case "proceed", "go", "goahead", "execute", "runit": + return "The user explicitly approved execution. Execute the approved plan now, work inside the active repo, and report concrete progress as you go.", true + default: + return "", false + } +} + +func codexDeployApprovalMessage(raw string) (string, bool) { + switch normalizeCodexApprovalPhrase(raw) { + case "deploy", "confirmdeploy", "restart", "shipit": + return "The user explicitly approved deployment.", true + default: + return "", false + } +} + +func normalizeCodexApprovalPhrase(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + if raw == "" { + return "" + } + var b strings.Builder + for _, r := range raw { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + return b.String() +} + +func codexApprovalReady(content string) bool { + content = strings.TrimSpace(content) + if content == "" { + return false + } + lines := strings.Split(content, "\n") + for _, line := range lines { + if strings.TrimSpace(line) == codexApprovalReadyMarker { + return true + } + } + return false +} + +func normalizeCodexPlanningReply(content string) (string, bool) { + armed := codexApprovalReady(content) + if !armed { + armed = codexPlanningImplicitApprovalReady(content) + } + content = strings.ReplaceAll(content, codexApprovalReadyMarker, "") + content = strings.TrimSpace(content) + if !armed { + return content, false + } + if content == "" { + content = "Plan is ready." + } + content += "\n\nReply `proceed` to execute this plan." + return content, true +} + +func codexPlanningImplicitApprovalReady(content string) bool { + normalized := strings.ToLower(strings.TrimSpace(content)) + if normalized == "" { + return false + } + if strings.Contains(normalized, "?") { + return false + } + if !strings.Contains(normalized, "plan") { + return false + } + + readyPhrases := []string{ + "i can proceed", + "ready to proceed", + "ready to execute", + "ready to implement", + "i can implement next", + "i can make the change next", + "i can proceed with the change next", + "if you want, i can proceed", + } + for _, phrase := range readyPhrases { + if strings.Contains(normalized, phrase) { + return true + } + } + return false +} + +func injectWorkModePrompt( + messages []providers.Message, + workMode, workspace string, + activeCodex *commands.CodexSessionInfo, + delegateTargets []string, + repoTargets []string, +) []providers.Message { + instruction := workModePrompt(workMode, workspace, activeCodex, delegateTargets, repoTargets) + if instruction == "" || len(messages) == 0 { + return messages + } + if strings.TrimSpace(messages[0].Role) != "system" { + return messages + } + + messages[0].Content = strings.TrimSpace(messages[0].Content + "\n\n---\n\n" + instruction) + messages[0].SystemParts = append(messages[0].SystemParts, providers.ContentBlock{ + Type: "text", + Text: instruction, + }) + return messages +} + +func rateLimitBackoff(attempt int) time.Duration { + if attempt < 0 { + attempt = 0 + } + + backoff := 5 * time.Second + for i := 0; i < attempt; i++ { + if backoff >= time.Minute/3 { + return time.Minute + } + backoff *= 3 + } + + if backoff > time.Minute { + return time.Minute + } + return backoff +} + +func isRateLimitRetryError(err error) bool { + if err == nil { + return false + } + + var failErr *providers.FailoverError + if errors.As(err, &failErr) { + return failErr.Reason == providers.FailoverRateLimit || failErr.Reason == providers.FailoverOverloaded + } + + var exhausted *providers.FallbackExhaustedError + if errors.As(err, &exhausted) { + for _, attempt := range exhausted.Attempts { + if attempt.Reason == providers.FailoverRateLimit || attempt.Reason == providers.FailoverOverloaded { + return true + } + } + } + + if classified := providers.ClassifyError(err, "", ""); classified != nil { + return classified.Reason == providers.FailoverRateLimit || classified.Reason == providers.FailoverOverloaded + } + + return false +} + // selectCandidates returns the model candidates and resolved model name to use // for a conversation turn. When model routing is configured and the incoming // message scores below the complexity threshold, it returns the light model @@ -2946,30 +3364,42 @@ func (al *AgentLoop) selectCandidates( agent *AgentInstance, userMsg string, history []providers.Message, -) (candidates []providers.FallbackCandidate, model string, usedLight bool) { - if agent.Router == nil || len(agent.LightCandidates) == 0 { - return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false +) (candidates []providers.FallbackCandidate, model string, provider providers.LLMProvider, thinking ThinkingLevel, tierName string) { + if agent.Router == nil { + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), agent.Provider, agent.ThinkingLevel, "" } - _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) - if !usedLight { + selectedTier, score := agent.Router.SelectTier(userMsg, history) + if selectedTier == "" { logger.DebugCF("agent", "Model routing: primary model selected", map[string]any{ "agent_id": agent.ID, "score": score, "threshold": agent.Router.Threshold(), }) - return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), agent.Provider, agent.ThinkingLevel, "" } - logger.InfoCF("agent", "Model routing: light model selected", + tier := agent.RouteTiers[selectedTier] + if tier == nil { + logger.WarnCF("agent", "Model routing selected missing tier; falling back to primary", + map[string]any{ + "agent_id": agent.ID, + "tier": selectedTier, + "score": score, + }) + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), agent.Provider, agent.ThinkingLevel, "" + } + + logger.InfoCF("agent", "Model routing: tier selected", map[string]any{ - "agent_id": agent.ID, - "light_model": agent.Router.LightModel(), - "score": score, - "threshold": agent.Router.Threshold(), + "agent_id": agent.ID, + "tier": selectedTier, + "model": tier.Primary, + "score": score, + "threshold": agent.Router.Threshold(), }) - return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true + return tier.Candidates, resolvedCandidateModel(tier.Candidates, tier.Primary), tier.Provider, tier.ThinkingLevel, selectedTier } // resolveContextManager selects the ContextManager implementation based on config. @@ -3230,7 +3660,7 @@ func (al *AgentLoop) applyExplicitSkillCommand( return true, false, "" } -func (al *AgentLoop) resolveModelSelection(cfg *config.Config, agent *AgentInstance, modelName string) (*config.ModelConfig, providers.LLMProvider, []providers.FallbackCandidate, error) { +func (al *AgentLoop) resolveModelSelection(cfg *config.Config, agent *AgentInstance, modelName, sessionKey string) (*config.ModelConfig, providers.LLMProvider, []providers.FallbackCandidate, error) { if cfg == nil { return nil, nil, nil, fmt.Errorf("config is nil") } @@ -3243,11 +3673,28 @@ func (al *AgentLoop) resolveModelSelection(cfg *config.Config, agent *AgentInsta if agent != nil { workspace = agent.Workspace fallbacks = agent.Fallbacks + if tierName, ok := parseRoutingTierRef(modelName); ok { + tier := agent.RouteTiers[tierName] + if tier == nil { + return nil, nil, nil, fmt.Errorf("routing tier %q is not configured", tierName) + } + modelCfg, err := resolvedModelConfig(cfg, tier.Primary, workspace) + if err != nil { + return nil, nil, nil, err + } + if override := al.codexWorkspaceOverride(sessionKey, modelCfg); override != "" { + modelCfg.Workspace = override + } + return modelCfg, tier.Provider, tier.Candidates, nil + } } modelCfg, err := resolvedModelConfig(cfg, modelName, workspace) if err != nil { return nil, nil, nil, err } + if override := al.codexWorkspaceOverride(sessionKey, modelCfg); override != "" { + modelCfg.Workspace = override + } provider, _, err := providers.CreateProviderFromConfig(modelCfg) if err != nil { return nil, nil, nil, fmt.Errorf("failed to initialize model %q: %w", modelName, err) @@ -3341,7 +3788,7 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt if agent == nil { return fmt.Errorf("session model overrides unavailable in current context") } - _, _, _, err := al.resolveModelSelection(cfg, agent, value) + _, _, _, err := al.resolveModelSelection(cfg, agent, value, "") return err } @@ -3372,6 +3819,221 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt al.setPendingModelOverride(opts.SessionKey, value) return nil } + rt.ClearSessionModelMode = func() error { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return fmt.Errorf("session model overrides unavailable in current context") + } + al.clearSessionModelOverride(opts.SessionKey) + al.clearPendingModelOverride(opts.SessionKey) + al.clearSessionWorkMode(opts.SessionKey) + return nil + } + rt.GetSessionWorkMode = func() string { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return "" + } + return al.getSessionWorkMode(opts.SessionKey) + } + rt.SetSessionWorkMode = func(value string) error { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return fmt.Errorf("session work mode overrides unavailable in current context") + } + al.setSessionWorkMode(opts.SessionKey, value) + return nil + } + rt.ClearSessionWorkMode = func() error { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return fmt.Errorf("session work mode overrides unavailable in current context") + } + al.clearSessionWorkMode(opts.SessionKey) + return nil + } + rt.GetCodexApprovalPending = func() bool { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return false + } + return al.hasCodexApprovalPending(opts.SessionKey) + } + rt.ClearCodexApprovalPending = func() { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return + } + al.clearCodexApprovalPending(opts.SessionKey) + } + rt.FindCodexModel = func() string { + return al.findCodexModelName(cfg) + } + rt.ListCodexDelegateTargets = func() []string { + return al.codexDelegateTargets(cfg) + } + rt.ListCodexRepoTargets = func(limit int) ([]string, error) { + return al.codexGitHubRepos(limit) + } + rt.CodexNewSession = func(slug, source string) (*commands.CodexSessionInfo, error) { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return nil, fmt.Errorf("codex sessions unavailable in current context") + } + if al.codexStore == nil { + return nil, fmt.Errorf("codex sessions are not initialized") + } + executorModel := strings.TrimSpace(al.findCodexModelName(cfg)) + if executorModel == "" { + return nil, fmt.Errorf("no codex-cli model configured in model_list") + } + plannerModel := strings.TrimSpace(al.findCodexPlannerModelName(cfg)) + if plannerModel == "" { + return nil, fmt.Errorf("no planner model configured for /codex") + } + if err := validateSessionModel(executorModel); err != nil { + return nil, err + } + if err := validateSessionModel(plannerModel); err != nil { + return nil, err + } + if _, err := exec.LookPath("codex"); err != nil { + return nil, fmt.Errorf("codex binary not found on host; install Codex CLI first") + } + + rec, err := al.codexStore.CreateOrActivate(opts.SessionKey, slug, source) + if err != nil { + return nil, err + } + + _ = al.codexStore.SetSessionRuntime(opts.SessionKey, codexSessionRuntimeState{ + PlannerModel: plannerModel, + ExecutorModel: executorModel, + WorkMode: "codex-plan", + }) + al.setSessionModelOverride(opts.SessionKey, plannerModel) + al.clearPendingModelOverride(opts.SessionKey) + al.clearCodexApprovalPending(opts.SessionKey) + al.setSessionWorkMode(opts.SessionKey, "codex-plan") + + info := codexRecordToInfo(rec, true) + return &info, nil + } + rt.CodexAttach = func(ref string) (*commands.CodexSessionInfo, error) { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return nil, fmt.Errorf("codex sessions unavailable in current context") + } + if al.codexStore == nil { + return nil, fmt.Errorf("codex sessions are not initialized") + } + executorModel := strings.TrimSpace(al.findCodexModelName(cfg)) + if executorModel == "" { + return nil, fmt.Errorf("no codex-cli model configured in model_list") + } + plannerModel := strings.TrimSpace(al.findCodexPlannerModelName(cfg)) + if plannerModel == "" { + return nil, fmt.Errorf("no planner model configured for /codex") + } + if err := validateSessionModel(executorModel); err != nil { + return nil, err + } + if err := validateSessionModel(plannerModel); err != nil { + return nil, err + } + if _, err := exec.LookPath("codex"); err != nil { + return nil, fmt.Errorf("codex binary not found on host; install Codex CLI first") + } + + rec, err := al.codexStore.Attach(opts.SessionKey, ref) + if err != nil { + return nil, err + } + + if runtime, ok := al.codexStore.SessionRuntime(opts.SessionKey); ok { + if strings.TrimSpace(runtime.ExecutorModel) == "" { + runtime.ExecutorModel = executorModel + } + if strings.TrimSpace(runtime.PlannerModel) == "" { + runtime.PlannerModel = plannerModel + } + if strings.TrimSpace(runtime.WorkMode) == "" { + runtime.WorkMode = "codex-plan" + } + _ = al.codexStore.SetSessionRuntime(opts.SessionKey, runtime) + if strings.TrimSpace(runtime.PlannerModel) != "" { + plannerModel = runtime.PlannerModel + } + } + al.setSessionModelOverride(opts.SessionKey, plannerModel) + al.clearPendingModelOverride(opts.SessionKey) + al.clearCodexApprovalPending(opts.SessionKey) + al.setSessionWorkMode(opts.SessionKey, "codex-plan") + + info := codexRecordToInfo(rec, true) + return &info, nil + } + rt.CodexListSessions = func() []commands.CodexSessionInfo { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return nil + } + return al.codexListRuntimeInfo(opts.SessionKey) + } + rt.CodexActive = func() (*commands.CodexSessionInfo, bool) { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return nil, false + } + return al.codexActiveRuntimeInfo(opts.SessionKey) + } + rt.CodexPlannerStatus = func() (*commands.CodexPlannerStatusInfo, bool) { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return nil, false + } + return al.codexPlannerStatusRuntimeInfo(opts.SessionKey) + } + rt.CodexRunList = func() []commands.CodexRunInfo { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return nil + } + return al.codexRunListRuntimeInfo(opts.SessionKey) + } + rt.CodexRunStatus = func() (*commands.CodexRunInfo, bool) { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return nil, false + } + return al.codexRunStatusRuntimeInfo(opts.SessionKey) + } + rt.CodexRunTail = func(runID string, lines int) (string, error) { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return "", fmt.Errorf("codex runs unavailable in current context") + } + return al.codexRunTail(opts.SessionKey, runID, lines) + } + rt.CodexRunStop = func() error { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return fmt.Errorf("codex runs unavailable in current context") + } + if err := al.stopActiveCodexRun(opts.SessionKey); err != nil { + return err + } + if al.codexStore != nil { + if err := al.codexStore.Stop(opts.SessionKey); err != nil { + return err + } + } + al.clearSessionWorkMode(opts.SessionKey) + al.clearCodexApprovalPending(opts.SessionKey) + al.clearSessionModelOverride(opts.SessionKey) + al.clearPendingModelOverride(opts.SessionKey) + return nil + } + rt.CodexStop = func() error { + if opts == nil || strings.TrimSpace(opts.SessionKey) == "" { + return fmt.Errorf("codex sessions unavailable in current context") + } + if al.codexStore != nil { + if err := al.codexStore.Stop(opts.SessionKey); err != nil { + return err + } + } + al.clearSessionWorkMode(opts.SessionKey) + al.clearCodexApprovalPending(opts.SessionKey) + al.clearSessionModelOverride(opts.SessionKey) + al.clearPendingModelOverride(opts.SessionKey) + return nil + } rt.ClearHistory = func() error { if opts == nil { @@ -3418,9 +4080,19 @@ func (al *AgentLoop) setSessionModelOverride(sessionKey, modelName string) { } if modelName == "" { al.sessionModelOverrides.Delete(sessionKey) + if al != nil && al.codexStore != nil { + _ = al.codexStore.UpdateSessionRuntime(sessionKey, func(runtime *codexSessionRuntimeState) { + runtime.PlannerModel = "" + }) + } return } al.sessionModelOverrides.Store(sessionKey, modelName) + if al != nil && al.codexStore != nil { + _ = al.codexStore.UpdateSessionRuntime(sessionKey, func(runtime *codexSessionRuntimeState) { + runtime.PlannerModel = modelName + }) + } } func (al *AgentLoop) getSessionModelOverride(sessionKey string) string { @@ -3429,14 +4101,19 @@ func (al *AgentLoop) getSessionModelOverride(sessionKey string) string { return "" } value, ok := al.sessionModelOverrides.Load(sessionKey) - if !ok { - return "" + if ok { + modelName, ok := value.(string) + if !ok { + return "" + } + return strings.TrimSpace(modelName) } - modelName, ok := value.(string) - if !ok { - return "" + if al != nil && al.codexStore != nil { + if runtime, ok := al.codexStore.SessionRuntime(sessionKey); ok { + return strings.TrimSpace(runtime.PlannerModel) + } } - return strings.TrimSpace(modelName) + return "" } func (al *AgentLoop) clearSessionModelOverride(sessionKey string) { @@ -3445,6 +4122,169 @@ func (al *AgentLoop) clearSessionModelOverride(sessionKey string) { return } al.sessionModelOverrides.Delete(sessionKey) + if al != nil && al.codexStore != nil { + _ = al.codexStore.UpdateSessionRuntime(sessionKey, func(runtime *codexSessionRuntimeState) { + runtime.PlannerModel = "" + }) + } +} + +func (al *AgentLoop) setSessionWorkMode(sessionKey, workMode string) { + sessionKey = strings.TrimSpace(sessionKey) + workMode = strings.TrimSpace(workMode) + if sessionKey == "" { + return + } + if workMode == "" { + al.sessionWorkModeOverrides.Delete(sessionKey) + al.sessionCodexApproval.Delete(sessionKey) + if al != nil && al.codexStore != nil { + _ = al.codexStore.UpdateSessionRuntime(sessionKey, func(runtime *codexSessionRuntimeState) { + runtime.WorkMode = "" + runtime.ApprovalPending = false + runtime.PendingPlanID = "" + runtime.PendingPlanHash = "" + }) + } + return + } + al.sessionWorkModeOverrides.Store(sessionKey, workMode) + if al != nil && al.codexStore != nil { + _ = al.codexStore.UpdateSessionRuntime(sessionKey, func(runtime *codexSessionRuntimeState) { + runtime.WorkMode = workMode + }) + } + if !strings.EqualFold(workMode, "codex-plan") { + al.sessionCodexApproval.Delete(sessionKey) + if al != nil && al.codexStore != nil { + _ = al.codexStore.UpdateSessionRuntime(sessionKey, func(runtime *codexSessionRuntimeState) { + runtime.ApprovalPending = false + runtime.PendingPlanID = "" + runtime.PendingPlanHash = "" + }) + } + } +} + +func (al *AgentLoop) getSessionWorkMode(sessionKey string) string { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return "" + } + value, ok := al.sessionWorkModeOverrides.Load(sessionKey) + if ok { + workMode, ok := value.(string) + if !ok { + return "" + } + return strings.TrimSpace(workMode) + } + if al != nil && al.codexStore != nil { + if runtime, ok := al.codexStore.SessionRuntime(sessionKey); ok { + if workMode := strings.TrimSpace(runtime.WorkMode); workMode != "" { + return workMode + } + } + if _, active := al.codexStore.Active(sessionKey); active { + return "codex-plan" + } + } + return "" +} + +func (al *AgentLoop) clearSessionWorkMode(sessionKey string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return + } + al.sessionWorkModeOverrides.Delete(sessionKey) + al.sessionCodexApproval.Delete(sessionKey) + if al != nil && al.codexStore != nil { + _ = al.codexStore.UpdateSessionRuntime(sessionKey, func(runtime *codexSessionRuntimeState) { + runtime.WorkMode = "" + runtime.ApprovalPending = false + runtime.PendingPlanID = "" + runtime.PendingPlanHash = "" + }) + } +} + +func (al *AgentLoop) setCodexApprovalPending(sessionKey string, pending bool) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return + } + if !pending { + al.sessionCodexApproval.Delete(sessionKey) + if al != nil && al.codexStore != nil { + _ = al.codexStore.UpdateSessionRuntime(sessionKey, func(runtime *codexSessionRuntimeState) { + runtime.ApprovalPending = false + runtime.PendingPlanID = "" + runtime.PendingPlanHash = "" + }) + } + return + } + al.sessionCodexApproval.Store(sessionKey, true) + if al != nil && al.codexStore != nil { + _ = al.codexStore.UpdateSessionRuntime(sessionKey, func(runtime *codexSessionRuntimeState) { + runtime.ApprovalPending = true + }) + } +} + +func (al *AgentLoop) setCodexApprovalState(sessionKey string, pending bool, planText string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return + } + planText = strings.TrimSpace(planText) + if !pending || planText == "" { + al.clearCodexApprovalPending(sessionKey) + return + } + al.sessionCodexApproval.Store(sessionKey, true) + if al != nil && al.codexStore != nil { + planID, planHash := codexPlanIdentity(planText) + _ = al.codexStore.UpdateSessionRuntime(sessionKey, func(runtime *codexSessionRuntimeState) { + runtime.ApprovalPending = true + runtime.PendingPlanID = planID + runtime.PendingPlanHash = planHash + }) + } +} + +func (al *AgentLoop) hasCodexApprovalPending(sessionKey string) bool { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return false + } + if al != nil && al.codexStore != nil { + if runtime, ok := al.codexStore.SessionRuntime(sessionKey); ok && runtime.ApprovalPending { + return true + } + } + value, ok := al.sessionCodexApproval.Load(sessionKey) + if !ok { + return false + } + pending, ok := value.(bool) + return ok && pending +} + +func (al *AgentLoop) clearCodexApprovalPending(sessionKey string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return + } + al.sessionCodexApproval.Delete(sessionKey) + if al != nil && al.codexStore != nil { + _ = al.codexStore.UpdateSessionRuntime(sessionKey, func(runtime *codexSessionRuntimeState) { + runtime.ApprovalPending = false + runtime.PendingPlanID = "" + runtime.PendingPlanHash = "" + }) + } } func (al *AgentLoop) setPendingModelOverride(sessionKey, modelName string) { diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 01ca78359..2fad9c2a5 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "slices" "strings" @@ -73,6 +74,9 @@ func newStartedTestChannelManager( type recordingProvider struct { lastMessages []providers.Message + lastTools []providers.ToolDefinition + responseText string + callCount int } func (r *recordingProvider) Chat( @@ -82,9 +86,15 @@ func (r *recordingProvider) Chat( model string, opts map[string]any, ) (*providers.LLMResponse, error) { + r.callCount++ r.lastMessages = append([]providers.Message(nil), messages...) + r.lastTools = append([]providers.ToolDefinition(nil), tools...) + content := strings.TrimSpace(r.responseText) + if content == "" { + content = "Mock response" + } return &providers.LLMResponse{ - Content: "Mock response", + Content: content, ToolCalls: []providers.ToolCall{}, }, nil } @@ -228,6 +238,483 @@ func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) { } } +func TestProcessMessage_CodeWorkModeInjectsPrompt(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + al.setSessionWorkMode(sessionKeyAgentPrefix+routing.DefaultAgentID+":main", "code") + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "add a new MCP", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages") + } + + systemPrompt := provider.lastMessages[0].Content + if !strings.Contains(systemPrompt, "## Code Mode") { + t.Fatalf("system prompt missing code mode section:\n%s", systemPrompt) + } + if !strings.Contains(systemPrompt, "spawn a paid subagent") { + t.Fatalf("system prompt missing code delegation guidance:\n%s", systemPrompt) + } +} + +func TestProcessMessage_CodexPlanModeInjectsPromptAndDisablesTools(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + al.setSessionWorkMode(sessionKeyAgentPrefix+routing.DefaultAgentID+":main", "codex-plan") + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "Plan how to add a deployment healthcheck", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages") + } + + systemPrompt := provider.lastMessages[0].Content + if !strings.Contains(systemPrompt, "## Codex Planning Mode") { + t.Fatalf("system prompt missing codex planning mode section:\n%s", systemPrompt) + } + if len(provider.lastTools) != 0 { + t.Fatalf("planning mode should disable tools, got %d tool defs", len(provider.lastTools)) + } +} + +func TestResolveScopeKey_PreservesExplicitAgentScopedKey(t *testing.T) { + route := routing.ResolvedRoute{ + AgentID: routing.DefaultAgentID, + SessionKey: "agent:main:telegram:direct:123", + } + + got := resolveScopeKey(route, "agent:main:cli:test") + if got != "agent:main:cli:test" { + t.Fatalf("resolveScopeKey() = %q, want explicit agent-scoped key preserved", got) + } +} + +func TestResolveScopeKey_NamespacesExplicitExternalKey(t *testing.T) { + route := routing.ResolvedRoute{ + AgentID: routing.DefaultAgentID, + SessionKey: "agent:main:cli:direct:cron", + } + + got := resolveScopeKey(route, "cli:diag-github") + want := "agent:main:session:cli:diag-github" + if got != want { + t.Fatalf("resolveScopeKey() = %q, want %q", got, want) + } +} + +func TestResolveScopeKey_FallsBackToRouteSessionKey(t *testing.T) { + route := routing.ResolvedRoute{ + AgentID: routing.DefaultAgentID, + SessionKey: "agent:main:telegram:direct:123", + } + + got := resolveScopeKey(route, "") + if got != route.SessionKey { + t.Fatalf("resolveScopeKey() = %q, want route session key %q", got, route.SessionKey) + } +} + +func TestProcessMessage_CodexPlanModeArmsApprovalWhenMarkerPresent(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &recordingProvider{responseText: "Plan:\n- do the thing\n[CODEX_APPROVAL_READY]"} + al := NewAgentLoop(cfg, msgBus, provider) + sessionKey := sessionKeyAgentPrefix + routing.DefaultAgentID + ":main" + al.setSessionWorkMode(sessionKey, "codex-plan") + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "Plan the self-deploy change", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if strings.Contains(response, "[CODEX_APPROVAL_READY]") { + t.Fatalf("response should not expose approval marker: %q", response) + } + if !strings.Contains(response, "Reply `proceed` to execute this plan.") { + t.Fatalf("response=%q, want proceed instruction", response) + } + if !al.hasCodexApprovalPending(sessionKey) { + t.Fatal("expected approval to be armed") + } +} + +func TestProcessMessage_CodexPlanModeArmsApprovalWhenResponseClearlyOffersProceed(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &recordingProvider{responseText: "Plan:\n1. Create NOTES.md\n2. Validate the change\n\nIf you want, I can proceed with the change next."} + al := NewAgentLoop(cfg, msgBus, provider) + sessionKey := sessionKeyAgentPrefix + routing.DefaultAgentID + ":main" + al.setSessionWorkMode(sessionKey, "codex-plan") + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "Plan the tiny repo change", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if !strings.Contains(response, "Reply `proceed` to execute this plan.") { + t.Fatalf("response=%q, want proceed instruction", response) + } + if !al.hasCodexApprovalPending(sessionKey) { + t.Fatal("expected approval to be armed") + } +} + +func TestProcessMessage_CodexProceedRequiresArmedApprovalLegacy(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + sessionKey := sessionKeyAgentPrefix + routing.DefaultAgentID + ":main" + al.setSessionWorkMode(sessionKey, "codex-plan") + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "proceed", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "No codex plan is awaiting approval yet. Keep chatting in /codex until I ask you to reply `proceed`." { + t.Fatalf("response=%q, want approval gate message", response) + } + if provider.callCount != 0 { + t.Fatalf("provider call count=%d, want 0", provider.callCount) + } +} + +func TestProcessMessage_CodexProceedLaunchesBackgroundRun(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + sessionKey := sessionKeyAgentPrefix + routing.DefaultAgentID + ":main" + + repoPath := filepath.Join(tmpDir, "repos", "picoclaw") + if err := os.MkdirAll(repoPath, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + run := func(args ...string) { + t.Helper() + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = repoPath + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s failed: %v\n%s", strings.Join(args, " "), err, out) + } + } + run("git", "init", "-b", "main") + run("git", "config", "user.email", "test@example.com") + run("git", "config", "user.name", "Test User") + if err := os.WriteFile(filepath.Join(repoPath, "README.md"), []byte("hello\n"), 0o644); err != nil { + t.Fatalf("write README: %v", err) + } + run("git", "add", "README.md") + run("git", "commit", "-m", "init") + + binDir := filepath.Join(tmpDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin: %v", err) + } + codexPath := filepath.Join(binDir, "codex") + codexStub := "#!/bin/sh\ncat >/dev/null\nsleep 1\nprintf '{\"type\":\"item.completed\",\"item\":{\"id\":\"item_1\",\"type\":\"agent_message\",\"text\":\"Run complete\"}}\\n'\nprintf '{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\\n'\n" + if err := os.WriteFile(codexPath, []byte(codexStub), 0o755); err != nil { + t.Fatalf("write codex stub: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + if _, err := al.codexStore.CreateOrActivate(sessionKey, "picoclaw", ""); err != nil { + t.Fatalf("CreateOrActivate() error = %v", err) + } + if err := al.codexStore.SetSessionRuntime(sessionKey, codexSessionRuntimeState{ + PlannerModel: "test-model", + ExecutorModel: "codex-local", + WorkMode: "codex-plan", + ApprovalPending: true, + PendingPlanID: "plan-1", + PendingPlanHash: "hash-1", + }); err != nil { + t.Fatalf("SetSessionRuntime() error = %v", err) + } + defaultAgent := al.GetRegistry().GetDefaultAgent() + defaultAgent.Sessions.AddMessage(sessionKey, "assistant", "Plan:\n- update the repo\nReply `proceed` to execute this plan.") + al.setSessionWorkMode(sessionKey, "codex-plan") + al.setSessionModelOverride(sessionKey, "test-model") + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "proceed", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if !strings.Contains(response, "Codex run started:") { + t.Fatalf("response=%q, want run start message", response) + } + if got := al.getSessionWorkMode(sessionKey); got != "codex-plan" { + t.Fatalf("work mode=%q, want %q", got, "codex-plan") + } + if al.hasCodexApprovalPending(sessionKey) { + t.Fatal("approval should be cleared after proceed") + } + if provider.callCount != 0 { + t.Fatalf("provider call count=%d, want 0", provider.callCount) + } + runs := al.codexStore.ListRuns(sessionKey) + if len(runs) != 1 { + t.Fatalf("expected 1 codex run, got %d", len(runs)) + } +} + +func TestGetSessionWorkMode_FallsBackToCodexPlanForBoundSession(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + al := NewAgentLoop(cfg, bus.NewMessageBus(), &recordingProvider{}) + sessionKey := sessionKeyAgentPrefix + routing.DefaultAgentID + ":main" + if al.codexStore == nil { + t.Fatal("codex store should be initialized") + } + if _, err := al.codexStore.CreateOrActivate(sessionKey, "picoclaw", ""); err != nil { + t.Fatalf("CreateOrActivate() error = %v", err) + } + if got := al.getSessionWorkMode(sessionKey); got != "codex-plan" { + t.Fatalf("getSessionWorkMode() = %q, want %q", got, "codex-plan") + } +} + +func TestCodexDelegateTargets_IncludesConfiguredModels(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "gpt-5.4-mini", + MaxTokens: 4096, + MaxToolIterations: 10, + Routing: &config.RoutingConfig{ + Enabled: true, + LightModel: "openrouter-free", + }, + }, + }, + ModelList: []*config.ModelConfig{ + {ModelName: "gpt-5.4-mini", Model: "openai/gpt-5.4-mini"}, + {ModelName: "openrouter-free", Model: "openrouter/free"}, + {ModelName: "codex-local", Model: "codex-cli/gpt-5.4-mini"}, + }, + } + al := NewAgentLoop(cfg, bus.NewMessageBus(), &recordingProvider{}) + + targets := al.codexDelegateTargets(cfg) + want := []string{"codex-local", "gpt-5.4-mini", "openrouter-free"} + if len(targets) != len(want) { + t.Fatalf("targets=%v, want %v", targets, want) + } + for i := range want { + if targets[i] != want[i] { + t.Fatalf("targets=%v, want %v", targets, want) + } + } +} + +func TestCodexGitHubRepos_ReturnsReposAndBoundsResults(t *testing.T) { + tmpDir := t.TempDir() + ghDir := filepath.Join(tmpDir, "bin") + if err := os.MkdirAll(ghDir, 0o755); err != nil { + t.Fatalf("mkdir gh stub dir: %v", err) + } + ghPath := filepath.Join(ghDir, "gh") + ghScript := `#!/bin/sh +case "$1 $2" in + "auth status") + exit 0 + ;; + "repo list") + printf 'octo/project-one +octo/project-two +octo/project-three +' + exit 0 + ;; + *) + exit 1 + ;; +esac +` + if err := os.WriteFile(ghPath, []byte(ghScript), 0o755); err != nil { + t.Fatalf("write gh stub: %v", err) + } + t.Setenv("PATH", ghDir) + + al := NewAgentLoop(&config.Config{}, bus.NewMessageBus(), &recordingProvider{}) + repos, err := al.codexGitHubRepos(2) + if err != nil { + t.Fatalf("codexGitHubRepos() error = %v", err) + } + want := []string{"octo/project-one", "octo/project-two"} + if len(repos) != len(want) { + t.Fatalf("repos=%v, want %v", repos, want) + } + for i := range want { + if repos[i] != want[i] { + t.Fatalf("repos=%v, want %v", repos, want) + } + } +} + +func TestCodexGitHubRepos_ReturnsClearErrorWhenUnauthenticated(t *testing.T) { + tmpDir := t.TempDir() + ghDir := filepath.Join(tmpDir, "bin") + if err := os.MkdirAll(ghDir, 0o755); err != nil { + t.Fatalf("mkdir gh stub dir: %v", err) + } + ghPath := filepath.Join(ghDir, "gh") + ghScript := `#!/bin/sh +case "$1 $2" in + "auth status") + exit 1 + ;; + *) + exit 0 + ;; +esac +` + if err := os.WriteFile(ghPath, []byte(ghScript), 0o755); err != nil { + t.Fatalf("write gh stub: %v", err) + } + t.Setenv("PATH", ghDir) + + al := NewAgentLoop(&config.Config{}, bus.NewMessageBus(), &recordingProvider{}) + _, err := al.codexGitHubRepos(5) + if err == nil { + t.Fatal("codexGitHubRepos() error = nil, want unauthenticated error") + } + if got := err.Error(); got != "gh CLI is not authenticated to github.com" { + t.Fatalf("error = %q, want unauthenticated message", got) + } +} + +func TestResolveCodexCLIModelArg_UsesConfiguredModelID(t *testing.T) { + cfg := &config.Config{ + ModelList: []*config.ModelConfig{ + { + ModelName: "codex-cli-local", + Model: "codex-cli/codex", + }, + }, + } + + got := resolveCodexCLIModelArg(cfg, "codex-cli-local") + if got != "codex" { + t.Fatalf("resolveCodexCLIModelArg() = %q, want %q", got, "codex") + } +} + func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) { tmpDir := t.TempDir() cfg := &config.Config{ @@ -741,6 +1228,7 @@ func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) { cfg.Agents.Defaults.ModelName = "test-model" cfg.Agents.Defaults.MaxTokens = 4096 cfg.Agents.Defaults.MaxToolIterations = 10 + cfg.Agents.Defaults.Routing = nil msgBus := bus.NewMessageBus() provider := &artifactThenSendProvider{} @@ -1959,6 +2447,111 @@ func TestProcessMessage_ModelRoutingUsesLightProvider(t *testing.T) { } } +func TestProcessMessage_TieredRoutingUsesToolsTierForRepoPrompt(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + fastCalls := 0 + fastServer := newStrictChatCompletionTestServer( + t, + "fast", + "gpt-5.4-nano", + "fast reply", + &fastCalls, + ) + defer fastServer.Close() + + toolsCalls := 0 + toolsServer := newStrictChatCompletionTestServer( + t, + "tools", + "gpt-5.4-mini", + "tools reply", + &toolsCalls, + ) + defer toolsServer.Close() + + heavyCalls := 0 + heavyServer := newStrictChatCompletionTestServer( + t, + "heavy", + "gpt-5-mini", + "heavy reply", + &heavyCalls, + ) + defer heavyServer.Close() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "gpt-5-mini", + MaxTokens: 4096, + MaxToolIterations: 10, + Routing: &config.RoutingConfig{ + Enabled: true, + FreeTier: "free", + PaidTier: "heavy", + Tiers: []config.RoutingTierConfig{ + {Name: "fast", MaxScore: 0.20, Model: &config.AgentModelConfig{Primary: "gpt-5.4-nano"}}, + {Name: "tools", MaxScore: 0, Model: &config.AgentModelConfig{Primary: "gpt-5.4-mini"}}, + {Name: "heavy", MaxScore: 0, Model: &config.AgentModelConfig{Primary: "gpt-5-mini"}}, + {Name: "free", MaxScore: -1, Model: &config.AgentModelConfig{Primary: "openrouter-free"}}, + }, + }, + }, + }, + ModelList: []*config.ModelConfig{ + {ModelName: "gpt-5.4-nano", Model: "openai/gpt-5.4-nano", APIBase: fastServer.URL, APIKeys: config.SimpleSecureStrings("fast-key")}, + {ModelName: "gpt-5.4-mini", Model: "openai/gpt-5.4-mini", APIBase: toolsServer.URL, APIKeys: config.SimpleSecureStrings("tools-key")}, + {ModelName: "gpt-5-mini", Model: "openai/gpt-5-mini", APIBase: heavyServer.URL, APIKeys: config.SimpleSecureStrings("heavy-key")}, + {ModelName: "openrouter-free", Model: "openrouter/openai/gpt-oss-20b:free", APIBase: "https://openrouter.ai/api/v1", APIKeys: config.SimpleSecureStrings("free-key")}, + }, + } + + msgBus := bus.NewMessageBus() + provider, _, err := providers.CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + simpleResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hi there", + Peer: bus.Peer{Kind: "direct", ID: "user1"}, + }) + if simpleResp != "fast reply" { + t.Fatalf("simple response = %q, want %q", simpleResp, "fast reply") + } + + repoResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "fix ./pkg/agent/loop.go and run go test", + Peer: bus.Peer{Kind: "direct", ID: "user1"}, + }) + if repoResp != "tools reply" { + t.Fatalf("repo response = %q, want %q", repoResp, "tools reply") + } + if fastCalls != 1 { + t.Fatalf("fast calls = %d, want 1", fastCalls) + } + if toolsCalls != 1 { + t.Fatalf("tools calls = %d, want 1", toolsCalls) + } + if heavyCalls != 0 { + t.Fatalf("heavy calls = %d, want 0", heavyCalls) + } +} + // TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") @@ -2226,7 +2819,8 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) { ID: "cron", }, }) - history := defaultAgent.Sessions.GetHistory(route.SessionKey) + scopeKey := resolveScopeKey(route, "tool-limit") + history := defaultAgent.Sessions.GetHistory(scopeKey) if len(history) != 4 { t.Fatalf("history len = %d, want 4", len(history)) } diff --git a/pkg/agent/model_resolution.go b/pkg/agent/model_resolution.go index 7cbf3a8d6..6f08fb148 100644 --- a/pkg/agent/model_resolution.go +++ b/pkg/agent/model_resolution.go @@ -8,6 +8,8 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) +const routingTierRefPrefix = "tier:" + func ensureProtocolModel(model string) string { model = strings.TrimSpace(model) if model == "" { @@ -168,3 +170,23 @@ func resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*conf return &clone, nil } + +func routingTierRef(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + return routingTierRefPrefix + name +} + +func parseRoutingTierRef(raw string) (string, bool) { + raw = strings.TrimSpace(raw) + if !strings.HasPrefix(strings.ToLower(raw), routingTierRefPrefix) { + return "", false + } + name := strings.TrimSpace(raw[len(routingTierRefPrefix):]) + if name == "" { + return "", false + } + return name, true +} diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index f03993deb..41268b60c 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -15,6 +15,10 @@ func BuiltinDefinitions() []Definition { boostCommand(), paidCommand(), freeCommand(), + codeCommand(), + codexCommand(), + routeCommand(), + defaultCommand(), statusCommand(), checkCommand(), clearCommand(), diff --git a/pkg/commands/cmd_codex.go b/pkg/commands/cmd_codex.go new file mode 100644 index 000000000..adb7ed05e --- /dev/null +++ b/pkg/commands/cmd_codex.go @@ -0,0 +1,1103 @@ +package commands + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + "time" +) + +func codexCommand() Definition { + attachHandler := func(actionLabel string) func(context.Context, Request, *Runtime) error { + return func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.CodexAttach == nil { + return req.Reply(unavailableMsg) + } + executorModel := codexExecutorModel(rt) + if executorModel == "" { + return req.Reply("Codex mode unavailable: no codex-cli model is configured.") + } + ref := strings.TrimSpace(nthToken(req.Text, 2)) + if ref == "" { + return req.Reply("Usage: /codex " + actionLabel + " ") + } + info, err := rt.CodexAttach(ref) + if err != nil { + return req.Reply(err.Error()) + } + _, plannerModel, _ := codexPlannerState(rt) + return req.Reply(formatCodexActivatedMessage(info, plannerModel, executorModel)) + } + } + + return Definition{ + Name: "codex", + Description: "Manage repo-scoped Codex sessions", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + return handleCodexConversationalEntry(req, rt) + }, + SubCommands: []SubCommand{ + { + Name: "new", + Description: "Create and activate a Codex session", + ArgsUsage: " [repo-url]", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.CodexNewSession == nil { + return req.Reply(unavailableMsg) + } + executorModel := codexExecutorModel(rt) + if executorModel == "" { + return req.Reply("Codex mode unavailable: no codex-cli model is configured.") + } + + slug, source, err := parseCodexNewArgs(req.Text) + if err != nil { + return req.Reply(err.Error()) + } + + info, err := rt.CodexNewSession(slug, source) + if err != nil { + return req.Reply(err.Error()) + } + _, plannerModel, _ := codexPlannerState(rt) + return req.Reply(formatCodexActivatedMessage(info, plannerModel, executorModel)) + }, + }, + { + Name: "attach", + Description: "Attach this chat to an existing Codex session", + ArgsUsage: "", + Handler: attachHandler("attach"), + }, + { + Name: "use", + Description: "Alias for /codex attach", + ArgsUsage: "", + Handler: attachHandler("use"), + }, + { + Name: "resume", + Description: "Alias for /codex attach", + ArgsUsage: "", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.CodexAttach == nil { + return req.Reply(unavailableMsg) + } + executorModel := codexExecutorModel(rt) + if executorModel == "" { + return req.Reply("Codex mode unavailable: no codex-cli model is configured.") + } + ref := strings.TrimSpace(nthToken(req.Text, 2)) + if ref == "" { + return req.Reply("Usage: /codex resume ") + } + info, err := rt.CodexAttach(ref) + if err != nil { + return req.Reply(err.Error()) + } + _, plannerModel, _ := codexPlannerState(rt) + return req.Reply(formatCodexActivatedMessage(info, plannerModel, executorModel)) + }, + }, + { + Name: "projects", + Description: "List known Codex projects", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.CodexListSessions == nil { + return req.Reply(unavailableMsg) + } + sessions := rt.CodexListSessions() + if len(sessions) == 0 { + return req.Reply("No codex projects yet. Start one with /codex new [repo-url].") + } + + lines := make([]string, 0, len(sessions)+1) + lines = append(lines, "Codex projects:") + for _, s := range sessions { + flag := "" + if s.Active { + flag = " [active]" + } + extra := "" + if s.RepoPath != "" { + extra += fmt.Sprintf(" path=%s", s.RepoPath) + } + if s.RepoURL != "" { + extra += fmt.Sprintf(" remote=%s", s.RepoURL) + } + updated := "" + if !s.Updated.IsZero() { + updated = " @ " + s.Updated.Format(time.RFC3339) + } + lines = append(lines, fmt.Sprintf("- %s %s%s%s%s", s.ID, s.Slug, flag, extra, updated)) + } + return req.Reply(strings.Join(lines, "\n")) + }, + }, + { + Name: "repos", + Description: "List GitHub repos visible to local gh auth", + ArgsUsage: "[limit]", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ListCodexRepoTargets == nil { + return req.Reply("Codex repo discovery unavailable: runtime callback is not configured.") + } + + limit := 20 + rawLimit := strings.TrimSpace(nthToken(req.Text, 2)) + if rawLimit != "" { + parsed, err := parsePositiveInt(rawLimit) + if err != nil { + return req.Reply("Usage: /codex repos [limit]") + } + limit = parsed + } + if limit > 50 { + limit = 50 + } + + repos, err := rt.ListCodexRepoTargets(limit) + if err != nil { + return req.Reply("GitHub repo discovery unavailable: " + err.Error()) + } + if len(repos) == 0 { + return req.Reply("No GitHub repos discovered. Verify gh auth on this host.") + } + + lines := make([]string, 0, len(repos)+1) + lines = append(lines, "GitHub repos:") + for _, repo := range repos { + lines = append(lines, "- "+repo) + } + return req.Reply(strings.Join(lines, "\n")) + }, + }, + { + Name: "list", + Description: "List known Codex sessions", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.CodexListSessions == nil { + return req.Reply(unavailableMsg) + } + sessions := rt.CodexListSessions() + if len(sessions) == 0 { + return req.Reply("No codex sessions yet. Start one with /codex new [repo-url].") + } + + lines := make([]string, 0, len(sessions)+1) + lines = append(lines, "Codex sessions:") + for _, s := range sessions { + flag := "" + if s.Active { + flag = " [active]" + } + updated := "" + if !s.Updated.IsZero() { + updated = " @ " + s.Updated.Format(time.RFC3339) + } + lines = append(lines, fmt.Sprintf("- %s %s%s%s", s.ID, s.Slug, flag, updated)) + } + return req.Reply(strings.Join(lines, "\n")) + }, + }, + { + Name: "models", + Description: "Show Codex model and delegation state", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil { + return req.Reply(unavailableMsg) + } + + defaultModel := "" + if rt.FindCodexModel != nil { + defaultModel = strings.TrimSpace(rt.FindCodexModel()) + } + persistent, pending := "", "" + if rt.GetSessionModelMode != nil { + persistent, pending = rt.GetSessionModelMode() + } + workMode := "" + if rt.GetSessionWorkMode != nil { + workMode = strings.TrimSpace(rt.GetSessionWorkMode()) + } + + lines := []string{"Codex model settings:"} + if defaultModel != "" { + lines = append(lines, fmt.Sprintf("- Default: %s", defaultModel)) + } else { + lines = append(lines, "- Default: unavailable") + } + if persistent != "" { + lines = append(lines, fmt.Sprintf("- Session: %s", persistent)) + } else { + lines = append(lines, "- Session: default routing") + } + if pending != "" { + lines = append(lines, fmt.Sprintf("- Pending: %s", pending)) + } else { + lines = append(lines, "- Pending: none") + } + if workMode != "" { + lines = append(lines, fmt.Sprintf("- Work mode: %s", workMode)) + } + + targets := codexDelegateTargets(rt) + if len(targets) == 0 { + lines = append(lines, "- Delegate targets: none configured") + } else { + lines = append(lines, fmt.Sprintf("- Delegate targets: %s", strings.Join(targets, ", "))) + } + + return req.Reply(strings.Join(lines, "\n")) + }, + }, + { + Name: "plan", + Description: "Refine the active Codex plan", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.CodexActive == nil || rt.SetSessionWorkMode == nil { + return req.Reply(unavailableMsg) + } + info, ok := rt.CodexActive() + if !ok || info == nil { + return req.Reply("No active codex session in this chat. Use /codex new or /codex attach first.") + } + if rt.ClearCodexApprovalPending != nil { + rt.ClearCodexApprovalPending() + } + if err := rt.SetSessionWorkMode("codex-plan"); err != nil { + return req.Reply(err.Error()) + } + return req.Reply("Codex planning mode enabled. I will discuss and refine a plan only. When the plan is ready, I will ask you to reply `proceed` to execute it.") + }, + }, + { + Name: "execute", + Description: "Arm the active Codex session for run execution", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.CodexActive == nil || rt.SetSessionWorkMode == nil { + return req.Reply(unavailableMsg) + } + info, ok := rt.CodexActive() + if !ok || info == nil { + return req.Reply("No active codex session in this chat. Use /codex new or /codex attach first.") + } + if rt.ClearCodexApprovalPending != nil { + rt.ClearCodexApprovalPending() + } + if err := rt.SetSessionWorkMode("codex-plan"); err != nil { + return req.Reply(err.Error()) + } + return req.Reply("Codex run execution is armed. Keep planning in chat, then reply `proceed` when you want me to launch the approved run.") + }, + }, + { + Name: "guide", + Description: "Show recommended conversational /codex workflow", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + lines := []string{ + "Conversational /codex quick start:", + "1) Send /codex once. It resumes your most recent project when possible, otherwise it helps you pick one.", + "2) Then chat normally, e.g. \"Open SkezOS, review latest changes, and propose the next feature.\"", + "3) I stay in planning mode first. When the plan is ready, I will tell you to reply `proceed`.", + "4) Reply `proceed` and I will launch the approved Codex run in the active repo session.", + "", + "Optional controls:", + "- /codex new owner/repo", + "- /codex resume ", + "- /codex status", + "- /codex runs", + "- /codex tail [run-id] [lines]", + "- /codex plan", + "- /codex repos [limit]", + "- /codex models", + } + return req.Reply(strings.Join(lines, "\n")) + }, + }, + { + Name: "help", + Description: "Alias for /codex guide", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + lines := []string{ + "Conversational /codex quick start:", + "1) Send /codex once. It resumes your most recent project when possible, otherwise it helps you pick one.", + "2) Then chat normally, e.g. \"Open SkezOS, review latest changes, and propose the next feature.\"", + "3) I stay in planning mode first. When the plan is ready, I will tell you to reply `proceed`.", + "4) Reply `proceed` and I will launch the approved Codex run in the active repo session.", + "", + "Optional controls:", + "- /codex new owner/repo", + "- /codex resume ", + "- /codex status", + "- /codex runs", + "- /codex tail [run-id] [lines]", + "- /codex plan", + "- /codex repos [limit]", + "- /codex models", + } + return req.Reply(strings.Join(lines, "\n")) + }, + }, + { + Name: "delegate", + Description: "Delegate the active codex session to a model", + ArgsUsage: " [next|session]", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.CodexActive == nil { + return req.Reply(unavailableMsg) + } + info, ok := rt.CodexActive() + if !ok || info == nil { + return req.Reply("No active codex session in this chat. Use /codex new or /codex attach first.") + } + + model := strings.TrimSpace(nthToken(req.Text, 2)) + if model == "" { + return req.Reply("Usage: /codex delegate [next|session]") + } + mode := strings.ToLower(strings.TrimSpace(nthToken(req.Text, 3))) + if mode == "" { + mode = "next" + } + targets := codexDelegateTargets(rt) + if len(targets) > 0 && !containsStringFold(targets, model) { + return req.Reply(fmt.Sprintf("Model %q is not an allowed delegate target. Use /codex models to view targets.", model)) + } + switch mode { + case "next": + if rt.ArmNextModelMode == nil { + return req.Reply("Codex delegation unavailable: next-message model callback is not configured.") + } + if err := rt.ArmNextModelMode(model); err != nil { + return req.Reply(err.Error()) + } + return req.Reply(fmt.Sprintf("Codex delegation armed for next message: %s", model)) + case "session": + if rt.SetSessionModelMode == nil { + return req.Reply("Codex delegation unavailable: session model callback is not configured.") + } + if err := rt.SetSessionModelMode(model); err != nil { + return req.Reply(err.Error()) + } + return req.Reply(fmt.Sprintf("Codex session model set to %s.", model)) + default: + return req.Reply("Usage: /codex delegate [next|session]") + } + }, + }, + { + Name: "status", + Description: "Show current Codex planner and run state", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.CodexActive == nil { + return req.Reply(unavailableMsg) + } + info, ok := rt.CodexActive() + if !ok || info == nil { + return req.Reply("No active codex session in this chat. Use /codex new or /codex attach.") + } + phase, model, planner := codexPlannerState(rt) + run := codexActiveRunState(rt) + lines := []string{ + "Codex session is active.", + fmt.Sprintf("ID: %s", info.ID), + fmt.Sprintf("Repo: %s", info.Slug), + fmt.Sprintf("Path: %s", info.RepoPath), + } + if info.RepoURL != "" { + lines = append(lines, fmt.Sprintf("Remote: %s", info.RepoURL)) + } + lines = append(lines, fmt.Sprintf("Phase: %s", phase)) + if planner != nil { + if planner.Model != "" { + lines = append(lines, fmt.Sprintf("Planner model: %s", planner.Model)) + } + if planner.SessionID != "" { + lines = append(lines, fmt.Sprintf("Planner session: %s", planner.SessionID)) + } + } else if model != "" { + lines = append(lines, fmt.Sprintf("Planner model: %s", model)) + } + if executorModel := codexExecutorModel(rt); executorModel != "" { + lines = append(lines, fmt.Sprintf("Executor model: %s", executorModel)) + } + if run != nil { + lines = append(lines, fmt.Sprintf("Run: %s", codexRunLabel(run))) + if run.ID != "" { + lines = append(lines, fmt.Sprintf("Run ID: %s", run.ID)) + } + if run.Status != "" { + lines = append(lines, fmt.Sprintf("Run status: %s", run.Status)) + } + if run.Model != "" { + lines = append(lines, fmt.Sprintf("Run model: %s", run.Model)) + } + if run.Branch != "" { + lines = append(lines, fmt.Sprintf("Run branch: %s", run.Branch)) + } + if run.Worktree != "" { + lines = append(lines, fmt.Sprintf("Run worktree: %s", run.Worktree)) + } + if run.PID > 0 { + lines = append(lines, fmt.Sprintf("Run pid: %d", run.PID)) + } + } else { + lines = append(lines, "Run: none") + } + return req.Reply(strings.Join(lines, "\n")) + }, + }, + { + Name: "runs", + Description: "List background Codex runs", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.CodexRunList == nil { + return req.Reply("Codex run listing unavailable: runtime callback is not configured.") + } + runs := rt.CodexRunList() + if len(runs) == 0 { + return req.Reply("No codex runs recorded yet.") + } + + lines := make([]string, 0, len(runs)+1) + lines = append(lines, "Codex runs:") + for _, run := range runs { + lines = append(lines, formatCodexRunListLine(run)) + } + return req.Reply(strings.Join(lines, "\n")) + }, + }, + { + Name: "tail", + Description: "Show the tail of a Codex run log", + ArgsUsage: "[run-id] [lines]", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.CodexRunTail == nil { + return req.Reply("Codex run tail unavailable: runtime callback is not configured.") + } + runID := strings.TrimSpace(nthToken(req.Text, 2)) + lines := 120 + rawLines := strings.TrimSpace(nthToken(req.Text, 3)) + if rawLines != "" { + parsed, err := parsePositiveInt(rawLines) + if err != nil { + return req.Reply("Usage: /codex tail [run-id] [lines]") + } + lines = parsed + } + if lines > 500 { + lines = 500 + } + if runID == "" { + if current := codexActiveRunState(rt); current != nil && current.ID != "" { + runID = current.ID + } + } + if runID == "" { + return req.Reply("No codex run is active yet. Start or resume a run first.") + } + + output, err := rt.CodexRunTail(runID, lines) + if err != nil { + return req.Reply(err.Error()) + } + if strings.TrimSpace(output) == "" { + return req.Reply(fmt.Sprintf("No log output available for run %s.", runID)) + } + return req.Reply(output) + }, + }, + { + Name: "stop", + Description: "Stop the active Codex run and detach this chat", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil { + return req.Reply(unavailableMsg) + } + if rt.CodexRunStop != nil { + if err := rt.CodexRunStop(); err != nil { + return req.Reply(err.Error()) + } + return req.Reply("Codex run stopped. Session routing returned to default.") + } + if rt.CodexStop == nil { + return req.Reply(unavailableMsg) + } + if err := rt.CodexStop(); err != nil { + return req.Reply(err.Error()) + } + return req.Reply("Codex run stop requested. Session routing returned to default.") + }, + }, + }, + } +} + +func codexDelegateTargets(rt *Runtime) []string { + if rt == nil { + return nil + } + if rt.ListCodexDelegateTargets != nil { + targets := rt.ListCodexDelegateTargets() + if len(targets) == 0 { + return nil + } + dedup := make(map[string]struct{}, len(targets)) + out := make([]string, 0, len(targets)) + for _, target := range targets { + target = strings.TrimSpace(target) + if target == "" { + continue + } + if _, exists := dedup[target]; exists { + continue + } + dedup[target] = struct{}{} + out = append(out, target) + } + return out + } + + models := []string{} + if rt.FindCodexModel != nil { + if model := strings.TrimSpace(rt.FindCodexModel()); model != "" { + models = append(models, model) + } + } + if rt.Config != nil { + targets := sessionModeTargets(rt) + if targets.Fast.Label != "" { + models = append(models, targets.Fast.Label) + } + if targets.Heavy.Label != "" { + models = append(models, targets.Heavy.Label) + } + if targets.Tools.Label != "" { + models = append(models, targets.Tools.Label) + } + if targets.Free.Label != "" { + models = append(models, targets.Free.Label) + } + } + + dedup := make(map[string]struct{}, len(models)) + out := make([]string, 0, len(models)) + for _, model := range models { + if _, exists := dedup[model]; exists { + continue + } + dedup[model] = struct{}{} + out = append(out, model) + } + return out +} + +func formatCodexActivatedMessage(info *CodexSessionInfo, plannerModel, executorModel string) string { + if info == nil { + return "Codex session is active." + } + lines := []string{ + "Codex session is active in planning mode.", + fmt.Sprintf("ID: %s", info.ID), + fmt.Sprintf("Repo: %s", info.Slug), + fmt.Sprintf("Path: %s", info.RepoPath), + } + if info.RepoURL != "" { + lines = append(lines, fmt.Sprintf("Remote: %s", info.RepoURL)) + } + if plannerModel != "" { + lines = append(lines, fmt.Sprintf("Planner model: %s", plannerModel)) + } + if executorModel != "" { + lines = append(lines, fmt.Sprintf("Executor model: %s", executorModel)) + } + lines = append(lines, "Chat normally now. I will plan first, then ask you to reply `proceed` when the approved run is ready.") + return strings.Join(lines, "\n") +} + +func handleCodexConversationalEntry(req Request, rt *Runtime) error { + if rt == nil { + return req.Reply(unavailableMsg) + } + + executorModel := codexExecutorModel(rt) + if executorModel == "" { + return req.Reply("Codex mode unavailable: no codex-cli model is configured.") + } + + intent := codexCommandTail(req.Text) + info, note, err := activateCodexSessionForConversationalEntry(rt, intent) + if err != nil { + return req.Reply(err.Error()) + } + if info == nil { + if strings.TrimSpace(intent) == "" { + return req.Reply("No codex session is active yet. Start one with /codex new owner/repo, then chat normally.") + } + return req.Reply("I understand your intent, but I need a repo session first. Use /codex new owner/repo (or /codex repos), then send the request again without /codex.") + } + + lines := []string{ + "Codex conversational mode is ready.", + fmt.Sprintf("Repo: %s", info.Slug), + fmt.Sprintf("Path: %s", info.RepoPath), + } + phase, _, planner := codexPlannerState(rt) + if strings.TrimSpace(phase) == "" || strings.EqualFold(strings.TrimSpace(phase), "inactive") { + phase = "planning" + } + lines = append(lines, fmt.Sprintf("Phase: %s", phase)) + if planner != nil && planner.Model != "" { + lines = append(lines, fmt.Sprintf("Planner model: %s", planner.Model)) + } + if executorModel != "" { + lines = append(lines, fmt.Sprintf("Executor model: %s", executorModel)) + } + if note != "" { + lines = append(lines, note) + } + if strings.TrimSpace(intent) == "" { + lines = append(lines, `Talk normally now, for example: "review latest changes and propose the next 3 tasks".`) + } else { + lines = append(lines, `I captured your kickoff request. Send it again without "/codex" and I will use it as the planning brief for this repo session.`) + } + return req.Reply(strings.Join(lines, "\n")) +} + +func codexExecutorModel(rt *Runtime) string { + if rt == nil || rt.FindCodexModel == nil { + return "" + } + return strings.TrimSpace(rt.FindCodexModel()) +} + +func codexPhaseLabel(workMode string, approvalPending bool) string { + switch strings.ToLower(strings.TrimSpace(workMode)) { + case "codex": + return "executing" + case "codex-plan": + if approvalPending { + return "awaiting approval" + } + return "planning" + default: + if approvalPending { + return "awaiting approval" + } + return "inactive" + } +} + +func codexPlannerState(rt *Runtime) (phase, model string, planner *CodexPlannerStatusInfo) { + if rt == nil { + return "", "", nil + } + if rt.CodexPlannerStatus != nil { + if info, ok := rt.CodexPlannerStatus(); ok && info != nil { + phase = strings.TrimSpace(info.Phase) + if phase == "" { + if info.ApprovalPending { + phase = "awaiting approval" + } else { + phase = "inactive" + } + } + if info.Phase == "" && info.ApprovalPending { + info.Phase = phase + } + return phase, strings.TrimSpace(info.Model), info + } + } + + model = "" + if rt.FindCodexModel != nil { + model = strings.TrimSpace(rt.FindCodexModel()) + } + phase = "inactive" + approvalPending := false + if rt.GetSessionWorkMode != nil { + workMode := strings.TrimSpace(rt.GetSessionWorkMode()) + approvalPending = rt.GetCodexApprovalPending != nil && rt.GetCodexApprovalPending() + phase = codexPhaseLabel(workMode, approvalPending) + } + if (phase == "" || phase == "inactive") && rt.CodexActive != nil { + if info, ok := rt.CodexActive(); ok && info != nil { + phase = codexPhaseLabel("codex-plan", approvalPending) + } + } + return phase, model, &CodexPlannerStatusInfo{ + Phase: phase, + Model: model, + ApprovalPending: approvalPending, + } +} + +func codexActiveRunState(rt *Runtime) *CodexRunInfo { + if rt == nil { + return nil + } + if rt.CodexRunStatus != nil { + if info, ok := rt.CodexRunStatus(); ok && info != nil { + return info + } + } + if rt.CodexRunList == nil { + return nil + } + runs := rt.CodexRunList() + if len(runs) == 0 { + return nil + } + for i := range runs { + run := runs[i] + if run.Active || strings.EqualFold(strings.TrimSpace(run.Status), "running") { + return &run + } + } + run := runs[0] + return &run +} + +func codexRunLabel(info *CodexRunInfo) string { + if info == nil { + return "none" + } + status := strings.TrimSpace(info.Status) + if status == "" { + if info.Active { + return "active" + } + return "unknown" + } + return status +} + +func formatCodexRunListLine(run CodexRunInfo) string { + parts := make([]string, 0, 8) + head := run.ID + if head == "" { + head = "unknown" + } + if run.RepoSlug != "" { + head += " " + run.RepoSlug + } + if run.Active { + head += " [active]" + } + parts = append(parts, head) + if run.Status != "" { + parts = append(parts, "status="+run.Status) + } + if run.Model != "" { + parts = append(parts, "model="+run.Model) + } + if run.Branch != "" { + parts = append(parts, "branch="+run.Branch) + } + if run.Worktree != "" { + parts = append(parts, "worktree="+run.Worktree) + } + if !run.StartedAt.IsZero() { + parts = append(parts, "@ "+run.StartedAt.UTC().Format(time.RFC3339)) + } + return "- " + strings.Join(parts, " ") +} + +func activateCodexSessionForConversationalEntry(rt *Runtime, intent string) (*CodexSessionInfo, string, error) { + if rt == nil || rt.CodexActive == nil { + return nil, "", fmt.Errorf(unavailableMsg) + } + if info, ok := rt.CodexActive(); ok && info != nil { + return info, "Using the already-active codex session for this chat.", nil + } + + intent = strings.TrimSpace(intent) + + if rt.CodexListSessions != nil && rt.CodexAttach != nil { + sessions := rt.CodexListSessions() + if len(sessions) > 0 { + if matched, ambiguous := matchCodexSessionByIntent(intent, sessions); matched != nil { + info, err := rt.CodexAttach(matched.ID) + if err != nil { + return nil, "", err + } + return info, fmt.Sprintf("Matched existing project: %s.", matched.Slug), nil + } else if len(ambiguous) > 0 { + return nil, "", fmt.Errorf("multiple codex projects matched (%s). Use /codex resume .", strings.Join(ambiguous, ", ")) + } + + latest := sessions[0] + info, err := rt.CodexAttach(latest.ID) + if err != nil { + return nil, "", err + } + return info, fmt.Sprintf("Resumed most recent project: %s.", latest.Slug), nil + } + } + + if intent == "" || rt.ListCodexRepoTargets == nil || rt.CodexNewSession == nil { + return nil, "", nil + } + + repos, err := rt.ListCodexRepoTargets(30) + if err != nil { + return nil, "", nil + } + matchedRepo, ambiguousRepos := matchCodexRepoByIntent(intent, repos) + if matchedRepo == "" { + if len(ambiguousRepos) > 0 { + return nil, "", fmt.Errorf("multiple GitHub repos matched (%s). Use /codex new owner/repo.", strings.Join(ambiguousRepos, ", ")) + } + return nil, "", nil + } + + slug, source, ok := inferCodexNewFromSingleArg(matchedRepo) + if !ok { + slug = strings.ReplaceAll(strings.TrimSpace(matchedRepo), "/", "-") + source = strings.TrimSpace(matchedRepo) + } + info, err := rt.CodexNewSession(slug, source) + if err != nil { + return nil, "", err + } + return info, fmt.Sprintf("Created new project from GitHub repo: %s.", matchedRepo), nil +} + +func codexCommandTail(text string) string { + parts := strings.Fields(strings.TrimSpace(text)) + if len(parts) <= 1 { + return "" + } + return strings.TrimSpace(strings.Join(parts[1:], " ")) +} + +func matchCodexSessionByIntent(intent string, sessions []CodexSessionInfo) (*CodexSessionInfo, []string) { + intentNorm := normalizeCodexIntentToken(intent) + if intentNorm == "" { + return nil, nil + } + + matches := make([]CodexSessionInfo, 0, 3) + for _, session := range sessions { + if codexSessionMatchesIntent(intentNorm, session) { + matches = append(matches, session) + } + } + switch len(matches) { + case 0: + return nil, nil + case 1: + return &matches[0], nil + default: + labels := make([]string, 0, len(matches)) + for _, m := range matches { + if len(labels) >= 3 { + break + } + labels = append(labels, m.Slug) + } + return nil, labels + } +} + +func matchCodexRepoByIntent(intent string, repos []string) (string, []string) { + intentNorm := normalizeCodexIntentToken(intent) + if intentNorm == "" { + return "", nil + } + + matches := make([]string, 0, 3) + for _, repo := range repos { + if codexRepoMatchesIntent(intentNorm, repo) { + matches = append(matches, repo) + } + } + switch len(matches) { + case 0: + return "", nil + case 1: + return matches[0], nil + default: + if len(matches) > 3 { + matches = matches[:3] + } + return "", matches + } +} + +func codexSessionMatchesIntent(intentNorm string, session CodexSessionInfo) bool { + aliases := make([]string, 0, 4) + aliases = append(aliases, session.Slug) + if idx := strings.LastIndex(session.Slug, "-"); idx >= 0 && idx+1 < len(session.Slug) { + aliases = append(aliases, session.Slug[idx+1:]) + } + if repoName := repoNameFromOwnerRepo(session.RepoURL); repoName != "" { + aliases = append(aliases, repoName) + } + if repoName := repoNameFromPath(session.RepoPath); repoName != "" { + aliases = append(aliases, repoName) + } + + for _, alias := range aliases { + aliasNorm := normalizeCodexIntentToken(alias) + if len(aliasNorm) < 3 { + continue + } + if strings.Contains(intentNorm, aliasNorm) { + return true + } + } + return false +} + +func codexRepoMatchesIntent(intentNorm, repo string) bool { + ownerRepoNorm := normalizeCodexIntentToken(repo) + if len(ownerRepoNorm) >= 3 && strings.Contains(intentNorm, ownerRepoNorm) { + return true + } + repoName := repoNameFromOwnerRepo(repo) + repoNameNorm := normalizeCodexIntentToken(repoName) + return len(repoNameNorm) >= 3 && strings.Contains(intentNorm, repoNameNorm) +} + +func repoNameFromOwnerRepo(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + + if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") || strings.HasPrefix(value, "ssh://") { + parsed, err := url.Parse(value) + if err == nil { + value = strings.Trim(parsed.Path, "/") + } + } + if strings.HasPrefix(value, "git@") { + if idx := strings.Index(value, ":"); idx >= 0 && idx+1 < len(value) { + value = value[idx+1:] + } + } + value = strings.TrimSuffix(strings.TrimSpace(value), ".git") + value = strings.Trim(value, "/") + if value == "" { + return "" + } + parts := strings.Split(value, "/") + return strings.TrimSpace(parts[len(parts)-1]) +} + +func repoNameFromPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) == 0 { + return "" + } + return strings.TrimSpace(parts[len(parts)-1]) +} + +func normalizeCodexIntentToken(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" + } + var b strings.Builder + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + return b.String() +} + +func parseCodexNewArgs(text string) (string, string, error) { + parts := strings.Fields(strings.TrimSpace(text)) + if len(parts) < 3 { + return "", "", fmt.Errorf("Usage: /codex new [repo-url]") + } + + first := strings.TrimSpace(parts[2]) + if first == "" { + return "", "", fmt.Errorf("Usage: /codex new [repo-url]") + } + + if len(parts) == 3 { + if slug, source, ok := inferCodexNewFromSingleArg(first); ok { + return slug, source, nil + } + return first, "", nil + } + + source := strings.TrimSpace(strings.Join(parts[3:], " ")) + return first, source, nil +} + +func inferCodexNewFromSingleArg(arg string) (slug, source string, ok bool) { + arg = strings.TrimSpace(arg) + if arg == "" { + return "", "", false + } + + isURL := strings.HasPrefix(arg, "https://") || strings.HasPrefix(arg, "ssh://") || strings.HasPrefix(arg, "git@") + isOwnerRepo := strings.Count(arg, "/") == 1 && !strings.ContainsAny(arg, " \t:") + if !isURL && !isOwnerRepo { + return "", "", false + } + + source = arg + if isOwnerRepo { + slug = strings.ReplaceAll(arg, "/", "-") + return slug, source, true + } + + slug = slugFromRepoSource(arg) + if slug == "" { + return "", "", false + } + return slug, source, true +} + +func slugFromRepoSource(source string) string { + source = strings.TrimSpace(source) + if source == "" { + return "" + } + + pathPart := source + if strings.HasPrefix(source, "git@") { + if idx := strings.Index(source, ":"); idx >= 0 && idx+1 < len(source) { + pathPart = source[idx+1:] + } + } else if parsed, err := url.Parse(source); err == nil { + pathPart = parsed.Path + } + + pathPart = strings.TrimSpace(strings.Trim(pathPart, "/")) + pathPart = strings.TrimSuffix(pathPart, ".git") + pathPart = strings.ReplaceAll(pathPart, "/", "-") + pathPart = strings.Trim(pathPart, "-._") + return pathPart +} + +func parsePositiveInt(raw string) (int, error) { + value, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || value <= 0 { + return 0, fmt.Errorf("invalid positive integer") + } + return value, nil +} + +func containsStringFold(values []string, needle string) bool { + needle = strings.TrimSpace(needle) + for _, value := range values { + if strings.EqualFold(strings.TrimSpace(value), needle) { + return true + } + } + return false +} diff --git a/pkg/commands/cmd_codex_test.go b/pkg/commands/cmd_codex_test.go new file mode 100644 index 000000000..1c0423209 --- /dev/null +++ b/pkg/commands/cmd_codex_test.go @@ -0,0 +1,976 @@ +package commands + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestCodexNew_ActivatesSession(t *testing.T) { + var gotSlug, gotSource string + rt := &Runtime{ + FindCodexModel: func() string { return "codex-local" }, + CodexNewSession: func(slug, source string) (*CodexSessionInfo, error) { + gotSlug, gotSource = slug, source + return &CodexSessionInfo{ + ID: "abc123", + Slug: "acme_repo", + RepoPath: "/workspace/repos/acme_repo", + RepoURL: "https://github.com/acme/repo.git", + }, nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex new acme_repo https://github.com/acme/repo.git", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if gotSlug != "acme_repo" { + t.Fatalf("slug=%q, want=%q", gotSlug, "acme_repo") + } + if gotSource != "https://github.com/acme/repo.git" { + t.Fatalf("source=%q, want repo url", gotSource) + } + if !strings.Contains(reply, "Codex session is active") { + t.Fatalf("reply=%q, expected activation message", reply) + } + if !strings.Contains(reply, "Planner model: codex-local") { + t.Fatalf("reply=%q, expected planner model line", reply) + } + if !strings.Contains(reply, "Executor model: codex-local") { + t.Fatalf("reply=%q, expected executor model line", reply) + } + if !strings.Contains(reply, "reply `proceed`") { + t.Fatalf("reply=%q, expected proceed guidance", reply) + } +} + +func TestCodexNew_RequiresCodexModel(t *testing.T) { + rt := &Runtime{ + FindCodexModel: func() string { return "" }, + CodexNewSession: func(slug, source string) (*CodexSessionInfo, error) { + return nil, nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex new acme_repo", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Codex mode unavailable: no codex-cli model is configured." { + t.Fatalf("reply=%q, want unavailable codex model message", reply) + } +} + +func TestCodexNew_InferOwnerRepoSource(t *testing.T) { + var gotSlug, gotSource string + rt := &Runtime{ + FindCodexModel: func() string { return "codex-local" }, + CodexNewSession: func(slug, source string) (*CodexSessionInfo, error) { + gotSlug, gotSource = slug, source + return &CodexSessionInfo{ + ID: "abc123", + Slug: slug, + RepoPath: "/workspace/repos/" + slug, + RepoURL: "https://github.com/acme/repo.git", + }, nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex new acme/repo", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if gotSlug != "acme-repo" { + t.Fatalf("slug=%q, want=%q", gotSlug, "acme-repo") + } + if gotSource != "acme/repo" { + t.Fatalf("source=%q, want=%q", gotSource, "acme/repo") + } + if !strings.Contains(reply, "Codex session is active") { + t.Fatalf("reply=%q, expected activation message", reply) + } +} + +func TestCodexNew_InferURLSource(t *testing.T) { + var gotSlug, gotSource string + rt := &Runtime{ + FindCodexModel: func() string { return "codex-local" }, + CodexNewSession: func(slug, source string) (*CodexSessionInfo, error) { + gotSlug, gotSource = slug, source + return &CodexSessionInfo{ + ID: "abc123", + Slug: slug, + RepoPath: "/workspace/repos/" + slug, + RepoURL: source, + }, nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex new https://github.com/acme/repo.git", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if gotSlug != "acme-repo" { + t.Fatalf("slug=%q, want=%q", gotSlug, "acme-repo") + } + if gotSource != "https://github.com/acme/repo.git" { + t.Fatalf("source=%q, want URL", gotSource) + } + if !strings.Contains(reply, "Codex session is active") { + t.Fatalf("reply=%q, expected activation message", reply) + } +} + +func TestCodexList_IncludesActiveMarker(t *testing.T) { + now := time.Date(2026, 4, 6, 14, 0, 0, 0, time.UTC) + rt := &Runtime{ + CodexListSessions: func() []CodexSessionInfo { + return []CodexSessionInfo{ + {ID: "a1", Slug: "picoclaw", Active: true, Updated: now}, + {ID: "b2", Slug: "docs"}, + } + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex list", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "a1 picoclaw [active] @ 2026-04-06T14:00:00Z") { + t.Fatalf("reply=%q, expected active listing", reply) + } + if !strings.Contains(reply, "b2 docs") { + t.Fatalf("reply=%q, expected secondary listing", reply) + } +} + +func TestCodexStatus_NoActiveSession(t *testing.T) { + rt := &Runtime{ + CodexActive: func() (*CodexSessionInfo, bool) { + return nil, false + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex status", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "No active codex session in this chat. Use /codex new or /codex attach." { + t.Fatalf("reply=%q, want inactive message", reply) + } +} + +func TestCodexStatus_ShowsAwaitingApprovalPhase(t *testing.T) { + rt := &Runtime{ + CodexActive: func() (*CodexSessionInfo, bool) { + return &CodexSessionInfo{ID: "a1", Slug: "picoclaw", RepoPath: "/workspace/repos/picoclaw"}, true + }, + FindCodexModel: func() string { return "gpt-5.4-mini" }, + GetSessionWorkMode: func() string { return "codex-plan" }, + GetCodexApprovalPending: func() bool { return true }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex status", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + for _, want := range []string{"Repo: picoclaw", "Phase: awaiting approval"} { + if !strings.Contains(reply, want) { + t.Fatalf("reply=%q, missing %q", reply, want) + } + } +} + +func TestCodexStatus_ShowsPlannerAndRunState(t *testing.T) { + rt := &Runtime{ + CodexActive: func() (*CodexSessionInfo, bool) { + return &CodexSessionInfo{ID: "a1", Slug: "picoclaw", RepoPath: "/workspace/repos/picoclaw", RepoURL: "https://github.com/sipeed/picoclaw.git"}, true + }, + CodexPlannerStatus: func() (*CodexPlannerStatusInfo, bool) { + return &CodexPlannerStatusInfo{ + Phase: "planning", + Model: "gpt-5.4-mini", + SessionID: "chat-1", + ApprovalPending: false, + }, true + }, + CodexRunStatus: func() (*CodexRunInfo, bool) { + return &CodexRunInfo{ + ID: "run-1", + RepoSlug: "picoclaw", + Status: "running", + Model: "codex-cli-local", + Branch: "pc/chat-1/run-1", + Worktree: "/workspace/worktrees/picoclaw/run-1", + PID: 1234, + Active: true, + }, true + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex status", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + for _, want := range []string{ + "Phase: planning", + "Planner model: gpt-5.4-mini", + "Planner session: chat-1", + "Run: running", + "Run ID: run-1", + "Run status: running", + "Run model: codex-cli-local", + "Run branch: pc/chat-1/run-1", + "Run worktree: /workspace/worktrees/picoclaw/run-1", + "Run pid: 1234", + } { + if !strings.Contains(reply, want) { + t.Fatalf("reply=%q, missing %q", reply, want) + } + } +} + +func TestCodexResume_UsesAttach(t *testing.T) { + var gotRef string + rt := &Runtime{ + FindCodexModel: func() string { return "codex-local" }, + CodexAttach: func(ref string) (*CodexSessionInfo, error) { + gotRef = ref + return &CodexSessionInfo{ID: "a1", Slug: "picoclaw", RepoPath: "/workspace/repos/picoclaw"}, nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex resume picoclaw", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if gotRef != "picoclaw" { + t.Fatalf("ref=%q, want=%q", gotRef, "picoclaw") + } + if !strings.Contains(reply, "Codex session is active") { + t.Fatalf("reply=%q, expected activation message", reply) + } +} + +func TestCodexUse_AliasForAttach(t *testing.T) { + var gotRef string + rt := &Runtime{ + FindCodexModel: func() string { return "codex-local" }, + CodexAttach: func(ref string) (*CodexSessionInfo, error) { + gotRef = ref + return &CodexSessionInfo{ID: "a1", Slug: "picoclaw", RepoPath: "/workspace/repos/picoclaw"}, nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex use picoclaw", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if gotRef != "picoclaw" { + t.Fatalf("ref=%q, want=%q", gotRef, "picoclaw") + } + if !strings.Contains(reply, "Codex session is active") { + t.Fatalf("reply=%q, expected activation message", reply) + } +} + +func TestCodexProjects_ShowsRepoPathsAndRemote(t *testing.T) { + now := time.Date(2026, 4, 6, 14, 30, 0, 0, time.UTC) + rt := &Runtime{ + CodexListSessions: func() []CodexSessionInfo { + return []CodexSessionInfo{ + {ID: "a1", Slug: "picoclaw", RepoPath: "/workspace/repos/picoclaw", RepoURL: "https://github.com/sipeed/picoclaw.git", Active: true, Updated: now}, + {ID: "b2", Slug: "docs"}, + } + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex projects", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "Codex projects:") { + t.Fatalf("reply=%q, expected header", reply) + } + if !strings.Contains(reply, "a1 picoclaw [active] path=/workspace/repos/picoclaw remote=https://github.com/sipeed/picoclaw.git @ 2026-04-06T14:30:00Z") { + t.Fatalf("reply=%q, expected detailed project listing", reply) + } + if !strings.Contains(reply, "b2 docs") { + t.Fatalf("reply=%q, expected second project listing", reply) + } +} + +func TestCodexRepos_ListsTargets(t *testing.T) { + rt := &Runtime{ + ListCodexRepoTargets: func(limit int) ([]string, error) { + if limit != 20 { + t.Fatalf("limit=%d, want=20", limit) + } + return []string{"acme/repo-one", "acme/repo-two"}, nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex repos", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "GitHub repos:") { + t.Fatalf("reply=%q, expected header", reply) + } + if !strings.Contains(reply, "- acme/repo-one") || !strings.Contains(reply, "- acme/repo-two") { + t.Fatalf("reply=%q, expected repo entries", reply) + } +} + +func TestCodexRepos_InvalidLimitShowsUsage(t *testing.T) { + rt := &Runtime{ + ListCodexRepoTargets: func(limit int) ([]string, error) { + return nil, nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex repos abc", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Usage: /codex repos [limit]" { + t.Fatalf("reply=%q, want usage", reply) + } +} + +func TestCodexModels_ShowsSessionAndTargets(t *testing.T) { + rt := &Runtime{ + FindCodexModel: func() string { return "gpt-5.4-mini" }, + GetSessionModelMode: func() (string, string) { + return "gpt-5.4-pro", "gpt-5.4-mini" + }, + GetSessionWorkMode: func() string { return "codex" }, + ListCodexDelegateTargets: func() []string { + return []string{"gpt-5.4-mini", "gpt-5.4-pro"} + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex models", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + for _, want := range []string{ + "Codex model settings:", + "- Default: gpt-5.4-mini", + "- Session: gpt-5.4-pro", + "- Pending: gpt-5.4-mini", + "- Work mode: codex", + "- Delegate targets: gpt-5.4-mini, gpt-5.4-pro", + } { + if !strings.Contains(reply, want) { + t.Fatalf("reply=%q, missing %q", reply, want) + } + } +} + +func TestCodexPlan_SetsPlanningWorkMode(t *testing.T) { + var setMode string + cleared := false + rt := &Runtime{ + CodexActive: func() (*CodexSessionInfo, bool) { + return &CodexSessionInfo{ID: "a1", Slug: "picoclaw"}, true + }, + SetSessionWorkMode: func(value string) error { + setMode = value + return nil + }, + ClearCodexApprovalPending: func() { + cleared = true + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex plan", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if setMode != "codex-plan" { + t.Fatalf("work mode=%q, want=%q", setMode, "codex-plan") + } + if !cleared { + t.Fatal("expected approval state to be cleared") + } + if !strings.Contains(reply, "planning mode enabled") { + t.Fatalf("reply=%q, want planning mode confirmation", reply) + } + if !strings.Contains(reply, "reply `proceed`") { + t.Fatalf("reply=%q, want proceed guidance", reply) + } +} + +func TestCodexExecute_SetsExecutionWorkMode(t *testing.T) { + var setMode string + cleared := false + rt := &Runtime{ + CodexActive: func() (*CodexSessionInfo, bool) { + return &CodexSessionInfo{ID: "a1", Slug: "picoclaw"}, true + }, + SetSessionWorkMode: func(value string) error { + setMode = value + return nil + }, + ClearCodexApprovalPending: func() { + cleared = true + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex execute", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if setMode != "codex-plan" { + t.Fatalf("work mode=%q, want=%q", setMode, "codex-plan") + } + if !cleared { + t.Fatal("expected approval state to be cleared") + } + if !strings.Contains(reply, "launch the approved run") { + t.Fatalf("reply=%q, want launch guidance", reply) + } +} + +func TestCodexGuide_ContainsPlanAndExecuteFlow(t *testing.T) { + rt := &Runtime{} + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex guide", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + for _, want := range []string{"reply `proceed`", "/codex status", "/codex runs", "/codex tail [run-id] [lines]"} { + if !strings.Contains(reply, want) { + t.Fatalf("reply=%q, missing %q", reply, want) + } + } +} + +func TestCodexRuns_ListsRuns(t *testing.T) { + now := time.Date(2026, 4, 6, 15, 0, 0, 0, time.UTC) + rt := &Runtime{ + CodexRunList: func() []CodexRunInfo { + return []CodexRunInfo{ + {ID: "run-1", RepoSlug: "picoclaw", Status: "running", Model: "codex-cli-local", Branch: "pc/chat-1/run-1", Worktree: "/workspace/worktrees/picoclaw/run-1", Active: true, StartedAt: now}, + {ID: "run-2", RepoSlug: "skezos", Status: "succeeded", Model: "codex-cli-local", Branch: "pc/chat-2/run-2", Worktree: "/workspace/worktrees/skezos/run-2", StartedAt: now.Add(-time.Hour)}, + } + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex runs", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + for _, want := range []string{ + "Codex runs:", + "run-1 picoclaw [active]", + "status=running", + "model=codex-cli-local", + "branch=pc/chat-1/run-1", + "worktree=/workspace/worktrees/picoclaw/run-1", + "run-2 skezos", + "status=succeeded", + } { + if !strings.Contains(reply, want) { + t.Fatalf("reply=%q, missing %q", reply, want) + } + } +} + +func TestCodexTail_UsesActiveRunWhenIDMissing(t *testing.T) { + var gotID string + var gotLines int + rt := &Runtime{ + CodexRunStatus: func() (*CodexRunInfo, bool) { + return &CodexRunInfo{ID: "run-1", RepoSlug: "picoclaw", Status: "running", Active: true}, true + }, + CodexRunTail: func(runID string, lines int) (string, error) { + gotID = runID + gotLines = lines + return "tail line 1\ntail line 2", nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex tail", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if gotID != "run-1" { + t.Fatalf("runID=%q, want=%q", gotID, "run-1") + } + if gotLines != 120 { + t.Fatalf("lines=%d, want=120", gotLines) + } + if reply != "tail line 1\ntail line 2" { + t.Fatalf("reply=%q, want tail contents", reply) + } +} + +func TestCodexConversationalFallback_BareCodexResumesMostRecent(t *testing.T) { + var attachRef string + rt := &Runtime{ + FindCodexModel: func() string { return "gpt-5.4-mini" }, + CodexActive: func() (*CodexSessionInfo, bool) { + return nil, false + }, + CodexListSessions: func() []CodexSessionInfo { + return []CodexSessionInfo{ + {ID: "s1", Slug: "skezos", RepoPath: "/workspace/repos/skezos"}, + {ID: "s2", Slug: "picoclaw", RepoPath: "/workspace/repos/picoclaw"}, + } + }, + CodexAttach: func(ref string) (*CodexSessionInfo, error) { + attachRef = ref + return &CodexSessionInfo{ID: "s1", Slug: "skezos", RepoPath: "/workspace/repos/skezos"}, nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if attachRef != "s1" { + t.Fatalf("attach ref=%q, want=%q", attachRef, "s1") + } + for _, want := range []string{ + "Codex conversational mode is ready.", + "Repo: skezos", + "Resumed most recent project: skezos.", + "Phase: planning", + "Talk normally now", + } { + if !strings.Contains(reply, want) { + t.Fatalf("reply=%q, missing %q", reply, want) + } + } +} + +func TestCodexConversationalFallback_IntentMatchesExistingSession(t *testing.T) { + var attachRef string + rt := &Runtime{ + FindCodexModel: func() string { return "gpt-5.4-mini" }, + CodexActive: func() (*CodexSessionInfo, bool) { + return nil, false + }, + CodexListSessions: func() []CodexSessionInfo { + return []CodexSessionInfo{ + {ID: "p1", Slug: "picoclaw", RepoPath: "/workspace/repos/picoclaw"}, + {ID: "s1", Slug: "skezos", RepoPath: "/workspace/repos/skezos"}, + } + }, + CodexAttach: func(ref string) (*CodexSessionInfo, error) { + attachRef = ref + return &CodexSessionInfo{ID: "s1", Slug: "skezos", RepoPath: "/workspace/repos/skezos"}, nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex I want you to check out SkezOS and review latest changes", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if attachRef != "s1" { + t.Fatalf("attach ref=%q, want=%q", attachRef, "s1") + } + for _, want := range []string{ + "Matched existing project: skezos.", + "planning brief", + } { + if !strings.Contains(reply, want) { + t.Fatalf("reply=%q, missing %q", reply, want) + } + } +} + +func TestCodexConversationalFallback_IntentCanCreateFromRepoDiscovery(t *testing.T) { + var gotSlug, gotSource string + rt := &Runtime{ + FindCodexModel: func() string { return "gpt-5.4-mini" }, + CodexActive: func() (*CodexSessionInfo, bool) { + return nil, false + }, + CodexListSessions: func() []CodexSessionInfo { return nil }, + ListCodexRepoTargets: func(limit int) ([]string, error) { + return []string{"joe/SkezOS", "joe/picoclaw"}, nil + }, + CodexNewSession: func(slug, source string) (*CodexSessionInfo, error) { + gotSlug, gotSource = slug, source + return &CodexSessionInfo{ID: "s3", Slug: slug, RepoPath: "/workspace/repos/" + slug}, nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex please open SkezOS so we can work on it", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if gotSlug != "joe-SkezOS" { + t.Fatalf("slug=%q, want=%q", gotSlug, "joe-SkezOS") + } + if gotSource != "joe/SkezOS" { + t.Fatalf("source=%q, want=%q", gotSource, "joe/SkezOS") + } + if !strings.Contains(reply, "Created new project from GitHub repo: joe/SkezOS.") { + t.Fatalf("reply=%q, expected repo discovery note", reply) + } +} + +func TestCodexDelegate_NextArmsNextModel(t *testing.T) { + var armed string + rt := &Runtime{ + CodexActive: func() (*CodexSessionInfo, bool) { + return &CodexSessionInfo{ID: "a1", Slug: "picoclaw"}, true + }, + ArmNextModelMode: func(value string) error { + armed = value + return nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex delegate gpt-5.4-mini", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if armed != "gpt-5.4-mini" { + t.Fatalf("armed=%q, want=%q", armed, "gpt-5.4-mini") + } + if reply != "Codex delegation armed for next message: gpt-5.4-mini" { + t.Fatalf("reply=%q, want next delegation confirmation", reply) + } +} + +func TestCodexDelegate_SessionSetsPersistentModel(t *testing.T) { + var set string + rt := &Runtime{ + CodexActive: func() (*CodexSessionInfo, bool) { + return &CodexSessionInfo{ID: "a1", Slug: "picoclaw"}, true + }, + SetSessionModelMode: func(value string) error { + set = value + return nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex delegate gpt-5.4-pro session", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if set != "gpt-5.4-pro" { + t.Fatalf("set=%q, want=%q", set, "gpt-5.4-pro") + } + if reply != "Codex session model set to gpt-5.4-pro." { + t.Fatalf("reply=%q, want session delegation confirmation", reply) + } +} + +func TestCodexDelegate_RequiresActiveSession(t *testing.T) { + rt := &Runtime{ + CodexActive: func() (*CodexSessionInfo, bool) { + return nil, false + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex delegate gpt-5.4-mini", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "No active codex session in this chat. Use /codex new or /codex attach first." { + t.Fatalf("reply=%q, want inactive guidance", reply) + } +} + +func TestCodexDelegate_PropagatesCallbackErrors(t *testing.T) { + rt := &Runtime{ + CodexActive: func() (*CodexSessionInfo, bool) { + return &CodexSessionInfo{ID: "a1", Slug: "picoclaw"}, true + }, + ArmNextModelMode: func(value string) error { + return context.Canceled + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex delegate gpt-5.4-mini", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != context.Canceled.Error() { + t.Fatalf("reply=%q, want callback error verbatim", reply) + } +} + +func TestCodexDelegate_RejectsUnknownModelAgainstTargets(t *testing.T) { + rt := &Runtime{ + CodexActive: func() (*CodexSessionInfo, bool) { + return &CodexSessionInfo{ID: "a1", Slug: "picoclaw"}, true + }, + ListCodexDelegateTargets: func() []string { + return []string{"gpt-5.4-mini", "gpt-5.4-pro"} + }, + ArmNextModelMode: func(value string) error { + t.Fatalf("ArmNextModelMode should not be called for invalid model") + return nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex delegate openrouter-free", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "is not an allowed delegate target") { + t.Fatalf("reply=%q, want delegate allowlist rejection", reply) + } +} + +func TestCodexStop_ClearsSession(t *testing.T) { + runStopped := false + sessionStopped := false + rt := &Runtime{ + CodexRunStop: func() error { + runStopped = true + return nil + }, + CodexStop: func() error { + sessionStopped = true + return nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/codex stop", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !runStopped { + t.Fatal("CodexRunStop callback was not called") + } + if sessionStopped { + t.Fatal("CodexStop fallback should not be called when CodexRunStop is available") + } + if reply != "Codex run stopped. Session routing returned to default." { + t.Fatalf("reply=%q, want stop confirmation", reply) + } +} diff --git a/pkg/commands/cmd_mode.go b/pkg/commands/cmd_mode.go index 43522d181..20f882aa6 100644 --- a/pkg/commands/cmd_mode.go +++ b/pkg/commands/cmd_mode.go @@ -4,25 +4,32 @@ import ( "context" "fmt" "strings" + + "github.com/sipeed/picoclaw/pkg/config" ) func boostCommand() Definition { return Definition{ Name: "boost", - Description: "Use the paid model for your next message", + Description: "Use the heavy routed model for your next message", Usage: "/boost", Handler: func(_ context.Context, req Request, rt *Runtime) error { - paidModel, _ := sessionModelNames(rt) - if paidModel == "" { - return req.Reply("Boost unavailable: paid model is not configured.") + targets := sessionModeTargets(rt) + if targets.Heavy.Target == "" { + return req.Reply("Boost unavailable: heavy routing tier is not configured.") } if rt == nil || rt.ArmNextModelMode == nil { return req.Reply(unavailableMsg) } - if err := rt.ArmNextModelMode(paidModel); err != nil { + if err := rt.ArmNextModelMode(targets.Heavy.Target); err != nil { return req.Reply(err.Error()) } - return req.Reply(fmt.Sprintf("Boost armed. Next message will use %s.", paidModel)) + if rt.ClearSessionWorkMode != nil { + if err := rt.ClearSessionWorkMode(); err != nil { + return req.Reply(err.Error()) + } + } + return req.Reply(fmt.Sprintf("Boost armed. Next message will use %s.", targets.Heavy.Label)) }, } } @@ -30,20 +37,25 @@ func boostCommand() Definition { func paidCommand() Definition { return Definition{ Name: "paid", - Description: "Use the paid model for this session", + Description: "Legacy alias for the heavy routed model", Usage: "/paid", Handler: func(_ context.Context, req Request, rt *Runtime) error { - paidModel, _ := sessionModelNames(rt) - if paidModel == "" { - return req.Reply("Paid mode unavailable: primary model is not configured.") + targets := sessionModeTargets(rt) + if targets.Heavy.Target == "" { + return req.Reply("Paid mode unavailable: heavy routing tier is not configured.") } if rt == nil || rt.SetSessionModelMode == nil { return req.Reply(unavailableMsg) } - if err := rt.SetSessionModelMode(paidModel); err != nil { + if err := rt.SetSessionModelMode(targets.Heavy.Target); err != nil { return req.Reply(err.Error()) } - return req.Reply(fmt.Sprintf("Session mode set to paid (%s).", paidModel)) + if rt.ClearSessionWorkMode != nil { + if err := rt.ClearSessionWorkMode(); err != nil { + return req.Reply(err.Error()) + } + } + return req.Reply(fmt.Sprintf("Legacy paid mode set to heavy (%s).", targets.Heavy.Label)) }, } } @@ -51,20 +63,93 @@ func paidCommand() Definition { func freeCommand() Definition { return Definition{ Name: "free", - Description: "Use the free model for this session", + Description: "Use the manual free tier for this session", Usage: "/free", Handler: func(_ context.Context, req Request, rt *Runtime) error { - _, freeModel := sessionModelNames(rt) - if freeModel == "" { - return req.Reply("Free mode unavailable: light model is not configured.") + targets := sessionModeTargets(rt) + if targets.Free.Target == "" { + return req.Reply("Free mode unavailable: free tier is not configured.") } if rt == nil || rt.SetSessionModelMode == nil { return req.Reply(unavailableMsg) } - if err := rt.SetSessionModelMode(freeModel); err != nil { + if err := rt.SetSessionModelMode(targets.Free.Target); err != nil { return req.Reply(err.Error()) } - return req.Reply(fmt.Sprintf("Session mode set to free (%s).", freeModel)) + if rt.ClearSessionWorkMode != nil { + if err := rt.ClearSessionWorkMode(); err != nil { + return req.Reply(err.Error()) + } + } + return req.Reply(fmt.Sprintf("Session mode set to free (%s).", targets.Free.Label)) + }, + } +} + +func codeCommand() Definition { + return Definition{ + Name: "code", + Description: "Use the tools routing tier for this session", + Usage: "/code", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + targets := sessionModeTargets(rt) + if targets.Tools.Target == "" { + return req.Reply("Code mode unavailable: tools tier is not configured.") + } + if rt == nil || rt.SetSessionModelMode == nil || rt.SetSessionWorkMode == nil { + return req.Reply(unavailableMsg) + } + if err := rt.SetSessionModelMode(targets.Tools.Target); err != nil { + return req.Reply(err.Error()) + } + if err := rt.SetSessionWorkMode("code"); err != nil { + return req.Reply(err.Error()) + } + return req.Reply(fmt.Sprintf("Session mode set to code (%s).", targets.Tools.Label)) + }, + } +} + +func routeCommand() Definition { + return Definition{ + Name: "route", + Description: "Use automatic tier routing for this session", + Usage: "/route", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ClearSessionModelMode == nil { + return req.Reply(unavailableMsg) + } + if err := rt.ClearSessionModelMode(); err != nil { + return req.Reply(err.Error()) + } + if rt.ClearSessionWorkMode != nil { + if err := rt.ClearSessionWorkMode(); err != nil { + return req.Reply(err.Error()) + } + } + return req.Reply("Session mode set to route.") + }, + } +} + +func defaultCommand() Definition { + return Definition{ + Name: "default", + Description: "Return this session to default routing", + Usage: "/default", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ClearSessionModelMode == nil { + return req.Reply(unavailableMsg) + } + if err := rt.ClearSessionModelMode(); err != nil { + return req.Reply(err.Error()) + } + if rt.ClearSessionWorkMode != nil { + if err := rt.ClearSessionWorkMode(); err != nil { + return req.Reply(err.Error()) + } + } + return req.Reply("Session mode set to route.") }, } } @@ -84,7 +169,11 @@ func statusCommand() Definition { currentModel, provider = rt.GetModelInfo() } - paidModel, freeModel := sessionModelNames(rt) + targets := sessionModeTargets(rt) + workMode := "" + if rt.GetSessionWorkMode != nil { + workMode = strings.TrimSpace(rt.GetSessionWorkMode()) + } persistent, pending := "", "" if rt.GetSessionModelMode != nil { persistent, pending = rt.GetSessionModelMode() @@ -98,47 +187,161 @@ func statusCommand() Definition { lines = append(lines, fmt.Sprintf("Current Model: %s", currentModel)) } } - lines = append(lines, fmt.Sprintf("Session Mode: %s", sessionModeDescription(persistent, pending, paidModel, freeModel))) + lines = append(lines, fmt.Sprintf("Session Mode: %s", sessionModeDescription(persistent, pending, workMode, targets))) + if workMode != "" { + lines = append(lines, fmt.Sprintf("Work Mode: %s", workMode)) + } + if rt.CodexActive != nil { + if codex, ok := rt.CodexActive(); ok && codex != nil { + lines = append(lines, fmt.Sprintf("Codex Session: %s (%s)", codex.Slug, codex.ID)) + lines = append(lines, fmt.Sprintf("Codex Repo Path: %s", codex.RepoPath)) + } + } if pending != "" { - lines = append(lines, fmt.Sprintf("Pending Boost: %s", pending)) + lines = append(lines, fmt.Sprintf("Pending Boost: %s", sessionModeLabel(pending, targets))) } else { lines = append(lines, "Pending Boost: none") } - if paidModel != "" { - lines = append(lines, fmt.Sprintf("Paid Model: %s", paidModel)) + if targets.Fast.Label != "" { + lines = append(lines, fmt.Sprintf("Fast Model: %s", targets.Fast.Label)) } - if freeModel != "" { - lines = append(lines, fmt.Sprintf("Free Model: %s", freeModel)) + if targets.Heavy.Label != "" { + lines = append(lines, fmt.Sprintf("Heavy Model: %s", targets.Heavy.Label)) + } + if targets.Tools.Label != "" { + lines = append(lines, fmt.Sprintf("Tools Model: %s", targets.Tools.Label)) + } + if targets.Free.Label != "" { + lines = append(lines, fmt.Sprintf("Free Model: %s", targets.Free.Label)) } return req.Reply(strings.Join(lines, "\n")) }, } } -func sessionModelNames(rt *Runtime) (paidModel, freeModel string) { +type sessionModeTarget struct { + Name string + Target string + Label string +} + +type sessionTargets struct { + Fast sessionModeTarget + Heavy sessionModeTarget + Tools sessionModeTarget + Free sessionModeTarget +} + +func sessionModeTargets(rt *Runtime) sessionTargets { if rt == nil || rt.Config == nil { + return sessionTargets{} + } + + targets := sessionTargets{ + Heavy: sessionModeTarget{ + Name: "heavy", + Target: strings.TrimSpace(rt.Config.Agents.Defaults.ModelName), + Label: strings.TrimSpace(rt.Config.Agents.Defaults.ModelName), + }, + } + + if rc := rt.Config.Agents.Defaults.Routing; rc != nil { + if tierName, tierLabel := preferredRoutingTier(rc, "fast"); tierName != "" && tierLabel != "" { + targets.Fast = sessionModeTarget{Name: "fast", Target: "tier:" + tierName, Label: tierLabel} + } + if tierName, tierLabel := preferredRoutingTier(rc, "heavy", "paid"); tierName != "" && tierLabel != "" { + targets.Heavy = sessionModeTarget{Name: "heavy", Target: "tier:" + tierName, Label: tierLabel} + } + if tierName, tierLabel := preferredRoutingTier(rc, "tools"); tierName != "" && tierLabel != "" { + targets.Tools = sessionModeTarget{Name: "tools", Target: "tier:" + tierName, Label: tierLabel} + } else { + targets.Tools = targets.Heavy + targets.Tools.Name = "tools" + } + if tierName, tierLabel := preferredRoutingTier(rc, "free"); tierName != "" && tierLabel != "" { + targets.Free = sessionModeTarget{Name: "free", Target: "tier:" + tierName, Label: tierLabel} + } else if free := strings.TrimSpace(rc.LightModel); free != "" { + targets.Free = sessionModeTarget{Name: "free", Target: free, Label: free} + } + } + return targets +} + +func preferredRoutingTier(rc *config.RoutingConfig, modes ...string) (name, label string) { + if rc == nil { return "", "" } - paidModel = strings.TrimSpace(rt.Config.Agents.Defaults.ModelName) - if rt.Config.Agents.Defaults.Routing != nil { - freeModel = strings.TrimSpace(rt.Config.Agents.Defaults.Routing.LightModel) + for _, mode := range modes { + target := "" + switch strings.ToLower(strings.TrimSpace(mode)) { + case "paid": + target = strings.TrimSpace(rc.PaidTier) + if target == "" { + target = "paid" + } + case "free": + target = strings.TrimSpace(rc.FreeTier) + if target == "" { + target = "free" + } + case "fast", "heavy", "tools": + target = strings.TrimSpace(mode) + default: + continue + } + + for _, tier := range rc.Tiers { + if !strings.EqualFold(strings.TrimSpace(tier.Name), target) || tier.Model == nil { + continue + } + primary := strings.TrimSpace(tier.Model.Primary) + if primary == "" { + return "", "" + } + return strings.TrimSpace(tier.Name), primary + } } - return paidModel, freeModel + return "", "" } -func sessionModeDescription(persistent, pending, paidModel, freeModel string) string { +func sessionModeDescription(persistent, pending, workMode string, targets sessionTargets) string { if pending != "" { - return fmt.Sprintf("boost armed for next message (%s)", pending) + return fmt.Sprintf("boost armed for next message (%s)", sessionModeLabel(pending, targets)) + } + if workMode != "" { + if persistent != "" { + return fmt.Sprintf("%s (%s)", workMode, sessionModeLabel(persistent, targets)) + } + return workMode } if persistent == "" { return "route (default)" } - if paidModel != "" && strings.EqualFold(persistent, paidModel) { - return fmt.Sprintf("paid (%s)", persistent) + if targets.Heavy.Target != "" && strings.EqualFold(persistent, targets.Heavy.Target) { + return fmt.Sprintf("heavy (%s)", targets.Heavy.Label) } - if freeModel != "" && strings.EqualFold(persistent, freeModel) { - return fmt.Sprintf("free (%s)", persistent) + if targets.Tools.Target != "" && strings.EqualFold(persistent, targets.Tools.Target) { + return fmt.Sprintf("tools (%s)", targets.Tools.Label) + } + if targets.Free.Target != "" && strings.EqualFold(persistent, targets.Free.Target) { + return fmt.Sprintf("free (%s)", targets.Free.Label) + } + return fmt.Sprintf("custom (%s)", sessionModeLabel(persistent, targets)) +} + +func sessionModeLabel(value string, targets sessionTargets) string { + value = strings.TrimSpace(value) + switch { + case targets.Heavy.Target != "" && strings.EqualFold(value, targets.Heavy.Target): + return targets.Heavy.Label + case targets.Tools.Target != "" && strings.EqualFold(value, targets.Tools.Target): + return targets.Tools.Label + case targets.Free.Target != "" && strings.EqualFold(value, targets.Free.Target): + return targets.Free.Label + case targets.Fast.Target != "" && strings.EqualFold(value, targets.Fast.Target): + return targets.Fast.Label + default: + return value } - return fmt.Sprintf("custom (%s)", persistent) } diff --git a/pkg/commands/cmd_mode_test.go b/pkg/commands/cmd_mode_test.go index 6dd7871b1..82e113251 100644 --- a/pkg/commands/cmd_mode_test.go +++ b/pkg/commands/cmd_mode_test.go @@ -13,9 +13,36 @@ func newModeTestRuntime() *Runtime { Config: &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - ModelName: "gpt-5.4-mini", + ModelName: "gpt-5-mini", Routing: &config.RoutingConfig{ - LightModel: "openrouter-free", + FreeTier: "free", + PaidTier: "heavy", + Tiers: []config.RoutingTierConfig{ + { + Name: "fast", + Model: &config.AgentModelConfig{ + Primary: "gpt-5.4-nano", + }, + }, + { + Name: "tools", + Model: &config.AgentModelConfig{ + Primary: "gpt-5.4-mini", + }, + }, + { + Name: "heavy", + Model: &config.AgentModelConfig{ + Primary: "gpt-5-mini", + }, + }, + { + Name: "free", + Model: &config.AgentModelConfig{ + Primary: "openrouter-free", + }, + }, + }, }, }, }, @@ -44,10 +71,10 @@ func TestBoostCommand_ArmsNextModel(t *testing.T) { if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } - if armed != "gpt-5.4-mini" { - t.Fatalf("armed=%q, want %q", armed, "gpt-5.4-mini") + if armed != "tier:heavy" { + t.Fatalf("armed=%q, want %q", armed, "tier:heavy") } - if reply != "Boost armed. Next message will use gpt-5.4-mini." { + if reply != "Boost armed. Next message will use gpt-5-mini." { t.Fatalf("reply=%q, want boost confirmation", reply) } } @@ -55,10 +82,15 @@ func TestBoostCommand_ArmsNextModel(t *testing.T) { func TestPaidCommand_SetsPersistentModel(t *testing.T) { rt := newModeTestRuntime() var persistent string + workMode := "code" rt.SetSessionModelMode = func(value string) error { persistent = value return nil } + rt.ClearSessionWorkMode = func() error { + workMode = "" + return nil + } ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) @@ -73,21 +105,65 @@ func TestPaidCommand_SetsPersistentModel(t *testing.T) { if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } - if persistent != "gpt-5.4-mini" { - t.Fatalf("persistent=%q, want %q", persistent, "gpt-5.4-mini") + if persistent != "tier:heavy" { + t.Fatalf("persistent=%q, want %q", persistent, "tier:heavy") } - if reply != "Session mode set to paid (gpt-5.4-mini)." { + if workMode != "" { + t.Fatalf("workMode=%q, want cleared", workMode) + } + if reply != "Legacy paid mode set to heavy (gpt-5-mini)." { t.Fatalf("reply=%q, want paid confirmation", reply) } } +func TestCodeCommand_SetsWorkModeAndPaidModel(t *testing.T) { + rt := newModeTestRuntime() + var persistent, workMode string + rt.SetSessionModelMode = func(value string) error { + persistent = value + return nil + } + rt.SetSessionWorkMode = func(value string) error { + workMode = value + return nil + } + + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/code", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if persistent != "tier:tools" { + t.Fatalf("persistent=%q, want %q", persistent, "tier:tools") + } + if workMode != "code" { + t.Fatalf("workMode=%q, want %q", workMode, "code") + } + if reply != "Session mode set to code (gpt-5.4-mini)." { + t.Fatalf("reply=%q, want code confirmation", reply) + } +} + func TestFreeCommand_SetsPersistentModel(t *testing.T) { rt := newModeTestRuntime() var persistent string + workMode := "code" rt.SetSessionModelMode = func(value string) error { persistent = value return nil } + rt.ClearSessionWorkMode = func() error { + workMode = "" + return nil + } ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) @@ -102,22 +178,95 @@ func TestFreeCommand_SetsPersistentModel(t *testing.T) { if res.Outcome != OutcomeHandled { t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) } - if persistent != "openrouter-free" { - t.Fatalf("persistent=%q, want %q", persistent, "openrouter-free") + if persistent != "tier:free" { + t.Fatalf("persistent=%q, want %q", persistent, "tier:free") + } + if workMode != "" { + t.Fatalf("workMode=%q, want cleared", workMode) } if reply != "Session mode set to free (openrouter-free)." { t.Fatalf("reply=%q, want free confirmation", reply) } } +func TestDefaultCommand_ClearsSessionModel(t *testing.T) { + rt := newModeTestRuntime() + persistent := "tier:free" + pending := "tier:heavy" + workMode := "code" + rt.GetSessionModelMode = func() (string, string) { + return persistent, pending + } + rt.GetSessionWorkMode = func() string { return workMode } + rt.ClearSessionModelMode = func() error { + persistent = "" + pending = "" + return nil + } + rt.ClearSessionWorkMode = func() error { + workMode = "" + return nil + } + + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/default", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if persistent != "" || pending != "" || workMode != "" { + t.Fatalf("persistent=%q pending=%q workMode=%q, want all cleared", persistent, pending, workMode) + } + if reply != "Session mode set to route." { + t.Fatalf("reply=%q, want default confirmation", reply) + } +} + +func TestRouteCommand_ClearsSessionModel(t *testing.T) { + rt := newModeTestRuntime() + cleared := false + rt.ClearSessionModelMode = func() error { + cleared = true + return nil + } + + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/route", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !cleared { + t.Fatal("expected session mode to be cleared") + } + if reply != "Session mode set to route." { + t.Fatalf("reply=%q, want route confirmation", reply) + } +} + func TestStatusCommand_ReportsPendingBoost(t *testing.T) { rt := newModeTestRuntime() rt.GetModelInfo = func() (string, string) { return "gpt-5.4-mini", "openai" } rt.GetSessionModelMode = func() (string, string) { - return "openrouter-free", "gpt-5.4-mini" + return "tier:free", "tier:heavy" } + rt.GetSessionWorkMode = func() string { return "code" } ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) @@ -134,9 +283,12 @@ func TestStatusCommand_ReportsPendingBoost(t *testing.T) { } if !containsAll(reply, []string{ "Current Model: gpt-5.4-mini (Provider: openai)", - "Session Mode: boost armed for next message (gpt-5.4-mini)", - "Pending Boost: gpt-5.4-mini", - "Paid Model: gpt-5.4-mini", + "Session Mode: boost armed for next message (gpt-5-mini)", + "Work Mode: code", + "Pending Boost: gpt-5-mini", + "Fast Model: gpt-5.4-nano", + "Heavy Model: gpt-5-mini", + "Tools Model: gpt-5.4-mini", "Free Model: openrouter-free", }) { t.Fatalf("reply=%q, missing expected status content", reply) diff --git a/pkg/commands/executor.go b/pkg/commands/executor.go index 78a50e6c2..d86049429 100644 --- a/pkg/commands/executor.go +++ b/pkg/commands/executor.go @@ -68,6 +68,10 @@ func (e *Executor) executeDefinition(ctx context.Context, req Request, def Defin // Sub-command routing subName := nthToken(req.Text, 1) if subName == "" { + if def.Handler != nil { + err := def.Handler(ctx, req, e.rt) + return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} + } err := req.Reply("Usage: " + def.EffectiveUsage()) return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} } @@ -84,6 +88,10 @@ func (e *Executor) executeDefinition(ctx context.Context, req Request, def Defin } // Unknown sub-command + if def.Handler != nil { + err := def.Handler(ctx, req, e.rt) + return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} + } err := req.Reply(fmt.Sprintf("Unknown option: %s. Usage: %s", subName, def.EffectiveUsage())) return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} } diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 941294399..e83350810 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -1,6 +1,50 @@ package commands -import "github.com/sipeed/picoclaw/pkg/config" +import ( + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// CodexSessionInfo describes a repo-scoped codex session. +type CodexSessionInfo struct { + ID string + Slug string + RepoPath string + RepoURL string + Updated time.Time + Active bool +} + +// CodexPlannerStatusInfo describes the user-facing planner state for a repo-scoped codex session. +type CodexPlannerStatusInfo struct { + Phase string + Model string + SessionID string + RepoSlug string + RepoPath string + RepoURL string + ApprovalPending bool +} + +// CodexRunInfo describes a background codex execution run. +type CodexRunInfo struct { + ID string + SessionID string + RepoSlug string + RepoPath string + RepoURL string + Branch string + Worktree string + Model string + Status string + PID int + ExitCode int + Active bool + StartedAt time.Time + UpdatedAt time.Time + FinishedAt time.Time +} // Runtime provides runtime dependencies to command handlers. It is constructed // per-request by the agent loop so that per-request state (like session scope) @@ -18,7 +62,28 @@ type Runtime struct { ClearHistory func() error ReloadConfig func() error - GetSessionModelMode func() (persistent, pending string) - SetSessionModelMode func(value string) error - ArmNextModelMode func(value string) error + GetSessionModelMode func() (persistent, pending string) + SetSessionModelMode func(value string) error + ArmNextModelMode func(value string) error + ClearSessionModelMode func() error + + GetSessionWorkMode func() string + SetSessionWorkMode func(value string) error + ClearSessionWorkMode func() error + GetCodexApprovalPending func() bool + ClearCodexApprovalPending func() + + FindCodexModel func() string + ListCodexDelegateTargets func() []string + ListCodexRepoTargets func(limit int) ([]string, error) + CodexNewSession func(slug, source string) (*CodexSessionInfo, error) + CodexAttach func(ref string) (*CodexSessionInfo, error) + CodexListSessions func() []CodexSessionInfo + CodexActive func() (*CodexSessionInfo, bool) + CodexPlannerStatus func() (*CodexPlannerStatusInfo, bool) + CodexRunList func() []CodexRunInfo + CodexRunStatus func() (*CodexRunInfo, bool) + CodexRunTail func(runID string, lines int) (string, error) + CodexRunStop func() error + CodexStop func() error } diff --git a/pkg/config/config.go b/pkg/config/config.go index 85623cbc4..4d48dda13 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -200,16 +200,30 @@ type SessionConfig struct { IdentityLinks map[string][]string `json:"identity_links,omitempty"` } +// RoutingTierConfig defines a named tier that routing can select. +// Tiers are evaluated in order; the first tier whose MaxScore is >= the +// computed complexity score is selected. A MaxScore of 0 means "catch-all". +// A MaxScore below 0 marks the tier as manual-only, so commands like /free or +// /paid can select it without /route ever picking it automatically. +type RoutingTierConfig struct { + Name string `json:"name"` + MaxScore float64 `json:"max_score,omitempty"` + Model *AgentModelConfig `json:"model,omitempty"` +} + // RoutingConfig controls the intelligent model routing feature. // When enabled, each incoming message is scored against structural features // (message length, code blocks, tool call history, conversation depth, attachments). -// Messages scoring below Threshold are sent to LightModel; all others use the -// agent's primary model. This reduces cost and latency for simple tasks without -// requiring any keyword matching — all scoring is language-agnostic. +// Legacy configs can continue to use LightModel/Threshold; newer configs can +// define named tiers with their own model chains and use FreeTier/PaidTier +// for the session mode commands. type RoutingConfig struct { - Enabled bool `json:"enabled"` - LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks - Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model + Enabled bool `json:"enabled"` + LightModel string `json:"light_model,omitempty"` // legacy: model_name from model_list to use for simple tasks + Threshold float64 `json:"threshold,omitempty"` // legacy: complexity score in [0,1]; score >= threshold → primary model + Tiers []RoutingTierConfig `json:"tiers,omitempty"` + FreeTier string `json:"free_tier,omitempty"` + PaidTier string `json:"paid_tier,omitempty"` } // SubTurnConfig configures the SubTurn execution system. @@ -805,6 +819,19 @@ type ExecConfig struct { TimeoutSeconds int ` json:"timeout_seconds" env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS"` // 0 means use default (60s) } +type GitToolsConfig struct { + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_GIT_"` + TimeoutSeconds int ` json:"timeout_seconds" env:"PICOCLAW_TOOLS_GIT_TIMEOUT_SECONDS"` // 0 means use default (60s) +} + +type GithubToolsConfig struct { + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_GITHUB_"` + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_TOOLS_GITHUB_TOKEN"` + BaseURL string `json:"base_url,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_GITHUB_BASE_URL"` + Proxy string `json:"proxy,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_GITHUB_PROXY"` + TimeoutSeconds int `json:"timeout_seconds" yaml:"-" env:"PICOCLAW_TOOLS_GITHUB_TIMEOUT_SECONDS"` +} + type SkillsToolsConfig struct { ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_SKILLS_"` Registries SkillsRegistriesConfig `yaml:",inline,omitempty" json:"registries"` @@ -855,6 +882,8 @@ type ToolsConfig struct { Web WebToolsConfig `json:"web" yaml:"web,omitempty"` Cron CronToolsConfig `json:"cron" yaml:"-"` Exec ExecConfig `json:"exec" yaml:"-"` + Git GitToolsConfig `json:"git" yaml:"-"` + Github GithubToolsConfig `json:"github" yaml:"github,omitempty"` Skills SkillsToolsConfig `json:"skills" yaml:"skills,omitempty"` MediaCleanup MediaCleanupConfig `json:"media_cleanup" yaml:"-"` MCP MCPConfig `json:"mcp" yaml:"-"` @@ -1084,6 +1113,9 @@ func LoadConfig(path string) (*Config, error) { if err = cfg.ValidateModelList(); err != nil { return nil, err } + if err = cfg.ValidateRouting(); err != nil { + return nil, err + } // Ensure Workspace has a default if not set if cfg.Agents.Defaults.Workspace == "" { @@ -1219,6 +1251,49 @@ func (c *Config) ValidateModelList() error { return nil } +func (c *Config) ValidateRouting() error { + if c == nil || c.Agents.Defaults.Routing == nil { + return nil + } + + rc := c.Agents.Defaults.Routing + seen := make(map[string]struct{}, len(rc.Tiers)) + for i, tier := range rc.Tiers { + name := strings.TrimSpace(tier.Name) + if name == "" { + return fmt.Errorf("agents.defaults.routing.tiers[%d]: name is required", i) + } + key := strings.ToLower(name) + if _, exists := seen[key]; exists { + return fmt.Errorf("agents.defaults.routing.tiers[%d]: duplicate tier name %q", i, name) + } + seen[key] = struct{}{} + + if tier.Model == nil || strings.TrimSpace(tier.Model.Primary) == "" { + return fmt.Errorf("agents.defaults.routing.tiers[%d]: model.primary is required", i) + } + } + + validateTierRef := func(fieldName, tierName string) error { + tierName = strings.TrimSpace(tierName) + if tierName == "" { + return nil + } + if _, ok := seen[strings.ToLower(tierName)]; !ok { + return fmt.Errorf("agents.defaults.routing.%s references unknown tier %q", fieldName, tierName) + } + return nil + } + + if err := validateTierRef("free_tier", rc.FreeTier); err != nil { + return err + } + if err := validateTierRef("paid_tier", rc.PaidTier); err != nil { + return err + } + return nil +} + func (c *Config) SecurityCopyFrom(path string) error { return loadSecurityConfig(c, securityPath(path)) } @@ -1312,6 +1387,10 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.Cron.Enabled case "exec": return t.Exec.Enabled + case "git": + return t.Git.Enabled + case "github": + return t.Github.Enabled case "skills": return t.Skills.Enabled case "media_cleanup": diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index a1410f940..7b9580cc2 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -259,6 +259,114 @@ func TestDefaultConfig_MaxToolIterations(t *testing.T) { } } +func TestDefaultConfig_TieredRoutingHydrated(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Agents.Defaults.ModelName != "gpt-5-mini" { + t.Fatalf("DefaultConfig().Agents.Defaults.ModelName = %q, want %q", cfg.Agents.Defaults.ModelName, "gpt-5-mini") + } + if got := cfg.Agents.Defaults.ModelFallbacks; len(got) != 1 || got[0] != "gpt-5.4-mini" { + t.Fatalf("DefaultConfig().Agents.Defaults.ModelFallbacks = %v, want [gpt-5.4-mini]", got) + } + + rt := cfg.Agents.Defaults.Routing + if rt == nil { + t.Fatal("DefaultConfig().Agents.Defaults.Routing should be configured") + } + if !rt.Enabled { + t.Fatal("DefaultConfig().Agents.Defaults.Routing.Enabled should be true") + } + if rt.FreeTier != "free" { + t.Fatalf("DefaultConfig().Agents.Defaults.Routing.FreeTier = %q, want %q", rt.FreeTier, "free") + } + if rt.PaidTier != "heavy" { + t.Fatalf("DefaultConfig().Agents.Defaults.Routing.PaidTier = %q, want %q", rt.PaidTier, "heavy") + } + + expected := map[string]struct { + maxScore float64 + primary string + fallbacks []string + }{ + "fast": { + maxScore: 0.20, + primary: "gpt-5.4-nano", + fallbacks: []string{"gpt-5-nano"}, + }, + "tools": { + maxScore: 0, + primary: "gpt-5.4-mini", + fallbacks: []string{"gpt-5-mini"}, + }, + "heavy": { + maxScore: 0, + primary: "gpt-5-mini", + fallbacks: []string{"gpt-5.4-mini"}, + }, + "free": { + maxScore: -1, + primary: "openrouter-free-qwen", + fallbacks: []string{"openrouter-free-oss", "openrouter-free-step"}, + }, + } + + if len(rt.Tiers) != len(expected) { + t.Fatalf("DefaultConfig().Agents.Defaults.Routing.Tiers len = %d, want %d", len(rt.Tiers), len(expected)) + } + + for _, tier := range rt.Tiers { + want, ok := expected[tier.Name] + if !ok { + t.Fatalf("unexpected routing tier %q in defaults", tier.Name) + } + if tier.MaxScore != want.maxScore { + t.Fatalf("tier %q max_score = %v, want %v", tier.Name, tier.MaxScore, want.maxScore) + } + if tier.Model == nil { + t.Fatalf("tier %q model should not be nil", tier.Name) + } + if tier.Model.Primary != want.primary { + t.Fatalf("tier %q primary = %q, want %q", tier.Name, tier.Model.Primary, want.primary) + } + if strings.Join(tier.Model.Fallbacks, ",") != strings.Join(want.fallbacks, ",") { + t.Fatalf("tier %q fallbacks = %v, want %v", tier.Name, tier.Model.Fallbacks, want.fallbacks) + } + } + + if err := cfg.ValidateRouting(); err != nil { + t.Fatalf("DefaultConfig().ValidateRouting() error = %v", err) + } +} + +func TestDefaultConfig_TieredModelAliasesPresent(t *testing.T) { + cfg := DefaultConfig() + + expectedModels := map[string]string{ + "gpt-5.4-mini": "openai/gpt-5.4-mini", + "gpt-5-mini": "openai/gpt-5-mini", + "gpt-5.4-nano": "openai/gpt-5.4-nano", + "gpt-5-nano": "openai/gpt-5-nano", + "openrouter-free-qwen": "openrouter/qwen/qwen3-next-80b-a3b-instruct:free", + "openrouter-free-oss": "openrouter/openai/gpt-oss-20b:free", + "openrouter-free-step": "openrouter/stepfun/step-3.5-flash:free", + } + + modelsByName := make(map[string]*ModelConfig, len(cfg.ModelList)) + for _, modelCfg := range cfg.ModelList { + modelsByName[modelCfg.ModelName] = modelCfg + } + + for modelName, modelPath := range expectedModels { + modelCfg, ok := modelsByName[modelName] + if !ok { + t.Fatalf("DefaultConfig().ModelList missing %q", modelName) + } + if modelCfg.Model != modelPath { + t.Fatalf("model %q path = %q, want %q", modelName, modelCfg.Model, modelPath) + } + } +} + // TestDefaultConfig_Temperature verifies temperature has default value func TestDefaultConfig_Temperature(t *testing.T) { cfg := DefaultConfig() @@ -348,7 +456,7 @@ func TestSaveConfig_FilePermissions(t *testing.T) { } } -func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) { +func TestSaveConfig_IncludesHydratedDefaultModelField(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "config.json") @@ -362,8 +470,8 @@ func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) { t.Fatalf("ReadFile failed: %v", err) } - if !strings.Contains(string(data), `"model_name": ""`) { - t.Fatalf("saved config should include empty legacy model_name field, got: %s", string(data)) + if !strings.Contains(string(data), `"model_name": "gpt-5-mini"`) { + t.Fatalf("saved config should include hydrated default model_name, got: %s", string(data)) } } @@ -560,6 +668,26 @@ func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) { } } +func TestDefaultConfig_GitTimeout(t *testing.T) { + cfg := DefaultConfig() + if cfg.Tools.Git.TimeoutSeconds != 60 { + t.Fatalf("DefaultConfig().Tools.Git.TimeoutSeconds = %d, want 60", cfg.Tools.Git.TimeoutSeconds) + } +} + +func TestDefaultConfig_GithubDefaults(t *testing.T) { + cfg := DefaultConfig() + if cfg.Tools.Github.Enabled { + t.Fatal("DefaultConfig().Tools.Github.Enabled should be false") + } + if cfg.Tools.Github.BaseURL != "https://api.github.com" { + t.Fatalf("DefaultConfig().Tools.Github.BaseURL = %q, want https://api.github.com", cfg.Tools.Github.BaseURL) + } + if cfg.Tools.Github.TimeoutSeconds != 20 { + t.Fatalf("DefaultConfig().Tools.Github.TimeoutSeconds = %d, want 20", cfg.Tools.Github.TimeoutSeconds) + } +} + func TestDefaultConfig_FilterSensitiveDataEnabled(t *testing.T) { cfg := DefaultConfig() if !cfg.Tools.FilterSensitiveData { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 39cdb89e6..bc3b78f91 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -22,12 +22,51 @@ func DefaultConfig() *Config { Workspace: workspacePath, RestrictToWorkspace: true, Provider: "", + ModelName: "gpt-5-mini", + ModelFallbacks: []string{"gpt-5.4-mini"}, MaxTokens: 32768, Temperature: nil, // nil means use provider default MaxToolIterations: 50, SummarizeMessageThreshold: 20, SummarizeTokenPercent: 75, - SteeringMode: "one-at-a-time", + Routing: &RoutingConfig{ + Enabled: true, + FreeTier: "free", + PaidTier: "heavy", + Tiers: []RoutingTierConfig{ + { + Name: "fast", + MaxScore: 0.20, + Model: &AgentModelConfig{ + Primary: "gpt-5.4-nano", + Fallbacks: []string{"gpt-5-nano"}, + }, + }, + { + Name: "tools", + Model: &AgentModelConfig{ + Primary: "gpt-5.4-mini", + Fallbacks: []string{"gpt-5-mini"}, + }, + }, + { + Name: "heavy", + Model: &AgentModelConfig{ + Primary: "gpt-5-mini", + Fallbacks: []string{"gpt-5.4-mini"}, + }, + }, + { + Name: "free", + MaxScore: -1, + Model: &AgentModelConfig{ + Primary: "openrouter-free-qwen", + Fallbacks: []string{"openrouter-free-oss", "openrouter-free-step"}, + }, + }, + }, + }, + SteeringMode: "one-at-a-time", ToolFeedback: ToolFeedbackConfig{ Enabled: false, MaxArgsLength: 300, @@ -170,6 +209,26 @@ func DefaultConfig() *Config { Model: "openai/gpt-5.4", APIBase: "https://api.openai.com/v1", }, + { + ModelName: "gpt-5.4-mini", + Model: "openai/gpt-5.4-mini", + APIBase: "https://api.openai.com/v1", + }, + { + ModelName: "gpt-5-mini", + Model: "openai/gpt-5-mini", + APIBase: "https://api.openai.com/v1", + }, + { + ModelName: "gpt-5.4-nano", + Model: "openai/gpt-5.4-nano", + APIBase: "https://api.openai.com/v1", + }, + { + ModelName: "gpt-5-nano", + Model: "openai/gpt-5-nano", + APIBase: "https://api.openai.com/v1", + }, // Anthropic Claude - https://console.anthropic.com/settings/keys { @@ -231,6 +290,21 @@ func DefaultConfig() *Config { Model: "openrouter/openai/gpt-5.4", APIBase: "https://openrouter.ai/api/v1", }, + { + ModelName: "openrouter-free-qwen", + Model: "openrouter/qwen/qwen3-next-80b-a3b-instruct:free", + APIBase: "https://openrouter.ai/api/v1", + }, + { + ModelName: "openrouter-free-oss", + Model: "openrouter/openai/gpt-oss-20b:free", + APIBase: "https://openrouter.ai/api/v1", + }, + { + ModelName: "openrouter-free-step", + Model: "openrouter/stepfun/step-3.5-flash:free", + APIBase: "https://openrouter.ai/api/v1", + }, // NVIDIA - https://build.nvidia.com/ { @@ -429,6 +503,19 @@ func DefaultConfig() *Config { AllowRemote: true, TimeoutSeconds: 60, }, + Git: GitToolsConfig{ + ToolConfig: ToolConfig{ + Enabled: false, + }, + TimeoutSeconds: 60, + }, + Github: GithubToolsConfig{ + ToolConfig: ToolConfig{ + Enabled: false, + }, + BaseURL: "https://api.github.com", + TimeoutSeconds: 20, + }, Skills: SkillsToolsConfig{ ToolConfig: ToolConfig{ Enabled: true, diff --git a/pkg/config/model_config_test.go b/pkg/config/model_config_test.go index 6e88f4783..e523e1a83 100644 --- a/pkg/config/model_config_test.go +++ b/pkg/config/model_config_test.go @@ -297,6 +297,95 @@ func TestConfig_ValidateModelList(t *testing.T) { } } +func TestConfig_ValidateRouting(t *testing.T) { + tests := []struct { + name string + config *Config + wantErr bool + errMsg string + }{ + { + name: "valid tiered routing", + config: &Config{ + Agents: AgentsConfig{ + Defaults: AgentDefaults{ + Routing: &RoutingConfig{ + FreeTier: "free", + PaidTier: "heavy", + Tiers: []RoutingTierConfig{ + {Name: "fast", Model: &AgentModelConfig{Primary: "gpt-5.4-nano"}}, + {Name: "tools", Model: &AgentModelConfig{Primary: "gpt-5.4-mini"}}, + {Name: "heavy", Model: &AgentModelConfig{Primary: "gpt-5-mini"}}, + {Name: "free", MaxScore: -1, Model: &AgentModelConfig{Primary: "openrouter-free"}}, + }, + }, + }, + }, + }, + }, + { + name: "duplicate tier names", + config: &Config{ + Agents: AgentsConfig{ + Defaults: AgentDefaults{ + Routing: &RoutingConfig{ + Tiers: []RoutingTierConfig{ + {Name: "fast", Model: &AgentModelConfig{Primary: "a"}}, + {Name: "fast", Model: &AgentModelConfig{Primary: "b"}}, + }, + }, + }, + }, + }, + wantErr: true, + errMsg: "duplicate tier name", + }, + { + name: "missing tier primary", + config: &Config{ + Agents: AgentsConfig{ + Defaults: AgentDefaults{ + Routing: &RoutingConfig{ + Tiers: []RoutingTierConfig{{Name: "fast", Model: &AgentModelConfig{}}}, + }, + }, + }, + }, + wantErr: true, + errMsg: "model.primary is required", + }, + { + name: "unknown free tier reference", + config: &Config{ + Agents: AgentsConfig{ + Defaults: AgentDefaults{ + Routing: &RoutingConfig{ + FreeTier: "free", + Tiers: []RoutingTierConfig{ + {Name: "fast", Model: &AgentModelConfig{Primary: "a"}}, + }, + }, + }, + }, + }, + wantErr: true, + errMsg: "unknown tier", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.ValidateRouting() + if (err != nil) != tt.wantErr { + t.Fatalf("ValidateRouting() error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Fatalf("ValidateRouting() error = %v, want substring %q", err, tt.errMsg) + } + }) + } +} + func TestModelConfig_RequestTimeoutParsing(t *testing.T) { jsonData := `{ "model_name": "slow-local", diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go index 13f53ad9e..4a46a141f 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/codex_cli_provider.go @@ -32,22 +32,9 @@ func (p *CodexCliProvider) Chat( return nil, fmt.Errorf("codex command not configured") } - prompt := p.buildPrompt(messages, tools) + prompt := BuildCodexCLIPrompt(messages, tools) - args := []string{ - "exec", - "--json", - "--dangerously-bypass-approvals-and-sandbox", - "--skip-git-repo-check", - "--color", "never", - } - if model != "" && model != "codex-cli" { - args = append(args, "-m", model) - } - if p.workspace != "" { - args = append(args, "-C", p.workspace) - } - args = append(args, "-") // read prompt from stdin + args := BuildCodexCLIArgs(model, p.workspace) cmd := exec.CommandContext(ctx, p.command, args...) cmd.Stdin = bytes.NewReader([]byte(prompt)) @@ -62,7 +49,7 @@ func (p *CodexCliProvider) Chat( // because codex writes diagnostic noise to stderr (e.g. rollout errors) // but still produces valid JSONL output. if stdoutStr := stdout.String(); stdoutStr != "" { - resp, parseErr := p.parseJSONLEvents(stdoutStr) + resp, parseErr := ParseCodexCLIJSONLEvents(stdoutStr) if parseErr == nil && resp != nil && (resp.Content != "" || len(resp.ToolCalls) > 0) { return resp, nil } @@ -78,7 +65,7 @@ func (p *CodexCliProvider) Chat( return nil, fmt.Errorf("codex cli error: %w", err) } - return p.parseJSONLEvents(stdout.String()) + return ParseCodexCLIJSONLEvents(stdout.String()) } // GetDefaultModel returns the default model identifier. @@ -86,9 +73,29 @@ func (p *CodexCliProvider) GetDefaultModel() string { return "codex-cli" } -// buildPrompt converts messages to a prompt string for the Codex CLI. +// BuildCodexCLIArgs returns the standard non-interactive codex exec arguments. +func BuildCodexCLIArgs(model, workspace string) []string { + args := []string{ + "exec", + "--json", + "--dangerously-bypass-approvals-and-sandbox", + "--skip-git-repo-check", + "--color", "never", + } + model = strings.TrimSpace(model) + if model != "" && model != "codex-cli" && !strings.EqualFold(model, "codex") { + args = append(args, "-m", model) + } + if workspace != "" { + args = append(args, "-C", workspace) + } + args = append(args, "-") + return args +} + +// BuildCodexCLIPrompt converts messages to a prompt string for the Codex CLI. // System messages are prepended as instructions since Codex CLI has no --system-prompt flag. -func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinition) string { +func BuildCodexCLIPrompt(messages []Message, tools []ToolDefinition) string { var systemParts []string var conversationParts []string @@ -128,6 +135,12 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio return sb.String() } +// buildPrompt preserves the existing method surface for tests and callers inside +// this provider while delegating to the shared exported helper. +func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinition) string { + return BuildCodexCLIPrompt(messages, tools) +} + // codexEvent represents a single JSONL event from `codex exec --json`. type codexEvent struct { Type string `json:"type"` @@ -158,8 +171,8 @@ type codexEventErr struct { Message string `json:"message"` } -// parseJSONLEvents processes the JSONL output from codex exec --json. -func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) { +// ParseCodexCLIJSONLEvents processes the JSONL output from codex exec --json. +func ParseCodexCLIJSONLEvents(output string) (*LLMResponse, error) { var contentParts []string var usage *UsageInfo var lastError string @@ -221,3 +234,7 @@ func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) Usage: usage, }, nil } + +func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) { + return ParseCodexCLIJSONLEvents(output) +} diff --git a/pkg/providers/codex_cli_provider_test.go b/pkg/providers/codex_cli_provider_test.go index 0f66e25f4..949242ee2 100644 --- a/pkg/providers/codex_cli_provider_test.go +++ b/pkg/providers/codex_cli_provider_test.go @@ -516,6 +516,39 @@ echo '{"type":"turn.completed"}'` } } +func TestCodexCliProvider_MockCLI_OmitsDefaultCodexModelFlag(t *testing.T) { + tmpDir := t.TempDir() + scriptPath := filepath.Join(tmpDir, "codex") + script := `#!/bin/bash +echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `" +echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}' +echo '{"type":"turn.completed"}'` + + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + p := &CodexCliProvider{ + command: scriptPath, + workspace: "/tmp/test-workspace", + } + + messages := []Message{{Role: "user", Content: "test"}} + _, err := p.Chat(context.Background(), messages, nil, "codex", nil) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + + argsData, err := os.ReadFile(filepath.Join(tmpDir, "args.txt")) + if err != nil { + t.Fatalf("reading args: %v", err) + } + args := string(argsData) + if strings.Contains(args, "-m codex") { + t.Errorf("args should omit default codex model flag, got: %s", args) + } +} + func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) { // Script that sleeps forever tmpDir := t.TempDir() diff --git a/pkg/routing/classifier.go b/pkg/routing/classifier.go index 8cddaf069..7e80210c9 100644 --- a/pkg/routing/classifier.go +++ b/pkg/routing/classifier.go @@ -21,6 +21,7 @@ type Classifier interface { // token > 200 (≈600 chars): 0.35 — very long prompts are almost always complex // token 50-200: 0.15 — medium length; may or may not be complex // code block present: 0.40 — coding tasks need the heavy model +// tool intent detected: 0.30 — repo/tool prompts should leave nano early // tool calls > 3 (recent): 0.25 — dense tool usage signals an agentic workflow // tool calls 1-3 (recent): 0.10 — some tool activity // conversation depth > 10: 0.10 — long sessions carry implicit complexity @@ -57,6 +58,9 @@ func (c *RuleClassifier) Score(f Features) float64 { if f.CodeBlockCount > 0 { score += 0.40 } + if f.ToolIntent { + score += 0.30 + } // Recent tool call density — indicates an ongoing agentic workflow switch { diff --git a/pkg/routing/features.go b/pkg/routing/features.go index c371e21aa..4e69f046c 100644 --- a/pkg/routing/features.go +++ b/pkg/routing/features.go @@ -1,6 +1,7 @@ package routing import ( + "regexp" "strings" "unicode/utf8" @@ -11,9 +12,9 @@ import ( // Six entries covers roughly one full tool-use round-trip (user → assistant+tool_call → tool_result → assistant). const lookbackWindow = 6 -// Features holds the structural signals extracted from a message and its session context. -// Every dimension is language-agnostic by construction — no keyword or pattern matching -// against natural-language content. This ensures consistent routing for all locales. +// Features holds the signals extracted from a message and its session context. +// Most dimensions are structural; ToolIntent adds a narrow set of repo/tool cues +// so routing can leave nano early for likely agentic turns. type Features struct { // TokenEstimate is a proxy for token count. // CJK runes count as 1 token each; non-CJK runes as 0.25 tokens each. @@ -35,8 +36,25 @@ type Features struct { // HasAttachments is true when the message appears to contain media (images, // audio, video). Multi-modal inputs require vision-capable heavy models. HasAttachments bool + + // ToolIntent is true when the prompt strongly suggests repo/tool activity + // before any tool calls have happened in this session. + ToolIntent bool } +var ( + pathLikePattern = regexp.MustCompile(`(?i)(?:\./|\.\./|/|(?:^|\s)(?:src|pkg|cmd|internal|test|tests|apps?)/)[^\s]*`) + fileExtPattern = regexp.MustCompile(`(?i)\.(go|ts|tsx|js|jsx|py|rs|java|json|ya?ml|toml|sh|sql|md)\b`) + actionCues = []string{ + "fix", "edit", "change", "patch", "refactor", "implement", "rename", "remove", "add", + "run", "test", "build", "debug", "grep", "search", "inspect", + } + commandCues = []string{ + "git ", "go ", "npm ", "pnpm ", "yarn ", "cargo ", "pytest", "python ", "uv ", "bash ", "sh ", "make ", "docker ", "kubectl ", + "$ ", "> ", + } +) + // ExtractFeatures computes the structural feature vector for a message. // It is a pure function with no side effects and zero allocations beyond // the returned struct. @@ -47,6 +65,7 @@ func ExtractFeatures(msg string, history []providers.Message) Features { RecentToolCalls: countRecentToolCalls(history), ConversationDepth: len(history), HasAttachments: hasAttachments(msg), + ToolIntent: hasToolIntent(msg), } } @@ -125,3 +144,36 @@ func hasAttachments(msg string) bool { return false } + +func hasToolIntent(msg string) bool { + lower := strings.ToLower(msg) + if lower == "" { + return false + } + + // Code fences and shell-like snippets are strong signals on their own. + if countCodeBlocks(msg) > 0 { + return true + } + for _, cue := range commandCues { + if strings.Contains(lower, cue) { + return true + } + } + + artifactSignal := pathLikePattern.MatchString(msg) || fileExtPattern.MatchString(msg) + if !artifactSignal { + // Inline code spans can also carry repo/tool intent when paired with an action. + artifactSignal = strings.Count(msg, "`") >= 2 + } + if !artifactSignal { + return false + } + + for _, cue := range actionCues { + if strings.Contains(lower, cue) { + return true + } + } + return false +} diff --git a/pkg/routing/router.go b/pkg/routing/router.go index b1fa347e9..d0fda892c 100644 --- a/pkg/routing/router.go +++ b/pkg/routing/router.go @@ -1,6 +1,9 @@ package routing import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -14,12 +17,18 @@ const defaultThreshold = 0.35 // dependency graph simple: pkg/agent resolves config → routing, not the reverse. type RouterConfig struct { // LightModel is the model_name (from model_list) used for simple tasks. + // Legacy field retained for backwards compatibility with the original + // binary "light vs primary" router. LightModel string // Threshold is the complexity score cutoff in [0, 1]. // score >= Threshold → primary (heavy) model. // score < Threshold → light model. Threshold float64 + + // Tiers are evaluated in order. The first tier whose MaxScore is greater + // than or equal to the computed complexity score is selected. + Tiers []config.RoutingTierConfig } // Router selects the appropriate model tier for each incoming message. @@ -29,6 +38,13 @@ type Router struct { classifier Classifier } +const ( + DefaultFastTierName = "fast" + DefaultHeavyTierName = "heavy" + DefaultToolsTierName = "tools" + DefaultFreeTierName = "free" +) + // New creates a Router with the given config and the default RuleClassifier. // If cfg.Threshold is zero or negative, defaultThreshold (0.35) is used. func New(cfg RouterConfig) *Router { @@ -63,14 +79,52 @@ func (r *Router) SelectModel( history []providers.Message, primaryModel string, ) (model string, usedLight bool, score float64) { - features := ExtractFeatures(msg, history) - score = r.classifier.Score(features) - if score < r.cfg.Threshold { + tier, score := r.SelectTier(msg, history) + if tier != "" { return r.cfg.LightModel, true, score } return primaryModel, false, score } +// SelectTier returns the selected routing tier name, or the empty string when +// the primary agent model should be used. When a "tools" tier exists, prompts +// with early tool intent or recent tool activity are promoted there before +// score-based tier matching runs. +func (r *Router) SelectTier(msg string, history []providers.Message) (tier string, score float64) { + features := ExtractFeatures(msg, history) + score = r.classifier.Score(features) + + if len(r.cfg.Tiers) > 0 { + if (features.ToolIntent || features.RecentToolCalls > 0) && r.hasAutoTier(DefaultToolsTierName) { + return DefaultToolsTierName, score + } + for _, candidate := range r.cfg.Tiers { + name := strings.TrimSpace(candidate.Name) + if name == "" { + continue + } + if candidate.MaxScore < 0 { + continue + } + if strings.EqualFold(name, DefaultToolsTierName) { + continue + } + if candidate.MaxScore == 0 || score <= candidate.MaxScore { + return name, score + } + } + return "", score + } + + if features.ToolIntent || features.RecentToolCalls > 0 { + return "", score + } + if score < r.cfg.Threshold { + return "light", score + } + return "", score +} + // LightModel returns the configured light model name. func (r *Router) LightModel() string { return r.cfg.LightModel @@ -80,3 +134,12 @@ func (r *Router) LightModel() string { func (r *Router) Threshold() float64 { return r.cfg.Threshold } + +func (r *Router) hasAutoTier(name string) bool { + for _, tier := range r.cfg.Tiers { + if strings.EqualFold(strings.TrimSpace(tier.Name), name) && tier.MaxScore >= 0 { + return true + } + } + return false +} diff --git a/pkg/routing/router_test.go b/pkg/routing/router_test.go index 2824d10ab..89a142adf 100644 --- a/pkg/routing/router_test.go +++ b/pkg/routing/router_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -26,6 +27,9 @@ func TestExtractFeatures_EmptyMessage(t *testing.T) { if f.HasAttachments { t.Error("HasAttachments: got true, want false") } + if f.ToolIntent { + t.Error("ToolIntent: got true, want false") + } } func TestExtractFeatures_TokenEstimate(t *testing.T) { @@ -143,6 +147,24 @@ func TestExtractFeatures_HasAttachments_Extension(t *testing.T) { } } +func TestExtractFeatures_ToolIntent(t *testing.T) { + cases := []struct { + msg string + want bool + }{ + {"fix src/app.ts and run tests", true}, + {"please inspect ./pkg/agent/loop.go", true}, + {"git status and then patch the handler", true}, + {"what's the weather today?", false}, + } + for _, tc := range cases { + f := ExtractFeatures(tc.msg, nil) + if f.ToolIntent != tc.want { + t.Errorf("msg=%q: ToolIntent got %v, want %v", tc.msg, f.ToolIntent, tc.want) + } + } +} + // ── RuleClassifier ─────────────────────────────────────────────────────────── func TestRuleClassifier_ZeroFeatures(t *testing.T) { @@ -239,6 +261,14 @@ func TestRuleClassifier_ScoreDoesNotExceedOne(t *testing.T) { } } +func TestRuleClassifier_ToolIntentRaisesScore(t *testing.T) { + c := &RuleClassifier{} + score := c.Score(Features{ToolIntent: true}) + if score < 0.30 { + t.Fatalf("tool intent score = %f, want at least 0.30", score) + } +} + // ── Router ─────────────────────────────────────────────────────────────────── func TestRouter_DefaultThreshold(t *testing.T) { @@ -304,9 +334,7 @@ func TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) { } } -func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) { - // Tool calls alone (0.25) don't cross the 0.35 threshold — acceptable behavior. - // Routing is conservative: only promote to heavy when the signal is unambiguous. +func TestRouter_SelectModel_DeepToolChainUsesPrimary(t *testing.T) { r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) history := []providers.Message{ {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}}, @@ -314,8 +342,17 @@ func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) { } msg := "ok" _, usedLight, _ := r.SelectModel(msg, history, "claude-sonnet-4-6") - if !usedLight { - t.Error("short message + moderate tool calls: expected light model (score 0.20 < 0.35)") + if usedLight { + t.Error("short message + tool history: expected primary model") + } +} + +func TestRouter_SelectModel_ToolIntentUsesPrimaryInLegacyMode(t *testing.T) { + r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) + msg := "fix ./pkg/agent/loop.go and run go test" + _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") + if usedLight { + t.Error("tool-intent prompt: expected primary model in legacy mode") } } @@ -346,12 +383,12 @@ func TestRouter_SelectModel_CustomThreshold(t *testing.T) { } func TestRouter_SelectModel_HighThreshold(t *testing.T) { - // Very high threshold: even code blocks route to light + // Tool-intent prompts stay on the primary model even with a high threshold. r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.99}) msg := "```go\nfmt.Println()\n```" _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") - if !usedLight { - t.Error("very high threshold: code block (0.40) should route to light model") + if usedLight { + t.Error("very high threshold: code block should still use primary model") } } @@ -362,6 +399,72 @@ func TestRouter_LightModel(t *testing.T) { } } +func TestRouter_SelectTier_UsesFirstMatchingTier(t *testing.T) { + r := New(RouterConfig{ + Tiers: []config.RoutingTierConfig{ + {Name: "free", MaxScore: 0.2}, + {Name: "balanced", MaxScore: 1.0}, + }, + }) + + tier, _ := r.SelectTier("hi", nil) + if tier != "free" { + t.Fatalf("tier=%q, want %q", tier, "free") + } + + complex := "```go\nfmt.Println(\"x\")\n```\nPlease refactor this with tests." + tier, _ = r.SelectTier(complex, nil) + if tier != "balanced" { + t.Fatalf("tier=%q, want %q", tier, "balanced") + } +} + +func TestRouter_SelectTier_ToolIntentPrefersToolsTier(t *testing.T) { + r := New(RouterConfig{ + Tiers: []config.RoutingTierConfig{ + {Name: "fast", MaxScore: 0.2}, + {Name: "tools", MaxScore: 0}, + {Name: "heavy", MaxScore: 0}, + }, + }) + + tier, _ := r.SelectTier("fix ./pkg/agent/loop.go and run go test", nil) + if tier != "tools" { + t.Fatalf("tier=%q, want %q", tier, "tools") + } +} + +func TestRouter_SelectTier_RecentToolCallsPreferToolsTier(t *testing.T) { + r := New(RouterConfig{ + Tiers: []config.RoutingTierConfig{ + {Name: "fast", MaxScore: 0.2}, + {Name: "tools", MaxScore: 0}, + {Name: "heavy", MaxScore: 0}, + }, + }) + history := []providers.Message{ + {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}}}, + } + tier, _ := r.SelectTier("ok", history) + if tier != "tools" { + t.Fatalf("tier=%q, want %q", tier, "tools") + } +} + +func TestRouter_SelectTier_SkipsManualOnlyTiers(t *testing.T) { + r := New(RouterConfig{ + Tiers: []config.RoutingTierConfig{ + {Name: "free", MaxScore: -1}, + {Name: "fast", MaxScore: 0.3}, + }, + }) + + tier, _ := r.SelectTier("hi", nil) + if tier != "fast" { + t.Fatalf("tier=%q, want %q", tier, "fast") + } +} + // ── newWithClassifier (internal testing hook) ───────────────────────────────── type fixedScoreClassifier struct{ score float64 } diff --git a/pkg/tools/git.go b/pkg/tools/git.go new file mode 100644 index 000000000..2a46873f6 --- /dev/null +++ b/pkg/tools/git.go @@ -0,0 +1,576 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +const defaultGitTimeout = 60 * time.Second + +// GitRunner runs git commands. It exists so tests can inject a fake runner. +type GitRunner interface { + Run(ctx context.Context, dir string, args ...string) (string, error) +} + +type osGitRunner struct{} + +func (osGitRunner) Run(ctx context.Context, dir string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + if dir != "" { + cmd.Dir = dir + } + + // Keep git non-interactive and avoid system/global hook/config surprises. + cmd.Env = append(os.Environ(), + "GIT_TERMINAL_PROMPT=0", + "GIT_ASKPASS=", + "GIT_CONFIG_NOSYSTEM=1", + ) + + out, err := cmd.CombinedOutput() + return string(out), err +} + +// GitTool wraps the git binary with workspace-scoped path validation. +type GitTool struct { + workspace string + timeout time.Duration + restrictToWorkspace bool + allowedPathPatterns []*regexp.Regexp + runner GitRunner +} + +func NewGitTool( + workspace string, + restrict bool, + timeoutSeconds int, + allowPaths ...[]*regexp.Regexp, +) (*GitTool, error) { + return newGitTool(workspace, restrict, timeoutSeconds, osGitRunner{}, allowPaths...) +} + +func newGitTool( + workspace string, + restrict bool, + timeoutSeconds int, + runner GitRunner, + allowPaths ...[]*regexp.Regexp, +) (*GitTool, error) { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] + } + + timeout := time.Duration(timeoutSeconds) * time.Second + if timeout <= 0 { + timeout = defaultGitTimeout + } + if runner == nil { + runner = osGitRunner{} + } + + return &GitTool{ + workspace: workspace, + timeout: timeout, + restrictToWorkspace: restrict, + allowedPathPatterns: patterns, + runner: runner, + }, nil +} + +func (t *GitTool) Name() string { + return "git" +} + +func (t *GitTool) Description() string { + return "Work with git repositories using the git binary. Supports status, branch, log, diff, fetch, pull, checkout, clone, add, commit, and push. Paths are workspace-scoped when workspace restriction is enabled." +} + +func (t *GitTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"status", "branch", "log", "diff", "fetch", "pull", "checkout", "clone", "add", "commit", "push"}, + "description": "Git action to run", + }, + "path": map[string]any{ + "type": "string", + "description": "Working tree path or repo path for non-clone actions", + }, + "destination": map[string]any{ + "type": "string", + "description": "Destination path for clone", + }, + "url": map[string]any{ + "type": "string", + "description": "Clone source URL or local path", + }, + "ref": map[string]any{ + "type": "string", + "description": "Checkout ref or branch name", + }, + "remote": map[string]any{ + "type": "string", + "description": "Remote name for pull/push (defaults to origin)", + }, + "branch": map[string]any{ + "type": "string", + "description": "Branch name for checkout/pull/push/clone", + }, + "message": map[string]any{ + "type": "string", + "description": "Commit message", + }, + "paths": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "File paths for add or diff", + }, + "limit": map[string]any{ + "type": "integer", + "description": "Maximum log entries to return", + }, + "cached": map[string]any{ + "type": "boolean", + "description": "Use staged changes for diff", + }, + }, + "required": []string{"action"}, + } +} + +func (t *GitTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, _ := args["action"].(string) + if action == "" { + return ErrorResult("action is required") + } + + var ( + output string + err error + ) + + switch action { + case "status": + output, err = t.runInRepo(ctx, args, "status", "--short", "--branch") + case "branch": + output, err = t.runInRepo(ctx, args, "branch", "--show-current") + case "log": + limit, limitErr := getInt64Arg(args, "limit", 10) + if limitErr != nil { + return ErrorResult(limitErr.Error()) + } + if limit <= 0 { + limit = 10 + } + output, err = t.runInRepo(ctx, args, "log", "--oneline", "--decorate", "--graph", "-n", strconv.FormatInt(limit, 10)) + case "diff": + output, err = t.runDiff(ctx, args) + case "fetch": + output, err = t.runFetch(ctx, args) + case "pull": + output, err = t.runPull(ctx, args) + case "checkout": + output, err = t.runCheckout(ctx, args) + case "clone": + output, err = t.runClone(ctx, args) + case "add": + output, err = t.runAdd(ctx, args) + case "commit": + output, err = t.runCommit(ctx, args) + case "push": + output, err = t.runPush(ctx, args) + default: + return ErrorResult(fmt.Sprintf("unknown git action: %s", action)) + } + + if err != nil { + msg := output + if msg == "" { + msg = err.Error() + } else if !strings.Contains(msg, err.Error()) { + msg += "\n" + err.Error() + } + return ErrorResult(msg).WithError(err) + } + + if output == "" { + output = "(no output)" + } + return SilentResult(output) +} + +func (t *GitTool) runInRepo(ctx context.Context, args map[string]any, gitArgs ...string) (string, error) { + repoDir, err := t.resolveRepoDir(ctx, args) + if err != nil { + return "", err + } + cmdArgs := append([]string{"-c", "core.hooksPath=/dev/null"}, gitArgs...) + return t.runGit(ctx, repoDir, cmdArgs...) +} + +func (t *GitTool) runDiff(ctx context.Context, args map[string]any) (string, error) { + repoDir, err := t.resolveRepoDir(ctx, args) + if err != nil { + return "", err + } + + cmdArgs := []string{"-c", "core.hooksPath=/dev/null", "diff"} + if cached, _ := boolArg(args, "cached"); cached { + cmdArgs = append(cmdArgs, "--cached") + } + paths, err := stringSliceArg(args, "paths") + if err != nil { + return "", err + } + if len(paths) > 0 { + cmdArgs = append(cmdArgs, "--") + cmdArgs = append(cmdArgs, paths...) + } + return t.runGit(ctx, repoDir, cmdArgs...) +} + +func (t *GitTool) runFetch(ctx context.Context, args map[string]any) (string, error) { + repoDir, err := t.resolveRepoDir(ctx, args) + if err != nil { + return "", err + } + remote, err := stringArg(args, "remote", "") + if err != nil { + return "", err + } + cmdArgs := []string{"-c", "core.hooksPath=/dev/null", "fetch", "--prune"} + if remote != "" { + if err := rejectOptionLike(remote, "remote"); err != nil { + return "", err + } + cmdArgs = append(cmdArgs, remote) + } else { + cmdArgs = append([]string{"-c", "core.hooksPath=/dev/null", "fetch", "--all", "--prune"}) + } + return t.runGit(ctx, repoDir, cmdArgs...) +} + +func (t *GitTool) runPull(ctx context.Context, args map[string]any) (string, error) { + repoDir, err := t.resolveRepoDir(ctx, args) + if err != nil { + return "", err + } + remote, err := stringArg(args, "remote", "origin") + if err != nil { + return "", err + } + branch, err := stringArg(args, "branch", "") + if err != nil { + return "", err + } + if remote != "" { + if err := rejectOptionLike(remote, "remote"); err != nil { + return "", err + } + } + if branch != "" { + if err := rejectOptionLike(branch, "branch"); err != nil { + return "", err + } + } + cmdArgs := []string{"-c", "core.hooksPath=/dev/null", "pull", "--ff-only"} + if remote != "" { + cmdArgs = append(cmdArgs, remote) + } + if branch != "" { + cmdArgs = append(cmdArgs, branch) + } + return t.runGit(ctx, repoDir, cmdArgs...) +} + +func (t *GitTool) runCheckout(ctx context.Context, args map[string]any) (string, error) { + repoDir, err := t.resolveRepoDir(ctx, args) + if err != nil { + return "", err + } + ref, err := stringArg(args, "ref", "") + if err != nil { + return "", err + } + if err := rejectOptionLike(ref, "ref"); err != nil { + return "", err + } + return t.runGit(ctx, repoDir, "-c", "core.hooksPath=/dev/null", "checkout", ref) +} + +func (t *GitTool) runClone(ctx context.Context, args map[string]any) (string, error) { + url, err := stringArg(args, "url", "") + if err != nil { + return "", err + } + if err := rejectOptionLike(url, "url"); err != nil { + return "", err + } + + dest, err := stringArg(args, "destination", "") + if err != nil { + return "", err + } + if err := rejectOptionLike(dest, "destination"); err != nil { + return "", err + } + resolvedDest, err := t.resolvePath(dest, true) + if err != nil { + return "", fmt.Errorf("clone destination blocked by safety guard: %w", err) + } + + branch, err := stringArg(args, "branch", "") + if err != nil { + return "", err + } + if branch != "" { + if err := rejectOptionLike(branch, "branch"); err != nil { + return "", err + } + } + depth, err := getInt64Arg(args, "depth", 0) + if err != nil { + return "", err + } + + cmdArgs := []string{"-c", "core.hooksPath=/dev/null", "clone"} + if branch != "" { + cmdArgs = append(cmdArgs, "--branch", branch) + } + if depth > 0 { + cmdArgs = append(cmdArgs, "--depth", strconv.FormatInt(depth, 10)) + } + cmdArgs = append(cmdArgs, url, resolvedDest) + + return t.runGit(ctx, t.workspace, cmdArgs...) +} + +func (t *GitTool) runAdd(ctx context.Context, args map[string]any) (string, error) { + repoDir, err := t.resolveRepoDir(ctx, args) + if err != nil { + return "", err + } + paths, err := stringSliceArg(args, "paths") + if err != nil { + return "", err + } + if len(paths) == 0 { + return "", fmt.Errorf("paths is required") + } + for _, p := range paths { + if err := rejectOptionLike(p, "path"); err != nil { + return "", err + } + } + cmdArgs := append([]string{"-c", "core.hooksPath=/dev/null", "add", "--"}, paths...) + return t.runGit(ctx, repoDir, cmdArgs...) +} + +func (t *GitTool) runCommit(ctx context.Context, args map[string]any) (string, error) { + repoDir, err := t.resolveRepoDir(ctx, args) + if err != nil { + return "", err + } + message, err := stringArg(args, "message", "") + if err != nil { + return "", err + } + if strings.TrimSpace(message) == "" { + return "", fmt.Errorf("message is required") + } + if err := rejectOptionLike(message, "message"); err != nil { + return "", err + } + return t.runGit(ctx, repoDir, "-c", "core.hooksPath=/dev/null", "commit", "-m", message) +} + +func (t *GitTool) runPush(ctx context.Context, args map[string]any) (string, error) { + repoDir, err := t.resolveRepoDir(ctx, args) + if err != nil { + return "", err + } + remote, err := stringArg(args, "remote", "origin") + if err != nil { + return "", err + } + branch, err := stringArg(args, "branch", "") + if err != nil { + return "", err + } + if remote != "" { + if err := rejectOptionLike(remote, "remote"); err != nil { + return "", err + } + } + if branch != "" { + if err := rejectOptionLike(branch, "branch"); err != nil { + return "", err + } + } + cmdArgs := []string{"-c", "core.hooksPath=/dev/null", "push"} + if remote != "" { + cmdArgs = append(cmdArgs, remote) + } + if branch != "" { + cmdArgs = append(cmdArgs, branch) + } + return t.runGit(ctx, repoDir, cmdArgs...) +} + +func (t *GitTool) resolveRepoDir(ctx context.Context, args map[string]any) (string, error) { + path, err := stringArg(args, "path", "") + if err != nil { + return "", err + } + resolved, err := t.resolvePath(path, false) + if err != nil { + return "", err + } + + root, err := t.repoRoot(ctx, resolved) + if err != nil { + return "", err + } + return root, nil +} + +func (t *GitTool) resolvePath(path string, allowMissing bool) (string, error) { + if strings.TrimSpace(path) == "" { + if t.workspace == "" { + return "", fmt.Errorf("workspace is not defined") + } + return filepath.Clean(t.workspace), nil + } + + resolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrictToWorkspace, t.allowedPathPatterns) + if err != nil { + return "", err + } + + if allowMissing { + return resolved, nil + } + + info, err := os.Stat(resolved) + if err != nil { + return "", err + } + if info.IsDir() { + return resolved, nil + } + return filepath.Dir(resolved), nil +} + +func (t *GitTool) repoRoot(ctx context.Context, dir string) (string, error) { + if strings.TrimSpace(dir) == "" { + return "", fmt.Errorf("repository path is required") + } + + cmdCtx, cancel := context.WithTimeout(ctx, t.timeout) + defer cancel() + + out, err := t.runner.Run(cmdCtx, dir, "-c", "core.hooksPath=/dev/null", "rev-parse", "--show-toplevel") + if err != nil { + if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) { + return "", fmt.Errorf("git command timed out after %s", t.timeout) + } + return "", fmt.Errorf("not a git repository: %w", err) + } + + root := strings.TrimSpace(out) + if root == "" { + return "", fmt.Errorf("not a git repository") + } + return root, nil +} + +func (t *GitTool) runGit(ctx context.Context, dir string, args ...string) (string, error) { + cmdCtx, cancel := context.WithTimeout(ctx, t.timeout) + defer cancel() + + out, err := t.runner.Run(cmdCtx, dir, args...) + if err != nil { + if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) { + return strings.TrimSpace(out), fmt.Errorf("git command timed out after %s", t.timeout) + } + if strings.TrimSpace(out) == "" { + return "", err + } + return strings.TrimSpace(out), err + } + + return strings.TrimSpace(out), nil +} + +func stringArg(args map[string]any, key, defaultVal string) (string, error) { + raw, exists := args[key] + if !exists || raw == nil { + return defaultVal, nil + } + switch v := raw.(type) { + case string: + return v, nil + default: + return "", fmt.Errorf("%s must be a string", key) + } +} + +func boolArg(args map[string]any, key string) (bool, error) { + raw, exists := args[key] + if !exists || raw == nil { + return false, nil + } + switch v := raw.(type) { + case bool: + return v, nil + case string: + return strings.EqualFold(strings.TrimSpace(v), "true"), nil + default: + return false, fmt.Errorf("%s must be a boolean", key) + } +} + +func stringSliceArg(args map[string]any, key string) ([]string, error) { + raw, exists := args[key] + if !exists || raw == nil { + return nil, nil + } + + switch v := raw.(type) { + case []string: + return v, nil + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf("%s must contain only strings", key) + } + out = append(out, s) + } + return out, nil + default: + return nil, fmt.Errorf("%s must be an array of strings", key) + } +} + +func rejectOptionLike(value, field string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s is required", field) + } + if strings.HasPrefix(strings.TrimSpace(value), "-") { + return fmt.Errorf("%s cannot start with '-'", field) + } + return nil +} diff --git a/pkg/tools/github.go b/pkg/tools/github.go new file mode 100644 index 000000000..6f9f0c5be --- /dev/null +++ b/pkg/tools/github.go @@ -0,0 +1,840 @@ +package tools + +import ( + "archive/zip" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "sort" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/utils" +) + +const ( + defaultGitHubBaseURL = "https://api.github.com" + defaultGitHubTimeout = 20 * time.Second + defaultGitHubAPIVersion = "2022-11-28" + defaultGitHubListLimit = int64(10) + defaultGitHubMaxChars = int64(12000) + maxGitHubListLimit = int64(100) + maxGitHubContentChars = int64(40000) + maxGitHubLogsZipBytes = int64(15 * 1024 * 1024) + gitHubToolUserAgent = "picoclaw-github-tool/1.0" +) + +type GitHubTool struct { + client *http.Client + baseURL string + apiVersion string + token string +} + +type gitHubUser struct { + Login string `json:"login"` + Name string `json:"name"` + HTMLURL string `json:"html_url"` + Bio string `json:"bio"` + PublicRepos int `json:"public_repos"` + TotalPrivateRepos int `json:"total_private_repos"` + Followers int `json:"followers"` + Following int `json:"following"` +} + +type gitHubRepo struct { + FullName string `json:"full_name"` + Description string `json:"description"` + Private bool `json:"private"` + Archived bool `json:"archived"` + Fork bool `json:"fork"` + HTMLURL string `json:"html_url"` + DefaultBranch string `json:"default_branch"` + Language string `json:"language"` + UpdatedAt string `json:"updated_at"` + PushedAt string `json:"pushed_at"` + OpenIssues int `json:"open_issues_count"` + Watchers int `json:"watchers_count"` + Stargazers int `json:"stargazers_count"` + Forks int `json:"forks_count"` +} + +type gitHubCommit struct { + SHA string `json:"sha"` + Commit struct { + Message string `json:"message"` + Author struct { + Name string `json:"name"` + Date string `json:"date"` + } `json:"author"` + } `json:"commit"` +} + +type gitHubBranch struct { + Name string `json:"name"` + Protected bool `json:"protected"` + Commit struct { + SHA string `json:"sha"` + } `json:"commit"` +} + +type gitHubContent struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Size int `json:"size"` + Encoding string `json:"encoding"` + Content string `json:"content"` + SHA string `json:"sha"` + HTMLURL string `json:"html_url"` + DownloadURL string `json:"download_url"` +} + +type gitHubWorkflowRun struct { + ID int64 `json:"id"` + Name string `json:"name"` + DisplayTitle string `json:"display_title"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Event string `json:"event"` + HeadBranch string `json:"head_branch"` + HeadSHA string `json:"head_sha"` + HTMLURL string `json:"html_url"` + UpdatedAt string `json:"updated_at"` +} + +type gitHubWorkflowRunsResponse struct { + WorkflowRuns []gitHubWorkflowRun `json:"workflow_runs"` +} + +func NewGitHubTool(token, baseURL, proxy string, timeoutSeconds int) (*GitHubTool, error) { + token = strings.TrimSpace(token) + if token == "" { + token = strings.TrimSpace(os.Getenv("GITHUB_MCP_PAT")) + } + if token == "" { + token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) + } + if token == "" { + return nil, fmt.Errorf("github token is required") + } + + timeout := time.Duration(timeoutSeconds) * time.Second + if timeout <= 0 { + timeout = defaultGitHubTimeout + } + + client, err := utils.CreateHTTPClient(proxy, timeout) + if err != nil { + return nil, fmt.Errorf("failed to create GitHub HTTP client: %w", err) + } + + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + baseURL = defaultGitHubBaseURL + } + + return &GitHubTool{ + client: client, + baseURL: strings.TrimRight(baseURL, "/"), + apiVersion: defaultGitHubAPIVersion, + token: token, + }, nil +} + +func (t *GitHubTool) Name() string { + return "github" +} + +func (t *GitHubTool) Description() string { + return "Inspect GitHub via the REST API. Supports account lookup, repo listing, repo summaries, branches, directory listings, file reads, workflow runs, and workflow run logs." +} + +func (t *GitHubTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{ + "me", + "my_repos", + "repo_summary", + "list_branches", + "list_directory", + "get_file", + "list_workflow_runs", + "get_workflow_run_logs", + }, + "description": "GitHub action to run", + }, + "repo": map[string]any{ + "type": "string", + "description": "Repository in owner/repo format", + }, + "path": map[string]any{ + "type": "string", + "description": "Repository file or directory path for content actions", + }, + "ref": map[string]any{ + "type": "string", + "description": "Branch, tag, or commit for content actions", + }, + "limit": map[string]any{ + "type": "integer", + "description": "Maximum number of results to return", + }, + "visibility": map[string]any{ + "type": "string", + "enum": []string{"all", "public", "private"}, + "description": "Visibility filter for my_repos", + }, + "affiliation": map[string]any{ + "type": "string", + "description": "Affiliation filter for my_repos, for example owner,collaborator,organization_member", + }, + "branch": map[string]any{ + "type": "string", + "description": "Branch filter for workflow runs", + }, + "status": map[string]any{ + "type": "string", + "description": "Workflow status filter, for example queued, in_progress, completed, success, failure", + }, + "event": map[string]any{ + "type": "string", + "description": "Workflow event filter, for example push or pull_request", + }, + "run_id": map[string]any{ + "type": "integer", + "description": "Workflow run ID for log retrieval", + }, + "max_chars": map[string]any{ + "type": "integer", + "description": "Maximum characters to return for file content or workflow logs", + }, + }, + "required": []string{"action"}, + } +} + +func (t *GitHubTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, _ := args["action"].(string) + if action == "" { + return ErrorResult("action is required") + } + + var ( + output string + err error + ) + + switch action { + case "me": + output, err = t.me(ctx) + case "my_repos": + output, err = t.myRepos(ctx, args) + case "repo_summary": + output, err = t.repoSummary(ctx, args) + case "list_branches": + output, err = t.listBranches(ctx, args) + case "list_directory": + output, err = t.listDirectory(ctx, args) + case "get_file": + output, err = t.getFile(ctx, args) + case "list_workflow_runs": + output, err = t.listWorkflowRuns(ctx, args) + case "get_workflow_run_logs": + output, err = t.getWorkflowRunLogs(ctx, args) + default: + return ErrorResult(fmt.Sprintf("unknown github action: %s", action)) + } + + if err != nil { + return ErrorResult(err.Error()).WithError(err) + } + return SilentResult(output) +} + +func (t *GitHubTool) me(ctx context.Context) (string, error) { + var user gitHubUser + if err := t.getJSON(ctx, "/user", nil, &user); err != nil { + return "", err + } + + lines := []string{ + fmt.Sprintf("Authenticated GitHub user: %s", user.Login), + } + if strings.TrimSpace(user.Name) != "" { + lines = append(lines, fmt.Sprintf("Name: %s", user.Name)) + } + if strings.TrimSpace(user.HTMLURL) != "" { + lines = append(lines, fmt.Sprintf("Profile: %s", user.HTMLURL)) + } + if strings.TrimSpace(user.Bio) != "" { + lines = append(lines, fmt.Sprintf("Bio: %s", user.Bio)) + } + lines = append(lines, + fmt.Sprintf("Public repos: %d", user.PublicRepos), + fmt.Sprintf("Private repos visible to token: %d", user.TotalPrivateRepos), + fmt.Sprintf("Followers: %d", user.Followers), + fmt.Sprintf("Following: %d", user.Following), + ) + return strings.Join(lines, "\n"), nil +} + +func (t *GitHubTool) myRepos(ctx context.Context, args map[string]any) (string, error) { + limit, err := getBoundedIntArg(args, "limit", defaultGitHubListLimit, 1, maxGitHubListLimit) + if err != nil { + return "", err + } + visibility, err := stringArg(args, "visibility", "") + if err != nil { + return "", err + } + affiliation, err := stringArg(args, "affiliation", "") + if err != nil { + return "", err + } + + query := url.Values{} + query.Set("per_page", fmt.Sprintf("%d", limit)) + query.Set("sort", "updated") + query.Set("direction", "desc") + if visibility != "" { + query.Set("visibility", visibility) + } + if affiliation != "" { + query.Set("affiliation", affiliation) + } + + var repos []gitHubRepo + if err := t.getJSON(ctx, "/user/repos", query, &repos); err != nil { + return "", err + } + if len(repos) == 0 { + return "No repositories matched the current filters.", nil + } + + lines := []string{fmt.Sprintf("Repositories (%d):", len(repos))} + for _, repo := range repos { + visibilityLabel := "public" + if repo.Private { + visibilityLabel = "private" + } + extras := []string{visibilityLabel} + if repo.Archived { + extras = append(extras, "archived") + } + if repo.Fork { + extras = append(extras, "fork") + } + meta := strings.Join(extras, ", ") + if repo.Language != "" { + meta += ", " + repo.Language + } + lines = append(lines, fmt.Sprintf("- %s [%s]", repo.FullName, meta)) + if repo.Description != "" { + lines = append(lines, " "+repo.Description) + } + lines = append(lines, fmt.Sprintf(" default=%s updated=%s", repo.DefaultBranch, repo.UpdatedAt)) + } + return strings.Join(lines, "\n"), nil +} + +func (t *GitHubTool) repoSummary(ctx context.Context, args map[string]any) (string, error) { + owner, repoName, err := repoArg(args) + if err != nil { + return "", err + } + + var repo gitHubRepo + if err := t.getJSON(ctx, fmt.Sprintf("/repos/%s/%s", owner, repoName), nil, &repo); err != nil { + return "", err + } + + languages := map[string]int{} + _ = t.getJSON(ctx, fmt.Sprintf("/repos/%s/%s/languages", owner, repoName), nil, &languages) + + var commits []gitHubCommit + commitsQuery := url.Values{} + commitsQuery.Set("per_page", "5") + _ = t.getJSON(ctx, fmt.Sprintf("/repos/%s/%s/commits", owner, repoName), commitsQuery, &commits) + + visibilityLabel := "public" + if repo.Private { + visibilityLabel = "private" + } + + lines := []string{ + fmt.Sprintf("Repository: %s", repo.FullName), + fmt.Sprintf("URL: %s", repo.HTMLURL), + fmt.Sprintf("Visibility: %s", visibilityLabel), + fmt.Sprintf("Default branch: %s", repo.DefaultBranch), + fmt.Sprintf("Archived: %t", repo.Archived), + fmt.Sprintf("Open issues: %d", repo.OpenIssues), + fmt.Sprintf("Stars: %d", repo.Stargazers), + fmt.Sprintf("Forks: %d", repo.Forks), + fmt.Sprintf("Watchers: %d", repo.Watchers), + fmt.Sprintf("Last push: %s", repo.PushedAt), + } + if repo.Description != "" { + lines = append(lines, fmt.Sprintf("Description: %s", repo.Description)) + } + if len(languages) > 0 { + lines = append(lines, "Languages: "+formatLanguageBreakdown(languages)) + } + if len(commits) > 0 { + lines = append(lines, "Recent commits:") + for _, commit := range commits { + subject := strings.TrimSpace(strings.Split(commit.Commit.Message, "\n")[0]) + lines = append(lines, fmt.Sprintf("- %s %s (%s, %s)", + shortSHA(commit.SHA), subject, commit.Commit.Author.Name, commit.Commit.Author.Date)) + } + } + return strings.Join(lines, "\n"), nil +} + +func (t *GitHubTool) listBranches(ctx context.Context, args map[string]any) (string, error) { + owner, repoName, err := repoArg(args) + if err != nil { + return "", err + } + limit, err := getBoundedIntArg(args, "limit", defaultGitHubListLimit, 1, maxGitHubListLimit) + if err != nil { + return "", err + } + + query := url.Values{} + query.Set("per_page", fmt.Sprintf("%d", limit)) + + var branches []gitHubBranch + if err := t.getJSON(ctx, fmt.Sprintf("/repos/%s/%s/branches", owner, repoName), query, &branches); err != nil { + return "", err + } + if len(branches) == 0 { + return fmt.Sprintf("No branches returned for %s/%s.", owner, repoName), nil + } + + lines := []string{fmt.Sprintf("Branches for %s/%s:", owner, repoName)} + for _, branch := range branches { + protected := "unprotected" + if branch.Protected { + protected = "protected" + } + lines = append(lines, fmt.Sprintf("- %s [%s] %s", branch.Name, protected, shortSHA(branch.Commit.SHA))) + } + return strings.Join(lines, "\n"), nil +} + +func (t *GitHubTool) listDirectory(ctx context.Context, args map[string]any) (string, error) { + owner, repoName, err := repoArg(args) + if err != nil { + return "", err + } + ref, err := stringArg(args, "ref", "") + if err != nil { + return "", err + } + pathArg, err := stringArg(args, "path", "") + if err != nil { + return "", err + } + + body, err := t.getContentRaw(ctx, owner, repoName, pathArg, ref) + if err != nil { + return "", err + } + + var items []gitHubContent + if err := json.Unmarshal(body, &items); err != nil { + var file gitHubContent + if err2 := json.Unmarshal(body, &file); err2 == nil && file.Type == "file" { + return "", fmt.Errorf("%s is a file; use get_file instead", displayRepoPath(pathArg)) + } + return "", fmt.Errorf("unexpected GitHub contents response: %w", err) + } + if len(items) == 0 { + return fmt.Sprintf("Directory %s is empty.", displayRepoPath(pathArg)), nil + } + + sort.Slice(items, func(i, j int) bool { + if items[i].Type != items[j].Type { + return items[i].Type == "dir" + } + return items[i].Name < items[j].Name + }) + + lines := []string{fmt.Sprintf("Directory listing for %s/%s:%s", owner, repoName, formatRepoPathSuffix(pathArg))} + for _, item := range items { + lines = append(lines, fmt.Sprintf("- %s %s (%d bytes)", item.Type, item.Path, item.Size)) + } + return strings.Join(lines, "\n"), nil +} + +func (t *GitHubTool) getFile(ctx context.Context, args map[string]any) (string, error) { + owner, repoName, err := repoArg(args) + if err != nil { + return "", err + } + pathArg, err := stringArg(args, "path", "") + if err != nil { + return "", err + } + if strings.TrimSpace(pathArg) == "" { + return "", fmt.Errorf("path is required") + } + ref, err := stringArg(args, "ref", "") + if err != nil { + return "", err + } + maxChars, err := getBoundedIntArg(args, "max_chars", defaultGitHubMaxChars, 256, maxGitHubContentChars) + if err != nil { + return "", err + } + + body, err := t.getContentRaw(ctx, owner, repoName, pathArg, ref) + if err != nil { + return "", err + } + + var file gitHubContent + if err := json.Unmarshal(body, &file); err != nil { + return "", fmt.Errorf("failed to decode file response: %w", err) + } + if file.Type != "file" { + return "", fmt.Errorf("%s is not a file", displayRepoPath(pathArg)) + } + if file.Encoding != "base64" { + return "", fmt.Errorf("unsupported file encoding %q for %s", file.Encoding, pathArg) + } + + decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(file.Content, "\n", "")) + if err != nil { + return "", fmt.Errorf("failed to decode file content: %w", err) + } + + text := normalizeGitHubText(string(decoded)) + truncated := false + if int64(len(text)) > maxChars { + text = text[:maxChars] + truncated = true + } + + lines := []string{ + fmt.Sprintf("File: %s", file.Path), + fmt.Sprintf("Repository: %s/%s", owner, repoName), + } + if ref != "" { + lines = append(lines, fmt.Sprintf("Ref: %s", ref)) + } + lines = append(lines, fmt.Sprintf("Size: %d bytes", file.Size)) + if truncated { + lines = append(lines, fmt.Sprintf("Content truncated to %d characters.", maxChars)) + } + lines = append(lines, "", text) + return strings.Join(lines, "\n"), nil +} + +func (t *GitHubTool) listWorkflowRuns(ctx context.Context, args map[string]any) (string, error) { + owner, repoName, err := repoArg(args) + if err != nil { + return "", err + } + limit, err := getBoundedIntArg(args, "limit", defaultGitHubListLimit, 1, maxGitHubListLimit) + if err != nil { + return "", err + } + branch, err := stringArg(args, "branch", "") + if err != nil { + return "", err + } + status, err := stringArg(args, "status", "") + if err != nil { + return "", err + } + event, err := stringArg(args, "event", "") + if err != nil { + return "", err + } + + query := url.Values{} + query.Set("per_page", fmt.Sprintf("%d", limit)) + if branch != "" { + query.Set("branch", branch) + } + if status != "" { + query.Set("status", status) + } + if event != "" { + query.Set("event", event) + } + + var resp gitHubWorkflowRunsResponse + if err := t.getJSON(ctx, fmt.Sprintf("/repos/%s/%s/actions/runs", owner, repoName), query, &resp); err != nil { + return "", err + } + if len(resp.WorkflowRuns) == 0 { + return fmt.Sprintf("No workflow runs matched for %s/%s.", owner, repoName), nil + } + + lines := []string{fmt.Sprintf("Workflow runs for %s/%s:", owner, repoName)} + for _, run := range resp.WorkflowRuns { + title := strings.TrimSpace(run.DisplayTitle) + if title == "" { + title = run.Name + } + statusText := run.Status + if run.Conclusion != "" { + statusText += "/" + run.Conclusion + } + lines = append(lines, fmt.Sprintf("- #%d %s [%s] branch=%s sha=%s event=%s updated=%s", + run.ID, title, statusText, run.HeadBranch, shortSHA(run.HeadSHA), run.Event, run.UpdatedAt)) + if run.HTMLURL != "" { + lines = append(lines, " "+run.HTMLURL) + } + } + return strings.Join(lines, "\n"), nil +} + +func (t *GitHubTool) getWorkflowRunLogs(ctx context.Context, args map[string]any) (string, error) { + owner, repoName, err := repoArg(args) + if err != nil { + return "", err + } + runID, err := getBoundedIntArg(args, "run_id", 0, 1, 1<<62) + if err != nil { + return "", err + } + maxChars, err := getBoundedIntArg(args, "max_chars", defaultGitHubMaxChars, 512, maxGitHubContentChars) + if err != nil { + return "", err + } + + body, err := t.getBytes(ctx, fmt.Sprintf("/repos/%s/%s/actions/runs/%d/logs", owner, repoName, runID), nil, maxGitHubLogsZipBytes) + if err != nil { + return "", err + } + + reader, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + if err != nil { + return "", fmt.Errorf("failed to read workflow logs archive: %w", err) + } + if len(reader.File) == 0 { + return "", fmt.Errorf("workflow logs archive is empty") + } + + sort.Slice(reader.File, func(i, j int) bool { + return reader.File[i].Name < reader.File[j].Name + }) + + var b strings.Builder + fmt.Fprintf(&b, "Workflow logs for %s/%s run #%d\n", owner, repoName, runID) + remaining := maxChars - int64(b.Len()) + if remaining <= 0 { + return b.String(), nil + } + + for _, file := range reader.File { + if file.FileInfo().IsDir() { + continue + } + if remaining <= 0 { + break + } + + rc, err := file.Open() + if err != nil { + return "", fmt.Errorf("failed to open workflow log %s: %w", file.Name, err) + } + entryBytes, readErr := io.ReadAll(io.LimitReader(rc, remaining+1)) + rc.Close() + if readErr != nil { + return "", fmt.Errorf("failed to read workflow log %s: %w", file.Name, readErr) + } + + entryText := normalizeGitHubText(string(entryBytes)) + entryHeader := fmt.Sprintf("\n=== %s ===\n", file.Name) + if int64(len(entryHeader)) >= remaining { + break + } + b.WriteString(entryHeader) + remaining = maxChars - int64(b.Len()) + + truncated := int64(len(entryText)) > remaining + if truncated { + entryText = entryText[:remaining] + } + b.WriteString(entryText) + if truncated { + b.WriteString("\n[truncated]\n") + } + remaining = maxChars - int64(b.Len()) + } + + if maxChars-int64(b.Len()) <= 0 { + b.WriteString("\n[overall log output truncated]\n") + } + return b.String(), nil +} + +func (t *GitHubTool) getJSON(ctx context.Context, apiPath string, query url.Values, dst any) error { + body, err := t.getBytes(ctx, apiPath, query, 0) + if err != nil { + return err + } + if err := json.Unmarshal(body, dst); err != nil { + return fmt.Errorf("failed to decode GitHub response: %w", err) + } + return nil +} + +func (t *GitHubTool) getContentRaw(ctx context.Context, owner, repoName, contentPath, ref string) ([]byte, error) { + relPath := fmt.Sprintf("/repos/%s/%s/contents", owner, repoName) + if trimmed := strings.Trim(contentPath, "/"); trimmed != "" { + escaped := make([]string, 0, len(strings.Split(trimmed, "/"))) + for _, part := range strings.Split(trimmed, "/") { + escaped = append(escaped, url.PathEscape(part)) + } + relPath += "/" + strings.Join(escaped, "/") + } + query := url.Values{} + if ref != "" { + query.Set("ref", ref) + } + return t.getBytes(ctx, relPath, query, 0) +} + +func (t *GitHubTool) getBytes(ctx context.Context, apiPath string, query url.Values, maxBytes int64) ([]byte, error) { + req, err := t.newRequest(ctx, apiPath, query) + if err != nil { + return nil, err + } + + resp, err := utils.DoRequestWithRetry(t.client, req) + if err != nil { + return nil, fmt.Errorf("GitHub request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("GitHub API %s returned %d: %s", apiPath, resp.StatusCode, strings.TrimSpace(string(body))) + } + + reader := io.Reader(resp.Body) + if maxBytes > 0 { + reader = io.LimitReader(resp.Body, maxBytes+1) + } + body, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read GitHub response: %w", err) + } + if maxBytes > 0 && int64(len(body)) > maxBytes { + return nil, fmt.Errorf("GitHub response exceeded %d bytes", maxBytes) + } + return body, nil +} + +func (t *GitHubTool) newRequest(ctx context.Context, apiPath string, query url.Values) (*http.Request, error) { + fullURL := t.baseURL + apiPath + if len(query) > 0 { + fullURL += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create GitHub request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Authorization", "Bearer "+t.token) + req.Header.Set("User-Agent", gitHubToolUserAgent) + req.Header.Set("X-GitHub-Api-Version", t.apiVersion) + return req, nil +} + +func repoArg(args map[string]any) (string, string, error) { + repo, err := stringArg(args, "repo", "") + if err != nil { + return "", "", err + } + if strings.TrimSpace(repo) == "" { + return "", "", fmt.Errorf("repo is required") + } + parts := strings.Split(strings.Trim(repo, "/"), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("repo must be in owner/repo format") + } + return parts[0], parts[1], nil +} + +func getBoundedIntArg(args map[string]any, key string, defaultVal, minVal, maxVal int64) (int64, error) { + value, err := getInt64Arg(args, key, defaultVal) + if err != nil { + return 0, err + } + if value < minVal { + return 0, fmt.Errorf("%s must be at least %d", key, minVal) + } + if value > maxVal { + return maxVal, nil + } + return value, nil +} + +func formatLanguageBreakdown(languages map[string]int) string { + type item struct { + Name string + Bytes int + } + items := make([]item, 0, len(languages)) + total := 0 + for name, count := range languages { + items = append(items, item{Name: name, Bytes: count}) + total += count + } + sort.Slice(items, func(i, j int) bool { + return items[i].Bytes > items[j].Bytes + }) + + parts := make([]string, 0, len(items)) + for _, item := range items { + if total == 0 { + parts = append(parts, item.Name) + continue + } + parts = append(parts, fmt.Sprintf("%s %.1f%%", item.Name, (float64(item.Bytes)/float64(total))*100)) + } + return strings.Join(parts, ", ") +} + +func shortSHA(sha string) string { + if len(sha) > 7 { + return sha[:7] + } + return sha +} + +func displayRepoPath(path string) string { + if strings.TrimSpace(path) == "" { + return "/" + } + return path +} + +func formatRepoPathSuffix(path string) string { + if strings.TrimSpace(path) == "" { + return "/" + } + return ":" + path +} + +func normalizeGitHubText(text string) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + return strings.TrimSpace(text) +} diff --git a/pkg/tools/github_test.go b/pkg/tools/github_test.go new file mode 100644 index 000000000..1bf99563a --- /dev/null +++ b/pkg/tools/github_test.go @@ -0,0 +1,175 @@ +package tools + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestNewGitHubTool_UsesEnvFallback(t *testing.T) { + t.Setenv("GITHUB_MCP_PAT", "env-token") + + tool, err := NewGitHubTool("", "", "", 0) + if err != nil { + t.Fatalf("NewGitHubTool() error = %v", err) + } + if tool.token != "env-token" { + t.Fatalf("tool.token = %q, want env-token", tool.token) + } + if tool.baseURL != defaultGitHubBaseURL { + t.Fatalf("tool.baseURL = %q, want %q", tool.baseURL, defaultGitHubBaseURL) + } +} + +func TestGitHubTool_Me(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/user" { + t.Fatalf("path = %q, want /user", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Fatalf("Authorization = %q", got) + } + if got := r.Header.Get("X-GitHub-Api-Version"); got != defaultGitHubAPIVersion { + t.Fatalf("X-GitHub-Api-Version = %q", got) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "login":"Skezza", + "name":"Joe", + "html_url":"https://github.com/Skezza", + "bio":"Builder", + "public_repos":12, + "total_private_repos":5, + "followers":3, + "following":4 + }`) + })) + defer server.Close() + + tool, err := NewGitHubTool("test-token", server.URL, "", 5) + if err != nil { + t.Fatalf("NewGitHubTool() error = %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{"action": "me"}) + if result.IsError { + t.Fatalf("Execute() unexpected error: %s", result.ForLLM) + } + if !result.Silent { + t.Fatal("me should be silent") + } + if !strings.Contains(result.ForLLM, "Authenticated GitHub user: Skezza") { + t.Fatalf("ForLLM = %q", result.ForLLM) + } +} + +func TestGitHubTool_GetFile(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if want := "/repos/Skezza/picoclaw/contents/docs/readme.md"; r.URL.Path != want { + t.Fatalf("path = %q, want %q", r.URL.Path, want) + } + if got := r.URL.Query().Get("ref"); got != "main" { + t.Fatalf("ref query = %q, want main", got) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "type":"file", + "path":"docs/readme.md", + "size":24, + "encoding":"base64", + "content":"SGVsbG8gZnJvbSBHaXRIdWIgZmlsZSEK" + }`) + })) + defer server.Close() + + tool, err := NewGitHubTool("test-token", server.URL, "", 5) + if err != nil { + t.Fatalf("NewGitHubTool() error = %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "action": "get_file", + "repo": "Skezza/picoclaw", + "path": "docs/readme.md", + "ref": "main", + "max_chars": 512, + }) + if result.IsError { + t.Fatalf("Execute() unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "File: docs/readme.md") { + t.Fatalf("ForLLM = %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Hello from GitHub file!") { + t.Fatalf("ForLLM missing decoded content: %q", result.ForLLM) + } +} + +func TestGitHubTool_GetWorkflowRunLogs(t *testing.T) { + var zipBuf bytes.Buffer + zw := zip.NewWriter(&zipBuf) + fw, err := zw.Create("build/1_Setup.txt") + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if _, err := fw.Write([]byte("setup complete\n")); err != nil { + t.Fatalf("Write() error = %v", err) + } + fw, err = zw.Create("build/2_Test.txt") + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if _, err := fw.Write([]byte("tests passed\n")); err != nil { + t.Fatalf("Write() error = %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if want := "/repos/Skezza/picoclaw/actions/runs/42/logs"; r.URL.Path != want { + t.Fatalf("path = %q, want %q", r.URL.Path, want) + } + w.Header().Set("Content-Type", "application/zip") + if _, err := w.Write(zipBuf.Bytes()); err != nil { + t.Fatalf("Write() error = %v", err) + } + })) + defer server.Close() + + tool, err := NewGitHubTool("test-token", server.URL, "", 5) + if err != nil { + t.Fatalf("NewGitHubTool() error = %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "action": "get_workflow_run_logs", + "repo": "Skezza/picoclaw", + "run_id": 42, + "max_chars": 4096, + }) + if result.IsError { + t.Fatalf("Execute() unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Workflow logs for Skezza/picoclaw run #42") { + t.Fatalf("ForLLM = %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "setup complete") || !strings.Contains(result.ForLLM, "tests passed") { + t.Fatalf("ForLLM missing log contents: %q", result.ForLLM) + } +} + +func TestNewGitHubTool_RequiresToken(t *testing.T) { + _ = os.Unsetenv("GITHUB_MCP_PAT") + _ = os.Unsetenv("GITHUB_TOKEN") + + if _, err := NewGitHubTool("", "", "", 0); err == nil { + t.Fatal("expected missing token error") + } +}