Add task sidebar workflow

This commit is contained in:
2026-06-24 12:28:56 +01:00
parent 45263d59f8
commit d133839194
33 changed files with 1890 additions and 134 deletions
@@ -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/<projectKey>/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_<hex>`.
- 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: <title>` 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`.
+8
View File
@@ -7,6 +7,12 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added ### 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 - MCP clients can now call `scratchpad_delete` with a scratchpad name
to remove a shared project scratchpad. 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. only include full tool lists when `include_tools` is requested.
### Fixed ### Fixed
- Child PTYs now honor their configured working directory when
launched.
- Injected agent input now sends the submit Enter as a separated, - Injected agent input now sends the submit Enter as a separated,
settled keystroke so messages reliably submit instead of sometimes settled keystroke so messages reliably submit instead of sometimes
sitting unsent in the composer. sitting unsent in the composer.
+1 -1
View File
@@ -108,7 +108,7 @@ func run(argv []string, cols, rows uint16, idleMS int, followHost, stdinPassthro
} }
defer em.Close() defer em.Close()
child, err := pty.Start(argv, nil, cols, rows) child, err := pty.Start(argv, nil, "", cols, rows)
if err != nil { if err != nil {
return fmt.Errorf("pty: %w", err) return fmt.Errorf("pty: %w", err)
} }
+333 -17
View File
@@ -21,6 +21,7 @@ import (
"github.com/hjbdev/patterm/internal/persist" "github.com/hjbdev/patterm/internal/persist"
"github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad" "github.com/hjbdev/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/task"
"github.com/hjbdev/patterm/internal/trust" "github.com/hjbdev/patterm/internal/trust"
"github.com/hjbdev/patterm/internal/vt" "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) 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 // In-process MCP server bound to the per-PID socket. Children that
// support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`. // support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`.
// SPEC §10. // 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 // Wire the tool host into MCP. Spawns through MCP use the host
// terminal's viewport grid for their initial PTY size; SIGWINCH paths // terminal's viewport grid for their initial PTY size; SIGWINCH paths
// resize them later. // 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) mcpSrv.SetHost(host)
var restoreState *term.State var restoreState *term.State
@@ -166,6 +172,7 @@ func Run(ctx context.Context, opts Options) error {
presets: presets, presets: presets,
launcher: launcher, launcher: launcher,
pads: pads, pads: pads,
tasks: taskStore,
chromeWake: make(chan struct{}, 1), chromeWake: make(chan struct{}, 1),
trust: trustStore, trust: trustStore,
timers: host.timers, timers: host.timers,
@@ -194,6 +201,7 @@ func Run(ctx context.Context, opts Options) error {
host.focus = st host.focus = st
host.prompter = st host.prompter = st
host.scratch = st host.scratch = st
host.taskUI = st
st.lastExit.Store(-1) st.lastExit.Store(-1)
sess.Subscribe(st) sess.Subscribe(st)
go st.summaries.run(ctx) go st.summaries.run(ctx)
@@ -402,6 +410,7 @@ type uiState struct {
presets preset.Set presets preset.Set
launcher *Launcher launcher *Launcher
pads *scratchpad.Store pads *scratchpad.Store
tasks *task.Store
trust *trust.Store trust *trust.Store
timers *timerManager timers *timerManager
@@ -417,6 +426,11 @@ type uiState struct {
// exclusive with focusedID. The palette also reads this to surface // exclusive with focusedID. The palette also reads this to surface
// scratchpad-specific actions at the top of the command list. // scratchpad-specific actions at the top of the command list.
focusedPad string 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 // padOffset is the index of the top-most rendered row in the
// markdown-formatted view of focusedPad. Reset when focus moves to // markdown-formatted view of focusedPad. Reset when focus moves to
// a different pad; preserved across content changes for the same // 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 // repaint; the cache invalidates in scratchpadsChanged() which is
// the canonical "pads mutated" signal from MCP write/append. nil // the canonical "pads mutated" signal from MCP write/append. nil
// means "never read yet" — next caller refreshes. // means "never read yet" — next caller refreshes.
padsCacheMu sync.Mutex padsCacheMu sync.Mutex
padsCache []scratchpad.Entry padsCache []scratchpad.Entry
tasksCacheMu sync.Mutex
tasksCache []task.Task
lastExit atomic.Int32 lastExit atomic.Int32
} }
@@ -585,8 +601,9 @@ func (st *uiState) focusProcess(processID string) {
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
onAlt := childIsOnAlt(c) onAlt := childIsOnAlt(c)
st.mu.Lock() st.mu.Lock()
leavingPad := st.focusedPad != "" leavingStaticView := st.focusedPad != "" || st.focusedTaskID != ""
st.focusedPad = "" st.focusedPad = ""
st.focusedTaskID = ""
st.focusedID = c.ID st.focusedID = c.ID
st.focusedName = c.DisplayName() st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c) st.updateActiveAgentLocked(c)
@@ -597,7 +614,7 @@ func (st *uiState) focusProcess(processID string) {
st.syncHostMouseForChild(onAlt) st.syncHostMouseForChild(onAlt)
// Wipe whatever the previous focus (PTY child or pad view) left in // Wipe whatever the previous focus (PTY child or pad view) left in
// the viewport before painting the new child's snapshot. // the viewport before painting the new child's snapshot.
if leavingPad { if leavingStaticView {
st.clearViewportArea() st.clearViewportArea()
} }
st.repaintFocused() st.repaintFocused()
@@ -656,6 +673,7 @@ func (st *uiState) focusScratchpad(name string) {
st.padOffsetName = name st.padOffsetName = name
} }
st.focusedPad = name st.focusedPad = name
st.focusedTaskID = ""
st.focusedID = "" st.focusedID = ""
st.focusedName = name st.focusedName = name
st.renderer = nil st.renderer = nil
@@ -667,6 +685,29 @@ func (st *uiState) focusScratchpad(name string) {
st.drawStatusLine() 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 // clearViewportArea wipes the rectangle the focused-child PTY (or pad
// view) paints into so the next paint starts on a clean canvas. Used // view) paints into so the next paint starts on a clean canvas. Used
// when transitioning between pad and child focus. // when transitioning between pad and child focus.
@@ -691,8 +732,11 @@ func (st *uiState) clearViewportArea() {
func (st *uiState) repaintFocusedWithChrome() { func (st *uiState) repaintFocusedWithChrome() {
st.mu.Lock() st.mu.Lock()
padFocused := st.focusedPad != "" padFocused := st.focusedPad != ""
taskFocused := st.focusedTaskID != ""
st.mu.Unlock() st.mu.Unlock()
if padFocused { if taskFocused {
st.repaintFocusedTask()
} else if padFocused {
st.repaintFocusedPad() st.repaintFocusedPad()
} else { } else {
st.repaintFocused() 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() { func (st *uiState) invalidateScratchpadsCache() {
st.padsCacheMu.Lock() st.padsCacheMu.Lock()
st.padsCache = nil st.padsCache = nil
@@ -795,6 +851,15 @@ func (st *uiState) invalidateScratchpadsCache() {
st.chromeCacheMu.Unlock() 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 // OnChildSpawned auto-focuses the new child when the spawn came from
// the user (palette, persistence restore, or an external MCP client with // the user (palette, persistence restore, or an external MCP client with
// no resolved identity). When ParentID is set — meaning a patterm-managed // 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 { if st.palette != nil {
st.palette.children = st.sess.Children() st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID 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.palette.rebuild()
st.renderPaletteLocked() st.renderPaletteLocked()
} }
@@ -823,6 +892,7 @@ func (st *uiState) OnChildSpawned(c *Child) {
onAlt := childIsOnAlt(c) onAlt := childIsOnAlt(c)
st.mu.Lock() st.mu.Lock()
st.focusedPad = "" st.focusedPad = ""
st.focusedTaskID = ""
st.focusedID = c.ID st.focusedID = c.ID
st.focusedName = c.DisplayName() st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c) st.updateActiveAgentLocked(c)
@@ -833,6 +903,10 @@ func (st *uiState) OnChildSpawned(c *Child) {
if palOpen { if palOpen {
st.palette.children = st.sess.Children() st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID 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.palette.rebuild()
st.renderPaletteLocked() st.renderPaletteLocked()
} }
@@ -915,6 +989,10 @@ func (st *uiState) OnChildExited(c *Child) {
if st.palette != nil { if st.palette != nil {
st.palette.children = st.sess.Children() st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID 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.palette.rebuild()
st.renderPaletteLocked() st.renderPaletteLocked()
} }
@@ -1158,6 +1236,40 @@ func (st *uiState) padsList() []scratchpad.Entry {
return entries 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 // markChromeDirty schedules a chrome (tab bar + status line) repaint
// on the next ticker frame. Cheap to call from the per-PTY-chunk hot // on the next ticker frame. Cheap to call from the per-PTY-chunk hot
// path. Latency-sensitive sites (focus change, owner flip, attention, // path. Latency-sensitive sites (focus change, owner flip, attention,
@@ -1269,6 +1381,7 @@ func (st *uiState) drawStatusLine() {
palOpen := st.palette != nil palOpen := st.palette != nil
focusID := st.focusedID focusID := st.focusedID
focusName := st.focusedName focusName := st.focusedName
focusTaskID := st.focusedTaskID
var trustMsg string var trustMsg string
if st.pendingTrust != nil { if st.pendingTrust != nil {
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName) 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" owner = "you have control"
} }
} }
if focusTaskID != "" && focusName == "" {
focusName = st.taskTitle(focusTaskID)
}
left := "" left := ""
if focusName != "" { if focusTaskID != "" && focusName != "" {
left = "task: " + focusName
} else if focusName != "" {
left = 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 owner != "" {
if left != "" { if left != "" {
left = left + " · " + owner left = left + " · " + owner
@@ -1369,7 +1496,7 @@ func (st *uiState) drawStatusLine() {
// child is focused. // child is focused.
func (st *uiState) renderEmptyState() { func (st *uiState) renderEmptyState() {
layout := st.layoutSnapshot() 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) row := int(layout.mainTop) + (int(layout.childRows()) / 2)
col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2) col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2)
if row < int(layout.mainTop) { if row < int(layout.mainTop) {
@@ -1790,13 +1917,13 @@ func (st *uiState) processStdin(chunk []byte) {
} }
if hit, adv := matchCtrlChar(chunk, i, 'w'); hit { if hit, adv := matchCtrlChar(chunk, i, 'w'); hit {
flushForward() 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 i += adv
break break
} }
if hit, adv := matchCtrlChar(chunk, i, 's'); hit { if hit, adv := matchCtrlChar(chunk, i, 's'); hit {
flushForward() 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 i += adv
break break
} }
@@ -1884,6 +2011,8 @@ func (st *uiState) processStdin(chunk []byte) {
} }
if !pendingNav.empty() { if !pendingNav.empty() {
switch { switch {
case pendingNav.isTask():
st.focusTask(pendingNav.taskID)
case pendingNav.isPad(): case pendingNav.isPad():
st.focusScratchpad(pendingNav.pad) st.focusScratchpad(pendingNav.pad)
case pendingNav.isChild(): case pendingNav.isChild():
@@ -1965,7 +2094,7 @@ func (st *uiState) openPaletteLocked() {
st.settingsMu.Lock() st.settingsMu.Lock()
appSettings := st.settings.clone() appSettings := st.settings.clone()
st.settingsMu.Unlock() 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 // Push a "no kitty flags" entry onto the host terminal's keyboard
// stack so palette input arrives in plain legacy form regardless of // stack so palette input arrives in plain legacy form regardless of
// what the focused child pushed. Codex/ratatui enables kitty mode // what the focused child pushed. Codex/ratatui enables kitty mode
@@ -2001,7 +2130,12 @@ func (st *uiState) closePalette(action paletteAction) {
restoreView := func() { restoreView := func() {
st.mu.Lock() st.mu.Lock()
padFocused := st.focusedPad != "" padFocused := st.focusedPad != ""
taskFocused := st.focusedTaskID != ""
st.mu.Unlock() st.mu.Unlock()
if taskFocused {
st.repaintFocusedTask()
return
}
if padFocused { if padFocused {
st.repaintFocusedPad() st.repaintFocusedPad()
return return
@@ -2025,7 +2159,7 @@ func (st *uiState) closePalette(action paletteAction) {
st.launcher.SetSize(l.childCols(), l.childRows()) st.launcher.SetSize(l.childCols(), l.childRows())
// LaunchAgent fires OnChildSpawned synchronously; it will draw // LaunchAgent fires OnChildSpawned synchronously; it will draw
// chrome and set focus. // 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)) st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
} }
@@ -2036,14 +2170,14 @@ func (st *uiState) closePalette(action paletteAction) {
} }
l := st.layoutSnapshot() l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows()) 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)) st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
} }
case "spawn-terminal": case "spawn-terminal":
l := st.layoutSnapshot() l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows()) 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)) 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 // shell=true so multi-word commands like "bun run dev" pass
// through `sh -lc` and the user's PATH resolves binaries the // through `sh -lc` and the user's PATH resolves binaries the
// way they expect from an interactive shell. // 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 { if err != nil {
st.flashError(fmt.Sprintf("spawn: %v", err)) st.flashError(fmt.Sprintf("spawn: %v", err))
return return
@@ -2077,6 +2211,18 @@ func (st *uiState) closePalette(action paletteAction) {
st.drawSidebar() 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": case "switch":
c := st.sess.FindChild(action.childID) c := st.sess.FindChild(action.childID)
if c == nil || (c.Kind == KindAgent && c.Status() != StatusRunning) { if c == nil || (c.Kind == KindAgent && c.Status() != StatusRunning) {
@@ -2085,8 +2231,9 @@ func (st *uiState) closePalette(action paletteAction) {
} }
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
st.mu.Lock() st.mu.Lock()
leavingPad := st.focusedPad != "" leavingStaticView := st.focusedPad != "" || st.focusedTaskID != ""
st.focusedPad = "" st.focusedPad = ""
st.focusedTaskID = ""
st.focusedID = action.childID st.focusedID = action.childID
st.focusedName = c.DisplayName() st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c) 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 // Switching from a pad to a child: wipe the pad body so the
// child's snapshot paints onto a clean canvas, mirroring // child's snapshot paints onto a clean canvas, mirroring
// focusProcess. // focusProcess.
if leavingPad { if leavingStaticView {
st.clearViewportArea() st.clearViewportArea()
} }
st.repaintFocused() 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) { func (st *uiState) applySettingsAction(action paletteAction) {
if action.settings == nil { if action.settings == nil {
return return
@@ -2459,9 +2670,14 @@ func (st *uiState) repaintFocused() {
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
st.mu.Lock() st.mu.Lock()
id := st.focusedID id := st.focusedID
taskID := st.focusedTaskID
renderer := st.renderer renderer := st.renderer
st.mu.Unlock() st.mu.Unlock()
if id == "" { if id == "" {
if taskID != "" {
st.repaintFocusedTask()
return
}
st.renderEmptyState() st.renderEmptyState()
return return
} }
@@ -2492,6 +2708,106 @@ func (st *uiState) repaintFocused() {
st.renderToasts() 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 // repaintFocusedPad paints the focused scratchpad's content into the
// main viewport, honouring the per-pad scroll offset and clamping it // 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 // to the rendered body size so a shrunk pad doesn't leave the view
+8 -8
View File
@@ -83,10 +83,10 @@ func TestCanonicalizeTerminalTextMaxLines(t *testing.T) {
func TestGetProcessOutputStreamCanonicalByDefault(t *testing.T) { func TestGetProcessOutputStreamCanonicalByDefault(t *testing.T) {
sess := NewSession(t.TempDir(), "test") sess := NewSession(t.TempDir(), "test")
c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "") c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "", "")
addChild(sess, c) addChild(sess, c)
c.recordWrite([]byte("\x1b[31mStatus: running 12s\x1b[0m\nStatus: running 13s\nresult\n")) 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"}) out, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "stream"})
if err != nil { if err != nil {
@@ -105,10 +105,10 @@ func TestGetProcessOutputStreamCanonicalByDefault(t *testing.T) {
func TestGetProcessOutputRawReturnsStreamBytes(t *testing.T) { func TestGetProcessOutputRawReturnsStreamBytes(t *testing.T) {
sess := NewSession(t.TempDir(), "test") sess := NewSession(t.TempDir(), "test")
c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "") c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "", "")
addChild(sess, c) addChild(sess, c)
c.recordWrite([]byte("\x1b[31mred\x1b[0m")) 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}) out, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "grid", Raw: true})
if err != nil { if err != nil {
@@ -130,10 +130,10 @@ func TestGetProcessOutputRawReturnsStreamBytes(t *testing.T) {
func TestGetProcessOutputCanonicalAfterRawRead(t *testing.T) { func TestGetProcessOutputCanonicalAfterRawRead(t *testing.T) {
sess := NewSession(t.TempDir(), "test") sess := NewSession(t.TempDir(), "test")
c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "") c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "", "")
addChild(sess, c) addChild(sess, c)
c.recordWrite([]byte("\x1b[31mStatus: running 12s\x1b[0m\nStatus: running 13s\nDownloading 10%\rDownloading 100%\nFINAL: deploy ready\n")) 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 { if _, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "stream", Raw: true}); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -149,10 +149,10 @@ func TestGetProcessOutputCanonicalAfterRawRead(t *testing.T) {
func TestGetProcessOutputIncludeMetaRestoresFields(t *testing.T) { func TestGetProcessOutputIncludeMetaRestoresFields(t *testing.T) {
sess := NewSession(t.TempDir(), "test") sess := NewSession(t.TempDir(), "test")
c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "") c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "", "")
addChild(sess, c) addChild(sess, c)
c.recordWrite([]byte("ok")) 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}) out, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "stream", IncludeMeta: true})
if err != nil { if err != nil {
+4 -2
View File
@@ -77,6 +77,7 @@ type Child struct {
WorkDir string WorkDir string
Kind ChildKind Kind ChildKind
ParentID string // empty for top-level sessions 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 // PresetRef names the source preset (when known). Used by trust
// gating to re-check on restart_process. Empty for freeform-argv // 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 const ringCap = 1 << 20 // 1 MiB per SPEC §5
// newChildEntry builds the in-memory Child record but does NOT start a PTY. // 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{ c := &Child{
ID: id, ID: id,
Name: name, Name: name,
@@ -200,6 +201,7 @@ func newChildEntry(id, name string, kind ChildKind, argv, env []string, parentID
WorkDir: workDir, WorkDir: workDir,
Kind: kind, Kind: kind,
ParentID: parentID, ParentID: parentID,
TaskID: taskID,
PresetRef: presetRef, PresetRef: presetRef,
ring: make([]byte, ringCap), ring: make([]byte, ringCap),
} }
@@ -228,7 +230,7 @@ func (c *Child) startPTY(cols, rows uint16) (uint64, error) {
} }
starting := StatusStarting starting := StatusStarting
c.status.Store(&starting) 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 { if err != nil {
em.Close() em.Close()
errored := StatusErrored errored := StatusErrored
+132 -26
View File
@@ -2,6 +2,7 @@ package app
import ( import (
"fmt" "fmt"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
@@ -12,6 +13,7 @@ import (
"github.com/hjbdev/patterm/internal/mcp" "github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad" "github.com/hjbdev/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/task"
"github.com/hjbdev/patterm/internal/trust" "github.com/hjbdev/patterm/internal/trust"
pkgvt "github.com/hjbdev/patterm/internal/vt" pkgvt "github.com/hjbdev/patterm/internal/vt"
) )
@@ -41,11 +43,16 @@ type scratchpadSink interface {
scratchpadsChanged() scratchpadsChanged()
} }
type taskSink interface {
tasksChanged()
}
// toolHost adapts the running session + scratchpad store + trust store // toolHost adapts the running session + scratchpad store + trust store
// to the MCP ToolHost interface. SPEC §7 tools route through here. // to the MCP ToolHost interface. SPEC §7 tools route through here.
type toolHost struct { type toolHost struct {
sess *Session sess *Session
pads *scratchpad.Store pads *scratchpad.Store
tasks *task.Store
launcher *Launcher launcher *Launcher
presets preset.Set presets preset.Set
trust *trust.Store trust *trust.Store
@@ -61,6 +68,7 @@ type toolHost struct {
focus focusSink focus focusSink
prompter trustPrompter prompter trustPrompter
scratch scratchpadSink scratch scratchpadSink
taskUI taskSink
timers *timerManager timers *timerManager
} }
@@ -76,10 +84,11 @@ const (
maxSearchMatches = 50 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{ h := &toolHost{
sess: sess, sess: sess,
pads: pads, pads: pads,
tasks: tasks,
launcher: launcher, launcher: launcher,
presets: presets, presets: presets,
trust: tr, trust: tr,
@@ -157,6 +166,21 @@ func (h *toolHost) CallerRole(processID string) mcp.CallerRole {
return mcp.RoleSubAgent 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 // Lifecycle
// ─────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────
@@ -176,8 +200,14 @@ func (h *toolHost) SpawnAgent(callerID string, args mcp.SpawnAgentArgs) (mcp.Pro
if display == "" { if display == "" {
display = args.Agent display = args.Agent
} }
prompt := wrapSubAgentPrompt(args.AgentInstructions, h.sess.FindChild(callerID) != nil) var taskInfo *mcp.TaskInfo
c, err := h.launcher.LaunchAgent(p, display, prompt, callerID) 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 { if err != nil {
return mcp.ProcessInfo{}, err 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'") return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindInvalidKind, "spawn_process: kind must be 'command' or 'terminal'")
} }
env := h.mergeEnv(args.Env) 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" { 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 { if err != nil {
return mcp.ProcessInfo{}, err return mcp.ProcessInfo{}, err
} }
@@ -215,7 +249,7 @@ func (h *toolHost) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp
if display == "" { if display == "" {
display = ps.Name display = ps.Name
} }
c, err := h.launcher.LaunchCommandPreset(ps, display, callerID) c, err := h.launcher.LaunchCommandPreset(ps, display, ctx)
if err != nil { if err != nil {
return mcp.ProcessInfo{}, err return mcp.ProcessInfo{}, err
} }
@@ -229,7 +263,7 @@ func (h *toolHost) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp
if display == "" { if display == "" {
display = args.Argv[0] 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 { if err != nil {
return mcp.ProcessInfo{}, err return mcp.ProcessInfo{}, err
} }
@@ -368,12 +402,16 @@ func (h *toolHost) GetProjectStatus(callerID string, includeTools bool) (mcp.Pro
caller := h.WhoAmI(callerID, includeTools) caller := h.WhoAmI(callerID, includeTools)
processes := h.ListProcesses(callerID, "") processes := h.ListProcesses(callerID, "")
pads, _ := h.pads.List() pads, _ := h.pads.List()
return mcp.ProjectStatus{ status := mcp.ProjectStatus{
Project: caller.Project, Project: caller.Project,
Caller: caller, Caller: caller,
Processes: processes, Processes: processes,
Scratchpads: pads, 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) { 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 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 { func (h *toolHost) WhoAmI(callerID string, includeTools bool) mcp.WhoAmI {
role := h.CallerRole(callerID)
taskInfo, taskBound := h.CallerTask(callerID)
w := mcp.WhoAmI{ w := mcp.WhoAmI{
ProcessID: callerID, ProcessID: callerID,
Role: h.CallerRole(callerID), Role: role,
Project: mcp.ProjectMeta{ Project: mcp.ProjectMeta{
Path: h.sess.projectDir, Path: h.sess.projectDir,
Key: h.sess.projectKey, Key: h.sess.projectKey,
}, },
} }
if taskBound {
w.Task = &taskInfo
}
if includeTools { if includeTools {
w.AvailableTools = availableToolsForRole(h.CallerRole(callerID)) w.AvailableTools = availableToolsForRole(role, taskBound)
} }
if c := h.sess.FindChild(callerID); c != nil { if c := h.sess.FindChild(callerID); c != nil {
w.Name = c.DisplayName() w.Name = c.DisplayName()
@@ -976,6 +1056,27 @@ func (h *toolHost) processInfoOf(c *Child) mcp.ProcessInfo {
return info 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 { func (h *toolHost) chromeHintsFor(presetName string) []string {
if presetName == "" { if presetName == "" {
return nil return nil
@@ -1034,23 +1135,25 @@ func (h *toolHost) askForTrust(callerID, presetName, reason string) {
h.prompter.promptTrust(callerID, presetName, reason) h.prompter.promptTrust(callerID, presetName, reason)
} }
// wrapSubAgentPrompt prepends a one-line orientation block to the // buildAgentPrompt prepends one-line orientation blocks to the initial
// caller-supplied agent_instructions. patterm injects nothing on its // prompt. The blocks are single-line on purpose: writeInput splits on
// own (SPEC §7), but vendor TUIs that learn their role purely from // CR/LF, so any embedded newline would submit prematurely.
// their first turn need to be told they're a sub-agent — otherwise func buildAgentPrompt(instructions string, hasParent bool, taskInfo *mcp.TaskInfo) string {
// they finish without reporting back to the parent or cleaning up var parts []string
// processes/scratchpads they spawned. The block is single-line on if hasParent && (instructions != "" || taskInfo != nil) {
// purpose: writeInput splits on CR/LF, so any embedded newline would 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').]")
// submit prematurely.
func wrapSubAgentPrompt(instructions string, hasParent bool) string {
if !hasParent {
return instructions
} }
if instructions == "" { if taskInfo != nil {
return "" 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').] " if instructions != "" {
return preface + 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. // 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 // availableToolsForRole — SPEC §7 whoami exposes the list a caller can
// invoke from its current role. Sub-agents lose `spawn_agent` (§8 // invoke from its current role. Sub-agents lose `spawn_agent` (§8
// two-level-tree rule). // two-level-tree rule).
func availableToolsForRole(role mcp.CallerRole) []string { func availableToolsForRole(role mcp.CallerRole, taskBound bool) []string {
tools := []string{ tools := []string{
"spawn_process", "start_process", "restart_process", "stop_process", "spawn_process", "start_process", "restart_process", "stop_process",
"close_process", "rename_process", "select_process", "close_process", "rename_process", "select_process",
@@ -1289,6 +1392,9 @@ func availableToolsForRole(role mcp.CallerRole) []string {
if role == mcp.RoleOrchestrator { if role == mcp.RoleOrchestrator {
tools = append([]string{"spawn_agent"}, tools...) tools = append([]string{"spawn_agent"}, tools...)
} }
if taskBound {
tools = append(tools, "task_register_worktree")
}
return tools return tools
} }
+99 -7
View File
@@ -1,11 +1,14 @@
package app package app
import ( import (
"path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/hjbdev/patterm/internal/mcp" "github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad" "github.com/hjbdev/patterm/internal/scratchpad"
taskstore "github.com/hjbdev/patterm/internal/task"
) )
// mkChild builds a Child without starting a PTY. Use sparingly — the // 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) { func TestBuildAgentPromptPrependsSystemBlockWhenParented(t *testing.T) {
out := wrapSubAgentPrompt("ship feature X", true) out := buildAgentPrompt("ship feature X", true, nil)
if !strings.HasPrefix(out, "[system:") { if !strings.HasPrefix(out, "[system:") {
t.Fatalf("expected prepended [system: …] block, got %q", out) t.Fatalf("expected prepended [system: …] block, got %q", out)
} }
@@ -119,18 +122,18 @@ func TestWrapSubAgentPromptPrependsSystemBlockWhenParented(t *testing.T) {
} }
} }
func TestWrapSubAgentPromptPassthroughWhenNoParent(t *testing.T) { func TestBuildAgentPromptPassthroughWhenNoParent(t *testing.T) {
out := wrapSubAgentPrompt("hello", false) out := buildAgentPrompt("hello", false, nil)
if out != "hello" { if out != "hello" {
t.Fatalf("expected passthrough for top-level spawn, got %q", out) 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 // Empty instructions mean "no inject" upstream; we must not synthesize
// content here or LaunchAgent would type the system block into an // content here or LaunchAgent would type the system block into an
// otherwise-idle agent. // otherwise-idle agent.
if out := wrapSubAgentPrompt("", true); out != "" { if out := buildAgentPrompt("", true, nil); out != "" {
t.Fatalf("empty instructions should stay empty, got %q", 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", "timer_cancel", "timer_pause", "timer_resume", "timer_list",
} }
for _, role := range []mcp.CallerRole{mcp.RoleOrchestrator, mcp.RoleSubAgent} { for _, role := range []mcp.CallerRole{mcp.RoleOrchestrator, mcp.RoleSubAgent} {
tools := availableToolsForRole(role) tools := availableToolsForRole(role, false)
for _, w := range want { for _, w := range want {
if !containsString(tools, w) { if !containsString(tools, w) {
t.Fatalf("role %q missing %q in available tools: %v", role, w, tools) 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 // TestHelpTimersDocumentsAllTools mirrors the whoami check for the
// help("timers") topic — the related-tools list must enumerate every // help("timers") topic — the related-tools list must enumerate every
// timer_* tool so callers reading help can dispatch them. // timer_* tool so callers reading help can dispatch them.
+33 -13
View File
@@ -24,6 +24,12 @@ type Launcher struct {
cols, rows uint16 cols, rows uint16
} }
type LaunchContext struct {
ParentID string
TaskID string
WorkDir string
}
func NewLauncher(sess *Session, mcpSocket string, cols, rows uint16) *Launcher { func NewLauncher(sess *Session, mcpSocket string, cols, rows uint16) *Launcher {
bin, err := os.Executable() bin, err := os.Executable()
if err != nil { if err != nil {
@@ -47,7 +53,7 @@ func (l *Launcher) size() (uint16, uint16) {
// LaunchAgent spawns the agent preset, applies the preset's MCP // LaunchAgent spawns the agent preset, applies the preset's MCP
// injection, waits for the ready signal, and types initial_prompt into // injection, waits for the ready signal, and types initial_prompt into
// the PTY. SPEC §7 spawn_agent, §8 conversation protocol. // 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 { if p.Kind != preset.KindAgent {
return nil, fmt.Errorf("launch: %q is not an agent preset", p.Name) 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, Argv: argv,
Env: env, Env: env,
Name: displayName, Name: displayName,
ParentID: parentID, ParentID: ctx.ParentID,
TaskID: ctx.TaskID,
WorkDir: firstNonEmpty(ctx.WorkDir, p.WorkingDir),
PresetRef: p.Name, PresetRef: p.Name,
Identity: identity, Identity: identity,
CleanupPaths: cleanupPaths, 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 // LaunchCommandPreset spawns a process preset as a SPEC §7 command
// entry. No MCP injection; just argv. The entry is session-persistent // entry. No MCP injection; just argv. The entry is session-persistent
// (survives PTY exit so it can be Restart'd). // (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 { if p.Kind != preset.KindCommand {
return nil, fmt.Errorf("launch: %q is not a command preset", p.Name) 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(), Argv: p.ResolvedArgv(),
Env: env, Env: env,
Name: displayName, Name: displayName,
ParentID: parentID, ParentID: ctx.ParentID,
WorkDir: p.WorkingDir, TaskID: ctx.TaskID,
WorkDir: firstNonEmpty(ctx.WorkDir, p.WorkingDir),
PresetRef: p.Name, PresetRef: p.Name,
IdleDetection: resolveIdleDetection(p.IdleDetection), IdleDetection: resolveIdleDetection(p.IdleDetection),
}, cols, rows) }, 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 // LaunchCommandArgv spawns a freeform-argv command entry. Trust gating
// (SPEC §7) lives one level up in toolHost — by the time we get here // (SPEC §7) lives one level up in toolHost — by the time we get here
// trust is settled (freeform argv is implicitly trusted). // 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 { if shell && len(argv) > 0 {
argv = []string{"sh", "-lc", strings.Join(argv, " ")} argv = []string{"sh", "-lc", strings.Join(argv, " ")}
} }
@@ -204,8 +213,9 @@ func (l *Launcher) LaunchCommandArgv(argv []string, displayName, parentID, workD
Argv: argv, Argv: argv,
Env: env, Env: env,
Name: displayName, Name: displayName,
ParentID: parentID, ParentID: ctx.ParentID,
WorkDir: workDir, TaskID: ctx.TaskID,
WorkDir: ctx.WorkDir,
}, cols, rows) }, cols, rows)
} }
@@ -223,7 +233,7 @@ func (l *Launcher) RestoreCommand(e persist.Entry, presets preset.Set) (*Child,
if e.PresetRef != "" { if e.PresetRef != "" {
for _, p := range presets.Processes { for _, p := range presets.Processes {
if p.Name == e.PresetRef { 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 // 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 { if len(e.Argv) == 0 {
return nil, fmt.Errorf("restore: entry %s has no argv", e.ID) 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. // LaunchTerminal spawns a bare interactive shell. SPEC §7 kind=terminal.
// argv defaults to $SHELL -i when empty. // 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 { if len(argv) == 0 {
sh := os.Getenv("SHELL") sh := os.Getenv("SHELL")
if sh == "" { if sh == "" {
@@ -255,11 +265,21 @@ func (l *Launcher) LaunchTerminal(argv []string, displayName, parentID, workDir
Argv: argv, Argv: argv,
Env: env, Env: env,
Name: displayName, Name: displayName,
ParentID: parentID, ParentID: ctx.ParentID,
WorkDir: workDir, TaskID: ctx.TaskID,
WorkDir: ctx.WorkDir,
}, cols, rows) }, cols, rows)
} }
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
func (l *Launcher) writeMCPConfig(identity string) (string, error) { func (l *Launcher) writeMCPConfig(identity string) (string, error) {
dir, err := mcpRuntimeDir(identity) dir, err := mcpRuntimeDir(identity)
if err != nil { if err != nil {
+1 -1
View File
@@ -50,7 +50,7 @@ func TestSpawnSizingUsesViewportDimensions(t *testing.T) {
t.Fatalf("launcher size: got %dx%d want 91x36", cols, rows) 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() cols, rows = host.size()
if cols != 91 || rows != 36 { if cols != 91 || rows != 36 {
t.Fatalf("tool host size: got %dx%d want 91x36", cols, rows) t.Fatalf("tool host size: got %dx%d want 91x36", cols, rows)
+118 -17
View File
@@ -7,6 +7,7 @@ import (
"unicode/utf8" "unicode/utf8"
"github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/task"
) )
// paletteAction is what the palette returns when the user picks an item. // 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. // For pad-* actions, the scratchpad name to operate on.
padName string 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 newName string
// For settings actions, the updated settings snapshot to persist. // 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 / // renameForm is a one-field inline form used by the "Rename scratchpad /
// agent / process" context palette entries. The submit action kind // 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 // is carried alongside so closePalette knows what to apply the new
// name to. // name to.
type renameForm struct { type renameForm struct {
name []rune name []rune
subject string // "pad" | "agent" | "proc" subject string // "pad" | "task" | "task-create" | "agent" | "proc"
target string // padName for "pad"; childID for "agent"/"proc" target string // padName for "pad"; childID for "agent"/"proc"
title string // e.g. "Rename" title string // e.g. "Rename"
subjectLine string // e.g. "scratchpad: notes.md" rendered above the input 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 // paletteState is the in-memory model for the overlay. SPEC §4: a
// single fuzzy-searchable list of commands scoped to the current focus. // single fuzzy-searchable list of commands scoped to the current focus.
type paletteState struct { type paletteState struct {
query []rune query []rune
cursor int cursor int
children []*Child children []*Child
focused string focused string
focusedPad string focusedPad string
presets preset.Set focusedTaskID string
settings settings tasksEnabled bool
tasks []task.Task
presets preset.Set
settings settings
items []paletteItem items []paletteItem
@@ -135,9 +142,9 @@ type paletteState struct {
// macro is active. Typing `sw <query>` filters to switch entries only, // macro is active. Typing `sw <query>` filters to switch entries only,
// `k <query>` to close entries, `sp <query>` to spawn entries. // `k <query>` to close entries, `sp <query>` to spawn entries.
var macroPrefixes = map[string][]string{ var macroPrefixes = map[string][]string{
"sw": {"switch"}, "sw": {"switch", "task-switch"},
"k": {"kill", "agent-close", "proc-stop", "proc-delete"}, "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 // 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 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 { 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() st := defaultSettings()
if len(appSettings) > 0 { if len(appSettings) > 0 {
st = appSettings[0].clone() 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() p.rebuild()
return p return p
} }
@@ -259,6 +289,21 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
paletteItem{label: "Delete", hint: "delete scratchpad · " + name, paletteItem{label: "Delete", hint: "delete scratchpad · " + name,
action: paletteAction{kind: "pad-delete", padName: name}, group: groupFocused}, 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 != "": case p.focused != "":
if c := findChildByID(p.children, p.focused); c != nil { if c := findChildByID(p.children, p.focused); c != nil {
name := c.DisplayName() name := c.DisplayName()
@@ -291,13 +336,46 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
action: paletteAction{kind: "proc-delete", childID: c.ID}, group: groupFocused}, 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 // the one already focused (no point offering a no-op switch). Dead
// agents are filtered out (no restart path); dead command processes // agents are filtered out (no restart path); dead command processes
// remain so they can be restarted. // 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 { for _, c := range p.children {
if c.ID == p.focused { if c.ID == p.focused {
continue continue
@@ -649,6 +727,16 @@ func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) {
p.mode = paletteModeSpawnForm p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{} p.form = &spawnProcessForm{}
return paletteAction{}, false, adv 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": case "settings-open":
p.mode = paletteModeSettings p.mode = paletteModeSettings
p.query = nil 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) { 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.mode = paletteModeRenameForm
p.renameForm = &renameForm{ p.renameForm = &renameForm{
name: []rune(current), name: []rune(current),
subject: subject, subject: subject,
target: target, target: target,
title: "Rename", title: title,
subjectLine: subjectLine, subjectLine: subjectLine,
} }
} }
@@ -921,6 +1013,10 @@ func (p *paletteState) submitRename() paletteAction {
case "pad": case "pad":
kind = "pad-rename-submit" kind = "pad-rename-submit"
return paletteAction{kind: kind, padName: p.renameForm.target, newName: newName} 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": case "agent":
kind = "agent-rename-submit" kind = "agent-rename-submit"
case "proc": case "proc":
@@ -1151,12 +1247,17 @@ func (p *paletteState) selectableIndex() int {
} }
// focusedSubject returns the short context string shown in the title // focusedSubject returns the short context string shown in the title
// bar — "on: <child>" / "pad: <name>" / "" — so the user knows which // bar — "on: <child>" / "pad: <name>" / "task: <title>" / "" — so the
// focus the context-section is targeting. // user knows which focus the context-section is targeting.
func (p *paletteState) focusedSubject() string { func (p *paletteState) focusedSubject() string {
if p.focusedPad != "" { if p.focusedPad != "" {
return "pad: " + 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 p.focused != "" {
if c := findChildByID(p.children, p.focused); c != nil { if c := findChildByID(p.children, p.focused); c != nil {
return "on: " + c.DisplayName() return "on: " + c.DisplayName()
+49
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/task"
) )
// makeFakeChild builds a Child with just enough state for the palette // 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) { func TestContextItemsTerminalUsesCloseNotStop(t *testing.T) {
c := makeFakeChild("tid", "terminal", KindTerminal) c := makeFakeChild("tid", "terminal", KindTerminal)
p := newPalette([]*Child{c}, "tid", "", preset.Set{}) p := newPalette([]*Child{c}, "tid", "", preset.Set{})
+1 -1
View File
@@ -6,7 +6,7 @@ import (
) )
func newRingChild() *Child { func newRingChild() *Child {
return newChildEntry("id", "name", KindCommand, nil, nil, "", "", "") return newChildEntry("id", "name", KindCommand, nil, nil, "", "", "", "")
} }
func TestRingShortWrite(t *testing.T) { func TestRingShortWrite(t *testing.T) {
+1 -1
View File
@@ -116,7 +116,7 @@ func TestToolHostScratchpadDeleteRemovesPadAndRefreshes(t *testing.T) {
t.Fatalf("write doomed.md: %v", err) t.Fatalf("write doomed.md: %v", err)
} }
recorder := &scratchpadChangeRecorder{} 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 host.scratch = recorder
if err := host.ScratchpadDelete("doomed.md"); err != nil { if err := host.ScratchpadDelete("doomed.md"); err != nil {
+2 -1
View File
@@ -201,6 +201,7 @@ type SpawnSpec struct {
WorkDir string WorkDir string
Name string Name string
ParentID string ParentID string
TaskID string
PresetRef string PresetRef string
Identity string // pre-minted; otherwise the constructor mints one for agents Identity string // pre-minted; otherwise the constructor mints one for agents
// CleanupPaths are owned runtime files/dirs removed when the child exits // 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() 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 != "" { if spec.Identity != "" {
c.Identity = spec.Identity c.Identity = spec.Identity
} }
+37
View File
@@ -168,6 +168,7 @@ func (st *uiState) drawSidebar() {
palOpen := st.palette != nil palOpen := st.palette != nil
focus := st.focusedID focus := st.focusedID
focusPad := st.focusedPad focusPad := st.focusedPad
focusTask := st.focusedTaskID
activeAgent := st.activeAgentID activeAgent := st.activeAgentID
st.mu.Unlock() st.mu.Unlock()
if palOpen { 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, // Processes section — top-level command/terminal processes,
// session-wide (does not change when the user switches agent tabs). // session-wide (does not change when the user switches agent tabs).
writeHeader("Processes") writeHeader("Processes")
+3 -3
View File
@@ -12,11 +12,11 @@ func TestOnChildSpawnedAgentChildKeepsFocus(t *testing.T) {
sess := NewSession(t.TempDir(), "test") sess := NewSession(t.TempDir(), "test")
st := &uiState{sess: sess} 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.focusedID = parent.ID
st.focusedName = parent.Name 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) st.OnChildSpawned(subAgent)
@@ -36,7 +36,7 @@ func TestOnChildSpawnedPaletteChildTakesFocus(t *testing.T) {
st := &uiState{sess: sess} st := &uiState{sess: sess}
st.lastExit.Store(-1) st.lastExit.Store(-1)
c := newChildEntry("p_new", "newchild", KindAgent, nil, nil, "", "", "") c := newChildEntry("p_new", "newchild", KindAgent, nil, nil, "", "", "", "")
st.OnChildSpawned(c) st.OnChildSpawned(c)
+2 -2
View File
@@ -89,7 +89,7 @@ func TestSummaryTextForSelectsChildAndClips(t *testing.T) {
func TestSummaryManagerArmsOnlyTrackedTopLevelAgents(t *testing.T) { func TestSummaryManagerArmsOnlyTrackedTopLevelAgents(t *testing.T) {
sess := NewSession(t.TempDir(), "test") sess := NewSession(t.TempDir(), "test")
c := newChildEntry("a1", "agent", KindAgent, []string{"fake"}, nil, "", "", "") c := newChildEntry("a1", "agent", KindAgent, []string{"fake"}, nil, "", "", "", "")
running := StatusRunning running := StatusRunning
c.status.Store(&running) c.status.Store(&running)
sess.children[c.ID] = c 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) 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) sub.status.Store(&running)
m.RegisterChild(sub) m.RegisterChild(sub)
m.ObserveHumanInput(sub.ID, []byte("please summarize")) m.ObserveHumanInput(sub.ID, []byte("please summarize"))
+1 -1
View File
@@ -41,7 +41,7 @@ func (r *recorderFire) snapshot() []recordedFire {
// Doesn't open a PTY — fireFn is overridden so InjectAsOrchestrator is // Doesn't open a PTY — fireFn is overridden so InjectAsOrchestrator is
// never reached. // never reached.
func fakeChild(id string) *Child { func fakeChild(id string) *Child {
c := newChildEntry(id, id, KindAgent, []string{"echo"}, nil, "", "", "") c := newChildEntry(id, id, KindAgent, []string{"echo"}, nil, "", "", "", "")
running := StatusRunning running := StatusRunning
c.status.Store(&running) c.status.Store(&running)
return c return c
+23 -13
View File
@@ -1,16 +1,21 @@
package app 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 // 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 // one of taskID, childID, or pad is set. childID points at a Child by ID;
// names a scratchpad entry. Empty zero-value means "no target". // pad names a scratchpad entry. Empty zero-value means "no target".
type navEntry struct { type navEntry struct {
taskID string
childID string childID string
pad 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) isPad() bool { return n.pad != "" }
func (n navEntry) isChild() bool { return n.childID != "" } func (n navEntry) isChild() bool { return n.childID != "" }
@@ -221,12 +226,14 @@ func sidebarNavList(children []*Child, activeAgentID string) []*Child {
return out return out
} }
// sidebarNav returns the combined Processes + Agent Tree + Scratchpads // sidebarNav returns the combined Tasks + Processes + Agent Tree + Scratchpads
// navigation list. Scratchpads always appear after children so the // navigation list. Order matches the right rail top-to-bottom.
// existing "step past the tree" expectation still holds. func sidebarNav(children []*Child, activeAgentID string, tasks []task.Task, pads []scratchpad.Entry) []navEntry {
func sidebarNav(children []*Child, activeAgentID string, pads []scratchpad.Entry) []navEntry {
flat := sidebarNavList(children, activeAgentID) 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 { for _, c := range flat {
out = append(out, navEntry{childID: c.ID}) 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 // nextNavEntry returns the entry `step` positions away from the
// current focus in the unified nav list. Either focusChildID or // current focus in the unified nav list. Exactly one focus identifier is
// focusPad will be set (or both empty for "nothing focused yet"). // usually set (or all empty for "nothing focused yet").
// Empty when there's nothing else to land on. // Empty when there's nothing else to land on.
func nextNavEntry(children []*Child, focusChildID, focusPad, activeAgentID string, pads []scratchpad.Entry, step int) navEntry { func nextNavEntry(children []*Child, focusChildID, focusPad, focusTaskID, activeAgentID string, tasks []task.Task, pads []scratchpad.Entry, step int) navEntry {
flat := sidebarNav(children, activeAgentID, pads) flat := sidebarNav(children, activeAgentID, tasks, pads)
if len(flat) == 0 { if len(flat) == 0 {
return navEntry{} return navEntry{}
} }
matches := func(e navEntry) bool { matches := func(e navEntry) bool {
if focusTaskID != "" && e.taskID != "" {
return e.taskID == focusTaskID
}
if focusPad != "" && e.pad != "" { if focusPad != "" && e.pad != "" {
return e.pad == focusPad return e.pad == focusPad
} }
+33 -1
View File
@@ -1,6 +1,10 @@
package app package app
import "testing" import (
"testing"
"github.com/hjbdev/patterm/internal/task"
)
func TestVisibleSessionTreeScopesToFocusedRoot(t *testing.T) { func TestVisibleSessionTreeScopesToFocusedRoot(t *testing.T) {
root1 := testChild("c1", "root1", "", StatusRunning) 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) { func TestSidebarNavListIncludesExitedProcesses(t *testing.T) {
p := testProcess("p1", "shell", StatusExited) p := testProcess("p1", "shell", StatusExited)
r := testAgent("a1", "claude", "", StatusRunning) r := testAgent("a1", "claude", "", StatusRunning)
+4 -4
View File
@@ -23,9 +23,9 @@ func TestRestartRestoresUserCommandProcess(t *testing.T) {
} }
sc := &Scenario{ sc := &Scenario{
Name: "restart_persist", Name: "restart_persist",
Cols: 120, Cols: 120,
Rows: 40, Rows: 40,
Trust: []string{"persist-target"}, Trust: []string{"persist-target"},
Presets: ScenarioPresets{ Presets: ScenarioPresets{
Processes: []ScenarioPreset{{ Processes: []ScenarioPreset{{
@@ -143,7 +143,7 @@ func openSession(t *testing.T, env *testEnv, childEnv []string) *Session {
if err != nil { if err != nil {
t.Fatalf("vt emulator: %v", err) 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 { if err != nil {
_ = em.Close() _ = em.Close()
t.Fatalf("pty start: %v", err) t.Fatalf("pty start: %v", err)
@@ -22,7 +22,7 @@
{ "type": "assert_contains", "contains": "Scratchpads" }, { "type": "assert_contains", "contains": "Scratchpads" },
{ {
"type": "assert_regex", "type": "assert_regex",
"regex": "(?m)^[^\\n]*\\+ new[^\\n]*Processes[^\\n]*$" "regex": "(?m)^[^\\n]*\\+ new[^\\n]*Tasks[^\\n]*$"
}, },
{ {
"type": "assert_regex", "type": "assert_regex",
@@ -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"
}
]
}
+1 -1
View File
@@ -55,7 +55,7 @@ func NewCLI(opts Options) (*Session, error) {
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
_ = em.Close() _ = em.Close()
return nil, err return nil, err
+12 -2
View File
@@ -108,10 +108,17 @@ type blockingToolHost struct {
waitEntered chan struct{} waitEntered chan struct{}
waitRelease chan struct{} waitRelease chan struct{}
waitOnce sync.Once waitOnce sync.Once
task TaskInfo
} }
func (h *blockingToolHost) ResolveCallerIdentity(identity string) string { return "caller-" + identity } func (h *blockingToolHost) ResolveCallerIdentity(identity string) string { return "caller-" + identity }
func (h *blockingToolHost) CallerRole(string) CallerRole { return RoleOrchestrator } 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) { func (h *blockingToolHost) SpawnAgent(string, SpawnAgentArgs) (ProcessInfo, error) {
return ProcessInfo{}, nil 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) ScratchpadAppend(string, string) error { return nil }
func (h *blockingToolHost) ScratchpadDelete(string) error { return nil } func (h *blockingToolHost) ScratchpadDelete(string) error { return nil }
func (h *blockingToolHost) WhoAmI(string, bool) WhoAmI { return WhoAmI{} } func (h *blockingToolHost) RegisterTaskWorktree(string, TaskRegisterWorktreeArgs) (TaskInfo, error) {
func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} } return TaskInfo{}, nil
}
func (h *blockingToolHost) WhoAmI(string, bool) WhoAmI { return WhoAmI{} }
func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} }
+35 -10
View File
@@ -29,7 +29,7 @@ var serverInfo = map[string]any{
"version": "0.1.0", "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 // 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 server. Failure modes we've seen and want to head off:
// - The agent assumes patterm is something it has to launch (running // - 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. // 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. // 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 // toolDescriptor is the shape returned by `tools/list`. inputSchema is
// a JSON Schema object — we provide a minimal `{type: "object"}` schema // 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 // toolCatalog is the full list advertised via tools/list. Descriptions
// are intentionally short — clients are expected to fetch help() for // are intentionally short — clients are expected to fetch help() for
// detail. Schemas mirror the param structs in tools.go. // detail. Schemas mirror the param structs in tools.go.
func toolCatalog(role CallerRole) []toolDescriptor { func toolCatalog(role CallerRole, taskBound bool) []toolDescriptor {
tools := []toolDescriptor{ tools := []toolDescriptor{
{ {
Name: "spawn_agent", Name: "spawn_agent",
@@ -382,6 +389,14 @@ func toolCatalog(role CallerRole) []toolDescriptor {
"name": stringProp("Scratchpad name."), "name": stringProp("Scratchpad name."),
}, []string{"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", Name: "whoami",
Description: "Return caller identity, role, parent, and project metadata.", Description: "Return caller identity, role, parent, and project metadata.",
@@ -397,14 +412,15 @@ func toolCatalog(role CallerRole) []toolDescriptor {
}, nil), }, nil),
}, },
} }
if role != RoleSubAgent {
return tools
}
filtered := tools[:0] filtered := tools[:0]
for _, tool := range tools { for _, tool := range tools {
if tool.Name != "spawn_agent" { if role == RoleSubAgent && tool.Name == "spawn_agent" {
filtered = append(filtered, tool) continue
} }
if !taskBound && tool.Name == "task_register_worktree" {
continue
}
filtered = append(filtered, tool)
} }
return filtered return filtered
} }
@@ -426,13 +442,20 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
if protoVersion == "" { if protoVersion == "" {
protoVersion = supportedProtocolVersion protoVersion = supportedProtocolVersion
} }
taskBound := false
s.mu.Lock()
host := s.host
s.mu.Unlock()
if host != nil {
_, taskBound = host.CallerTask(callerID)
}
result := map[string]any{ result := map[string]any{
"protocolVersion": protoVersion, "protocolVersion": protoVersion,
"capabilities": map[string]any{ "capabilities": map[string]any{
"tools": map[string]any{"listChanged": false}, "tools": map[string]any{"listChanged": false},
}, },
"serverInfo": serverInfo, "serverInfo": serverInfo,
"instructions": serverInstructions, "instructions": serverInstructions(taskBound),
} }
return result, true, 0, "", nil return result, true, 0, "", nil
@@ -446,13 +469,15 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
case "tools/list": case "tools/list":
role := RoleOrchestrator role := RoleOrchestrator
taskBound := false
s.mu.Lock() s.mu.Lock()
host := s.host host := s.host
s.mu.Unlock() s.mu.Unlock()
if host != nil { if host != nil {
role = host.CallerRole(callerID) 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": case "tools/call":
var p struct { var p struct {
+89
View File
@@ -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) { func TestWrapToolResultDoesNotDuplicateStructuredJSON(t *testing.T) {
result := ProcessOutput{ result := ProcessOutput{
Content: strings.Repeat("x", 1024), 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) { func TestTypedInvalidArgsMapToInvalidParams(t *testing.T) {
for _, errKind := range []string{ErrorKindInvalidArgs, ErrorKindInvalidKind} { for _, errKind := range []string{ErrorKindInvalidArgs, ErrorKindInvalidKind} {
_, code, msg, data := mapToolError(Errorf(errKind, "bad args")) _, code, msg, data := mapToolError(Errorf(errKind, "bad args"))
+38
View File
@@ -60,6 +60,7 @@ type ToolHost interface {
// callers default to RoleOrchestrator (treated as a top-level peer) // callers default to RoleOrchestrator (treated as a top-level peer)
// so they don't get silently denied. // so they don't get silently denied.
CallerRole(processID string) CallerRole CallerRole(processID string) CallerRole
CallerTask(processID string) (TaskInfo, bool)
// Lifecycle (SPEC §7). // Lifecycle (SPEC §7).
SpawnAgent(callerID string, args SpawnAgentArgs) (ProcessInfo, error) SpawnAgent(callerID string, args SpawnAgentArgs) (ProcessInfo, error)
@@ -102,6 +103,7 @@ type ToolHost interface {
ScratchpadWrite(name, content, expectedRevision string) (revision string, err error) ScratchpadWrite(name, content, expectedRevision string) (revision string, err error)
ScratchpadAppend(name, content string) error ScratchpadAppend(name, content string) error
ScratchpadDelete(name string) error ScratchpadDelete(name string) error
RegisterTaskWorktree(callerID string, args TaskRegisterWorktreeArgs) (TaskInfo, error)
// Meta. // Meta.
WhoAmI(callerID string, includeTools bool) WhoAmI WhoAmI(callerID string, includeTools bool) WhoAmI
@@ -153,6 +155,7 @@ type Cursor struct {
type ProjectStatus struct { type ProjectStatus struct {
Project ProjectMeta `json:"project"` Project ProjectMeta `json:"project"`
Caller WhoAmI `json:"caller"` Caller WhoAmI `json:"caller"`
Task *TaskInfo `json:"task,omitempty"`
Processes []ProcessInfo `json:"processes"` Processes []ProcessInfo `json:"processes"`
Scratchpads []scratchpad.Entry `json:"scratchpads"` Scratchpads []scratchpad.Entry `json:"scratchpads"`
} }
@@ -167,6 +170,26 @@ type ProjectMeta struct {
Key string `json:"key"` 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 // ProcessOutput is the get_process_output payload. By default it is
// canonical text with light metadata; include_meta restores screen // canonical text with light metadata; include_meta restores screen
// geometry + version, and raw requests return stream bytes. // geometry + version, and raw requests return stream bytes.
@@ -341,6 +364,7 @@ type WhoAmI struct {
Role CallerRole `json:"role"` Role CallerRole `json:"role"`
ParentProcessID string `json:"parent_process_id,omitempty"` ParentProcessID string `json:"parent_process_id,omitempty"`
Project ProjectMeta `json:"project"` Project ProjectMeta `json:"project"`
Task *TaskInfo `json:"task,omitempty"`
AvailableTools []string `json:"available_tools"` 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 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": case "whoami":
var p WhoAmIArgs var p WhoAmIArgs
_ = unmarshalParamsOptional(params, &p) _ = unmarshalParamsOptional(params, &p)
+4 -1
View File
@@ -19,11 +19,14 @@ type PTY struct {
// Start spawns argv with stdin/stdout/stderr attached to a new PTY sized // 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 // (cols, rows). The returned PTY exposes the master fd for the parent to
// read from and write 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 { if len(argv) == 0 {
return nil, fmt.Errorf("pty: empty argv") return nil, fmt.Errorf("pty: empty argv")
} }
cmd := exec.Command(argv[0], argv[1:]...) cmd := exec.Command(argv[0], argv[1:]...)
if workDir != "" {
cmd.Dir = workDir
}
if env != nil { if env != nil {
cmd.Env = ensureTerm(env) cmd.Env = ensureTerm(env)
} else { } else {
+40
View File
@@ -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)
}
}
+264
View File
@@ -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) }
+113
View File
@@ -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")
}
}