From 2b77e15b36e25a512578f816b322acbd8d29ba57 Mon Sep 17 00:00:00 2001 From: Thomas Nilles Date: Sun, 20 Sep 2026 08:27:22 -0400 Subject: [PATCH] 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 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 --- AGENT.md | 18 +-- CHANGELOG.md | 22 +++ cmd/server/main.go | 29 ++++ components/activity-feed/activity-feed.js | 4 +- components/activity-feed/activity-feed.md | 2 + components/handoff-bar/handoff-bar.js | 155 ++++++++++++++++++++++ components/handoff-bar/handoff-bar.md | 28 ++++ internal/activity/activity.go | 72 +++++++++- internal/mcp/mcp.go | 44 ++++++ internal/mcp/mcp_test.go | 42 ++++++ internal/service/service.go | 29 ++++ web/templates/help.html | 12 ++ web/templates/index.html | 2 + 13 files changed, 449 insertions(+), 10 deletions(-) create mode 100644 components/handoff-bar/handoff-bar.js create mode 100644 components/handoff-bar/handoff-bar.md diff --git a/AGENT.md b/AGENT.md index 457e2d4..7166133 100644 --- a/AGENT.md +++ b/AGENT.md @@ -395,18 +395,20 @@ obeys the safety rules (§1.4). When the user switches project/task in the app, it is a **request**, not an instant yank. The cooperative protocol: -1. **User** picks a new project/task in the app → the app records a - **pending‑switch request** (target + optional note) and the UI shows - "waiting for Claude to reach a good stopping point." +1. **User** asks (in the app) for Claude to switch to a project → + `POST /api/switch` records a **pending‑switch request** (target + optional + note) and `` shows "waiting for Claude to reach a good stopping + point." (The request is a **user‑only** action — there is no MCP tool to raise + it; Claude fulfils requests, it doesn't create them.) 2. **Claude** sees the pending request (it checks at its natural turn‑boundary checkpoints via `get_pending_switch`). It **finishes to a safe stopping point and preserves work** — never abandons uncommitted changes to switch; it completes the in‑flight step and commits/stashes as appropriate — then - performs the switch (`set_active_project`, moving its working context to the - new repo) and calls **`ack_switch`** with a short summary of where it left - the previous project. -3. **App** marks the request fulfilled and **notifies the user** over SSE - ("Claude switched to *ProjectB*; *ProjectA* left at: …"). The user proceeds. + calls **`ack_switch`** with a short summary of where it left the previous + project. `ack_switch` **atomically** makes the requested target the active + project and clears the request. +3. **App** records `switch-completed` and **notifies the user** over SSE + ("Claude switched to *ProjectB* — *left at: …*"). The user proceeds. **Rule:** the switch is Claude‑completed at a checkpoint, not app‑forced. Losing or interrupting uncommitted work to satisfy a switch is a §1.4‑class violation. diff --git a/CHANGELOG.md b/CHANGELOG.md index f346185..6010329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,3 +116,25 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last. - **Affects:** `internal/activity` (new), `internal/service`, `internal/mcp` (+test), `cmd/server/main.go`, `components/activity-feed` (new), `components/repo-list`, `web/templates/{index,help}.html`. + +## 2026-09-20 — Slice 4: graceful project handoff (§8.3) +- **What:** `internal/activity` gained a pending-switch model + (`RequestSwitch`/`PendingSwitch`/`AckSwitch`/`CancelSwitch`); `AckSwitch` + atomically sets the active project to the requested target and records a + `switch-completed` event with Claude's summary. Service methods added. New MCP + tools `get_pending_switch` and `ack_switch` (request is user-only — no MCP tool + raises it). HTTP: `GET/POST/DELETE /api/switch`. New `` component: + "Ask Claude to switch to ", the "waiting for a good stopping point" + state with Cancel, and the completion notice; `` also updates the + active project on `switch-completed`. Extended the MCP test to cover the full + request→ack→clear flow. Help page + AGENT.md §8.3 updated. +- **Why:** The headline feature — the user asks Claude to switch projects; Claude + finishes to a safe stopping point, then `ack_switch` completes it and the app + notifies the user over SSE. The switch is Claude-completed at a checkpoint, + never app-forced (§1.4-class rule). +- **Affects:** `internal/activity`, `internal/service`, `internal/mcp` (+test), + `cmd/server/main.go`, `components/handoff-bar` (new), `components/activity-feed`, + `web/templates/{index,help}.html`, `AGENT.md` (§8.3). +- **Verified live:** request → "waiting" → cancel, all over SSE with activity + logging. The ack/completion path is covered by the test; its live ✅ notice + needs the two new MCP tools, which appear after the next Claude Desktop restart. diff --git a/cmd/server/main.go b/cmd/server/main.go index df28347..6091d6d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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() diff --git a/components/activity-feed/activity-feed.js b/components/activity-feed/activity-feed.js index 69d6d10..7d1e75b 100644 --- a/components/activity-feed/activity-feed.js +++ b/components/activity-feed/activity-feed.js @@ -50,7 +50,9 @@ class ActivityFeed extends HTMLElement { #onEvent(ev) { this.#events.push(ev); if (this.#events.length > 200) this.#events = this.#events.slice(-200); - if (ev.kind === 'active-project-changed') this.#renderActive(ev.repo || ''); + if (ev.kind === 'active-project-changed' || ev.kind === 'switch-completed') { + this.#renderActive(ev.repo || ''); + } this.#renderFeed(); } diff --git a/components/activity-feed/activity-feed.md b/components/activity-feed/activity-feed.md index a0a7445..58b7f5b 100644 --- a/components/activity-feed/activity-feed.md +++ b/components/activity-feed/activity-feed.md @@ -19,6 +19,8 @@ graceful project handoff (§8.3). ## History - 2026-09-20: created — slice 3; live active-project + activity view over SSE. +- 2026-09-20: also update the active-project display on `switch-completed` events + (slice 4 handoff), not only `active-project-changed`. ## Notes / gotchas - All server-derived text is escaped before insertion (repo paths, details, diff --git a/components/handoff-bar/handoff-bar.js b/components/handoff-bar/handoff-bar.js new file mode 100644 index 0000000..5cb2975 --- /dev/null +++ b/components/handoff-bar/handoff-bar.js @@ -0,0 +1,155 @@ +// — the graceful project handoff control (AGENT.md §8.3). +// +// A self-contained control (§1.1): shadow DOM, self-fetching, live over SSE, +// cleans up on disconnect. It lets the user ask Claude to switch to the active +// project, shows the "waiting for a good stopping point" state while a request +// is pending, and announces the completion (with Claude's summary of where it +// left off) when Claude calls ack_switch. + +class HandoffBar extends HTMLElement { + #es = null; + #controller = null; + #active = ''; + #pending = null; + #completed = null; + + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.#renderShell(); + this.#loadInitial(); + this.#es = new EventSource('/events'); + this.#es.addEventListener('activity', (e) => { + try { this.#onEvent(JSON.parse(e.data)); } catch { /* ignore */ } + }); + } + + disconnectedCallback() { + this.#es?.close(); + this.#controller?.abort(); + } + + async #loadInitial() { + this.#controller?.abort(); + this.#controller = new AbortController(); + try { + const [swRes, apRes] = await Promise.all([ + fetch('/api/switch', { signal: this.#controller.signal }), + fetch('/api/active-project', { signal: this.#controller.signal }), + ]); + const sw = await swRes.json(); + const ap = await apRes.json(); + this.#pending = sw.pending ? sw : null; + this.#active = ap.path || ''; + this.#render(); + } catch (err) { + if (err.name !== 'AbortError') { /* keep default UI */ } + } + } + + #onEvent(ev) { + switch (ev.kind) { + case 'switch-requested': + this.#pending = { target: ev.repo, note: ev.detail }; + this.#completed = null; + break; + case 'switch-completed': + this.#pending = null; + this.#active = ev.repo || this.#active; + this.#completed = { target: ev.repo, summary: ev.detail }; + break; + case 'switch-cancelled': + this.#pending = null; + break; + case 'active-project-changed': + this.#active = ev.repo || ''; + break; + default: + return; + } + this.#render(); + } + + async #request() { + if (!this.#active) return; + await fetch('/api/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target: this.#active }), + }).catch(() => {}); + // The SSE switch-requested event updates the UI. + } + + async #cancel() { + await fetch('/api/switch', { method: 'DELETE' }).catch(() => {}); + } + + #render() { + const body = this.shadowRoot.getElementById('body'); + if (this.#pending) { + body.innerHTML = ` + + Waiting for Claude to reach a good stopping point to switch to + ${this.#esc(this.#base(this.#pending.target))} + `; + this.shadowRoot.getElementById('cancel').onclick = () => this.#cancel(); + return; + } + + const done = this.#completed + ? `✅ Claude switched to + ${this.#esc(this.#base(this.#completed.target))}${ + this.#completed.summary ? ' — ' + this.#esc(this.#completed.summary) : '' + }` + : ''; + + if (!this.#active) { + body.innerHTML = `${done}Select a repository to make it the active project, then hand it off to Claude.`; + return; + } + + body.innerHTML = ` + ${done} + `; + this.shadowRoot.getElementById('ask').onclick = () => this.#request(); + } + + #renderShell() { + this.shadowRoot.innerHTML = ` + +
`; + } + + #base(p) { return String(p).split(/[/\\]/).filter(Boolean).pop() || p; } + + #esc(s) { + return String(s ?? '').replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + )); + } +} + +customElements.define('handoff-bar', HandoffBar); diff --git a/components/handoff-bar/handoff-bar.md b/components/handoff-bar/handoff-bar.md new file mode 100644 index 0000000..5a19886 --- /dev/null +++ b/components/handoff-bar/handoff-bar.md @@ -0,0 +1,28 @@ +# handoff-bar + +## Intent +The user-facing control for the **graceful project handoff** (AGENT.md §8.3). It +lets the user ask Claude to switch to the active project, shows the "waiting for a +good stopping point" state while the request is pending, and announces completion +(with Claude's summary of where it left off) when Claude calls `ack_switch`. The +switch is Claude-completed at a checkpoint, never app-forced (§1.4-class rule). + +## Public surface +- **Tag:** `` +- **Attributes/properties:** none. +- **Fetches (initial):** `GET /api/switch` (pending request), `GET /api/active-project`. +- **Writes:** `POST /api/switch {target}` to request a handoff to the active + project (actor=user); `DELETE /api/switch` to cancel. +- **Subscribes:** `EventSource('/events')` — reacts to `switch-requested`, + `switch-completed` (shows Claude's summary), `switch-cancelled`, and + `active-project-changed`. Closed on disconnect. + +## History +- 2026-09-20: created — slice 4; request/pending/completed UI over the + `/api/switch` endpoints and SSE. + +## Notes / gotchas +- All server-derived text is escaped before insertion (targets, summaries). +- The request targets the current **active project**; select a repo first (that + sets the active project via ``). +- The completion message persists until the next request; it is informational. diff --git a/internal/activity/activity.go b/internal/activity/activity.go index 2c6c43b..f49f1c8 100644 --- a/internal/activity/activity.go +++ b/internal/activity/activity.go @@ -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() diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 8005364..62f290b 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -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 } diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 01a9d6f..45f40cf 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -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. diff --git a/internal/service/service.go b/internal/service/service.go index 3e926ae..ffca5f4 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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) +} diff --git a/web/templates/help.html b/web/templates/help.html index bdc11a4..f0c2026 100644 --- a/web/templates/help.html +++ b/web/templates/help.html @@ -62,6 +62,18 @@ project and this activity, so you stay on the same page.

+

Handing a project off to Claude

+

+ When GitManager is connected to Claude, you can hand the current project + off. Select the repository (that makes it your active project), then click + "Ask Claude to switch to …" in the handoff bar. Claude + won't drop what it's doing — it finishes to a safe stopping point (saving + any in-progress work) and then switches. You'll see a "waiting…" message + while it wraps up, and a confirmation with a short note of where it left the + previous project once it has switched. You can Cancel a + pending request any time before Claude completes it. +

+

Background refresh

The dashboard refreshes on its own. By default it does not reach diff --git a/web/templates/index.html b/web/templates/index.html index 1620e8c..c2e5699 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -10,6 +10,7 @@ +