diff --git a/cmd/task/main.go b/cmd/task/main.go index 80934be6..1fe9f8a3 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -591,6 +591,7 @@ Examples: project, _ := cmd.Flags().GetString("project") taskExecutor, _ := cmd.Flags().GetString("executor") effortLevel, _ := cmd.Flags().GetString("effort") + modelProfile, _ := cmd.Flags().GetString("model-profile") execute, _ := cmd.Flags().GetBool("execute") createDangerous, _ := cmd.Flags().GetBool("dangerous") permissionModeFlag, _ := cmd.Flags().GetString("permission-mode") @@ -721,6 +722,7 @@ Examples: Project: project, Executor: taskExecutor, EffortLevel: effortLevel, + ModelProfile: strings.TrimSpace(modelProfile), Tags: tags, Pinned: pinned, SourceBranch: branch, @@ -748,6 +750,9 @@ Examples: if task.EffortLevel != "" { output["effort_level"] = task.EffortLevel } + if task.ModelProfile != "" { + output["model_profile"] = task.ModelProfile + } jsonBytes, _ := json.Marshal(output) fmt.Println(string(jsonBytes)) } else { @@ -771,6 +776,7 @@ Examples: createCmd.Flags().StringP("project", "p", "", "Project name (auto-detected from cwd if not specified)") createCmd.Flags().StringP("executor", "e", "", "Task executor: claude, codex, gemini, pi, opencode, openclaw (default: claude)") createCmd.Flags().String("effort", "", "Per-task Claude effort override: low, medium, high, xhigh, max (default: Claude's global default)") + createCmd.Flags().String("model-profile", "", "Named model profile for the Pi executor (provider+model+base_url from ~/.config/taskyou/pi-models.json)") createCmd.Flags().BoolP("execute", "x", false, "Queue task for immediate execution") createCmd.Flags().Bool("dangerous", false, "Execute in dangerous mode (alias for --permission-mode dangerous)") createCmd.Flags().String("permission-mode", "", "Permission mode: default (prompt), accept-edits (auto-accept file edits), auto (Claude Code auto mode: auto-approve safe actions, block risky ones), dangerous (skip all). Defaults to the project's setting") diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index b74f41cc..33de9aab 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -297,6 +297,8 @@ func (db *DB) migrate() error { `ALTER TABLE tasks ADD COLUMN effort_level TEXT DEFAULT ''`, `ALTER TABLE tasks ADD COLUMN remote_control INTEGER DEFAULT 0`, // Whether to launch claude with --remote-control + // Per-task model profile name (Pi executor): selects provider+model+base_url from the model-profile config ("" = Pi default) + `ALTER TABLE tasks ADD COLUMN model_profile TEXT DEFAULT ''`, } for _, m := range alterMigrations { diff --git a/internal/db/tasks.go b/internal/db/tasks.go index b69b4609..cb1fb577 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -20,6 +20,7 @@ type Task struct { Project string Executor string // Task executor: "claude" (default), "codex", "gemini" EffortLevel string // Per-task Claude effort override ("" = use global/Claude default; otherwise low/medium/high/xhigh/max) + ModelProfile string // Per-task model profile name (Pi executor): selects provider+model+base_url from the model-profile config. "" = Pi's default model. WorktreePath string BranchName string Port int // Unique port for running the application in this task's worktree @@ -298,9 +299,9 @@ func (db *DB) CreateTask(t *Task) error { t.DangerousMode = t.PermissionMode == PermissionModeDangerous result, err := db.Exec(` - INSERT INTO tasks (title, body, status, type, project, executor, pinned, tags, source_branch, dangerous_mode, permission_mode, remote_control, effort_level) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, t.Pinned, t.Tags, t.SourceBranch, t.DangerousMode, t.PermissionMode, t.RemoteControl, t.EffortLevel) + INSERT INTO tasks (title, body, status, type, project, executor, pinned, tags, source_branch, dangerous_mode, permission_mode, remote_control, effort_level, model_profile) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, t.Pinned, t.Tags, t.SourceBranch, t.DangerousMode, t.PermissionMode, t.RemoteControl, t.EffortLevel, t.ModelProfile) if err != nil { return fmt.Errorf("insert task: %w", err) } @@ -353,7 +354,7 @@ func (db *DB) GetTask(id int64) (*Task, error) { COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''), COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(remote_control, 0), COALESCE(pinned, 0), COALESCE(tags, ''), - COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), + COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), COALESCE(model_profile, ''), created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, COALESCE(archive_ref, ''), COALESCE(archive_commit, ''), @@ -365,7 +366,7 @@ func (db *DB) GetTask(id int64) (*Task, error) { &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, &t.PRInfoJSON, &t.DangerousMode, &t.PermissionMode, &t.RemoteControl, &t.Pinned, &t.Tags, - &t.SourceBranch, &t.Summary, &t.EffortLevel, + &t.SourceBranch, &t.Summary, &t.EffortLevel, &t.ModelProfile, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, &t.ArchiveRef, &t.ArchiveCommit, &t.ArchiveWorktreePath, &t.ArchiveBranchName, @@ -399,7 +400,7 @@ func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) { COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''), COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(remote_control, 0), COALESCE(pinned, 0), COALESCE(tags, ''), - COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), + COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), COALESCE(model_profile, ''), created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, COALESCE(archive_ref, ''), COALESCE(archive_commit, ''), @@ -468,7 +469,7 @@ func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) { &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, &t.PRInfoJSON, &t.DangerousMode, &t.PermissionMode, &t.RemoteControl, &t.Pinned, &t.Tags, - &t.SourceBranch, &t.Summary, &t.EffortLevel, + &t.SourceBranch, &t.Summary, &t.EffortLevel, &t.ModelProfile, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, &t.ArchiveRef, &t.ArchiveCommit, &t.ArchiveWorktreePath, &t.ArchiveBranchName, @@ -493,7 +494,7 @@ func (db *DB) GetMostRecentlyCreatedTask() (*Task, error) { COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''), COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(remote_control, 0), COALESCE(pinned, 0), COALESCE(tags, ''), - COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), + COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), COALESCE(model_profile, ''), created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, COALESCE(archive_ref, ''), COALESCE(archive_commit, ''), @@ -507,7 +508,7 @@ func (db *DB) GetMostRecentlyCreatedTask() (*Task, error) { &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, &t.PRInfoJSON, &t.DangerousMode, &t.PermissionMode, &t.RemoteControl, &t.Pinned, &t.Tags, - &t.SourceBranch, &t.Summary, &t.EffortLevel, + &t.SourceBranch, &t.Summary, &t.EffortLevel, &t.ModelProfile, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, &t.ArchiveRef, &t.ArchiveCommit, &t.ArchiveWorktreePath, &t.ArchiveBranchName, @@ -536,7 +537,7 @@ func (db *DB) SearchTasks(query string, limit int) ([]*Task, error) { COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''), COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(remote_control, 0), COALESCE(pinned, 0), COALESCE(tags, ''), - COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), + COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), COALESCE(model_profile, ''), created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, COALESCE(archive_ref, ''), COALESCE(archive_commit, ''), @@ -569,7 +570,7 @@ func (db *DB) SearchTasks(query string, limit int) ([]*Task, error) { &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, &t.PRInfoJSON, &t.DangerousMode, &t.PermissionMode, &t.RemoteControl, &t.Pinned, &t.Tags, - &t.SourceBranch, &t.Summary, &t.EffortLevel, + &t.SourceBranch, &t.Summary, &t.EffortLevel, &t.ModelProfile, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, &t.ArchiveRef, &t.ArchiveCommit, &t.ArchiveWorktreePath, &t.ArchiveBranchName, @@ -671,13 +672,13 @@ func (db *DB) UpdateTask(t *Task) error { title = ?, body = ?, status = ?, type = ?, project = ?, executor = ?, worktree_path = ?, branch_name = ?, port = ?, claude_session_id = ?, daemon_session = ?, pr_url = ?, pr_number = ?, pr_info_json = ?, dangerous_mode = ?, permission_mode = ?, remote_control = ?, - pinned = ?, tags = ?, source_branch = ?, effort_level = ?, + pinned = ?, tags = ?, source_branch = ?, effort_level = ?, model_profile = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, t.WorktreePath, t.BranchName, t.Port, t.ClaudeSessionID, t.DaemonSession, t.PRURL, t.PRNumber, t.PRInfoJSON, t.DangerousMode, t.PermissionMode, t.RemoteControl, - t.Pinned, t.Tags, t.SourceBranch, t.EffortLevel, t.ID) + t.Pinned, t.Tags, t.SourceBranch, t.EffortLevel, t.ModelProfile, t.ID) if err != nil { return fmt.Errorf("update task: %w", err) } @@ -1003,7 +1004,7 @@ func (db *DB) GetNextQueuedTask() (*Task, error) { COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''), COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(remote_control, 0), COALESCE(pinned, 0), COALESCE(tags, ''), - COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), + COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), COALESCE(model_profile, ''), created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, COALESCE(archive_ref, ''), COALESCE(archive_commit, ''), @@ -1018,7 +1019,7 @@ func (db *DB) GetNextQueuedTask() (*Task, error) { &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, &t.PRInfoJSON, &t.DangerousMode, &t.PermissionMode, &t.RemoteControl, &t.Pinned, &t.Tags, - &t.SourceBranch, &t.Summary, &t.EffortLevel, + &t.SourceBranch, &t.Summary, &t.EffortLevel, &t.ModelProfile, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, &t.ArchiveRef, &t.ArchiveCommit, &t.ArchiveWorktreePath, &t.ArchiveBranchName, @@ -1041,7 +1042,7 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) { COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''), COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(remote_control, 0), COALESCE(pinned, 0), COALESCE(tags, ''), - COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), + COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), COALESCE(model_profile, ''), created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, COALESCE(archive_ref, ''), COALESCE(archive_commit, ''), @@ -1064,7 +1065,7 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) { &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, &t.PRInfoJSON, &t.DangerousMode, &t.PermissionMode, &t.RemoteControl, &t.Pinned, &t.Tags, - &t.SourceBranch, &t.Summary, &t.EffortLevel, + &t.SourceBranch, &t.Summary, &t.EffortLevel, &t.ModelProfile, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, &t.ArchiveRef, &t.ArchiveCommit, &t.ArchiveWorktreePath, &t.ArchiveBranchName, @@ -2007,7 +2008,7 @@ func (db *DB) GetStaleWorktreeTasks(maxAge time.Duration) ([]*Task, error) { COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''), COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(remote_control, 0), COALESCE(pinned, 0), COALESCE(tags, ''), - COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), + COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), COALESCE(model_profile, ''), created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, COALESCE(archive_ref, ''), COALESCE(archive_commit, ''), @@ -2034,7 +2035,7 @@ func (db *DB) GetStaleWorktreeTasks(maxAge time.Duration) ([]*Task, error) { &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, &t.PRInfoJSON, &t.DangerousMode, &t.PermissionMode, &t.RemoteControl, &t.Pinned, &t.Tags, - &t.SourceBranch, &t.Summary, &t.EffortLevel, + &t.SourceBranch, &t.Summary, &t.EffortLevel, &t.ModelProfile, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, &t.ArchiveRef, &t.ArchiveCommit, &t.ArchiveWorktreePath, &t.ArchiveBranchName, diff --git a/internal/executor/executor.go b/internal/executor/executor.go index e34d8e55..8c9e077e 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -3386,18 +3386,43 @@ func (e *Executor) pollTmuxSession(ctx context.Context, taskID int64, sessionNam // Window genuinely gone for missingThreshold consecutive checks — // check final status from hooks. finalTask, _ := e.db.GetTask(taskID) - if finalTask != nil { - if finalTask.Status == db.StatusDone { - return execResult{Success: true} - } - if finalTask.Status == db.StatusBacklog { - return execResult{Interrupted: true} - } - } - // Default: blocked (user must mark done or retry) - return execResult{NeedsInput: true, Message: "Task needs review"} + return windowDeathResult(finalTask) + } + } +} + +// windowDeathResult decides a task's outcome when its tmux window has genuinely +// disappeared (the executor process exited) without an explicit terminal status +// already recorded by hooks/MCP/the CLI. +// +// For most executors (Claude, Codex, …) a clean window death with no recorded +// status is ambiguous, so we surface it as NeedsInput and let a human review it. +// +// The Pi executor has no hooks and no MCP server: it self-reports its terminal +// state via the `task status` CLI as change #2 of board-completion parity. A +// hands-off Pi run by a "junior" local model may finish (or be killed) without +// ever calling the CLI, which would otherwise strand it in "needs input". So for +// Pi we treat a clean exit as agent-success — landing it in Backlog +// ("awaiting human review", exactly how Claude's agent-success is handled by the +// caller) instead of Blocked. The CLI self-report remains the primary done +// signal; this is the belt-and-suspenders backstop. +func windowDeathResult(finalTask *db.Task) execResult { + if finalTask != nil { + if finalTask.Status == db.StatusDone { + return execResult{Success: true} + } + if finalTask.Status == db.StatusBacklog { + return execResult{Interrupted: true} + } + // Pi backstop: a clean exit with no self-reported blocked state is treated + // as agent-success. If the agent DID self-report blocked via the CLI, fall + // through to NeedsInput so that explicit blocked state is preserved. + if finalTask.Executor == db.ExecutorPi && finalTask.Status != db.StatusBlocked { + return execResult{Success: true} } } + // Default: needs input (user must mark done or retry) + return execResult{NeedsInput: true, Message: "Task needs review"} } // ensureShellPane creates a shell pane alongside the Claude pane in the daemon window. @@ -5218,16 +5243,20 @@ func (e *Executor) runPi(ctx context.Context, task *db.Task, workDir, prompt str } } + // Model profile + completion self-reporting flags. piExtraFlags is the single + // source of truth shared with PiExecutor.BuildCommand so the daemon and TUI + // command builders stay in sync (see reference_executor_command_builder_divergence). + e.ensurePiModelProfile(task) + extraFlags := piExtraFlags(task) + // Check if session file exists at the explicit path to decide whether to resume var script string if piSessionExists(sessionPath) { e.logLine(task.ID, "system", fmt.Sprintf("Resuming existing session %s", filepath.Base(sessionPath))) - script = fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q pi --session %q --continue "$(cat %q)"`, - task.ID, sessionID, task.Port, task.WorktreePath, sessionPath, promptFile.Name()) + script = buildPiDaemonScript(task, sessionID, extraFlags, sessionPath, promptFile.Name(), true) } else { // Start fresh using the explicit session path - script = fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q pi --session %q "$(cat %q)"`, - task.ID, sessionID, task.Port, task.WorktreePath, sessionPath, promptFile.Name()) + script = buildPiDaemonScript(task, sessionID, extraFlags, sessionPath, promptFile.Name(), false) } // Create new window in task-daemon session (with retry logic for race conditions) @@ -5335,8 +5364,11 @@ func (e *Executor) runPiResume(ctx context.Context, task *db.Task, workDir, prom taskSessionID = fmt.Sprintf("%d", os.Getpid()) } - script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q pi --session %q --continue "$(cat %q)"`, - task.ID, taskSessionID, task.Port, task.WorktreePath, sessionPath, feedbackFile.Name()) + // Model profile + completion flags (kept in sync with runPi / BuildCommand). + e.ensurePiModelProfile(task) + extraFlags := piExtraFlags(task) + + script := buildPiDaemonScript(task, taskSessionID, extraFlags, sessionPath, feedbackFile.Name(), true) // Create new window in task-daemon session (with retry logic for race conditions) actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script, e.getProjectDir(task.Project)) @@ -5393,6 +5425,31 @@ func (e *Executor) ensurePiSessionDir(sessionPath string) error { return os.MkdirAll(filepath.Dir(sessionPath), 0755) } +// ensurePiModelProfile registers a custom OpenAI-compatible provider in Pi's +// models.json when the task's model profile points at a base_url (e.g. a local +// Ollama/vLLM server). It is a no-op for built-in providers (OpenRouter etc.) +// and for tasks without a profile. Errors are logged but non-fatal — Pi will +// surface a clearer error if the model truly can't be resolved. +func (e *Executor) ensurePiModelProfile(task *db.Task) { + if task == nil || task.ModelProfile == "" { + return + } + prof, err := GetPiModelProfile(task.ModelProfile) + if err != nil { + e.logLine(task.ID, "system", fmt.Sprintf("Model profile %q: %s", task.ModelProfile, err.Error())) + return + } + if prof == nil { + e.logLine(task.ID, "system", fmt.Sprintf("Model profile %q not found; using Pi default model", task.ModelProfile)) + return + } + if err := EnsurePiCustomProvider(piModelsJSONPath(), prof); err != nil { + e.logLine(task.ID, "system", fmt.Sprintf("Model profile %q: failed to register provider: %s", task.ModelProfile, err.Error())) + return + } + e.logLine(task.ID, "system", fmt.Sprintf("Using model profile %q (provider=%s model=%s)", prof.Name, prof.Provider, prof.Model)) +} + // getPiPID finds the PID of the Pi process for a task. func (e *Executor) getPiPID(taskID int64) int { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) diff --git a/internal/executor/pi_executor.go b/internal/executor/pi_executor.go index bd2b280e..a2a84e84 100644 --- a/internal/executor/pi_executor.go +++ b/internal/executor/pi_executor.go @@ -91,10 +91,23 @@ func (p *PiExecutor) BuildCommand(task *db.Task, sessionID, prompt string) strin // Ensure session directory exists (for manual runs via BuildCommand) os.MkdirAll(filepath.Dir(sessionPath), 0755) + // Model profile + completion self-reporting flags. piExtraFlags is shared with + // the daemon path (runPi/runPiResume) so both command builders stay in sync — + // see reference_executor_command_builder_divergence. For custom (base_url) + // providers, also register them in Pi's models.json before launch. + if task.ModelProfile != "" { + if prof, err := GetPiModelProfile(task.ModelProfile); err == nil && prof != nil { + if perr := EnsurePiCustomProvider(piModelsJSONPath(), prof); perr != nil { + p.logger.Warn("BuildCommand: failed to register pi provider", "profile", task.ModelProfile, "error", perr) + } + } + } + extraFlags := piExtraFlags(task) + // Build command - resume if we have a session ID, otherwise start fresh if sessionID != "" { - return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q pi --session %q --continue`, - task.ID, worktreeSessionID, task.Port, task.WorktreePath, sessionPath) + return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q pi%s --session %q --continue`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, extraFlags, sessionPath) } // Start fresh - if prompt is provided, write to temp file and pass it @@ -103,18 +116,18 @@ func (p *PiExecutor) BuildCommand(task *db.Task, sessionID, prompt string) strin promptFile, err := os.CreateTemp("", "task-prompt-*.txt") if err != nil { p.logger.Error("BuildCommand: failed to create temp file", "error", err) - return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q pi --session %q`, - task.ID, worktreeSessionID, task.Port, task.WorktreePath, sessionPath) + return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q pi%s --session %q`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, extraFlags, sessionPath) } promptFile.WriteString(prompt) promptFile.Close() - return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q pi --session %q "$(cat %q)"; rm -f %q`, - task.ID, worktreeSessionID, task.Port, task.WorktreePath, sessionPath, promptFile.Name(), promptFile.Name()) + return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q pi%s --session %q "$(cat %q)"; rm -f %q`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, extraFlags, sessionPath, promptFile.Name(), promptFile.Name()) } - return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q pi --session %q`, - task.ID, worktreeSessionID, task.Port, task.WorktreePath, sessionPath) + return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q pi%s --session %q`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, extraFlags, sessionPath) } // ---- Session and Dangerous Mode Support ---- diff --git a/internal/executor/pi_profiles.go b/internal/executor/pi_profiles.go new file mode 100644 index 00000000..3d3ef5a4 --- /dev/null +++ b/internal/executor/pi_profiles.go @@ -0,0 +1,297 @@ +package executor + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/bborn/workflow/internal/db" +) + +// PiModelProfile describes a named provider+model selection for the Pi executor. +// +// Two flavours are supported, distinguished by BaseURL: +// +// - Built-in provider (BaseURL == ""): the provider/model already exist in Pi's +// bundled model registry (e.g. provider "openrouter", model "qwen/qwen3-coder"). +// Selection is just `--provider
--model --model