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
+29
View File
@@ -121,6 +121,35 @@ func main() {
return c.JSON(http.StatusOK, svc.Activity(0))
})
// Graceful handoff (§8.3): the user requests a switch; Claude completes it.
e.GET("/api/switch", func(c echo.Context) error {
p, ok := svc.PendingSwitch()
if !ok {
return c.JSON(http.StatusOK, map[string]any{"pending": false})
}
return c.JSON(http.StatusOK, map[string]any{
"pending": true, "target": p.Target, "note": p.Note, "requestedAt": p.RequestedAt,
})
})
e.POST("/api/switch", func(c echo.Context) error {
var body struct {
Target string `json:"target"`
Note string `json:"note"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
p, err := svc.RequestSwitch(activity.ActorUser, body.Target, body.Note)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{"pending": true, "target": p.Target, "note": p.Note})
})
e.DELETE("/api/switch", func(c echo.Context) error {
svc.CancelSwitch(activity.ActorUser)
return c.JSON(http.StatusOK, map[string]any{"pending": false})
})
// SSE stream of activity events for live UI (§8.2).
e.GET("/events", func(c echo.Context) error {
w := c.Response()