From de073f255d611a7cc8b7ba83c274a8e81220371e Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 23 Jun 2026 08:14:53 -0500 Subject: [PATCH] =?UTF-8?q?feat(executor):=20Pi=20local-model=20executor?= =?UTF-8?q?=20=E2=80=94=20model=20profiles=20+=20board-completion=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the Pi executor usable as a real local/hosted coding-model executor with Kanban-board parity: a Pi task selects its model AND reports completion back to the board hands-off, the way Claude tasks do. Three additive changes to the Pi path only (Claude path untouched): 1. Model profiles (internal/executor/pi_profiles.go) - Named provider+model(+base_url+api_key_env) config at ~/.config/taskyou/pi-models.json, selected per-task via a new `model_profile` column (CLI: `task create --model-profile `). - Built-in providers (OpenRouter etc.) -> just `--provider/--model`; key resolved by Pi from its conventional env var, never on the command line. - Custom OpenAI-compatible endpoints (Ollama/vLLM/LM Studio) are registered in Pi's models.json (EnsurePiCustomProvider); schema verified against Pi's actual typebox validator. - provider/model validated to a safe token charset (no shell injection). 2. Completion signalling (primary): every Pi task gets a completion system prompt via --append-system-prompt instructing the agent to run `task status $WORKTREE_TASK_ID done|blocked` through its bash tool — rides the existing CLI -> db.UpdateTaskStatus path, no MCP server needed. 3. Completion backstop: windowDeathResult() now treats a clean Pi process exit (no self-report) as agent-success -> Backlog/awaiting-review instead of stranding it in NeedsInput. Claude path behavior is unchanged; a Pi task that self-reported `blocked` keeps that state. Sync hazard: the Pi command is built in two places (daemon runPi/runPiResume and TUI PiExecutor.BuildCommand). Both now derive their flag segment from the single shared piExtraFlags()/buildPiDaemonScript() helpers, with TestPiCommandBuildersStayInSync guarding against divergence. Follow-ups (not in scope): --mode rpc live processing/blocked status streaming; the same treatment for the OpenCode executor; a TUI profile-picker UI. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/task/main.go | 6 + internal/db/sqlite.go | 2 + internal/db/tasks.go | 39 ++-- internal/executor/executor.go | 89 ++++++-- internal/executor/pi_executor.go | 29 ++- internal/executor/pi_profiles.go | 297 +++++++++++++++++++++++++ internal/executor/pi_profiles_test.go | 303 ++++++++++++++++++++++++++ 7 files changed, 722 insertions(+), 43 deletions(-) create mode 100644 internal/executor/pi_profiles.go create mode 100644 internal/executor/pi_profiles_test.go 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 `; the API key is resolved by Pi +// from the provider's conventional env var (e.g. OPENROUTER_API_KEY). +// +// - Custom OpenAI-compatible endpoint (BaseURL != ""): a local or self-hosted +// server (Ollama, vLLM, LM Studio, …). Pi has no CLI flag for a base URL, so we +// register the provider in Pi's models.json (see EnsurePiCustomProvider) and then +// select it with the same `--provider/--model` flags. +// +// The API key never appears on the command line. For custom endpoints it is written +// into models.json as the *name* of an env var (APIKeyEnv), which Pi resolves at +// runtime; the env var must therefore be present in the daemon's environment. +type PiModelProfile struct { + Name string `json:"name"` + Provider string `json:"provider"` // Pi provider id, e.g. "openrouter", "ollama" + Model string `json:"model"` // Model id, e.g. "qwen/qwen3-coder" + BaseURL string `json:"base_url,omitempty"` // OpenAI-compatible endpoint for custom providers ("" = built-in) + APIKeyEnv string `json:"api_key_env,omitempty"` // Name of env var holding the API key (custom providers) + API string `json:"api,omitempty"` // Pi API protocol (default "openai-completions") + + // Optional model metadata required by Pi's models.json schema for custom providers. + ContextWindow int `json:"context_window,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Reasoning bool `json:"reasoning,omitempty"` +} + +// piModelProfilesFile is the on-disk shape of the model-profile config. +type piModelProfilesFile struct { + Profiles map[string]PiModelProfile `json:"profiles"` +} + +// safeProviderModelToken guards values that are injected unquoted into the Pi +// command line. Provider/model ids are simple tokens (alnum plus a few path-ish +// separators); anything else is rejected so a profile can never inject shell. +var safeProviderModelToken = regexp.MustCompile(`^[A-Za-z0-9._:/@-]+$`) + +// PiModelProfilesPath returns the path to the model-profile config file. +// Override with TASKYOU_PI_MODELS (used by tests); defaults to +// ~/.config/taskyou/pi-models.json. +func PiModelProfilesPath() string { + if p := os.Getenv("TASKYOU_PI_MODELS"); p != "" { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "taskyou", "pi-models.json") +} + +// loadPiModelProfiles reads all profiles from the given path. A missing file is +// not an error (returns an empty map) so the feature is purely opt-in. +func loadPiModelProfiles(path string) (map[string]PiModelProfile, error) { + if path == "" { + return map[string]PiModelProfile{}, nil + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]PiModelProfile{}, nil + } + return nil, fmt.Errorf("read pi model profiles: %w", err) + } + var file piModelProfilesFile + if err := json.Unmarshal(data, &file); err != nil { + return nil, fmt.Errorf("parse pi model profiles %s: %w", path, err) + } + if file.Profiles == nil { + file.Profiles = map[string]PiModelProfile{} + } + // Backfill the Name field from the map key so callers always have it. + for name, p := range file.Profiles { + p.Name = name + file.Profiles[name] = p + } + return file.Profiles, nil +} + +// GetPiModelProfile resolves a profile by name from the default config path. +// Returns nil (no error) when name is empty or the profile is not found, so a +// task with an unknown/empty profile simply falls back to Pi's default model. +func GetPiModelProfile(name string) (*PiModelProfile, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, nil + } + profiles, err := loadPiModelProfiles(PiModelProfilesPath()) + if err != nil { + return nil, err + } + p, ok := profiles[name] + if !ok { + return nil, nil + } + return &p, nil +} + +// apiProtocol returns the Pi API protocol for this profile (defaulting to the +// OpenAI Chat Completions protocol used by OpenRouter/Ollama/most local servers). +func (p *PiModelProfile) apiProtocol() string { + if p.API != "" { + return p.API + } + return "openai-completions" +} + +// commandFlags returns the `--provider

--model ` segment (with a leading +// space) to inject into a Pi invocation, or "" if the profile is nil/incomplete. +// Provider/model are validated to a safe token charset so they can be injected +// unquoted without risk of shell injection. +func (p *PiModelProfile) commandFlags() string { + if p == nil || p.Provider == "" || p.Model == "" { + return "" + } + if !safeProviderModelToken.MatchString(p.Provider) || !safeProviderModelToken.MatchString(p.Model) { + return "" + } + return fmt.Sprintf(" --provider %s --model %s", p.Provider, p.Model) +} + +// EnsurePiCustomProvider registers a custom OpenAI-compatible provider in Pi's +// models.json so `--provider/--model` can select it. It is a no-op for built-in +// providers (BaseURL == ""). Existing providers in the file are preserved; only +// this profile's provider entry is upserted. The write is atomic (temp + rename). +func EnsurePiCustomProvider(modelsPath string, p *PiModelProfile) error { + if p == nil || p.BaseURL == "" { + return nil + } + if modelsPath == "" { + return fmt.Errorf("pi models.json path is empty") + } + + // Read existing config (tolerate a missing file). + cfg := map[string]any{} + if data, err := os.ReadFile(modelsPath); err == nil { + if err := json.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("parse existing %s: %w", modelsPath, err) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("read %s: %w", modelsPath, err) + } + + providers, _ := cfg["providers"].(map[string]any) + if providers == nil { + providers = map[string]any{} + } + + contextWindow := p.ContextWindow + if contextWindow <= 0 { + contextWindow = 32768 + } + maxTokens := p.MaxTokens + if maxTokens <= 0 { + maxTokens = 8192 + } + apiKey := p.APIKeyEnv + if apiKey == "" { + // Pi requires an apiKey field; local servers (Ollama) ignore it. A literal + // placeholder is fine — resolveConfigValue treats an unknown string as a literal. + apiKey = "local" + } + + providers[p.Provider] = map[string]any{ + "baseUrl": p.BaseURL, + "apiKey": apiKey, + "api": p.apiProtocol(), + "models": []any{ + map[string]any{ + "id": p.Model, + "name": p.Model, + "api": p.apiProtocol(), + "reasoning": p.Reasoning, + "input": []any{"text"}, + "cost": map[string]any{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}, + "contextWindow": contextWindow, + "maxTokens": maxTokens, + }, + }, + } + cfg["providers"] = providers + + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("marshal models.json: %w", err) + } + if err := os.MkdirAll(filepath.Dir(modelsPath), 0o755); err != nil { + return fmt.Errorf("mkdir for models.json: %w", err) + } + tmp := modelsPath + ".tmp" + if err := os.WriteFile(tmp, out, 0o644); err != nil { + return fmt.Errorf("write models.json: %w", err) + } + if err := os.Rename(tmp, modelsPath); err != nil { + return fmt.Errorf("rename models.json: %w", err) + } + return nil +} + +// piAgentDir resolves Pi's config directory the same way the Pi CLI does: +// honoring PI_CODING_AGENT_DIR, otherwise ~/.pi/agent. +func piAgentDir() string { + if d := os.Getenv("PI_CODING_AGENT_DIR"); d != "" { + if d == "~" { + if home, err := os.UserHomeDir(); err == nil { + return home + } + } + if strings.HasPrefix(d, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, d[2:]) + } + } + return d + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".pi", "agent") +} + +// piModelsJSONPath returns the path to Pi's models.json (agentDir/models.json). +func piModelsJSONPath() string { + dir := piAgentDir() + if dir == "" { + return "" + } + return filepath.Join(dir, "models.json") +} + +// piCompletionPrompt is appended to every Pi task's system prompt (via +// --append-system-prompt) so the agent self-reports its terminal state to the +// TaskYou board. It rides Pi's built-in `bash` tool and the `task` CLI, which +// calls the same db.UpdateTaskStatus path as the MCP completion tool — no MCP +// server required. Single-line and free of single quotes so it can be wrapped in +// shell single quotes; $WORKTREE_TASK_ID is left literal for the agent's shell to +// expand at run time (the var is exported into the pane). +const piCompletionPrompt = "TaskYou board integration: you are running as a task on a Kanban board. " + + "When you have fully completed the task, use your bash tool to run `task status $WORKTREE_TASK_ID done` to mark it done. " + + "If you become blocked or need input from the user before you can continue, run `task status $WORKTREE_TASK_ID blocked` instead. " + + "Always run exactly one of these commands before you finish the session." + +// piExtraFlags returns the flag segment (leading space) injected into EVERY Pi +// invocation — both the daemon path (runPi/runPiResume) and the TUI path +// (PiExecutor.BuildCommand). Keeping a single source of truth is what guarantees +// the two command-builders stay in sync (see reference_executor_command_builder_divergence). +// +// It always appends the completion system prompt, and prepends model-selection +// flags when the task names a resolvable model profile. +func piExtraFlags(task *db.Task) string { + var b strings.Builder + if task != nil { + if prof, err := GetPiModelProfile(task.ModelProfile); err == nil && prof != nil { + b.WriteString(prof.commandFlags()) + } + } + b.WriteString(" --append-system-prompt '") + b.WriteString(piCompletionPrompt) + b.WriteString("'") + return b.String() +} + +// buildPiDaemonScript constructs the shell command the daemon runs in a tmux +// window for a Pi task. It is the single builder behind both runPi (fresh and +// resume-existing) and runPiResume, so the daemon's command string is built in +// exactly one place. extraFlags must come from piExtraFlags(task) so the daemon +// and TUI (PiExecutor.BuildCommand) inject identical model/completion flags. +// +// inputFile holds the prompt (fresh) or feedback (resume); it is cat'd in so the +// shell, not Pi's argv, carries the (possibly large/multiline) text. resume adds +// Pi's --continue flag to attach to the existing --session file. +func buildPiDaemonScript(task *db.Task, sessionID, extraFlags, sessionPath, inputFile string, resume bool) string { + cont := "" + if resume { + cont = "--continue " + } + return fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q pi%s --session %q %s"$(cat %q)"`, + task.ID, sessionID, task.Port, task.WorktreePath, extraFlags, sessionPath, cont, inputFile) +} diff --git a/internal/executor/pi_profiles_test.go b/internal/executor/pi_profiles_test.go new file mode 100644 index 00000000..099f7378 --- /dev/null +++ b/internal/executor/pi_profiles_test.go @@ -0,0 +1,303 @@ +package executor + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/bborn/workflow/internal/config" + "github.com/bborn/workflow/internal/db" +) + +// writeProfiles writes a model-profile config to a temp file and points +// TASKYOU_PI_MODELS at it for the duration of the test. +func writeProfiles(t *testing.T, profiles map[string]PiModelProfile) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "pi-models.json") + data, err := json.Marshal(piModelProfilesFile{Profiles: profiles}) + if err != nil { + t.Fatalf("marshal profiles: %v", err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("write profiles: %v", err) + } + t.Setenv("TASKYOU_PI_MODELS", path) + return path +} + +func TestLoadAndGetPiModelProfile(t *testing.T) { + writeProfiles(t, map[string]PiModelProfile{ + "qwen-openrouter": {Provider: "openrouter", Model: "qwen/qwen3-coder", APIKeyEnv: "OPENROUTER_API_KEY"}, + "qwen-local": {Provider: "ollama", Model: "qwen2.5-coder", BaseURL: "http://localhost:11434/v1"}, + }) + + prof, err := GetPiModelProfile("qwen-openrouter") + if err != nil { + t.Fatalf("GetPiModelProfile: %v", err) + } + if prof == nil { + t.Fatal("expected profile, got nil") + } + if prof.Name != "qwen-openrouter" { + t.Errorf("expected Name backfilled to map key, got %q", prof.Name) + } + if prof.Provider != "openrouter" || prof.Model != "qwen/qwen3-coder" { + t.Errorf("unexpected profile: %+v", prof) + } + + // Unknown profile -> nil, no error (graceful fallback to Pi default). + missing, err := GetPiModelProfile("does-not-exist") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if missing != nil { + t.Errorf("expected nil for unknown profile, got %+v", missing) + } + + // Empty name -> nil, no error. + empty, err := GetPiModelProfile("") + if err != nil || empty != nil { + t.Errorf("expected (nil,nil) for empty name, got (%+v,%v)", empty, err) + } +} + +func TestGetPiModelProfile_MissingFile(t *testing.T) { + // Point at a path that does not exist: should be a no-op, not an error. + t.Setenv("TASKYOU_PI_MODELS", filepath.Join(t.TempDir(), "nope.json")) + prof, err := GetPiModelProfile("anything") + if err != nil { + t.Fatalf("missing file should not error: %v", err) + } + if prof != nil { + t.Errorf("expected nil profile, got %+v", prof) + } +} + +func TestPiModelProfile_CommandFlags(t *testing.T) { + cases := []struct { + name string + p *PiModelProfile + want string + }{ + {"nil", nil, ""}, + {"empty", &PiModelProfile{}, ""}, + {"missing model", &PiModelProfile{Provider: "openrouter"}, ""}, + {"built-in", &PiModelProfile{Provider: "openrouter", Model: "qwen/qwen3-coder"}, " --provider openrouter --model qwen/qwen3-coder"}, + {"local", &PiModelProfile{Provider: "ollama", Model: "qwen2.5-coder", BaseURL: "http://x"}, " --provider ollama --model qwen2.5-coder"}, + // A provider/model with shell metacharacters is rejected (no injection). + {"unsafe provider", &PiModelProfile{Provider: "ev;il", Model: "m"}, ""}, + {"unsafe model", &PiModelProfile{Provider: "p", Model: "a b"}, ""}, + {"unsafe quote", &PiModelProfile{Provider: "p", Model: "a'b"}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.p.commandFlags(); got != tc.want { + t.Errorf("commandFlags() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestPiExtraFlags_NoProfile(t *testing.T) { + t.Setenv("TASKYOU_PI_MODELS", filepath.Join(t.TempDir(), "none.json")) + task := &db.Task{ID: 1} + got := piExtraFlags(task) + + // No model flags, but the completion system prompt is always appended. + if strings.Contains(got, "--provider") { + t.Errorf("expected no --provider for profileless task, got %q", got) + } + if !strings.HasPrefix(got, " --append-system-prompt '") || !strings.HasSuffix(got, "'") { + t.Errorf("expected single-quoted append-system-prompt, got %q", got) + } + // Correct CLI arg order: `task status ` (id BEFORE status). + if !strings.Contains(got, "task status $WORKTREE_TASK_ID done") { + t.Errorf("completion prompt missing/incorrect done command: %q", got) + } + if !strings.Contains(got, "task status $WORKTREE_TASK_ID blocked") { + t.Errorf("completion prompt missing/incorrect blocked command: %q", got) + } +} + +func TestPiExtraFlags_WithProfile(t *testing.T) { + writeProfiles(t, map[string]PiModelProfile{ + "qwen-openrouter": {Provider: "openrouter", Model: "qwen/qwen3-coder", APIKeyEnv: "OPENROUTER_API_KEY"}, + }) + task := &db.Task{ID: 1, Executor: db.ExecutorPi, ModelProfile: "qwen-openrouter"} + got := piExtraFlags(task) + + wantPrefix := " --provider openrouter --model qwen/qwen3-coder --append-system-prompt '" + if !strings.HasPrefix(got, wantPrefix) { + t.Errorf("piExtraFlags() = %q, want prefix %q", got, wantPrefix) + } + if !strings.HasSuffix(got, "'") { + t.Errorf("piExtraFlags() should end with closing quote, got %q", got) + } + // The API key must never appear on the command line. + if strings.Contains(got, "OPENROUTER_API_KEY") || strings.Contains(got, "--api-key") { + t.Errorf("API key/env leaked into command line: %q", got) + } +} + +func TestBuildPiDaemonScript(t *testing.T) { + task := &db.Task{ID: 42, WorktreePath: "/tmp/wt", Port: 3100} + extra := " --provider openrouter --model qwen/qwen3-coder --append-system-prompt 'go'" + + fresh := buildPiDaemonScript(task, "sess-1", extra, "/tmp/s/task-42.jsonl", "/tmp/prompt.txt", false) + wantFresh := `WORKTREE_TASK_ID=42 WORKTREE_SESSION_ID=sess-1 WORKTREE_PORT=3100 WORKTREE_PATH="/tmp/wt" pi --provider openrouter --model qwen/qwen3-coder --append-system-prompt 'go' --session "/tmp/s/task-42.jsonl" "$(cat "/tmp/prompt.txt")"` + if fresh != wantFresh { + t.Errorf("fresh script mismatch:\n got: %s\nwant: %s", fresh, wantFresh) + } + + resume := buildPiDaemonScript(task, "sess-1", extra, "/tmp/s/task-42.jsonl", "/tmp/fb.txt", true) + wantResume := `WORKTREE_TASK_ID=42 WORKTREE_SESSION_ID=sess-1 WORKTREE_PORT=3100 WORKTREE_PATH="/tmp/wt" pi --provider openrouter --model qwen/qwen3-coder --append-system-prompt 'go' --session "/tmp/s/task-42.jsonl" --continue "$(cat "/tmp/fb.txt")"` + if resume != wantResume { + t.Errorf("resume script mismatch:\n got: %s\nwant: %s", resume, wantResume) + } +} + +// TestPiCommandBuildersStayInSync is the guard against the two-command-builder +// divergence bug: the daemon (buildPiDaemonScript, used by runPi/runPiResume) and +// the TUI (PiExecutor.BuildCommand) must inject the IDENTICAL model+completion +// flag segment for a given task. Both derive it from piExtraFlags(task). +func TestPiCommandBuildersStayInSync(t *testing.T) { + writeProfiles(t, map[string]PiModelProfile{ + "qwen-openrouter": {Provider: "openrouter", Model: "qwen/qwen3-coder", APIKeyEnv: "OPENROUTER_API_KEY"}, + }) + + tmpDB, err := os.CreateTemp("", "sync-*.db") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpDB.Name()) + tmpDB.Close() + database, err := db.Open(tmpDB.Name()) + if err != nil { + t.Fatal(err) + } + defer database.Close() + exec := New(database, &config.Config{}) + piExec := exec.GetExecutor("pi") + + task := &db.Task{ID: 7, WorktreePath: "/tmp/wt", Port: 3107, Executor: db.ExecutorPi, ModelProfile: "qwen-openrouter"} + + extra := piExtraFlags(task) + + // Daemon side. + daemon := buildPiDaemonScript(task, "s", extra, "/tmp/sess.jsonl", "/tmp/p.txt", false) + if !strings.Contains(daemon, "pi"+extra+" --session") { + t.Errorf("daemon script does not embed the shared flag segment %q:\n%s", extra, daemon) + } + + // TUI side (no session ID = fresh, no prompt = bare launch). + tui := piExec.BuildCommand(task, "", "") + if !strings.Contains(tui, "pi"+extra+" --session") { + t.Errorf("TUI BuildCommand does not embed the shared flag segment %q:\n%s", extra, tui) + } +} + +func TestEnsurePiCustomProvider(t *testing.T) { + dir := t.TempDir() + modelsPath := filepath.Join(dir, "models.json") + + // Seed with an unrelated provider that must be preserved. + seed := `{"providers":{"keepme":{"baseUrl":"http://keep","apiKey":"K","api":"openai-completions"}}}` + if err := os.WriteFile(modelsPath, []byte(seed), 0o644); err != nil { + t.Fatal(err) + } + + prof := &PiModelProfile{ + Name: "qwen-local", + Provider: "ollama", + Model: "qwen2.5-coder", + BaseURL: "http://localhost:11434/v1", + APIKeyEnv: "OLLAMA_API_KEY", + } + if err := EnsurePiCustomProvider(modelsPath, prof); err != nil { + t.Fatalf("EnsurePiCustomProvider: %v", err) + } + + data, _ := os.ReadFile(modelsPath) + var cfg struct { + Providers map[string]struct { + BaseURL string `json:"baseUrl"` + APIKey string `json:"apiKey"` + API string `json:"api"` + Models []struct { + ID string `json:"id"` + ContextWindow int `json:"contextWindow"` + MaxTokens int `json:"maxTokens"` + } `json:"models"` + } `json:"providers"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("parse result: %v\n%s", err, data) + } + + if _, ok := cfg.Providers["keepme"]; !ok { + t.Error("existing provider 'keepme' was not preserved") + } + ollama, ok := cfg.Providers["ollama"] + if !ok { + t.Fatal("ollama provider not written") + } + if ollama.BaseURL != "http://localhost:11434/v1" { + t.Errorf("baseUrl = %q", ollama.BaseURL) + } + if ollama.APIKey != "OLLAMA_API_KEY" { + t.Errorf("apiKey should be the env var name, got %q", ollama.APIKey) + } + if len(ollama.Models) != 1 || ollama.Models[0].ID != "qwen2.5-coder" { + t.Errorf("unexpected models: %+v", ollama.Models) + } + if ollama.Models[0].ContextWindow <= 0 || ollama.Models[0].MaxTokens <= 0 { + t.Errorf("expected defaulted contextWindow/maxTokens, got %+v", ollama.Models[0]) + } + + // Idempotent: a second call must not error or duplicate. + if err := EnsurePiCustomProvider(modelsPath, prof); err != nil { + t.Fatalf("second EnsurePiCustomProvider: %v", err) + } +} + +func TestEnsurePiCustomProvider_BuiltinIsNoOp(t *testing.T) { + dir := t.TempDir() + modelsPath := filepath.Join(dir, "models.json") + // Built-in provider (no BaseURL) must not create a models.json. + prof := &PiModelProfile{Provider: "openrouter", Model: "qwen/qwen3-coder", APIKeyEnv: "OPENROUTER_API_KEY"} + if err := EnsurePiCustomProvider(modelsPath, prof); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(modelsPath); !os.IsNotExist(err) { + t.Error("built-in provider should not have written models.json") + } +} + +func TestWindowDeathResult(t *testing.T) { + cases := []struct { + name string + task *db.Task + want execResult + }{ + {"nil -> needs input", nil, execResult{NeedsInput: true, Message: "Task needs review"}}, + {"done -> success", &db.Task{Status: db.StatusDone, Executor: db.ExecutorClaude}, execResult{Success: true}}, + {"backlog -> interrupted", &db.Task{Status: db.StatusBacklog, Executor: db.ExecutorClaude}, execResult{Interrupted: true}}, + // Claude clean exit with no recorded status stays ambiguous (unchanged behavior). + {"claude processing -> needs input", &db.Task{Status: db.StatusProcessing, Executor: db.ExecutorClaude}, execResult{NeedsInput: true, Message: "Task needs review"}}, + // Pi clean exit with no self-report -> backstop to success (awaiting review). + {"pi processing -> success", &db.Task{Status: db.StatusProcessing, Executor: db.ExecutorPi}, execResult{Success: true}}, + // Pi that self-reported blocked via the CLI keeps its blocked state. + {"pi blocked -> needs input (preserve blocked)", &db.Task{Status: db.StatusBlocked, Executor: db.ExecutorPi}, execResult{NeedsInput: true, Message: "Task needs review"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := windowDeathResult(tc.task) + if got != tc.want { + t.Errorf("windowDeathResult() = %+v, want %+v", got, tc.want) + } + }) + } +}