Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions internal/mcpserver/firstcall.go
Original file line number Diff line number Diff line change
@@ -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
}
184 changes: 184 additions & 0 deletions internal/mcpserver/firstcall_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
16 changes: 14 additions & 2 deletions internal/mcpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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).
Expand All @@ -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}},
Expand Down
14 changes: 14 additions & 0 deletions internal/setup/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading