From d1338391944b0e1ed12a1011c5c9f52fb945cca1 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 24 Jun 2026 12:28:56 +0100 Subject: [PATCH] Add task sidebar workflow --- .opencode/plans/1782293186639-eager-falcon.md | 358 ++++++++++++++++++ CHANGELOG.md | 8 + cmd/spike/main.go | 2 +- internal/app/app.go | 350 ++++++++++++++++- internal/app/canonical_test.go | 16 +- internal/app/child.go | 6 +- internal/app/host.go | 158 ++++++-- internal/app/host_test.go | 106 +++++- internal/app/launch.go | 46 ++- internal/app/layout_test.go | 2 +- internal/app/palette.go | 135 ++++++- internal/app/palette_context_test.go | 49 +++ internal/app/ring_test.go | 2 +- internal/app/scratchpad_delete_test.go | 2 +- internal/app/session.go | 3 +- internal/app/sidebar.go | 37 ++ internal/app/spawn_focus_test.go | 6 +- internal/app/summarizer_test.go | 4 +- internal/app/timers_test.go | 2 +- internal/app/tree.go | 36 +- internal/app/tree_test.go | 34 +- internal/harness/restart_persist_test.go | 8 +- .../chrome_survives_origin_mode.json | 2 +- .../create_task_and_start_agent.json | 42 ++ internal/harness/session.go | 2 +- internal/mcp/mcp_test.go | 14 +- internal/mcp/protocol.go | 45 ++- internal/mcp/protocol_test.go | 89 +++++ internal/mcp/tools.go | 38 ++ internal/pty/pty.go | 5 +- internal/pty/pty_test.go | 40 ++ internal/task/task.go | 264 +++++++++++++ internal/task/task_test.go | 113 ++++++ 33 files changed, 1890 insertions(+), 134 deletions(-) create mode 100644 .opencode/plans/1782293186639-eager-falcon.md create mode 100644 internal/harness/scenarios/create_task_and_start_agent.json create mode 100644 internal/pty/pty_test.go create mode 100644 internal/task/task.go create mode 100644 internal/task/task_test.go diff --git a/.opencode/plans/1782293186639-eager-falcon.md b/.opencode/plans/1782293186639-eager-falcon.md new file mode 100644 index 0000000..2f2d90c --- /dev/null +++ b/.opencode/plans/1782293186639-eager-falcon.md @@ -0,0 +1,358 @@ +# Task Sidebar Simplification Plan + +## User Decisions + +- First pass is manual project-local tasks only. Future GitHub/Linear linking should not be built now. +- The right sidebar should put Tasks first, while keeping existing process, agent tree, and scratchpad navigation secondary. +- Generic palette-spawned top-level agents must not inherit a selected task. Only explicit task-scoped actions, such as `Start agent for task`, create task-bound agents. +- Agents should receive task context only when they are task-bound, and task-bound agents should be told to register task worktrees through MCP. + +## UX Review: Missing Pieces Today + +- There is no task model. The current right rail in `internal/app/sidebar.go` is `Processes`, `Agent Tree`, and `Scratchpads` only. +- There is no task list, task detail view, task-focused navigation entry, or visible way to discover existing tasks. +- Task creation is absent. Existing creation flows are process/agent palette actions in `internal/app/palette.go` and `internal/app/app.go`. +- There is no explicit task-scoped launch action, so a user cannot intentionally start an agent for a task. +- MCP has no task context and no worktree registration tool, so agents cannot tell patterm which worktrees belong to a task. +- Current process rows are not grouped by task and do not surface task membership. +- Sidebar empty states are generic process/scratchpad placeholders and do not advertise task creation. +- The sidebar has no click or row activation model. First pass should stay keyboard/palette driven rather than inventing mouse interactions. +- Cleanup/status/archive and external issue links are real task-list UX needs, but they are out of scope for this first simplification pass unless requested later. + +## Recommended Scope + +Implement a minimal first-class task feature: + +- Persist manual tasks per project. +- Render Tasks as the first sidebar section. +- Let users create, open, and rename tasks through the palette. +- Render a focused task detail view in the main viewport. +- Add explicit `Start agent for task` actions for focused tasks and task-bound children. +- Thread `TaskID` through children spawned from task context. +- Expose task context and `task_register_worktree` only to task-bound MCP callers. +- Fix PTY working-directory propagation, because task worktree paths are otherwise misleading. + +Do not add GitHub/Linear linking, task import, agent-side task creation, task list MCP tools, click handling, or broad task/project management UI in this pass. + +## Implementation Steps + +### 1. Add Project Task Store + +Add `internal/task/task.go` and `internal/task/task_test.go`. + +Persist to `$XDG_DATA_HOME/patterm/projects//tasks.json`, matching the existing project-local persistence pattern in `internal/persist/persist.go`. + +Minimal structs: + +```go +type Task struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Worktrees []Worktree `json:"worktrees,omitempty"` +} + +type Worktree struct { + Path string `json:"path"` + Branch string `json:"branch,omitempty"` + CreatedByProcessID string `json:"created_by_process_id,omitempty"` + RegisteredAt string `json:"registered_at"` +} +``` + +Store API: + +```go +Open(projectKey string) (*Store, error) +(*Store).List() []Task +(*Store).Get(id string) (Task, bool) +(*Store).Create(title string) (Task, error) +(*Store).Rename(id, title string) (Task, error) +(*Store).RegisterWorktree(taskID string, wt Worktree) (Task, error) +``` + +Keep it deliberately small: + +- Generate opaque IDs like `task_`. +- Validate non-empty titles. +- Use stable list ordering. +- De-duplicate worktrees by cleaned absolute path. +- Atomic write via `tasks.json.tmp` then rename. +- No status, assignee, priority, external refs, labels, or issue links yet. + +### 2. Fix PTY Working Directory + +Change `internal/pty/pty.go`: + +- Update `Start(argv, env, cols, rows)` to `Start(argv, env, workDir, cols, rows)`. +- Set `cmd.Dir = workDir` when non-empty. + +Update call sites: + +- `internal/app/child.go: startPTY` passes `c.WorkDir`. +- `internal/harness/session.go` and `internal/harness/restart_persist_test.go` pass an empty workdir or project dir as appropriate. +- `cmd/spike/main.go` passes an empty workdir. + +Add a focused PTY/app test that spawns `sh -lc pwd` with a temp workdir and verifies the child runs there. + +### 3. Thread Task Context Through Runtime State + +Modify `internal/app/child.go`: + +- Add `TaskID string` to `Child`. +- Add `taskID` to `newChildEntry`. + +Modify `internal/app/session.go`: + +- Add `TaskID string` to `SpawnSpec`. +- Pass it into `newChildEntry`. +- Keep persistence limited to top-level command entries. Do not persist agent/task process trees in `processes.json`. + +Modify `internal/app/launch.go`: + +- Avoid adding more positional string parameters. Introduce a small launch context struct, for example: + +```go +type LaunchContext struct { + ParentID string + TaskID string + WorkDir string +} +``` + +- Update `LaunchAgent`, `LaunchCommandPreset`, `LaunchCommandArgv`, and `LaunchTerminal` to accept this context or an equivalent minimal option. +- Generic palette spawns pass empty `TaskID` even when a task is focused. +- Explicit task actions pass `TaskID`. +- MCP spawns inherit `TaskID` from the caller only when the caller is task-bound. + +### 4. Wire Task Store Into App State + +Modify `internal/app/app.go`: + +- Open `task.Open(opts.ProjectKey)` during `Run`, near scratchpads/trust/persist stores. +- Add `tasks *task.Store` to `uiState`. +- Add `focusedTaskID string`, mutually exclusive with `focusedID` and `focusedPad`. +- Add `tasksCacheMu` / `tasksCache`, mirroring `padsList()`. +- Add `tasksList()`, `invalidateTasksCache()`, and `tasksChanged()`. +- Pass the task store to `newToolHost`. + +Modify `internal/app/host.go`: + +- Add `tasks *task.Store` to `toolHost`. +- Extend `newToolHost` to accept it. +- Add a small `taskSink` interface like `tasksChanged()` so MCP worktree registration can refresh the sidebar/detail view. + +### 5. Make Tasks First In The Sidebar + +Modify `internal/app/sidebar.go:drawSidebar`: + +- Render `Tasks` before `Processes`. +- Show empty state as something actionable, for example `(Ctrl-K create task)`. +- Show task rows with focus marker, title, and a compact suffix such as `2 worktrees` or `2w`. +- Keep current `Processes`, `Agent Tree`, and `Scratchpads` sections after Tasks. +- Do not add mouse or click behavior in this pass. + +Modify `internal/app/tree.go`: + +- Extend `navEntry` with `taskID string` and `isTask()`. +- Change `empty()` to include task entries. +- Change `sidebarNav` and `nextNavEntry` to accept tasks and order entries as `tasks -> processes -> active agent tree -> scratchpads`. +- Keep `nextChildID` compatibility tests working for process-only behavior. + +Modify `internal/app/app.go`: + +- Add `focusTask(taskID string)`. +- Add `renderTaskView(taskID string)` and `repaintFocusedTask()`. +- Update `focusProcess` and `focusScratchpad` to clear `focusedTaskID`. +- Update `repaintFocused`, `repaintFocusedWithChrome`, `restoreView` inside `closePalette`, and Ctrl-W/S pending nav handling to route task entries correctly. +- Update `drawStatusLine` to show `task: ` when a task is focused, and optionally `task: <title>` when a focused child is task-bound. +- Update `renderEmptyState` copy to mention tasks, for example `Press Ctrl-K to create a task or spawn an agent/process`. + +Focused task detail view should be read-only and compact: + +- Title and task ID. +- Registered worktrees. +- Task-bound running children if cheap to derive from `Session.Children()`. +- Hints: `Ctrl-K task actions`, `Ctrl-W/S navigate`. + +### 6. Add Palette Task Actions + +Modify `internal/app/palette.go`: + +- Add task list and focused task fields to `paletteState`. +- Add `taskID string` to `paletteAction`. +- Add task action kinds: + - `task-create-form` + - `task-create-submit` + - `task-switch` + - `task-rename-form` + - `task-rename-submit` + - `task-start-agent` +- Add a task macro if useful, but do not make it required for the first patch. +- Reuse `renameForm` for create/rename by allowing subject `task` and different submit kinds. + +Palette item behavior: + +- Global/Open group includes `Create task...` and `Open task: <title>`. +- When a task is focused, show `Rename task` and `Start agent for task: <preset>` rows. +- When a task-bound child is focused, show `Open task: <title>` and `Start another agent for task: <preset>` rows. +- Normal `Spawn agent: <preset>` remains unscoped and passes empty `TaskID`. + +Modify `internal/app/app.go:openPaletteLocked`: + +- Pass `tasksList()`, `focusedTaskID`, and current focused child task information into `newPalette`. + +Modify `internal/app/app.go:closePalette`: + +- `task-create-submit`: create task, invalidate cache, focus the new task. +- `task-switch`: focus task. +- `task-rename-submit`: rename task, invalidate cache, redraw/focus task. +- `task-start-agent`: launch selected agent preset with the task ID and a task initial prompt. + +Task initial prompt should be injected only for task-bound agents. Keep it single-line because agent input submission is line-sensitive: + +```text +[system: you are working on patterm task "<title>" (<task_id>). If you create or use git worktrees for this task, call task_register_worktree with the path and branch.] +``` + +Include known registered worktrees in the prompt only if the list is short; otherwise rely on `whoami`. + +### 7. Add Conditional MCP Task Context And Worktree Registration + +Modify `internal/mcp/tools.go`: + +- Add MCP structs `TaskInfo`, `TaskWorktree`, and `TaskRegisterWorktreeArgs`. +- Add optional `Task *TaskInfo `json:"task,omitempty"`` to `WhoAmI` and `ProjectStatus`. +- Do not add task fields to `ProcessInfo` in this pass, so unbound agents cannot discover task assignments through `list_processes`. +- Extend `ToolHost` with: + +```go +CallerTask(processID string) (TaskInfo, bool) +RegisterTaskWorktree(callerID string, args TaskRegisterWorktreeArgs) (TaskInfo, error) +``` + +`TaskRegisterWorktreeArgs` should contain only `path` and optional `branch`. Do not accept a task ID from the caller; the host infers the task from `callerID`. + +Modify `internal/mcp/protocol.go`: + +- Replace the constant-only `serverInstructions` path with a helper that can append task-bound instructions only when `host.CallerTask(callerID)` is present. +- Change `toolCatalog(role)` to `toolCatalog(role, taskBound bool)`. +- Advertise `task_register_worktree` only when `taskBound` is true. +- Keep `spawn_agent` hidden from sub-agents as today. + +Modify `internal/mcp/tools.go:callTool`: + +- Add case `task_register_worktree`. +- Reject unbound callers with `role_forbidden`. +- Call `h.RegisterTaskWorktree(callerID, args)` and return the updated task. + +Modify `internal/app/host.go`: + +- Add helper `callerTaskID(callerID string) string` based on `Child.TaskID`. +- Implement `CallerTask` by resolving the caller child and task store entry. +- `WhoAmI` includes `Task` only when the caller child has a valid task ID. +- `GetProjectStatus` includes the same optional task only for task-bound callers. +- `SpawnAgent` and `SpawnProcess` inherit `TaskID` from the caller. +- Replace `wrapSubAgentPrompt` with a small prompt builder that can include sub-agent instructions, task instructions, or both. Task instructions should be added whenever the spawned agent is task-bound, even when caller `agent_instructions` is empty. +- `RegisterTaskWorktree` resolves relative paths against the caller child `WorkDir`, falling back to the project dir, cleans/absolutizes the path, stores it, and triggers `tasksChanged()`. + +Important security/UX rule: + +- Unbound MCP callers should not receive task context, task tool availability, task worktree data, or task IDs. + +### 8. Tests + +Add or update focused unit tests before broad harness work. + +Task store: + +- `internal/task/task_test.go`: create/list persists across reopen. +- Validate empty title errors. +- Rename updates title and `updated_at`. +- Register worktree de-duplicates cleaned absolute paths. + +Runtime/spawn: + +- `internal/app/session_test.go` or focused launch tests: `TaskID` lands on spawned child. +- MCP spawn from task-bound caller propagates `TaskID` to child. +- Generic palette spawn while `focusedTaskID` is set keeps `TaskID == ""`. +- Explicit `task-start-agent` sets `TaskID` and injects task prompt. + +Sidebar/navigation/palette: + +- `internal/app/tree_test.go`: task nav entries precede processes, agent tree, and scratchpads. +- `internal/app/palette_context_test.go`: focused task shows task actions; generic spawn remains unscoped. +- Add render-level tests only where existing helpers make that cheap; avoid brittle full-frame snapshots. + +MCP: + +- `internal/mcp/protocol_test.go`: unbound tools/list omits `task_register_worktree`; task-bound tools/list includes it. +- `internal/mcp/tools.go` tests or app host tests: unbound registration is rejected. +- `internal/app/host_test.go`: task-bound `whoami` includes task; unbound `whoami` omits task. +- `internal/app/host_test.go`: registration stores an absolute path and returns updated task info. + +PTY working dir: + +- Add the smallest test proving a PTY child honors configured `WorkDir`. + +Harness: + +- Add one lightweight scenario under `internal/harness/scenarios/`: + - Open palette. + - Create a task. + - Assert sidebar shows `Tasks` and the task title. + - Start a fake agent through `Start agent for task`. + - Use MCP `whoami` from that fake agent if feasible, or assert the prompt/context path indirectly through captured output. + +### 9. Changelog + +Update `CHANGELOG.md` under `[Unreleased]`. + +Suggested bullets: + +```md +### Added +- Added project-local manual tasks in the right sidebar, with palette actions to create tasks, open tasks, rename tasks, and start task-scoped agents. +- Added task-bound MCP context and `task_register_worktree` so agents launched for a task can register git worktrees they create. + +### Fixed +- Child PTYs now honor their configured working directory when launched. +``` + +## Verification Commands + +Run focused checks first: + +```sh +go test ./internal/task/... +go test ./internal/pty/... +go test ./internal/app/... +go test ./internal/mcp/... +``` + +Then run broader checks: + +```sh +go test ./internal/harness/... +go test ./... +go build -o ./bin/patterm ./cmd/patterm +``` + +Manual TUI smoke test: + +```sh +./bin/patterm --project /home/harry/Dev/patterm +``` + +Manual flow to verify: + +- Press `Ctrl-K`, create a task, and confirm it appears first in the sidebar. +- Focus the task from the sidebar navigation with `Ctrl-W/S`. +- Press `Ctrl-K`, start an agent for that task, and confirm the task remains visible and the agent is task-bound. +- Start a normal agent through `Spawn agent` while the task is selected and confirm it is not task-bound. +- From a task-bound fake/test agent, call `whoami` and confirm task context appears. +- From an unbound agent, call `whoami` and confirm no task context appears. +- From a task-bound agent, call `task_register_worktree` and confirm the task detail view/sidebar suffix updates. + +If harness tests fail with Unix socket or PTY permission errors, rerun them in an environment that permits sockets and PTYs, as documented in `AGENTS.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f3cb5c..52012f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added +- Project-local manual tasks now appear first in the right sidebar, + with palette actions to create tasks, open tasks, rename tasks, and + start task-scoped agents. +- Task-bound agents now receive task MCP context and can call + `task_register_worktree` to register git worktrees they create or + use for the task. - MCP clients can now call `scratchpad_delete` with a scratchpad name to remove a shared project scratchpad. @@ -23,6 +29,8 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). only include full tool lists when `include_tools` is requested. ### Fixed +- Child PTYs now honor their configured working directory when + launched. - Injected agent input now sends the submit Enter as a separated, settled keystroke so messages reliably submit instead of sometimes sitting unsent in the composer. diff --git a/cmd/spike/main.go b/cmd/spike/main.go index a3999c5..1a01ea3 100644 --- a/cmd/spike/main.go +++ b/cmd/spike/main.go @@ -108,7 +108,7 @@ func run(argv []string, cols, rows uint16, idleMS int, followHost, stdinPassthro } defer em.Close() - child, err := pty.Start(argv, nil, cols, rows) + child, err := pty.Start(argv, nil, "", cols, rows) if err != nil { return fmt.Errorf("pty: %w", err) } diff --git a/internal/app/app.go b/internal/app/app.go index a4a9339..946d7dc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -21,6 +21,7 @@ import ( "github.com/hjbdev/patterm/internal/persist" "github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/scratchpad" + "github.com/hjbdev/patterm/internal/task" "github.com/hjbdev/patterm/internal/trust" "github.com/hjbdev/patterm/internal/vt" ) @@ -81,6 +82,11 @@ func Run(ctx context.Context, opts Options) error { return fmt.Errorf("app: persist init: %w", err) } + taskStore, err := task.Open(opts.ProjectKey) + if err != nil { + return fmt.Errorf("app: task init: %w", err) + } + // In-process MCP server bound to the per-PID socket. Children that // support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`. // SPEC §10. @@ -129,7 +135,7 @@ func Run(ctx context.Context, opts Options) error { // Wire the tool host into MCP. Spawns through MCP use the host // terminal's viewport grid for their initial PTY size; SIGWINCH paths // resize them later. - host := newToolHost(sess, pads, launcher, presets, trustStore, layout.childCols(), layout.childRows()) + host := newToolHost(sess, pads, taskStore, launcher, presets, trustStore, layout.childCols(), layout.childRows()) mcpSrv.SetHost(host) var restoreState *term.State @@ -166,6 +172,7 @@ func Run(ctx context.Context, opts Options) error { presets: presets, launcher: launcher, pads: pads, + tasks: taskStore, chromeWake: make(chan struct{}, 1), trust: trustStore, timers: host.timers, @@ -194,6 +201,7 @@ func Run(ctx context.Context, opts Options) error { host.focus = st host.prompter = st host.scratch = st + host.taskUI = st st.lastExit.Store(-1) sess.Subscribe(st) go st.summaries.run(ctx) @@ -402,6 +410,7 @@ type uiState struct { presets preset.Set launcher *Launcher pads *scratchpad.Store + tasks *task.Store trust *trust.Store timers *timerManager @@ -417,6 +426,11 @@ type uiState struct { // exclusive with focusedID. The palette also reads this to surface // scratchpad-specific actions at the top of the command list. focusedPad string + // focusedTaskID names the task currently rendered in the main viewport. + // It is mutually exclusive with focusedID and focusedPad. Task selection + // is not ambient spawn context; only explicit task actions launch + // task-bound agents/processes. + focusedTaskID string // padOffset is the index of the top-most rendered row in the // markdown-formatted view of focusedPad. Reset when focus moves to // a different pad; preserved across content changes for the same @@ -503,8 +517,10 @@ type uiState struct { // repaint; the cache invalidates in scratchpadsChanged() which is // the canonical "pads mutated" signal from MCP write/append. nil // means "never read yet" — next caller refreshes. - padsCacheMu sync.Mutex - padsCache []scratchpad.Entry + padsCacheMu sync.Mutex + padsCache []scratchpad.Entry + tasksCacheMu sync.Mutex + tasksCache []task.Task lastExit atomic.Int32 } @@ -585,8 +601,9 @@ func (st *uiState) focusProcess(processID string) { layout := st.layoutSnapshot() onAlt := childIsOnAlt(c) st.mu.Lock() - leavingPad := st.focusedPad != "" + leavingStaticView := st.focusedPad != "" || st.focusedTaskID != "" st.focusedPad = "" + st.focusedTaskID = "" st.focusedID = c.ID st.focusedName = c.DisplayName() st.updateActiveAgentLocked(c) @@ -597,7 +614,7 @@ func (st *uiState) focusProcess(processID string) { st.syncHostMouseForChild(onAlt) // Wipe whatever the previous focus (PTY child or pad view) left in // the viewport before painting the new child's snapshot. - if leavingPad { + if leavingStaticView { st.clearViewportArea() } st.repaintFocused() @@ -656,6 +673,7 @@ func (st *uiState) focusScratchpad(name string) { st.padOffsetName = name } st.focusedPad = name + st.focusedTaskID = "" st.focusedID = "" st.focusedName = name st.renderer = nil @@ -667,6 +685,29 @@ func (st *uiState) focusScratchpad(name string) { st.drawStatusLine() } +func (st *uiState) focusTask(taskID string) { + if taskID == "" || st.tasks == nil { + return + } + t, ok := st.tasks.Get(taskID) + if !ok { + return + } + st.marquee.reset() + st.mu.Lock() + st.focusedTaskID = t.ID + st.focusedPad = "" + st.focusedID = "" + st.focusedName = t.Title + st.renderer = nil + st.mu.Unlock() + st.clearViewportArea() + st.repaintFocusedTask() + st.drawTabBar() + st.drawSidebar() + st.drawStatusLine() +} + // clearViewportArea wipes the rectangle the focused-child PTY (or pad // view) paints into so the next paint starts on a clean canvas. Used // when transitioning between pad and child focus. @@ -691,8 +732,11 @@ func (st *uiState) clearViewportArea() { func (st *uiState) repaintFocusedWithChrome() { st.mu.Lock() padFocused := st.focusedPad != "" + taskFocused := st.focusedTaskID != "" st.mu.Unlock() - if padFocused { + if taskFocused { + st.repaintFocusedTask() + } else if padFocused { st.repaintFocusedPad() } else { st.repaintFocused() @@ -786,6 +830,18 @@ func (st *uiState) scratchpadsChanged() { } } +func (st *uiState) tasksChanged() { + st.invalidateTasksCache() + st.drawSidebar() + st.mu.Lock() + focusedTask := st.focusedTaskID + st.mu.Unlock() + if focusedTask != "" { + st.repaintFocusedTask() + st.drawStatusLine() + } +} + func (st *uiState) invalidateScratchpadsCache() { st.padsCacheMu.Lock() st.padsCache = nil @@ -795,6 +851,15 @@ func (st *uiState) invalidateScratchpadsCache() { st.chromeCacheMu.Unlock() } +func (st *uiState) invalidateTasksCache() { + st.tasksCacheMu.Lock() + st.tasksCache = nil + st.tasksCacheMu.Unlock() + st.chromeCacheMu.Lock() + st.sidebarCache = "" + st.chromeCacheMu.Unlock() +} + // OnChildSpawned auto-focuses the new child when the spawn came from // the user (palette, persistence restore, or an external MCP client with // no resolved identity). When ParentID is set — meaning a patterm-managed @@ -810,6 +875,10 @@ func (st *uiState) OnChildSpawned(c *Child) { if st.palette != nil { st.palette.children = st.sess.Children() st.palette.focused = st.focusedID + st.palette.focusedPad = st.focusedPad + st.palette.focusedTaskID = st.focusedTaskID + st.palette.tasksEnabled = st.tasks != nil + st.palette.tasks = st.tasksList() st.palette.rebuild() st.renderPaletteLocked() } @@ -823,6 +892,7 @@ func (st *uiState) OnChildSpawned(c *Child) { onAlt := childIsOnAlt(c) st.mu.Lock() st.focusedPad = "" + st.focusedTaskID = "" st.focusedID = c.ID st.focusedName = c.DisplayName() st.updateActiveAgentLocked(c) @@ -833,6 +903,10 @@ func (st *uiState) OnChildSpawned(c *Child) { if palOpen { st.palette.children = st.sess.Children() st.palette.focused = st.focusedID + st.palette.focusedPad = st.focusedPad + st.palette.focusedTaskID = st.focusedTaskID + st.palette.tasksEnabled = st.tasks != nil + st.palette.tasks = st.tasksList() st.palette.rebuild() st.renderPaletteLocked() } @@ -915,6 +989,10 @@ func (st *uiState) OnChildExited(c *Child) { if st.palette != nil { st.palette.children = st.sess.Children() st.palette.focused = st.focusedID + st.palette.focusedPad = st.focusedPad + st.palette.focusedTaskID = st.focusedTaskID + st.palette.tasksEnabled = st.tasks != nil + st.palette.tasks = st.tasksList() st.palette.rebuild() st.renderPaletteLocked() } @@ -1158,6 +1236,40 @@ func (st *uiState) padsList() []scratchpad.Entry { return entries } +// tasksList returns the cached task listing. Callers must not mutate the +// returned slice — it is shared until invalidateTasksCache runs. +func (st *uiState) tasksList() []task.Task { + if st.tasks == nil { + return nil + } + st.tasksCacheMu.Lock() + if st.tasksCache != nil { + out := st.tasksCache + st.tasksCacheMu.Unlock() + return out + } + st.tasksCacheMu.Unlock() + entries := st.tasks.List() + st.tasksCacheMu.Lock() + st.tasksCache = entries + st.tasksCacheMu.Unlock() + return entries +} + +func (st *uiState) taskByID(id string) (task.Task, bool) { + if id == "" || st.tasks == nil { + return task.Task{}, false + } + return st.tasks.Get(id) +} + +func (st *uiState) taskTitle(id string) string { + if t, ok := st.taskByID(id); ok { + return t.Title + } + return "" +} + // markChromeDirty schedules a chrome (tab bar + status line) repaint // on the next ticker frame. Cheap to call from the per-PTY-chunk hot // path. Latency-sensitive sites (focus change, owner flip, attention, @@ -1269,6 +1381,7 @@ func (st *uiState) drawStatusLine() { palOpen := st.palette != nil focusID := st.focusedID focusName := st.focusedName + focusTaskID := st.focusedTaskID var trustMsg string if st.pendingTrust != nil { trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName) @@ -1297,10 +1410,24 @@ func (st *uiState) drawStatusLine() { owner = "you have control" } } + if focusTaskID != "" && focusName == "" { + focusName = st.taskTitle(focusTaskID) + } left := "" - if focusName != "" { + if focusTaskID != "" && focusName != "" { + left = "task: " + focusName + } else if focusName != "" { left = focusName } + if focusedChild != nil && focusedChild.TaskID != "" { + if title := st.taskTitle(focusedChild.TaskID); title != "" { + if left != "" { + left = left + " · task: " + title + } else { + left = "task: " + title + } + } + } if owner != "" { if left != "" { left = left + " · " + owner @@ -1369,7 +1496,7 @@ func (st *uiState) drawStatusLine() { // child is focused. func (st *uiState) renderEmptyState() { layout := st.layoutSnapshot() - line := "Press Ctrl-K to spawn an agent or process" + line := "Press Ctrl-K to create a task or spawn an agent/process" row := int(layout.mainTop) + (int(layout.childRows()) / 2) col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2) if row < int(layout.mainTop) { @@ -1790,13 +1917,13 @@ func (st *uiState) processStdin(chunk []byte) { } if hit, adv := matchCtrlChar(chunk, i, 'w'); hit { flushForward() - pendingNav = nextNavEntry(st.sess.Children(), st.focusedID, st.focusedPad, st.activeAgentID, st.padsList(), -1) + pendingNav = nextNavEntry(st.sess.Children(), st.focusedID, st.focusedPad, st.focusedTaskID, st.activeAgentID, st.tasksList(), st.padsList(), -1) i += adv break } if hit, adv := matchCtrlChar(chunk, i, 's'); hit { flushForward() - pendingNav = nextNavEntry(st.sess.Children(), st.focusedID, st.focusedPad, st.activeAgentID, st.padsList(), +1) + pendingNav = nextNavEntry(st.sess.Children(), st.focusedID, st.focusedPad, st.focusedTaskID, st.activeAgentID, st.tasksList(), st.padsList(), +1) i += adv break } @@ -1884,6 +2011,8 @@ func (st *uiState) processStdin(chunk []byte) { } if !pendingNav.empty() { switch { + case pendingNav.isTask(): + st.focusTask(pendingNav.taskID) case pendingNav.isPad(): st.focusScratchpad(pendingNav.pad) case pendingNav.isChild(): @@ -1965,7 +2094,7 @@ func (st *uiState) openPaletteLocked() { st.settingsMu.Lock() appSettings := st.settings.clone() st.settingsMu.Unlock() - st.palette = newPalette(st.sess.Children(), st.focusedID, st.focusedPad, st.presets, appSettings) + st.palette = newPaletteWithTasks(st.sess.Children(), st.focusedID, st.focusedPad, st.focusedTaskID, st.tasksList(), st.presets, appSettings) // Push a "no kitty flags" entry onto the host terminal's keyboard // stack so palette input arrives in plain legacy form regardless of // what the focused child pushed. Codex/ratatui enables kitty mode @@ -2001,7 +2130,12 @@ func (st *uiState) closePalette(action paletteAction) { restoreView := func() { st.mu.Lock() padFocused := st.focusedPad != "" + taskFocused := st.focusedTaskID != "" st.mu.Unlock() + if taskFocused { + st.repaintFocusedTask() + return + } if padFocused { st.repaintFocusedPad() return @@ -2025,7 +2159,7 @@ func (st *uiState) closePalette(action paletteAction) { st.launcher.SetSize(l.childCols(), l.childRows()) // LaunchAgent fires OnChildSpawned synchronously; it will draw // chrome and set focus. - if _, err := st.launcher.LaunchAgent(action.preset, action.preset.Name, "", ""); err != nil { + if _, err := st.launcher.LaunchAgent(action.preset, action.preset.Name, "", LaunchContext{}); err != nil { st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err)) } @@ -2036,14 +2170,14 @@ func (st *uiState) closePalette(action paletteAction) { } l := st.layoutSnapshot() st.launcher.SetSize(l.childCols(), l.childRows()) - if _, err := st.launcher.LaunchCommandPreset(action.preset, action.preset.Name, ""); err != nil { + if _, err := st.launcher.LaunchCommandPreset(action.preset, action.preset.Name, LaunchContext{}); err != nil { st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err)) } case "spawn-terminal": l := st.layoutSnapshot() st.launcher.SetSize(l.childCols(), l.childRows()) - if _, err := st.launcher.LaunchTerminal(nil, "terminal", "", "", nil); err != nil { + if _, err := st.launcher.LaunchTerminal(nil, "terminal", LaunchContext{}, nil); err != nil { st.flashError(fmt.Sprintf("spawn terminal: %v", err)) } @@ -2061,7 +2195,7 @@ func (st *uiState) closePalette(action paletteAction) { // shell=true so multi-word commands like "bun run dev" pass // through `sh -lc` and the user's PATH resolves binaries the // way they expect from an interactive shell. - c, err := st.launcher.LaunchCommandArgv([]string{action.command}, display, "", "", nil, true) + c, err := st.launcher.LaunchCommandArgv([]string{action.command}, display, LaunchContext{}, nil, true) if err != nil { st.flashError(fmt.Sprintf("spawn: %v", err)) return @@ -2077,6 +2211,18 @@ func (st *uiState) closePalette(action paletteAction) { st.drawSidebar() } + case "task-create-submit": + st.handleTaskCreate(action.newName) + + case "task-switch": + st.focusTask(action.taskID) + + case "task-rename-submit": + st.handleTaskRename(action.taskID, action.newName) + + case "task-start-agent": + st.handleTaskStartAgent(action.taskID, action.preset) + case "switch": c := st.sess.FindChild(action.childID) if c == nil || (c.Kind == KindAgent && c.Status() != StatusRunning) { @@ -2085,8 +2231,9 @@ func (st *uiState) closePalette(action paletteAction) { } layout := st.layoutSnapshot() st.mu.Lock() - leavingPad := st.focusedPad != "" + leavingStaticView := st.focusedPad != "" || st.focusedTaskID != "" st.focusedPad = "" + st.focusedTaskID = "" st.focusedID = action.childID st.focusedName = c.DisplayName() st.updateActiveAgentLocked(c) @@ -2095,7 +2242,7 @@ func (st *uiState) closePalette(action paletteAction) { // Switching from a pad to a child: wipe the pad body so the // child's snapshot paints onto a clean canvas, mirroring // focusProcess. - if leavingPad { + if leavingStaticView { st.clearViewportArea() } st.repaintFocused() @@ -2162,6 +2309,70 @@ func (st *uiState) closePalette(action paletteAction) { } } +func (st *uiState) handleTaskCreate(title string) { + if st.tasks == nil { + st.flashError("tasks unavailable") + return + } + t, err := st.tasks.Create(title) + if err != nil { + st.flashError(fmt.Sprintf("create task: %v", err)) + return + } + st.invalidateTasksCache() + st.focusTask(t.ID) +} + +func (st *uiState) handleTaskRename(taskID, title string) { + if st.tasks == nil || taskID == "" { + st.repaintFocused() + return + } + t, err := st.tasks.Rename(taskID, title) + if err != nil { + st.flashError(fmt.Sprintf("rename task: %v", err)) + return + } + st.invalidateTasksCache() + st.mu.Lock() + wasFocused := st.focusedTaskID == taskID + if wasFocused { + st.focusedName = t.Title + } + st.mu.Unlock() + if wasFocused { + st.focusTask(taskID) + return + } + st.drawSidebar() + st.drawStatusLine() +} + +func (st *uiState) handleTaskStartAgent(taskID string, p *preset.Preset) { + if p == nil { + st.repaintFocused() + return + } + t, ok := st.taskByID(taskID) + if !ok { + st.flashError("start task agent: task not found") + return + } + l := st.layoutSnapshot() + st.launcher.SetSize(l.childCols(), l.childRows()) + display := p.Name + " · " + t.Title + ctx := LaunchContext{TaskID: t.ID, WorkDir: st.sess.projectDir} + if _, err := st.launcher.LaunchAgent(p, display, taskAgentPrompt(t), ctx); err != nil { + st.flashError(fmt.Sprintf("start task agent: %v", err)) + } +} + +func taskAgentPrompt(t task.Task) string { + title := strings.NewReplacer("\r", " ", "\n", " ", `"`, "'").Replace(t.Title) + id := strings.NewReplacer("\r", " ", "\n", " ").Replace(t.ID) + return 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.]", title, id) +} + func (st *uiState) applySettingsAction(action paletteAction) { if action.settings == nil { return @@ -2459,9 +2670,14 @@ func (st *uiState) repaintFocused() { layout := st.layoutSnapshot() st.mu.Lock() id := st.focusedID + taskID := st.focusedTaskID renderer := st.renderer st.mu.Unlock() if id == "" { + if taskID != "" { + st.repaintFocusedTask() + return + } st.renderEmptyState() return } @@ -2492,6 +2708,106 @@ func (st *uiState) repaintFocused() { st.renderToasts() } +func (st *uiState) repaintFocusedTask() { + st.mu.Lock() + taskID := st.focusedTaskID + st.mu.Unlock() + if taskID == "" { + return + } + t, ok := st.taskByID(taskID) + if !ok { + st.renderEmptyState() + return + } + out := st.renderTaskView(t, st.sess.Children(), st.layoutSnapshot()) + if len(out) == 0 { + return + } + st.outMu.Lock() + _, _ = os.Stdout.Write(out) + st.outMu.Unlock() + st.renderToasts() +} + +func (st *uiState) renderTaskView(t task.Task, children []*Child, layout terminalLayout) []byte { + mainBottom := int(layout.statusRow) - statusRows + width := int(layout.childCols()) + if mainBottom < int(layout.mainTop) || width < 1 { + return nil + } + contentWidth := width - 2 + if contentWidth < 1 { + contentWidth = 1 + } + var b strings.Builder + fmt.Fprintf(&b, "\x1b[0m\x1b[?6l\x1b[%d;%dr\x1b[?25l\x1b[%d;%dH", + int(layout.mainTop), mainBottom, + int(layout.mainTop), int(layout.mainLeft)) + + row := int(layout.mainTop) + writeRow := func(text, style string) { + if row > mainBottom { + return + } + if visibleLen(text) > contentWidth { + text = clampVisible(text, contentWidth) + } + fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", row, int(layout.mainLeft), width) + fmt.Fprintf(&b, "\x1b[%d;%dH%s %s%s", row, int(layout.mainLeft), style, text, styleReset) + row++ + } + + writeRow("task: "+t.Title, styleActive+styleBold) + writeRow("id: "+t.ID, styleDim) + if width > 2 { + writeRow(strings.Repeat("─", contentWidth), styleBorder) + } + writeRow("worktrees", styleHint) + if len(t.Worktrees) == 0 { + writeRow("(none registered yet)", styleDim) + } else { + for _, wt := range t.Worktrees { + line := wt.Path + if wt.Branch != "" { + line += " [" + wt.Branch + "]" + } + writeRow(line, "") + } + } + + bound := taskBoundChildren(children, t.ID) + if row+1 <= mainBottom { + writeRow("", "") + writeRow("processes", styleHint) + if len(bound) == 0 { + writeRow("(none running for this task)", styleDim) + } else { + for _, c := range bound { + writeRow(fmt.Sprintf("%s %s %s", c.ID, c.Kind, c.DisplayName()), "") + } + } + } + if row+1 <= mainBottom { + writeRow("", "") + writeRow("Ctrl-K task actions · Ctrl-W/S navigate", styleDim) + } + for row <= mainBottom { + writeRow("", "") + } + return []byte(b.String()) +} + +func taskBoundChildren(children []*Child, taskID string) []*Child { + out := make([]*Child, 0, 4) + for _, c := range children { + if c.TaskID == taskID { + out = append(out, c) + } + } + return out +} + // repaintFocusedPad paints the focused scratchpad's content into the // main viewport, honouring the per-pad scroll offset and clamping it // to the rendered body size so a shrunk pad doesn't leave the view diff --git a/internal/app/canonical_test.go b/internal/app/canonical_test.go index 0d6d602..571462b 100644 --- a/internal/app/canonical_test.go +++ b/internal/app/canonical_test.go @@ -83,10 +83,10 @@ func TestCanonicalizeTerminalTextMaxLines(t *testing.T) { func TestGetProcessOutputStreamCanonicalByDefault(t *testing.T) { sess := NewSession(t.TempDir(), "test") - c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "") + c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "", "") addChild(sess, c) c.recordWrite([]byte("\x1b[31mStatus: running 12s\x1b[0m\nStatus: running 13s\nresult\n")) - host := newToolHost(sess, nil, nil, preset.Set{}, nil, 80, 24) + host := newToolHost(sess, nil, nil, nil, preset.Set{}, nil, 80, 24) out, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "stream"}) if err != nil { @@ -105,10 +105,10 @@ func TestGetProcessOutputStreamCanonicalByDefault(t *testing.T) { func TestGetProcessOutputRawReturnsStreamBytes(t *testing.T) { sess := NewSession(t.TempDir(), "test") - c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "") + c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "", "") addChild(sess, c) c.recordWrite([]byte("\x1b[31mred\x1b[0m")) - host := newToolHost(sess, nil, nil, preset.Set{}, nil, 80, 24) + host := newToolHost(sess, nil, nil, nil, preset.Set{}, nil, 80, 24) out, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "grid", Raw: true}) if err != nil { @@ -130,10 +130,10 @@ func TestGetProcessOutputRawReturnsStreamBytes(t *testing.T) { func TestGetProcessOutputCanonicalAfterRawRead(t *testing.T) { sess := NewSession(t.TempDir(), "test") - c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "") + c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "", "") addChild(sess, c) c.recordWrite([]byte("\x1b[31mStatus: running 12s\x1b[0m\nStatus: running 13s\nDownloading 10%\rDownloading 100%\nFINAL: deploy ready\n")) - host := newToolHost(sess, nil, nil, preset.Set{}, nil, 80, 24) + host := newToolHost(sess, nil, nil, nil, preset.Set{}, nil, 80, 24) if _, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "stream", Raw: true}); err != nil { t.Fatal(err) @@ -149,10 +149,10 @@ func TestGetProcessOutputCanonicalAfterRawRead(t *testing.T) { func TestGetProcessOutputIncludeMetaRestoresFields(t *testing.T) { sess := NewSession(t.TempDir(), "test") - c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "") + c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "", "") addChild(sess, c) c.recordWrite([]byte("ok")) - host := newToolHost(sess, nil, nil, preset.Set{}, nil, 80, 24) + host := newToolHost(sess, nil, nil, nil, preset.Set{}, nil, 80, 24) out, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "stream", IncludeMeta: true}) if err != nil { diff --git a/internal/app/child.go b/internal/app/child.go index e134cd2..17721e8 100644 --- a/internal/app/child.go +++ b/internal/app/child.go @@ -77,6 +77,7 @@ type Child struct { WorkDir string Kind ChildKind ParentID string // empty for top-level sessions + TaskID string // empty unless launched from an explicit task context // PresetRef names the source preset (when known). Used by trust // gating to re-check on restart_process. Empty for freeform-argv @@ -191,7 +192,7 @@ type PortSighting struct { const ringCap = 1 << 20 // 1 MiB per SPEC §5 // newChildEntry builds the in-memory Child record but does NOT start a PTY. -func newChildEntry(id, name string, kind ChildKind, argv, env []string, parentID, workDir, presetRef string) *Child { +func newChildEntry(id, name string, kind ChildKind, argv, env []string, parentID, taskID, workDir, presetRef string) *Child { c := &Child{ ID: id, Name: name, @@ -200,6 +201,7 @@ func newChildEntry(id, name string, kind ChildKind, argv, env []string, parentID WorkDir: workDir, Kind: kind, ParentID: parentID, + TaskID: taskID, PresetRef: presetRef, ring: make([]byte, ringCap), } @@ -228,7 +230,7 @@ func (c *Child) startPTY(cols, rows uint16) (uint64, error) { } starting := StatusStarting c.status.Store(&starting) - p, err := pkgpty.Start(c.Argv, c.Env, cols, rows) + p, err := pkgpty.Start(c.Argv, c.Env, c.WorkDir, cols, rows) if err != nil { em.Close() errored := StatusErrored diff --git a/internal/app/host.go b/internal/app/host.go index 1bece30..5e12e5d 100644 --- a/internal/app/host.go +++ b/internal/app/host.go @@ -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 } diff --git a/internal/app/host_test.go b/internal/app/host_test.go index fb09441..1196430 100644 --- a/internal/app/host_test.go +++ b/internal/app/host_test.go @@ -1,11 +1,14 @@ package app import ( + "path/filepath" "strings" "testing" "github.com/hjbdev/patterm/internal/mcp" + "github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/scratchpad" + taskstore "github.com/hjbdev/patterm/internal/task" ) // mkChild builds a Child without starting a PTY. Use sparingly — the @@ -100,8 +103,8 @@ func TestClassifySendMessageNilCallerRejectsNonTopLevelTarget(t *testing.T) { } } -func TestWrapSubAgentPromptPrependsSystemBlockWhenParented(t *testing.T) { - out := wrapSubAgentPrompt("ship feature X", true) +func TestBuildAgentPromptPrependsSystemBlockWhenParented(t *testing.T) { + out := buildAgentPrompt("ship feature X", true, nil) if !strings.HasPrefix(out, "[system:") { t.Fatalf("expected prepended [system: …] block, got %q", out) } @@ -119,18 +122,18 @@ func TestWrapSubAgentPromptPrependsSystemBlockWhenParented(t *testing.T) { } } -func TestWrapSubAgentPromptPassthroughWhenNoParent(t *testing.T) { - out := wrapSubAgentPrompt("hello", false) +func TestBuildAgentPromptPassthroughWhenNoParent(t *testing.T) { + out := buildAgentPrompt("hello", false, nil) if out != "hello" { t.Fatalf("expected passthrough for top-level spawn, got %q", out) } } -func TestWrapSubAgentPromptEmptyStaysEmpty(t *testing.T) { +func TestBuildAgentPromptEmptyStaysEmpty(t *testing.T) { // Empty instructions mean "no inject" upstream; we must not synthesize // content here or LaunchAgent would type the system block into an // otherwise-idle agent. - if out := wrapSubAgentPrompt("", true); out != "" { + if out := buildAgentPrompt("", true, nil); out != "" { t.Fatalf("empty instructions should stay empty, got %q", out) } } @@ -215,7 +218,7 @@ func TestAvailableToolsAdvertisesAllTimerTools(t *testing.T) { "timer_cancel", "timer_pause", "timer_resume", "timer_list", } for _, role := range []mcp.CallerRole{mcp.RoleOrchestrator, mcp.RoleSubAgent} { - tools := availableToolsForRole(role) + tools := availableToolsForRole(role, false) for _, w := range want { if !containsString(tools, w) { t.Fatalf("role %q missing %q in available tools: %v", role, w, tools) @@ -224,6 +227,95 @@ func TestAvailableToolsAdvertisesAllTimerTools(t *testing.T) { } } +func TestWhoAmITaskContextIsConditional(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + tasks, err := taskstore.Open("projkey") + if err != nil { + t.Fatalf("task open: %v", err) + } + task, err := tasks.Create("Fix sidebar") + if err != nil { + t.Fatalf("task create: %v", err) + } + sess := NewSession(t.TempDir(), "projkey") + unbound := newChildEntry("p_unbound", "agent", KindAgent, []string{"sh"}, nil, "", "", "", "") + bound := newChildEntry("p_bound", "agent", KindAgent, []string{"sh"}, nil, "", task.ID, "", "") + addTestChild(sess, unbound) + addTestChild(sess, bound) + h := newToolHost(sess, nil, tasks, nil, preset.Set{}, nil, 80, 24) + + if got := h.WhoAmI(unbound.ID, true); got.Task != nil || containsString(got.AvailableTools, "task_register_worktree") { + t.Fatalf("unbound whoami leaked task context/tools: %+v", got) + } + got := h.WhoAmI(bound.ID, true) + if got.Task == nil || got.Task.ID != task.ID || got.Task.Title != task.Title { + t.Fatalf("bound whoami missing task: %+v", got) + } + if !containsString(got.AvailableTools, "task_register_worktree") { + t.Fatalf("bound whoami missing task_register_worktree: %+v", got.AvailableTools) + } +} + +func TestRegisterTaskWorktreeResolvesAgainstCallerWorkDir(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + tasks, err := taskstore.Open("projkey") + if err != nil { + t.Fatalf("task open: %v", err) + } + task, err := tasks.Create("Fix sidebar") + if err != nil { + t.Fatalf("task create: %v", err) + } + workDir := t.TempDir() + sess := NewSession(t.TempDir(), "projkey") + caller := newChildEntry("p_bound", "agent", KindAgent, []string{"sh"}, nil, "", task.ID, workDir, "") + addTestChild(sess, caller) + h := newToolHost(sess, nil, tasks, nil, preset.Set{}, nil, 80, 24) + + info, err := h.RegisterTaskWorktree(caller.ID, mcp.TaskRegisterWorktreeArgs{Path: "../worktree", Branch: "task-branch"}) + if err != nil { + t.Fatalf("register: %v", err) + } + wantPath := filepath.Clean(filepath.Join(workDir, "../worktree")) + if len(info.Worktrees) != 1 || info.Worktrees[0].Path != wantPath || info.Worktrees[0].Branch != "task-branch" || info.Worktrees[0].CreatedByProcessID != caller.ID { + t.Fatalf("registered worktree = %+v, want path %q branch/task", info.Worktrees, wantPath) + } +} + +func TestSpawnProcessInheritsCallerTask(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + tasks, err := taskstore.Open("projkey") + if err != nil { + t.Fatalf("task open: %v", err) + } + task, err := tasks.Create("Fix sidebar") + if err != nil { + t.Fatalf("task create: %v", err) + } + sess := NewSession(t.TempDir(), "projkey") + defer sess.Shutdown() + caller := newChildEntry("p_bound", "agent", KindAgent, []string{"sh"}, nil, "", task.ID, "", "") + addTestChild(sess, caller) + launcher := NewLauncher(sess, "", 80, 24) + h := newToolHost(sess, nil, tasks, launcher, preset.Set{}, nil, 80, 24) + + info, err := h.SpawnProcess(caller.ID, mcp.SpawnProcessArgs{Argv: []string{"sh", "-lc", "exit 0"}}) + if err != nil { + t.Fatalf("spawn process: %v", err) + } + spawned := sess.FindChild(info.ID) + if spawned == nil || spawned.TaskID != task.ID { + t.Fatalf("spawned child task = %+v, want %q", spawned, task.ID) + } +} + +func addTestChild(sess *Session, c *Child) { + sess.mu.Lock() + defer sess.mu.Unlock() + sess.children[c.ID] = c + sess.order = append(sess.order, c.ID) +} + // TestHelpTimersDocumentsAllTools mirrors the whoami check for the // help("timers") topic — the related-tools list must enumerate every // timer_* tool so callers reading help can dispatch them. diff --git a/internal/app/launch.go b/internal/app/launch.go index 54142b7..2929bb7 100644 --- a/internal/app/launch.go +++ b/internal/app/launch.go @@ -24,6 +24,12 @@ type Launcher struct { cols, rows uint16 } +type LaunchContext struct { + ParentID string + TaskID string + WorkDir string +} + func NewLauncher(sess *Session, mcpSocket string, cols, rows uint16) *Launcher { bin, err := os.Executable() if err != nil { @@ -47,7 +53,7 @@ func (l *Launcher) size() (uint16, uint16) { // LaunchAgent spawns the agent preset, applies the preset's MCP // injection, waits for the ready signal, and types initial_prompt into // the PTY. SPEC §7 spawn_agent, §8 conversation protocol. -func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, parentID string) (*Child, error) { +func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt string, ctx LaunchContext) (*Child, error) { if p.Kind != preset.KindAgent { return nil, fmt.Errorf("launch: %q is not an agent preset", p.Name) } @@ -131,7 +137,9 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par Argv: argv, Env: env, Name: displayName, - ParentID: parentID, + ParentID: ctx.ParentID, + TaskID: ctx.TaskID, + WorkDir: firstNonEmpty(ctx.WorkDir, p.WorkingDir), PresetRef: p.Name, Identity: identity, CleanupPaths: cleanupPaths, @@ -163,7 +171,7 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par // LaunchCommandPreset spawns a process preset as a SPEC §7 command // entry. No MCP injection; just argv. The entry is session-persistent // (survives PTY exit so it can be Restart'd). -func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID string) (*Child, error) { +func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName string, ctx LaunchContext) (*Child, error) { if p.Kind != preset.KindCommand { return nil, fmt.Errorf("launch: %q is not a command preset", p.Name) } @@ -177,8 +185,9 @@ func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID s Argv: p.ResolvedArgv(), Env: env, Name: displayName, - ParentID: parentID, - WorkDir: p.WorkingDir, + ParentID: ctx.ParentID, + TaskID: ctx.TaskID, + WorkDir: firstNonEmpty(ctx.WorkDir, p.WorkingDir), PresetRef: p.Name, IdleDetection: resolveIdleDetection(p.IdleDetection), }, cols, rows) @@ -191,7 +200,7 @@ func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID s // LaunchCommandArgv spawns a freeform-argv command entry. Trust gating // (SPEC §7) lives one level up in toolHost — by the time we get here // trust is settled (freeform argv is implicitly trusted). -func (l *Launcher) LaunchCommandArgv(argv []string, displayName, parentID, workDir string, env []string, shell bool) (*Child, error) { +func (l *Launcher) LaunchCommandArgv(argv []string, displayName string, ctx LaunchContext, env []string, shell bool) (*Child, error) { if shell && len(argv) > 0 { argv = []string{"sh", "-lc", strings.Join(argv, " ")} } @@ -204,8 +213,9 @@ func (l *Launcher) LaunchCommandArgv(argv []string, displayName, parentID, workD Argv: argv, Env: env, Name: displayName, - ParentID: parentID, - WorkDir: workDir, + ParentID: ctx.ParentID, + TaskID: ctx.TaskID, + WorkDir: ctx.WorkDir, }, cols, rows) } @@ -223,7 +233,7 @@ func (l *Launcher) RestoreCommand(e persist.Entry, presets preset.Set) (*Child, if e.PresetRef != "" { for _, p := range presets.Processes { if p.Name == e.PresetRef { - return l.LaunchCommandPreset(p, e.Name, "") + return l.LaunchCommandPreset(p, e.Name, LaunchContext{}) } } // Preset has been deleted since the entry was saved. Fall @@ -233,12 +243,12 @@ func (l *Launcher) RestoreCommand(e persist.Entry, presets preset.Set) (*Child, if len(e.Argv) == 0 { return nil, fmt.Errorf("restore: entry %s has no argv", e.ID) } - return l.LaunchCommandArgv(e.Argv, e.Name, "", e.WorkDir, nil, false) + return l.LaunchCommandArgv(e.Argv, e.Name, LaunchContext{WorkDir: e.WorkDir}, nil, false) } // LaunchTerminal spawns a bare interactive shell. SPEC §7 kind=terminal. // argv defaults to $SHELL -i when empty. -func (l *Launcher) LaunchTerminal(argv []string, displayName, parentID, workDir string, env []string) (*Child, error) { +func (l *Launcher) LaunchTerminal(argv []string, displayName string, ctx LaunchContext, env []string) (*Child, error) { if len(argv) == 0 { sh := os.Getenv("SHELL") if sh == "" { @@ -255,11 +265,21 @@ func (l *Launcher) LaunchTerminal(argv []string, displayName, parentID, workDir Argv: argv, Env: env, Name: displayName, - ParentID: parentID, - WorkDir: workDir, + ParentID: ctx.ParentID, + TaskID: ctx.TaskID, + WorkDir: ctx.WorkDir, }, cols, rows) } +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + func (l *Launcher) writeMCPConfig(identity string) (string, error) { dir, err := mcpRuntimeDir(identity) if err != nil { diff --git a/internal/app/layout_test.go b/internal/app/layout_test.go index 515e929..0f2a591 100644 --- a/internal/app/layout_test.go +++ b/internal/app/layout_test.go @@ -50,7 +50,7 @@ func TestSpawnSizingUsesViewportDimensions(t *testing.T) { t.Fatalf("launcher size: got %dx%d want 91x36", cols, rows) } - host := newToolHost(nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows()) + host := newToolHost(nil, nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows()) cols, rows = host.size() if cols != 91 || rows != 36 { t.Fatalf("tool host size: got %dx%d want 91x36", cols, rows) diff --git a/internal/app/palette.go b/internal/app/palette.go index 76bb8ce..3aa9bb0 100644 --- a/internal/app/palette.go +++ b/internal/app/palette.go @@ -7,6 +7,7 @@ import ( "unicode/utf8" "github.com/hjbdev/patterm/internal/preset" + "github.com/hjbdev/patterm/internal/task" ) // paletteAction is what the palette returns when the user picks an item. @@ -35,7 +36,10 @@ type paletteAction struct { // For pad-* actions, the scratchpad name to operate on. padName string - // For *-rename-submit actions, the user-typed new name. + // For task-* actions, the task to operate on. + taskID string + + // For *-rename-submit and task-create-submit actions, the user-typed name. newName string // For settings actions, the updated settings snapshot to persist. @@ -90,12 +94,12 @@ type spawnProcessForm struct { // renameForm is a one-field inline form used by the "Rename scratchpad / // agent / process" context palette entries. The submit action kind -// determines what gets renamed; the target name (pad name or child id) +// determines what gets renamed; the target name (pad name, task id, or child id) // is carried alongside so closePalette knows what to apply the new // name to. type renameForm struct { name []rune - subject string // "pad" | "agent" | "proc" + subject string // "pad" | "task" | "task-create" | "agent" | "proc" target string // padName for "pad"; childID for "agent"/"proc" title string // e.g. "Rename" subjectLine string // e.g. "scratchpad: notes.md" rendered above the input @@ -110,13 +114,16 @@ type settingsInputForm struct { // paletteState is the in-memory model for the overlay. SPEC §4: a // single fuzzy-searchable list of commands scoped to the current focus. type paletteState struct { - query []rune - cursor int - children []*Child - focused string - focusedPad string - presets preset.Set - settings settings + query []rune + cursor int + children []*Child + focused string + focusedPad string + focusedTaskID string + tasksEnabled bool + tasks []task.Task + presets preset.Set + settings settings items []paletteItem @@ -135,9 +142,9 @@ type paletteState struct { // macro is active. Typing `sw <query>` filters to switch entries only, // `k <query>` to close entries, `sp <query>` to spawn entries. var macroPrefixes = map[string][]string{ - "sw": {"switch"}, + "sw": {"switch", "task-switch"}, "k": {"kill", "agent-close", "proc-stop", "proc-delete"}, - "sp": {"spawn-agent", "spawn-process", "spawn-terminal", "spawn-process-form"}, + "sp": {"spawn-agent", "spawn-process", "spawn-terminal", "spawn-process-form", "task-start-agent"}, } // chipOrder is the cycle order for Tab / Shift-Tab when the user @@ -179,12 +186,35 @@ func findChildByID(children []*Child, id string) *Child { return nil } +func (p *paletteState) taskByID(id string) (task.Task, bool) { + if id == "" { + return task.Task{}, false + } + for _, t := range p.tasks { + if t.ID == id { + return t, true + } + } + return task.Task{}, false +} + +func taskHint(t task.Task) string { + if len(t.Worktrees) == 0 { + return t.ID + } + return fmt.Sprintf("%s · %d worktrees", t.ID, len(t.Worktrees)) +} + func newPalette(children []*Child, focused, focusedPad string, presets preset.Set, appSettings ...settings) *paletteState { + return newPaletteWithTasks(children, focused, focusedPad, "", nil, presets, appSettings...) +} + +func newPaletteWithTasks(children []*Child, focused, focusedPad, focusedTaskID string, tasks []task.Task, presets preset.Set, appSettings ...settings) *paletteState { st := defaultSettings() if len(appSettings) > 0 { st = appSettings[0].clone() } - p := &paletteState{children: children, focused: focused, focusedPad: focusedPad, presets: presets, settings: st} + p := &paletteState{children: children, focused: focused, focusedPad: focusedPad, focusedTaskID: focusedTaskID, tasksEnabled: tasks != nil || focusedTaskID != "", tasks: tasks, presets: presets, settings: st} p.rebuild() return p } @@ -259,6 +289,21 @@ func (p *paletteState) buildItems(macro string) []paletteItem { paletteItem{label: "Delete", hint: "delete scratchpad · " + name, action: paletteAction{kind: "pad-delete", padName: name}, group: groupFocused}, ) + case p.focusedTaskID != "": + if t, ok := p.taskByID(p.focusedTaskID); ok { + out = append(out, + paletteItem{label: "Rename task", hint: "rename task · " + t.Title, + action: paletteAction{kind: "task-rename-form", taskID: t.ID}, group: groupFocused}, + ) + for _, pr := range p.presets.Agents { + out = append(out, paletteItem{ + label: "Start agent for task: " + pr.Name, + hint: t.Title + " · task-scoped", + action: paletteAction{kind: "task-start-agent", taskID: t.ID, preset: pr}, + group: groupFocused, + }) + } + } case p.focused != "": if c := findChildByID(p.children, p.focused); c != nil { name := c.DisplayName() @@ -291,13 +336,46 @@ func (p *paletteState) buildItems(macro string) []paletteItem { action: paletteAction{kind: "proc-delete", childID: c.ID}, group: groupFocused}, ) } + if c.TaskID != "" { + if t, ok := p.taskByID(c.TaskID); ok { + out = append(out, paletteItem{label: "Open task", hint: "open task · " + t.Title, + action: paletteAction{kind: "task-switch", taskID: t.ID}, group: groupFocused}) + for _, pr := range p.presets.Agents { + out = append(out, paletteItem{ + label: "Start another agent for task: " + pr.Name, + hint: t.Title + " · task-scoped", + action: paletteAction{kind: "task-start-agent", taskID: t.ID, preset: pr}, + group: groupFocused, + }) + } + } + } } } - // Group 1: Open — switch entries for every running child *other than* + // Group 1: Open — tasks first, then switch entries for every running child *other than* // the one already focused (no point offering a no-op switch). Dead // agents are filtered out (no restart path); dead command processes // remain so they can be restarted. + if p.tasksEnabled { + out = append(out, paletteItem{ + label: "Create task...", + hint: "manual project task", + action: paletteAction{kind: "task-create-form"}, + group: groupOpen, + }) + for _, t := range p.tasks { + if t.ID == p.focusedTaskID { + continue + } + out = append(out, paletteItem{ + label: "Open task: " + t.Title, + hint: taskHint(t), + action: paletteAction{kind: "task-switch", taskID: t.ID}, + group: groupOpen, + }) + } + } for _, c := range p.children { if c.ID == p.focused { continue @@ -649,6 +727,16 @@ func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) { p.mode = paletteModeSpawnForm p.form = &spawnProcessForm{} return paletteAction{}, false, adv + case "task-create-form": + p.enterNameForm("task-create", "", "", "new task", "Create task") + return paletteAction{}, false, adv + case "task-rename-form": + current := "" + if t, ok := p.taskByID(a.taskID); ok { + current = t.Title + } + p.enterNameForm("task", a.taskID, current, "task: "+current, "Rename task") + return paletteAction{}, false, adv case "settings-open": p.mode = paletteModeSettings p.query = nil @@ -676,12 +764,16 @@ func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) { } func (p *paletteState) enterRenameForm(subject, target, current, subjectLine string) { + p.enterNameForm(subject, target, current, subjectLine, "Rename") +} + +func (p *paletteState) enterNameForm(subject, target, current, subjectLine, title string) { p.mode = paletteModeRenameForm p.renameForm = &renameForm{ name: []rune(current), subject: subject, target: target, - title: "Rename", + title: title, subjectLine: subjectLine, } } @@ -921,6 +1013,10 @@ func (p *paletteState) submitRename() paletteAction { case "pad": kind = "pad-rename-submit" return paletteAction{kind: kind, padName: p.renameForm.target, newName: newName} + case "task-create": + return paletteAction{kind: "task-create-submit", newName: newName} + case "task": + return paletteAction{kind: "task-rename-submit", taskID: p.renameForm.target, newName: newName} case "agent": kind = "agent-rename-submit" case "proc": @@ -1151,12 +1247,17 @@ func (p *paletteState) selectableIndex() int { } // focusedSubject returns the short context string shown in the title -// bar — "on: <child>" / "pad: <name>" / "" — so the user knows which -// focus the context-section is targeting. +// bar — "on: <child>" / "pad: <name>" / "task: <title>" / "" — so the +// user knows which focus the context-section is targeting. func (p *paletteState) focusedSubject() string { if p.focusedPad != "" { return "pad: " + p.focusedPad } + if p.focusedTaskID != "" { + if t, ok := p.taskByID(p.focusedTaskID); ok { + return "task: " + t.Title + } + } if p.focused != "" { if c := findChildByID(p.children, p.focused); c != nil { return "on: " + c.DisplayName() diff --git a/internal/app/palette_context_test.go b/internal/app/palette_context_test.go index 9307323..5958c66 100644 --- a/internal/app/palette_context_test.go +++ b/internal/app/palette_context_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/hjbdev/patterm/internal/preset" + "github.com/hjbdev/patterm/internal/task" ) // makeFakeChild builds a Child with just enough state for the palette @@ -83,6 +84,54 @@ func TestContextItemsProcess(t *testing.T) { } } +func TestFocusedTaskShowsTaskActions(t *testing.T) { + agent := &preset.Preset{Name: "codex", Kind: preset.KindAgent, Argv: []string{"codex"}} + taskList := []task.Task{{ID: "task_1", Title: "Fix sidebar"}} + p := newPaletteWithTasks(nil, "", "", "task_1", taskList, preset.Set{Agents: []*preset.Preset{agent}}) + if got := p.focusedSubject(); got != "task: Fix sidebar" { + t.Fatalf("focused subject = %q", got) + } + if _, it := findItem(p, "task-rename-form"); it == nil || it.action.taskID != "task_1" { + t.Fatalf("task rename missing or wrong: %+v", it) + } + if _, it := findItem(p, "task-start-agent"); it == nil || it.action.taskID != "task_1" || it.action.preset != agent { + t.Fatalf("task start-agent missing or wrong: %+v", it) + } + if i, _ := findItem(p, "spawn-agent"); i < 0 { + t.Fatalf("normal unscoped spawn-agent row should remain available") + } +} + +func TestTaskCreateFormSubmitsTitle(t *testing.T) { + p := newPaletteWithTasks(nil, "", "", "", []task.Task{}, preset.Set{}) + idx, _ := findItem(p, "task-create-form") + if idx < 0 { + t.Fatalf("task-create-form missing") + } + p.cursor = idx + _, done, _ := p.handleInput([]byte("\r"), 0) + if done || p.mode != paletteModeRenameForm || p.renameForm == nil || p.renameForm.subject != "task-create" { + t.Fatalf("create task did not open name form: done=%v form=%+v", done, p.renameForm) + } + for _, b := range []byte("New task") { + _, _, _ = p.handleInput([]byte{b}, 0) + } + action, done, _ := p.handleInput([]byte("\r"), 0) + if !done || action.kind != "task-create-submit" || action.newName != "New task" { + t.Fatalf("submit = %+v done=%v", action, done) + } +} + +func TestTaskBoundChildShowsOpenTask(t *testing.T) { + c := makeFakeChild("aid", "codex", KindAgent) + c.TaskID = "task_1" + taskList := []task.Task{{ID: "task_1", Title: "Fix sidebar"}} + p := newPaletteWithTasks([]*Child{c}, "aid", "", "", taskList, preset.Set{}) + if _, it := findItem(p, "task-switch"); it == nil || it.action.taskID != "task_1" { + t.Fatalf("task-switch missing for task-bound child: %+v", it) + } +} + func TestContextItemsTerminalUsesCloseNotStop(t *testing.T) { c := makeFakeChild("tid", "terminal", KindTerminal) p := newPalette([]*Child{c}, "tid", "", preset.Set{}) diff --git a/internal/app/ring_test.go b/internal/app/ring_test.go index 2b1f7e6..d64d9f8 100644 --- a/internal/app/ring_test.go +++ b/internal/app/ring_test.go @@ -6,7 +6,7 @@ import ( ) func newRingChild() *Child { - return newChildEntry("id", "name", KindCommand, nil, nil, "", "", "") + return newChildEntry("id", "name", KindCommand, nil, nil, "", "", "", "") } func TestRingShortWrite(t *testing.T) { diff --git a/internal/app/scratchpad_delete_test.go b/internal/app/scratchpad_delete_test.go index 6074098..35d56a1 100644 --- a/internal/app/scratchpad_delete_test.go +++ b/internal/app/scratchpad_delete_test.go @@ -116,7 +116,7 @@ func TestToolHostScratchpadDeleteRemovesPadAndRefreshes(t *testing.T) { t.Fatalf("write doomed.md: %v", err) } recorder := &scratchpadChangeRecorder{} - host := newToolHost(nil, pads, nil, preset.Set{}, nil, 120, 40) + host := newToolHost(nil, pads, nil, nil, preset.Set{}, nil, 120, 40) host.scratch = recorder if err := host.ScratchpadDelete("doomed.md"); err != nil { diff --git a/internal/app/session.go b/internal/app/session.go index b7851f3..7eac608 100644 --- a/internal/app/session.go +++ b/internal/app/session.go @@ -201,6 +201,7 @@ type SpawnSpec struct { WorkDir string Name string ParentID string + TaskID string PresetRef string Identity string // pre-minted; otherwise the constructor mints one for agents // CleanupPaths are owned runtime files/dirs removed when the child exits @@ -235,7 +236,7 @@ func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) { } s.mu.Unlock() - c := newChildEntry(id, spec.Name, spec.Kind, spec.Argv, spec.Env, spec.ParentID, spec.WorkDir, spec.PresetRef) + c := newChildEntry(id, spec.Name, spec.Kind, spec.Argv, spec.Env, spec.ParentID, spec.TaskID, spec.WorkDir, spec.PresetRef) if spec.Identity != "" { c.Identity = spec.Identity } diff --git a/internal/app/sidebar.go b/internal/app/sidebar.go index eb3ca00..f19152a 100644 --- a/internal/app/sidebar.go +++ b/internal/app/sidebar.go @@ -168,6 +168,7 @@ func (st *uiState) drawSidebar() { palOpen := st.palette != nil focus := st.focusedID focusPad := st.focusedPad + focusTask := st.focusedTaskID activeAgent := st.activeAgentID st.mu.Unlock() if palOpen { @@ -262,6 +263,42 @@ func (st *uiState) drawSidebar() { } } + // Tasks section — manual project-local tasks. Task selection is a real + // focus target, but it does not implicitly scope generic spawns. + writeHeader("Tasks") + tasks := st.tasksList() + if len(tasks) == 0 { + write(" " + styleDim + "(Ctrl-K create task)" + styleReset) + } + for _, t := range tasks { + if row > maxRow { + break + } + focused := t.ID == focusTask + var prefix, openStyle string + if focused { + prefix = " " + styleAccent + "▎" + styleReset + " " + openStyle = styleBold + } else { + prefix = " " + openStyle = styleHint + } + suffix := "" + if n := len(t.Worktrees); n > 0 { + suffix = " " + styleDim + fmt.Sprintf("%dw", n) + styleReset + } + budget := width - visibleLen(prefix) - visibleLen(suffix) + if budget < 1 { + budget = 1 + } + nameCell := st.rowNameSlot("task:"+t.ID, t.Title, budget, focused) + write(prefix + openStyle + nameCell + styleReset + suffix) + } + + if row+2 <= maxRow { + write("") + } + // Processes section — top-level command/terminal processes, // session-wide (does not change when the user switches agent tabs). writeHeader("Processes") diff --git a/internal/app/spawn_focus_test.go b/internal/app/spawn_focus_test.go index 15cf2e8..1df9fee 100644 --- a/internal/app/spawn_focus_test.go +++ b/internal/app/spawn_focus_test.go @@ -12,11 +12,11 @@ func TestOnChildSpawnedAgentChildKeepsFocus(t *testing.T) { sess := NewSession(t.TempDir(), "test") st := &uiState{sess: sess} - parent := newChildEntry("p_parent", "parent", KindAgent, nil, nil, "", "", "") + parent := newChildEntry("p_parent", "parent", KindAgent, nil, nil, "", "", "", "") st.focusedID = parent.ID st.focusedName = parent.Name - subAgent := newChildEntry("p_sub", "sub", KindAgent, nil, nil, parent.ID, "", "") + subAgent := newChildEntry("p_sub", "sub", KindAgent, nil, nil, parent.ID, "", "", "") st.OnChildSpawned(subAgent) @@ -36,7 +36,7 @@ func TestOnChildSpawnedPaletteChildTakesFocus(t *testing.T) { st := &uiState{sess: sess} st.lastExit.Store(-1) - c := newChildEntry("p_new", "newchild", KindAgent, nil, nil, "", "", "") + c := newChildEntry("p_new", "newchild", KindAgent, nil, nil, "", "", "", "") st.OnChildSpawned(c) diff --git a/internal/app/summarizer_test.go b/internal/app/summarizer_test.go index bbaab62..822076b 100644 --- a/internal/app/summarizer_test.go +++ b/internal/app/summarizer_test.go @@ -89,7 +89,7 @@ func TestSummaryTextForSelectsChildAndClips(t *testing.T) { func TestSummaryManagerArmsOnlyTrackedTopLevelAgents(t *testing.T) { sess := NewSession(t.TempDir(), "test") - c := newChildEntry("a1", "agent", KindAgent, []string{"fake"}, nil, "", "", "") + c := newChildEntry("a1", "agent", KindAgent, []string{"fake"}, nil, "", "", "", "") running := StatusRunning c.status.Store(&running) sess.children[c.ID] = c @@ -112,7 +112,7 @@ func TestSummaryManagerArmsOnlyTrackedTopLevelAgents(t *testing.T) { t.Fatalf("tracked top-level agent not armed/dirty: %+v", e) } - sub := newChildEntry("a2", "sub", KindAgent, []string{"fake"}, nil, c.ID, "", "") + sub := newChildEntry("a2", "sub", KindAgent, []string{"fake"}, nil, c.ID, "", "", "") sub.status.Store(&running) m.RegisterChild(sub) m.ObserveHumanInput(sub.ID, []byte("please summarize")) diff --git a/internal/app/timers_test.go b/internal/app/timers_test.go index adb25ed..f88ee16 100644 --- a/internal/app/timers_test.go +++ b/internal/app/timers_test.go @@ -41,7 +41,7 @@ func (r *recorderFire) snapshot() []recordedFire { // Doesn't open a PTY — fireFn is overridden so InjectAsOrchestrator is // never reached. func fakeChild(id string) *Child { - c := newChildEntry(id, id, KindAgent, []string{"echo"}, nil, "", "", "") + c := newChildEntry(id, id, KindAgent, []string{"echo"}, nil, "", "", "", "") running := StatusRunning c.status.Store(&running) return c diff --git a/internal/app/tree.go b/internal/app/tree.go index b3cf035..5e51431 100644 --- a/internal/app/tree.go +++ b/internal/app/tree.go @@ -1,16 +1,21 @@ package app -import "github.com/hjbdev/patterm/internal/scratchpad" +import ( + "github.com/hjbdev/patterm/internal/scratchpad" + "github.com/hjbdev/patterm/internal/task" +) // navEntry is one row in the unified sidebar navigation list. Exactly -// one of childID or pad is set. childID points at a Child by ID; pad -// names a scratchpad entry. Empty zero-value means "no target". +// one of taskID, childID, or pad is set. childID points at a Child by ID; +// pad names a scratchpad entry. Empty zero-value means "no target". type navEntry struct { + taskID string childID string pad string } -func (n navEntry) empty() bool { return n.childID == "" && n.pad == "" } +func (n navEntry) empty() bool { return n.taskID == "" && n.childID == "" && n.pad == "" } +func (n navEntry) isTask() bool { return n.taskID != "" } func (n navEntry) isPad() bool { return n.pad != "" } func (n navEntry) isChild() bool { return n.childID != "" } @@ -221,12 +226,14 @@ func sidebarNavList(children []*Child, activeAgentID string) []*Child { return out } -// sidebarNav returns the combined Processes + Agent Tree + Scratchpads -// navigation list. Scratchpads always appear after children so the -// existing "step past the tree" expectation still holds. -func sidebarNav(children []*Child, activeAgentID string, pads []scratchpad.Entry) []navEntry { +// sidebarNav returns the combined Tasks + Processes + Agent Tree + Scratchpads +// navigation list. Order matches the right rail top-to-bottom. +func sidebarNav(children []*Child, activeAgentID string, tasks []task.Task, pads []scratchpad.Entry) []navEntry { flat := sidebarNavList(children, activeAgentID) - out := make([]navEntry, 0, len(flat)+len(pads)) + out := make([]navEntry, 0, len(tasks)+len(flat)+len(pads)) + for _, t := range tasks { + out = append(out, navEntry{taskID: t.ID}) + } for _, c := range flat { out = append(out, navEntry{childID: c.ID}) } @@ -237,15 +244,18 @@ func sidebarNav(children []*Child, activeAgentID string, pads []scratchpad.Entry } // nextNavEntry returns the entry `step` positions away from the -// current focus in the unified nav list. Either focusChildID or -// focusPad will be set (or both empty for "nothing focused yet"). +// current focus in the unified nav list. Exactly one focus identifier is +// usually set (or all empty for "nothing focused yet"). // Empty when there's nothing else to land on. -func nextNavEntry(children []*Child, focusChildID, focusPad, activeAgentID string, pads []scratchpad.Entry, step int) navEntry { - flat := sidebarNav(children, activeAgentID, pads) +func nextNavEntry(children []*Child, focusChildID, focusPad, focusTaskID, activeAgentID string, tasks []task.Task, pads []scratchpad.Entry, step int) navEntry { + flat := sidebarNav(children, activeAgentID, tasks, pads) if len(flat) == 0 { return navEntry{} } matches := func(e navEntry) bool { + if focusTaskID != "" && e.taskID != "" { + return e.taskID == focusTaskID + } if focusPad != "" && e.pad != "" { return e.pad == focusPad } diff --git a/internal/app/tree_test.go b/internal/app/tree_test.go index b5c0d8c..b613126 100644 --- a/internal/app/tree_test.go +++ b/internal/app/tree_test.go @@ -1,6 +1,10 @@ package app -import "testing" +import ( + "testing" + + "github.com/hjbdev/patterm/internal/task" +) func TestVisibleSessionTreeScopesToFocusedRoot(t *testing.T) { root1 := testChild("c1", "root1", "", StatusRunning) @@ -125,6 +129,34 @@ func TestSidebarNavListIncludesProcessesAboveAgentTree(t *testing.T) { } } +func TestSidebarNavOrdersTasksBeforeProcessesAndPads(t *testing.T) { + p := testProcess("p1", "bun", StatusRunning) + r := testAgent("a1", "claude", "", StatusRunning) + tasks := []task.Task{{ID: "t1", Title: "Fix sidebar"}} + nav := sidebarNav([]*Child{p, r}, "a1", tasks, nil) + if len(nav) != 3 { + t.Fatalf("nav len = %d, want 3 (%+v)", len(nav), nav) + } + if !nav[0].isTask() || nav[0].taskID != "t1" { + t.Fatalf("first nav entry = %+v, want task t1", nav[0]) + } + if !nav[1].isChild() || nav[1].childID != "p1" { + t.Fatalf("second nav entry = %+v, want process p1", nav[1]) + } + if !nav[2].isChild() || nav[2].childID != "a1" { + t.Fatalf("third nav entry = %+v, want agent a1", nav[2]) + } +} + +func TestNextNavEntryWalksFromTaskToProcess(t *testing.T) { + p := testProcess("p1", "bun", StatusRunning) + tasks := []task.Task{{ID: "t1", Title: "Fix sidebar"}} + next := nextNavEntry([]*Child{p}, "", "", "t1", "", tasks, nil, +1) + if !next.isChild() || next.childID != "p1" { + t.Fatalf("task -> next = %+v, want process p1", next) + } +} + func TestSidebarNavListIncludesExitedProcesses(t *testing.T) { p := testProcess("p1", "shell", StatusExited) r := testAgent("a1", "claude", "", StatusRunning) diff --git a/internal/harness/restart_persist_test.go b/internal/harness/restart_persist_test.go index c278df5..28f9e58 100644 --- a/internal/harness/restart_persist_test.go +++ b/internal/harness/restart_persist_test.go @@ -23,9 +23,9 @@ func TestRestartRestoresUserCommandProcess(t *testing.T) { } sc := &Scenario{ - Name: "restart_persist", - Cols: 120, - Rows: 40, + Name: "restart_persist", + Cols: 120, + Rows: 40, Trust: []string{"persist-target"}, Presets: ScenarioPresets{ Processes: []ScenarioPreset{{ @@ -143,7 +143,7 @@ func openSession(t *testing.T, env *testEnv, childEnv []string) *Session { if err != nil { t.Fatalf("vt emulator: %v", err) } - p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, env.Cols, env.Rows) + p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, env.ProjectDir, env.Cols, env.Rows) if err != nil { _ = em.Close() t.Fatalf("pty start: %v", err) diff --git a/internal/harness/scenarios/chrome_survives_origin_mode.json b/internal/harness/scenarios/chrome_survives_origin_mode.json index 50444ee..333a92b 100644 --- a/internal/harness/scenarios/chrome_survives_origin_mode.json +++ b/internal/harness/scenarios/chrome_survives_origin_mode.json @@ -22,7 +22,7 @@ { "type": "assert_contains", "contains": "Scratchpads" }, { "type": "assert_regex", - "regex": "(?m)^[^\\n]*\\+ new[^\\n]*Processes[^\\n]*$" + "regex": "(?m)^[^\\n]*\\+ new[^\\n]*Tasks[^\\n]*$" }, { "type": "assert_regex", diff --git a/internal/harness/scenarios/create_task_and_start_agent.json b/internal/harness/scenarios/create_task_and_start_agent.json new file mode 100644 index 0000000..f40f722 --- /dev/null +++ b/internal/harness/scenarios/create_task_and_start_agent.json @@ -0,0 +1,42 @@ +{ + "name": "create_task_and_start_agent", + "presets": { + "agents": [ + { + "name": "fake-agent", + "argv": ["fake-agent"] + } + ] + }, + "scripts": [ + { + "name": "fake-agent", + "body": "#!/bin/sh\necho FAKE READY\ncat\n" + } + ], + "steps": [ + { "type": "wait_stable", "timeout_ms": 3000 }, + { "type": "assert_contains", "contains": "Tasks" }, + { "type": "assert_contains", "contains": "Ctrl-K create task" }, + + { "type": "send_chord", "chord": "ctrl-k" }, + { "type": "send_text", "text": "Create task" }, + { "type": "send_chord", "chord": "enter" }, + { "type": "wait_text", "contains": "Create task", "timeout_ms": 3000 }, + { "type": "send_text", "text": "Harness task" }, + { "type": "send_chord", "chord": "enter" }, + { "type": "wait_text", "contains": "task: Harness task", "timeout_ms": 5000 }, + { "type": "assert_contains", "contains": "Harness task" }, + + { "type": "send_chord", "chord": "ctrl-k" }, + { "type": "send_text", "text": "fake-agent" }, + { "type": "send_chord", "chord": "enter" }, + { "type": "wait_text", "contains": "task_register_worktree", "timeout_ms": 8000 }, + { + "type": "assert_mcp", + "method": "list_processes", + "path": "0.name", + "contains": "Harness task" + } + ] +} diff --git a/internal/harness/session.go b/internal/harness/session.go index 7928353..a549272 100644 --- a/internal/harness/session.go +++ b/internal/harness/session.go @@ -55,7 +55,7 @@ func NewCLI(opts Options) (*Session, error) { if err != nil { return nil, err } - p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, env.Cols, env.Rows) + p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, env.ProjectDir, env.Cols, env.Rows) if err != nil { _ = em.Close() return nil, err diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 7d7bfd4..eacb0bc 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -108,10 +108,17 @@ type blockingToolHost struct { waitEntered chan struct{} waitRelease chan struct{} waitOnce sync.Once + task TaskInfo } func (h *blockingToolHost) ResolveCallerIdentity(identity string) string { return "caller-" + identity } func (h *blockingToolHost) CallerRole(string) CallerRole { return RoleOrchestrator } +func (h *blockingToolHost) CallerTask(string) (TaskInfo, bool) { + if h.task.ID == "" { + return TaskInfo{}, false + } + return h.task, true +} func (h *blockingToolHost) SpawnAgent(string, SpawnAgentArgs) (ProcessInfo, error) { return ProcessInfo{}, nil } @@ -186,5 +193,8 @@ func (h *blockingToolHost) ScratchpadWrite(string, string, string) (string, erro } func (h *blockingToolHost) ScratchpadAppend(string, string) error { return nil } func (h *blockingToolHost) ScratchpadDelete(string) error { return nil } -func (h *blockingToolHost) WhoAmI(string, bool) WhoAmI { return WhoAmI{} } -func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} } +func (h *blockingToolHost) RegisterTaskWorktree(string, TaskRegisterWorktreeArgs) (TaskInfo, error) { + return TaskInfo{}, nil +} +func (h *blockingToolHost) WhoAmI(string, bool) WhoAmI { return WhoAmI{} } +func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} } diff --git a/internal/mcp/protocol.go b/internal/mcp/protocol.go index 4c36351..5267b10 100644 --- a/internal/mcp/protocol.go +++ b/internal/mcp/protocol.go @@ -29,7 +29,7 @@ var serverInfo = map[string]any{ "version": "0.1.0", } -// serverInstructions is returned in the MCP `initialize` response. MCP +// baseServerInstructions is returned in the MCP `initialize` response. MCP // clients show this to the underlying LLM as context for how to use // the server. Failure modes we've seen and want to head off: // - The agent assumes patterm is something it has to launch (running @@ -45,7 +45,14 @@ var serverInfo = map[string]any{ // up as sub-agents and won't be tied into the patterm lifecycle. // // Keep this short — clients vary in how much they surface to the LLM. -const serverInstructions = "You are inside patterm. Use these MCP tools; do not launch patterm or poke its Unix socket yourself. Use spawn_agent for sub-agents, close spawned panes when done, and use timer_fire_when_idle_* instead of wait_for_pattern to wait for send_message replies." +const baseServerInstructions = "You are inside patterm. Use these MCP tools; do not launch patterm or poke its Unix socket yourself. Use spawn_agent for sub-agents, close spawned panes when done, and use timer_fire_when_idle_* instead of wait_for_pattern to wait for send_message replies." + +func serverInstructions(taskBound bool) string { + if !taskBound { + return baseServerInstructions + } + return baseServerInstructions + " This MCP connection is task-bound; call whoami for the task, and register git worktrees you create or use with task_register_worktree." +} // toolDescriptor is the shape returned by `tools/list`. inputSchema is // a JSON Schema object — we provide a minimal `{type: "object"}` schema @@ -108,7 +115,7 @@ func arrayOfStringsProp(desc string) map[string]any { // toolCatalog is the full list advertised via tools/list. Descriptions // are intentionally short — clients are expected to fetch help() for // detail. Schemas mirror the param structs in tools.go. -func toolCatalog(role CallerRole) []toolDescriptor { +func toolCatalog(role CallerRole, taskBound bool) []toolDescriptor { tools := []toolDescriptor{ { Name: "spawn_agent", @@ -382,6 +389,14 @@ func toolCatalog(role CallerRole) []toolDescriptor { "name": stringProp("Scratchpad name."), }, []string{"name"}), }, + { + Name: "task_register_worktree", + Description: "Register a git worktree path for the current task.", + InputSchema: objectSchema(map[string]any{ + "path": stringProp("Absolute path, or path relative to this process working directory."), + "branch": stringProp("Git branch name, if known."), + }, []string{"path"}), + }, { Name: "whoami", Description: "Return caller identity, role, parent, and project metadata.", @@ -397,14 +412,15 @@ func toolCatalog(role CallerRole) []toolDescriptor { }, nil), }, } - if role != RoleSubAgent { - return tools - } filtered := tools[:0] for _, tool := range tools { - if tool.Name != "spawn_agent" { - filtered = append(filtered, tool) + if role == RoleSubAgent && tool.Name == "spawn_agent" { + continue } + if !taskBound && tool.Name == "task_register_worktree" { + continue + } + filtered = append(filtered, tool) } return filtered } @@ -426,13 +442,20 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe if protoVersion == "" { protoVersion = supportedProtocolVersion } + taskBound := false + s.mu.Lock() + host := s.host + s.mu.Unlock() + if host != nil { + _, taskBound = host.CallerTask(callerID) + } result := map[string]any{ "protocolVersion": protoVersion, "capabilities": map[string]any{ "tools": map[string]any{"listChanged": false}, }, "serverInfo": serverInfo, - "instructions": serverInstructions, + "instructions": serverInstructions(taskBound), } return result, true, 0, "", nil @@ -446,13 +469,15 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe case "tools/list": role := RoleOrchestrator + taskBound := false s.mu.Lock() host := s.host s.mu.Unlock() if host != nil { role = host.CallerRole(callerID) + _, taskBound = host.CallerTask(callerID) } - return map[string]any{"tools": toolCatalog(role)}, true, 0, "", nil + return map[string]any{"tools": toolCatalog(role, taskBound)}, true, 0, "", nil case "tools/call": var p struct { diff --git a/internal/mcp/protocol_test.go b/internal/mcp/protocol_test.go index 55a23a1..5cf49fe 100644 --- a/internal/mcp/protocol_test.go +++ b/internal/mcp/protocol_test.go @@ -119,6 +119,32 @@ func TestToolsListReturnsConcreteSchemas(t *testing.T) { } } +func TestToolsListTaskRegisterWorktreeIsTaskBound(t *testing.T) { + unbound := toolsListNames(t, &Server{}) + if containsString(unbound, "task_register_worktree") { + t.Fatalf("unbound tools leaked task_register_worktree: %v", unbound) + } + s := &Server{} + s.SetHost(&blockingToolHost{task: TaskInfo{ID: "task_1", Title: "Fix sidebar"}}) + bound := toolsListNames(t, s) + if !containsString(bound, "task_register_worktree") { + t.Fatalf("task-bound tools missing task_register_worktree: %v", bound) + } +} + +func TestInitializeTaskInstructionsAreTaskBound(t *testing.T) { + unbound := initializeInstructions(t, &Server{}) + if strings.Contains(unbound, "task_register_worktree") { + t.Fatalf("unbound initialize leaked task instruction: %q", unbound) + } + s := &Server{} + s.SetHost(&blockingToolHost{task: TaskInfo{ID: "task_1", Title: "Fix sidebar"}}) + bound := initializeInstructions(t, s) + if !strings.Contains(bound, "task_register_worktree") { + t.Fatalf("task-bound initialize missing worktree instruction: %q", bound) + } +} + func TestWrapToolResultDoesNotDuplicateStructuredJSON(t *testing.T) { result := ProcessOutput{ Content: strings.Repeat("x", 1024), @@ -162,6 +188,69 @@ func TestPingReturnsEmptyObject(t *testing.T) { } } +func toolsListNames(t *testing.T, s *Server) []string { + t.Helper() + req := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/list"}`) + resp := s.dispatch("caller", req) + if resp == nil { + t.Fatal("expected response for tools/list") + } + var parsed struct { + Result struct { + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(resp, &parsed); err != nil { + t.Fatalf("parse tools/list: %v\n%s", err, resp) + } + if parsed.Error != nil { + t.Fatalf("tools/list error: %+v", parsed.Error) + } + out := make([]string, 0, len(parsed.Result.Tools)) + for _, tool := range parsed.Result.Tools { + out = append(out, tool.Name) + } + return out +} + +func initializeInstructions(t *testing.T, s *Server) string { + t.Helper() + req := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) + resp := s.dispatch("caller", req) + if resp == nil { + t.Fatal("expected response for initialize") + } + var parsed struct { + Result struct { + Instructions string `json:"instructions"` + } `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(resp, &parsed); err != nil { + t.Fatalf("parse initialize: %v\n%s", err, resp) + } + if parsed.Error != nil { + t.Fatalf("initialize error: %+v", parsed.Error) + } + return parsed.Result.Instructions +} + +func containsString(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + func TestTypedInvalidArgsMapToInvalidParams(t *testing.T) { for _, errKind := range []string{ErrorKindInvalidArgs, ErrorKindInvalidKind} { _, code, msg, data := mapToolError(Errorf(errKind, "bad args")) diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index c29d3c4..4bc1868 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -60,6 +60,7 @@ type ToolHost interface { // callers default to RoleOrchestrator (treated as a top-level peer) // so they don't get silently denied. CallerRole(processID string) CallerRole + CallerTask(processID string) (TaskInfo, bool) // Lifecycle (SPEC §7). SpawnAgent(callerID string, args SpawnAgentArgs) (ProcessInfo, error) @@ -102,6 +103,7 @@ type ToolHost interface { ScratchpadWrite(name, content, expectedRevision string) (revision string, err error) ScratchpadAppend(name, content string) error ScratchpadDelete(name string) error + RegisterTaskWorktree(callerID string, args TaskRegisterWorktreeArgs) (TaskInfo, error) // Meta. WhoAmI(callerID string, includeTools bool) WhoAmI @@ -153,6 +155,7 @@ type Cursor struct { type ProjectStatus struct { Project ProjectMeta `json:"project"` Caller WhoAmI `json:"caller"` + Task *TaskInfo `json:"task,omitempty"` Processes []ProcessInfo `json:"processes"` Scratchpads []scratchpad.Entry `json:"scratchpads"` } @@ -167,6 +170,26 @@ type ProjectMeta struct { Key string `json:"key"` } +type TaskInfo struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Worktrees []TaskWorktree `json:"worktrees,omitempty"` +} + +type TaskWorktree struct { + Path string `json:"path"` + Branch string `json:"branch,omitempty"` + CreatedByProcessID string `json:"created_by_process_id,omitempty"` + RegisteredAt string `json:"registered_at,omitempty"` +} + +type TaskRegisterWorktreeArgs struct { + Path string `json:"path"` + Branch string `json:"branch,omitempty"` +} + // ProcessOutput is the get_process_output payload. By default it is // canonical text with light metadata; include_meta restores screen // geometry + version, and raw requests return stream bytes. @@ -341,6 +364,7 @@ type WhoAmI struct { Role CallerRole `json:"role"` ParentProcessID string `json:"parent_process_id,omitempty"` Project ProjectMeta `json:"project"` + Task *TaskInfo `json:"task,omitempty"` AvailableTools []string `json:"available_tools"` } @@ -836,6 +860,20 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, } return map[string]any{"ok": true}, 0, "", nil + case "task_register_worktree": + if _, ok := h.CallerTask(callerID); !ok { + return nil, codeRoleForbidden, "task_register_worktree: caller is not attached to a task", structuredKind(ErrorKindRoleForbidden) + } + var p TaskRegisterWorktreeArgs + if err := unmarshalParams(params, &p); err != nil { + return nil, codeInvalidParams, err.Error(), nil + } + if p.Path == "" { + return nil, codeInvalidParams, "task_register_worktree: path required", nil + } + info, err := h.RegisterTaskWorktree(callerID, p) + return mapToolResult(info, err) + case "whoami": var p WhoAmIArgs _ = unmarshalParamsOptional(params, &p) diff --git a/internal/pty/pty.go b/internal/pty/pty.go index 4f8beca..ccd26a7 100644 --- a/internal/pty/pty.go +++ b/internal/pty/pty.go @@ -19,11 +19,14 @@ type PTY struct { // Start spawns argv with stdin/stdout/stderr attached to a new PTY sized // (cols, rows). The returned PTY exposes the master fd for the parent to // read from and write to. -func Start(argv []string, env []string, cols, rows uint16) (*PTY, error) { +func Start(argv []string, env []string, workDir string, cols, rows uint16) (*PTY, error) { if len(argv) == 0 { return nil, fmt.Errorf("pty: empty argv") } cmd := exec.Command(argv[0], argv[1:]...) + if workDir != "" { + cmd.Dir = workDir + } if env != nil { cmd.Env = ensureTerm(env) } else { diff --git a/internal/pty/pty_test.go b/internal/pty/pty_test.go new file mode 100644 index 0000000..22c5c2a --- /dev/null +++ b/internal/pty/pty_test.go @@ -0,0 +1,40 @@ +package pty + +import ( + "io" + "strings" + "testing" +) + +func TestStartHonorsWorkDir(t *testing.T) { + dir := t.TempDir() + p, err := Start([]string{"sh", "-lc", "pwd"}, nil, dir, 80, 24) + if err != nil { + t.Fatalf("start: %v", err) + } + defer p.Close() + + var out strings.Builder + buf := make([]byte, 256) + for { + n, err := p.Read(buf) + if n > 0 { + out.Write(buf[:n]) + if strings.Contains(out.String(), dir) { + break + } + } + if err != nil { + if err == io.EOF || strings.Contains(err.Error(), "input/output error") { + break + } + t.Fatalf("read: %v", err) + } + } + if err := p.Wait(); err != nil { + t.Fatalf("wait: %v", err) + } + if got := out.String(); !strings.Contains(got, dir) { + t.Fatalf("pwd output %q does not contain %q", got, dir) + } +} diff --git a/internal/task/task.go b/internal/task/task.go new file mode 100644 index 0000000..f4c1310 --- /dev/null +++ b/internal/task/task.go @@ -0,0 +1,264 @@ +// Package task stores manual project-local tasks and the worktrees agents +// register while working on those tasks. +package task + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// Task is one manual project-local task. +type Task struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Worktrees []Worktree `json:"worktrees,omitempty"` +} + +// Worktree is a git worktree path registered by a task-bound agent. +type Worktree struct { + Path string `json:"path"` + Branch string `json:"branch,omitempty"` + CreatedByProcessID string `json:"created_by_process_id,omitempty"` + RegisteredAt string `json:"registered_at"` +} + +// Store is one project's tasks file. Safe for concurrent use. +type Store struct { + path string + + mu sync.Mutex + tasks map[string]Task + order []string +} + +// Open loads or creates the task store for projectKey. +func Open(projectKey string) (*Store, error) { + if projectKey == "" { + return nil, errors.New("task.Open: empty project key") + } + base, err := dataDir() + if err != nil { + return nil, err + } + dir := filepath.Join(base, "projects", projectKey) + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("task: mkdir %s: %w", dir, err) + } + path := filepath.Join(dir, "tasks.json") + s := &Store{path: path, tasks: make(map[string]Task)} + if err := s.loadLocked(); err != nil { + return nil, err + } + return s, nil +} + +func dataDir() (string, error) { + if h := os.Getenv("XDG_DATA_HOME"); h != "" { + return filepath.Join(h, "patterm"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "share", "patterm"), nil +} + +// Path returns the on-disk file path. Used by tests and diagnostics. +func (s *Store) Path() string { return s.path } + +// List returns tasks in creation order. +func (s *Store) List() []Task { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]Task, 0, len(s.order)) + for _, id := range s.order { + if t, ok := s.tasks[id]; ok { + out = append(out, cloneTask(t)) + } + } + return out +} + +// Get returns one task by id. +func (s *Store) Get(id string) (Task, bool) { + s.mu.Lock() + defer s.mu.Unlock() + t, ok := s.tasks[id] + if !ok { + return Task{}, false + } + return cloneTask(t), true +} + +// Create inserts a new task with title. +func (s *Store) Create(title string) (Task, error) { + title = strings.TrimSpace(title) + if title == "" { + return Task{}, errors.New("task.Create: empty title") + } + s.mu.Lock() + defer s.mu.Unlock() + now := timestamp() + t := Task{ID: s.mintIDLocked(), Title: title, CreatedAt: now, UpdatedAt: now} + s.tasks[t.ID] = t + s.order = append(s.order, t.ID) + if err := s.saveLocked(); err != nil { + return Task{}, err + } + return cloneTask(t), nil +} + +// Rename updates a task title. +func (s *Store) Rename(id, title string) (Task, error) { + title = strings.TrimSpace(title) + if title == "" { + return Task{}, errors.New("task.Rename: empty title") + } + s.mu.Lock() + defer s.mu.Unlock() + t, ok := s.tasks[id] + if !ok { + return Task{}, fmt.Errorf("task.Rename: no such task %q", id) + } + t.Title = title + t.UpdatedAt = timestamp() + s.tasks[id] = t + if err := s.saveLocked(); err != nil { + return Task{}, err + } + return cloneTask(t), nil +} + +// RegisterWorktree records or updates a worktree for a task. Paths are +// de-duplicated by cleaned absolute path. +func (s *Store) RegisterWorktree(taskID string, wt Worktree) (Task, error) { + path := strings.TrimSpace(wt.Path) + if path == "" { + return Task{}, errors.New("task.RegisterWorktree: empty path") + } + if !filepath.IsAbs(path) { + abs, err := filepath.Abs(path) + if err != nil { + return Task{}, err + } + path = abs + } + wt.Path = filepath.Clean(path) + wt.Branch = strings.TrimSpace(wt.Branch) + if wt.RegisteredAt == "" { + wt.RegisteredAt = timestamp() + } + + s.mu.Lock() + defer s.mu.Unlock() + t, ok := s.tasks[taskID] + if !ok { + return Task{}, fmt.Errorf("task.RegisterWorktree: no such task %q", taskID) + } + replaced := false + for i, existing := range t.Worktrees { + if filepath.Clean(existing.Path) == wt.Path { + if wt.CreatedByProcessID == "" { + wt.CreatedByProcessID = existing.CreatedByProcessID + } + t.Worktrees[i] = wt + replaced = true + break + } + } + if !replaced { + t.Worktrees = append(t.Worktrees, wt) + } + t.UpdatedAt = timestamp() + s.tasks[taskID] = t + if err := s.saveLocked(); err != nil { + return Task{}, err + } + return cloneTask(t), nil +} + +type fileShape struct { + Tasks []Task `json:"tasks"` +} + +func (s *Store) loadLocked() error { + b, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("task: read %s: %w", s.path, err) + } + if len(b) == 0 { + return nil + } + var f fileShape + if err := json.Unmarshal(b, &f); err != nil { + return fmt.Errorf("task: parse %s: %w", s.path, err) + } + for _, t := range f.Tasks { + if t.ID == "" || strings.TrimSpace(t.Title) == "" { + continue + } + t.Title = strings.TrimSpace(t.Title) + if _, exists := s.tasks[t.ID]; !exists { + s.order = append(s.order, t.ID) + } + s.tasks[t.ID] = cloneTask(t) + } + return nil +} + +func (s *Store) saveLocked() error { + out := make([]Task, 0, len(s.tasks)) + for _, id := range s.order { + if t, ok := s.tasks[id]; ok { + out = append(out, cloneTask(t)) + } + } + body, err := json.MarshalIndent(fileShape{Tasks: out}, "", " ") + if err != nil { + return err + } + body = append(body, '\n') + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, body, 0o600); err != nil { + return fmt.Errorf("task: write %s: %w", tmp, err) + } + if err := os.Rename(tmp, s.path); err != nil { + return fmt.Errorf("task: rename %s: %w", s.path, err) + } + return nil +} + +func (s *Store) mintIDLocked() string { + for { + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("task_%d", time.Now().UnixNano()) + } + id := "task_" + hex.EncodeToString(b[:]) + if _, exists := s.tasks[id]; !exists { + return id + } + } +} + +func cloneTask(t Task) Task { + if t.Worktrees != nil { + t.Worktrees = append([]Worktree(nil), t.Worktrees...) + } + return t +} + +func timestamp() string { return time.Now().UTC().Format(time.RFC3339) } diff --git a/internal/task/task_test.go b/internal/task/task_test.go new file mode 100644 index 0000000..4397acf --- /dev/null +++ b/internal/task/task_test.go @@ -0,0 +1,113 @@ +package task + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCreateListAndReload(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_DATA_HOME", dir) + + s1, err := Open("projkey") + if err != nil { + t.Fatalf("open: %v", err) + } + created, err := s1.Create(" Ship task sidebar ") + if err != nil { + t.Fatalf("create: %v", err) + } + if created.ID == "" || created.Title != "Ship task sidebar" || created.CreatedAt == "" || created.UpdatedAt == "" { + t.Fatalf("created task incomplete: %+v", created) + } + + s2, err := Open("projkey") + if err != nil { + t.Fatalf("reopen: %v", err) + } + got := s2.List() + if len(got) != 1 { + t.Fatalf("tasks len = %d, want 1 (%v)", len(got), got) + } + if got[0].ID != created.ID || got[0].Title != "Ship task sidebar" { + t.Fatalf("reload mismatch: got %+v want %+v", got[0], created) + } + if _, err := os.Stat(s2.Path()); err != nil { + t.Fatalf("stat tasks.json: %v", err) + } +} + +func TestCreateRequiresTitle(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + s, err := Open("projkey") + if err != nil { + t.Fatalf("open: %v", err) + } + if _, err := s.Create(" "); err == nil { + t.Fatalf("create with empty title should fail") + } +} + +func TestRenameTask(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + s, err := Open("projkey") + if err != nil { + t.Fatalf("open: %v", err) + } + created, err := s.Create("Old") + if err != nil { + t.Fatalf("create: %v", err) + } + renamed, err := s.Rename(created.ID, "New") + if err != nil { + t.Fatalf("rename: %v", err) + } + if renamed.Title != "New" { + t.Fatalf("title = %q, want New", renamed.Title) + } + got, ok := s.Get(created.ID) + if !ok || got.Title != "New" { + t.Fatalf("get after rename = %+v, %v", got, ok) + } +} + +func TestRegisterWorktreeDedupesByPath(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + s, err := Open("projkey") + if err != nil { + t.Fatalf("open: %v", err) + } + task, err := s.Create("Worktree task") + if err != nil { + t.Fatalf("create: %v", err) + } + path := filepath.Join(t.TempDir(), "repo", "..", "repo") + updated, err := s.RegisterWorktree(task.ID, Worktree{Path: path, Branch: "one", CreatedByProcessID: "p1"}) + if err != nil { + t.Fatalf("register: %v", err) + } + if len(updated.Worktrees) != 1 { + t.Fatalf("worktrees len = %d, want 1", len(updated.Worktrees)) + } + clean := filepath.Clean(path) + if updated.Worktrees[0].Path != clean { + t.Fatalf("path = %q, want %q", updated.Worktrees[0].Path, clean) + } + updated, err = s.RegisterWorktree(task.ID, Worktree{Path: clean, Branch: "two"}) + if err != nil { + t.Fatalf("register duplicate: %v", err) + } + if len(updated.Worktrees) != 1 { + t.Fatalf("duplicate should replace, got %+v", updated.Worktrees) + } + if got := updated.Worktrees[0]; got.Branch != "two" || got.CreatedByProcessID != "p1" { + t.Fatalf("duplicate replace mismatch: %+v", got) + } +} + +func TestOpenRequiresProjectKey(t *testing.T) { + if _, err := Open(""); err == nil { + t.Fatalf("open with empty project key should fail") + } +}