Add scratchpad_delete MCP tool

Mirrors the existing scratchpad_* tools end-to-end: catalog schema,
dispatch, ToolHost.ScratchpadDelete, and a host method that delegates to
scratchpad.Store.Delete and fires scratchpadsChanged() on success so the
sidebar refreshes. Missing-pad errors surface rather than being masked.

Resolves the [MCP SCRATCHPAD DELETE] TODO item.
This commit is contained in:
2026-05-25 12:23:58 +01:00
parent f61788eff2
commit 96f7c66d5f
6 changed files with 146 additions and 5 deletions

View File

@@ -6,6 +6,10 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added
- MCP clients can now call `scratchpad_delete` with a scratchpad name
to remove a shared project scratchpad.
### Fixed ### Fixed
- Sidebar timer indicators now repaint as their visible countdown - Sidebar timer indicators now repaint as their visible countdown
value changes, so labels progress from minutes to seconds without value changes, so labels progress from minutes to seconds without

69
TODO.md
View File

@@ -1,5 +1,74 @@
- [ ] We should deduplicate /r/n newlines or /n newlines to save tokens on mcp responses for terminal reads.
- [ ] Codex idle detection seems to trigger too soon, see below [CODEX IDLE]
- [ ] Issue with mcp timing out [MCP TIMEOUT]
- [ ] I have to run the close action twice on opencode for it to actually close the tab.
- [ ] When opening a codex sub agent, the message gets input to the field, but the message is never submitted. - [ ] When opening a codex sub agent, the message gets input to the field, but the message is never submitted.
- This appears to be inconsistent. Sometimes it works, sometimes it doesn't. Might be because of popups on codex sub agents? - This appears to be inconsistent. Sometimes it works, sometimes it doesn't. Might be because of popups on codex sub agents?
- Question: when it fails, is a Codex startup popup visible (trust/workspace, auth/model selection, permissions), or is the normal composer focused? - Question: when it fails, is a Codex startup popup visible (trust/workspace, auth/model selection, permissions), or is the normal composer focused?
- Question: if the message is sitting in the composer, does pressing Enter once manually submit it, or does something else need to be dismissed first? - Question: if the message is sitting in the composer, does pressing Enter once manually submit it, or does something else need to be dismissed first?
- Question: does this happen with short one-line prompts as well as long/multiline sub-agent instructions? - Question: does this happen with short one-line prompts as well as long/multiline sub-agent instructions?
# [CODEX IDLE]
Crunched for 1m 57s │
│ Agent Tree
codex-implement is idle. Read your own pane for the [sub-agent:codex-implement] reply. │ ─────────────────────────
│ ▎ ○ claude ⏱ 59m
Called patterm, searched memories (ctrl+o to expand) │ ● codex-implement-2
● Bash(git -C /home/harry/Dev/popdog/.worktrees/harvester-issue-136-kick-segment-repair log -3 --oneline && git -C /home/harry/Dev/popdog/.worktrees/harvester-issue-13…) │ Issue #136 plan drafted
 9604013 fix(youtube): finalize segments on ghost-cleanup path (#120) │ in scratchpad;
6da9125 fix(kick): bump KickCategoryAuditor MaxPerCycle to 500 (#135) │ verification sub-agent
db149e6 fix(twitch): enqueue stream segment repairs (#134) │
 Allowed by auto mode classifier │ Scratchpads
│ ─────────────────────────
● No commits in the worktree. Let me check the codex-implement pane. │ 0027-review
│ 569-state
Called patterm (ctrl+o to expand) │ db-infra-options
│ db-perf-batching-plan
● Codex exited mid-update prompting "Please restart Codex." Let me spawn a fresh one — the update is now installed. │ e2e-canonicalisation-ru…
│ issue-136-kick-segment-…
Called patterm 3 times (ctrl+o to expand) │ pg-stat-optimization-de…
│ pg-stat-statements-find…
● Fresh codex-implement-2 spawned. Waiting. │ pr-569-stream-category-…
│ stream-category-cutover…
✻ Sautéed for 55s │
codex-implement-2 is idle. Read your own pane for the [sub-agent:codex-implement-2] reply. │
● Bash(git log -3 --oneline && echo "---STATUS---" && git status -s && echo "---BRANCH---" && git branch -vv && echo "---REMOTE---" && git ls-remote origin fix/kick-se…) │
 9604013 fix(youtube): finalize segments on ghost-cleanup path (#120) │
6da9125 fix(kick): bump KickCategoryAuditor MaxPerCycle to 500 (#135) │
db149e6 fix(twitch): enqueue stream segment repairs (#134) │
… +44 lines (ctrl+o to expand) │
 Allowed by auto mode classifier │
 Interrupted · What should Claude do instead? │
it's still running. │
● Got it — re-scheduling the wait. │
Called patterm (ctrl+o to expand) │
● Waiting for codex-implement-2 to finish. │
✻ Sautéed for 12s │
7 tasks (5 done, 1 in progress, 1 open) │
◼ Spawn codex sub-agent to implement plan │
◻ Open PR for issue 136 │
✔ Read GitHub issue 136 and gather reference code │
✔ Set up worktree for issue 136 implementation │
✔ Draft implementation plan │
… +2 completed │
# [MCP TIMEOUT]
⚙ patterm_send_input [key=enter, kind=key, process_id=p_a6726d, submit=false, tail_mode=stream, text=, wait_ms=1000] │
⚙ patterm_wait_for_pattern [pattern=Findings|No findings|No issues|Residual risk, process_id=p_a6726d, scope=scrollback, timeout_seconds=300] │
MCP error -32001: Request timed out │
⚙ patterm_get_process_status [process_id=p_a6726d] │
MCP error -32001: Request timed out │

View File

@@ -832,6 +832,14 @@ func (h *toolHost) ScratchpadAppend(name, content string) error {
return err return err
} }
func (h *toolHost) ScratchpadDelete(name string) error {
err := h.pads.Delete(name)
if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged()
}
return err
}
func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI { func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI {
w := mcp.WhoAmI{ w := mcp.WhoAmI{
ProcessID: callerID, ProcessID: callerID,
@@ -1091,7 +1099,7 @@ func availableToolsForRole(role mcp.CallerRole) []string {
"send_input", "send_message", "request_human_attention", "send_input", "send_message", "request_human_attention",
"timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all", "timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list", "timer_cancel", "timer_pause", "timer_resume", "timer_list",
"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_delete",
"whoami", "help", "whoami", "help",
} }
if role == mcp.RoleOrchestrator { if role == mcp.RoleOrchestrator {
@@ -1146,8 +1154,8 @@ func helpFor(topic string) mcp.HelpResponse {
case "scratchpads": case "scratchpads":
return mcp.HelpResponse{ return mcp.HelpResponse{
Topic: "scratchpads", Topic: "scratchpads",
Content: "Project-scoped markdown files. Read returns content + revision; pass that back as expected_revision on write to get last-write-wins-with-detection. Append is unconditional.", Content: "Project-scoped markdown files. Read returns content + revision; pass that back as expected_revision on write to get last-write-wins-with-detection. Append is unconditional; delete removes a pad by name.",
RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append"}, RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_delete"},
} }
case "timers": case "timers":
return mcp.HelpResponse{ return mcp.HelpResponse{

View File

@@ -1,10 +1,12 @@
package app package app
import ( import (
"errors"
"io" "io"
"os" "os"
"testing" "testing"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad" "github.com/hjbdev/patterm/internal/scratchpad"
) )
@@ -95,3 +97,41 @@ func TestDeletingLastFocusedScratchpadFocusesRunningChild(t *testing.T) {
t.Fatalf("focusedID = %q, want pid", st.focusedID) t.Fatalf("focusedID = %q, want pid", st.focusedID)
} }
} }
type scratchpadChangeRecorder struct {
count int
}
func (r *scratchpadChangeRecorder) scratchpadsChanged() {
r.count++
}
func TestToolHostScratchpadDeleteRemovesPadAndRefreshes(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
pads, err := scratchpad.Open("scratchpad-delete-host-test")
if err != nil {
t.Fatalf("scratchpad.Open: %v", err)
}
if _, err := pads.Write("doomed.md", "content", ""); err != nil {
t.Fatalf("write doomed.md: %v", err)
}
recorder := &scratchpadChangeRecorder{}
host := newToolHost(nil, pads, nil, preset.Set{}, nil, 120, 40)
host.scratch = recorder
if err := host.ScratchpadDelete("doomed.md"); err != nil {
t.Fatalf("ScratchpadDelete: %v", err)
}
if recorder.count != 1 {
t.Fatalf("scratchpadsChanged calls = %d, want 1", recorder.count)
}
if _, _, err := pads.Read("doomed.md"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("read deleted pad error = %v, want os.ErrNotExist", err)
}
if err := host.ScratchpadDelete("doomed.md"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("delete missing error = %v, want os.ErrNotExist", err)
}
if recorder.count != 1 {
t.Fatalf("scratchpadsChanged calls after failed delete = %d, want 1", recorder.count)
}
}

View File

@@ -358,6 +358,13 @@ func toolCatalog() []toolDescriptor {
"content": stringProp("Text to append."), "content": stringProp("Text to append."),
}, []string{"name", "content"}), }, []string{"name", "content"}),
}, },
{
Name: "scratchpad_delete",
Description: "Delete a scratchpad entry.",
InputSchema: objectSchema(map[string]any{
"name": stringProp("Scratchpad name."),
}, []string{"name"}),
},
{ {
Name: "whoami", Name: "whoami",
Description: "Return the caller's identity, role, parent, project metadata, and available tools.", Description: "Return the caller's identity, role, parent, project metadata, and available tools.",

View File

@@ -101,6 +101,7 @@ type ToolHost interface {
ScratchpadRead(name string) (content string, revision string, err error) ScratchpadRead(name string) (content string, revision string, err error)
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
// Meta. // Meta.
WhoAmI(callerID string) WhoAmI WhoAmI(callerID string) WhoAmI
@@ -244,8 +245,8 @@ type TimerInfo struct {
ID string `json:"timer_id"` ID string `json:"timer_id"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
Body string `json:"body,omitempty"` Body string `json:"body,omitempty"`
Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all" Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all"
Status string `json:"status"` // "pending" | "paused" Status string `json:"status"` // "pending" | "paused"
OwnerID string `json:"owner_process_id"` OwnerID string `json:"owner_process_id"`
WatchedIDs []string `json:"watched,omitempty"` WatchedIDs []string `json:"watched,omitempty"`
FiresAtUnixMS int64 `json:"fires_at_unix_ms,omitempty"` FiresAtUnixMS int64 `json:"fires_at_unix_ms,omitempty"`
@@ -776,6 +777,18 @@ 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 "scratchpad_delete":
var p struct {
Name string `json:"name"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.ScratchpadDelete(p.Name); err != nil {
return nil, codeInternal, err.Error(), nil
}
return map[string]any{"ok": true}, 0, "", nil
case "whoami": case "whoami":
return h.WhoAmI(callerID), 0, "", nil return h.WhoAmI(callerID), 0, "", nil