diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6c497..1f449da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- MCP clients can now call `scratchpad_delete` with a scratchpad name + to remove a shared project scratchpad. + ### Fixed - Sidebar timer indicators now repaint as their visible countdown value changes, so labels progress from minutes to seconds without diff --git a/TODO.md b/TODO.md index 628b485..fc892e3 100644 --- a/TODO.md +++ b/TODO.md @@ -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. - 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: 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? + + + +# [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 │ diff --git a/internal/app/host.go b/internal/app/host.go index 651ca91..370ca31 100644 --- a/internal/app/host.go +++ b/internal/app/host.go @@ -832,6 +832,14 @@ func (h *toolHost) ScratchpadAppend(name, content string) error { 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 { w := mcp.WhoAmI{ ProcessID: callerID, @@ -1091,7 +1099,7 @@ func availableToolsForRole(role mcp.CallerRole) []string { "send_input", "send_message", "request_human_attention", "timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all", "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", } if role == mcp.RoleOrchestrator { @@ -1146,8 +1154,8 @@ func helpFor(topic string) mcp.HelpResponse { case "scratchpads": return mcp.HelpResponse{ 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.", - RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append"}, + 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", "scratchpad_delete"}, } case "timers": return mcp.HelpResponse{ diff --git a/internal/app/scratchpad_delete_test.go b/internal/app/scratchpad_delete_test.go index 50d0f64..6074098 100644 --- a/internal/app/scratchpad_delete_test.go +++ b/internal/app/scratchpad_delete_test.go @@ -1,10 +1,12 @@ package app import ( + "errors" "io" "os" "testing" + "github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/scratchpad" ) @@ -95,3 +97,41 @@ func TestDeletingLastFocusedScratchpadFocusesRunningChild(t *testing.T) { 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) + } +} diff --git a/internal/mcp/protocol.go b/internal/mcp/protocol.go index 73cb65b..73e32f0 100644 --- a/internal/mcp/protocol.go +++ b/internal/mcp/protocol.go @@ -358,6 +358,13 @@ func toolCatalog() []toolDescriptor { "content": stringProp("Text to append."), }, []string{"name", "content"}), }, + { + Name: "scratchpad_delete", + Description: "Delete a scratchpad entry.", + InputSchema: objectSchema(map[string]any{ + "name": stringProp("Scratchpad name."), + }, []string{"name"}), + }, { Name: "whoami", Description: "Return the caller's identity, role, parent, project metadata, and available tools.", diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index cffd2cc..f005026 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -101,6 +101,7 @@ type ToolHost interface { ScratchpadRead(name string) (content string, revision string, err error) ScratchpadWrite(name, content, expectedRevision string) (revision string, err error) ScratchpadAppend(name, content string) error + ScratchpadDelete(name string) error // Meta. WhoAmI(callerID string) WhoAmI @@ -244,8 +245,8 @@ type TimerInfo struct { ID string `json:"timer_id"` Label string `json:"label,omitempty"` Body string `json:"body,omitempty"` - Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all" - Status string `json:"status"` // "pending" | "paused" + Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all" + Status string `json:"status"` // "pending" | "paused" OwnerID string `json:"owner_process_id"` WatchedIDs []string `json:"watched,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 + 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": return h.WhoAmI(callerID), 0, "", nil