Add task sidebar workflow
This commit is contained in:
+132
-26
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user