diff --git a/internal/mcpserver/firstcall.go b/internal/mcpserver/firstcall.go new file mode 100644 index 0000000..d3d0501 --- /dev/null +++ b/internal/mcpserver/firstcall.go @@ -0,0 +1,112 @@ +package mcpserver + +import ( + "fmt" + + "github.com/Coder8124/logos/internal/setup" +) + +// startHooked reports whether this host's session already opened with the +// handoff, injected by a session-start hook. Every answer is read from disk +// rather than assumed from the host's name: Claude Code registered with +// `claude mcp add` has no plugin and so no hook, and Cursor or Codex only have +// one if setup wrote it. +var startHooked = func(agent string) bool { + switch agent { + case "claude-code": + return setup.LogosPluginRecord().Connects + case "cursor": + return setup.StartHookInstalled("Cursor") + case "codex": + return setup.StartHookInstalled("Codex") + } + return false +} + +// handsOverItself lists the tools whose own answer is the handoff, so a first +// call to one of them needs nothing added. +var handsOverItself = map[string]bool{"resume": true, "context": true} + +// closesWithoutIt lists the tools that end a stretch of work. A model whose +// first call is one of them has already done the work the handoff was for, and +// after it the "last checkpoint" a resume would show is the one it just wrote, +// handed back as if a previous agent had left it. +var closesWithoutIt = map[string]bool{"checkpoint": true, "handoff": true} + +// handoffDue decides, before the tool runs, which project's handoff this call +// should carry, or "" for none. Before, not after: a call that creates the +// project (a first note_progress in a new one) would otherwise find it existing +// and hand the model its own new work as where an earlier agent left off. +// +// Whatever is settled here is settled for the session, so a hooked host or a +// session with nothing to hand over pays for the check once, not per call. +func (s *Session) handoffDue(tool string) string { + if s.started || handsOverItself[tool] || closesWithoutIt[tool] { + return "" + } + if startHooked(s.clientAgent) { + s.started = true + return "" + } + // Only the project this session is in, and only one the vault holds. + // Resume's fallback for no project is the most recent one in the vault, and + // for an unknown name a "nothing filed under it" report: the first is a + // guess a model that asked for nothing has no reason to doubt, the second + // is noise on a call that was about something else. + project := s.sessionProject() + if project == "" || !s.projectExists(project) { + s.started = true + return "" + } + return project +} + +// firstCallResume is the handoff a host with no session-start hook never got, +// carried on the first Logos tool result of the session that succeeded, or "" +// when there is nothing to add. project is what handoffDue decided before the +// call ran. +// +// In a host with a hook the session opens with where the last agent stopped and +// what it ruled out. In one without, that depended on the model choosing to call +// resume, which is the step it most often skips — and the one whose absence +// costs the most, because the next thing it does is re-try a dead end. Any +// first call is the earliest point the server can reach the model at all, so +// the handoff rides on that. +// +// After the tool's own answer, not before it: the model called for that answer, +// and a handoff above it pushes it out of the place it is read. +func (s *Session) firstCallResume(tool, project string) string { + if s.started { + return "" + } + if handsOverItself[tool] { + s.startOffered = true + return "" + } + if closesWithoutIt[tool] { + s.started = true + return "" + } + if project == "" { + return "" + } + out, err := s.resume(project, "", 0, "") + s.startOffered = true + if err != nil { + retry := "" + if s.toolSet == nil || s.toolSet["resume"] { + retry = " Call resume to try again." + } + return fmt.Sprintf("\n\n(Logos could not load where work on %s left off: %v.%s)", project, err, retry) + } + return fmt.Sprintf("\n\n---\n_This host has no session-start hook, so this is the first chance to hand over: where work on %s left off. Read it before you plan; you don't need to call resume._\n\n", project) + out +} + +// startSent records whether the result carrying the handoff reached the host. +// A cancelled call's reply is dropped unread, so the handoff stays owed. +func (s *Session) startSent(delivered bool) { + if s.startOffered && delivered { + s.started = true + } + s.startOffered = false +} diff --git a/internal/mcpserver/firstcall_test.go b/internal/mcpserver/firstcall_test.go new file mode 100644 index 0000000..974a73f --- /dev/null +++ b/internal/mcpserver/firstcall_test.go @@ -0,0 +1,184 @@ +package mcpserver + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Coder8124/logos/internal/session" +) + +// firstCallServer is a server whose session is in project "kestrel", which has +// a checkpoint, connected as host — the hookless host by default. +func firstCallServer(t *testing.T, host string) *testClient { + t.Helper() + t.Setenv("LOGOS_PROJECT", "kestrel") + c, db, vault := startServer(t) + c.req("initialize", map[string]any{ + "protocolVersion": "2025-06-18", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": host, "version": "1"}, + }) + if err := session.Init(db); err != nil { + t.Fatal(err) + } + cp := session.Checkpoint{Project: "kestrel", Agent: "cursor", Next: "quote the extruded option", TS: time.Now().Unix()} + if err := session.Commit(db, vault, &cp); err != nil { + t.Fatal(err) + } + return c +} + +// A host with no session-start hook opened every session blind unless the +// model chose to call resume, which is the step it most often skips. The first +// thing it calls Logos for, whatever that is, now carries the handoff. +func TestTheFirstToolCallInAHostWithNoHookCarriesTheHandoff(t *testing.T) { + c := firstCallServer(t, "claude-ai") + + out, isErr := c.callText(t, "list_projects", map[string]any{}) + if isErr { + t.Fatalf("list_projects errored: %s", out) + } + if !strings.Contains(out, "quote the extruded option") { + t.Errorf("the first call in a hookless host did not carry where work left off:\n%s", out) + } + if !strings.Contains(out, "no session-start hook") { + t.Errorf("the handoff arrived without saying why it is there:\n%s", out) + } +} + +// Once, not on every call: the handoff is a few thousand tokens, and repeated +// it would bury every answer after it. +func TestTheHandoffIsCarriedOnlyOnce(t *testing.T) { + c := firstCallServer(t, "claude-ai") + + c.callText(t, "list_projects", map[string]any{}) + out, _ := c.callText(t, "list_projects", map[string]any{}) + if strings.Contains(out, "quote the extruded option") { + t.Errorf("the second call carried the handoff again:\n%s", out) + } +} + +// A model that resumed on its own already has the handoff; adding it to the +// next call would hand it over twice. +func TestAModelThatCalledResumeFirstIsNotHandedItAgain(t *testing.T) { + c := firstCallServer(t, "claude-ai") + + if out, isErr := c.callText(t, "resume", map[string]any{"project": "kestrel"}); isErr || strings.Count(out, "quote the extruded option") != 1 { + t.Fatalf("resume as the first call: isErr=%v, want the handoff once:\n%s", isErr, out) + } + if out, _ := c.callText(t, "list_projects", map[string]any{}); strings.Contains(out, "quote the extruded option") { + t.Errorf("the call after resume carried the handoff again:\n%s", out) + } +} + +// Claude Code's plugin hook already opened the session with the handoff. +func TestAHostWhoseHookHandedOverIsNotHandedItAgain(t *testing.T) { + dir := t.TempDir() + t.Setenv("CLAUDE_CONFIG_DIR", dir) + if err := os.MkdirAll(filepath.Join(dir, "plugins"), 0o755); err != nil { + t.Fatal(err) + } + installed := `{"plugins":{"logos@noeton":[{"scope":"user","version":"0.5.8"}]}}` + if err := os.WriteFile(filepath.Join(dir, "plugins", "installed_plugins.json"), []byte(installed), 0o644); err != nil { + t.Fatal(err) + } + c := firstCallServer(t, "claude-code") + + if out, _ := c.callText(t, "list_projects", map[string]any{}); strings.Contains(out, "quote the extruded option") { + t.Errorf("a hooked host was handed the handoff a second time:\n%s", out) + } +} + +// Cursor only has a hook if setup wrote one. Cursor wired by hand opens blind +// like any hookless host, and is treated as one. +func TestCursorWithoutTheHookInstalledIsHandedOver(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + c := firstCallServer(t, "cursor-vscode") + + if out, _ := c.callText(t, "list_projects", map[string]any{}); !strings.Contains(out, "quote the extruded option") { + t.Errorf("Cursor with no hooks file was not handed over:\n%s", out) + } +} + +// A cancelled call's reply is dropped unread; the handoff on it is still owed. +func TestAHandoffOnAResultTheHostNeverReceivedIsCarriedAgain(t *testing.T) { + s := &Session{} + s.startOffered = true + s.startSent(false) + if s.started { + t.Error("a handoff dropped with a cancelled reply was counted as delivered") + } + s.startOffered = true + s.startSent(true) + if !s.started { + t.Error("a delivered handoff was not counted") + } +} + +// The other half: Cursor whose hooks file holds logos's session-start hook +// already opened the session with the handoff. +func TestCursorWithTheHookInstalledIsNotHandedItAgain(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.MkdirAll(filepath.Join(home, ".cursor"), 0o755); err != nil { + t.Fatal(err) + } + hooks := `{"version":1,"hooks":{"sessionStart":[{"command":"/usr/local/bin/logos hook cursor session-start"}]}}` + if err := os.WriteFile(filepath.Join(home, ".cursor", "hooks.json"), []byte(hooks), 0o644); err != nil { + t.Fatal(err) + } + c := firstCallServer(t, "cursor-vscode") + + if out, _ := c.callText(t, "list_projects", map[string]any{}); strings.Contains(out, "quote the extruded option") { + t.Errorf("Cursor with the hook installed was handed over twice:\n%s", out) + } +} + +// Claude Code registered with `claude mcp add`, without the plugin, has no +// session-start hook: being Claude Code is not the same as being hooked. +func TestClaudeCodeWithoutThePluginIsHandedOver(t *testing.T) { + t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir()) + c := firstCallServer(t, "claude-code") + + if out, _ := c.callText(t, "list_projects", map[string]any{}); !strings.Contains(out, "quote the extruded option") { + t.Errorf("Claude Code with no plugin was not handed over:\n%s", out) + } +} + +// A session in a project the vault does not hold yet has no earlier work. Its +// own first write creates the project, and the call after it must not hand the +// model what it just wrote as where a previous agent left off. +func TestASessionThatCreatesItsProjectIsNotHandedItsOwnWork(t *testing.T) { + c := firstCallServer(t, "claude-ai") + t.Setenv("LOGOS_PROJECT", "snowflake") + + out, isErr := c.callText(t, "remember", map[string]any{"project": "snowflake", "text": "the drift flag is per tenant"}) + if isErr { + t.Fatalf("remember errored: %s", out) + } + if strings.Contains(out, "no session-start hook") { + t.Errorf("the write that created the project carried a handoff of itself:\n%s", out) + } + if out, _ := c.callText(t, "list_projects", map[string]any{}); strings.Contains(out, "no session-start hook") { + t.Errorf("the call after it handed the session its own new work:\n%s", out) + } +} + +// A model whose first call is a checkpoint has finished the work the handoff +// was for; the checkpoint it gets back on the next call would be its own. +func TestAFirstCallThatCheckpointsIsNotHandedItsOwnCheckpoint(t *testing.T) { + c := firstCallServer(t, "claude-ai") + + for _, tool := range []string{"checkpoint", "list_projects"} { + args := map[string]any{} + if tool == "checkpoint" { + args = map[string]any{"project": "kestrel", "agent": "desktop", "state": "shipped the quote", "next": "invoice it"} + } + if out, _ := c.callText(t, tool, args); strings.Contains(out, "no session-start hook") { + t.Errorf("%s after a first checkpoint carried a handoff:\n%s", tool, out) + } + } +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index ee842f0..6c2f0c7 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -194,6 +194,12 @@ type Session struct { // saved holds the projects this session checkpointed itself, which the // close path uses to leave those alone. See unsaved.go. saved map[string]bool + + // started is whether the model has been handed this session's resume, and + // startOffered whether the result now being sent carries it. See + // firstcall.go. + started bool + startOffered bool } // checkpointOnDisk asks by stem, not path: the last checkpoint this session @@ -398,6 +404,7 @@ func (s *Server) Serve(in io.Reader, w io.Writer) error { // The nudge in this response is only spent if the response is // actually sent; a cancelled call's reply is dropped unread. sess.nudgeSent(!cancelled) + sess.startSent(!cancelled) if !cancelled { send(resp) } @@ -692,7 +699,14 @@ func (s *Session) callTool(req request) *response { } stalls := s.embed.Stalls() + handoff := s.handoffDue(p.Name) text, err := s.dispatch(p.Name, args) + // Only on a result that worked: a failing call is already asking the model + // to deal with something, and a second thing to attend to competes with it. + // Inside the stall window, so a handoff built without embeddings says so. + if err == nil { + text += s.firstCallResume(p.Name, handoff) + } // Falling back without embeddings is right, and doing it without a word is // not: the answer is thinner than usual and the fix — the runtime — is // outside Logos (invariant 3). @@ -713,8 +727,6 @@ func (s *Session) callTool(req request) *response { "isError": true, }) } - // Only on a result that worked: a failing call is already asking the model - // to deal with something, and a second thing to attend to competes with it. text += s.unsavedNudge() return reply(req.ID, map[string]any{ "content": []map[string]any{{"type": "text", "text": text}}, diff --git a/internal/setup/hooks.go b/internal/setup/hooks.go index ef5c7f5..5ca26ee 100644 --- a/internal/setup/hooks.go +++ b/internal/setup/hooks.go @@ -401,6 +401,20 @@ func hooksPathFor(name string) string { return "" } +// StartHookInstalled reports whether host ("Cursor" or "Codex", as +// hooksPathFor names them) has logos's session-start hook in its hooks file, so +// the MCP server knows the session already opened with its handoff. Read from +// the file rather than assumed from the host's name: Cursor wired by hand, or +// by a one-click install, has no hook and would otherwise never be handed one. +func StartHookInstalled(host string) bool { + path := hooksPathFor(host) + if path == "" { + return false + } + raw, err := os.ReadFile(path) + return err == nil && isLogosHook(string(raw), strings.ToLower(host)) +} + // removeHook takes logos's session-start hook out and leaves the rest of the // file as it was, reporting whether there was one and where the copy of the // file as it found it is. A hook left behind after uninstall runs a binary that