Slice 4: graceful project handoff (pending switch + ack)

Add pending-switch coordination to internal/activity (RequestSwitch/PendingSwitch/AckSwitch/CancelSwitch); AckSwitch atomically sets the active project and records switch-completed with Claude's summary. New MCP tools get_pending_switch and ack_switch (request is user-only via HTTP). HTTP GET/POST/DELETE /api/switch. New <handoff-bar> component (ask/waiting/cancel/completed) over SSE; activity-feed reflects switch-completed. Test covers request->ack->clear. Verified live: request/waiting/cancel over SSE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 08:27:22 -04:00
parent 65daedc902
commit 2b77e15b36
13 changed files with 449 additions and 10 deletions
+71 -1
View File
@@ -31,10 +31,21 @@ type Event struct {
Detail string `json:"detail,omitempty"` // human-readable extra context
}
// PendingSwitch is a user's request for Claude to switch to another project.
// It is the request half of the graceful handoff (§8.3) — Claude fulfils it at a
// safe stopping point via AckSwitch.
type PendingSwitch struct {
Target string `json:"target"` // requested repo path
Note string `json:"note,omitempty"`
RequestedBy Actor `json:"requestedBy"` // normally the user
RequestedAt time.Time `json:"requestedAt"`
}
// Feed is the concurrency-safe active-project + activity store with fan-out.
type Feed struct {
mu sync.RWMutex
active string // active project path ("" = none)
active string // active project path ("" = none)
pending *PendingSwitch // an outstanding switch request, if any
events []Event
maxEvents int
nextID int64
@@ -79,6 +90,65 @@ func (f *Feed) SetActiveProject(actor Actor, path string) (Event, bool) {
return ev, true
}
// RequestSwitch records a user's request for Claude to switch to target. The
// latest request wins (it overwrites any outstanding one).
func (f *Feed) RequestSwitch(actor Actor, target, note string) PendingSwitch {
f.mu.Lock()
p := PendingSwitch{Target: target, Note: note, RequestedBy: actor, RequestedAt: time.Now()}
f.pending = &p
ev, subs := f.appendLocked(actor, "switch-requested", target, note)
f.mu.Unlock()
publish(subs, ev)
return p
}
// PendingSwitch returns the outstanding switch request, if any.
func (f *Feed) PendingSwitch() (PendingSwitch, bool) {
f.mu.RLock()
defer f.mu.RUnlock()
if f.pending == nil {
return PendingSwitch{}, false
}
return *f.pending, true
}
// AckSwitch completes a pending switch: it makes the requested target the active
// project, clears the request, and records a "switch-completed" event carrying
// Claude's summary of where it left the previous project. Returns false if there
// was nothing pending.
func (f *Feed) AckSwitch(actor Actor, summary string) (PendingSwitch, bool) {
f.mu.Lock()
if f.pending == nil {
f.mu.Unlock()
return PendingSwitch{}, false
}
p := *f.pending
f.pending = nil
f.active = p.Target
ev, subs := f.appendLocked(actor, "switch-completed", p.Target, summary)
f.mu.Unlock()
publish(subs, ev)
return p, true
}
// CancelSwitch clears a pending switch (e.g. the user changed their mind).
func (f *Feed) CancelSwitch(actor Actor) (PendingSwitch, bool) {
f.mu.Lock()
if f.pending == nil {
f.mu.Unlock()
return PendingSwitch{}, false
}
p := *f.pending
f.pending = nil
ev, subs := f.appendLocked(actor, "switch-cancelled", p.Target, "")
f.mu.Unlock()
publish(subs, ev)
return p, true
}
// Record adds an arbitrary event to the feed.
func (f *Feed) Record(actor Actor, kind, repo, detail string) Event {
f.mu.Lock()
+44
View File
@@ -9,6 +9,7 @@ import (
"context"
"fmt"
"net/http"
"time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
@@ -44,6 +45,25 @@ type activityOutput struct {
Events []activity.Event `json:"events" jsonschema:"recent activity events, oldest first"`
}
// pendingSwitchOutput reports whether the user has asked Claude to switch projects.
type pendingSwitchOutput struct {
Pending bool `json:"pending" jsonschema:"true if the user has requested a switch you should complete"`
Target string `json:"target,omitempty" jsonschema:"the repository path to switch to"`
Note string `json:"note,omitempty" jsonschema:"an optional note from the user"`
RequestedAt time.Time `json:"requestedAt,omitempty"`
}
// ackSwitchInput is the argument schema for ack_switch.
type ackSwitchInput struct {
Summary string `json:"summary" jsonschema:"a short note on where you left the previous project (shown to the user)"`
}
// ackSwitchOutput reports the completed switch.
type ackSwitchOutput struct {
Switched bool `json:"switched"`
Target string `json:"target,omitempty" jsonschema:"the repository now active"`
}
// NewServer builds the MCP server and registers the (currently read-only) tools.
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
s := mcpsdk.NewServer(&mcpsdk.Implementation{
@@ -100,6 +120,30 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
return nil, activityOutput{Events: svc.Activity(50)}, nil
})
// get_pending_switch — has the user asked you to switch projects? (§8.3)
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "get_pending_switch",
Description: "Check whether the user has asked you to switch to a different project. If pending is true, finish your current work to a SAFE stopping point (commit or stash so nothing is lost), then call ack_switch to complete the handoff.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, pendingSwitchOutput, error) {
p, ok := svc.PendingSwitch()
if !ok {
return nil, pendingSwitchOutput{Pending: false}, nil
}
return nil, pendingSwitchOutput{Pending: true, Target: p.Target, Note: p.Note, RequestedAt: p.RequestedAt}, nil
})
// ack_switch — complete a pending handoff and tell the user where you left off.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "ack_switch",
Description: "Complete a pending project switch: makes the requested target the active project and clears the request. Call this only after reaching a safe stopping point in the current project. Pass a short summary of where you left it — the user is notified.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, in ackSwitchInput) (*mcpsdk.CallToolResult, ackSwitchOutput, error) {
p, ok := svc.AckSwitch(in.Summary)
if !ok {
return nil, ackSwitchOutput{Switched: false}, fmt.Errorf("no pending switch to acknowledge")
}
return nil, ackSwitchOutput{Switched: true, Target: p.Target}, nil
})
return s
}
+42
View File
@@ -159,6 +159,48 @@ func TestMCPRoundTrip(t *testing.T) {
if !found {
t.Fatalf("expected an active-project-changed event by claude, got %+v", act.Events)
}
// --- graceful handoff (§8.3): user requests, Claude acks -----------------
svc.RequestSwitch(activity.ActorUser, states[0].Path, "fix a bug")
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_pending_switch"})
var ps struct {
Pending bool `json:"pending"`
Target string `json:"target"`
}
decodeResult(t, res, &ps)
if !ps.Pending || ps.Target != states[0].Path {
t.Fatalf("expected pending switch to %q, got %+v", states[0].Path, ps)
}
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "ack_switch",
Arguments: map[string]any{"summary": "left tests green"},
})
if err != nil {
t.Fatalf("ack_switch: %v", err)
}
if res.IsError {
t.Fatalf("ack_switch tool error: %+v", res.Content)
}
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_pending_switch"})
decodeResult(t, res, &ps)
if ps.Pending {
t.Fatalf("expected no pending switch after ack, got %+v", ps)
}
// ack with nothing pending is a tool error.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "ack_switch",
Arguments: map[string]any{"summary": "nothing"},
})
if err != nil {
t.Fatalf("ack_switch(empty) protocol error: %v", err)
}
if !res.IsError {
t.Fatalf("expected IsError acking with no pending switch")
}
}
// decodeResult unmarshals the JSON text content of a tool result into v.
+29
View File
@@ -86,3 +86,32 @@ func (s *Service) Activity(limit int) []activity.Event {
func (s *Service) SubscribeActivity() (<-chan activity.Event, func()) {
return s.feed.Subscribe()
}
// --- Graceful project handoff (§8.3) ---------------------------------------
// RequestSwitch records a user's request for Claude to switch to target. The
// target must be an indexed repository.
func (s *Service) RequestSwitch(actor activity.Actor, target, note string) (activity.PendingSwitch, error) {
target = filepath.Clean(target)
if _, ok := s.index.Get(target); !ok {
return activity.PendingSwitch{}, fmt.Errorf("unknown repository %q", target)
}
return s.feed.RequestSwitch(actor, target, note), nil
}
// PendingSwitch returns the outstanding switch request, if any.
func (s *Service) PendingSwitch() (activity.PendingSwitch, bool) {
return s.feed.PendingSwitch()
}
// AckSwitch completes the pending handoff on Claude's behalf: sets the active
// project to the requested target and records Claude's summary. Returns false if
// nothing was pending.
func (s *Service) AckSwitch(summary string) (activity.PendingSwitch, bool) {
return s.feed.AckSwitch(activity.ActorClaude, summary)
}
// CancelSwitch clears a pending switch request.
func (s *Service) CancelSwitch(actor activity.Actor) (activity.PendingSwitch, bool) {
return s.feed.CancelSwitch(actor)
}