diff --git a/internal/app/daemon_core.go b/internal/app/daemon_core.go index a5e145b..0154f3f 100644 --- a/internal/app/daemon_core.go +++ b/internal/app/daemon_core.go @@ -150,6 +150,12 @@ func (r *ProjectRegistry) Count() int { return len(r.projects) } +func (r *ProjectRegistry) DefaultProject() *Project { + r.mu.Lock() + defer r.mu.Unlock() + return r.projects[r.defaultProjectKey] +} + func (r *ProjectRegistry) Shutdown() { r.mu.Lock() projects := make([]*Project, 0, len(r.projects)) @@ -418,24 +424,24 @@ func (r *ProjectRegistry) TimerList(callerID string) ([]mcp.TimerInfo, error) { return r.hostForCaller(callerID).TimerList(callerID) } -func (r *ProjectRegistry) ScratchpadList() ([]scratchpad.Entry, error) { - return r.hostForCaller("").ScratchpadList() +func (r *ProjectRegistry) ScratchpadList(callerID string) ([]scratchpad.Entry, error) { + return r.hostForCaller(callerID).ScratchpadList(callerID) } -func (r *ProjectRegistry) ScratchpadRead(name string) (string, string, error) { - return r.hostForCaller("").ScratchpadRead(name) +func (r *ProjectRegistry) ScratchpadRead(callerID, name string) (string, string, error) { + return r.hostForCaller(callerID).ScratchpadRead(callerID, name) } -func (r *ProjectRegistry) ScratchpadWrite(name, content, expectedRevision string) (string, error) { - return r.hostForCaller("").ScratchpadWrite(name, content, expectedRevision) +func (r *ProjectRegistry) ScratchpadWrite(callerID, name, content, expectedRevision string) (string, error) { + return r.hostForCaller(callerID).ScratchpadWrite(callerID, name, content, expectedRevision) } -func (r *ProjectRegistry) ScratchpadAppend(name, content string) error { - return r.hostForCaller("").ScratchpadAppend(name, content) +func (r *ProjectRegistry) ScratchpadAppend(callerID, name, content string) error { + return r.hostForCaller(callerID).ScratchpadAppend(callerID, name, content) } -func (r *ProjectRegistry) ScratchpadDelete(name string) error { - return r.hostForCaller("").ScratchpadDelete(name) +func (r *ProjectRegistry) ScratchpadDelete(callerID, name string) error { + return r.hostForCaller(callerID).ScratchpadDelete(callerID, name) } func (r *ProjectRegistry) WhoAmI(callerID string) mcp.WhoAmI { diff --git a/internal/app/host.go b/internal/app/host.go index 4f5ad49..d220d68 100644 --- a/internal/app/host.go +++ b/internal/app/host.go @@ -811,13 +811,13 @@ func (h *toolHost) TimerList(callerID string) ([]mcp.TimerInfo, error) { // Scratchpads / Meta // ─────────────────────────────────────────────────────────────────── -func (h *toolHost) ScratchpadList() ([]scratchpad.Entry, error) { return h.pads.List() } +func (h *toolHost) ScratchpadList(string) ([]scratchpad.Entry, error) { return h.pads.List() } -func (h *toolHost) ScratchpadRead(name string) (string, string, error) { +func (h *toolHost) ScratchpadRead(_ string, name string) (string, string, error) { return h.pads.Read(name) } -func (h *toolHost) ScratchpadWrite(name, content, expectedRevision string) (string, error) { +func (h *toolHost) ScratchpadWrite(_, name, content, expectedRevision string) (string, error) { rev, err := h.pads.Write(name, content, expectedRevision) if err == nil && h.scratch != nil { h.scratch.scratchpadsChanged() @@ -825,7 +825,7 @@ func (h *toolHost) ScratchpadWrite(name, content, expectedRevision string) (stri return rev, err } -func (h *toolHost) ScratchpadAppend(name, content string) error { +func (h *toolHost) ScratchpadAppend(_, name, content string) error { err := h.pads.Append(name, content) if err == nil && h.scratch != nil { h.scratch.scratchpadsChanged() @@ -833,7 +833,7 @@ func (h *toolHost) ScratchpadAppend(name, content string) error { return err } -func (h *toolHost) ScratchpadDelete(name string) error { +func (h *toolHost) ScratchpadDelete(_, name string) error { err := h.pads.Delete(name) if err == nil && h.scratch != nil { h.scratch.scratchpadsChanged() diff --git a/internal/app/project_registry_test.go b/internal/app/project_registry_test.go index a26f000..3df2092 100644 --- a/internal/app/project_registry_test.go +++ b/internal/app/project_registry_test.go @@ -98,3 +98,65 @@ func TestSwitchProjectPreservesProjectProcessTrees(t *testing.T) { t.Fatalf("switching back should preserve both project process trees") } } + +func TestProjectRegistryScratchpadsRouteByCallerProject(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + reg := newProjectRegistry(preset.Set{}, defaultSettings(), nil, 80, 24) + defer reg.Shutdown() + + projectA, err := reg.Open(ctx, t.TempDir()) + if err != nil { + t.Fatalf("open project A: %v", err) + } + projectB, err := reg.Open(ctx, t.TempDir()) + if err != nil { + t.Fatalf("open project B: %v", err) + } + + a, err := projectA.Session.Spawn(SpawnSpec{ + Kind: KindCommand, + Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"}, + Name: "a-caller", + }, 80, 24) + if err != nil { + t.Fatalf("spawn project A caller: %v", err) + } + b, err := projectB.Session.Spawn(SpawnSpec{ + Kind: KindCommand, + Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"}, + Name: "b-caller", + }, 80, 24) + if err != nil { + t.Fatalf("spawn project B caller: %v", err) + } + t.Cleanup(func() { + _ = projectA.Session.Kill(a.ID, syscall.SIGTERM) + _ = projectB.Session.Kill(b.ID, syscall.SIGTERM) + }) + waitUntilLive(t, a) + waitUntilLive(t, b) + + if _, err := reg.ScratchpadWrite(a.ID, "note.md", "project A", ""); err != nil { + t.Fatalf("write project A scratchpad: %v", err) + } + if _, err := reg.ScratchpadWrite(b.ID, "note.md", "project B", ""); err != nil { + t.Fatalf("write project B scratchpad: %v", err) + } + + gotA, _, err := reg.ScratchpadRead(a.ID, "note.md") + if err != nil { + t.Fatalf("read project A scratchpad: %v", err) + } + gotB, _, err := reg.ScratchpadRead(b.ID, "note.md") + if err != nil { + t.Fatalf("read project B scratchpad: %v", err) + } + if gotA != "project A" || gotB != "project B" { + t.Fatalf("scratchpad routing leaked between projects: A=%q B=%q", gotA, gotB) + } +} diff --git a/internal/app/scratchpad_delete_test.go b/internal/app/scratchpad_delete_test.go index 6074098..72fc547 100644 --- a/internal/app/scratchpad_delete_test.go +++ b/internal/app/scratchpad_delete_test.go @@ -119,7 +119,7 @@ func TestToolHostScratchpadDeleteRemovesPadAndRefreshes(t *testing.T) { host := newToolHost(nil, pads, nil, preset.Set{}, nil, 120, 40) host.scratch = recorder - if err := host.ScratchpadDelete("doomed.md"); err != nil { + if err := host.ScratchpadDelete("", "doomed.md"); err != nil { t.Fatalf("ScratchpadDelete: %v", err) } if recorder.count != 1 { @@ -128,7 +128,7 @@ func TestToolHostScratchpadDeleteRemovesPadAndRefreshes(t *testing.T) { 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) { + 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 { diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 066f080..a20139f 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -177,14 +177,14 @@ func (h *blockingToolHost) TimerResume(string, string) error { return nil } func (h *blockingToolHost) TimerList(string) ([]TimerInfo, error) { return nil, nil } -func (h *blockingToolHost) ScratchpadList() ([]scratchpad.Entry, error) { return nil, nil } -func (h *blockingToolHost) ScratchpadRead(string) (string, string, error) { +func (h *blockingToolHost) ScratchpadList(string) ([]scratchpad.Entry, error) { return nil, nil } +func (h *blockingToolHost) ScratchpadRead(string, string) (string, string, error) { return "", "", nil } -func (h *blockingToolHost) ScratchpadWrite(string, string, string) (string, error) { +func (h *blockingToolHost) ScratchpadWrite(string, string, string, 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) WhoAmI(string) WhoAmI { return WhoAmI{} } -func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} } +func (h *blockingToolHost) ScratchpadAppend(string, string, string) error { return nil } +func (h *blockingToolHost) ScratchpadDelete(string, string) error { return nil } +func (h *blockingToolHost) WhoAmI(string) WhoAmI { return WhoAmI{} } +func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} } diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index f005026..9a9f581 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -97,11 +97,11 @@ type ToolHost interface { TimerList(callerID string) ([]TimerInfo, error) // Scratchpads. - ScratchpadList() ([]scratchpad.Entry, error) - 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 + ScratchpadList(callerID string) ([]scratchpad.Entry, error) + ScratchpadRead(callerID, name string) (content string, revision string, err error) + ScratchpadWrite(callerID, name, content, expectedRevision string) (revision string, err error) + ScratchpadAppend(callerID, name, content string) error + ScratchpadDelete(callerID, name string) error // Meta. WhoAmI(callerID string) WhoAmI @@ -724,7 +724,7 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, return ts, 0, "", nil case "scratchpad_list": - entries, err := h.ScratchpadList() + entries, err := h.ScratchpadList(callerID) if err != nil { return nil, codeInternal, err.Error(), nil } @@ -737,7 +737,7 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, if err := unmarshalParams(params, &p); err != nil { return nil, codeInvalidParams, err.Error(), nil } - content, rev, err := h.ScratchpadRead(p.Name) + content, rev, err := h.ScratchpadRead(callerID, p.Name) if err != nil { return nil, codeInternal, err.Error(), nil } @@ -752,7 +752,7 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, if err := unmarshalParams(params, &p); err != nil { return nil, codeInvalidParams, err.Error(), nil } - rev, err := h.ScratchpadWrite(p.Name, p.Content, p.ExpectedRevision) + rev, err := h.ScratchpadWrite(callerID, p.Name, p.Content, p.ExpectedRevision) if err != nil { // Optimistic-concurrency miss returns ok:false + current_revision // rather than a JSON-RPC error so callers can re-read + merge. @@ -772,7 +772,7 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, if err := unmarshalParams(params, &p); err != nil { return nil, codeInvalidParams, err.Error(), nil } - if err := h.ScratchpadAppend(p.Name, p.Content); err != nil { + if err := h.ScratchpadAppend(callerID, p.Name, p.Content); err != nil { return nil, codeInternal, err.Error(), nil } return map[string]any{"ok": true}, 0, "", nil @@ -784,7 +784,7 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, if err := unmarshalParams(params, &p); err != nil { return nil, codeInvalidParams, err.Error(), nil } - if err := h.ScratchpadDelete(p.Name); err != nil { + if err := h.ScratchpadDelete(callerID, p.Name); err != nil { return nil, codeInternal, err.Error(), nil } return map[string]any{"ok": true}, 0, "", nil