Add task sidebar workflow

This commit is contained in:
2026-06-24 12:28:56 +01:00
parent 45263d59f8
commit d133839194
33 changed files with 1890 additions and 134 deletions
+132 -26
View File
@@ -2,6 +2,7 @@ package app
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"sync"
@@ -12,6 +13,7 @@ import (
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/task"
"github.com/hjbdev/patterm/internal/trust"
pkgvt "github.com/hjbdev/patterm/internal/vt"
)
@@ -41,11 +43,16 @@ type scratchpadSink interface {
scratchpadsChanged()
}
type taskSink interface {
tasksChanged()
}
// toolHost adapts the running session + scratchpad store + trust store
// to the MCP ToolHost interface. SPEC §7 tools route through here.
type toolHost struct {
sess *Session
pads *scratchpad.Store
tasks *task.Store
launcher *Launcher
presets preset.Set
trust *trust.Store
@@ -61,6 +68,7 @@ type toolHost struct {
focus focusSink
prompter trustPrompter
scratch scratchpadSink
taskUI taskSink
timers *timerManager
}
@@ -76,10 +84,11 @@ const (
maxSearchMatches = 50
)
func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost {
func newToolHost(sess *Session, pads *scratchpad.Store, tasks *task.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost {
h := &toolHost{
sess: sess,
pads: pads,
tasks: tasks,
launcher: launcher,
presets: presets,
trust: tr,
@@ -157,6 +166,21 @@ func (h *toolHost) CallerRole(processID string) mcp.CallerRole {
return mcp.RoleSubAgent
}
func (h *toolHost) CallerTask(processID string) (mcp.TaskInfo, bool) {
if h == nil || h.sess == nil || h.tasks == nil || processID == "" {
return mcp.TaskInfo{}, false
}
c := h.sess.FindChild(processID)
if c == nil || c.TaskID == "" {
return mcp.TaskInfo{}, false
}
t, ok := h.tasks.Get(c.TaskID)
if !ok {
return mcp.TaskInfo{}, false
}
return taskInfoOf(t), true
}
// ───────────────────────────────────────────────────────────────────
// Lifecycle
// ───────────────────────────────────────────────────────────────────
@@ -176,8 +200,14 @@ func (h *toolHost) SpawnAgent(callerID string, args mcp.SpawnAgentArgs) (mcp.Pro
if display == "" {
display = args.Agent
}
prompt := wrapSubAgentPrompt(args.AgentInstructions, h.sess.FindChild(callerID) != nil)
c, err := h.launcher.LaunchAgent(p, display, prompt, callerID)
var taskInfo *mcp.TaskInfo
ctx := LaunchContext{ParentID: callerID}
if ti, ok := h.CallerTask(callerID); ok {
ctx.TaskID = ti.ID
taskInfo = &ti
}
prompt := buildAgentPrompt(args.AgentInstructions, h.sess.FindChild(callerID) != nil, taskInfo)
c, err := h.launcher.LaunchAgent(p, display, prompt, ctx)
if err != nil {
return mcp.ProcessInfo{}, err
}
@@ -193,8 +223,12 @@ func (h *toolHost) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindInvalidKind, "spawn_process: kind must be 'command' or 'terminal'")
}
env := h.mergeEnv(args.Env)
ctx := LaunchContext{ParentID: callerID, WorkDir: args.WorkingDir}
if ti, ok := h.CallerTask(callerID); ok {
ctx.TaskID = ti.ID
}
if args.Kind == "terminal" {
c, err := h.launcher.LaunchTerminal(args.Argv, h.terminalName(args.Name), callerID, args.WorkingDir, env)
c, err := h.launcher.LaunchTerminal(args.Argv, h.terminalName(args.Name), ctx, env)
if err != nil {
return mcp.ProcessInfo{}, err
}
@@ -215,7 +249,7 @@ func (h *toolHost) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp
if display == "" {
display = ps.Name
}
c, err := h.launcher.LaunchCommandPreset(ps, display, callerID)
c, err := h.launcher.LaunchCommandPreset(ps, display, ctx)
if err != nil {
return mcp.ProcessInfo{}, err
}
@@ -229,7 +263,7 @@ func (h *toolHost) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp
if display == "" {
display = args.Argv[0]
}
c, err := h.launcher.LaunchCommandArgv(args.Argv, display, callerID, args.WorkingDir, env, args.Shell)
c, err := h.launcher.LaunchCommandArgv(args.Argv, display, ctx, env, args.Shell)
if err != nil {
return mcp.ProcessInfo{}, err
}
@@ -368,12 +402,16 @@ func (h *toolHost) GetProjectStatus(callerID string, includeTools bool) (mcp.Pro
caller := h.WhoAmI(callerID, includeTools)
processes := h.ListProcesses(callerID, "")
pads, _ := h.pads.List()
return mcp.ProjectStatus{
status := mcp.ProjectStatus{
Project: caller.Project,
Caller: caller,
Processes: processes,
Scratchpads: pads,
}, nil
}
if caller.Task != nil {
status.Task = caller.Task
}
return status, nil
}
func (h *toolHost) GetProcessOutput(callerID string, args mcp.ProcessOutputArgs) (mcp.ProcessOutput, error) {
@@ -925,17 +963,59 @@ func (h *toolHost) ScratchpadDelete(name string) error {
return err
}
func (h *toolHost) RegisterTaskWorktree(callerID string, args mcp.TaskRegisterWorktreeArgs) (mcp.TaskInfo, error) {
current, ok := h.CallerTask(callerID)
if !ok {
return mcp.TaskInfo{}, mcp.Errorf(mcp.ErrorKindRoleForbidden, "task_register_worktree: caller is not attached to a task")
}
if h.tasks == nil {
return mcp.TaskInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "task_register_worktree: task store unavailable")
}
path := strings.TrimSpace(args.Path)
if path == "" {
return mcp.TaskInfo{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "task_register_worktree: path required")
}
if !filepath.IsAbs(path) {
base := h.sess.projectDir
if c := h.sess.FindChild(callerID); c != nil && c.WorkDir != "" {
base = c.WorkDir
}
path = filepath.Join(base, path)
}
abs, err := filepath.Abs(path)
if err != nil {
return mcp.TaskInfo{}, err
}
updated, err := h.tasks.RegisterWorktree(current.ID, task.Worktree{
Path: abs,
Branch: args.Branch,
CreatedByProcessID: callerID,
})
if err != nil {
return mcp.TaskInfo{}, err
}
if h.taskUI != nil {
h.taskUI.tasksChanged()
}
return taskInfoOf(updated), nil
}
func (h *toolHost) WhoAmI(callerID string, includeTools bool) mcp.WhoAmI {
role := h.CallerRole(callerID)
taskInfo, taskBound := h.CallerTask(callerID)
w := mcp.WhoAmI{
ProcessID: callerID,
Role: h.CallerRole(callerID),
Role: role,
Project: mcp.ProjectMeta{
Path: h.sess.projectDir,
Key: h.sess.projectKey,
},
}
if taskBound {
w.Task = &taskInfo
}
if includeTools {
w.AvailableTools = availableToolsForRole(h.CallerRole(callerID))
w.AvailableTools = availableToolsForRole(role, taskBound)
}
if c := h.sess.FindChild(callerID); c != nil {
w.Name = c.DisplayName()
@@ -976,6 +1056,27 @@ func (h *toolHost) processInfoOf(c *Child) mcp.ProcessInfo {
return info
}
func taskInfoOf(t task.Task) mcp.TaskInfo {
out := mcp.TaskInfo{
ID: t.ID,
Title: t.Title,
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
}
if len(t.Worktrees) > 0 {
out.Worktrees = make([]mcp.TaskWorktree, 0, len(t.Worktrees))
for _, wt := range t.Worktrees {
out.Worktrees = append(out.Worktrees, mcp.TaskWorktree{
Path: wt.Path,
Branch: wt.Branch,
CreatedByProcessID: wt.CreatedByProcessID,
RegisteredAt: wt.RegisteredAt,
})
}
}
return out
}
func (h *toolHost) chromeHintsFor(presetName string) []string {
if presetName == "" {
return nil
@@ -1034,23 +1135,25 @@ func (h *toolHost) askForTrust(callerID, presetName, reason string) {
h.prompter.promptTrust(callerID, presetName, reason)
}
// wrapSubAgentPrompt prepends a one-line orientation block to the
// caller-supplied agent_instructions. patterm injects nothing on its
// own (SPEC §7), but vendor TUIs that learn their role purely from
// their first turn need to be told they're a sub-agent — otherwise
// they finish without reporting back to the parent or cleaning up
// processes/scratchpads they spawned. The block is single-line on
// purpose: writeInput splits on CR/LF, so any embedded newline would
// submit prematurely.
func wrapSubAgentPrompt(instructions string, hasParent bool) string {
if !hasParent {
return instructions
// buildAgentPrompt prepends one-line orientation blocks to the initial
// prompt. The blocks are single-line on purpose: writeInput splits on
// CR/LF, so any embedded newline would submit prematurely.
func buildAgentPrompt(instructions string, hasParent bool, taskInfo *mcp.TaskInfo) string {
var parts []string
if hasParent && (instructions != "" || taskInfo != nil) {
parts = append(parts, "[system: you are a patterm sub-agent. When your work is done, call send_message to your parent (use whoami to get parent_process_id) with a summary, and close_process / scratchpad cleanup anything you created. See help('conventions').]")
}
if instructions == "" {
return ""
if taskInfo != nil {
parts = append(parts, fmt.Sprintf("[system: you are working on patterm task %q (%s). If you create or use git worktrees for this task, call task_register_worktree with the path and branch.]", sanitizePromptText(taskInfo.Title), sanitizePromptText(taskInfo.ID)))
}
const preface = "[system: you are a patterm sub-agent. When your work is done, call send_message to your parent (use whoami to get parent_process_id) with a summary, and close_process / scratchpad cleanup anything you created. See help('conventions').] "
return preface + instructions
if instructions != "" {
parts = append(parts, instructions)
}
return strings.Join(parts, " ")
}
func sanitizePromptText(s string) string {
return strings.NewReplacer("\r", " ", "\n", " ", `"`, "'").Replace(s)
}
// applyChromeTrim deletes lines matching any of the given regexes.
@@ -1273,7 +1376,7 @@ func stripANSIBytes(dst, src []byte) []byte {
// availableToolsForRole — SPEC §7 whoami exposes the list a caller can
// invoke from its current role. Sub-agents lose `spawn_agent` (§8
// two-level-tree rule).
func availableToolsForRole(role mcp.CallerRole) []string {
func availableToolsForRole(role mcp.CallerRole, taskBound bool) []string {
tools := []string{
"spawn_process", "start_process", "restart_process", "stop_process",
"close_process", "rename_process", "select_process",
@@ -1289,6 +1392,9 @@ func availableToolsForRole(role mcp.CallerRole) []string {
if role == mcp.RoleOrchestrator {
tools = append([]string{"spawn_agent"}, tools...)
}
if taskBound {
tools = append(tools, "task_register_worktree")
}
return tools
}