Remove bundled MCP servers and add code index fallback

This commit is contained in:
Sivan committed 2026-06-16 17:44:27 +08:00
1 parent 046ccec992
commit 655c72ad9b
82 files changed
+1330 -5822

No files matched your search

+1 -20
View File
@@ -2,11 +2,7 @@ VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
GOEXE := $(shell go env GOEXE)
# CodeGraph release pinned for the bundled MCP server / e2e test. Bump together
# with any change to the integration in internal/codegraph.
CODEGRAPH_VERSION := v0.9.7
.PHONY: build vet fmt test hooks cross clean e2e-codegraph
.PHONY: build vet fmt test hooks cross clean
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
@@ -35,18 +31,3 @@ cross:
clean:
rm -rf bin dist
# Fetch the matching CodeGraph bundle into bin/codegraph/ (the distribution
# layout: launcher at bin/codegraph/bin/codegraph beside bin/reasonix) and run the
# gated MCP end-to-end test against it. Requires `gh`. Windows: install via the
# upstream install.ps1 and run the test with REASONIX_CODEGRAPH_BIN set.
e2e-codegraph:
@os=$$(uname -s | tr 'A-Z' 'a-z'); arch=$$(uname -m); \
case $$arch in arm64|aarch64) arch=arm64;; x86_64|amd64) arch=x64;; *) echo "unsupported arch $$arch"; exit 1;; esac; \
asset=codegraph-$$os-$$arch.tar.gz; dest=bin/codegraph; \
echo "fetching $$asset ($(CODEGRAPH_VERSION)) -> $$dest"; \
rm -rf $$dest && mkdir -p $$dest; \
gh release download $(CODEGRAPH_VERSION) -R colbymchenry/codegraph -p $$asset -O /tmp/$$asset; \
tar -xzf /tmp/$$asset -C $$dest --strip-components=1; \
REASONIX_CODEGRAPH_E2E=1 REASONIX_CODEGRAPH_BIN=$$PWD/$$dest/bin/codegraph \
go test ./internal/codegraph/ -run E2E -v -count=1
+2 -366
View File
@@ -30,8 +30,6 @@ import (
"reasonix/internal/agent"
"reasonix/internal/billing"
"reasonix/internal/boot"
"reasonix/internal/builtinmcp"
"reasonix/internal/codegraph"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
@@ -41,7 +39,6 @@ import (
"reasonix/internal/i18n"
"reasonix/internal/mcpdiag"
"reasonix/internal/memory"
"reasonix/internal/netclient"
"reasonix/internal/plugin"
"reasonix/internal/provider"
"reasonix/internal/skill"
@@ -58,8 +55,6 @@ const eventChannel = "agent:event"
// reopen behavior remains predictable on every platform.
const singleInstanceID = "com.reasonix.desktop"
var updateBuiltInCodegraph = codegraph.UpdateWithClient
// PromptHistoryEntry is one user prompt extracted from a session JSONL file.
// The frontend uses these for ↑/↓ prompt-history navigation.
type PromptHistoryEntry struct {
@@ -114,9 +109,6 @@ type App struct {
botInstalls map[string]*botInstallSession
botRuntime *desktopBotRuntime
builtInMCPUpdatesMu sync.RWMutex
builtInMCPUpdates map[string]BuiltInMCPUpdateStatus
metrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics
runtimeEvents asyncRuntimeEmitter
@@ -330,7 +322,6 @@ func (a *App) startup(ctx context.Context) {
go a.restoreOrBuildTabs()
a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
a.goSafe("checkBuiltInMCPUpdates", a.checkBuiltInMCPUpdates)
a.goSafe("sendStartupPing", a.sendStartupPing)
a.goSafe("flushMetrics", a.flushMetrics)
a.goSafe("flushPendingCrash", a.flushPendingCrash)
@@ -3659,12 +3650,6 @@ type ToolView struct {
Description string `json:"description"`
}
type BuiltInMCPUpdateResult struct {
Name string `json:"name"`
Version string `json:"version"`
Path string `json:"path"`
}
// SkillView is one discoverable skill for the drawer.
type SkillView struct {
Name string `json:"name"`
@@ -3716,11 +3701,9 @@ func (a *App) Capabilities() CapabilitiesView {
seen := map[string]bool{}
connected := map[string]bool{}
retainedDisabled := map[string]ServerView{}
var loadedCfg *config.Config
configured := map[string]config.PluginEntry{}
var configuredEntries []config.PluginEntry
if cfg, err := config.LoadForRoot(tab.WorkspaceRoot); err == nil {
loadedCfg = cfg
configuredEntries = append(configuredEntries, cfg.Plugins...)
for _, p := range configuredEntries {
configured[p.Name] = p
@@ -3737,10 +3720,6 @@ func (a *App) Capabilities() CapabilitiesView {
}
if p, ok := configured[s.Name]; ok {
view = withPluginConfig(view, p)
} else if s.Name == "codegraph" && loadedCfg != nil {
view = withCodegraphConfig(view, loadedCfg.Codegraph)
} else if p, ok := builtinmcp.Entry(s.Name); ok {
view = withBuiltInMCPConfig(view, p, builtInMCPEnabled(loadedCfg, p.Name))
}
out.Servers = append(out.Servers, view)
}
@@ -3751,17 +3730,13 @@ func (a *App) Capabilities() CapabilitiesView {
}
if p, ok := configured[f.Name]; ok {
view = withPluginConfig(view, p)
} else if f.Name == "codegraph" && loadedCfg != nil {
view = withCodegraphConfig(view, loadedCfg.Codegraph)
} else if p, ok := builtinmcp.Entry(f.Name); ok {
view = withBuiltInMCPConfig(view, p, builtInMCPEnabled(loadedCfg, p.Name))
}
out.Servers = append(out.Servers, view)
}
}
// Configured servers that are neither connected nor failed are either lazy
// (deferred), background/eager (initializing), or toggled off this session.
if len(configuredEntries) > 0 || loadedCfg != nil {
if len(configuredEntries) > 0 {
for _, p := range configuredEntries {
if seen[p.Name] {
continue
@@ -3788,44 +3763,6 @@ func (a *App) Capabilities() CapabilitiesView {
out.Servers = append(out.Servers, withPluginConfig(ServerView{Name: p.Name, Status: status}, p))
seen[p.Name] = true
}
if loadedCfg != nil && !seen["codegraph"] {
status := "disabled"
if loadedCfg.Codegraph.Enabled {
status = "initializing"
}
if s, ok := disabled["codegraph"]; ok {
s.Status = "disabled"
s.Transport = "stdio"
s.BuiltIn = true
s = withCodegraphConfig(s, loadedCfg.Codegraph)
s.Error = ""
out.Servers = append(out.Servers, s)
retainedDisabled["codegraph"] = s
delete(disabled, "codegraph")
} else {
out.Servers = append(out.Servers, withCodegraphConfig(ServerView{Name: "codegraph", Status: status}, loadedCfg.Codegraph))
}
seen["codegraph"] = true
}
for _, p := range builtinmcp.Entries() {
if configured[p.Name].Name != "" || seen[p.Name] {
continue
}
enabled := builtInMCPEnabled(loadedCfg, p.Name)
if s, ok := disabled[p.Name]; ok {
s.Status = "disabled"
s = withBuiltInMCPConfig(s, p, enabled)
s.Error = ""
out.Servers = append(out.Servers, s)
retainedDisabled[p.Name] = s
delete(disabled, p.Name)
} else if enabled {
out.Servers = append(out.Servers, withBuiltInMCPConfig(ServerView{Name: p.Name, Status: "deferred"}, p, true))
} else {
out.Servers = append(out.Servers, withBuiltInMCPConfig(ServerView{Name: p.Name, Status: "disabled"}, p, false))
}
seen[p.Name] = true
}
}
out.Servers = orderServerViews(out.Servers, order)
@@ -3874,37 +3811,6 @@ func withPluginConfig(v ServerView, p config.PluginEntry) ServerView {
return v
}
func withCodegraphConfig(v ServerView, c config.CodegraphConfig) ServerView {
v.Name = "codegraph"
v.Transport = "stdio"
v.BuiltIn = true
v.Configured = true
v.AutoStart = c.ShouldAutoStart()
v.Tier = c.ResolvedTier()
v.Command = strings.TrimSpace(c.Path)
if v.Command == "" {
v.Command = "codegraph"
}
v.Args = []string{"serve", "--mcp"}
v.AuthStatus = mcpdiag.AuthNone
return v
}
func withBuiltInMCPConfig(v ServerView, p config.PluginEntry, enabled bool) ServerView {
v = withPluginConfig(v, p)
v.Name = p.Name
v.BuiltIn = true
v.Configured = true
v.AutoStart = enabled
v.AuthStatus = mcpdiag.AuthNone
v.AuthURL = ""
return v
}
func builtInMCPEnabled(cfg *config.Config, name string) bool {
return cfg != nil && cfg.BuiltInMCP.Enabled(name)
}
func skillRootsView() []SkillRootView {
cwd, _ := os.Getwd()
cfg, _ := config.Load()
@@ -4140,9 +4046,6 @@ func (a *App) AddMCPServer(in MCPServerInput) (int, error) {
if ctrl == nil {
return 0, fmt.Errorf("no active session")
}
if builtinmcp.IsBuiltIn(strings.TrimSpace(in.Name)) {
return 0, fmt.Errorf("%s is built in; no configuration is required", strings.TrimSpace(in.Name))
}
entry := config.PluginEntry{
Name: in.Name,
Type: normalizeMCPTransport(in.Transport),
@@ -4161,9 +4064,6 @@ func (a *App) AddMCPServer(in MCPServerInput) (int, error) {
// UpdateMCPServer edits a persisted external MCP server. The name is the stable
// identity; callers must remove + add if they want to rename a server.
func (a *App) UpdateMCPServer(name string, in MCPServerInput) error {
if name == "codegraph" {
return fmt.Errorf("codegraph is built in; configure it with [codegraph]")
}
ctrl := a.activeCtrl()
if ctrl == nil {
return fmt.Errorf("no active session")
@@ -4176,9 +4076,6 @@ func (a *App) UpdateMCPServer(name string, in MCPServerInput) error {
return err
}
if !found {
if builtinmcp.IsBuiltIn(name) {
return fmt.Errorf("%s is built in; it cannot be edited", name)
}
return fmt.Errorf("no configured MCP server named %q", name)
}
updated.Type = normalizeMCPTransport(in.Transport)
@@ -4223,16 +4120,6 @@ func (a *App) UpdateMCPServer(name string, in MCPServerInput) error {
// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).
func (a *App) RemoveMCPServer(name string) error {
if name == "codegraph" {
return fmt.Errorf("codegraph is built in; it cannot be removed")
}
if builtinmcp.IsBuiltIn(name) {
if _, found, err := a.desktopMCPServerForEdit(name); err != nil {
return err
} else if !found {
return fmt.Errorf("%s is built in; it cannot be removed", name)
}
}
tab := a.activeTab()
if tab == nil || tab.Ctrl == nil {
return fmt.Errorf("no active session")
@@ -4268,8 +4155,6 @@ func (a *App) ReconnectMCPServer(name string) error {
entry := config.PluginEntry{Name: name}
if p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {
entry = p
} else if p, ok := builtinmcp.Entry(name); ok {
entry = p
}
recordMCPFailure(tab.Ctrl, entry, err)
return err
@@ -4280,71 +4165,10 @@ func (a *App) ReconnectMCPServer(name string) error {
return nil
}
// UpdateBuiltInMCPServer downloads and activates the latest runtime for a
// bundled MCP. It is intentionally explicit because newer MCP releases can
// change tool schemas and prompt-cache shape.
func (a *App) UpdateBuiltInMCPServer(name string) (BuiltInMCPUpdateResult, error) {
name = strings.TrimSpace(name)
if name != "codegraph" {
return BuiltInMCPUpdateResult{}, fmt.Errorf("%s is not an updatable built-in MCP server", name)
}
tab := a.activeTab()
if tab == nil || tab.Ctrl == nil {
return BuiltInMCPUpdateResult{}, fmt.Errorf("no active session")
}
cfg, err := config.LoadForRoot(tab.WorkspaceRoot)
if err != nil {
return BuiltInMCPUpdateResult{}, err
}
client, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{})
if err != nil {
return BuiltInMCPUpdateResult{}, err
}
updated, err := updateBuiltInCodegraph(a.bootContext(), client, nil)
if err != nil {
return BuiltInMCPUpdateResult{}, err
}
result := BuiltInMCPUpdateResult{Name: name, Version: updated.Version, Path: updated.Path}
a.recordBuiltInMCPUpdateStatus(BuiltInMCPUpdateStatus{
Name: name,
Mode: "manual",
Current: codegraph.ActiveVersion(),
Latest: updated.Version,
Phase: "activated",
Path: updated.Path,
})
a.mu.RLock()
_, sessionDisabled := tab.disabledMCP[name]
a.mu.RUnlock()
if cfg.Codegraph.Enabled && !sessionDisabled {
if mcpConnected(tab.Ctrl, name) {
tab.Ctrl.DisconnectMCPServer(name)
}
if h := tab.Ctrl.Host(); h != nil {
h.ClearFailure(name)
}
if _, err := tab.Ctrl.ConnectCodegraphMCPServerForRoot(cfg, tab.WorkspaceRoot); err != nil {
recordCodegraphFailure(tab.Ctrl, cfg.Codegraph, err)
}
}
return result, nil
}
// ClearMCPServerAuthentication removes local auth-like config for one MCP and
// clears the current session's cached connection failure. It does not remove the
// server itself or try to sign the user out of the third-party browser session.
func (a *App) ClearMCPServerAuthentication(name string) error {
if name == "codegraph" {
return fmt.Errorf("codegraph is built in; it has no stored MCP authentication")
}
if builtinmcp.IsBuiltIn(name) {
if _, found, err := a.desktopMCPServerForEdit(name); err != nil {
return err
} else if !found {
return fmt.Errorf("%s is built in; it has no stored MCP authentication", name)
}
}
ctrl := a.activeCtrl()
if ctrl == nil {
return fmt.Errorf("no active session")
@@ -4367,16 +4191,10 @@ func (a *App) SetMCPServerEnabled(name string, enabled bool) error {
if tab == nil || tab.Ctrl == nil {
return fmt.Errorf("no active session")
}
if name == "codegraph" {
return a.setCodegraphEnabled(enabled)
}
configuredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)
if err != nil {
return err
}
if builtinmcp.IsBuiltIn(name) && !hasConfiguredEntry {
return a.setBuiltInMCPEnabled(name, enabled)
}
if enabled {
_, err := a.connectConfiguredMCPServerForTab(tab, name)
if err == nil {
@@ -4423,31 +4241,18 @@ func (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (
return tab.Ctrl.ConnectMCPServer(p)
}
}
if name == "codegraph" {
return tab.Ctrl.ConnectCodegraphMCPServerForRoot(cfg, tab.WorkspaceRoot)
}
if p, ok := builtinmcp.Entry(name); ok {
return tab.Ctrl.ConnectMCPServer(p)
}
return 0, fmt.Errorf("no configured MCP server named %q", name)
}
// SetMCPServerTier is kept for old desktop bindings. New config writes drop the
// retired tier field; for CodeGraph this now means "enable and start in the
// background".
// retired tier field.
func (a *App) SetMCPServerTier(name, tier string) error {
if name == "codegraph" {
return a.setCodegraphTier(tier)
}
tier = normalizeMCPTier(tier)
updated, found, err := a.desktopMCPServerForEdit(name)
if err != nil {
return err
}
if !found {
if builtinmcp.IsBuiltIn(name) {
return fmt.Errorf("%s is built in; it always uses lazy startup", name)
}
return fmt.Errorf("no configured MCP server named %q", name)
}
updated.Tier = tier
@@ -4471,124 +4276,6 @@ func (a *App) SetMCPServerTier(name, tier string) error {
return nil
}
func (a *App) setBuiltInMCPEnabled(name string, enabled bool) error {
entry, ok := builtinmcp.Entry(name)
if !ok {
return fmt.Errorf("no built-in MCP server named %q", name)
}
cfg, path, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return err
}
if !cfg.BuiltInMCP.SetEnabled(name, enabled) {
return fmt.Errorf("no built-in MCP server named %q", name)
}
tab := a.activeTab()
if tab == nil || tab.Ctrl == nil {
return fmt.Errorf("no active session")
}
if err := cfg.SaveTo(path); err != nil {
return err
}
if err := a.syncProjectBuiltInMCPOverride(cfg.BuiltInMCP); err != nil {
return err
}
if enabled {
a.mu.Lock()
delete(tab.disabledMCP, name)
a.mu.Unlock()
_, err := tab.Ctrl.ConnectMCPServer(entry)
if err != nil {
recordMCPFailure(tab.Ctrl, entry, err)
return nil
}
return nil
}
if h := tab.Ctrl.Host(); h != nil {
h.ClearFailure(name)
}
tab.Ctrl.DisconnectMCPServer(name)
s := withBuiltInMCPConfig(ServerView{Name: name, Status: "disabled"}, entry, false)
a.mu.Lock()
if tab.disabledMCP == nil {
tab.disabledMCP = map[string]ServerView{}
}
tab.disabledMCP[name] = s
tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})
a.mu.Unlock()
return nil
}
func (a *App) setCodegraphEnabled(enabled bool) error {
cfg, path, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return err
}
tab := a.activeTab()
if tab == nil || tab.Ctrl == nil {
return fmt.Errorf("no active session")
}
cfg.Codegraph.Enabled = enabled
if err := cfg.SaveTo(path); err != nil {
return err
}
if err := a.syncProjectCodegraphOverride(cfg.Codegraph); err != nil {
return err
}
if enabled {
a.mu.Lock()
delete(tab.disabledMCP, "codegraph")
a.mu.Unlock()
if _, err := tab.Ctrl.ConnectCodegraphMCPServerForRoot(cfg, tab.WorkspaceRoot); err != nil {
recordCodegraphFailure(tab.Ctrl, cfg.Codegraph, err)
return nil
}
return nil
}
if h := tab.Ctrl.Host(); h != nil {
h.ClearFailure("codegraph")
}
tab.Ctrl.DisconnectMCPServer("codegraph")
s := withCodegraphConfig(ServerView{Name: "codegraph", Status: "disabled"}, cfg.Codegraph)
a.mu.Lock()
if tab.disabledMCP == nil {
tab.disabledMCP = map[string]ServerView{}
}
tab.disabledMCP["codegraph"] = s
tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})
a.mu.Unlock()
return nil
}
func (a *App) setCodegraphTier(_ string) error {
cfg, path, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return err
}
cfg.Codegraph.Enabled = true
cfg.Codegraph.Tier = ""
if err := cfg.SaveTo(path); err != nil {
return err
}
if err := a.syncProjectCodegraphOverride(cfg.Codegraph); err != nil {
return err
}
tab := a.activeTab()
if tab == nil || tab.Ctrl == nil {
return nil
}
a.mu.Lock()
delete(tab.disabledMCP, "codegraph")
a.mu.Unlock()
if !mcpConnected(tab.Ctrl, "codegraph") {
if _, err := tab.Ctrl.ConnectCodegraphMCPServerForRoot(cfg, tab.WorkspaceRoot); err != nil {
recordCodegraphFailure(tab.Ctrl, cfg.Codegraph, err)
return nil
}
}
return nil
}
func (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {
cfg, _, err := a.loadDesktopUserConfigForEdit()
if err != nil {
@@ -4661,40 +4348,6 @@ func (a *App) removeProjectMCPOverride(name string) (bool, error) {
return true, nil
}
func (a *App) syncProjectCodegraphOverride(c config.CodegraphConfig) error {
path := projectConfigPathForRoot(a.activeWorkspaceRoot())
userPath := config.UserConfigPath()
if path == "" || sameConfigPath(path, userPath) {
return nil
}
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
cfg := config.LoadForEdit(path)
cfg.Codegraph = c
return cfg.SaveTo(path)
}
func (a *App) syncProjectBuiltInMCPOverride(c config.BuiltInMCPConfig) error {
path := projectConfigPathForRoot(a.activeWorkspaceRoot())
userPath := config.UserConfigPath()
if path == "" || sameConfigPath(path, userPath) {
return nil
}
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
cfg := config.LoadForEdit(path)
cfg.BuiltInMCP = c
return cfg.SaveTo(path)
}
func findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {
for _, p := range entries {
if p.Name == name {
@@ -4768,22 +4421,6 @@ func recordMCPFailure(ctrl *control.Controller, e config.PluginEntry, err error)
}, err)
}
func recordCodegraphFailure(ctrl *control.Controller, c config.CodegraphConfig, err error) {
if ctrl == nil || ctrl.Host() == nil || err == nil {
return
}
cmd := strings.TrimSpace(c.Path)
if cmd == "" {
cmd = "codegraph"
}
ctrl.Host().RecordFailure(plugin.Spec{
Name: "codegraph",
Type: "stdio",
Command: cmd,
Args: []string{"serve", "--mcp"},
}, err)
}
func findMCPServerView(ctrl *control.Controller, name string) (ServerView, bool) {
if ctrl == nil || ctrl.Host() == nil {
return ServerView{}, false
@@ -5299,7 +4936,6 @@ type WorkspaceChangesView struct {
// and "@" menu regardless of where they appear.
var workspaceNoiseNames = map[string]bool{
".codex": true,
".codegraph": true,
".DS_Store": true,
".git": true,
".npm": true,
+4 -521
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
@@ -18,7 +17,6 @@ import (
"time"
"reasonix/internal/agent"
"reasonix/internal/codegraph"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
@@ -1620,12 +1618,6 @@ func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "node_modules", "pkg", "runtime.js"), []byte("noise"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, ".codegraph", "cache"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".codegraph", "cache", "runtime.js"), []byte("index"), 0o644); err != nil {
t.Fatal(err)
}
for _, noise := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
if err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {
t.Fatal(err)
@@ -1652,7 +1644,7 @@ func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
app := &App{}
listed := app.ListDir("")
for _, hidden := range []string{".codex", ".codegraph", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
for _, hidden := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
if hasDirEntry(listed, hidden) {
t.Fatalf("ListDir should hide local noise %q, got %+v", hidden, listed)
}
@@ -1680,7 +1672,6 @@ func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
}
for _, hidden := range []string{
".codex/runtime.js",
".codegraph/cache/runtime.js",
".npm/runtime.js",
".pnpm-store/runtime.js",
"bin/runtime.js",
@@ -2186,7 +2177,7 @@ func TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {
isolateDesktopUserDirs(t)
workspace := robustTempDir(t)
if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte("[codegraph]\nenabled = false\n"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte(""), 0o644); err != nil {
t.Fatalf("write workspace config: %v", err)
}
dir := config.SessionDir()
@@ -2281,9 +2272,6 @@ func TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = false
[[plugins]]
name = "playwright"
command = "npx"
@@ -2312,134 +2300,11 @@ args = ["-y", "@playwright/mcp"]
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
}
func TestCapabilitiesShowsDefaultCodegraphDisabled(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "codegraph" {
if s.Status != "disabled" {
t.Fatalf("codegraph status = %q, want disabled; server = %+v", s.Status, s)
}
if !s.BuiltIn || !s.Configured {
t.Fatalf("codegraph builtIn/configured = %v/%v, want true/true; server = %+v", s.BuiltIn, s.Configured, s)
}
if s.AutoStart {
t.Fatalf("codegraph autoStart = true, want false; server = %+v", s)
}
if s.Tier != "background" {
t.Fatalf("codegraph tier = %q, want background; server = %+v", s.Tier, s)
}
if s.Command != "codegraph" || !reflect.DeepEqual(s.Args, []string{"serve", "--mcp"}) {
t.Fatalf("codegraph command = %q %+v, want codegraph serve --mcp", s.Command, s.Args)
}
return
}
}
t.Fatalf("codegraph missing from Capabilities: %+v", view.Servers)
}
func TestCapabilitiesShowsBuiltInMCPDefaults(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
view := app.Capabilities()
want := map[string][]string{
"time": []string{"builtin-mcp", "time"},
}
found := map[string]bool{}
for _, s := range view.Servers {
if s.Name != "time" && s.Name != "context7" {
continue
}
found[s.Name] = true
wantStatus := map[string]string{"time": "deferred", "context7": "disabled"}[s.Name]
wantAutoStart := s.Name == "time"
if s.Status != wantStatus {
t.Fatalf("%s status = %q, want %s; server = %+v", s.Name, s.Status, wantStatus, s)
}
if !s.BuiltIn || !s.Configured || s.AutoStart != wantAutoStart {
t.Fatalf("%s builtIn/configured/autoStart = %v/%v/%v, want true/true/%v; server = %+v", s.Name, s.BuiltIn, s.Configured, s.AutoStart, wantAutoStart, s)
}
if s.Tier != "lazy" || s.Transport != "stdio" || strings.TrimSpace(s.Command) == "" {
t.Fatalf("%s transport/tier/command = %q/%q/%q, want stdio/lazy/non-empty; server = %+v", s.Name, s.Transport, s.Tier, s.Command, s)
}
if s.Name == "time" && !reflect.DeepEqual(s.Args, want["time"]) {
t.Fatalf("time args = %+v, want %+v", s.Args, want["time"])
}
if s.Name == "context7" && !validContext7Runner(s.Command, s.Args) {
t.Fatalf("context7 runner = %q %+v, want npx/pnpm/bunx for @upstash/context7-mcp", s.Command, s.Args)
}
}
for _, name := range []string{"time", "context7"} {
if !found[name] {
t.Fatalf("built-in MCP %s missing from Capabilities: %+v", name, view.Servers)
}
}
}
func TestCapabilitiesShowsManuallyEnabledContext7Deferred(t *testing.T) {
func TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = false
[builtin_mcp]
context7_enabled = true
`), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "context7" {
if s.Status != "deferred" || !s.AutoStart || !s.BuiltIn || !s.Configured {
t.Fatalf("enabled context7 view = %+v, want deferred built-in configured autoStart", s)
}
return
}
}
t.Fatalf("context7 missing from Capabilities: %+v", view.Servers)
}
func validContext7Runner(command string, args []string) bool {
switch command {
case "npx":
return reflect.DeepEqual(args, []string{"-y", "@upstash/context7-mcp"})
case "pnpm":
return reflect.DeepEqual(args, []string{"dlx", "@upstash/context7-mcp"})
case "bunx":
return reflect.DeepEqual(args, []string{"@upstash/context7-mcp"})
default:
return false
}
}
func TestConfiguredMCPWithBuiltInNameTakesPrecedence(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = false
[[plugins]]
name = "time"
command = "custom-time"
@@ -2461,7 +2326,7 @@ tier = "lazy"
}
found = true
if s.BuiltIn || !s.Configured || s.Command != "custom-time" || !reflect.DeepEqual(s.Args, []string{"serve"}) {
t.Fatalf("configured time view = %+v, want user config to take precedence over built-in", s)
t.Fatalf("configured time view = %+v, want ordinary user MCP config", s)
}
}
if !found {
@@ -2488,9 +2353,6 @@ func TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = false
[[plugins]]
name = "time"
command = "custom-time"
@@ -2532,45 +2394,11 @@ args = ["serve"]
}
}
func TestSetBuiltInMCPDisabledWritesBuiltInConfigOnly(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
if err := app.SetMCPServerEnabled("time", false); err != nil {
t.Fatalf("SetMCPServerEnabled(time,false): %v", err)
}
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "time" {
if s.Status != "disabled" || !s.BuiltIn || !s.Configured {
t.Fatalf("time disabled view = %+v, want disabled built-in configured", s)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if _, ok := findPluginEntry(cfg.Plugins, "time"); ok {
t.Fatalf("time built-in disable wrote a user plugin: %+v", cfg.Plugins)
}
if cfg.BuiltInMCP.TimeEnabled {
t.Fatalf("time built-in disable left time_enabled true: %+v", cfg.BuiltInMCP)
}
return
}
}
t.Fatalf("time missing from Capabilities after disable: %+v", view.Servers)
}
func TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = false
[[plugins]]
name = "dida"
type = "http"
@@ -2601,9 +2429,6 @@ func TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = false
[[plugins]]
name = "stripe"
type = "http"
@@ -2635,9 +2460,6 @@ func TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = false
[[plugins]]
name = "figma"
type = "http"
@@ -2670,9 +2492,6 @@ func TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = false
[[plugins]]
name = "figma"
type = "http"
@@ -2733,9 +2552,6 @@ func TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = false
[[plugins]]
name = "playwright"
command = "npx"
@@ -2807,9 +2623,6 @@ func TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = false
[[plugins]]
name = "playwright"
command = "npx"
@@ -2847,9 +2660,6 @@ func TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = false
[[plugins]]
name = "broken"
command = "npx"
@@ -2902,9 +2712,6 @@ func TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = false
[[plugins]]
name = "broken"
command = "reasonix-missing-mcp-binary"
@@ -2961,335 +2768,11 @@ tier = "lazy"
t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
}
func TestSetMCPServerTierEnablesCodegraphAndIgnoresLegacyTier(t *testing.T) {
t.Setenv("HOME", robustTempDir(t))
t.Setenv("USERPROFILE", robustTempDir(t))
t.Setenv("XDG_CONFIG_HOME", robustTempDir(t))
t.Setenv("AppData", robustTempDir(t))
t.Setenv("PATH", robustTempDir(t))
t.Setenv("REASONIX_CACHE_DIR", robustTempDir(t)) // isolate the codegraph bundle cache so Resolve fails deterministically
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = false
auto_install = true
`), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
if err := app.SetMCPServerTier("codegraph", "eager"); err != nil {
t.Fatalf("SetMCPServerTier(codegraph): %v", err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
if !cfg.Codegraph.Enabled {
t.Fatal("codegraph enabled = false, want true after legacy tier update")
}
if got := cfg.Codegraph.Tier; got != "" {
t.Fatalf("codegraph tier = %q, want ignored legacy tier", got)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
if !userCfg.Codegraph.Enabled {
t.Fatal("user codegraph enabled = false, want true after legacy tier update")
}
if got := userCfg.Codegraph.Tier; got != "" {
t.Fatalf("user codegraph tier = %q, want ignored legacy tier", got)
}
if !mcpFailed(app.activeCtrl(), "codegraph") {
t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded for missing runtime", app.activeCtrl().Host().Failures())
}
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "codegraph" {
if s.Status != "failed" {
t.Fatalf("codegraph status = %q, want failed; server = %+v", s.Status, s)
}
if !s.BuiltIn || !s.Configured || s.Tier != "background" || !s.AutoStart {
t.Fatalf("codegraph view did not preserve built-in config: %+v", s)
}
return
}
}
t.Fatalf("codegraph missing from Capabilities: %+v", view.Servers)
}
func TestSetMCPServerEnabledPersistsCodegraphOff(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = true
tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
if err := app.SetMCPServerEnabled("codegraph", false); err != nil {
t.Fatalf("SetMCPServerEnabled(codegraph,false): %v", err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
if cfg.Codegraph.Enabled {
t.Fatal("codegraph enabled = true, want false after disabling")
}
userCfg := config.LoadForEdit(config.UserConfigPath())
if userCfg.Codegraph.Enabled {
t.Fatal("user codegraph enabled = true, want false after disabling")
}
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "codegraph" {
if s.Status != "disabled" || s.AutoStart {
t.Fatalf("codegraph disabled view = %+v, want disabled with autoStart=false", s)
}
return
}
}
t.Fatalf("codegraph missing from Capabilities: %+v", view.Servers)
}
func TestUpdateBuiltInMCPServerUpdatesCodegraphRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = false
`), 0o644); err != nil {
t.Fatal(err)
}
orig := updateBuiltInCodegraph
defer func() { updateBuiltInCodegraph = orig }()
called := false
updateBuiltInCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) {
called = true
if ctx == nil {
t.Fatal("context is nil")
}
if client == nil {
t.Fatal("http client is nil")
}
return codegraph.UpdateResult{Version: "v9.9.9", Path: filepath.Join(dir, "cache", "codegraph")}, nil
}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
got, err := app.UpdateBuiltInMCPServer("codegraph")
if err != nil {
t.Fatalf("UpdateBuiltInMCPServer(codegraph): %v", err)
}
if !called {
t.Fatal("updater was not called")
}
if got.Name != "codegraph" || got.Version != "v9.9.9" || got.Path == "" {
t.Fatalf("UpdateBuiltInMCPServer result = %+v", got)
}
}
func TestUpdateBuiltInMCPServerRejectsOtherServers(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
if _, err := app.UpdateBuiltInMCPServer("time"); err == nil {
t.Fatal("UpdateBuiltInMCPServer(time) succeeded; want error")
}
}
func TestBuiltInMCPBackgroundNotifyDoesNotDownload(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("REASONIX_CACHE_DIR", robustTempDir(t))
origCheck := checkCodegraphLatest
origDownload := downloadLatestCodegraph
origUpdate := updateBuiltInCodegraph
defer func() {
checkCodegraphLatest = origCheck
downloadLatestCodegraph = origDownload
updateBuiltInCodegraph = origUpdate
}()
checkCodegraphLatest = func(ctx context.Context, client *http.Client) (string, error) {
return "v99.99.99", nil
}
downloadLatestCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) {
t.Fatal("notify mode should not download")
return codegraph.UpdateResult{}, nil
}
updateBuiltInCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) {
t.Fatal("notify mode should not activate")
return codegraph.UpdateResult{}, nil
}
cfg := config.Default()
cfg.BuiltInMCPUpdates.Mode = config.BuiltInMCPUpdateModeNotify
statuses, err := NewApp().runBuiltInMCPUpdateCheck(cfg)
if err != nil {
t.Fatalf("runBuiltInMCPUpdateCheck: %v", err)
}
if len(statuses) != 1 || statuses[0].Phase != "available" || statuses[0].Latest != "v99.99.99" {
t.Fatalf("statuses = %+v, want available latest v99.99.99", statuses)
}
}
func TestBuiltInMCPUpdateStatusesReturnArray(t *testing.T) {
app := NewApp()
empty := app.BuiltInMCPUpdateStatuses()
if empty == nil {
t.Fatal("empty BuiltInMCPUpdateStatuses returned nil; Wails should encode []")
}
app.recordBuiltInMCPUpdateStatus(BuiltInMCPUpdateStatus{
Name: "codegraph",
Mode: "notify",
Current: "v0.9.7",
Latest: "v9.9.9",
Phase: "available",
})
statuses := app.BuiltInMCPUpdateStatuses()
if len(statuses) != 1 || statuses[0].Name != "codegraph" || statuses[0].Phase != "available" {
t.Fatalf("BuiltInMCPUpdateStatuses = %+v, want codegraph available", statuses)
}
}
func TestBuiltInMCPBackgroundDownloadDoesNotActivate(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("REASONIX_CACHE_DIR", robustTempDir(t))
origCheck := checkCodegraphLatest
origDownload := downloadLatestCodegraph
origUpdate := updateBuiltInCodegraph
defer func() {
checkCodegraphLatest = origCheck
downloadLatestCodegraph = origDownload
updateBuiltInCodegraph = origUpdate
}()
checkCodegraphLatest = func(ctx context.Context, client *http.Client) (string, error) {
if _, ok := ctx.Deadline(); !ok {
t.Fatal("manifest check context has no deadline")
}
return "v99.99.99", nil
}
downloaded := false
downloadLatestCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) {
downloaded = true
if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) <= httpTimeout+time.Second {
t.Fatalf("download context inherited manifest timeout; deadline=%v", deadline)
}
return codegraph.UpdateResult{Version: "v99.99.99", Path: filepath.Join(t.TempDir(), "codegraph")}, nil
}
updateBuiltInCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) {
t.Fatal("download mode should not activate")
return codegraph.UpdateResult{}, nil
}
cfg := config.Default()
cfg.BuiltInMCPUpdates.Mode = config.BuiltInMCPUpdateModeDownload
statuses, err := NewApp().runBuiltInMCPUpdateCheck(cfg)
if err != nil {
t.Fatalf("runBuiltInMCPUpdateCheck: %v", err)
}
if !downloaded {
t.Fatal("download mode did not call downloader")
}
if len(statuses) != 1 || statuses[0].Phase != "downloaded" || statuses[0].Path == "" {
t.Fatalf("statuses = %+v, want downloaded with path", statuses)
}
}
func TestBuiltInMCPBackgroundAutoNextSessionActivates(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("REASONIX_CACHE_DIR", robustTempDir(t))
origCheck := checkCodegraphLatest
origDownload := downloadLatestCodegraph
origUpdate := updateBuiltInCodegraph
defer func() {
checkCodegraphLatest = origCheck
downloadLatestCodegraph = origDownload
updateBuiltInCodegraph = origUpdate
}()
checkCodegraphLatest = func(ctx context.Context, client *http.Client) (string, error) {
if _, ok := ctx.Deadline(); !ok {
t.Fatal("manifest check context has no deadline")
}
return "v99.99.99", nil
}
downloadLatestCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) {
t.Fatal("auto_next_session mode should activate through the updater")
return codegraph.UpdateResult{}, nil
}
activated := false
updateBuiltInCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) {
activated = true
if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) <= httpTimeout+time.Second {
t.Fatalf("activation context inherited manifest timeout; deadline=%v", deadline)
}
return codegraph.UpdateResult{Version: "v99.99.99", Path: filepath.Join(t.TempDir(), "codegraph")}, nil
}
cfg := config.Default()
cfg.BuiltInMCPUpdates.Mode = config.BuiltInMCPUpdateModeAutoNextSession
statuses, err := NewApp().runBuiltInMCPUpdateCheck(cfg)
if err != nil {
t.Fatalf("runBuiltInMCPUpdateCheck: %v", err)
}
if !activated {
t.Fatal("auto_next_session mode did not activate")
}
if len(statuses) != 1 || statuses[0].Phase != "activated" || statuses[0].Path == "" {
t.Fatalf("statuses = %+v, want activated with path", statuses)
}
}
func TestBuiltInMCPUpdateIntervalStamp(t *testing.T) {
isolateDesktopUserDirs(t)
origNow := builtInMCPUpdateNow
defer func() { builtInMCPUpdateNow = origNow }()
now := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
builtInMCPUpdateNow = func() time.Time { return now }
if !shouldRunBuiltInMCPUpdateCheck(time.Hour) {
t.Fatal("first update check should run without a stamp")
}
markBuiltInMCPUpdateChecked()
if shouldRunBuiltInMCPUpdateCheck(time.Hour) {
t.Fatal("update check should be suppressed inside interval")
}
builtInMCPUpdateNow = func() time.Time { return now.Add(2 * time.Hour) }
if !shouldRunBuiltInMCPUpdateCheck(time.Hour) {
t.Fatal("update check should run after interval")
}
}
func TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[codegraph]
enabled = false
[[plugins]]
name = "broken"
command = "reasonix-missing-mcp-binary"
-185
View File
@@ -1,185 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"reasonix/internal/codegraph"
"reasonix/internal/config"
)
var (
checkCodegraphLatest = codegraph.LatestVersionWithClient
downloadLatestCodegraph = codegraph.DownloadLatestWithClient
builtInMCPUpdateNow = time.Now
)
type BuiltInMCPUpdateStatus struct {
Name string `json:"name"`
Mode string `json:"mode"`
Current string `json:"current"`
Latest string `json:"latest"`
Phase string `json:"phase"` // current | available | downloaded | activated | skipped | error
Path string `json:"path,omitempty"`
Err string `json:"err,omitempty"`
}
func (a *App) checkBuiltInMCPUpdates() {
cfg, err := config.Load()
if err != nil {
return
}
mode := cfg.BuiltInMCPUpdates.ResolvedMode()
if mode == config.BuiltInMCPUpdateModeOff {
return
}
if !shouldRunBuiltInMCPUpdateCheck(cfg.BuiltInMCPUpdates.CheckIntervalDuration()) {
return
}
statuses, _ := a.runBuiltInMCPUpdateCheck(cfg)
markBuiltInMCPUpdateChecked()
for _, status := range statuses {
a.recordBuiltInMCPUpdateStatus(status)
}
}
func (a *App) runBuiltInMCPUpdateCheck(cfg *config.Config) ([]BuiltInMCPUpdateStatus, error) {
if cfg == nil {
cfg = config.Default()
}
mode := cfg.BuiltInMCPUpdates.ResolvedMode()
current := codegraph.ActiveVersion()
if current == "" {
current = codegraph.Version
}
status := BuiltInMCPUpdateStatus{
Name: "codegraph",
Mode: mode,
Current: current,
Phase: "skipped",
}
if mode == config.BuiltInMCPUpdateModeOff {
return []BuiltInMCPUpdateStatus{status}, nil
}
client, err := httpClient()
if err != nil {
status.Phase = "error"
status.Err = err.Error()
return []BuiltInMCPUpdateStatus{status}, nil
}
manifestCtx, cancel := context.WithTimeout(a.reqCtx(), httpTimeout)
defer cancel()
latest, err := checkCodegraphLatest(manifestCtx, client)
if err != nil {
status.Phase = "error"
status.Err = err.Error()
return []BuiltInMCPUpdateStatus{status}, nil
}
status.Latest = latest
if !codegraph.NewerThanActive(latest) {
status.Phase = "current"
return []BuiltInMCPUpdateStatus{status}, nil
}
switch mode {
case config.BuiltInMCPUpdateModeDownload:
res, err := downloadLatestCodegraph(a.reqCtx(), client, nil)
if err != nil {
status.Phase = "error"
status.Err = err.Error()
return []BuiltInMCPUpdateStatus{status}, nil
}
status.Latest = res.Version
status.Path = res.Path
status.Phase = "downloaded"
case config.BuiltInMCPUpdateModeAutoNextSession:
res, err := updateBuiltInCodegraph(a.reqCtx(), client, nil)
if err != nil {
status.Phase = "error"
status.Err = err.Error()
return []BuiltInMCPUpdateStatus{status}, nil
}
status.Latest = res.Version
status.Path = res.Path
status.Phase = "activated"
default:
status.Phase = "available"
}
return []BuiltInMCPUpdateStatus{status}, nil
}
func (a *App) emitBuiltInMCPUpdateStatus(status BuiltInMCPUpdateStatus) {
if a.ctx == nil {
return
}
wruntime.EventsEmit(a.ctx, "builtin-mcp:update", status)
}
func (a *App) recordBuiltInMCPUpdateStatus(status BuiltInMCPUpdateStatus) {
if strings.TrimSpace(status.Name) == "" {
return
}
a.builtInMCPUpdatesMu.Lock()
if a.builtInMCPUpdates == nil {
a.builtInMCPUpdates = map[string]BuiltInMCPUpdateStatus{}
}
a.builtInMCPUpdates[status.Name] = status
a.builtInMCPUpdatesMu.Unlock()
a.emitBuiltInMCPUpdateStatus(status)
}
func (a *App) BuiltInMCPUpdateStatuses() []BuiltInMCPUpdateStatus {
a.builtInMCPUpdatesMu.RLock()
defer a.builtInMCPUpdatesMu.RUnlock()
out := make([]BuiltInMCPUpdateStatus, 0, len(a.builtInMCPUpdates))
for _, status := range a.builtInMCPUpdates {
out = append(out, status)
}
return out
}
func shouldRunBuiltInMCPUpdateCheck(interval time.Duration) bool {
if interval <= 0 {
interval = 24 * time.Hour
}
path, err := builtInMCPUpdateStampPath()
if err != nil {
return true
}
data, err := os.ReadFile(path)
if err != nil {
return true
}
last, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(string(data)))
if err != nil {
return true
}
return !builtInMCPUpdateNow().Before(last.Add(interval))
}
func markBuiltInMCPUpdateChecked() {
path, err := builtInMCPUpdateStampPath()
if err != nil {
return
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return
}
_ = os.WriteFile(path, []byte(builtInMCPUpdateNow().Format(time.RFC3339Nano)+"\n"), 0o644)
}
func builtInMCPUpdateStampPath() (string, error) {
base, err := os.UserCacheDir()
if err != nil {
return "", err
}
if base == "" {
return "", fmt.Errorf("user cache dir is empty")
}
return filepath.Join(base, "reasonix", "builtin-mcp-updates", "last-check"), nil
}
+1 -16
View File
@@ -31,7 +31,7 @@ import { useToast } from "./lib/toast";
import { asArray } from "./lib/array";
import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, useI18n, useT, type Translator } from "./lib/i18n";
import { useController, type Item, type LiveStream } from "./lib/useController";
import { app, onBuiltInMCPUpdate, onEvent, onProjectTreeChanged } from "./lib/bridge";
import { app, onEvent, onProjectTreeChanged } from "./lib/bridge";
import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music";
import { playSuccessChime } from "./lib/sound";
import { Transcript } from "./components/Transcript";
@@ -906,21 +906,6 @@ export default function App() {
return unsub;
}, []);
useEffect(() => {
return onBuiltInMCPUpdate((status) => {
if (status.name !== "codegraph") return;
if (status.phase === "available") {
showToast(t("caps.updateToastAvailable", { name: "codegraph", latest: status.latest || "" }));
} else if (status.phase === "downloaded") {
showToast(t("caps.updateToastDownloaded", { name: "codegraph", latest: status.latest || "" }));
} else if (status.phase === "activated") {
showToast(t("caps.updateToastActivated", { name: "codegraph", latest: status.latest || "" }));
} else if (status.phase === "error") {
showToast(t("caps.updateToastError", { name: "codegraph" }), "warn");
}
});
}, [showToast, t]);
const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false);
const [workspacePanelMaximized, setWorkspacePanelMaximized] = useState(false);
const [rightDockMode, setRightDockMode] = useState<RightDockMode>("context");
@@ -138,6 +138,18 @@ for (const selector of [
eq(finalDeclaration(".composer-modebar", "overflow"), "hidden", "chat mode switcher contains enlarged labels");
eq(finalDeclaration(".md table", "overflow-x"), "auto", "markdown tables scroll horizontally");
eq(finalDeclaration(".code", "overflow"), "auto", "code blocks scroll instead of widening the layout");
ok(
/@media\s*\(max-width:\s*900px\)[\s\S]*?\.settings-center\s*\{[\s\S]*?grid-template-columns\s*:\s*1fr/.test(styles),
"settings center stacks navigation before the modal is too narrow",
);
ok(
/@media\s*\(max-width:\s*900px\)[\s\S]*?\.settings-field\s*\{[\s\S]*?grid-template-columns\s*:\s*1fr/.test(styles),
"settings fields collapse to one column at the mid-width breakpoint",
);
ok(
/@media\s*\(max-width:\s*760px\)[\s\S]*?\.settings-modal\s*\{[\s\S]*?width\s*:\s*100vw[\s\S]*?height\s*:\s*100vh/.test(styles),
"settings modal only becomes fullscreen at the narrow breakpoint",
);
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { asArray } from "../lib/array";
import { app, onBuiltInMCPUpdate, openExternal } from "../lib/bridge";
import { app, openExternal } from "../lib/bridge";
import { useT } from "../lib/i18n";
import type { BuiltInMCPUpdateStatus, CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillView } from "../lib/types";
import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillView } from "../lib/types";
import { InlineConfirmButton } from "./InlineConfirmButton";
import { ResizableDrawer } from "./ResizableDrawer";
import { Tooltip } from "./Tooltip";
@@ -33,32 +33,13 @@ export function CapabilitiesPanel({
const [expandedErrors, setExpandedErrors] = useState<Set<string>>(() => new Set());
const [expandedServers, setExpandedServers] = useState<Set<string>>(() => new Set());
const [expandedServerTools, setExpandedServerTools] = useState<Set<string>>(() => new Set());
const [updateStatuses, setUpdateStatuses] = useState<Record<string, BuiltInMCPUpdateStatus>>({});
const reload = useCallback(async () => {
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] }))));
}, []);
const reloadUpdateStatuses = useCallback(async () => {
setUpdateStatuses(normalizeBuiltInMCPUpdateStatuses(await app.BuiltInMCPUpdateStatuses().catch(() => [])));
}, []);
useEffect(() => {
void reload();
}, [reload]);
useEffect(() => {
let cancelled = false;
void app.BuiltInMCPUpdateStatuses()
.then((statuses) => {
if (!cancelled) setUpdateStatuses(normalizeBuiltInMCPUpdateStatuses(statuses));
})
.catch(() => {});
const unsubscribe = onBuiltInMCPUpdate((status) => {
setUpdateStatuses((prev) => mergeBuiltInMCPUpdateStatus(prev, status));
});
return () => {
cancelled = true;
unsubscribe();
};
}, []);
useEffect(() => {
if (tab !== "servers" || !view?.servers.some((s) => s.status === "initializing" || s.status === "deferred")) return;
const id = window.setInterval(() => void reload(), 2500);
@@ -83,12 +64,6 @@ export function CapabilitiesPanel({
}
};
const upgradeBuiltInMCP = async (name: string) => {
const ok = await mutate(() => app.UpdateBuiltInMCPServer(name));
await reloadUpdateStatuses();
return ok;
};
const summary = useMemo(() => {
if (!view) return "";
return t("caps.summary", {
@@ -119,11 +94,6 @@ export function CapabilitiesPanel({
active: servers.filter((s) => s.status !== "failed"),
};
}, [view]);
const visibleUpdateStatuses = useMemo(
() => Object.values(updateStatuses).filter((status) => isVisibleBuiltInMCPUpdateStatus(status)),
[updateStatuses],
);
const toggleSkill = useCallback((name: string) => {
setExpandedSkills((prev) => {
const next = new Set(prev);
@@ -204,13 +174,6 @@ export function CapabilitiesPanel({
{tab === "servers" ? (
<section className="mem-section">
{visibleUpdateStatuses.length > 0 && (
<BuiltInMCPUpdateNotice
statuses={visibleUpdateStatuses}
busy={busy}
onUpgrade={(name) => void upgradeBuiltInMCP(name)}
/>
)}
<div className="cap-mcp-toolbar cap-mcp-toolbar--drawer">
{!adding && (
<button className="btn btn--small" disabled={busy} onClick={() => setAdding(true)}>
@@ -225,8 +188,6 @@ export function CapabilitiesPanel({
onToggle={toggleError}
onRetry={(name) => void mutate(() => app.ReconnectMCPServer(name))}
onRetryMany={(names) => void mutate(() => Promise.allSettled(names.map((name) => app.ReconnectMCPServer(name))))}
onUpgrade={(name) => void upgradeBuiltInMCP(name)}
updates={updateStatuses}
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
onConfirm={(name) => void mutate(() => app.RemoveMCPServer(name))}
onConfirmMany={(names) => void mutate(() => Promise.allSettled(names.map((name) => app.RemoveMCPServer(name))))}
@@ -242,7 +203,6 @@ export function CapabilitiesPanel({
<ServerGroup
busy={busy}
servers={serverGroups.active}
updates={updateStatuses}
expanded={expandedServers}
expandedTools={expandedServerTools}
editing={editing}
@@ -253,7 +213,6 @@ export function CapabilitiesPanel({
onCancelEdit={() => setEditing(null)}
onRetry={(name) => void mutate(() => app.ReconnectMCPServer(name))}
onReconnect={(name) => void mutate(() => app.ReconnectMCPServer(name))}
onUpgrade={(name) => void upgradeBuiltInMCP(name)}
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
onToggle={(name, on) => void mutate(() => app.SetMCPServerEnabled(name, on))}
onUpdate={(name, input) =>
@@ -342,84 +301,6 @@ function normalizeCapabilitiesView(view: CapabilitiesView | null | undefined): C
};
}
function normalizeBuiltInMCPUpdateStatuses(
statuses: BuiltInMCPUpdateStatus[] | null | undefined,
): Record<string, BuiltInMCPUpdateStatus> {
const out: Record<string, BuiltInMCPUpdateStatus> = {};
for (const status of asArray(statuses)) {
const name = (status?.name || "").trim();
if (!name) continue;
out[name] = {
...status,
name,
phase: normalizeBuiltInMCPUpdatePhase(status.phase),
};
}
return out;
}
function mergeBuiltInMCPUpdateStatus(
prev: Record<string, BuiltInMCPUpdateStatus>,
status: BuiltInMCPUpdateStatus,
): Record<string, BuiltInMCPUpdateStatus> {
const name = (status?.name || "").trim();
if (!name) return prev;
return {
...prev,
[name]: {
...status,
name,
phase: normalizeBuiltInMCPUpdatePhase(status.phase),
},
};
}
function normalizeBuiltInMCPUpdatePhase(phase: string): BuiltInMCPUpdateStatus["phase"] {
switch (phase) {
case "current":
case "available":
case "downloaded":
case "activated":
case "skipped":
case "error":
return phase;
default:
return "skipped";
}
}
function isVisibleBuiltInMCPUpdateStatus(status: BuiltInMCPUpdateStatus): boolean {
return status.name === "codegraph" && ["available", "downloaded", "activated", "error"].includes(status.phase);
}
function BuiltInMCPUpdateNotice({
statuses,
busy,
onUpgrade,
}: {
statuses: BuiltInMCPUpdateStatus[];
busy: boolean;
onUpgrade: (name: string) => void;
}) {
const t = useT();
const primary = statuses[0];
if (!primary) return null;
const canUpgrade = primary.name === "codegraph" && (primary.phase === "available" || primary.phase === "downloaded");
return (
<div className={`cap-update-notice cap-update-notice--${primary.phase}`}>
<div className="cap-update-notice__text">
<div className="cap-update-notice__title">{t("caps.updateNoticeTitle")}</div>
<div className="cap-update-notice__summary">{builtInMCPUpdateSummary(primary, t)}</div>
</div>
{canUpgrade && (
<button className="btn btn--small" disabled={busy} type="button" onClick={() => onUpgrade(primary.name)}>
{t("caps.upgradeBuiltInMCP")}
</button>
)}
</div>
);
}
function sortServersForDisplay(servers: ServerView[]): ServerView[] {
return [...servers].sort((a, b) => {
const priority = serverDisplayPriority(a) - serverDisplayPriority(b);
@@ -725,7 +606,6 @@ function skillRootBadges(root: SkillRootView, t: ReturnType<typeof useT>): Array
function ServerGroup({
servers,
updates,
expanded,
expandedTools,
busy,
@@ -735,7 +615,6 @@ function ServerGroup({
onCancelEdit,
onRetry,
onReconnect,
onUpgrade,
onConfirmClearAuth,
onToggle,
onUpdate,
@@ -743,7 +622,6 @@ function ServerGroup({
onToggleTools,
}: {
servers: ServerView[];
updates: Record<string, BuiltInMCPUpdateStatus>;
expanded: Set<string>;
expandedTools: Set<string>;
busy: boolean;
@@ -753,7 +631,6 @@ function ServerGroup({
onCancelEdit: () => void;
onRetry: (name: string) => void;
onReconnect: (name: string) => void;
onUpgrade: (name: string) => void;
onConfirmClearAuth: (name: string) => void;
onToggle: (name: string, on: boolean) => void;
onUpdate: (name: string, input: MCPServerInput) => void;
@@ -767,7 +644,6 @@ function ServerGroup({
<ServerRow
key={s.name}
s={s}
updateStatus={updates[s.name]}
expanded={expanded.has(s.name)}
toolsExpanded={expandedTools.has(s.name)}
busy={busy}
@@ -777,7 +653,6 @@ function ServerGroup({
onCancelEdit={onCancelEdit}
onRetry={() => onRetry(s.name)}
onReconnect={() => onReconnect(s.name)}
onUpgrade={() => onUpgrade(s.name)}
onConfirmClearAuth={() => onConfirmClearAuth(s.name)}
onToggle={(on) => onToggle(s.name, on)}
onUpdate={(input) => onUpdate(s.name, input)}
@@ -791,24 +666,20 @@ function ServerGroup({
function FailedServersNotice({
servers,
updates,
expanded,
busy,
onToggle,
onRetry,
onUpgrade,
onRetryMany,
onConfirmClearAuth,
onConfirm,
onConfirmMany,
}: {
servers: ServerView[];
updates: Record<string, BuiltInMCPUpdateStatus>;
expanded: Set<string>;
busy: boolean;
onToggle: (name: string) => void;
onRetry: (name: string) => void;
onUpgrade: (name: string) => void;
onRetryMany: (names: string[]) => void;
onConfirmClearAuth: (name: string) => void;
onConfirm: (name: string) => void;
@@ -878,19 +749,13 @@ function FailedServersNotice({
<span className="cap-dot cap-dot--failed" />
<div className="cap-failure__text">
<div className="cap-failure__name">{s.name}</div>
<div className="cap-failure__summary">{s.authStatus === "required" ? t("caps.authRequiredSummary") : summarizeServerError(error, t)}</div>
{updates[s.name] && <div className="cap-failure__summary">{builtInMCPUpdateSummary(updates[s.name], t)}</div>}
<div className="cap-failure__summary">{s.authStatus === "required" ? t("caps.authRequiredSummary") : summarizeServerError(error)}</div>
</div>
</div>
<div className="cap-failure__actions">
<button className="btn btn--small" disabled={busy} onClick={handlePrimaryAction}>
{actionLabel}
</button>
{canUpgradeBuiltInMCP(s, updates[s.name]) && (
<button className="btn btn--small" disabled={busy} onClick={() => onUpgrade(s.name)}>
{t("caps.upgradeBuiltInMCP")}
</button>
)}
{canClearAuth(s) && (
<InlineConfirmButton
label={t("caps.clearAuth")}
@@ -935,7 +800,6 @@ function FailedServersNotice({
function ServerRow({
s,
updateStatus,
expanded,
toolsExpanded,
busy,
@@ -945,7 +809,6 @@ function ServerRow({
onCancelEdit,
onRetry,
onReconnect,
onUpgrade,
onConfirmClearAuth,
onToggle,
onUpdate,
@@ -953,7 +816,6 @@ function ServerRow({
onToggleTools,
}: {
s: ServerView;
updateStatus?: BuiltInMCPUpdateStatus;
expanded: boolean;
toolsExpanded: boolean;
busy: boolean;
@@ -963,7 +825,6 @@ function ServerRow({
onCancelEdit: () => void;
onRetry: () => void;
onReconnect: () => void;
onUpgrade: () => void;
onConfirmClearAuth: () => void;
onToggle: (on: boolean) => void;
onUpdate: (input: MCPServerInput) => void;
@@ -988,10 +849,6 @@ function ServerRow({
if (s.authStatus === "possible" && s.status !== "failed") {
sub = `${sub} · ${t("caps.authPossibleShort")}`;
}
const updateSummary = builtInMCPRowUpdateSummary(s, updateStatus, t);
if (updateSummary) {
sub = `${sub} · ${updateSummary}`;
}
const enabled = s.status === "connected" || s.status === "deferred" || s.status === "initializing";
const handlePrimaryAction = () => {
if (shouldOpenAuth(s)) {
@@ -1019,11 +876,6 @@ function ServerRow({
<span className="cap-row__name">{s.name}</span>
<span className="cap-row__transport">{s.transport}</span>
{s.builtIn && <span className="cap-row__builtin">{t("caps.builtIn")}</span>}
{isVisibleBuiltInMCPUpdateStatusForServer(s, updateStatus) && (
<span className={`cap-row__builtin cap-row__update cap-row__update--${updateStatus.phase}`}>
{builtInMCPUpdateBadge(updateStatus, t)}
</span>
)}
</div>
<div className="cap-row__sub">{sub}</div>
</div>
@@ -1053,13 +905,11 @@ function ServerRow({
{expanded && (
<ServerDetails
s={s}
updateStatus={updateStatus}
tools={tools}
busy={busy}
onConfirm={onConfirm}
onConnectNow={onRetry}
onReconnect={onReconnect}
onUpgrade={onUpgrade}
onConfirmClearAuth={onConfirmClearAuth}
toolsExpanded={toolsExpanded}
editing={editing}
@@ -1075,13 +925,11 @@ function ServerRow({
function ServerDetails({
s,
updateStatus,
tools,
busy,
onConfirm,
onConnectNow,
onReconnect,
onUpgrade,
onConfirmClearAuth,
toolsExpanded,
editing,
@@ -1091,13 +939,11 @@ function ServerDetails({
onToggleTools,
}: {
s: ServerView;
updateStatus?: BuiltInMCPUpdateStatus;
tools: ServerView["toolList"];
busy: boolean;
onConfirm: () => void;
onConnectNow: () => void;
onReconnect: () => void;
onUpgrade: () => void;
onConfirmClearAuth: () => void;
toolsExpanded: boolean;
editing: boolean;
@@ -1111,7 +957,6 @@ function ServerDetails({
const canEditConfig = s.configured && !s.builtIn;
const canConnectNow = s.status === "deferred" || s.status === "disabled";
const canReconnect = s.status === "connected";
const canUpgrade = canUpgradeBuiltInMCP(s, updateStatus);
const canShowTools = s.status === "connected" && ((s.tools ?? 0) > 0 || (tools?.length ?? 0) > 0);
const showClearAuth = canClearAuth(s);
const authLabel = serverAuthLabel(s, t);
@@ -1139,12 +984,6 @@ function ServerDetails({
<span className="cap-detail__value">{authLabel}</span>
</div>
)}
{s.builtIn && (
<div className="cap-detail cap-detail--wide">
<span className="cap-detail__label">{t("caps.updatePolicy")}</span>
<span className="cap-detail__value">{builtInMCPUpdatePolicy(s, updateStatus, t)}</span>
</div>
)}
{command && (
<div className="cap-detail cap-detail--wide">
<span className="cap-detail__label">{s.transport === "stdio" ? t("caps.command") : t("caps.url")}</span>
@@ -1169,11 +1008,6 @@ function ServerDetails({
{t("caps.reconnect")}
</button>
)}
{canUpgrade && (
<button className="btn btn--small" disabled={busy} onClick={onUpgrade}>
{t("caps.upgradeBuiltInMCP")}
</button>
)}
{canShowTools && (
<button className="btn btn--small" disabled={busy} onClick={onToggleTools} aria-expanded={toolsExpanded}>
{toolsExpanded ? t("caps.hideTools") : t("caps.showTools")}
@@ -1324,72 +1158,6 @@ function parseEnvText(env: string): Record<string, string> {
return envMap;
}
function isVisibleBuiltInMCPUpdateStatusForServer(s: ServerView, status?: BuiltInMCPUpdateStatus): status is BuiltInMCPUpdateStatus {
return Boolean(s.builtIn && status && isVisibleBuiltInMCPUpdateStatus(status));
}
function builtInMCPRowUpdateSummary(
s: ServerView,
status: BuiltInMCPUpdateStatus | undefined,
t: ReturnType<typeof useT>,
): string {
if (!s.builtIn) return "";
if (s.name === "codegraph" && status && status.phase !== "current" && status.phase !== "skipped") {
return builtInMCPUpdateSummary(status, t);
}
if (s.name === "context7") return t("caps.updateManagedByNpxShort");
if (s.name === "time") return t("caps.updateBundledWithAppShort");
return "";
}
function builtInMCPUpdatePolicy(
s: ServerView,
status: BuiltInMCPUpdateStatus | undefined,
t: ReturnType<typeof useT>,
): string {
if (s.name === "codegraph") {
if (status) return builtInMCPUpdateSummary(status, t);
return t("caps.updateCodegraphManual");
}
if (s.name === "context7") return t("caps.updateManagedByNpx");
if (s.name === "time") return t("caps.updateBundledWithApp");
return t("caps.updateNotManaged");
}
function builtInMCPUpdateSummary(status: BuiltInMCPUpdateStatus, t: ReturnType<typeof useT>): string {
const current = status.current || "";
const latest = status.latest || "";
switch (status.phase) {
case "available":
return t("caps.updateAvailable", { current, latest });
case "downloaded":
return t("caps.updateDownloaded", { current, latest });
case "activated":
return t("caps.updateActivated", { latest });
case "error":
return status.err ? t("caps.updateErrorWithMessage", { err: status.err }) : t("caps.updateError");
case "current":
return t("caps.updateCurrent", { current });
default:
return t("caps.updateCodegraphManual");
}
}
function builtInMCPUpdateBadge(status: BuiltInMCPUpdateStatus, t: ReturnType<typeof useT>): string {
switch (status.phase) {
case "available":
return t("caps.updateBadgeAvailable");
case "downloaded":
return t("caps.updateBadgeDownloaded");
case "activated":
return t("caps.updateBadgeActivated");
case "error":
return t("caps.updateBadgeError");
default:
return "";
}
}
function serverStatusLabel(s: ServerView, t: ReturnType<typeof useT>): string {
switch (s.status) {
case "connected":
@@ -1408,12 +1176,9 @@ function serverStatusLabel(s: ServerView, t: ReturnType<typeof useT>): string {
}
}
function summarizeServerError(error: string, t: ReturnType<typeof useT>): string {
function summarizeServerError(error: string): string {
const normalized = error.replace(/\s+/g, " ").trim();
const plugin = normalized.match(/plugin "([^"]+)"/i)?.[1];
if (plugin === "codegraph" && normalized.includes("context deadline exceeded")) {
return t("caps.codegraphWarming");
}
const npmCode = normalized.match(/\bnpm error code ([A-Z0-9_]+)/i)?.[1];
const errno = normalized.match(/\berrno (-?\d+)/i)?.[1];
const reason = npmCode
@@ -1515,12 +1280,6 @@ function canClearAuth(s: ServerView): boolean {
return Boolean(s.authConfigured || s.authStatus === "required" || s.authStatus === "possible" || isRemoteTransport(s.transport));
}
function canUpgradeBuiltInMCP(s: ServerView, status?: BuiltInMCPUpdateStatus): boolean {
if (!s.builtIn || s.name !== "codegraph") return false;
if (!status) return true;
return status.phase === "available" || status.phase === "downloaded" || status.phase === "error";
}
function isRemoteTransport(transport?: string): boolean {
const value = (transport || "").trim().toLowerCase();
return value === "http" || value === "streamable-http" || value === "sse";
@@ -1681,30 +1440,11 @@ export function MCPServersSettingsPage() {
const [expandedErrors, setExpandedErrors] = useState<Set<string>>(() => new Set());
const [expandedServers, setExpandedServers] = useState<Set<string>>(() => new Set());
const [expandedServerTools, setExpandedServerTools] = useState<Set<string>>(() => new Set());
const [updateStatuses, setUpdateStatuses] = useState<Record<string, BuiltInMCPUpdateStatus>>({});
const reload = useCallback(async () => {
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] }))));
}, []);
const reloadUpdateStatuses = useCallback(async () => {
setUpdateStatuses(normalizeBuiltInMCPUpdateStatuses(await app.BuiltInMCPUpdateStatuses().catch(() => [])));
}, []);
useEffect(() => { void reload(); }, [reload]);
useEffect(() => {
let cancelled = false;
void app.BuiltInMCPUpdateStatuses()
.then((statuses) => {
if (!cancelled) setUpdateStatuses(normalizeBuiltInMCPUpdateStatuses(statuses));
})
.catch(() => {});
const unsubscribe = onBuiltInMCPUpdate((status) => {
setUpdateStatuses((prev) => mergeBuiltInMCPUpdateStatus(prev, status));
});
return () => {
cancelled = true;
unsubscribe();
};
}, []);
useEffect(() => {
if (!view || !view.servers.some((s) => s.status === "initializing" || s.status === "deferred")) return;
const id = window.setInterval(() => void reload(), 2500);
@@ -1726,12 +1466,6 @@ export function MCPServersSettingsPage() {
setBusy(false);
}
};
const upgradeBuiltInMCP = async (name: string) => {
const ok = await mutate(() => app.UpdateBuiltInMCPServer(name));
await reloadUpdateStatuses();
return ok;
};
const serverGroups = useMemo(() => {
const servers = sortServersForDisplay(view?.servers ?? []);
return {
@@ -1739,11 +1473,6 @@ export function MCPServersSettingsPage() {
active: servers.filter((s) => s.status !== "failed"),
};
}, [view]);
const visibleUpdateStatuses = useMemo(
() => Object.values(updateStatuses).filter((status) => isVisibleBuiltInMCPUpdateStatus(status)),
[updateStatuses],
);
const toggleError = useCallback((name: string) => {
setExpandedErrors((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; });
}, []);
@@ -1764,13 +1493,6 @@ export function MCPServersSettingsPage() {
return (
<section className="mem-section">
{err && serverGroups.failed.length === 0 && <div className="banner banner--error">{err}</div>}
{visibleUpdateStatuses.length > 0 && (
<BuiltInMCPUpdateNotice
statuses={visibleUpdateStatuses}
busy={busy}
onUpgrade={(name) => void upgradeBuiltInMCP(name)}
/>
)}
<div className="cap-mcp-toolbar">
{view.servers.length > 0 ? <div className="drawer__summary">{summary}</div> : <span />}
<div className="cap-mcp-toolbar__actions">
@@ -1784,13 +1506,11 @@ export function MCPServersSettingsPage() {
{serverGroups.failed.length > 0 && (
<FailedServersNotice
servers={serverGroups.failed}
updates={updateStatuses}
expanded={expandedErrors}
busy={busy}
onToggle={toggleError}
onRetry={(name) => void mutate(() => app.ReconnectMCPServer(name))}
onRetryMany={(names) => void mutate(() => Promise.allSettled(names.map((name) => app.ReconnectMCPServer(name))))}
onUpgrade={(name) => void upgradeBuiltInMCP(name)}
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
onConfirm={(name) => void mutate(() => app.RemoveMCPServer(name))}
onConfirmMany={(names) => void mutate(() => Promise.allSettled(names.map((name) => app.RemoveMCPServer(name))))}
@@ -1805,7 +1525,6 @@ export function MCPServersSettingsPage() {
<ServerGroup
busy={busy}
servers={serverGroups.active}
updates={updateStatuses}
expanded={expandedServers}
expandedTools={expandedServerTools}
editing={editing}
@@ -1814,7 +1533,6 @@ export function MCPServersSettingsPage() {
onCancelEdit={() => setEditing(null)}
onRetry={(name) => void mutate(() => app.ReconnectMCPServer(name))}
onReconnect={(name) => void mutate(() => app.ReconnectMCPServer(name))}
onUpgrade={(name) => void upgradeBuiltInMCP(name)}
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
onToggle={(name, on) => void mutate(() => app.SetMCPServerEnabled(name, on))}
onUpdate={(name, input) =>
+1 -91
View File
@@ -19,8 +19,6 @@ import type {
BotInstallStartResult,
BotRuntimeStatusView,
BotSettingsView,
BuiltInMCPUpdateResult,
BuiltInMCPUpdateStatus,
CapabilitiesView,
CheckpointMeta,
CommandInfo,
@@ -166,8 +164,6 @@ export interface AppBindings {
UpdateMCPServer(name: string, input: MCPServerInput): Promise<void>;
RemoveMCPServer(name: string): Promise<void>;
ReconnectMCPServer(name: string): Promise<void>;
UpdateBuiltInMCPServer(name: string): Promise<BuiltInMCPUpdateResult>;
BuiltInMCPUpdateStatuses(): Promise<BuiltInMCPUpdateStatus[]>;
ClearMCPServerAuthentication(name: string): Promise<void>;
PickSkillFolder(): Promise<string>;
AddSkillPath(path: string): Promise<void>;
@@ -372,13 +368,6 @@ export function onUpdaterProgress(cb: (p: UpdateProgress) => void): () => void {
};
}
export function onBuiltInMCPUpdate(cb: (status: BuiltInMCPUpdateStatus) => void): () => void {
if (realApp() && typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("builtin-mcp:update", (status) => cb(status as BuiltInMCPUpdateStatus));
}
return () => {};
}
function errorMessage(err: unknown): string {
if (err && typeof err === "object" && "message" in err) {
const msg = (err as { message?: unknown }).message;
@@ -510,7 +499,7 @@ function bridgeBreadcrumb(method: string): string {
if (/^(SaveProvider|AddOfficialProviderAccess|RemoveProviderAccess|DeleteProvider|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
return `provider ${method}`;
if (/^(CheckUpdate|ApplyUpdate|OpenDownloadPage)/.test(method)) return `update ${method}`;
if (/^(AddMCPServer|UpdateMCPServer|RemoveMCPServer|ReconnectMCPServer|UpdateBuiltInMCPServer|BuiltInMCPUpdateStatuses|ClearMCPServerAuthentication|SetMCPServer)/.test(method))
if (/^(AddMCPServer|UpdateMCPServer|RemoveMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|SetMCPServer)/.test(method))
return `mcp ${method}`;
if (/^(AddSkillPath|RemoveSkillPath|RefreshSkills|SetSkillEnabled|AcceptSkillSuggestion)/.test(method))
return `skill ${method}`;
@@ -628,54 +617,6 @@ function makeMockApp(): AppBindings {
const t0 = Date.now();
// Mutable so MCP add/remove/retry are observable in browser dev.
let capServers: ServerView[] = [
{
name: "codegraph",
transport: "stdio",
status: "disabled",
builtIn: true,
configured: true,
autoStart: false,
tier: "background",
command: "codegraph",
args: ["serve", "--mcp"],
tools: 0,
prompts: 0,
resources: 0,
toolList: [
{ name: "search", description: "Search symbols, files, and text in the workspace." },
{ name: "context", description: "Fetch surrounding source context for a symbol or file." },
{ name: "trace", description: "Follow callers and callees across the code graph." },
{ name: "node", description: "Inspect a specific graph node." },
],
},
{
name: "time",
transport: "stdio",
status: "deferred",
builtIn: true,
configured: true,
autoStart: true,
tier: "lazy",
command: "reasonix",
args: ["builtin-mcp", "time"],
tools: 0,
prompts: 0,
resources: 0,
},
{
name: "context7",
transport: "stdio",
status: "disabled",
builtIn: true,
configured: true,
autoStart: false,
tier: "lazy",
command: "npx",
args: ["-y", "@upstash/context7-mcp"],
tools: 0,
prompts: 0,
resources: 0,
},
{ name: "github", transport: "stdio", status: "connected", configured: true, autoStart: true, tier: "background", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"], tools: 12, prompts: 2, resources: 0 },
{
name: "linear",
@@ -703,15 +644,6 @@ function makeMockApp(): AppBindings {
},
{ name: "figma", transport: "http", status: "failed", configured: true, autoStart: true, tier: "background", url: "https://mcp.figma.com/mcp", authStatus: "required", authUrl: "https://mcp.figma.com/mcp", tools: 0, prompts: 0, resources: 0, error: "connect: 401 unauthorized" },
];
let builtInMCPUpdates: BuiltInMCPUpdateStatus[] = [
{
name: "codegraph",
mode: "notify",
current: "v0.9.7",
latest: "v0.10.0",
phase: "available",
},
];
const capSkills: SkillView[] = [
{ name: "explore", description: "Investigate the codebase in an isolated subagent", scope: "builtin", runAs: "subagent", enabled: true },
{ name: "review", description: "Review the staged diff", scope: "project", runAs: "inline", enabled: false },
@@ -2019,28 +1951,6 @@ function makeMockApp(): AppBindings {
s.name === name ? { ...s, status: "connected", tools: s.tools || 4 } : s,
);
},
async UpdateBuiltInMCPServer(name: string) {
if (name !== "codegraph") throw new Error(`${name} is not an updatable built-in MCP server`);
capServers = capServers.map((s) =>
s.name === name
? { ...s, status: s.autoStart ? "connected" : s.status, tools: s.autoStart ? s.tools || 4 : s.tools, error: undefined }
: s,
);
builtInMCPUpdates = [
{
name,
mode: "manual",
current: "v0.10.0",
latest: "v0.10.0",
phase: "activated",
path: "/tmp/reasonix/codegraph/mock/codegraph",
},
];
return { name, version: "v0.10.0", path: "/tmp/reasonix/codegraph/mock/codegraph" };
},
async BuiltInMCPUpdateStatuses() {
return builtInMCPUpdates.map((s) => ({ ...s }));
},
async ClearMCPServerAuthentication(name: string) {
capServers = capServers.map((s) =>
s.name === name
-17
View File
@@ -536,23 +536,6 @@ export interface CapabilitiesView {
skills: SkillView[];
skillRoots: SkillRootView[];
}
export interface BuiltInMCPUpdateResult {
name: string;
version: string;
path: string;
}
export type BuiltInMCPUpdatePhase = "current" | "available" | "downloaded" | "activated" | "skipped" | "error";
export interface BuiltInMCPUpdateStatus {
name: string;
mode: string;
current: string;
latest: string;
phase: BuiltInMCPUpdatePhase;
path?: string;
err?: string;
}
export interface MCPServerInput {
name: string;
transport: string; // stdio | http | sse
+1 -11
View File
@@ -194,7 +194,7 @@ export function shouldReconcileStaleTurn(
return Math.max(0, now - lastTurnActivityAt) >= timeoutMs;
}
/** Mirrors Go backend's ReadOnly() + codegraph ReadOnlyToolNames(). */
/** Mirrors Go backend's ReadOnly() hints. */
export function isReadOnlyTool(name: string): boolean {
switch (name) {
case "read_file":
@@ -206,16 +206,6 @@ export function isReadOnlyTool(name: string): boolean {
case "waitJob":
case "todo_write":
case "read_skill":
case "codegraph_callees":
case "codegraph_callers":
case "codegraph_context":
case "codegraph_explore":
case "codegraph_files":
case "codegraph_impact":
case "codegraph_node":
case "codegraph_search":
case "codegraph_status":
case "codegraph_trace":
return true;
default:
return false;
-24
View File
@@ -235,29 +235,6 @@ export const en = {
"caps.status": "Status",
"caps.refresh": "Refresh MCP status",
"caps.reconnect": "Reconnect",
"caps.upgradeBuiltInMCP": "Upgrade built-in MCP",
"caps.updateNoticeTitle": "Built-in MCP update status",
"caps.updatePolicy": "Update source",
"caps.updateAvailable": "New version {latest} available (current {current})",
"caps.updateDownloaded": "{latest} is pre-downloaded; upgrade to enable it",
"caps.updateActivated": "Upgraded to {latest}; new connections will use it",
"caps.updateCurrent": "Already current at {current}",
"caps.updateError": "Update check failed",
"caps.updateErrorWithMessage": "Update check failed: {err}",
"caps.updateCodegraphManual": "Background checks run safely; CodeGraph can be upgraded manually",
"caps.updateManagedByNpx": "Managed by npx/npm; Reasonix does not pin this runtime",
"caps.updateManagedByNpxShort": "Updates via npx",
"caps.updateBundledWithApp": "Updated with the Reasonix app",
"caps.updateBundledWithAppShort": "Updates with app",
"caps.updateNotManaged": "Independent updates are not supported",
"caps.updateBadgeAvailable": "Update",
"caps.updateBadgeDownloaded": "Downloaded",
"caps.updateBadgeActivated": "Upgraded",
"caps.updateBadgeError": "Check failed",
"caps.updateToastAvailable": "{name} {latest} is available; upgrade it in MCP settings.",
"caps.updateToastDownloaded": "{name} {latest} is downloaded; enable it in MCP settings.",
"caps.updateToastActivated": "{name} upgraded to {latest}.",
"caps.updateToastError": "{name} update check failed.",
"caps.command": "Command",
"caps.url": "URL",
"caps.envKeys": "Environment",
@@ -281,7 +258,6 @@ export const en = {
"caps.authPossibleShort": "auth may be required",
"caps.clearAuth": "Clear auth",
"caps.confirmClearAuth": "Confirm clear",
"caps.codegraphWarming": "codegraph: built-in code intelligence is still warming up or preparing the project index; retry shortly",
"caps.deferred": "enabled; connects when this MCP is used",
"caps.initializing": "connecting to MCP; tools will appear when ready",
"caps.initializingShort": "Connecting",
-24
View File
@@ -151,29 +151,6 @@ export const zhTW: Record<DictKey, string> = {
"caps.status": "狀態",
"caps.refresh": "重新整理 MCP 狀態",
"caps.reconnect": "重新連線",
"caps.upgradeBuiltInMCP": "升級內建 MCP",
"caps.updateNoticeTitle": "內建 MCP 更新狀態",
"caps.updatePolicy": "更新方式",
"caps.updateAvailable": "有新版本 {latest}(目前 {current})",
"caps.updateDownloaded": "已預先下載 {latest},可點擊升級啟用",
"caps.updateActivated": "已升級到 {latest},新連線會使用新版",
"caps.updateCurrent": "已是最新版本 {current}",
"caps.updateError": "檢查更新失敗",
"caps.updateErrorWithMessage": "檢查更新失敗:{err}",
"caps.updateCodegraphManual": "背景會檢查更新;可手動升級 CodeGraph runtime",
"caps.updateManagedByNpx": "由 npx/npm 管理,Reasonix 不託管固定版本",
"caps.updateManagedByNpxShort": "由 npx 管理更新",
"caps.updateBundledWithApp": "隨 Reasonix 應用本體更新",
"caps.updateBundledWithAppShort": "隨應用更新",
"caps.updateNotManaged": "目前不支援獨立升級",
"caps.updateBadgeAvailable": "有更新",
"caps.updateBadgeDownloaded": "已預下載",
"caps.updateBadgeActivated": "已升級",
"caps.updateBadgeError": "檢查失敗",
"caps.updateToastAvailable": "{name} 有新版本 {latest},可在 MCP 管理頁升級。",
"caps.updateToastDownloaded": "{name} {latest} 已預下載,可在 MCP 管理頁啟用。",
"caps.updateToastActivated": "{name} 已升級到 {latest}。",
"caps.updateToastError": "{name} 更新檢查失敗。",
"caps.command": "命令",
"caps.url": "URL",
"caps.envKeys": "環境變數",
@@ -197,7 +174,6 @@ export const zhTW: Record<DictKey, string> = {
"caps.authPossibleShort": "可能需要授權",
"caps.clearAuth": "清除授權",
"caps.confirmClearAuth": "確認清除",
"caps.codegraphWarming": "codegraph:內建程式碼智慧仍在冷啟動或準備專案索引,稍後重試即可",
"caps.deferred": "已啟用,用到該 MCP 時連線",
"caps.initializing": "正在連線 MCP,工具可用後會自動出現",
"caps.initializingShort": "連線中",
-24
View File
@@ -236,29 +236,6 @@ export const zh: Record<DictKey, string> = {
"caps.status": "状态",
"caps.refresh": "刷新 MCP 状态",
"caps.reconnect": "重新连接",
"caps.upgradeBuiltInMCP": "升级内置 MCP",
"caps.updateNoticeTitle": "内置 MCP 更新状态",
"caps.updatePolicy": "更新方式",
"caps.updateAvailable": "有新版本 {latest}(当前 {current})",
"caps.updateDownloaded": "已预下载 {latest},可点击升级启用",
"caps.updateActivated": "已升级到 {latest},新连接会使用新版",
"caps.updateCurrent": "已是最新版本 {current}",
"caps.updateError": "检查更新失败",
"caps.updateErrorWithMessage": "检查更新失败:{err}",
"caps.updateCodegraphManual": "后台会检查更新;可手动升级 CodeGraph runtime",
"caps.updateManagedByNpx": "由 npx/npm 管理,Reasonix 不托管固定版本",
"caps.updateManagedByNpxShort": "由 npx 管理更新",
"caps.updateBundledWithApp": "随 Reasonix 应用本体更新",
"caps.updateBundledWithAppShort": "随应用更新",
"caps.updateNotManaged": "当前不支持独立升级",
"caps.updateBadgeAvailable": "有更新",
"caps.updateBadgeDownloaded": "已预下载",
"caps.updateBadgeActivated": "已升级",
"caps.updateBadgeError": "检查失败",
"caps.updateToastAvailable": "{name} 有新版本 {latest},可在 MCP 管理页升级。",
"caps.updateToastDownloaded": "{name} {latest} 已预下载,可在 MCP 管理页启用。",
"caps.updateToastActivated": "{name} 已升级到 {latest}。",
"caps.updateToastError": "{name} 更新检查失败。",
"caps.command": "命令",
"caps.url": "URL",
"caps.envKeys": "环境变量",
@@ -282,7 +259,6 @@ export const zh: Record<DictKey, string> = {
"caps.authPossibleShort": "可能需要授权",
"caps.clearAuth": "清除授权",
"caps.confirmClearAuth": "确认清除",
"caps.codegraphWarming": "codegraph:内置代码智能仍在冷启动或准备项目索引,稍后重试即可",
"caps.deferred": "已启用,用到该 MCP 时连接",
"caps.initializing": "正在连接 MCP,工具可用后会自动出现",
"caps.initializingShort": "连接中",
+43 -6
View File
@@ -11347,12 +11347,15 @@ a[href] {
}
}
/* ── responsive: stacked nav on narrow screens ──────────────────────────────── */
@media (max-width: 760px) {
/* ── responsive: stacked nav on narrow and mid screens ─────────────────────── */
@media (max-width: 900px) {
.settings-modal-backdrop {
padding: 16px;
}
.settings-modal {
width: min(100vw, 100vw);
height: min(100vh, 100vh);
border-radius: 0;
width: 100%;
height: min(900px, calc(100vh - 32px));
}
.settings-center {
@@ -11389,7 +11392,7 @@ a[href] {
}
.settings-center__content {
padding: 14px 14px;
padding: 18px 20px;
}
.settings-page--form,
@@ -11410,6 +11413,7 @@ a[href] {
.settings-field {
grid-template-columns: 1fr;
gap: 8px;
align-items: stretch;
}
.settings-field__control,
@@ -11421,6 +11425,18 @@ a[href] {
flex-wrap: wrap;
}
.settings-inline-controls .set-seg {
max-width: 100%;
min-width: 0;
overflow-x: auto;
}
.settings-inline-controls .set-seg__btn {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.step-limit-control {
justify-content: flex-start;
}
@@ -11459,6 +11475,27 @@ a[href] {
}
}
@media (max-width: 760px) {
.settings-modal-backdrop {
padding: 0;
}
.settings-modal {
width: 100vw;
height: 100vh;
border-radius: 0;
}
.settings-center__content {
padding: 14px 14px;
}
.settings-section,
.settings-page--manager .mem-section {
padding: 14px;
}
}
.settings-field__control > .settings-model-picker {
width: 100%;
max-width: 520px;
-6
View File
@@ -18,8 +18,6 @@ import (
"github.com/wailsapp/wails/v2/pkg/options/mac"
"github.com/wailsapp/wails/v2/pkg/options/windows"
"reasonix/internal/builtinmcp"
// Blank imports wire compile-time built-ins into their registries, exactly as
// cmd/reasonix does — boot.Build resolves providers/tools from these registries.
_ "reasonix/internal/provider/anthropic"
@@ -60,10 +58,6 @@ func windowsWebview2GPUDisabled() bool {
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "builtin-mcp" {
os.Exit(builtinmcp.RunCommand(os.Args[2:], os.Stdin, os.Stdout, os.Stderr, version))
}
app := NewApp()
// Restore saved window size, or fall back to the default.
+1 -1
View File
@@ -12,7 +12,7 @@ func TestEnsureBlankTabInheritsActiveTabSettings(t *testing.T) {
isolateDesktopUserDirs(t)
workspace := robustTempDir(t)
if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"),
[]byte("[codegraph]\nenabled = false\n"), 0o644); err != nil {
[]byte(""), 0o644); err != nil {
t.Fatalf("write workspace config: %v", err)
}
+5 -6
View File
@@ -12,7 +12,7 @@ changed and how to move over.
| Branch | [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1) (maintenance only) | `main-v2` (default, active) |
| Versions | `0.x` (up to v0.54.x) | `1.0.0`+ |
| Install | `npm i -g reasonix` (the `latest` tag, stays on `0.x`) | `npm i -g reasonix@next` — `latest` deliberately stays on `0.x`; or a release archive / `go build` |
| Code intelligence | embedding semantic search | bundled [CodeGraph](https://github.com/colbymchenry/codegraph) (symbol/call graph) |
| Code intelligence | embedding semantic search + tree-sitter symbols | LSP-assisted code reading plus grep/read_file/glob; semantic index is not yet ported |
"v1" and "v2" are **codebase generations**, not semver: the v1 line never reached
1.0, so the Go rewrite takes the `1.x` major.
@@ -77,11 +77,10 @@ and DeepSeek prefix-cache–oriented design.
## What's different
- **Code intelligence**: embedding semantic search is replaced by **CodeGraph**
(`codegraph_*` tools) — a tree-sitter symbol/call graph, no embedding service or
API cost. New (first-run) configs start with it off; existing configs keep it
on across upgrades. Toggle `[codegraph]` in the MCP manager or config; when
enabled it starts in the background so chat startup is never blocked.
- **Code intelligence**: the Go rewrite uses LSP-assisted code reading plus
`grep` / `read_file` / `glob` for local understanding. The legacy v1 semantic
search + tree-sitter symbol index is not bundled in v2 yet, and CodeGraph is no
longer shipped as an internal MCP server.
- **Plan mode** + `complete_step` (evidence-backed step sign-off).
- **No web dashboard** — the v2 line is terminal + desktop (Wails), by design.
- Some granular v1 tools are intentionally consolidated (e.g. file-management ops
+2 -2
View File
@@ -141,7 +141,7 @@ func TestMcpSpecsNil(t *testing.T) {
func TestMcpSpecsConversion(t *testing.T) {
in := []MCPServerSpec{
{Name: "codegraph", Command: "codegraph", Args: []string{"--stdio"}, Env: MCPEnv{"HOME": "/tmp"}},
{Name: "search", Command: "search-mcp", Args: []string{"--stdio"}, Env: MCPEnv{"HOME": "/tmp"}},
}
got, err := mcpSpecs(in, "/workspace")
if err != nil {
@@ -150,7 +150,7 @@ func TestMcpSpecsConversion(t *testing.T) {
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if got[0].Name != "codegraph" || got[0].Type != "stdio" || got[0].Command != "codegraph" {
if got[0].Name != "search" || got[0].Type != "stdio" || got[0].Command != "search-mcp" {
t.Errorf("spec = %+v", got[0])
}
if got[0].Args[0] != "--stdio" {
+16 -108
View File
@@ -20,8 +20,6 @@ import (
"time"
"reasonix/internal/agent"
"reasonix/internal/builtinmcp"
"reasonix/internal/codegraph"
"reasonix/internal/command"
"reasonix/internal/config"
"reasonix/internal/control"
@@ -96,7 +94,7 @@ type Options struct {
ExtraPlugins []plugin.Spec
// TokenMode selects how much optional context/tool surface this session exposes
// at boot. Empty/full preserves the normal capability surface. "economy" keeps
// the core coding tools visible and moves skills, MCP, CodeGraph, LSP, web_fetch,
// the core coding tools visible and moves skills, MCP, LSP, web_fetch,
// install_source, and task behind connect_tool_source.
TokenMode string
// SessionDir overrides where persisted chat transcripts are written. When
@@ -251,12 +249,13 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
// Partition configured plugins by tier so eager/lazy/background can each
// take the path that fits them. User entries default to background: the
// session starts immediately while enabled MCP servers warm up.
autoStartEntries := builtinmcp.AppendEnabled(cfg.AutoStartPlugins(), cfg.Plugins, cfg.BuiltInMCP.EnabledNames(), pluginSpecNames(opts.ExtraPlugins)...)
autoStartEntries := cfg.AutoStartPlugins()
eagerEntries, lazyEntries, bgEntries := partitionByTier(autoStartEntries)
extraSpecs := applyKnownPluginOverrides(opts.ExtraPlugins)
onDemandMCPSpecs := map[string]plugin.Spec{}
onDemandMCPNames := []string{}
if tokenEconomy {
for _, spec := range append(PluginSpecs(autoStartEntries), opts.ExtraPlugins...) {
for _, spec := range append(PluginSpecs(autoStartEntries), extraSpecs...) {
name := strings.TrimSpace(spec.Name)
if name == "" {
continue
@@ -291,61 +290,8 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
lazySpecs := PluginSpecs(lazyEntries)
bgSpecs := PluginSpecs(bgEntries)
// CodeGraph is a built-in MCP server fetched on first use. When it resolves,
// inject it as one more stdio plugin pinned to the project root (it is
// cwd-aware); EnsureInit only creates .codegraph/ (fast, size-independent),
// serve's daemon then indexes in the background, so startup never blocks even
// on a large repo. When it is not yet installed, fetch it in the background
// (one-time, ~45MB) if auto_install is on — startup still never blocks, the
// tools come online next session — otherwise point the user at the explicit
// install command. A failed init or fetch is a notice, not fatal.
//
// CodeGraph is fixed to background startup. Legacy tier values are ignored so
// enabling it never blocks chat startup.
if cfg.Codegraph.Enabled && !tokenEconomy {
bin, ok := codegraph.Resolve(cfg.Codegraph.Path)
switch {
case ok && !codegraph.IndexableRoot(root):
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn,
Text: "codegraph: project root is a filesystem root — skipped to avoid indexing the whole volume"})
case ok:
spec := codegraph.MCPSpec(bin, root)
warm := codegraph.Initialized(root)
if err := codegraph.EnsureInit(ctx, bin, root); err != nil {
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn,
Text: "codegraph: init failed (" + err.Error() + ") — symbol-graph tools disabled this session"})
break
}
bgNotice := func() {
if !warm {
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
Text: "codegraph: preparing code-intelligence tools in the background — tools will appear when ready"})
}
}
bgSpecs = append(bgSpecs, spec)
bgNotice()
case cfg.Codegraph.AutoInstall:
notify := func(msg string) { sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: msg}) }
notify("codegraph: fetching code-intelligence runtime in the background (one-time) — symbol-graph tools available next session")
codegraphClient, err := netclient.NewHTTPClient(proxySpec, netclient.TransportOptions{})
if err != nil {
notify("codegraph: install skipped (" + err.Error() + ")")
} else {
go func() {
if _, err := codegraph.InstallWithClient(context.WithoutCancel(ctx), codegraphClient, nil); err != nil {
notify("codegraph: install failed (" + err.Error() + ") — using grep/glob; retries next session")
} else {
notify("codegraph: installed — symbol-graph tools available next session")
}
}()
}
default:
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
Text: "codegraph: not installed — run `reasonix codegraph install` to enable symbol-graph tools"})
}
}
if !tokenEconomy {
eagerSpecs = append(eagerSpecs, opts.ExtraPlugins...)
eagerSpecs = append(eagerSpecs, extraSpecs...)
}
// Apply caller-supplied stderr override to every spec across tiers.
@@ -392,28 +338,6 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
registerDeferred(lazySpecs, false)
registerDeferred(bgSpecs, true)
// Inject codegraph steering into the system prompt when symbol-graph tools
// are available, so the model knows to prefer them for architecture / call-graph
// questions over grep/read_file. Also register codegraph tool names in the
// subagent allowed-tools list so explore/research/review can use them.
if cfg.Codegraph.Enabled {
prefix := plugin.ToolPrefix("codegraph")
var cgTools []string
for _, name := range reg.Names() {
if strings.HasPrefix(name, prefix) {
cgTools = append(cgTools, name)
}
}
if len(cgTools) > 0 {
sysPrompt += "\n\n" + codegraph.SteerText
skill.SetExtraReadTools(cgTools)
} else {
skill.SetExtraReadTools(nil)
}
} else {
skill.SetExtraReadTools(nil)
}
for _, msg := range demoteMessages {
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: msg})
}
@@ -555,6 +479,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
// task/skill meta-tools, to bar recursion), and an optional per-skill model.
// Its tool activity nests under the invoking call, like `task`.
skillRunner := func(sctx context.Context, sk skill.Skill, task string, runOpts skill.SubagentRunOptions) (string, error) {
sk = skill.WithCodeGraphTools(sk, skill.CodeGraphReadTools(reg))
prov, price, ctxWin := execProv, entry.Price, entry.ContextWindow
modelRef := subagentModelRef(cfg, sk)
effortRef := subagentEffortRef(cfg, sk)
@@ -782,32 +707,6 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
}
return "enabled " + strings.Join(names, ", ") + ".", nil
},
codegraph: func(context.Context) (string, error) {
if !cfg.Codegraph.Enabled {
return "", fmt.Errorf("codegraph is disabled in config")
}
bin, ok := codegraph.Resolve(cfg.Codegraph.Path)
if !ok {
return "", fmt.Errorf("codegraph is not installed")
}
if !codegraph.IndexableRoot(root) {
return "", fmt.Errorf("codegraph: project root is a filesystem root — skipped to avoid indexing the whole volume")
}
if err := codegraph.EnsureInit(ctx, bin, root); err != nil {
return "", fmt.Errorf("codegraph init: %w", err)
}
spec := codegraph.MCPSpec(bin, root)
if opts.Stderr != nil {
spec.Stderr = opts.Stderr
}
tools, err := pluginHost.Add(ctx, spec)
if err != nil {
return "", err
}
reg.RemovePrefix(plugin.ToolPrefix(spec.Name))
names := addTools(reg, tools)
return "enabled codegraph tools: " + strings.Join(names, ", ") + ".", nil
},
mcp: func(_ context.Context, name string) (string, error) {
spec, ok := onDemandMCPSpecs[name]
if !ok {
@@ -1301,7 +1200,7 @@ func PluginSpecs(entries []config.PluginEntry) []plugin.Spec {
specs := make([]plugin.Spec, len(entries))
for i, e := range entries {
e = e.ExpandedPlugin() // resolve ${VAR} / ${VAR:-default} from the environment
specs[i] = plugin.Spec{
spec := plugin.Spec{
Name: e.Name,
Type: e.Type,
Command: e.Command,
@@ -1310,10 +1209,19 @@ func PluginSpecs(entries []config.PluginEntry) []plugin.Spec {
URL: e.URL,
Headers: e.Headers,
}
specs[i] = plugin.ApplyKnownReadOnlyOverrides(spec)
}
return specs
}
func applyKnownPluginOverrides(specs []plugin.Spec) []plugin.Spec {
out := make([]plugin.Spec, len(specs))
for i, spec := range specs {
out[i] = plugin.ApplyKnownReadOnlyOverrides(spec)
}
return out
}
func pluginSpecNames(specs []plugin.Spec) []string {
names := make([]string, 0, len(specs))
for _, s := range specs {
+21 -381
View File
@@ -10,10 +10,8 @@ import (
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync"
"testing"
@@ -21,8 +19,6 @@ import (
"reasonix/internal/agent"
"reasonix/internal/agent/testutil"
"reasonix/internal/builtinmcp"
"reasonix/internal/codegraph"
"reasonix/internal/config"
"reasonix/internal/event"
"reasonix/internal/memory"
@@ -61,9 +57,6 @@ func TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE SYSTEM PROMPT"
@@ -109,9 +102,6 @@ func TestBuildRegistersUsableHistoryAndMemoryRetrievalTools(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -352,9 +342,6 @@ func TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -413,9 +400,6 @@ func TestBuildSubagentStoreHonorsSessionDirOverride(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -463,9 +447,6 @@ func TestBuildSubagentSkillUsesLiveReasoningLanguage(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
reasoning_language = "zh"
@@ -509,9 +490,6 @@ func TestBuildSubagentSkillGetsForegroundOnlyBash(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -689,9 +667,6 @@ func TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -886,9 +861,6 @@ func TestBuildHonorsSessionDirOverride(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[[providers]]
name = "test-model"
kind = "openai"
@@ -921,9 +893,6 @@ func TestBuildDiscoversSkills(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -972,9 +941,6 @@ func TestBuildTokenFullMatchesDefaultRequestPrefix(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -1019,9 +985,6 @@ func TestBuildTokenEconomyStartsWithLeanToolSurface(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -1053,6 +1016,7 @@ command = "reasonix-missing-mockmcp"
"ask",
"bash",
"bash_output",
"code_index",
"complete_step",
"connect_tool_source",
"edit_file",
@@ -1117,9 +1081,6 @@ func TestBuildTokenEconomyConnectsWebFetchOnDemand(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -1149,65 +1110,6 @@ model = "x"
}
}
func TestBuildTokenEconomyCodegraphSetsShortDaemonIdleTimeout(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
launcher := writeCodegraphHelper(t, dir)
envOut := filepath.Join(dir, "codegraph-idle-env")
t.Setenv("REASONIX_CODEGRAPH_HELPER_ENV_OUT", envOut)
registerBootTokenProfileTestProvider()
prov := testutil.NewMock("token-economy",
testutil.Turn{ToolCalls: []provider.ToolCall{
{ID: "source-1", Name: "connect_tool_source", Arguments: `{"source":"codegraph"}`},
}},
testutil.Turn{Text: "done"},
)
setBootTokenProfileTestProvider(t, prov)
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
default_model = "test-model"
[codegraph]
enabled = true
path = %q
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "boot-token-profile-test"
model = "x"
`, launcher))
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if err := ctrl.Run(context.Background(), "enable codegraph later"); err != nil {
t.Fatalf("Run: %v", err)
}
reqs := prov.Requests()
if len(reqs) != 2 {
t.Fatalf("requests = %d, want 2", len(reqs))
}
if requestHasToolPrefix(reqs[0], "mcp__codegraph__") {
t.Fatalf("first request should hide codegraph; tools=%v", toolSchemaNames(reqs[0].Tools))
}
if !requestHasToolPrefix(reqs[1], "mcp__codegraph__") {
t.Fatalf("second request should expose codegraph after connect_tool_source; tools=%v", toolSchemaNames(reqs[1].Tools))
}
got, err := os.ReadFile(envOut)
if err != nil {
t.Fatalf("read codegraph idle timeout env: %v", err)
}
if string(got) != codegraph.ReasonixDaemonIdleTimeoutMS {
t.Fatalf("%s = %q; want %q", codegraph.DaemonIdleTimeoutEnv, got, codegraph.ReasonixDaemonIdleTimeoutMS)
}
}
func TestBuildTokenEconomyPlanModeCanConnectWebFetch(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
@@ -1224,9 +1126,6 @@ func TestBuildTokenEconomyPlanModeCanConnectWebFetch(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -1278,9 +1177,6 @@ default_model = "test-model"
[tools]
enabled = ["read_file", "grep"]
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -1332,9 +1228,6 @@ func TestBuildTokenEconomyConnectsSkillsOnDemand(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -1404,9 +1297,6 @@ func TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -1460,9 +1350,6 @@ func TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -1510,9 +1397,6 @@ func TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "JUST THE BASE"
@@ -1554,9 +1438,6 @@ func TestBuildLanguagePolicyIsAppended(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -1757,9 +1638,9 @@ func TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {
proj := robustTempDir(t)
t.Chdir(proj)
// codegraph off keeps Build offline; it merges over the migrated user config
// without dropping the migrated plugins.
writeFile(t, proj, "reasonix.toml", "[codegraph]\nenabled = false\n")
// Project config merges over the migrated user config without dropping the
// migrated plugins.
writeFile(t, proj, "reasonix.toml", "")
writeFile(t, filepath.Join(home, ".reasonix"), "config.json",
`{"apiKey":"sk-e2e","lang":"zh","mcpServers":{"fs":{"command":"npx","args":["-y","server-fs"]}}}`)
writeFile(t, filepath.Join(home, ".reasonix", "sessions"), "chat-1.events.jsonl",
@@ -1832,7 +1713,7 @@ func TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {
t.Setenv("AppData", filepath.Join(home, "AppData"))
proj := robustTempDir(t)
writeFile(t, proj, "reasonix.toml", "[codegraph]\nenabled = false\n")
writeFile(t, proj, "reasonix.toml", "")
legacyDir := config.SessionDir()
writeFile(t, legacyDir, "custom-root.events.jsonl",
@@ -1919,17 +1800,25 @@ func TestPartitionByTier(t *testing.T) {
}
}
func TestBuiltInMCPsYieldToExtraPluginNames(t *testing.T) {
got := builtinmcp.AppendEnabled(nil, nil, []string{"time", "context7"}, pluginSpecNames([]plugin.Spec{{Name: "time"}})...)
if len(got) != 1 || got[0].Name != "context7" {
t.Fatalf("built-in MCP entries with extra time = %+v, want only context7", got)
func TestPluginSpecsTrustKnownCodeGraphReadTools(t *testing.T) {
specs := PluginSpecs([]config.PluginEntry{{Name: "codegraph"}})
if len(specs) != 1 {
t.Fatalf("PluginSpecs returned %d specs, want 1", len(specs))
}
for _, name := range []string{"codegraph_context", "codegraph_search", "context", "search"} {
if !specs[0].ReadOnlyToolNames[name] {
t.Fatalf("codegraph spec missing read-only override for %q: %+v", name, specs[0].ReadOnlyToolNames)
}
}
}
func TestBuiltInMCPDefaultsEnableOnlyTime(t *testing.T) {
got := builtinmcp.AppendEnabled(nil, nil, config.Default().BuiltInMCP.EnabledNames())
if len(got) != 1 || got[0].Name != "time" {
t.Fatalf("default built-in MCP entries = %+v, want only time", got)
func TestPluginSpecsDoNotTrustCodeGraphToolsForOtherServers(t *testing.T) {
specs := PluginSpecs([]config.PluginEntry{{Name: "not-codegraph"}})
if len(specs) != 1 {
t.Fatalf("PluginSpecs returned %d specs, want 1", len(specs))
}
if specs[0].ReadOnlyToolNames["codegraph_context"] {
t.Fatalf("non-codegraph spec should not receive codegraph read-only overrides: %+v", specs[0].ReadOnlyToolNames)
}
}
@@ -1941,9 +1830,6 @@ func TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -1989,9 +1875,6 @@ func TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -2029,164 +1912,6 @@ tier = "lazy"
}
}
func TestBuildColdCodegraphStartsInBackground(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
launcher := writeCodegraphHelper(t, dir)
t.Setenv("GO_WANT_HELPER_PROCESS", "1")
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
default_model = "test-model"
[codegraph]
enabled = true
path = %q
tier = "background"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`, launcher))
var notices []event.Event
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
ctrl, err := Build(ctx, Options{
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e)
}
}),
})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if got := ctrl.Host().Failures(); len(got) != 0 {
t.Fatalf("Host.Failures() = %+v, want empty for cold built-in codegraph background startup", got)
}
codegraphDir := filepath.Join(dir, ".codegraph")
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := os.Stat(codegraphDir); err == nil {
break
} else if time.Now().After(deadline) {
t.Fatalf("cold codegraph init did not create .codegraph/: %v", err)
}
time.Sleep(10 * time.Millisecond)
}
foundNotice := false
for _, n := range notices {
if strings.Contains(n.Text, "preparing code-intelligence tools in the background") {
foundNotice = true
break
}
}
if !foundNotice {
t.Fatalf("missing background warmup notice; got %+v", notices)
}
}
func TestBuildCodegraphSetsShortDaemonIdleTimeout(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
launcher := writeCodegraphHelper(t, dir)
envOut := filepath.Join(dir, "codegraph-idle-env")
t.Setenv("REASONIX_CODEGRAPH_HELPER_ENV_OUT", envOut)
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
default_model = "test-model"
[codegraph]
enabled = true
path = %q
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`, launcher))
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
ctrl, err := Build(ctx, Options{})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
deadline := time.Now().Add(5 * time.Second)
var got []byte
for {
got, err = os.ReadFile(envOut)
if err == nil && string(got) == codegraph.ReasonixDaemonIdleTimeoutMS {
break
}
if time.Now().After(deadline) {
t.Fatalf("codegraph helper idle timeout env = %q, want %q (read error: %v)", got, codegraph.ReasonixDaemonIdleTimeoutMS, err)
}
time.Sleep(10 * time.Millisecond)
}
}
func TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake launcher is a POSIX-sh script")
}
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.Mkdir(filepath.Join(dir, ".codegraph"), 0o755); err != nil {
t.Fatal(err)
}
launcher := filepath.Join(dir, "slow-codegraph")
writeFile(t, dir, "slow-codegraph", "#!/bin/sh\nif [ \"$1\" = serve ]; then sleep 5; fi\n")
if err := os.Chmod(launcher, 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
default_model = "test-model"
[codegraph]
enabled = true
path = %q
tier = "eager"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`, launcher))
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
ctrl, err := Build(ctx, Options{})
if err != nil {
t.Fatalf("Build should not block on warm codegraph with legacy eager tier: %v", err)
}
defer ctrl.Close()
}
func TestBuildDefaultsToNearestGitRoot(t *testing.T) {
isolateConfigHome(t)
root := robustTempDir(t)
@@ -2200,9 +1925,6 @@ func TestBuildDefaultsToNearestGitRoot(t *testing.T) {
writeFile(t, root, "reasonix.toml", `
default_model = "root-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -2239,9 +1961,6 @@ func TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[codegraph]
enabled = false
[agent]
system_prompt = "BASE"
@@ -2368,82 +2087,3 @@ func TestHelperProcess(t *testing.T) {
os.Stdout.Write(append(b, '\n'))
}
}
func writeCodegraphHelper(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "codegraph-helper")
if runtime.GOOS == "windows" {
path += ".exe"
}
src := filepath.Join(dir, "codegraph-helper.go")
if err := os.WriteFile(src, []byte(`package main
import (
"bufio"
"bytes"
"encoding/json"
"os"
"path/filepath"
)
func main() {
if len(os.Args) >= 3 && os.Args[1] == "init" {
_ = os.MkdirAll(filepath.Join(os.Args[2], ".codegraph"), 0o755)
return
}
if len(os.Args) >= 2 && os.Args[1] == "serve" {
if out := os.Getenv("REASONIX_CODEGRAPH_HELPER_ENV_OUT"); out != "" {
_ = os.WriteFile(out, []byte(os.Getenv("CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS")), 0o644)
}
}
in := bufio.NewReader(os.Stdin)
for {
line, err := in.ReadBytes('\n')
if err != nil {
return
}
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var req struct {
ID *int `+"`json:\"id\"`"+`
Method string `+"`json:\"method\"`"+`
Params json.RawMessage `+"`json:\"params\"`"+`
}
if err := json.Unmarshal(line, &req); err != nil || req.ID == nil {
continue
}
var result any
switch req.Method {
case "initialize":
result = map[string]any{
"protocolVersion": "2024-11-05",
"serverInfo": map[string]any{"name": "codegraph", "version": "0"},
"capabilities": map[string]any{},
}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "search",
"description": "Search symbols.",
"inputSchema": map[string]any{"type": "object"},
}}}
}
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
b, _ := json.Marshal(resp)
_, _ = os.Stdout.Write(append(b, '\n'))
}
}
`), 0o644); err != nil {
t.Fatal(err)
}
cmd := exec.Command("go", "build", "-o", path, src)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build codegraph helper: %v\n%s", err, out)
}
return path
}
-6
View File
@@ -21,9 +21,6 @@ func TestBuildUnknownModelErrorIsActionable(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "mimo"
[codegraph]
enabled = false
[[providers]]
name = "deepseek-flash"
kind = "openai"
@@ -54,9 +51,6 @@ func TestBuildNoticesMissingAPIKey(t *testing.T) {
writeFile(t, dir, "reasonix.toml", `
default_model = "x"
[codegraph]
enabled = false
[[providers]]
name = "x"
kind = "openai"
+11 -18
View File
@@ -17,11 +17,12 @@ const (
TokenModeEconomy = "economy"
)
const tokenEconomyPrompt = `Token economy mode is on. Keep the default tool surface lean. Optional sources are hidden behind connect_tool_source; enable skills, MCP servers, CodeGraph, LSP, web_fetch, install_source, or task only when the current request actually needs them.`
const tokenEconomyPrompt = `Token economy mode is on. Keep the default tool surface lean. Optional sources are hidden behind connect_tool_source; enable skills, MCP servers, LSP, web_fetch, install_source, or task only when the current request actually needs them.`
var tokenEconomyCoreBuiltins = []string{
"bash",
"bash_output",
"code_index",
"complete_step",
"edit_file",
"glob",
@@ -69,20 +70,19 @@ func tokenEconomyBuiltins(configured []string) []string {
type toolSourceConnector struct {
mu sync.Mutex
skills func(context.Context) (string, error)
task func(context.Context) (string, error)
install func(context.Context) (string, error)
webFetch func(context.Context) (string, error)
lsp func(context.Context) (string, error)
codegraph func(context.Context) (string, error)
mcp func(context.Context, string) (string, error)
mcpNames []string
skills func(context.Context) (string, error)
task func(context.Context) (string, error)
install func(context.Context) (string, error)
webFetch func(context.Context) (string, error)
lsp func(context.Context) (string, error)
mcp func(context.Context, string) (string, error)
mcpNames []string
}
func (*toolSourceConnector) Name() string { return "connect_tool_source" }
func (*toolSourceConnector) Description() string {
return "Token economy mode only: enable an optional tool source when the task needs it. Sources: skills, mcp, codegraph, lsp, web_fetch, install_source, task. For mcp, pass the configured server name; omit name to list servers. Newly enabled tools are available on the next model request."
return "Token economy mode only: enable an optional tool source when the task needs it. Sources: skills, mcp, lsp, web_fetch, install_source, task. For mcp, pass the configured server name; omit name to list servers. Newly enabled tools are available on the next model request."
}
func (*toolSourceConnector) ReadOnly() bool { return true }
@@ -91,7 +91,7 @@ func (*toolSourceConnector) Schema() json.RawMessage {
return json.RawMessage(`{
"type":"object",
"properties":{
"source":{"type":"string","description":"Tool source to enable: skills, mcp, codegraph, lsp, web_fetch, install_source, or task."},
"source":{"type":"string","description":"Tool source to enable: skills, mcp, lsp, web_fetch, install_source, or task."},
"name":{"type":"string","description":"For source=mcp, the configured server name. Omit to list configured MCP servers without connecting them."}
},
"required":["source"]
@@ -131,8 +131,6 @@ func (t *toolSourceConnector) Execute(ctx context.Context, args json.RawMessage)
return runSourceInstaller(ctx, "web_fetch", t.webFetch)
case "lsp":
return runSourceInstaller(ctx, "lsp", t.lsp)
case "codegraph":
return runSourceInstaller(ctx, "codegraph", t.codegraph)
case "mcp":
name := strings.TrimSpace(p.Name)
if name == "" {
@@ -158,8 +156,6 @@ func normalizeToolSource(source string) string {
return "skills"
case "mcp", "plugin", "plugins", "server", "servers":
return "mcp"
case "codegraph", "code_graph", "symbol", "symbols":
return "codegraph"
case "lsp", "language_server", "language-servers":
return "lsp"
case "web", "web_fetch", "webfetch", "fetch":
@@ -181,9 +177,6 @@ func (t *toolSourceConnector) availableSources() []string {
if t.mcp != nil || len(t.mcpNames) > 0 {
out = append(out, "mcp")
}
if t.codegraph != nil {
out = append(out, "codegraph")
}
if t.lsp != nil {
out = append(out, "lsp")
}
-112
View File
@@ -1,112 +0,0 @@
// Package builtinmcp defines MCP servers that ship with Reasonix without
// requiring user configuration.
package builtinmcp
import (
"os"
"os/exec"
"reasonix/internal/config"
)
const (
TimeName = "time"
Context7Name = "context7"
)
var (
executablePathDefault = os.Executable
lookPathDefault = exec.LookPath
currentExecutable = executablePathDefault
lookPath = lookPathDefault
)
// Entries returns the built-in MCP servers that are always available. They use
// the lazy tier so startup never blocks on package installation or network.
func Entries() []config.PluginEntry {
return []config.PluginEntry{
{
Name: TimeName,
Type: "stdio",
Command: executablePath(),
Args: []string{"builtin-mcp", TimeName},
Tier: "lazy",
},
context7Entry(),
}
}
func executablePath() string {
if path, err := currentExecutable(); err == nil && path != "" {
return path
}
return "reasonix"
}
func context7Entry() config.PluginEntry {
command, args := context7Command()
return config.PluginEntry{
Name: Context7Name,
Type: "stdio",
Command: command,
Args: args,
Tier: "lazy",
}
}
func context7Command() (string, []string) {
if _, err := lookPath("npx"); err == nil {
return "npx", []string{"-y", "@upstash/context7-mcp"}
}
if _, err := lookPath("pnpm"); err == nil {
return "pnpm", []string{"dlx", "@upstash/context7-mcp"}
}
if _, err := lookPath("bunx"); err == nil {
return "bunx", []string{"@upstash/context7-mcp"}
}
return "npx", []string{"-y", "@upstash/context7-mcp"}
}
// Entry returns one built-in MCP entry by name.
func Entry(name string) (config.PluginEntry, bool) {
for _, e := range Entries() {
if e.Name == name {
return e, true
}
}
return config.PluginEntry{}, false
}
// IsBuiltIn reports whether name is a Reasonix-shipped MCP server.
func IsBuiltIn(name string) bool {
_, ok := Entry(name)
return ok
}
// AppendMissing appends built-in MCP entries unless a configured or
// session-scoped entry with the same name exists. Explicit user and host config
// wins, including auto_start=false.
func AppendMissing(out []config.PluginEntry, configured []config.PluginEntry, reservedNames ...string) []config.PluginEntry {
return AppendEnabled(out, configured, []string{TimeName, Context7Name}, reservedNames...)
}
// AppendEnabled is like AppendMissing but only appends enabled built-in names.
func AppendEnabled(out []config.PluginEntry, configured []config.PluginEntry, enabledNames []string, reservedNames ...string) []config.PluginEntry {
seen := make(map[string]bool, len(configured))
for _, e := range configured {
seen[e.Name] = true
}
for _, name := range reservedNames {
seen[name] = true
}
enabled := make(map[string]bool, len(enabledNames))
for _, name := range enabledNames {
enabled[name] = true
}
for _, e := range Entries() {
if enabled[e.Name] && !seen[e.Name] {
out = append(out, e)
}
}
return out
}
-123
View File
@@ -1,123 +0,0 @@
package builtinmcp
import (
"bytes"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
)
func TestEntries(t *testing.T) {
currentExecutable = func() (string, error) { return "reasonix", nil }
lookPath = func(file string) (string, error) {
if file == "npx" {
return "/usr/bin/npx", nil
}
return "", errors.New("not found")
}
t.Cleanup(func() {
currentExecutable = executablePathDefault
lookPath = lookPathDefault
})
entries := Entries()
if len(entries) != 2 {
t.Fatalf("Entries() length = %d, want 2", len(entries))
}
want := map[string][]string{
TimeName: []string{"builtin-mcp", "time"},
Context7Name: []string{"-y", "@upstash/context7-mcp"},
}
for _, e := range entries {
args, ok := want[e.Name]
if !ok {
t.Fatalf("unexpected built-in MCP entry: %+v", e)
}
wantCommand := map[string]string{
TimeName: "reasonix",
Context7Name: "npx",
}[e.Name]
if e.Type != "stdio" || e.Command != wantCommand || e.Tier != "lazy" {
t.Fatalf("%s type/command/tier = %q/%q/%q, want stdio/%s/lazy", e.Name, e.Type, e.Command, e.Tier, wantCommand)
}
if !reflect.DeepEqual(e.Args, args) {
t.Fatalf("%s args = %+v, want %+v", e.Name, e.Args, args)
}
delete(want, e.Name)
}
if len(want) != 0 {
t.Fatalf("missing built-in MCP entries: %+v", want)
}
}
func TestAppendMissingLetsUserConfigWin(t *testing.T) {
base := Entries()[:1]
got := AppendMissing(nil, base)
if len(got) != 1 || got[0].Name != Context7Name {
t.Fatalf("AppendMissing with configured time = %+v, want only context7", got)
}
}
func TestAppendMissingLetsReservedNamesWin(t *testing.T) {
got := AppendMissing(nil, nil, TimeName)
if len(got) != 1 || got[0].Name != Context7Name {
t.Fatalf("AppendMissing with reserved time = %+v, want only context7", got)
}
}
func TestAppendEnabledOnlyAddsEnabledBuiltIns(t *testing.T) {
got := AppendEnabled(nil, nil, []string{TimeName})
if len(got) != 1 || got[0].Name != TimeName {
t.Fatalf("AppendEnabled(time) = %+v, want only time", got)
}
if got := AppendEnabled(nil, nil, nil); len(got) != 0 {
t.Fatalf("AppendEnabled(nil) = %+v, want none", got)
}
}
func TestContext7CommandFallsBackThroughJSRunners(t *testing.T) {
lookPath = func(file string) (string, error) {
if file == "pnpm" {
return "/usr/bin/pnpm", nil
}
return "", errors.New("not found")
}
t.Cleanup(func() { lookPath = lookPathDefault })
cmd, args := context7Command()
if cmd != "pnpm" || !reflect.DeepEqual(args, []string{"dlx", "@upstash/context7-mcp"}) {
t.Fatalf("context7Command = %q %+v, want pnpm dlx", cmd, args)
}
}
func TestServeTimeMCPListsTools(t *testing.T) {
var out bytes.Buffer
in := strings.NewReader(
`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` + "\n" +
`{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}` + "\n" +
`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}` + "\n")
if err := ServeTimeMCP(in, &out, "test"); err != nil {
t.Fatalf("ServeTimeMCP: %v", err)
}
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 2 {
t.Fatalf("response lines = %d (%q), want 2", len(lines), out.String())
}
var resp struct {
Result struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
} `json:"result"`
}
if err := json.Unmarshal([]byte(lines[1]), &resp); err != nil {
t.Fatalf("decode tools/list response: %v", err)
}
got := []string{resp.Result.Tools[0].Name, resp.Result.Tools[1].Name}
want := []string{"get_current_time", "convert_time"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("time MCP tools = %+v, want %+v", got, want)
}
}
-273
View File
@@ -1,273 +0,0 @@
package builtinmcp
import (
"bufio"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
stdtime "time"
)
const protocolVersion = "2024-11-05"
// RunCommand runs hidden built-in MCP subcommands shared by the CLI and desktop
// binary. It intentionally writes only MCP JSON-RPC frames to stdout.
func RunCommand(args []string, in io.Reader, out io.Writer, errOut io.Writer, version string) int {
if len(args) != 1 || args[0] != TimeName {
fmt.Fprintln(errOut, "usage: reasonix builtin-mcp time")
return 2
}
if serveErr := ServeTimeMCP(in, out, version); serveErr != nil {
fmt.Fprintln(errOut, serveErr)
return 1
}
return 0
}
// ServeTimeMCP serves Reasonix's dependency-free built-in time MCP over stdio.
func ServeTimeMCP(in io.Reader, out io.Writer, version string) error {
r := bufio.NewReader(in)
w := bufio.NewWriter(out)
defer w.Flush()
for {
line, err := r.ReadBytes('\n')
if len(strings.TrimSpace(string(line))) > 0 {
if handleErr := handleTimeLine(line, w, version); handleErr != nil {
return handleErr
}
if flushErr := w.Flush(); flushErr != nil {
return flushErr
}
}
if err != nil {
if err == io.EOF {
return nil
}
return err
}
}
}
type timeRequest struct {
JSONRPC string `json:"jsonrpc"`
ID *json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type timeResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *timeRPCError `json:"error,omitempty"`
}
type timeRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func handleTimeLine(line []byte, w *bufio.Writer, version string) error {
var req timeRequest
if err := json.Unmarshal(line, &req); err != nil {
return nil
}
if req.ID == nil {
return nil
}
resp := timeResponse{JSONRPC: "2.0", ID: *req.ID}
switch req.Method {
case "initialize":
resp.Result = map[string]any{
"protocolVersion": protocolVersion,
"capabilities": map[string]any{
"tools": map[string]any{},
},
"serverInfo": map[string]any{"name": "reasonix-time", "version": version},
}
case "tools/list":
resp.Result = map[string]any{"tools": timeTools()}
case "tools/call":
resp.Result, resp.Error = callTimeTool(req.Params)
default:
resp.Error = &timeRPCError{Code: -32601, Message: "method not found: " + req.Method}
}
b, err := json.Marshal(resp)
if err != nil {
return fmt.Errorf("marshal response: %w", err)
}
_, err = w.Write(append(b, '\n'))
return err
}
func timeTools() []map[string]any {
return []map[string]any{
{
"name": "get_current_time",
"description": "Get the current time in a specific IANA timezone, or the system timezone when omitted.",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{
"timezone": map[string]any{"type": "string", "description": "IANA timezone name, for example America/New_York"},
},
},
"annotations": map[string]any{"readOnlyHint": true, "title": "get_current_time"},
},
{
"name": "convert_time",
"description": "Convert a 24-hour HH:MM time between two IANA timezones using today's date in the source timezone.",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{
"source_timezone": map[string]any{"type": "string", "description": "Source IANA timezone name"},
"time": map[string]any{"type": "string", "description": "Time in 24-hour HH:MM format"},
"target_timezone": map[string]any{"type": "string", "description": "Target IANA timezone name"},
},
"required": []string{"source_timezone", "time", "target_timezone"},
},
"annotations": map[string]any{"readOnlyHint": true, "title": "convert_time"},
},
}
}
func callTimeTool(params json.RawMessage) (any, *timeRPCError) {
var p struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if err := json.Unmarshal(params, &p); err != nil {
return nil, &timeRPCError{Code: -32602, Message: "invalid params: " + err.Error()}
}
var (
text string
err error
)
switch p.Name {
case "get_current_time":
text, err = getCurrentTime(p.Arguments)
case "convert_time":
text, err = convertTime(p.Arguments)
default:
return nil, &timeRPCError{Code: -32602, Message: "unknown tool: " + p.Name}
}
if err != nil {
return timeTextResult(err.Error(), true), nil
}
return timeTextResult(text, false), nil
}
func getCurrentTime(args map[string]any) (string, error) {
loc, name, err := loadLocation(optionalString(args, "timezone"))
if err != nil {
return "", err
}
now := stdtime.Now().In(loc)
return marshalTimeResult(map[string]any{
"timezone": name,
"datetime": now.Format(stdtime.RFC3339),
"is_dst": now.IsDST(),
})
}
func convertTime(args map[string]any) (string, error) {
sourceName := requiredString(args, "source_timezone")
targetName := requiredString(args, "target_timezone")
rawTime := requiredString(args, "time")
if sourceName == "" || targetName == "" || rawTime == "" {
return "", fmt.Errorf("source_timezone, time, and target_timezone are required")
}
sourceLoc, _, err := loadLocation(sourceName)
if err != nil {
return "", err
}
targetLoc, _, err := loadLocation(targetName)
if err != nil {
return "", err
}
hour, minute, err := parseHHMM(rawTime)
if err != nil {
return "", err
}
today := stdtime.Now().In(sourceLoc)
source := stdtime.Date(today.Year(), today.Month(), today.Day(), hour, minute, 0, 0, sourceLoc)
target := source.In(targetLoc)
_, sourceOffset := source.Zone()
_, targetOffset := target.Zone()
return marshalTimeResult(map[string]any{
"source": map[string]any{
"timezone": sourceName,
"datetime": source.Format(stdtime.RFC3339),
"is_dst": source.IsDST(),
},
"target": map[string]any{
"timezone": targetName,
"datetime": target.Format(stdtime.RFC3339),
"is_dst": target.IsDST(),
},
"time_difference": fmt.Sprintf("%+.1fh", float64(targetOffset-sourceOffset)/3600),
})
}
func loadLocation(name string) (*stdtime.Location, string, error) {
name = strings.TrimSpace(name)
if name == "" {
return stdtime.Local, stdtime.Local.String(), nil
}
loc, err := stdtime.LoadLocation(name)
if err != nil {
return nil, "", fmt.Errorf("unknown timezone %q", name)
}
return loc, name, nil
}
func parseHHMM(raw string) (int, int, error) {
parts := strings.Split(strings.TrimSpace(raw), ":")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("time must use HH:MM format")
}
hour, err := strconv.Atoi(parts[0])
if err != nil || hour < 0 || hour > 23 {
return 0, 0, fmt.Errorf("hour must be between 00 and 23")
}
minute, err := strconv.Atoi(parts[1])
if err != nil || minute < 0 || minute > 59 {
return 0, 0, fmt.Errorf("minute must be between 00 and 59")
}
return hour, minute, nil
}
func optionalString(args map[string]any, key string) string {
if args == nil {
return ""
}
v, _ := args[key].(string)
return strings.TrimSpace(v)
}
func requiredString(args map[string]any, key string) string {
if args == nil {
return ""
}
v, _ := args[key].(string)
return strings.TrimSpace(v)
}
func marshalTimeResult(v any) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
}
func timeTextResult(text string, isError bool) map[string]any {
return map[string]any{
"content": []map[string]any{{"type": "text", "text": text}},
"isError": isError,
}
}
-6
View File
@@ -76,9 +76,6 @@ func TestACPFactoryLoadsSessionCwdProjectConfig(t *testing.T) {
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
default_model = "local"
[codegraph]
enabled = false
[[providers]]
name = "local"
kind = "acp-test-provider"
@@ -120,9 +117,6 @@ func TestACPFactoryClearsEffortOverrideForUnsupportedModel(t *testing.T) {
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
default_model = "reasoner/reasoning-model"
[codegraph]
enabled = false
[[providers]]
name = "reasoner"
kind = "acp-test-provider"
+3 -3
View File
@@ -209,12 +209,12 @@ func TestToolProgressStreamsThenCollapses(t *testing.T) {
}
// TestToolWorkingLineThenClears proves a dispatched tool that streams no output
// (e.g. codegraph_context) shows a live "working · Ns" line so it doesn't look
// (e.g. symbol_context) shows a live "working · Ns" line so it doesn't look
// frozen, and that the line clears on the result instead of collapsing to
// "0 lines".
func TestToolWorkingLineThenClears(t *testing.T) {
m := newTestChatTUI()
m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "c1", Name: "codegraph_context", Args: `{"q":"x"}`}})
m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "c1", Name: "symbol_context", Args: `{"q":"x"}`}})
m.tickToolRunning() // one elapsed tick fills the placeholder
joined := strings.Join(m.transcript, "\n")
@@ -222,7 +222,7 @@ func TestToolWorkingLineThenClears(t *testing.T) {
t.Fatalf("a running tool should show a 'working' progress line:\n%s", joined)
}
m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "c1", Name: "codegraph_context"}})
m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "c1", Name: "symbol_context"}})
joined = strings.Join(m.transcript, "\n")
if strings.Contains(joined, "working") {
t.Fatalf("working line should clear after the result:\n%s", joined)
+1 -1
View File
@@ -166,7 +166,7 @@ type chatTUI struct {
toolLineCountByID map[string]int
// toolStreamStart / toolStreamFrame drive the "⎿ working · Ns" line shown
// under a dispatched tool that hasn't produced output yet, so a slow tool
// (e.g. codegraph_context) reads as making progress rather than frozen.
// reads as making progress rather than frozen.
toolStreamStart time.Time
toolStreamFrame int
transcriptDirty bool
+1 -18
View File
@@ -23,7 +23,6 @@ import (
"reasonix/internal/agent"
"reasonix/internal/boot"
"reasonix/internal/builtinmcp"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
@@ -51,9 +50,6 @@ func Run(args []string, version string) int {
if cmd == "--acp" {
cmd = "acp"
}
if cmd == "builtin-mcp" {
return builtinmcp.RunCommand(args[1:], os.Stdin, os.Stdout, os.Stderr, version)
}
if shouldMigrateLegacyConfigForCLI(cmd) {
migrateLegacyConfigForCLI()
}
@@ -94,9 +90,6 @@ func Run(args []string, version string) int {
case "mcp":
configureCLIThemeFromConfigNoProbe()
return mcpCommand(rest)
case "codegraph":
configureCLIThemeFromConfigNoProbe()
return codegraphCommand(rest)
case "doctor":
configureCLIThemeFromConfigNoProbe()
return doctorCommand(rest, version)
@@ -124,7 +117,7 @@ func Run(args []string, version string) int {
func shouldMigrateLegacyConfigForCLI(cmd string) bool {
switch cmd {
case "", "run", "chat", "code", "serve", "setup", "config", "init", "acp", "mcp", "codegraph", "doctor", "bot", "upgrade", "update":
case "", "run", "chat", "code", "serve", "setup", "config", "init", "acp", "mcp", "doctor", "bot", "upgrade", "update":
return true
default:
return false
@@ -677,9 +670,6 @@ func confirmReconfigureExistingConfig(path string, in *bufio.Scanner, w io.Write
func writeDefaultConfig(path string) int {
c := config.Default()
// A freshly scaffolded config starts without the codegraph daemon; existing
// configs (which never wrote [codegraph]) keep it on via the built-in default.
c.Codegraph.Enabled = false
if err := c.SaveTo(path); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, err)
return 1
@@ -709,15 +699,8 @@ func interactiveSetup(configPath, envPath string) int {
// Seed from the existing config when reconfiguring, so a re-run to fix a key
// preserves the user's providers / agent settings instead of resetting to
// defaults. First run (no file) falls back to the built-in defaults.
_, statErr := os.Stat(configPath)
isNewConfig := statErr != nil
cfg := config.LoadForEdit(configPath)
prevDefault := cfg.DefaultModel
if isNewConfig {
// Brand-new user: start without the codegraph daemon. A reconfigure of an
// existing config keeps whatever the user already had.
cfg.Codegraph.Enabled = false
}
lang, err := selectLanguage()
if err != nil {
+10 -3
View File
@@ -976,13 +976,20 @@ func groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {
return members[key]
}
func TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {
func TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {
path := filepath.Join(t.TempDir(), "reasonix.toml")
if rc := writeDefaultConfig(path); rc != 0 {
t.Fatalf("writeDefaultConfig rc = %d", rc)
}
if c := config.LoadForEdit(path); c.Codegraph.Enabled {
t.Fatal("a freshly scaffolded config left codegraph enabled; new users should start without it")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(raw)
for _, forbidden := range []string{"[codegraph]", "[builtin_mcp]", "[builtin_mcp_updates]"} {
if strings.Contains(text, forbidden) {
t.Fatalf("default config should omit %s:\n%s", forbidden, text)
}
}
}
-111
View File
@@ -1,111 +0,0 @@
package cli
import (
"context"
"fmt"
"os"
"reasonix/internal/codegraph"
"reasonix/internal/config"
"reasonix/internal/netclient"
)
// codegraphCommand backs `reasonix codegraph` — managing the CodeGraph
// code-intelligence runtime that reasonix otherwise fetches lazily on first use.
func codegraphCommand(args []string) int {
sub := ""
if len(args) > 0 {
sub = args[0]
}
switch sub {
case "install":
return codegraphInstall()
case "update":
return codegraphUpdate()
case "status", "":
return codegraphStatus()
case "help", "-h", "--help":
codegraphUsage()
return 0
default:
fmt.Fprintf(os.Stderr, "unknown codegraph subcommand %q\n\n", sub)
codegraphUsage()
return 2
}
}
func codegraphInstall() int {
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
client, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{})
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
p, err := codegraph.InstallWithClient(context.Background(), client, func(m string) { fmt.Println(m) })
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Println("codegraph ready:", p)
return 0
}
func codegraphUpdate() int {
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
client, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{})
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
res, err := codegraph.UpdateWithClient(context.Background(), client, func(m string) { fmt.Println(m) })
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Printf("codegraph updated: %s (%s)\n", res.Path, res.Version)
return 0
}
func codegraphStatus() int {
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Printf("%-13s %v\n", "enabled:", cfg.Codegraph.Enabled)
fmt.Printf("%-13s %v\n", "auto_install:", cfg.Codegraph.AutoInstall)
fmt.Printf("%-13s %s\n", "startup:", cfg.Codegraph.ResolvedTier())
fmt.Printf("%-13s %s\n", "version:", codegraph.Version)
if active := codegraph.ActiveVersion(); active != "" {
fmt.Printf("%-13s %s\n", "active:", active)
}
fmt.Printf("%-13s %s\n", "cache:", codegraph.CacheDir())
if p, ok := codegraph.Resolve(cfg.Codegraph.Path); ok {
fmt.Printf("%-13s %s\n", "resolved:", p)
} else {
fmt.Printf("%-13s %s\n", "resolved:", "(not installed — run `reasonix codegraph install`)")
}
return 0
}
func codegraphUsage() {
fmt.Print(`reasonix codegraph — manage the CodeGraph code-intelligence runtime
Usage:
reasonix codegraph install download + cache the runtime for this platform
reasonix codegraph update download latest upstream runtime and make it active
reasonix codegraph status show config, cache dir, and resolved launcher
CodeGraph is fetched automatically on first use (unless [codegraph].auto_install
is false); install uses Reasonix's pinned runtime. Update is explicit because a
new CodeGraph release can change MCP tool schemas and prompt-cache shape.
`)
}
-15
View File
@@ -5,7 +5,6 @@ import (
"os"
"strings"
"reasonix/internal/codegraph"
"reasonix/internal/config"
)
@@ -192,20 +191,6 @@ func mcpList() int {
return 1
}
listed := 0
// CodeGraph is a built-in server injected by boot, not a [[plugins]] entry, so
// report its resolved status here too. It is listed even when disabled, matching
// the MCP manager where the user can enable it.
codegraphMeta := fmt.Sprintf(" [enabled=%v auto_install=%v]", cfg.Codegraph.Enabled, cfg.Codegraph.AutoInstall)
if bin, ok := codegraph.Resolve(cfg.Codegraph.Path); ok {
fmt.Printf("%-16s (stdio, built-in)%s %s serve --mcp\n", "codegraph", codegraphMeta, bin)
} else {
fmt.Printf("%-16s (built-in, not installed)%s run `reasonix codegraph install`", "codegraph", codegraphMeta)
if cfg.Codegraph.Enabled && cfg.Codegraph.AutoInstall {
fmt.Print(" (or let auto_install fetch it on next startup)")
}
fmt.Println()
}
listed++
for _, p := range cfg.Plugins {
typ := p.Type
if typ == "" {
+2 -30
View File
@@ -303,9 +303,7 @@ func (m chatTUI) buildMCPSnapshot() mcpSnapshot {
}
configured := map[string]config.PluginEntry{}
var configuredEntries []config.PluginEntry
var loadedCfg *config.Config
if cfg != nil {
loadedCfg = cfg
configuredEntries = append(configuredEntries, cfg.Plugins...)
for _, p := range configuredEntries {
configured[p.Name] = p
@@ -316,14 +314,11 @@ func (m chatTUI) buildMCPSnapshot() mcpSnapshot {
for _, s := range m.host.Servers() {
v := mcpServerView{
Name: s.Name, Transport: fallbackText(s.Transport, "stdio"), Status: "connected",
BuiltIn: s.Name == "codegraph",
Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
ToolList: append([]plugin.ToolInfo(nil), s.ToolList...),
}
if p, ok := configured[s.Name]; ok {
v = withMCPPluginConfig(v, p)
} else if s.Name == "codegraph" && loadedCfg != nil {
v = withMCPCodegraphConfig(v, loadedCfg.Codegraph)
}
snap.servers = append(snap.servers, v)
seen[s.Name] = true
@@ -331,13 +326,10 @@ func (m chatTUI) buildMCPSnapshot() mcpSnapshot {
for _, f := range m.host.Failures() {
v := mcpServerView{
Name: f.Name, Transport: fallbackText(f.Transport, "stdio"), Status: "failed",
BuiltIn: f.Name == "codegraph",
Error: f.Error,
Error: f.Error,
}
if p, ok := configured[f.Name]; ok {
v = withMCPPluginConfig(v, p)
} else if f.Name == "codegraph" && loadedCfg != nil {
v = withMCPCodegraphConfig(v, loadedCfg.Codegraph)
}
snap.servers = append(snap.servers, v)
seen[f.Name] = true
@@ -360,15 +352,6 @@ func (m chatTUI) buildMCPSnapshot() mcpSnapshot {
snap.servers = append(snap.servers, v)
seen[p.Name] = true
}
if loadedCfg != nil && !seen["codegraph"] {
status := "initializing"
if m.mcpDisabled["codegraph"] || !loadedCfg.Codegraph.Enabled {
status = "disabled"
}
snap.servers = append(snap.servers, withMCPCodegraphConfig(mcpServerView{
Name: "codegraph", Status: status,
}, loadedCfg.Codegraph))
}
return snap
}
@@ -398,17 +381,6 @@ func withMCPPluginConfig(v mcpServerView, p config.PluginEntry) mcpServerView {
return v
}
func withMCPCodegraphConfig(v mcpServerView, c config.CodegraphConfig) mcpServerView {
v.Name = "codegraph"
v.Transport = "stdio"
v.BuiltIn = true
v.Configured = true
v.AutoStart = c.ShouldAutoStart()
v.Tier = c.ResolvedTier()
v.AuthStatus = mcpdiag.AuthNone
return v
}
func visibleRange(total, sel, limit int) (int, int) {
if limit <= 0 || total <= limit {
return 0, total
+1 -72
View File
@@ -50,20 +50,6 @@ func (m chatTUI) connectSelectedMCP(v mcpServerView) (tea.Model, tea.Cmd) {
m.notice("mcp: no active session")
return m, nil
}
if v.BuiltIn && v.Name == "codegraph" {
cfg, err := config.Load()
if err != nil {
m.notice("mcp connect: " + err.Error())
return m, nil
}
if !cfg.Codegraph.Enabled {
cfg.Codegraph.Enabled = true
if err := cfg.Save(); err != nil {
m.notice("mcp connect: " + err.Error())
return m, nil
}
}
}
if v.Status == "connected" {
m.ctrl.DisconnectMCPServer(v.Name)
}
@@ -91,22 +77,6 @@ func (m chatTUI) disableSelectedMCP(v mcpServerView) (tea.Model, tea.Cmd) {
return m, nil
}
persisted := false
if v.BuiltIn && v.Name == "codegraph" {
cfg, err := config.Load()
if err != nil {
m.notice("mcp disable: " + err.Error())
return m, nil
}
cfg.Codegraph.Enabled = false
if err := cfg.Save(); err != nil {
m.notice("mcp disable: " + err.Error())
return m, nil
}
persisted = true
if h := m.ctrl.Host(); h != nil {
h.ClearFailure(v.Name)
}
}
if m.mcpDisabled == nil {
m.mcpDisabled = map[string]bool{}
}
@@ -169,31 +139,6 @@ func (m chatTUI) applyMCPMode(tier string) (tea.Model, tea.Cmd) {
m.notice("mcp mode: " + err.Error())
return m, nil
}
if v.BuiltIn && v.Name == "codegraph" {
cfg.Codegraph.Enabled = true
cfg.Codegraph.Tier = ""
if err := cfg.Save(); err != nil {
m.notice("mcp mode: " + err.Error())
return m, nil
}
if m.mcpDisabled != nil {
delete(m.mcpDisabled, v.Name)
}
if m.ctrl != nil && !mcpConnected(m.ctrl, v.Name) {
if _, err := m.ctrl.ConnectConfiguredMCPServer(v.Name); err != nil {
recordMCPModeCodegraphFailure(m.ctrl, cfg.Codegraph, err)
m.notice("saved connection mode, but connect failed: " + err.Error())
}
m.host = m.ctrl.Host()
}
m.refreshMCPManager()
if m.mcp != nil {
m.mcp.stage = mcpStageDetail
m.mcp.selectName(v.Name)
}
m.notice("updated connection mode for " + v.Name)
return m, nil
}
found := false
var selected config.PluginEntry
for i := range cfg.Plugins {
@@ -250,22 +195,6 @@ func recordMCPModePluginFailure(ctrl *control.Controller, e config.PluginEntry,
}, err)
}
func recordMCPModeCodegraphFailure(ctrl *control.Controller, c config.CodegraphConfig, err error) {
if ctrl == nil || ctrl.Host() == nil || err == nil {
return
}
cmd := strings.TrimSpace(c.Path)
if cmd == "" {
cmd = "codegraph"
}
ctrl.Host().RecordFailure(plugin.Spec{
Name: "codegraph",
Type: "stdio",
Command: cmd,
Args: []string{"serve", "--mcp"},
}, err)
}
func (m chatTUI) openMCPConfig() (tea.Model, tea.Cmd) {
path := ""
if m.mcp != nil {
@@ -319,7 +248,7 @@ func (m chatTUI) clearSelectedMCPAuthentication() (tea.Model, tea.Cmd) {
func (m chatTUI) clearMCPAuthentication(v mcpServerView) (tea.Model, tea.Cmd) {
if v.BuiltIn {
m.notice("codegraph is built in; it has no stored MCP authentication")
m.notice("managed MCP servers do not store authentication")
return m, nil
}
_, changed, _, err := config.ClearPluginAuthenticationInSource(v.Name)
+3 -11
View File
@@ -70,7 +70,7 @@ func (p *mcpManager) renderList(width int) string {
s := p.snapshot.servers[i]
group := "User MCPs"
if s.BuiltIn {
group = "Built-in MCPs"
group = "Managed MCPs"
}
if group != lastGroup {
if lastGroup != "" {
@@ -262,11 +262,7 @@ func mcpActionsFor(v mcpServerView, configPath string) []mcpActionItem {
out = append(out, mcpActionItem{mcpActionClearAuth, "Clear authentication"})
}
if v.Status != "disabled" {
label := "Disable for this session"
if v.BuiltIn && v.Name == "codegraph" {
label = "Disable"
}
out = append(out, mcpActionItem{mcpActionDisable, label})
out = append(out, mcpActionItem{mcpActionDisable, "Disable for this session"})
}
if !v.BuiltIn {
out = append(out, mcpActionItem{mcpActionRemove, "Remove server"})
@@ -280,11 +276,7 @@ func appendMCPFailureSecondaryActions(out []mcpActionItem, v mcpServerView, conf
}
out = appendMCPConfigActions(out, v, configPath)
if v.Status != "disabled" {
label := "Disable for this session"
if v.BuiltIn && v.Name == "codegraph" {
label = "Disable"
}
out = append(out, mcpActionItem{mcpActionDisable, label})
out = append(out, mcpActionItem{mcpActionDisable, "Disable for this session"})
}
if !v.BuiltIn {
out = append(out, mcpActionItem{mcpActionRemove, "Remove server"})
+5 -77
View File
@@ -161,7 +161,7 @@ func TestRenderMCPManagerListGroupsRuntimeAndConfiguredServers(t *testing.T) {
p := &mcpManager{snapshot: mcpSnapshot{
configPath: "reasonix.toml",
servers: []mcpServerView{
{Name: "codegraph", Transport: "stdio", Status: "connected", BuiltIn: true, Tools: 4},
{Name: "managed-search", Transport: "stdio", Status: "connected", BuiltIn: true, Tools: 4},
{Name: "github", Transport: "stdio", Status: "deferred", Configured: true, Tier: "lazy", Tools: 12},
{Name: "figma", Transport: "http", Status: "failed", Configured: true, Tier: "lazy", URL: "https://mcp.figma.com", Error: "connect: 401 unauthorized"},
},
@@ -170,9 +170,9 @@ func TestRenderMCPManagerListGroupsRuntimeAndConfiguredServers(t *testing.T) {
for _, want := range []string{
"Manage MCP servers",
"3 servers",
"Built-in MCPs",
"Managed MCPs",
"User MCPs (reasonix.toml)",
"codegraph",
"managed-search",
"connected",
"github",
"connect on use",
@@ -453,85 +453,13 @@ func TestApplyMCPModeRecordsPluginConnectFailure(t *testing.T) {
}
}
func TestApplyMCPModeRecordsCodegraphConnectFailure(t *testing.T) {
isolateUserConfig(t)
t.Setenv("PATH", "")
t.Setenv("REASONIX_CACHE_DIR", t.TempDir())
cfg := config.Default()
cfg.Codegraph.Enabled = false
cfg.Codegraph.Tier = "eager"
if err := cfg.SaveTo("reasonix.toml"); err != nil {
t.Fatalf("save config: %v", err)
}
m := newTestChatTUI()
m.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer m.ctrl.Close()
m.host = m.ctrl.Host()
m.mcp = &mcpManager{
stage: mcpStageMode,
name: "codegraph",
snapshot: mcpSnapshot{configPath: "reasonix.toml", servers: []mcpServerView{{
Name: "codegraph", Transport: "stdio", Status: "disabled", BuiltIn: true, Configured: true, Tier: "background",
}}},
}
_, _ = m.applyMCPMode("eager")
failures := m.ctrl.Host().Failures()
if len(failures) != 1 || failures[0].Name != "codegraph" {
t.Fatalf("Host.Failures() = %+v, want codegraph failure", failures)
}
if !strings.Contains(failures[0].Error, "not installed") {
t.Fatalf("codegraph failure error = %q, want not installed", failures[0].Error)
}
v, ok := m.mcp.selectedServer()
if !ok {
t.Fatal("selected server missing after refresh")
}
if v.Status != "failed" {
t.Fatalf("codegraph status = %q, want failed; server = %+v", v.Status, v)
}
}
func TestDisableCodegraphPersistsEnabledFalse(t *testing.T) {
isolateUserConfig(t)
cfg := config.Default()
cfg.Codegraph.Enabled = true
cfg.Codegraph.Tier = "background"
if err := cfg.SaveTo("reasonix.toml"); err != nil {
t.Fatalf("save config: %v", err)
}
m := newTestChatTUI()
m.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer m.ctrl.Close()
m.mcp = &mcpManager{
stage: mcpStageDetail,
name: "codegraph",
snapshot: mcpSnapshot{configPath: "reasonix.toml", servers: []mcpServerView{{
Name: "codegraph", Transport: "stdio", Status: "connected", BuiltIn: true, Configured: true, AutoStart: true, Tier: "background",
}}},
}
_, _ = m.disableSelectedMCP(m.mcp.snapshot.servers[0])
loaded, err := config.Load()
if err != nil {
t.Fatalf("load config: %v", err)
}
if loaded.Codegraph.Enabled {
t.Fatalf("codegraph enabled = true, want false")
}
}
func TestMCPManagerEscFromDetailReturnsToList(t *testing.T) {
m := newTestChatTUI()
m.mcp = &mcpManager{
stage: mcpStageDetail,
name: "codegraph",
name: "managed-search",
snapshot: mcpSnapshot{servers: []mcpServerView{{
Name: "codegraph", Transport: "stdio", Status: "connected", BuiltIn: true,
Name: "managed-search", Transport: "stdio", Status: "connected", BuiltIn: true,
}}},
}
-14
View File
@@ -1,14 +0,0 @@
package codegraph
// releaseAssetSHA256 holds the upstream SHA256SUMS values for Version. These
// are baked into the reasonix binary so downloaded CodeGraph archives are
// verified against trusted metadata instead of trusting whichever mirror served
// the bytes.
var releaseAssetSHA256 = map[string]string{
"codegraph-darwin-arm64.tar.gz": "83a3b90bc334ab2a34240e29e9fab7ff273ab6381794aa6f3cba428397c916b5",
"codegraph-darwin-x64.tar.gz": "74d2331161a317fa6164285a61ec480ce7893be46c1677a4b5a2932e35586b9d",
"codegraph-linux-arm64.tar.gz": "7b4225f90ca5285cccfec099323129348c2753bcbc9910281f9b61db88fa5f37",
"codegraph-linux-x64.tar.gz": "61805e3c9b4052db53c71241b800859095fea4f2cbd2a1844a6c2b9594b9f84a",
"codegraph-win32-arm64.zip": "c728ada3d42701213dde26d8e94ded3ed1c7d4b568124210649ce8f9f938a31a",
"codegraph-win32-x64.zip": "a5571d3ee54cc1caac76bf09e0f7cb350fc4dd6788a5437217eac33b71fa7a15",
}
-306
View File
@@ -1,306 +0,0 @@
// Package codegraph integrates the CodeGraph code-intelligence engine
// (https://github.com/colbymchenry/codegraph) as a built-in MCP server. CodeGraph
// indexes a project into a local symbol and call graph (tree-sitter + SQLite,
// FTS5) and serves it over stdio MCP, giving the agent symbol search, caller /
// callee, and change-impact tools without the per-language setup an LSP fleet
// would need.
//
// CodeGraph is fetched on first use into a per-version cache (see Install) rather
// than shipped in the reasonix binary, which keeps installs small. Resolve finds
// the cached launcher; an explicit config path, a system-installed `codegraph` on
// PATH, and a bundle placed beside the executable are also honored. boot injects
// the resolved launcher as one more stdio plugin, pinned to the project root via
// plugin.Spec.Dir (CodeGraph detects the project from its working directory).
package codegraph
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"reasonix/internal/proc"
)
const initTimeout = 30 * time.Second
const daemonProbeTimeout = 300 * time.Millisecond
// DaemonIdleTimeoutEnv is CodeGraph's own root-scoped daemon idle timeout knob.
const DaemonIdleTimeoutEnv = "CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS"
// ReasonixDaemonIdleTimeoutMS keeps Reasonix-launched daemons short-lived after
// the last client disconnects while still preserving CodeGraph's shared-client
// refcount semantics.
const ReasonixDaemonIdleTimeoutMS = "1000"
type daemonInfo struct {
PID int `json:"pid"`
Version string `json:"version"`
SocketPath string `json:"socketPath"`
StartedAt int64 `json:"startedAt"`
}
// SteerText is injected into the system prompt when CodeGraph tools are
// available, so the model knows to prefer them for symbol-level questions. The
// tool names are the model-visible registered names (mcp__codegraph__<tool>);
// TestSteerTextNamesMatchRegisteredTools fails if they drift from the daemon's
// actual tools so the model is never told to call a name it can't use.
const SteerText = `## Code Intelligence (codegraph)
You have codegraph tools for symbol-level code intelligence. For architecture questions, "how does X work", call graphs, symbol search, and impact analysis, prefer codegraph tools over grep/read_file:
- mcp__codegraph__context — entry points + related symbols + key code in one call (USE THIS FIRST for "how does X work")
- mcp__codegraph__search — find symbols by name (functions, types, interfaces)
- mcp__codegraph__callers / mcp__codegraph__callees — trace call chains
- mcp__codegraph__impact — what breaks if I change X
- mcp__codegraph__trace — full call path between two symbols
- mcp__codegraph__explore — walk the graph around a symbol (neighbours and relationships)
- mcp__codegraph__node — full detail for one symbol (definition, location, signature)
- mcp__codegraph__files — project file tree with symbol counts
- mcp__codegraph__status — code-intelligence index build/health status
Use grep/read_file for content search (comments, strings, config values) and when codegraph is not available.`
// BundleDirName is the optional directory, beside the reasonix executable, where
// an operator can place an unpacked CodeGraph bundle for offline use. Its
// launcher lives at <BundleDirName>/bin/codegraph, with the bundled node runtime
// and lib/ beside it; the launcher resolves those relative to itself, so the
// bundle is relocatable.
const BundleDirName = "codegraph"
// Resolve returns the absolute path to the CodeGraph launcher. Search order:
// 1. override — an explicit [codegraph].path from config (~ and ${VAR} expanded);
// 2. the per-version cache populated by Install;
// 3. a system-installed `codegraph` on PATH;
// 4. a bundle placed beside the executable (manual/offline fallback).
//
// ok is false when none resolves — the caller then triggers Install (or skips the
// feature), so the codegraph_* tools come online once the cache is populated.
func Resolve(override string) (string, bool) {
if override != "" {
if p := expand(override); isExec(p) {
return p, true
}
}
if p, ok := cached(); ok {
return p, true
}
if p, err := exec.LookPath("codegraph"); err == nil {
return p, true
}
if p, ok := bundled(); ok {
return p, true
}
return "", false
}
// bundled looks for the CodeGraph launcher unpacked beside the reasonix binary.
// The executable path is symlink-resolved first so a launcher installed via a
// symlink (e.g. a package manager's bin shim) still points at the real bundle.
func bundled() (string, bool) {
base, ok := bundledBaseDir()
if !ok {
return "", false
}
for _, rel := range launcherNames() {
if p := filepath.Join(base, rel); isExec(p) {
return p, true
}
}
return "", false
}
func bundledBaseDir() (string, bool) {
exe, err := os.Executable()
if err != nil {
return "", false
}
if real, err := filepath.EvalSymlinks(exe); err == nil {
exe = real
}
return filepath.Join(filepath.Dir(exe), BundleDirName), true
}
// launcherNames are the bundle-relative launcher paths to try, per OS. The unix
// bundle ships a POSIX-sh launcher at bin/codegraph; the Windows zip ships a
// batch / exe shim, so try the common names there.
func launcherNames() []string {
if runtime.GOOS == "windows" {
return []string{
filepath.Join("bin", "codegraph.cmd"),
filepath.Join("bin", "codegraph.exe"),
filepath.Join("bin", "codegraph.bat"),
"codegraph.cmd",
"codegraph.exe",
}
}
return []string{filepath.Join("bin", "codegraph")}
}
// EnsureInit initialises CodeGraph for root when it has not been already, by
// running a bare `codegraph init` (no -i). That only creates the .codegraph/
// structure — fast and independent of repo size (~100ms) — because the actual
// indexing is done by `serve --mcp`'s daemon in the background once connected: the
// MCP handshake returns in a few hundred ms and symbols fill in shortly after,
// with CodeGraph flagging partial results as stale meanwhile. So startup never
// blocks on indexing, even for a huge monorepo.
//
// An existing .codegraph/ is left untouched — serve re-syncs it on connect and the
// file-watcher keeps it fresh thereafter. The init step is required because serve
// does NOT auto-create .codegraph/: without it, it runs in a degraded, no-index
// mode rather than building one.
func EnsureInit(ctx context.Context, bin, root string) error {
if root == "" {
return nil
}
if Initialized(root) {
return nil // already initialised — serve re-syncs and the watcher keeps it fresh
}
ctx, cancel := context.WithTimeout(ctx, initTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "init", root)
proc.SetProcessGroupKill(cmd) // own group so Cancel→KillTree reaps the tree off Windows (no-op on Windows)
cmd.Cancel = func() error { proc.KillTree(cmd); return nil }
cmd.WaitDelay = 3 * time.Second
proc.HideWindow(cmd)
cmd.Dir = root
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("codegraph init: %w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// Initialized reports whether root already has CodeGraph's project state. Boot
// uses this to keep warm projects eager while moving first-time project setup to
// background startup, avoiding a cold MCP handshake on the app's critical path.
func Initialized(root string) bool {
if root == "" {
return false
}
fi, err := os.Stat(filepath.Join(root, ".codegraph"))
return err == nil && fi.IsDir()
}
// IndexableRoot reports whether root is a real project directory CodeGraph can
// safely be pinned to. A filesystem root (a Windows drive root like C:\, a UNC
// share root, or the unix /) is rejected: serve --mcp walks its working
// directory, so a root cwd makes it index the whole volume — C:\Windows,
// C:\Program Files, everything — pinning gigabytes of RAM (#3747). An empty
// root is rejected too: there is nothing to pin a cwd-aware server to.
func IndexableRoot(root string) bool {
root = strings.TrimSpace(root)
if root == "" {
return false
}
abs, err := filepath.Abs(root)
if err != nil {
return false
}
return filepath.Dir(abs) != abs // a filesystem root is its own parent
}
func expand(p string) string {
p = os.ExpandEnv(p)
if strings.HasPrefix(p, "~/") {
if home, err := os.UserHomeDir(); err == nil {
p = filepath.Join(home, p[2:])
}
}
return p
}
// isExec reports whether p is an existing regular file that looks runnable. On
// Unix it must carry an execute bit; on Windows existence is enough, since there
// runnability is decided by extension, not a mode bit.
func isExec(p string) bool {
fi, err := os.Stat(p)
if err != nil || fi.IsDir() {
return false
}
if runtime.GOOS == "windows" {
return true
}
return fi.Mode()&0o111 != 0
}
// DaemonPID returns the PID recorded in CodeGraph's structured daemon lockfile.
// It returns 0, false when the lockfile doesn't exist or does not contain the
// full pid/version/socket record written by CodeGraph's daemon.
func DaemonPID(root string) (int, bool) {
info, ok := readDaemonInfo(root)
if !ok {
return 0, false
}
return info.PID, true
}
func readDaemonInfo(root string) (daemonInfo, bool) {
pidPath := filepath.Join(root, ".codegraph", "daemon.pid")
raw, err := os.ReadFile(pidPath)
if err != nil {
return daemonInfo{}, false
}
var info daemonInfo
if err := json.Unmarshal(raw, &info); err != nil {
return daemonInfo{}, false
}
if info.PID <= 0 || info.Version == "" || info.SocketPath == "" || info.StartedAt <= 0 {
return daemonInfo{}, false
}
return info, true
}
// KillDaemon terminates the CodeGraph daemon for root after verifying that the
// recorded PID still answers on the daemon socket as the same CodeGraph process.
// This is intentionally for explicit cleanup paths such as tests/diagnostics:
// CodeGraph daemons are root-scoped and may be shared by other MCP clients, so a
// normal Reasonix controller close must only close its proxy connection and let
// CodeGraph's own refcount/idle timer own daemon lifetime.
func KillDaemon(root string) {
info, ok := readDaemonInfo(root)
if !ok {
return
}
if !daemonIdentityMatches(info) {
return
}
p, err := os.FindProcess(info.PID)
if err != nil {
return
}
_ = p.Kill()
}
func daemonIdentityMatches(info daemonInfo) bool {
if runtime.GOOS == "windows" {
return false
}
conn, err := net.DialTimeout("unix", info.SocketPath, daemonProbeTimeout)
if err != nil {
return false
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(daemonProbeTimeout))
line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
return false
}
var hello struct {
Codegraph string `json:"codegraph"`
PID int `json:"pid"`
SocketPath string `json:"socketPath"`
Protocol int `json:"protocol"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(line)), &hello); err != nil {
return false
}
return hello.Codegraph != "" &&
hello.PID == info.PID &&
hello.SocketPath == info.SocketPath &&
hello.Protocol == 1
}
-316
View File
@@ -1,316 +0,0 @@
package codegraph
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"
)
// writeExec writes an executable file at path with the given content and +x.
func writeExec(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatal(err)
}
}
func TestResolveOverride(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("override path test uses a unix +x bit")
}
dir := t.TempDir()
bin := filepath.Join(dir, "codegraph")
writeExec(t, bin, "#!/bin/sh\nexit 0\n")
got, ok := Resolve(bin)
if !ok || got != bin {
t.Fatalf("Resolve(%q) = %q, %v; want %q, true", bin, got, ok, bin)
}
}
func TestResolveOverrideMissingFallsThrough(t *testing.T) {
// A non-existent override must not resolve to itself; with no bundle/PATH
// match either, ok is false (a real codegraph on PATH could make this true,
// so only assert the override itself is not returned).
missing := filepath.Join(t.TempDir(), "nope")
if got, _ := Resolve(missing); got == missing {
t.Fatalf("Resolve returned the missing override path %q", got)
}
}
func TestExpandTilde(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home dir")
}
if got := expand("~/foo/bar"); got != filepath.Join(home, "foo", "bar") {
t.Fatalf("expand(~/foo/bar) = %q", got)
}
}
func TestEnsureInitSkipsWhenPresent(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, ".codegraph"), 0o755); err != nil {
t.Fatal(err)
}
// bin points at nothing runnable; EnsureInit must short-circuit before exec.
if err := EnsureInit(context.Background(), filepath.Join(root, "no-such-bin"), root); err != nil {
t.Fatalf("EnsureInit with existing .codegraph should be a no-op, got %v", err)
}
}
func TestEnsureInitRunsWhenAbsent(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake launcher is a POSIX-sh script")
}
root := t.TempDir()
// A fake codegraph that creates .codegraph in its working directory — EnsureInit
// runs it with cmd.Dir = root, so this is independent of the exact arguments.
bin := filepath.Join(t.TempDir(), "fakecg")
writeExec(t, bin, "#!/bin/sh\nmkdir -p .codegraph\n")
if err := EnsureInit(context.Background(), bin, root); err != nil {
t.Fatalf("EnsureInit = %v", err)
}
if fi, err := os.Stat(filepath.Join(root, ".codegraph")); err != nil || !fi.IsDir() {
t.Fatalf(".codegraph not created: err=%v", err)
}
}
func TestEnsureInitPropagatesFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake launcher is a POSIX-sh script")
}
root := t.TempDir()
bin := filepath.Join(t.TempDir(), "failcg")
writeExec(t, bin, "#!/bin/sh\necho boom 1>&2\nexit 3\n")
if err := EnsureInit(context.Background(), bin, root); err == nil {
t.Fatal("EnsureInit should return the init failure")
}
}
func TestDaemonPIDNoLock(t *testing.T) {
root := t.TempDir()
pid, ok := DaemonPID(root)
if ok || pid != 0 {
t.Fatalf("DaemonPID with no lock = %d, %v; want 0, false", pid, ok)
}
}
func TestDaemonPIDEmptyLock(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, ".codegraph"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".codegraph", "daemon.pid"), []byte(""), 0o644); err != nil {
t.Fatal(err)
}
pid, ok := DaemonPID(root)
if ok || pid != 0 {
t.Fatalf("DaemonPID with empty lock = %d, %v; want 0, false", pid, ok)
}
}
func TestDaemonPIDRejectsLegacyPlainPID(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, ".codegraph"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".codegraph", "daemon.pid"), []byte("12345\n"), 0o644); err != nil {
t.Fatal(err)
}
pid, ok := DaemonPID(root)
if ok || pid != 0 {
t.Fatalf("DaemonPID with legacy plain PID = %d, %v; want 0, false", pid, ok)
}
}
func TestDaemonPIDStructuredLock(t *testing.T) {
root := t.TempDir()
writeDaemonInfo(t, root, daemonInfo{
PID: 12345,
Version: "0.9.7",
SocketPath: filepath.Join(root, ".codegraph", "daemon.sock"),
StartedAt: 123456789,
})
pid, ok := DaemonPID(root)
if !ok || pid != 12345 {
t.Fatalf("DaemonPID = %d, %v; want 12345, true", pid, ok)
}
}
func TestDaemonPIDRejectsPartialLock(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, ".codegraph"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".codegraph", "daemon.pid"), []byte(`{"pid":42}`), 0o644); err != nil {
t.Fatal(err)
}
pid, ok := DaemonPID(root)
if ok || pid != 0 {
t.Fatalf("DaemonPID with partial lock = %d, %v; want 0, false", pid, ok)
}
}
func TestKillDaemonNoLock(t *testing.T) {
// Must not panic or error when there is no .codegraph directory at all.
root := t.TempDir()
KillDaemon(root) // no-op
}
func TestKillDaemonRequiresMatchingSocketHello(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix process/signal semantics only")
}
if _, err := exec.LookPath("sleep"); err != nil {
t.Skip("sleep command not available")
}
cmd := exec.Command("sleep", "30")
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
t.Cleanup(func() {
if processExists(cmd.Process.Pid) {
_ = cmd.Process.Kill()
}
select {
case <-done:
case <-time.After(time.Second):
}
})
root := t.TempDir()
writeDaemonInfo(t, root, daemonInfo{
PID: cmd.Process.Pid,
Version: "0.9.7",
SocketPath: filepath.Join(root, ".codegraph", "missing.sock"),
StartedAt: 123456789,
})
KillDaemon(root)
if !processExists(cmd.Process.Pid) {
t.Fatal("KillDaemon killed a process without a matching daemon socket hello")
}
}
func TestKillDaemonKillsMatchingSocketDaemon(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix socket/process semantics only")
}
if _, err := exec.LookPath("sleep"); err != nil {
t.Skip("sleep command not available")
}
root := shortTempDir(t)
cgDir := filepath.Join(root, ".codegraph")
if err := os.Mkdir(cgDir, 0o755); err != nil {
t.Fatal(err)
}
socketPath := filepath.Join(cgDir, "daemon.sock")
ln, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatal(err)
}
defer ln.Close()
cmd := exec.Command("sleep", "30")
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
t.Cleanup(func() {
if processExists(cmd.Process.Pid) {
_ = cmd.Process.Kill()
}
select {
case <-done:
case <-time.After(time.Second):
}
})
writeDaemonInfo(t, root, daemonInfo{
PID: cmd.Process.Pid,
Version: "0.9.7",
SocketPath: socketPath,
StartedAt: 123456789,
})
accepted := make(chan struct{})
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
fmt.Fprintf(conn, `{"codegraph":"0.9.7","pid":%d,"socketPath":%q,"protocol":1}`+"\n", cmd.Process.Pid, socketPath)
close(accepted)
}()
KillDaemon(root)
select {
case <-accepted:
case <-time.After(time.Second):
t.Fatal("KillDaemon did not probe the daemon socket")
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("KillDaemon did not kill the matching daemon process")
}
}
func shortTempDir(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("/tmp", "reasonix-codegraph-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
return dir
}
func writeDaemonInfo(t *testing.T, root string, info daemonInfo) {
t.Helper()
if err := os.MkdirAll(filepath.Join(root, ".codegraph"), 0o755); err != nil {
t.Fatal(err)
}
b, err := json.Marshal(info)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".codegraph", "daemon.pid"), append(b, '\n'), 0o644); err != nil {
t.Fatal(err)
}
}
func TestIndexableRootRejectsFilesystemRoots(t *testing.T) {
if got := IndexableRoot(t.TempDir()); !got {
t.Fatal("a real project dir must be indexable")
}
for _, root := range []string{"", " "} {
if IndexableRoot(root) {
t.Fatalf("IndexableRoot(%q) = true; want false", root)
}
}
var roots []string
if runtime.GOOS == "windows" {
roots = []string{`C:\`, `c:\`, `\\server\share`}
} else {
roots = []string{"/"}
}
for _, root := range roots {
if IndexableRoot(root) {
t.Fatalf("IndexableRoot(%q) = true; want false (filesystem root)", root)
}
}
}
-233
View File
@@ -1,233 +0,0 @@
package codegraph
import (
"context"
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
"time"
"reasonix/internal/plugin"
"reasonix/internal/tool"
)
// TestE2ECodegraphMCP drives the whole integration against a real CodeGraph
// bundle: index a fixture project, connect via the MCP client pinned to that
// project (Spec.Dir), and actually call codegraph_search. It is gated on
// REASONIX_CODEGRAPH_E2E so the normal `go test ./...` skips it (no network, no
// external binary), yet it still compiles every build so it can't bit-rot.
//
// Run it with `make e2e-codegraph` (fetches the matching bundle), or manually:
//
// REASONIX_CODEGRAPH_E2E=1 REASONIX_CODEGRAPH_BIN=/path/to/codegraph \
// go test ./internal/codegraph/ -run E2E -v -count=1
//
// With REASONIX_CODEGRAPH_BIN unset it falls back to Resolve("") (bundle / PATH).
func TestE2ECodegraphMCP(t *testing.T) {
if os.Getenv("REASONIX_CODEGRAPH_E2E") == "" {
t.Skip("set REASONIX_CODEGRAPH_E2E=1 to run the CodeGraph MCP end-to-end test")
}
bin := os.Getenv("REASONIX_CODEGRAPH_BIN")
if bin == "" {
var ok bool
if bin, ok = Resolve(""); !ok {
t.Fatal("REASONIX_CODEGRAPH_E2E is set but no codegraph binary found — set REASONIX_CODEGRAPH_BIN to the launcher path")
}
}
t.Logf("codegraph binary: %s", bin)
// A fixture project carrying a known symbol the search must surface.
root := t.TempDir()
src := "package demo\n\n// Greet builds a greeting.\nfunc Greet(name string) string { return \"hi \" + name }\n"
if err := os.WriteFile(filepath.Join(root, "greet.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
// 1) Initialise .codegraph/ (fast, no indexing — serve's daemon does that).
if err := EnsureInit(ctx, bin, root); err != nil {
t.Fatalf("EnsureInit: %v", err)
}
if fi, err := os.Stat(filepath.Join(root, ".codegraph")); err != nil || !fi.IsDir() {
t.Fatalf(".codegraph was not created by EnsureInit: %v", err)
}
// 2) Connect through the real MCP client, pinned to the project root via Dir —
// the same wiring boot uses for the built-in server.
host, tools, err := plugin.StartAll(ctx, []plugin.Spec{{
Name: "codegraph",
Command: bin,
Args: []string{"serve", "--mcp"},
Dir: root,
ReadOnlyToolNames: ReadOnlyToolNames(),
}})
if err != nil {
t.Fatalf("StartAll: %v", err)
}
defer func() {
host.Close()
// Reap the daemon that escapes process-group kill via setsid.
KillDaemon(root)
}()
if len(tools) == 0 {
t.Fatal("codegraph exposed no MCP tools")
}
// 3) Locate the search tool and actually invoke it through Tool.Execute.
var search tool.Tool
names := make([]string, 0, len(tools))
for _, tl := range tools {
names = append(names, tl.Name())
if strings.Contains(tl.Name(), "codegraph_search") {
search = tl
}
}
if search == nil {
t.Fatalf("no codegraph_search tool among %v", names)
}
if !search.ReadOnly() {
t.Fatalf("codegraph_search should be read-only under the built-in CodeGraph override; tools=%v", names)
}
// serve indexes in the background, so poll the search for a few seconds until
// the known symbol surfaces (rather than assuming it is ready at handshake).
var out string
deadline := time.Now().Add(15 * time.Second)
for {
var err error
if out, err = search.Execute(ctx, json.RawMessage(`{"query":"Greet"}`)); err != nil {
t.Fatalf("codegraph_search Execute: %v\nschema: %s", err, search.Schema())
}
if strings.Contains(out, "Greet") {
break
}
if time.Now().After(deadline) {
t.Fatalf("codegraph_search never surfaced the known symbol Greet within 15s:\n%s", out)
}
time.Sleep(300 * time.Millisecond)
}
t.Logf("e2e ok — %d tools (%v); search surfaced Greet", len(tools), names)
}
// TestE2ECodegraphKillDaemon verifies that KillDaemon can reap a real CodeGraph
// daemon that escapes the process-group kill in KillTree. The daemon does
// setsid(2) to detach into its own process group, so the negative-PID SIGKILL
// that KillTree sends to the launcher's group misses it. KillDaemon verifies the
// daemon lockfile against the socket hello before sending a direct SIGKILL.
//
// Gated on REASONIX_CODEGRAPH_E2E=1 like the other E2E test; skipped on Windows
// because the Job Object (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) already captures
// every descendant regardless of reparenting.
func TestE2ECodegraphKillDaemon(t *testing.T) {
if os.Getenv("REASONIX_CODEGRAPH_E2E") == "" {
t.Skip("set REASONIX_CODEGRAPH_E2E=1 to run the CodeGraph E2E daemon-kill test")
}
if runtime.GOOS == "windows" {
t.Skip("daemon escape is Unix-only; Windows Job Objects capture all descendants")
}
bin := os.Getenv("REASONIX_CODEGRAPH_BIN")
if bin == "" {
var ok bool
if bin, ok = Resolve(""); !ok {
t.Fatal("REASONIX_CODEGRAPH_E2E is set but no codegraph binary found — set REASONIX_CODEGRAPH_BIN to the launcher path")
}
}
t.Logf("codegraph binary: %s", bin)
root := t.TempDir()
src := "package demo\n\nfunc Greet(name string) string { return \"hi \" + name }\n"
if err := os.WriteFile(filepath.Join(root, "greet.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := EnsureInit(ctx, bin, root); err != nil {
t.Fatalf("EnsureInit: %v", err)
}
// Start the codegraph server — this launches the MCP bridge (foreground) and
// a daemon (background). The daemon records its PID in its lockfile on startup.
host, _, err := plugin.StartAll(ctx, []plugin.Spec{{
Name: "codegraph",
Command: bin,
Args: []string{"serve", "--mcp"},
Dir: root,
ReadOnlyToolNames: ReadOnlyToolNames(),
}})
if err != nil {
t.Fatalf("StartAll: %v", err)
}
// Give the daemon a moment to start and write its PID lockfile.
var daemonPID int
var daemonOK bool
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if daemonPID, daemonOK = DaemonPID(root); daemonOK {
break
}
time.Sleep(100 * time.Millisecond)
}
if !daemonOK {
host.Close()
t.Fatal("daemon PID never appeared in .codegraph/daemon.pid")
}
t.Logf("daemon PID: %d", daemonPID)
// Verify the daemon is alive before we close.
if !processExists(daemonPID) {
host.Close()
t.Fatalf("daemon PID %d not alive before Close", daemonPID)
}
t.Logf("daemon %d alive before host.Close()", daemonPID)
// host.Close kills the launcher via KillTree (process-group SIGKILL), but the
// daemon has detached into its own process group via setsid and escapes.
host.Close()
// Give the kernel a moment to deliver the SIGKILL and reap the launcher.
time.Sleep(300 * time.Millisecond)
if processExists(daemonPID) {
t.Logf("daemon %d survived process-group kill (as expected — it escaped via setsid)", daemonPID)
// Now explicitly kill the daemon via its logged PID.
KillDaemon(root)
// Poll for exit with a short deadline.
deadline := time.Now().Add(5 * time.Second)
for processExists(daemonPID) {
if time.Now().After(deadline) {
t.Fatalf("daemon %d still alive after KillDaemon", daemonPID)
}
time.Sleep(50 * time.Millisecond)
}
t.Logf("daemon %d killed by KillDaemon", daemonPID)
} else {
// The daemon didn't escape — process-group kill worked. This can happen
// if codegraph is built without daemonization or if the OS doesn't
// support setsid semantics. KillDaemon is still a safe no-op here.
t.Logf("daemon %d was already dead after host.Close() (process-group kill succeeded)", daemonPID)
KillDaemon(root) // no-op — best-effort is safe
}
}
// processExists reports whether a process with the given PID is currently
// running. It sends signal 0 (null signal) on Unix; on Windows it always
// returns false (but the daemon-kill test skips Windows anyway).
func processExists(pid int) bool {
p, err := os.FindProcess(pid)
if err != nil {
return false
}
return p.Signal(syscall.Signal(0)) == nil
}
-639
View File
@@ -1,639 +0,0 @@
package codegraph
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
const (
// Version is the pinned CodeGraph release fetched on first use. Keep in sync
// with CODEGRAPH_VERSION in the Makefile and .github/workflows.
Version = "v0.9.7"
cgRepo = "colbymchenry/codegraph"
officialMirrorBase = "https://dl.reasonix.io/codegraph"
officialMainlandMirrorBase = ""
perSourceDownloadTimeout = 10 * time.Minute
activeVersionFile = "active-version"
renameAttempts = 5
renameBackoff = 200 * time.Millisecond
)
var (
githubAPIBase = "https://api.github.com"
githubReleaseDownloadBase = func(version string) string {
return fmt.Sprintf("https://github.com/%s/releases/download/%s", cgRepo, version)
}
)
type UpdateResult struct {
Version string
Path string
}
// CacheDir is where the CodeGraph bundle is unpacked on first use:
// <user cache>/reasonix/codegraph/<Version>. Versioned so a bump installs cleanly
// beside the old one. REASONIX_CACHE_DIR overrides the base (relocate the cache,
// or isolate it in tests). Empty when no cache/config dir resolves.
func CacheDir() string {
return CacheDirForVersion(Version)
}
func CacheDirForVersion(version string) string {
base := cacheRoot()
if base == "" {
return ""
}
return filepath.Join(base, "codegraph", version)
}
func cacheRoot() string {
base := os.Getenv("REASONIX_CACHE_DIR")
if base == "" {
var err error
if base, err = os.UserCacheDir(); err != nil {
if base, err = os.UserConfigDir(); err != nil {
return ""
}
}
base = filepath.Join(base, "reasonix")
}
return base
}
func activeVersion() string {
base := cacheRoot()
if base == "" {
return ""
}
data, err := os.ReadFile(filepath.Join(base, "codegraph", activeVersionFile))
if err != nil {
return ""
}
version := strings.TrimSpace(string(data))
if !validVersion(version) {
return ""
}
return version
}
func ActiveVersion() string {
return activeVersion()
}
func NewerThanActive(version string) bool {
if !validVersion(version) {
return false
}
current := activeVersion()
if current == "" {
current = Version
}
return compareVersion(version, current) > 0
}
func writeActiveVersion(version string) error {
if !validVersion(version) {
return fmt.Errorf("codegraph: invalid version %q", version)
}
base := cacheRoot()
if base == "" {
return fmt.Errorf("codegraph: no cache directory available")
}
dir := filepath.Join(base, "codegraph")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, activeVersionFile), []byte(version+"\n"), 0o644)
}
func validVersion(version string) bool {
version = strings.TrimSpace(version)
if version == "" || strings.ContainsAny(version, `/\`) || version == "." || version == ".." {
return false
}
for _, r := range version {
if !(r == '.' || r == '-' || r == '_' || r == '+' || r >= '0' && r <= '9' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z') {
return false
}
}
return true
}
func compareVersion(a, b string) int {
aa, bb := versionParts(a), versionParts(b)
n := len(aa)
if len(bb) > n {
n = len(bb)
}
for i := 0; i < n; i++ {
av, bv := 0, 0
if i < len(aa) {
av = aa[i]
}
if i < len(bb) {
bv = bb[i]
}
if av > bv {
return 1
}
if av < bv {
return -1
}
}
return 0
}
func versionParts(version string) []int {
version = strings.TrimPrefix(strings.TrimSpace(version), "v")
fields := strings.FieldsFunc(version, func(r rune) bool {
return r < '0' || r > '9'
})
out := make([]int, 0, len(fields))
for _, f := range fields {
var n int
for _, r := range f {
n = n*10 + int(r-'0')
}
out = append(out, n)
}
return out
}
// cached returns the launcher path inside CacheDir when the bundle is present.
func cached() (string, bool) {
if active := activeVersion(); active != "" && compareVersion(active, Version) >= 0 {
if p, ok := cachedForVersion(active); ok {
return p, true
}
}
return cachedForVersion(Version)
}
func cachedForVersion(version string) (string, bool) {
dir := CacheDirForVersion(version)
if dir == "" {
return "", false
}
for _, rel := range launcherNames() {
if p := filepath.Join(dir, rel); isExec(p) {
return p, true
}
}
return "", false
}
// assetName is CodeGraph's release asset for the running platform (it names them
// codegraph-<darwin|linux|win32>-<x64|arm64>.<tar.gz|zip>).
func assetName() string {
osName := map[string]string{"darwin": "darwin", "linux": "linux", "windows": "win32"}[runtime.GOOS]
if osName == "" {
osName = runtime.GOOS
}
arch := map[string]string{"amd64": "x64", "arm64": "arm64"}[runtime.GOARCH]
if arch == "" {
arch = runtime.GOARCH
}
ext := "tar.gz"
if runtime.GOOS == "windows" {
ext = "zip"
}
return fmt.Sprintf("codegraph-%s-%s.%s", osName, arch, ext)
}
// Install downloads and unpacks the CodeGraph bundle into CacheDir on first use,
// verifying it against the checksum baked into the reasonix binary, then returns
// the launcher path.
// It is idempotent: a present cache is returned untouched. log, if non-nil,
// receives a couple of progress lines. The extraction is staged in a temp dir and
// atomically renamed into place, so a cancelled or failed run leaves no partial
// install behind.
func Install(ctx context.Context, log func(string)) (string, error) {
return InstallWithClient(ctx, http.DefaultClient, log)
}
// InstallWithClient is Install with an explicit HTTP client, used when Reasonix
// network proxy settings should apply.
func InstallWithClient(ctx context.Context, client *http.Client, log func(string)) (string, error) {
if client == nil {
client = http.DefaultClient
}
if p, ok := cachedForVersion(Version); ok {
return p, nil
}
asset := assetName()
logf(log, "codegraph: downloading %s (%s, one-time)…", asset, Version)
want := expectedAssetSHA256(asset)
if want == "" {
return "", fmt.Errorf("codegraph: no embedded checksum for %s (%s)", asset, Version)
}
return installVersionWithClient(ctx, client, Version, want, downloadBases(), false, log)
}
func UpdateWithClient(ctx context.Context, client *http.Client, log func(string)) (UpdateResult, error) {
res, err := DownloadLatestWithClient(ctx, client, log)
if err != nil {
return UpdateResult{}, err
}
if err := writeActiveVersion(res.Version); err != nil {
return UpdateResult{}, err
}
return res, nil
}
func DownloadLatestWithClient(ctx context.Context, client *http.Client, log func(string)) (UpdateResult, error) {
if client == nil {
client = http.DefaultClient
}
version, err := LatestVersionWithClient(ctx, client)
if err != nil {
return UpdateResult{}, err
}
if p, ok := cachedForVersion(version); ok {
return UpdateResult{Version: version, Path: p}, nil
}
asset := assetName()
sums, err := downloadReleaseFile(ctx, client, version, "SHA256SUMS")
if err != nil {
return UpdateResult{}, err
}
want, err := sha256For(string(sums), asset)
if err != nil {
return UpdateResult{}, err
}
logf(log, "codegraph: downloading %s (%s)…", asset, version)
path, err := installVersionWithClient(ctx, client, version, want, []string{githubReleaseDownloadBase(version)}, true, log)
if err != nil {
return UpdateResult{}, err
}
return UpdateResult{Version: version, Path: path}, nil
}
func LatestVersionWithClient(ctx context.Context, client *http.Client) (string, error) {
if client == nil {
client = http.DefaultClient
}
url := strings.TrimRight(githubAPIBase, "/") + "/repos/" + cgRepo + "/releases/latest"
data, err := httpGet(ctx, client, url)
if err != nil {
return "", err
}
var release struct {
TagName string `json:"tag_name"`
}
if err := json.Unmarshal(data, &release); err != nil {
return "", fmt.Errorf("codegraph: parse latest release: %w", err)
}
version := strings.TrimSpace(release.TagName)
if !validVersion(version) {
return "", fmt.Errorf("codegraph: invalid latest version %q", version)
}
return version, nil
}
func installVersionWithClient(ctx context.Context, client *http.Client, version, want string, bases []string, force bool, log func(string)) (string, error) {
dir := CacheDirForVersion(version)
if dir == "" {
return "", fmt.Errorf("codegraph: no cache directory available")
}
if !force {
if p, ok := cachedForVersion(version); ok {
return p, nil
}
}
asset := assetName()
data, err := downloadAssetFromBases(ctx, client, asset, want, bases, log)
if err != nil {
return "", err
}
parent := filepath.Dir(dir)
if err := os.MkdirAll(parent, 0o755); err != nil {
return "", err
}
tmp, err := os.MkdirTemp(parent, ".dl-")
if err != nil {
return "", err
}
defer os.RemoveAll(tmp)
if strings.HasSuffix(asset, ".zip") {
err = extractZip(data, tmp)
} else {
err = extractTarGz(data, tmp)
}
if err != nil {
return "", fmt.Errorf("codegraph: extract: %w", err)
}
// The archive holds a single top-level dir (codegraph-<target>/). Promote it
// to the versioned cache dir so the launcher lands at <dir>/bin/codegraph.
root, err := singleChild(tmp)
if err != nil {
return "", err
}
if !force {
if p, ok := cachedForVersion(version); ok {
return p, nil // a concurrent session already populated dir
}
}
if err := promote(root, dir); err != nil {
if p, ok := cachedForVersion(version); ok {
return p, nil // a concurrent winner landed during our retries
}
return "", fmt.Errorf("codegraph: install to %s failed: %w — the cache directory may be read-only or locked by antivirus; set REASONIX_CACHE_DIR to a writable location to relocate it", dir, err)
}
p, ok := cachedForVersion(version)
if !ok {
return "", fmt.Errorf("codegraph: launcher not found after install (unexpected bundle layout)")
}
logf(log, "codegraph: installed to %s", dir)
return p, nil
}
func downloadReleaseFile(ctx context.Context, client *http.Client, version, name string) ([]byte, error) {
if !validVersion(version) || strings.ContainsAny(name, `/\`) || strings.TrimSpace(name) == "" {
return nil, fmt.Errorf("codegraph: invalid release file %q for %q", name, version)
}
return httpGet(ctx, client, strings.TrimRight(githubReleaseDownloadBase(version), "/")+"/"+name)
}
func downloadAssetFromBases(ctx context.Context, client *http.Client, asset, want string, bases []string, log func(string)) ([]byte, error) {
var errs []string
for _, base := range bases {
url := strings.TrimRight(base, "/") + "/" + asset
getCtx := ctx
cancel := func() {}
if perSourceDownloadTimeout > 0 {
getCtx, cancel = context.WithTimeout(ctx, perSourceDownloadTimeout)
}
data, err := httpGet(getCtx, client, url)
cancel()
if err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", base, err))
continue
}
got := fmt.Sprintf("%x", sha256.Sum256(data))
if got != want {
errs = append(errs, fmt.Sprintf("%s: checksum mismatch for %s", base, asset))
continue
}
logf(log, "codegraph: downloaded from %s", base)
return data, nil
}
return nil, fmt.Errorf("codegraph: download %s failed (%s)", asset, strings.Join(errs, "; "))
}
func expectedAssetSHA256(asset string) string {
return releaseAssetSHA256[asset]
}
func downloadBases() []string {
bases := []string{officialMirrorBase + "/" + Version}
if strings.TrimSpace(officialMainlandMirrorBase) != "" {
bases = append(bases, strings.TrimRight(officialMainlandMirrorBase, "/")+"/"+Version)
}
bases = append(bases, fmt.Sprintf("https://github.com/%s/releases/download/%s", cgRepo, Version))
return dedupeStrings(bases)
}
func dedupeStrings(values []string) []string {
var out []string
seen := make(map[string]bool, len(values))
for _, v := range values {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
// promote moves the freshly extracted bundle (root) into its versioned home
// (dir). On Windows os.Rename onto an existing directory fails with "Access is
// denied", so a stale/partial dest from an earlier interrupted install (cached()
// already reported it incomplete) is cleared first; a just-extracted .exe can
// also stay briefly locked by an AV scanner, so the move is retried.
func promote(root, dir string) error {
_ = os.RemoveAll(dir)
var err error
for i := 0; i < renameAttempts; i++ {
if err = os.Rename(root, dir); err == nil {
return nil
}
time.Sleep(renameBackoff)
}
return err
}
func httpGet(ctx context.Context, client *http.Client, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
}
return io.ReadAll(resp.Body)
}
// sha256For returns the hex checksum recorded for name in a SHA256SUMS body
// (lines of "<hex> <name>").
func sha256For(sums, name string) (string, error) {
for _, line := range strings.Split(sums, "\n") {
f := strings.Fields(line)
if len(f) == 2 && f[1] == name {
return f[0], nil
}
}
return "", fmt.Errorf("codegraph: %s not listed in SHA256SUMS", name)
}
// resolveWithin returns the real path to write archive entry name under root
// (parents created), rejecting escapes. EvalSymlinks on the parent also catches
// the symlink-redirect variant a lexical "../" check misses: a parent component
// an earlier entry turned into a symlink, written *through* to land outside root.
func resolveWithin(root, name string) (string, error) {
if !filepath.IsLocal(name) {
return "", fmt.Errorf("unsafe path %q in archive", name)
}
target := filepath.Join(root, name)
if target != root && !strings.HasPrefix(target, root+string(os.PathSeparator)) {
return "", fmt.Errorf("unsafe path %q in archive", name)
}
if target == root {
return root, nil
}
parent := filepath.Dir(target)
if err := os.MkdirAll(parent, 0o755); err != nil {
return "", err
}
realParent, err := filepath.EvalSymlinks(parent)
if err != nil {
return "", err
}
if realParent != root && !strings.HasPrefix(realParent, root+string(os.PathSeparator)) {
return "", fmt.Errorf("unsafe path %q in archive: escapes via symlink", name)
}
return filepath.Join(realParent, filepath.Base(target)), nil
}
func cleanSymlinkTarget(root, linkPath, linkname string) (string, error) {
if linkname == "" || filepath.IsAbs(linkname) {
return "", fmt.Errorf("unsafe symlink target %q in archive", linkname)
}
clean := filepath.Clean(linkname)
dest, err := filepath.EvalSymlinks(filepath.Join(filepath.Dir(linkPath), clean))
if err != nil {
return "", fmt.Errorf("unsafe symlink target %q in archive: %w", linkname, err)
}
if dest != root && !strings.HasPrefix(dest, root+string(os.PathSeparator)) {
return "", fmt.Errorf("unsafe symlink target %q in archive", linkname)
}
return filepath.ToSlash(clean), nil
}
// symlinkWithin rejects a symlink whose target escapes root. linkPath is the
// symlink's already-resolved real location (from resolveWithin), so a relative
// target is judged from where the link truly lands, not its lexical archive path.
func symlinkWithin(root, linkPath, linkname string) error {
dest := linkname
if !filepath.IsAbs(dest) {
dest = filepath.Join(filepath.Dir(linkPath), linkname)
}
dest = filepath.Clean(dest)
if dest != root && !strings.HasPrefix(dest, root+string(os.PathSeparator)) {
return fmt.Errorf("unsafe symlink %q -> %q in archive", linkPath, linkname)
}
return nil
}
func extractTarGz(data []byte, dir string) error {
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return err
}
defer gz.Close()
root, err := filepath.EvalSymlinks(dir)
if err != nil {
return err
}
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
target, err := resolveWithin(root, hdr.Name)
if err != nil {
return err
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeSymlink:
linkname, err := cleanSymlinkTarget(root, target, hdr.Linkname)
if err != nil {
return err
}
if err := symlinkWithin(root, target, linkname); err != nil {
return err
}
_ = os.Remove(target)
if err := os.Symlink(linkname, target); err != nil {
return err
}
case tar.TypeReg:
if err := writeFileFromReader(target, tr, hdr.FileInfo().Mode()); err != nil {
return err
}
}
}
}
func extractZip(data []byte, dir string) error {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return err
}
root, err := filepath.EvalSymlinks(dir)
if err != nil {
return err
}
for _, f := range zr.File {
target, err := resolveWithin(root, f.Name)
if err != nil {
return err
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
continue
}
rc, err := f.Open()
if err != nil {
return err
}
err = writeFileFromReader(target, rc, f.Mode())
rc.Close()
if err != nil {
return err
}
}
return nil
}
func writeFileFromReader(target string, r io.Reader, mode os.FileMode) error {
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm())
if err != nil {
return err
}
if _, err := io.Copy(f, r); err != nil {
f.Close()
return err
}
return f.Close()
}
// singleChild returns the sole entry in dir (the archive's top-level
// codegraph-<target>/ directory).
func singleChild(dir string) (string, error) {
ents, err := os.ReadDir(dir)
if err != nil {
return "", err
}
if len(ents) != 1 || !ents[0].IsDir() {
return "", fmt.Errorf("codegraph: expected one top-level dir in archive, got %d entries", len(ents))
}
return filepath.Join(dir, ents[0].Name()), nil
}
func logf(log func(string), format string, a ...any) {
if log != nil {
log(fmt.Sprintf(format, a...))
}
}
-394
View File
@@ -1,394 +0,0 @@
package codegraph
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
func TestAssetNameForCurrentPlatform(t *testing.T) {
got := assetName()
if !strings.HasPrefix(got, "codegraph-") {
t.Fatalf("assetName %q lacks codegraph- prefix", got)
}
wantExt := ".tar.gz"
if runtime.GOOS == "windows" {
wantExt = ".zip"
}
if !strings.HasSuffix(got, wantExt) {
t.Fatalf("assetName %q should end in %s on %s", got, wantExt, runtime.GOOS)
}
}
func TestPromoteReplacesStalePartialDest(t *testing.T) {
parent := t.TempDir()
root := filepath.Join(parent, ".dl-x", "codegraph-x64")
if err := os.MkdirAll(filepath.Join(root, "bin"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "bin", "codegraph"), []byte("new"), 0o755); err != nil {
t.Fatal(err)
}
dir := filepath.Join(parent, "v0.9.7")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "stale.txt"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
if err := promote(root, dir); err != nil {
t.Fatalf("promote over a stale dest: %v", err)
}
if got, err := os.ReadFile(filepath.Join(dir, "bin", "codegraph")); err != nil || string(got) != "new" {
t.Fatalf("dest missing promoted bundle: %q %v", got, err)
}
if _, err := os.Stat(filepath.Join(dir, "stale.txt")); !os.IsNotExist(err) {
t.Fatal("stale dest content survived promote")
}
if _, err := os.Stat(root); !os.IsNotExist(err) {
t.Fatal("root should have been moved into dir")
}
}
func TestSha256For(t *testing.T) {
sums := "abc123 codegraph-linux-x64.tar.gz\ndef456 codegraph-darwin-arm64.tar.gz\n"
got, err := sha256For(sums, "codegraph-darwin-arm64.tar.gz")
if err != nil || got != "def456" {
t.Fatalf("sha256For = %q, %v; want def456", got, err)
}
if _, err := sha256For(sums, "codegraph-win32-x64.zip"); err == nil {
t.Fatal("expected error for absent asset")
}
}
func TestResolveWithinRejectsTraversal(t *testing.T) {
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
if _, err := resolveWithin(root, "../escape"); err == nil {
t.Fatal("resolveWithin should reject ../escape")
}
if _, err := resolveWithin(root, "bin/codegraph"); err != nil {
t.Fatalf("resolveWithin rejected a normal path: %v", err)
}
}
// makeTarGz builds an in-memory .tar.gz from name->(content, mode); a mode with
// the exec bit is preserved through extraction.
func makeTarGz(t *testing.T, files map[string]struct {
body string
mode int64
}) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for name, f := range files {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: f.mode, Size: int64(len(f.body)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(f.body)); err != nil {
t.Fatal(err)
}
}
tw.Close()
gz.Close()
return buf.Bytes()
}
func TestExtractTarGzPreservesExecBit(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("exec-bit semantics are POSIX")
}
data := makeTarGz(t, map[string]struct {
body string
mode int64
}{
"bin/codegraph": {"#!/bin/sh\n", 0o755},
"lib/app.js": {"console.log(1)", 0o644},
})
dir := t.TempDir()
if err := extractTarGz(data, dir); err != nil {
t.Fatalf("extractTarGz: %v", err)
}
fi, err := os.Stat(filepath.Join(dir, "bin", "codegraph"))
if err != nil {
t.Fatal(err)
}
if fi.Mode()&0o111 == 0 {
t.Fatal("launcher lost its exec bit on extraction")
}
b, _ := os.ReadFile(filepath.Join(dir, "lib", "app.js"))
if string(b) != "console.log(1)" {
t.Fatalf("lib/app.js content = %q", b)
}
}
func TestExtractTarGzRejectsTraversal(t *testing.T) {
data := makeTarGz(t, map[string]struct {
body string
mode int64
}{"../evil": {"x", 0o644}})
if err := extractTarGz(data, t.TempDir()); err == nil {
t.Fatal("extractTarGz should reject a ../ entry")
}
}
func TestInstallReturnsCachedWithoutNetwork(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX +x launcher")
}
base := t.TempDir()
t.Setenv("REASONIX_CACHE_DIR", base)
// Seed a fake cached launcher so Install short-circuits before any download.
launcher := filepath.Join(CacheDir(), "bin", "codegraph")
if err := os.MkdirAll(filepath.Dir(launcher), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(launcher, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
got, err := Install(context.Background(), nil)
if err != nil || got != launcher {
t.Fatalf("Install with a populated cache = %q, %v; want %q", got, err, launcher)
}
// Resolve should also find it (no override, cache wins).
if p, ok := Resolve(""); !ok || p != launcher {
t.Fatalf("Resolve = %q, %v; want %q", p, ok, launcher)
}
}
func TestUpdateDownloadsLatestAndActivates(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX +x launcher")
}
base := t.TempDir()
t.Setenv("REASONIX_CACHE_DIR", base)
asset := assetName()
body := makeTarGz(t, map[string]struct {
body string
mode int64
}{
"codegraph-test/bin/codegraph": {"#!/bin/sh\n", 0o755},
})
sum := sha256.Sum256(body)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/repos/colbymchenry/codegraph/releases/latest":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"tag_name":"v9.9.9"}`))
case "/download/v9.9.9/SHA256SUMS":
fmt.Fprintf(w, "%x %s\n", sum, asset)
case "/download/v9.9.9/" + asset:
w.Write(body)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
oldAPIBase := githubAPIBase
oldDownloadBase := githubReleaseDownloadBase
githubAPIBase = srv.URL
githubReleaseDownloadBase = func(version string) string { return srv.URL + "/download/" + version }
t.Cleanup(func() {
githubAPIBase = oldAPIBase
githubReleaseDownloadBase = oldDownloadBase
})
downloaded, err := DownloadLatestWithClient(context.Background(), srv.Client(), nil)
if err != nil {
t.Fatalf("DownloadLatestWithClient: %v", err)
}
if downloaded.Version != "v9.9.9" {
t.Fatalf("downloaded version = %q, want v9.9.9", downloaded.Version)
}
if got := ActiveVersion(); got != "" {
t.Fatalf("ActiveVersion after download-only = %q, want empty", got)
}
if p, ok := Resolve(""); ok && p == downloaded.Path {
t.Fatalf("Resolve used download-only latest path %q before activation", p)
}
res, err := UpdateWithClient(context.Background(), srv.Client(), nil)
if err != nil {
t.Fatalf("UpdateWithClient: %v", err)
}
if res.Version != "v9.9.9" {
t.Fatalf("version = %q, want v9.9.9", res.Version)
}
if want := filepath.Join(CacheDirForVersion("v9.9.9"), "bin", "codegraph"); res.Path != want {
t.Fatalf("path = %q, want %q", res.Path, want)
}
if got := ActiveVersion(); got != "v9.9.9" {
t.Fatalf("ActiveVersion = %q, want v9.9.9", got)
}
if p, ok := Resolve(""); !ok || p != res.Path {
t.Fatalf("Resolve = %q, %v; want active path %q", p, ok, res.Path)
}
}
func TestActiveVersionRejectsTraversal(t *testing.T) {
base := t.TempDir()
t.Setenv("REASONIX_CACHE_DIR", base)
dir := filepath.Join(base, "codegraph")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, activeVersionFile), []byte("../evil\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := ActiveVersion(); got != "" {
t.Fatalf("ActiveVersion = %q, want empty for traversal", got)
}
}
func TestDownloadAssetUsesEmbeddedChecksumAndMirrorFallback(t *testing.T) {
asset := assetName()
body := []byte("verified codegraph payload")
sum := sha256.Sum256(body)
prev, hadPrev := releaseAssetSHA256[asset]
releaseAssetSHA256[asset] = fmt.Sprintf("%x", sum)
t.Cleanup(func() {
if hadPrev {
releaseAssetSHA256[asset] = prev
} else {
delete(releaseAssetSHA256, asset)
}
})
bad := httptest.NewServer(http.NotFoundHandler())
defer bad.Close()
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/"+asset) {
t.Fatalf("download path = %q, want asset suffix", r.URL.Path)
}
w.Write(body)
}))
defer good.Close()
got, err := downloadAssetFromBases(context.Background(), http.DefaultClient, asset, fmt.Sprintf("%x", sum), []string{bad.URL, good.URL}, nil)
if err != nil {
t.Fatalf("downloadAsset: %v", err)
}
if string(got) != string(body) {
t.Fatalf("downloadAsset body = %q, want %q", got, body)
}
}
func TestDownloadAssetRejectsMirrorChecksumMismatch(t *testing.T) {
asset := assetName()
prev, hadPrev := releaseAssetSHA256[asset]
releaseAssetSHA256[asset] = strings.Repeat("0", 64)
t.Cleanup(func() {
if hadPrev {
releaseAssetSHA256[asset] = prev
} else {
delete(releaseAssetSHA256, asset)
}
})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("tampered"))
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if _, err := downloadAssetFromBases(ctx, http.DefaultClient, asset, strings.Repeat("0", 64), []string{srv.URL}, nil); err == nil {
t.Fatal("downloadAsset accepted a checksum mismatch")
}
}
func TestDownloadBasesUseOfficialSourcesBeforeGitHub(t *testing.T) {
got := downloadBases()
want := []string{
officialMirrorBase + "/" + Version,
fmt.Sprintf("https://github.com/%s/releases/download/%s", cgRepo, Version),
}
if len(got) != len(want) {
t.Fatalf("downloadBases = %#v, want %#v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("downloadBases[%d] = %q, want %q (all=%#v)", i, got[i], want[i], got)
}
}
}
func TestHTTPGetDetachedCtxSurvivesParentCancel(t *testing.T) {
started := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
select {
case <-time.After(300 * time.Millisecond):
case <-r.Context().Done():
}
w.Write([]byte("payload"))
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
var got []byte
var gotErr error
wg.Add(1)
go func() {
defer wg.Done()
got, gotErr = httpGet(context.WithoutCancel(ctx), http.DefaultClient, srv.URL)
}()
<-started
cancel()
wg.Wait()
if gotErr != nil {
t.Fatalf("detached download aborted after parent cancel: %v", gotErr)
}
if string(got) != "payload" {
t.Fatalf("got %q, want payload", got)
}
}
func TestHTTPGetPlainCtxAbortsOnParentCancel(t *testing.T) {
started := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
<-r.Context().Done()
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
var gotErr error
wg.Add(1)
go func() {
defer wg.Done()
_, gotErr = httpGet(ctx, http.DefaultClient, srv.URL)
}()
<-started
cancel()
wg.Wait()
if !errors.Is(gotErr, context.Canceled) {
t.Fatalf("plain ctx should abort on parent cancel, got %v", gotErr)
}
}
-18
View File
@@ -1,18 +0,0 @@
package codegraph
// ReadOnlyToolNames returns the CodeGraph MCP tools Reasonix treats as readers
// when older CodeGraph runtimes omit MCP annotations.readOnlyHint metadata.
func ReadOnlyToolNames() map[string]bool {
return map[string]bool{
"codegraph_callees": true,
"codegraph_callers": true,
"codegraph_context": true,
"codegraph_explore": true,
"codegraph_files": true,
"codegraph_impact": true,
"codegraph_node": true,
"codegraph_search": true,
"codegraph_status": true,
"codegraph_trace": true,
}
}
-24
View File
@@ -1,24 +0,0 @@
package codegraph
import "reasonix/internal/plugin"
// MCPSpec returns the built-in CodeGraph MCP plugin spec used by every Reasonix
// launch path. Keeping this in one place prevents lifecycle env overrides from
// drifting between boot, token-economy on-demand enablement, and manual MCP
// reconnects.
func MCPSpec(bin, root string) plugin.Spec {
return plugin.Spec{
Name: "codegraph",
StripRawPrefix: "codegraph_",
Command: bin,
Args: []string{"serve", "--mcp"},
Env: map[string]string{
DaemonIdleTimeoutEnv: ReasonixDaemonIdleTimeoutMS,
},
Dir: root,
ReadOnlyToolNames: ReadOnlyToolNames(),
// The daemon walks and indexes the whole tree; below-normal priority keeps
// it from starving the user's machine (#3797).
LowPriority: true,
}
}
-22
View File
@@ -1,22 +0,0 @@
package codegraph
import "testing"
func TestMCPSpecSetsReasonixDaemonIdleTimeout(t *testing.T) {
spec := MCPSpec("/tmp/codegraph", "/tmp/project")
if spec.Name != "codegraph" {
t.Fatalf("Name = %q, want codegraph", spec.Name)
}
if spec.StripRawPrefix != "codegraph_" {
t.Fatalf("StripRawPrefix = %q, want codegraph_", spec.StripRawPrefix)
}
if got := spec.Env[DaemonIdleTimeoutEnv]; got != ReasonixDaemonIdleTimeoutMS {
t.Fatalf("%s = %q, want %q", DaemonIdleTimeoutEnv, got, ReasonixDaemonIdleTimeoutMS)
}
if !spec.LowPriority {
t.Fatal("LowPriority = false, want true")
}
if !spec.ReadOnlyToolNames["codegraph_search"] {
t.Fatal("ReadOnlyToolNames missing codegraph_search")
}
}
-33
View File
@@ -1,33 +0,0 @@
package codegraph
import (
"strings"
"testing"
)
// TestSteerTextNamesMatchRegisteredTools keeps the system-prompt steering names
// in lockstep with the daemon's tools. A model-visible name is
// "mcp__codegraph__" + the raw tool name with the spec's StripRawPrefix
// ("codegraph_") removed — the mirror of plugin.ToolPrefix + Spec.StripRawPrefix
// applied in boot.go. If a tool is added/renamed or the steering text drifts,
// this fails instead of silently telling the model to call a name it can't use.
func TestSteerTextNamesMatchRegisteredTools(t *testing.T) {
const prefix = "mcp__codegraph__"
want := make(map[string]bool, len(ReadOnlyToolNames()))
for raw := range ReadOnlyToolNames() {
want[prefix+strings.TrimPrefix(raw, "codegraph_")] = true
}
for name := range want {
if !strings.Contains(SteerText, name) {
t.Errorf("SteerText is missing %q — the model won't know this tool exists", name)
}
}
for _, field := range strings.Fields(SteerText) {
if strings.HasPrefix(field, prefix) && !want[field] {
t.Errorf("SteerText names %q, which is not a registered codegraph tool", field)
}
}
}
-127
View File
@@ -1,127 +0,0 @@
package codegraph
import (
"archive/tar"
"bytes"
"compress/gzip"
"os"
"path/filepath"
"strings"
"testing"
)
// tarGzWithSymlink builds a one-entry tar.gz holding a symlink named link -> dest.
func tarGzWithSymlink(link, dest string) []byte {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
_ = tw.WriteHeader(&tar.Header{Name: link, Typeflag: tar.TypeSymlink, Linkname: dest, Mode: 0o777})
tw.Close()
gz.Close()
return buf.Bytes()
}
func tarGzWithExistingSymlinkTarget(link, dest string) []byte {
cleanDest := filepath.Clean(dest)
return tarGz(
&tar.Header{Name: cleanDest, Typeflag: tar.TypeReg, Mode: 0o644},
&tar.Header{Name: link, Typeflag: tar.TypeSymlink, Linkname: dest, Mode: 0o777},
)
}
// TestExtractRejectsEscapingSymlink proves a symlink pointing outside the
// extraction dir is refused — otherwise a later entry written through it escapes
// (tar-slip via symlink), letting a malicious third-party release plant files
// outside the cache. Relative escapes resolve the same on every host.
func TestExtractRejectsEscapingSymlink(t *testing.T) {
dir := t.TempDir()
for _, dest := range []string{"../escape", "../../etc/cron.d", "sub/../../escape"} {
err := extractTarGz(tarGzWithSymlink("evil", dest), dir)
if err == nil || !strings.Contains(err.Error(), "unsafe symlink") {
t.Errorf("symlink -> %q should be rejected, got %v", dest, err)
}
}
}
// tarGz builds a tar.gz from the given headers in order, each with no body.
func tarGz(hdrs ...*tar.Header) []byte {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, h := range hdrs {
_ = tw.WriteHeader(h)
}
tw.Close()
gz.Close()
return buf.Bytes()
}
// TestExtractRejectsSymlinkRedirectEscape covers the variant a lexical path check
// misses: an in-bounds symlink ("real/up" -> "..", which lands on root) is made a
// parent of a second symlink, so the second link's real location is root/escape
// even though its archive path is real/up/escape. Resolving the parent before
// validating the target is what catches it (go/unsafe-unzip-symlink).
func TestExtractRejectsSymlinkRedirectEscape(t *testing.T) {
dir := t.TempDir()
if err := os.Symlink("probe-target", filepath.Join(dir, "probe-link")); err != nil {
t.Skipf("symlink creation is not available in this environment: %v", err)
}
_ = os.Remove(filepath.Join(dir, "probe-link"))
data := tarGz(
&tar.Header{Name: "real/", Typeflag: tar.TypeDir, Mode: 0o755},
&tar.Header{Name: "real/up", Typeflag: tar.TypeSymlink, Linkname: "..", Mode: 0o777},
&tar.Header{Name: "real/up/escape", Typeflag: tar.TypeSymlink, Linkname: "..", Mode: 0o777},
)
err := extractTarGz(data, dir)
if err == nil || !strings.Contains(err.Error(), "unsafe symlink") {
t.Fatalf("symlink-redirect escape should be rejected, got %v", err)
}
if _, err := os.Lstat(filepath.Dir(dir)); err == nil {
if _, err := os.Lstat(filepath.Join(filepath.Dir(dir), "escape")); err == nil {
t.Fatal("escape symlink was planted outside the extraction dir")
}
}
}
// TestExtractAllowsInternalSymlink keeps legitimate in-bundle symlinks working.
func TestExtractAllowsInternalSymlink(t *testing.T) {
dir := t.TempDir()
probe := filepath.Join(dir, "probe-link")
if err := os.Symlink("probe-target", probe); err != nil {
t.Skipf("symlink creation is not available in this environment: %v", err)
}
_ = os.Remove(probe)
for _, dest := range []string{"bin/codegraph", "./node", "sub/dir/../tool"} {
if err := extractTarGz(tarGzWithExistingSymlinkTarget("link", dest), dir); err != nil {
t.Errorf("internal symlink -> %q should be allowed, got %v", dest, err)
}
}
}
func TestExtractAllowsParentRelativeInternalSymlink(t *testing.T) {
dir := t.TempDir()
probe := filepath.Join(dir, "probe-link")
if err := os.Symlink("probe-target", probe); err != nil {
t.Skipf("symlink creation is not available in this environment: %v", err)
}
_ = os.Remove(probe)
data := tarGz(
&tar.Header{Name: "codegraph-linux-x64/lib/tool", Typeflag: tar.TypeReg, Mode: 0o755},
&tar.Header{Name: "codegraph-linux-x64/bin/tool", Typeflag: tar.TypeSymlink, Linkname: "../lib/tool", Mode: 0o777},
)
if err := extractTarGz(data, dir); err != nil {
t.Fatalf("parent-relative internal symlink should be allowed: %v", err)
}
link := filepath.Join(dir, "codegraph-linux-x64", "bin", "tool")
if got, err := os.Readlink(link); err != nil || filepath.ToSlash(got) != "../lib/tool" {
t.Fatalf("symlink target = %q, %v; want ../lib/tool", got, err)
}
}
func TestExtractRejectsDanglingSymlink(t *testing.T) {
dir := t.TempDir()
err := extractTarGz(tarGzWithSymlink("link", "missing-target"), dir)
if err == nil || !strings.Contains(err.Error(), "unsafe symlink") {
t.Fatalf("dangling symlink should be rejected, got %v", err)
}
}
+18 -160
View File
@@ -13,7 +13,6 @@ import (
"regexp"
"runtime"
"strings"
"time"
"github.com/BurntSushi/toml"
@@ -40,26 +39,23 @@ func SkillNameKey(name string) string {
// Config is Reasonix's runtime configuration.
type Config struct {
ConfigVersion int `toml:"config_version"`
DefaultModel string `toml:"default_model"`
Language string `toml:"language"` // ui/model language tag (e.g. "zh"); empty = auto-detect from $LANG / $REASONIX_LANG
UI UIConfig `toml:"ui"`
Desktop DesktopConfig `toml:"desktop"`
Notifications NotificationsConfig `toml:"notifications"`
Agent AgentConfig `toml:"agent"`
Providers []ProviderEntry `toml:"providers"`
Tools ToolsConfig `toml:"tools"`
Permissions PermissionsConfig `toml:"permissions"`
Sandbox SandboxConfig `toml:"sandbox"`
Network NetworkConfig `toml:"network"`
Plugins []PluginEntry `toml:"plugins"`
Skills SkillsConfig `toml:"skills"`
Codegraph CodegraphConfig `toml:"codegraph"`
BuiltInMCP BuiltInMCPConfig `toml:"builtin_mcp"`
BuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:"builtin_mcp_updates"`
Statusline StatuslineConfig `toml:"statusline"`
LSP LSPConfig `toml:"lsp"`
Bot BotConfig `toml:"bot"`
ConfigVersion int `toml:"config_version"`
DefaultModel string `toml:"default_model"`
Language string `toml:"language"` // ui/model language tag (e.g. "zh"); empty = auto-detect from $LANG / $REASONIX_LANG
UI UIConfig `toml:"ui"`
Desktop DesktopConfig `toml:"desktop"`
Notifications NotificationsConfig `toml:"notifications"`
Agent AgentConfig `toml:"agent"`
Providers []ProviderEntry `toml:"providers"`
Tools ToolsConfig `toml:"tools"`
Permissions PermissionsConfig `toml:"permissions"`
Sandbox SandboxConfig `toml:"sandbox"`
Network NetworkConfig `toml:"network"`
Plugins []PluginEntry `toml:"plugins"`
Skills SkillsConfig `toml:"skills"`
Statusline StatuslineConfig `toml:"statusline"`
LSP LSPConfig `toml:"lsp"`
Bot BotConfig `toml:"bot"`
providerSources map[string]providerSourceScope
}
@@ -401,121 +397,6 @@ type StatuslineConfig struct {
Command string `toml:"command"`
}
// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph
// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*
// search / context / explore / trace / node tools. Enabled defaults to true so
// upgrades keep it for existing configs; first-run scaffolds write enabled =
// false so only brand-new users start without it. AutoInstall (default true)
// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is
// enabled but missing; set false to require an explicit `reasonix codegraph
// install` (e.g. for air-gapped or headless runs). Path overrides binary
// resolution; empty resolves the cache, then a `codegraph` on PATH, then a
// bundle beside the executable. CodeGraph always starts in the background when
// enabled; legacy tier values are ignored and removed during config load.
type CodegraphConfig struct {
Enabled bool `toml:"enabled"`
AutoInstall bool `toml:"auto_install"`
Path string `toml:"path"`
Tier string `toml:"tier"`
}
func (c CodegraphConfig) ShouldAutoStart() bool {
return c.Enabled
}
func (c CodegraphConfig) ResolvedTier() string {
return "background"
}
// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user
// server definition. They are off by default and become provider-visible only
// after the user enables them.
type BuiltInMCPConfig struct {
TimeEnabled bool `toml:"time_enabled"`
Context7Enabled bool `toml:"context7_enabled"`
}
func (c BuiltInMCPConfig) Enabled(name string) bool {
switch name {
case "time":
return c.TimeEnabled
case "context7":
return c.Context7Enabled
default:
return false
}
}
func (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {
switch name {
case "time":
c.TimeEnabled = enabled
return true
case "context7":
c.Context7Enabled = enabled
return true
default:
return false
}
}
func (c BuiltInMCPConfig) EnabledNames() []string {
var out []string
if c.TimeEnabled {
out = append(out, "time")
}
if c.Context7Enabled {
out = append(out, "context7")
}
return out
}
const (
BuiltInMCPUpdateModeOff = "off"
BuiltInMCPUpdateModeNotify = "notify"
BuiltInMCPUpdateModeDownload = "download"
BuiltInMCPUpdateModeAutoNextSession = "auto_next_session"
defaultBuiltInMCPUpdateInterval = 24 * time.Hour
)
// BuiltInMCPUpdatesConfig controls background update checks for Reasonix-owned
// built-in MCP runtimes. The default is notify-only so startup never silently
// changes provider-visible MCP tool schemas.
type BuiltInMCPUpdatesConfig struct {
Mode string `toml:"mode"`
CheckInterval string `toml:"check_interval"`
}
func (c BuiltInMCPUpdatesConfig) ResolvedMode() string {
switch strings.ToLower(strings.TrimSpace(c.Mode)) {
case BuiltInMCPUpdateModeOff:
return BuiltInMCPUpdateModeOff
case BuiltInMCPUpdateModeDownload:
return BuiltInMCPUpdateModeDownload
case BuiltInMCPUpdateModeAutoNextSession:
return BuiltInMCPUpdateModeAutoNextSession
default:
return BuiltInMCPUpdateModeNotify
}
}
func (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {
raw := strings.TrimSpace(c.CheckInterval)
if raw == "" {
return defaultBuiltInMCPUpdateInterval
}
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
return defaultBuiltInMCPUpdateInterval
}
return d
}
func (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {
return c.CheckIntervalDuration().String()
}
// BotConfig 控制多渠道 IM bot 消息网关。
type BotConfig struct {
Enabled bool `toml:"enabled"`
@@ -1275,18 +1156,6 @@ func Default() *Config {
// so an absent [sandbox] in a user's file keeps egress (zero value would
// wrongly deny it).
Sandbox: SandboxConfig{Bash: "enforce", Network: true},
// CodeGraph code-intelligence defaults on so existing configs (which never
// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds
// write enabled = false instead, so only brand-new users start without it.
// AutoInstall fetches the runtime into the cache when enabled and missing.
Codegraph: CodegraphConfig{Enabled: true, AutoInstall: true},
// Time is dependency-free and bundled, so expose it by default. Context7
// can invoke a package runner and remains opt-in.
BuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},
BuiltInMCPUpdates: BuiltInMCPUpdatesConfig{
Mode: BuiltInMCPUpdateModeNotify,
CheckInterval: defaultBuiltInMCPUpdateInterval.String(),
},
// LSP tools on by default, but dormant until a language server is on PATH;
// a missing server yields an install hint rather than an error.
LSP: LSPConfig{Enabled: true},
@@ -1577,10 +1446,8 @@ func LoadForRoot(root string) (*Config, error) {
tomlSources = append(tomlSources, uc)
}
tomlSources = append(tomlSources, projectTOML)
sawConfigFile := false
for _, path := range tomlSources {
if _, err := os.Stat(path); err == nil {
sawConfigFile = true
if err := migrateLegacyMCPTiersFile(path); err != nil {
slog.Warn("config: legacy mcp tier migration failed", "path", path, "err", err)
}
@@ -1636,12 +1503,6 @@ func LoadForRoot(root string) (*Config, error) {
backfillDeepSeekOfficialPrices(cfg)
normalizeEffortConfig(cfg)
backfillDeepSeekPro(cfg)
// First run (no config file anywhere): keep CodeGraph off until the user opts
// in. An existing config — even one without a [codegraph] section — keeps the
// built-in default (on), so an upgrade never silently drops code intelligence.
if !sawConfigFile {
cfg.Codegraph.Enabled = false
}
return cfg, nil
}
@@ -1894,7 +1755,6 @@ func normalizeLegacyMCPTiers(c *Config) {
if c == nil {
return
}
c.Codegraph.Tier = ""
for i := range c.Plugins {
c.Plugins[i].Tier = ""
}
@@ -1925,7 +1785,7 @@ func stripLegacyMCPTierLines(raw string) (string, bool) {
if header := tomlSectionHeader(line); header != "" {
section = header
}
if (section == "codegraph" || section == "plugins") && isTOMLKeyAssignment(line, "tier") {
if section == "plugins" && isTOMLKeyAssignment(line, "tier") {
changed = true
continue
}
@@ -1943,8 +1803,6 @@ func tomlSectionHeader(line string) string {
trimmed = strings.TrimSpace(trimmed[:i])
}
switch trimmed {
case "[codegraph]":
return "codegraph"
case "[[plugins]]":
return "plugins"
default:
-65
View File
@@ -6,7 +6,6 @@ import (
"reflect"
"strings"
"testing"
"time"
"github.com/BurntSushi/toml"
)
@@ -788,70 +787,6 @@ func TestAutoStartPlugins(t *testing.T) {
}
}
func TestCodegraphDefaultEnabledForUpgrades(t *testing.T) {
c := Default()
if !c.Codegraph.Enabled {
t.Fatal("default codegraph enabled = false; existing configs without a [codegraph] section would lose it on upgrade")
}
if !c.Codegraph.AutoInstall {
t.Fatal("default codegraph auto_install = false, want true")
}
if c.Codegraph.Tier != "" {
t.Fatalf("default codegraph tier = %q, want unset (background by default)", c.Codegraph.Tier)
}
}
func TestBuiltInMCPDefaultsEnableOnlyTime(t *testing.T) {
c := Default()
if !c.BuiltInMCP.TimeEnabled || c.BuiltInMCP.Context7Enabled {
t.Fatalf("built-in MCP defaults = %+v, want time enabled and context7 disabled", c.BuiltInMCP)
}
}
func TestBuiltInMCPUpdateDefaultsAreNotifyOnly(t *testing.T) {
c := Default()
if got := c.BuiltInMCPUpdates.ResolvedMode(); got != BuiltInMCPUpdateModeNotify {
t.Fatalf("builtin MCP update mode = %q, want notify", got)
}
if got := c.BuiltInMCPUpdates.CheckIntervalDuration(); got != 24*time.Hour {
t.Fatalf("builtin MCP update interval = %v, want 24h", got)
}
invalid := BuiltInMCPUpdatesConfig{Mode: "surprise", CheckInterval: "-1s"}
if got := invalid.ResolvedMode(); got != BuiltInMCPUpdateModeNotify {
t.Fatalf("invalid mode resolved to %q, want notify", got)
}
if got := invalid.CheckIntervalDuration(); got != 24*time.Hour {
t.Fatalf("invalid interval resolved to %v, want 24h", got)
}
}
func TestLoadForEditPreservesCodegraphWithoutSection(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "reasonix.toml")
if err := os.WriteFile(path, []byte("default_model = \"x\"\n"), 0o644); err != nil {
t.Fatal(err)
}
if c := LoadForEdit(path); !c.Codegraph.Enabled {
t.Fatal("a config omitting [codegraph] disabled codegraph; an upgrade must keep it on")
}
}
func TestLoadFirstRunDisablesCodegraph(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("AppData", t.TempDir())
t.Chdir(t.TempDir())
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.Codegraph.Enabled {
t.Fatal("first run (no config file anywhere) left codegraph enabled; new users should start without it")
}
}
func TestPluginResolvedTierDefaultsToBackground(t *testing.T) {
for _, tc := range []struct {
name string
+1 -8
View File
@@ -42,10 +42,6 @@ func TestLoadForEditMigratesLegacyMCPTiers(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "reasonix.toml")
body := `
[codegraph]
enabled = true
tier = "eager"
[[plugins]]
name = "playwright"
command = "npx"
@@ -62,9 +58,6 @@ model = "m"
}
cfg := LoadForEdit(path)
if cfg.Codegraph.Tier != "" {
t.Fatalf("codegraph tier = %q, want migrated empty", cfg.Codegraph.Tier)
}
if len(cfg.Plugins) != 1 || cfg.Plugins[0].Tier != "" {
t.Fatalf("plugins after migration = %+v, want empty tier", cfg.Plugins)
}
@@ -75,7 +68,7 @@ model = "m"
if strings.Contains(string(updated), "\ntier") {
t.Fatalf("legacy tier lines should be removed from file:\n%s", updated)
}
if !strings.Contains(string(updated), `command = "npx"`) || !strings.Contains(string(updated), `[codegraph]`) {
if !strings.Contains(string(updated), `command = "npx"`) || !strings.Contains(string(updated), `name = "local"`) {
t.Fatalf("migration should preserve ordinary config:\n%s", updated)
}
}
-22
View File
@@ -324,28 +324,6 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
b.WriteString("[tools.background_jobs]\n")
fmt.Fprintf(&b, "stalled_warning_seconds = %d # warn once per background job after this many quiet seconds; 0 disables\n\n", c.BackgroundJobStalledWarningSeconds())
b.WriteString("[codegraph]\n")
fmt.Fprintf(&b, "enabled = %v # built-in MCP server; off by default for first-run sessions\n", c.Codegraph.Enabled)
fmt.Fprintf(&b, "auto_install = %v # fetch the runtime when CodeGraph is enabled but missing\n", c.Codegraph.AutoInstall)
if c.Codegraph.Path != "" {
fmt.Fprintf(&b, "path = %q # optional launcher override\n", c.Codegraph.Path)
} else {
b.WriteString("# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n")
}
b.WriteString("\n")
b.WriteString("[builtin_mcp]\n")
fmt.Fprintf(&b, "time_enabled = %v # built-in Time MCP; off until manually enabled\n", c.BuiltInMCP.TimeEnabled)
fmt.Fprintf(&b, "context7_enabled = %v # built-in Context7 MCP; off until manually enabled\n", c.BuiltInMCP.Context7Enabled)
b.WriteString("\n")
if scope != RenderScopeProject {
b.WriteString("[builtin_mcp_updates]\n")
fmt.Fprintf(&b, "mode = %q # off|notify|download|auto_next_session; auto never hot-swaps active sessions\n", c.BuiltInMCPUpdates.ResolvedMode())
fmt.Fprintf(&b, "check_interval = %q # minimum interval between desktop startup background checks\n", c.BuiltInMCPUpdates.ResolvedCheckInterval())
b.WriteString("\n")
}
renderLSPConfig(&b, c.LSP)
b.WriteString("[skills]\n")
+2 -22
View File
@@ -96,8 +96,6 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
orig.Skills.ExcludedPaths = []string{"~/.agents/skills"}
orig.Skills.DisabledSkills = []string{"review", "explore"}
orig.Skills.MaxDepth = 2
orig.Codegraph = CodegraphConfig{Enabled: true, AutoInstall: false, Path: "/opt/codegraph", Tier: "background"}
orig.BuiltInMCPUpdates = BuiltInMCPUpdatesConfig{Mode: BuiltInMCPUpdateModeDownload, CheckInterval: "12h"}
orig.Bot.ToolApprovalMode = "auto"
orig.Bot.Connections = []BotConnectionConfig{{
ID: "feishu-lark",
@@ -238,24 +236,6 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
if got.Agent.SystemPrompt != orig.Agent.SystemPrompt {
t.Errorf("system_prompt mismatch:\n got %q\nwant %q", got.Agent.SystemPrompt, orig.Agent.SystemPrompt)
}
if !got.Codegraph.Enabled {
t.Error("codegraph.enabled = false, want true")
}
if got.Codegraph.AutoInstall {
t.Error("codegraph.auto_install = true, want false")
}
if got.Codegraph.Path != "/opt/codegraph" {
t.Errorf("codegraph.path = %q, want /opt/codegraph", got.Codegraph.Path)
}
if got.Codegraph.Tier != "" {
t.Errorf("codegraph.tier = %q, want migrated empty", got.Codegraph.Tier)
}
if got.BuiltInMCPUpdates.ResolvedMode() != BuiltInMCPUpdateModeDownload {
t.Errorf("builtin_mcp_updates.mode = %q, want download", got.BuiltInMCPUpdates.ResolvedMode())
}
if got.BuiltInMCPUpdates.ResolvedCheckInterval() != "12h0m0s" {
t.Errorf("builtin_mcp_updates.check_interval = %q, want 12h0m0s", got.BuiltInMCPUpdates.ResolvedCheckInterval())
}
if !got.LSP.Enabled {
t.Error("lsp.enabled = false, want true")
}
@@ -463,14 +443,14 @@ func TestScopedRenderSeparatesUserAndProjectConfig(t *testing.T) {
c.Desktop.CheckUpdates = boolPtr(false)
user := RenderTOMLForScope(c, RenderScopeUser)
for _, want := range []string{"config_version = 3", "[desktop]", `theme = "dark"`, `close_behavior = "background"`, `status_bar_style = "text"`, `check_updates = false`, "[notifications]", "[builtin_mcp_updates]"} {
for _, want := range []string{"config_version = 3", "[desktop]", `theme = "dark"`, `close_behavior = "background"`, `status_bar_style = "text"`, `check_updates = false`, "[notifications]"} {
if !strings.Contains(user, want) {
t.Fatalf("user render missing %q:\n%s", want, user)
}
}
project := RenderTOMLForScope(c, RenderScopeProject)
for _, forbidden := range []string{"[desktop]", "[notifications]", "[builtin_mcp_updates]", "close_behavior =", "check_updates ="} {
for _, forbidden := range []string{"[desktop]", "[notifications]", "close_behavior =", "check_updates ="} {
if strings.Contains(project, forbidden) {
t.Fatalf("project render should not contain %q:\n%s", forbidden, project)
}
-112
View File
@@ -1,112 +0,0 @@
package control
import (
"bufio"
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/plugin"
"reasonix/internal/tool"
)
func TestConnectCodegraphMCPServerForRootPinsRootAndStripsPrefix(t *testing.T) {
projectRoot := t.TempDir()
if err := os.MkdirAll(filepath.Join(projectRoot, ".codegraph"), 0o755); err != nil {
t.Fatal(err)
}
wantRoot, err := filepath.EvalSymlinks(projectRoot)
if err != nil {
t.Fatal(err)
}
cwdLog := filepath.Join(t.TempDir(), "cwd")
t.Setenv("REASONIX_TEST_CODEGRAPH_MCP", "1")
t.Setenv("REASONIX_TEST_CODEGRAPH_CWD_FILE", cwdLog)
cfg := config.Default()
cfg.Codegraph.Enabled = true
cfg.Codegraph.Path = os.Args[0]
reg := tool.NewRegistry()
c := New(Options{Host: plugin.NewHost(), Registry: reg})
defer c.Close()
if _, err := c.ConnectCodegraphMCPServerForRoot(cfg, projectRoot); err != nil {
t.Fatalf("ConnectCodegraphMCPServerForRoot: %v", err)
}
if _, ok := reg.Get("mcp__codegraph__context"); !ok {
t.Fatalf("stripped codegraph tool missing; names=%v", reg.Names())
}
if _, ok := reg.Get("mcp__codegraph__codegraph_context"); ok {
t.Fatalf("raw codegraph prefix leaked into visible tool names; names=%v", reg.Names())
}
t.Cleanup(func() {
c.DisconnectMCPServer("codegraph")
})
deadline := time.Now().Add(2 * time.Second)
for {
if data, err := os.ReadFile(cwdLog); err == nil {
got, err := filepath.EvalSymlinks(string(bytes.TrimSpace(data)))
if err != nil {
t.Fatal(err)
}
if got != wantRoot {
t.Fatalf("codegraph cwd = %q, want %q", got, wantRoot)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("helper did not record cwd at %s", cwdLog)
}
time.Sleep(10 * time.Millisecond)
}
}
func runCodegraphMCPHelper() {
if path := os.Getenv("REASONIX_TEST_CODEGRAPH_CWD_FILE"); path != "" {
if cwd, err := os.Getwd(); err == nil {
_ = os.WriteFile(path, []byte(cwd+"\n"), 0o644)
}
}
in := bufio.NewReader(os.Stdin)
for {
line, err := in.ReadBytes('\n')
if err != nil {
return
}
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var req struct {
ID *int `json:"id"`
Method string `json:"method"`
}
if err := json.Unmarshal(line, &req); err != nil || req.ID == nil {
continue
}
var result any
switch req.Method {
case "initialize":
result = map[string]any{
"protocolVersion": "2024-11-05",
"serverInfo": map[string]any{"name": "codegraph", "version": "test"},
"capabilities": map[string]any{},
}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "codegraph_context",
"description": "context",
"inputSchema": map[string]any{"type": "object"},
}}}
default:
result = map[string]any{}
}
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
b, _ := json.Marshal(resp)
os.Stdout.Write(append(b, '\n'))
}
}
-164
View File
@@ -1,164 +0,0 @@
package control
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"
"reasonix/internal/codegraph"
"reasonix/internal/plugin"
)
func TestConnectConfiguredCodegraphSetsShortDaemonIdleTimeout(t *testing.T) {
isolateControlConfigHome(t)
dir := t.TempDir()
t.Chdir(dir)
launcher := writeControlCodegraphHelper(t, dir)
envOut := filepath.Join(dir, "codegraph-idle-env")
t.Setenv("REASONIX_CODEGRAPH_HELPER_ENV_OUT", envOut)
writeControlFile(t, dir, "reasonix.toml", `
[codegraph]
enabled = true
path = "`+escapeTOMLPath(launcher)+`"
`)
ctrl := New(Options{Host: plugin.NewHost()})
defer ctrl.Close()
if _, err := ctrl.ConnectConfiguredMCPServer("codegraph"); err != nil {
t.Fatalf("ConnectConfiguredMCPServer(codegraph): %v", err)
}
got, err := os.ReadFile(envOut)
if err != nil {
t.Fatalf("read codegraph idle timeout env: %v", err)
}
if string(got) != codegraph.ReasonixDaemonIdleTimeoutMS {
t.Fatalf("%s = %q; want %q", codegraph.DaemonIdleTimeoutEnv, got, codegraph.ReasonixDaemonIdleTimeoutMS)
}
if !ctrl.DisconnectMCPServer("codegraph") {
t.Fatal("DisconnectMCPServer(codegraph) returned false")
}
time.Sleep(100 * time.Millisecond)
}
func isolateControlConfigHome(t *testing.T) {
t.Helper()
home := t.TempDir()
xdg := filepath.Join(home, ".config")
appData := filepath.Join(home, "AppData")
if err := os.MkdirAll(xdg, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(appData, 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv("AppData", appData)
}
func writeControlCodegraphHelper(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "codegraph-helper")
if runtime.GOOS == "windows" {
path += ".exe"
}
src := filepath.Join(dir, "codegraph-helper.go")
if err := os.WriteFile(src, []byte(`package main
import (
"bufio"
"bytes"
"encoding/json"
"os"
"path/filepath"
)
func main() {
if len(os.Args) >= 3 && os.Args[1] == "init" {
_ = os.MkdirAll(filepath.Join(os.Args[2], ".codegraph"), 0o755)
return
}
if len(os.Args) >= 2 && os.Args[1] == "serve" {
if out := os.Getenv("REASONIX_CODEGRAPH_HELPER_ENV_OUT"); out != "" {
_ = os.WriteFile(out, []byte(os.Getenv("CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS")), 0o644)
}
}
in := bufio.NewReader(os.Stdin)
for {
line, err := in.ReadBytes('\n')
if err != nil {
return
}
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var req struct {
ID *int `+"`json:\"id\"`"+`
Method string `+"`json:\"method\"`"+`
Params json.RawMessage `+"`json:\"params\"`"+`
}
if err := json.Unmarshal(line, &req); err != nil || req.ID == nil {
continue
}
var result any
switch req.Method {
case "initialize":
result = map[string]any{
"protocolVersion": "2024-11-05",
"serverInfo": map[string]any{"name": "codegraph", "version": "0"},
"capabilities": map[string]any{},
}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "search",
"description": "Search symbols.",
"inputSchema": map[string]any{"type": "object"},
}}}
}
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
b, _ := json.Marshal(resp)
_, _ = os.Stdout.Write(append(b, '\n'))
}
}
`), 0o644); err != nil {
t.Fatal(err)
}
cmd := exec.CommandContext(context.Background(), "go", "build", "-o", path, src)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build codegraph helper: %v\n%s", err, out)
}
return path
}
func writeControlFile(t *testing.T, dir, name, body string) {
t.Helper()
path := filepath.Join(dir, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func escapeTOMLPath(path string) string {
escaped := ""
for _, r := range path {
if r == '\\' || r == '"' {
escaped += "\\"
}
escaped += string(r)
}
return escaped
}
+2 -57
View File
@@ -31,9 +31,7 @@ import (
"reasonix/internal/agent"
"reasonix/internal/billing"
"reasonix/internal/builtinmcp"
"reasonix/internal/checkpoint"
"reasonix/internal/codegraph"
"reasonix/internal/command"
"reasonix/internal/config"
"reasonix/internal/diff"
@@ -2369,7 +2367,7 @@ func (c *Controller) ConnectMCPServer(e config.PluginEntry) (int, error) {
func (c *Controller) connectMCPServer(e config.PluginEntry) (int, error) {
exp := e.ExpandedPlugin()
return c.connectMCPSpec(plugin.Spec{
return c.connectMCPSpec(plugin.ApplyKnownReadOnlyOverrides(plugin.Spec{
Name: exp.Name,
Type: exp.Type,
Command: exp.Command,
@@ -2377,7 +2375,7 @@ func (c *Controller) connectMCPServer(e config.PluginEntry) (int, error) {
Env: exp.Env,
URL: exp.URL,
Headers: exp.Headers,
})
}))
}
func (c *Controller) connectMCPSpec(s plugin.Spec) (int, error) {
@@ -2488,62 +2486,9 @@ func (c *Controller) ConnectConfiguredMCPServer(name string) (int, error) {
return c.connectMCPServer(p)
}
}
if name == "codegraph" {
return c.connectCodegraphMCPServer(cfg)
}
if p, ok := builtinmcp.Entry(name); ok {
return c.connectMCPServer(p)
}
return 0, fmt.Errorf("no configured MCP server named %q", name)
}
// ConnectCodegraphMCPServer connects the built-in CodeGraph server using an
// already-resolved config. Desktop uses this after saving user-level settings so
// a stale project config cannot override the just-applied choice.
func (c *Controller) ConnectCodegraphMCPServer(cfg *config.Config) (int, error) {
cwd, err := os.Getwd()
if err != nil {
return 0, err
}
return c.ConnectCodegraphMCPServerForRoot(cfg, cwd)
}
// ConnectCodegraphMCPServerForRoot connects CodeGraph pinned to root. Desktop
// project tabs use this after hot updates so a reconnect keeps the same project
// scope as boot-time CodeGraph startup.
func (c *Controller) ConnectCodegraphMCPServerForRoot(cfg *config.Config, root string) (int, error) {
return c.connectCodegraphMCPServerForRoot(cfg, root)
}
func (c *Controller) connectCodegraphMCPServer(cfg *config.Config) (int, error) {
return c.ConnectCodegraphMCPServer(cfg)
}
func (c *Controller) connectCodegraphMCPServerForRoot(cfg *config.Config, root string) (int, error) {
if !cfg.Codegraph.Enabled {
return 0, fmt.Errorf("codegraph is disabled in config")
}
bin, ok := codegraph.Resolve(cfg.Codegraph.Path)
if !ok {
return 0, fmt.Errorf("codegraph is not installed")
}
root = strings.TrimSpace(root)
if root == "" {
cwd, err := os.Getwd()
if err != nil {
return 0, err
}
root = cwd
}
if !codegraph.IndexableRoot(root) {
return 0, fmt.Errorf("codegraph: refusing to index %q — a filesystem root would index the whole volume", root)
}
if err := codegraph.EnsureInit(c.pluginCtx, bin, root); err != nil {
return 0, fmt.Errorf("codegraph init: %w", err)
}
return c.connectMCPSpec(codegraph.MCPSpec(bin, root))
}
// RemoveMCPServer disconnects a live MCP server — its tools vanish from the next
// turn — and removes it from the config file. It reports whether a live server was
// disconnected; an error only when the name is neither connected nor in config (or
+5
View File
@@ -286,6 +286,11 @@ func TestDisconnectMCPServerRemovesLazyPlaceholder(t *testing.T) {
func TestRemoveMCPServerRemovesUnconnectedLazyPlaceholder(t *testing.T) {
dir := t.TempDir()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData", "Roaming"))
t.Chdir(dir)
if err := os.WriteFile("reasonix.toml", []byte(`
[[plugins]]
-5
View File
@@ -1,16 +1,11 @@
package control
import (
"os"
"testing"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
if os.Getenv("REASONIX_TEST_CODEGRAPH_MCP") == "1" {
runCodegraphMCPHelper()
os.Exit(0)
}
goleak.VerifyTestMain(m)
}
-31
View File
@@ -10,7 +10,6 @@ import (
"strings"
"reasonix/internal/agent"
"reasonix/internal/codegraph"
"reasonix/internal/config"
"reasonix/internal/netclient"
"reasonix/internal/sandbox"
@@ -29,7 +28,6 @@ type Report struct {
Config ConfigReport `json:"config"`
Providers []ProviderReport `json:"providers"`
Plugins []PluginReport `json:"plugins,omitempty"`
Codegraph CodegraphReport `json:"codegraph"`
LSP LSPReport `json:"lsp"`
Sessions SessionsReport `json:"sessions"`
Sandbox SandboxReport `json:"sandbox"`
@@ -63,15 +61,6 @@ type PluginReport struct {
Target string `json:"target,omitempty"`
}
type CodegraphReport struct {
Enabled bool `json:"enabled"`
AutoInstall bool `json:"auto_install"`
Version string `json:"version"`
CacheDir string `json:"cache_dir,omitempty"`
Resolved bool `json:"resolved"`
Path string `json:"path,omitempty"`
}
type LSPReport struct {
Enabled bool `json:"enabled"`
Servers int `json:"servers"`
@@ -129,12 +118,6 @@ func Collect(opts Options) Report {
UserPath: redactHome(config.UserConfigPath()),
DefaultModel: cfg.DefaultModel,
},
Codegraph: CodegraphReport{
Enabled: cfg.Codegraph.Enabled,
AutoInstall: cfg.Codegraph.AutoInstall,
Version: codegraph.Version,
CacheDir: redactHome(codegraph.CacheDir()),
},
LSP: LSPReport{
Enabled: cfg.LSP.Enabled,
Servers: len(cfg.LSP.Servers),
@@ -160,10 +143,6 @@ func Collect(opts Options) Report {
Warnings: warnings,
}
report.Sessions.Dir = redactHome(report.Sessions.Dir)
if p, ok := codegraph.Resolve(cfg.Codegraph.Path); ok {
report.Codegraph.Resolved = true
report.Codegraph.Path = redactHome(p)
}
for i := range cfg.Providers {
p := cfg.Providers[i]
models := p.ModelList()
@@ -233,16 +212,6 @@ func RenderText(r Report) string {
}
}
resolved := "missing"
if r.Codegraph.Resolved {
resolved = "resolved"
}
fmt.Fprintf(&b, "\ncodegraph\n")
fmt.Fprintf(&b, " enabled %v\n", r.Codegraph.Enabled)
fmt.Fprintf(&b, " auto_install %v\n", r.Codegraph.AutoInstall)
fmt.Fprintf(&b, " version %s\n", r.Codegraph.Version)
fmt.Fprintf(&b, " resolved %s\n", resolved)
fmt.Fprintf(&b, "\nlsp\n")
fmt.Fprintf(&b, " enabled %v\n", r.LSP.Enabled)
fmt.Fprintf(&b, " servers %d configured overrides\n", r.LSP.Servers)
-1
View File
@@ -9,7 +9,6 @@ import (
var skipEntryNames = map[string]bool{
".codex": true,
".codegraph": true,
".DS_Store": true,
".git": true,
".npm": true,
+6 -6
View File
@@ -87,12 +87,12 @@ func (t *installSourceTool) applySkillRoot(req request, act *action) error {
// copyDir uses O_EXCL so any race that slips through the Lstat check still
// loses atomically.
func (t *installSourceTool) applyCopySkill(req request, act *action) error {
canonical, err := t.skillCanonicalPath(act.skill.Name, req.Scope)
canonical, err := t.skillCanonicalPath(act.skill.Name, act.Scope)
if err != nil {
return err
}
targetDir := filepath.Dir(canonical)
conflicts, err := t.skillConflictTargets(act.skill.Name, req.Scope)
conflicts, err := t.skillConflictTargets(act.skill.Name, act.Scope)
if err != nil {
return err
}
@@ -115,7 +115,7 @@ func (t *installSourceTool) applyCopySkill(req request, act *action) error {
}
act.Target = canonical
act.CanonicalPath = canonical
return t.verifySkill(req.Scope, act.skill.Name, act)
return t.verifySkill(act.Scope, act.skill.Name, act)
}
// applyLinkSkill creates a symlink in the skills dir pointing at the source.
@@ -123,7 +123,7 @@ func (t *installSourceTool) applyCopySkill(req request, act *action) error {
// plan was approved: a link-mode skill should not become a backdoor to arbitrary
// host files.
func (t *installSourceTool) applyLinkSkill(req request, act *action) error {
canonical, err := t.skillCanonicalPath(act.skill.Name, req.Scope)
canonical, err := t.skillCanonicalPath(act.skill.Name, act.Scope)
if err != nil {
return err
}
@@ -131,7 +131,7 @@ func (t *installSourceTool) applyLinkSkill(req request, act *action) error {
if act.skill.IsDir {
target = filepath.Dir(canonical)
}
conflicts, err := t.skillConflictTargets(act.skill.Name, req.Scope)
conflicts, err := t.skillConflictTargets(act.skill.Name, act.Scope)
if err != nil {
return err
}
@@ -153,7 +153,7 @@ func (t *installSourceTool) applyLinkSkill(req request, act *action) error {
}
act.Target = target
act.CanonicalPath = canonical
return t.verifySkill(req.Scope, act.skill.Name, act)
return t.verifySkill(act.Scope, act.skill.Name, act)
}
// isLinkTargetSafe reports whether a symlink source is allowed. The link
+61 -9
View File
@@ -119,7 +119,7 @@ func (*installSourceTool) Schema() json.RawMessage {
"source":{"type":"string","description":"URL, local file/folder path, .mcp.json path, or package name to install from. Ignored when op=uninstall (use name instead)."},
"kind":{"type":"string","enum":["auto","skill","mcp"],"description":"Capability kind. Defaults to auto."},
"apply":{"type":"boolean","description":"false (default) only returns an install plan; true performs the planned writes/connects. Ignored for op=uninstall."},
"scope":{"type":"string","enum":["project","global"],"description":"Where to persist config or copy skills. Defaults to project when a workspace exists, otherwise global."},
"scope":{"type":"string","enum":["project","global"],"description":"Where to persist config or copy skills. MCP installs default to global so every project can use them; project-root .mcp.json imports default to project; skills default to project when a workspace exists, otherwise global."},
"mode":{"type":"string","enum":["auto","copy","link","register"],"description":"Skill install mode. auto registers multi-skill roots and copies single skills into the canonical <skill-name>/SKILL.md layout; copy copies skill files/folders; link creates symlinks; register adds a skill root to [skills].paths."},
"name":{"type":"string","description":"Optional override for the installed MCP server or single skill name. Required for op=uninstall when removing by name."},
"transport":{"type":"string","enum":["auto","stdio","http","sse"],"description":"MCP transport override. URL sources default to http unless --sse-like; package sources default to stdio."},
@@ -127,7 +127,7 @@ func (*installSourceTool) Schema() json.RawMessage {
"args":{"type":"array","items":{"type":"string"},"description":"Optional stdio MCP args override."},
"env":{"type":"object","additionalProperties":{"type":"string"},"description":"Environment variables for stdio MCP servers."},
"headers":{"type":"object","additionalProperties":{"type":"string"},"description":"HTTP headers for remote MCP servers. Prefer ${VAR} placeholders for secrets."},
"tier":{"type":"string","enum":["lazy","background","eager"],"description":"Persisted MCP startup tier. Defaults to lazy."},
"tier":{"type":"string","enum":["lazy","background","eager"],"description":"Persisted MCP startup tier. Defaults to background."},
"replace":{"type":"boolean","description":"Allow replacing an existing MCP config entry with the same name. Skills still refuse to overwrite existing files."},
"strict":{"type":"boolean","description":"Skill install strictness. true (default) requires name+description frontmatter; false copies the file as-is (use only for files you trust)."},
"planId":{"type":"string","description":"Optional. Echoed from a previous planned response to confirm the host is approving the same plan."}
@@ -158,7 +158,7 @@ func (t *installSourceTool) Execute(ctx context.Context, raw json.RawMessage) (s
return "", errors.New("install_source: op=uninstall requires a non-empty name")
}
req.Kind = normalizeKind(req.Kind)
req.Scope = t.normalizeScope(req.Scope)
req.Scope, req.scopeExplicit = t.normalizeScope(req.Scope)
req.Mode = normalizeMode(req.Mode)
req.Transport = normalizeTransport(req.Transport)
if norm, ok := normalizeTier(req.Tier); ok {
@@ -195,6 +195,7 @@ func (t *installSourceTool) Execute(ctx context.Context, raw json.RawMessage) (s
for i := range actions {
actions[i].Status = "planned"
}
scope := commonActionScope(actions)
out := response{
OK: true,
Status: "planned",
@@ -203,7 +204,7 @@ func (t *installSourceTool) Execute(ctx context.Context, raw json.RawMessage) (s
Source: req.Source,
Kind: summarizeKind(actions),
Kinds: kindCounts(actions),
Scope: req.Scope,
Scope: scope,
Mode: req.Mode,
PlanID: planID,
Actions: publicActions(actions),
@@ -278,7 +279,7 @@ func (t *installSourceTool) executeApply(ctx context.Context, req request, actio
Source: req.Source,
Kind: summarizeKind(actions),
Kinds: kindCounts(actions),
Scope: req.Scope,
Scope: commonActionScope(actions),
Mode: req.Mode,
PlanID: planID,
Actions: publicActions(actions),
@@ -294,6 +295,9 @@ func (t *installSourceTool) executeApply(ctx context.Context, req request, actio
// of the install they authorized.
func (t *installSourceTool) executeUninstall(req request) string {
scope := req.Scope
if scope == "" {
scope = "global"
}
actions := []action{}
cfgPath := t.configPath(scope)
cfg := config.LoadForEdit(cfgPath)
@@ -461,16 +465,64 @@ func (t *installSourceTool) configPath(scope string) string {
return filepath.Join(t.root, "reasonix.toml")
}
func (t *installSourceTool) normalizeScope(scope string) string {
func (t *installSourceTool) normalizeScope(scope string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(scope)) {
case "project":
return "project", true
case "global":
return "global"
return "global", true
default:
if strings.TrimSpace(t.root) != "" {
return "", false
}
}
func (t *installSourceTool) installScope(req request, kind, source string) string {
if req.scopeExplicit && req.Scope != "" {
return req.Scope
}
if kind == "mcp" {
if t.isProjectMCPJSONSource(source) {
return "project"
}
return "global"
}
if strings.TrimSpace(t.root) != "" {
return "project"
}
return "global"
}
func (t *installSourceTool) isProjectMCPJSONSource(source string) bool {
if isURL(source) || !strings.EqualFold(filepath.Base(source), ".mcp.json") {
return false
}
root := strings.TrimSpace(t.root)
if root == "" {
return false
}
sourceAbs, sourceErr := filepath.Abs(source)
rootAbs, rootErr := filepath.Abs(root)
if sourceErr != nil || rootErr != nil {
return false
}
rel, err := filepath.Rel(filepath.Clean(rootAbs), filepath.Clean(sourceAbs))
if err != nil {
return false
}
return rel == ".mcp.json" || (!strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..")
}
func commonActionScope(actions []action) string {
if len(actions) == 0 {
return ""
}
scope := actions[0].Scope
for _, action := range actions[1:] {
if action.Scope != scope {
return "mixed"
}
}
return scope
}
func (t *installSourceTool) resolvePath(p string) string {
@@ -519,7 +571,7 @@ func computePlanID(req request, actions []action) string {
Op: req.Op,
Source: req.Source,
Kind: req.Kind,
Scope: req.Scope,
Scope: commonActionScope(actions),
Mode: req.Mode,
Name: req.Name,
Transport: req.Transport,
+141 -3
View File
@@ -19,6 +19,20 @@ import (
"reasonix/internal/tool"
)
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "reasonix-installsource-test-*")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
_ = os.Setenv("HOME", dir)
_ = os.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, ".config"))
_ = os.Setenv("AppData", filepath.Join(dir, "AppData"))
code := m.Run()
_ = os.RemoveAll(dir)
os.Exit(code)
}
// --- shared helpers ---------------------------------------------------------
// execInstall marshals args, calls Execute, and unmarshals the response.
@@ -433,11 +447,41 @@ func TestPlanLocalMCPJSON(t *testing.T) {
if resp.Actions[1].Name != "remote" || resp.Actions[1].Transport != "http" {
t.Fatalf("second action = %+v", resp.Actions[1])
}
for _, action := range resp.Actions {
if action.Scope != "global" || action.ConfigPath != config.UserConfigPath() {
t.Fatalf("local .mcp.json outside project action scope/path = %q %q, want global %q", action.Scope, action.ConfigPath, config.UserConfigPath())
}
}
if resp.Kinds.MCP != 2 {
t.Errorf("Kinds.MCP = %d, want 2", resp.Kinds.MCP)
}
}
func TestPlanProjectMCPJSONDefaultsProject(t *testing.T) {
project := t.TempDir()
home := t.TempDir()
mcpPath := filepath.Join(project, ".mcp.json")
writeFile(t, mcpPath, `{
"mcpServers": {
"projectfs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] }
}
}`)
tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
resp := execInstall(t, tl, map[string]any{
"source": mcpPath,
"kind": "mcp",
})
if !resp.OK || len(resp.Actions) != 1 {
t.Fatalf("response = %+v", resp)
}
wantPath := filepath.Join(project, "reasonix.toml")
if resp.Scope != "project" || resp.Actions[0].Scope != "project" || resp.Actions[0].ConfigPath != wantPath {
t.Fatalf("project .mcp.json scope/path = response %q action %q path %q, want project %q", resp.Scope, resp.Actions[0].Scope, resp.Actions[0].ConfigPath, wantPath)
}
}
func TestPlanMCPJSONUnknownTierProducesWarning(t *testing.T) {
entries, warnings, err := parseMCPJSON([]byte(`{
"mcpServers": {
@@ -461,6 +505,32 @@ func TestPlanMCPJSONUnknownTierProducesWarning(t *testing.T) {
}
}
func TestPlanMCPJSONDefaultTierIsBackground(t *testing.T) {
entries, warnings, err := parseMCPJSON([]byte(`{
"mcpServers": {
"x": { "command": "node" }
}
}`))
if err != nil {
t.Fatalf("parseMCPJSON: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("warnings = %v, want none", warnings)
}
if len(entries) != 1 || entries[0].Tier != "background" {
t.Fatalf("entries = %+v, want default tier background", entries)
}
}
func TestNormalizeTierDefaultBackgroundUnknownLazy(t *testing.T) {
if got, ok := normalizeTier(""); got != "background" || !ok {
t.Fatalf("normalizeTier(empty) = %q, %v; want background, true", got, ok)
}
if got, ok := normalizeTier("absurd"); got != "lazy" || ok {
t.Fatalf("normalizeTier(absurd) = %q, %v; want lazy, false", got, ok)
}
}
func TestPlanMCPJSONSplitsPastedCommandLine(t *testing.T) {
project := t.TempDir()
home := t.TempDir()
@@ -549,6 +619,36 @@ func TestApplyRemoteMCPURLConnectsAndPersists(t *testing.T) {
}
}
func TestApplyRemoteMCPURLDefaultsGlobal(t *testing.T) {
project := t.TempDir()
home := t.TempDir()
stub := &stubConnector{toolCount: 1}
tl := NewTool(Options{
ProjectRoot: project,
HomeDir: home,
ConnectMCP: stub.connector(),
})
resp := execInstall(t, tl, map[string]any{
"source": "https://global.example.com/mcp",
"kind": "mcp",
"apply": true,
"name": "global-default",
})
if !resp.OK || resp.Scope != "global" || resp.Actions[0].ConfigPath != config.UserConfigPath() {
t.Fatalf("response = %+v, want global user config %q", resp, config.UserConfigPath())
}
userCfg := config.LoadForEdit(config.UserConfigPath())
if p, ok := findPlugin(userCfg.Plugins, "global-default"); !ok || p.URL != "https://global.example.com/mcp" {
t.Fatalf("global config plugins = %+v, want global-default", userCfg.Plugins)
}
projectCfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml"))
if _, ok := findPlugin(projectCfg.Plugins, "global-default"); ok {
t.Fatalf("project config should not receive default-global MCP: %+v", projectCfg.Plugins)
}
}
func TestApplyMCPRejectsDuplicateByDefault(t *testing.T) {
project := t.TempDir()
home := t.TempDir()
@@ -569,6 +669,7 @@ func TestApplyMCPRejectsDuplicateByDefault(t *testing.T) {
"kind": "mcp",
"apply": true,
"name": "dup",
"scope": "project",
})
if resp.OK {
@@ -599,6 +700,7 @@ func TestApplyMCPReplaceOverwritesExisting(t *testing.T) {
"apply": true,
"replace": true,
"name": "editable",
"scope": "project",
})
if !resp.OK {
@@ -652,6 +754,7 @@ func TestApplyMCPReplaceDisconnectsLiveServerBeforeConnect(t *testing.T) {
"apply": true,
"replace": true,
"name": "live",
"scope": "project",
})
if !resp.OK {
@@ -689,6 +792,7 @@ func TestApplyMCPRollsBackOnSaveFailure(t *testing.T) {
"kind": "mcp",
"apply": true,
"name": "ghost",
"scope": "project",
})
if resp.OK {
@@ -710,6 +814,7 @@ func TestApplyConnectFailureDoesNotPersist(t *testing.T) {
"kind": "mcp",
"apply": true,
"name": "broken",
"scope": "project",
})
if resp.OK {
@@ -739,6 +844,9 @@ func TestPackageActionUsesNpx(t *testing.T) {
if len(resp.Actions[0].Args) != 2 || resp.Actions[0].Args[0] != "-y" || resp.Actions[0].Args[1] != "@example/mcp-pkg" {
t.Errorf("args = %v, want [-y @example/mcp-pkg]", resp.Actions[0].Args)
}
if resp.Actions[0].Scope != "global" || resp.Actions[0].ConfigPath != config.UserConfigPath() {
t.Errorf("scope/path = %q %q, want global %q", resp.Actions[0].Scope, resp.Actions[0].ConfigPath, config.UserConfigPath())
}
}
func TestPlanURLBlobRewritesToRaw(t *testing.T) {
@@ -762,6 +870,9 @@ func TestPlanURLRemoteEndpointAuto(t *testing.T) {
if resp.Actions[0].Transport != "http" {
t.Errorf("transport = %q, want http", resp.Actions[0].Transport)
}
if resp.Actions[0].Scope != "global" || resp.Actions[0].ConfigPath != config.UserConfigPath() {
t.Errorf("scope/path = %q %q, want global %q", resp.Actions[0].Scope, resp.Actions[0].ConfigPath, config.UserConfigPath())
}
}
func TestPlanURLRemoteMCPHostAuto(t *testing.T) {
@@ -1215,6 +1326,23 @@ func TestComputePlanIDStable(t *testing.T) {
}
}
func TestPlanIDUsesResolvedActionScope(t *testing.T) {
reqOmitted := request{Op: "install", Source: "https://mcp.example.com/mcp", Kind: "mcp"}
reqGlobal := request{Op: "install", Source: "https://mcp.example.com/mcp", Kind: "mcp", Scope: "global", scopeExplicit: true}
actions := []action{{
Kind: "mcp",
Action: "install_mcp_server",
Name: "example",
URL: "https://mcp.example.com/mcp",
Transport: "http",
Scope: "global",
ConfigPath: config.UserConfigPath(),
}}
if got, want := computePlanID(reqOmitted, actions), computePlanID(reqGlobal, actions); got != want {
t.Fatalf("planId with omitted scope = %q, explicit global = %q; want same resolved plan", got, want)
}
}
// --- local executable -------------------------------------------------------
func TestPlanLocalExecutableDetected(t *testing.T) {
@@ -1267,12 +1395,22 @@ func TestApplyLocalExecutableHonorsCommandOverride(t *testing.T) {
if len(stub.connected) != 1 || stub.connected[0].Command != "node" || len(stub.connected[0].Args) != 1 || stub.connected[0].Args[0] != server {
t.Fatalf("connected entry = %+v, want node [%s]", stub.connected, server)
}
cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml"))
if len(cfg.Plugins) != 1 || cfg.Plugins[0].Command != "node" || len(cfg.Plugins[0].Args) != 1 || cfg.Plugins[0].Args[0] != server {
t.Fatalf("persisted plugins = %+v, want node [%s]", cfg.Plugins, server)
cfg := config.LoadForEdit(config.UserConfigPath())
p, ok := findPlugin(cfg.Plugins, "wrapped")
if !ok || p.Command != "node" || len(p.Args) != 1 || p.Args[0] != server {
t.Fatalf("persisted plugins = %+v, want wrapped node [%s]", cfg.Plugins, server)
}
}
func findPlugin(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {
for _, entry := range entries {
if entry.Name == name {
return entry, true
}
}
return config.PluginEntry{}, false
}
func writeLocalExecutable(t *testing.T, dir, name string) string {
t.Helper()
if runtime.GOOS == "windows" {
+3 -2
View File
@@ -15,6 +15,7 @@ import (
// caller decides whether apply=true actually runs cfg.UpsertPlugin +
// SaveTo + connectMCP.
func (t *installSourceTool) mcpEntryAction(req request, e config.PluginEntry, source string) action {
scope := t.installScope(req, "mcp", source)
var normalizedCommand bool
e, normalizedCommand = config.NormalizePluginCommandLine(e)
// Tier comes from the call (req.Tier) or the entry; either way we
@@ -28,8 +29,8 @@ func (t *installSourceTool) mcpEntryAction(req request, e config.PluginEntry, so
Action: "install_mcp_server",
Name: e.Name,
Source: source,
ConfigPath: t.configPath(req.Scope),
Scope: req.Scope,
ConfigPath: t.configPath(scope),
Scope: scope,
Transport: pluginTransport(e),
URL: e.URL,
Command: e.Command,
+3 -1
View File
@@ -264,8 +264,10 @@ func normalizeTier(tier string) (string, bool) {
return "eager", true
case "background":
return "background", true
case "", "lazy":
case "lazy":
return "lazy", true
case "":
return "background", true
default:
return "lazy", false
}
+8 -6
View File
@@ -23,21 +23,22 @@ const (
// skillAction builds the DTO for a single-skill install (copy or link).
func (t *installSourceTool) skillAction(req request, cand skillCandidate, mode string) action {
scope := t.installScope(req, "skill", cand.SourcePath)
actionName := "copy_skill"
if mode == "link" {
actionName = "link_skill"
}
canonical, _ := t.skillCanonicalPath(cand.Name, req.Scope)
root, _ := t.skillInstallRoot(req.Scope)
canonical, _ := t.skillCanonicalPath(cand.Name, scope)
root, _ := t.skillInstallRoot(scope)
a := action{
Kind: "skill",
Action: actionName,
Name: cand.Name,
Source: cand.SourcePath,
Target: canonical,
Scope: req.Scope,
Scope: scope,
Mode: mode,
ConfigPath: t.configPath(req.Scope),
ConfigPath: t.configPath(scope),
Skills: []string{cand.Name},
SkillCount: 1,
Layout: "canonical_dir",
@@ -76,14 +77,15 @@ func skillActionRisk(mode string, cand skillCandidate) (RiskLevel, []string) {
// skillRootAction builds the DTO for registering a whole skill directory.
func (t *installSourceTool) skillRootAction(req request, path string, names []string) action {
scope := t.installScope(req, "skill", path)
return action{
Kind: "skill",
Action: "register_skill_root",
Name: "",
Source: path,
Target: path,
ConfigPath: t.configPath(req.Scope),
Scope: req.Scope,
ConfigPath: t.configPath(scope),
Scope: scope,
Mode: "register",
Skills: names,
SkillCount: len(names),
+2
View File
@@ -28,6 +28,8 @@ type request struct {
// PlanID is echoed back on a confirm-apply call so the host can refuse
// to apply a plan that does not match the one it approved.
PlanID string `json:"planId"`
scopeExplicit bool
}
// response is the JSON shape returned to the model. Status is one of
+51
View File
@@ -0,0 +1,51 @@
package plugin
import "strings"
// ApplyKnownReadOnlyOverrides fills compatibility read-only hints for MCP
// servers whose read surfaces are stable but older runtimes may omit MCP
// annotations. It does not make the server built-in or change startup behavior.
func ApplyKnownReadOnlyOverrides(s Spec) Spec {
if isCodeGraphSpecName(s.Name) {
s.ReadOnlyToolNames = mergeReadOnlyToolNames(s.ReadOnlyToolNames, codeGraphReadOnlyToolNames())
}
return s
}
func isCodeGraphSpecName(name string) bool {
return strings.EqualFold(strings.TrimSpace(name), "codegraph")
}
func mergeReadOnlyToolNames(existing map[string]bool, extra map[string]bool) map[string]bool {
out := make(map[string]bool, len(existing)+len(extra))
for name, ok := range existing {
out[name] = ok
}
for name, ok := range extra {
if ok {
out[name] = true
}
}
return out
}
func codeGraphReadOnlyToolNames() map[string]bool {
base := []string{
"callees",
"callers",
"context",
"explore",
"files",
"impact",
"node",
"search",
"status",
"trace",
}
out := make(map[string]bool, len(base)*2)
for _, name := range base {
out[name] = true
out["codegraph_"+name] = true
}
return out
}
+8 -7
View File
@@ -47,17 +47,18 @@ type Spec struct {
// the terminal so child logs cannot corrupt interactive UIs.
Stderr io.Writer
// ReadOnlyToolNames marks trusted raw MCP tool names as read-only even when
// the server omits annotations.readOnlyHint. It is for first-party adapters
// with known semantics; user-configured plugins should rely on MCP metadata.
// the server omits annotations.readOnlyHint. It is for known compatibility
// overrides where the tool semantics are stable; other user-configured
// plugins should rely on MCP metadata.
ReadOnlyToolNames map[string]bool
// StripRawPrefix, when non-empty, removes this prefix from each MCP tool's
// raw name before namespacing. For example, StripRawPrefix="codegraph_" turns
// "codegraph_context" into "context", yielding "mcp__codegraph__context"
// instead of the redundant "mcp__codegraph__codegraph_context". The original
// raw name is preserved for MCP protocol calls.
// raw name before namespacing. For example, StripRawPrefix="server_" turns
// "server_search" into "search", yielding "mcp__search__search" instead of
// the redundant "mcp__search__server_search". The original raw name is
// preserved for MCP protocol calls.
StripRawPrefix string
// LowPriority runs a stdio subprocess below normal scheduling priority, for
// background indexers (CodeGraph) that must not starve the user's machine.
// background indexers that must not starve the user's machine.
LowPriority bool
}
+14
View File
@@ -95,6 +95,20 @@ func TestSpecReadOnlyToolNamesMarksUnhintedToolsReadOnly(t *testing.T) {
}
}
func TestApplyKnownReadOnlyOverridesMarksCodeGraphReadTools(t *testing.T) {
got := ApplyKnownReadOnlyOverrides(Spec{Name: "codegraph", ReadOnlyToolNames: map[string]bool{"custom": true}})
for _, name := range []string{"custom", "codegraph_context", "codegraph_search", "context", "search"} {
if !got.ReadOnlyToolNames[name] {
t.Fatalf("codegraph read-only override missing %q: %+v", name, got.ReadOnlyToolNames)
}
}
other := ApplyKnownReadOnlyOverrides(Spec{Name: "not-codegraph"})
if other.ReadOnlyToolNames["codegraph_context"] {
t.Fatalf("non-codegraph spec should not receive codegraph overrides: %+v", other.ReadOnlyToolNames)
}
}
func TestStartAvailableKeepsGoodServers(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
+3 -3
View File
@@ -9,9 +9,9 @@ import (
// KillTree kills cmd's whole process group. StartTracked (and SetProcessGroupKill
// for children started outside it) put the child in its own group via Setpgid, so
// the negative-pid signal reaches every descendant — a launcher whose sub-daemon
// survives the parent (e.g. codegraph's bundled node runtime) included, where a
// plain Process.Kill would only hit the direct child and orphan the grandchild.
// the negative-pid signal reaches every descendant, including a launcher whose
// sub-daemon survives the parent, where a plain Process.Kill would only hit the
// direct child and orphan the grandchild.
func KillTree(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
+1 -1
View File
@@ -53,7 +53,7 @@ func TestKillTrackedTerminatesChild(t *testing.T) {
// A launcher (sh) that backgrounds a grandchild (sleep) and stays alive: with
// the child in its own process group, KillTracked's negative-pid kill must reap
// the grandchild too, not just sh — the codegraph node-daemon leak this guards.
// the grandchild too, not just sh.
func TestKillTrackedReapsProcessGroupGrandchild(t *testing.T) {
cmd := exec.Command("sh", "-c", "sleep 60 & echo $!; wait")
stdout, err := cmd.StdoutPipe()
+2 -2
View File
@@ -39,8 +39,8 @@ func TestKillTreeUnblocksWaitOnSurvivingGrandchild(t *testing.T) {
}
// StartTracked must create a Job Object for the started process, and KillTracked
// must take the whole tracked tree down (the job reaps even descendants a plain
// taskkill /T would miss — see the codegraph daemon leak this guards against).
// must take the whole tracked tree down, including descendants a plain taskkill
// /T would miss.
func TestKillTrackedReapsTrackedTree(t *testing.T) {
cmd := exec.Command("cmd", "/c", "ping", "-n", "30", "127.0.0.1")
HideWindow(cmd)
+105 -16
View File
@@ -1,5 +1,12 @@
package skill
import (
"sort"
"strings"
"reasonix/internal/tool"
)
// Built-in skills ship with Reasonix and back the dedicated subagent tools
// (explore / research / review / security_review) plus the inline `test`
// playbook. A user/project file with the same name overrides the built-in (see
@@ -11,13 +18,15 @@ const negativeClaimRule = `When you claim something does NOT exist (no caller, n
// tuiFormatting nudges concise, terminal-friendly output.
const tuiFormatting = `Keep the final answer compact and terminal-friendly: short paragraphs or bullets, no walls of text, no restating the question.`
const optionalCodeGraphHint = `Optional installed code graph MCP tools are available in this session. Choose the semantic tool that fits the task: use LSP for language semantics (definitions, references, hover, diagnostics), use code graph tools first for call graph, impact analysis, and architecture relationships, use code_index only as the built-in outline/definition-candidate fallback, and verify textual or negative claims with read_file or grep.`
const builtinExploreBody = `You are running as an exploration subagent. Investigate the codebase the parent pointed you at, then return one focused, distilled answer.
How to operate:
- Use codegraph tools (codegraph_context, codegraph_search, codegraph_callers, codegraph_callees, codegraph_trace) as your PRIMARY tools for symbol/code-structure questions. Fall back to read_file, grep, glob, ls for content search (comments, strings, config) or when codegraph tools are not available. Stay read-only.
- codegraph_context is the best starting point for "how does X work" / architecture questions — it returns entry points + related symbols + key code in one call.
- For "find all places that call / reference / use X" questions: use codegraph_callers (preferred) or ` + "`grep`" + ` (content search) — NOT ` + "`glob`" + ` (which only matches file names). Using the wrong one gives empty results and wastes your budget.
- Cast a wide net first (codegraph_search for symbols, grep for content references, ls/glob for structure) to map the territory; then read the 3-10 most relevant files in full.
- For code intelligence, choose the best semantic tool for the task. Prefer LSP for language semantics (definitions, references, hover, diagnostics). If LSP is unavailable or insufficient, use code_index for file outlines and symbol definition candidates, then verify important claims with read_file or grep. Stay read-only.
- For "how does X work" / architecture questions, start with the strongest available structure tool, then read the key files in full.
- For "find all places that call / reference / use X" questions: use LSP references when available or ` + "`grep`" + ` (content search) — NOT ` + "`glob`" + ` (which only matches file names). code_index finds definitions/candidates, not full textual references.
- Cast a wide net first (LSP/code_index for symbols, grep for references, ls/glob for structure) to map the territory; then read the 3-10 most relevant files in full.
- Don't read every file — be selective. Breadth on the first pass, depth only where the question demands it.
- Stop exploring as soon as you can answer. The parent doesn't see your tool calls, so over-exploration is pure waste.
@@ -35,8 +44,8 @@ The 'task' the parent gave you is the question you must answer. Treat any other
const builtinResearchBody = `You are running as a research subagent. Gather information from code AND the web, synthesize it, and return one focused conclusion.
How to operate:
- Combine code reading (codegraph tools + read_file, grep, glob) with web_fetch as appropriate. (There is no dedicated web-search tool — fetch the canonical doc/spec URL directly when you know it.)
- For "how does X work" questions: use codegraph_context first for symbol-level understanding, then read_file for full context.
- Combine code reading (LSP for language semantics; code_index as the local symbol fallback; read_file, grep, glob for verification) with web_fetch as appropriate. (There is no dedicated web-search tool — fetch the canonical doc/spec URL directly when you know it.)
- For "how does X work" questions: use symbol/reference lookup first when available; otherwise use code_index, then read_file for full context.
- For "is Y supported" questions: fetch the canonical reference, then verify against the local code.
- For "what's our policy on Z" / "where do we use Q": local code first, web only to compare against external standards.
- Cap yourself at ~10 tool calls. If you can't converge, return what you have plus a note on what's missing.
@@ -68,6 +77,7 @@ Operate as an installer, not as a shell-script guesser:
7. After apply=true, report what was installed, where it was persisted, and whether it is usable in the current session. For skills, prefer actions[].canonicalPath, actions[].installRoot, actions[].discoverable, and actions[].indexed over guessing from the source path. The plan's kinds field tells you how many skills vs MCP servers were touched.
Defaults:
- MCP installs default to global so the server is available in every project; use scope="project" only for project-specific servers, tokens, or commands. A project-root .mcp.json import stays project-scoped by default.
- A folder containing many skills should be registered as a skill root, not copied.
- A single SKILL.md, <name>.md, or <name>/SKILL.md should be copied unless the user asked to link/register. The installer writes canonical <skill-name>/SKILL.md paths by default; flat <name>.md is compatibility input, not the preferred output.
- A local SKILL.md source may have references/, scripts/, assets/, or other sibling files. Treat its parent directory as the skill package so those files remain available after install.
@@ -88,7 +98,7 @@ How to operate:
- Default scope: the current branch's diff vs the default branch. If the task names a specific commit range or files, honor that instead.
- Discover scope first: ` + "`bash git status`" + `, ` + "`git diff --stat`" + `, ` + "`git log --oneline`" + `. Then ` + "`git diff`" + ` (or ` + "`git diff <base>...HEAD`" + `) for the hunks.
- Read touched files (read_file) when the diff alone lacks context — signatures, surrounding invariants, callers.
- For "any callers depending on this?" questions: use codegraph_callers or codegraph_impact (preferred) or grep the symbol BEFORE asserting impact.
- For "any callers depending on this?" questions: use LSP references/call hierarchy when available or grep the symbol BEFORE asserting impact. Use code_index only to find definition candidates/outline, not as proof of no callers.
- Stay read-only. Never commit, never write files, never propose edits as applied changes. The parent decides whether to act.
- Cap yourself at ~12 tool calls. If the diff is too big, pick the riskiest 2-3 files and say so.
@@ -116,7 +126,7 @@ const builtinSecurityReviewBody = `You are running as a security-review subagent
How to operate:
- Default scope: the current branch's diff vs the default branch. Honor a named range or directory if given.
- Discover scope first: ` + "`bash git status`" + `, ` + "`git diff --stat`" + `, ` + "`git diff <base>...HEAD`" + `. Read touched files (read_file) when the diff lacks security context — auth checks, input validation, the handler that calls the changed code.
- Use codegraph_callers or codegraph_impact (preferred) or grep to verify "is this user-controlled input ever sanitized later?" / "what other call sites depend on this validation?" before asserting impact.
- Use LSP references/call hierarchy when available or grep to verify "is this user-controlled input ever sanitized later?" / "what other call sites depend on this validation?" before asserting impact. Use code_index only to find definition candidates/outline, not as proof of no callers.
- Stay read-only. Never write, never run destructive commands. The parent decides what to act on.
- Cap yourself at ~12 tool calls. If the diff is too big, focus on the riskiest 2-3 files and say so.
@@ -179,19 +189,98 @@ Rules:
- Don't fabricate conventions the code doesn't demonstrate.
- After writing, summarize in one or two lines what you captured and tell the user to review and edit it.`
// extraReadTools holds additional tool names (e.g. codegraph tools) injected at
// boot time so subagent skills can use them without hardcoding MCP-prefixed names.
var extraReadTools []string
// CodeGraphReadTools returns read-only tool names that look like an installed
// codegraph MCP surface. Writable or untrusted tools stay out of subagents.
func CodeGraphReadTools(reg *tool.Registry) []string {
if reg == nil {
return nil
}
var names []string
for _, name := range reg.Names() {
if !looksLikeCodeGraphTool(name) {
continue
}
tl, ok := reg.Get(name)
if !ok || !tl.ReadOnly() {
continue
}
names = append(names, name)
}
return normalizeExtraToolNames(names)
}
// SetExtraReadTools registers additional read-only tool names that subagent
// skills (explore, research, review, security-review) are allowed to use. Call
// from boot after plugin tools are registered.
func SetExtraReadTools(names []string) { extraReadTools = names }
func looksLikeCodeGraphTool(name string) bool {
return strings.HasPrefix(name, "codegraph_") ||
strings.HasPrefix(name, tool.MCPNamePrefix+"codegraph__")
}
func normalizeExtraToolNames(names []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(names))
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" || seen[name] {
continue
}
seen[name] = true
out = append(out, name)
}
sort.Strings(out)
return out
}
func withOptionalCodeGraphHint(body string, enabled bool) string {
if !enabled {
return body
}
if strings.Contains(body, optionalCodeGraphHint) {
return body
}
return body + "\n\n" + optionalCodeGraphHint
}
// WithCodeGraphTools enables user-installed codegraph MCP tools for built-in
// code-reading subagent skills. The caller passes names discovered from its live
// registry so desktop tabs/sessions never share mutable skill state.
func WithCodeGraphTools(sk Skill, names []string) Skill {
names = normalizeExtraToolNames(names)
if len(names) == 0 || sk.Scope != ScopeBuiltin || !codeReadingBuiltin(sk.Name) {
return sk
}
sk.AllowedTools = appendUniqueToolNames(sk.AllowedTools, names...)
sk.Body = withOptionalCodeGraphHint(sk.Body, true)
return sk
}
func codeReadingBuiltin(name string) bool {
switch name {
case "explore", "research", "review", "security-review":
return true
default:
return false
}
}
func appendUniqueToolNames(base []string, extra ...string) []string {
out := append([]string(nil), base...)
seen := make(map[string]bool, len(out)+len(extra))
for _, name := range out {
seen[name] = true
}
for _, name := range extra {
if seen[name] {
continue
}
seen[name] = true
out = append(out, name)
}
return out
}
// builtinSkills returns the shipped skills. A fresh slice each call so callers
// can't mutate the shared set.
func builtinSkills() []Skill {
readCodeTools := append([]string{"read_file", "ls", "glob", "grep"}, extraReadTools...)
readCodeTools := []string{"read_file", "ls", "glob", "grep", "code_index"}
reviewTools := append(append([]string(nil), readCodeTools...), "bash")
return []Skill{
{
+132
View File
@@ -0,0 +1,132 @@
package skill
import (
"context"
"encoding/json"
"strings"
"testing"
"reasonix/internal/tool"
)
type builtinTestTool struct {
name string
readOnly bool
}
func (t builtinTestTool) Name() string { return t.name }
func (t builtinTestTool) Description() string { return t.name }
func (t builtinTestTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object"}`)
}
func (t builtinTestTool) Execute(context.Context, json.RawMessage) (string, error) {
return "", nil
}
func (t builtinTestTool) ReadOnly() bool { return t.readOnly }
func TestCodeGraphReadToolsRequireKnownNameAndReadOnly(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(builtinTestTool{name: "mcp__codegraph__symbols", readOnly: true})
reg.Add(builtinTestTool{name: "codegraph_search", readOnly: true})
reg.Add(builtinTestTool{name: "mcp__codegraph__write_index", readOnly: false})
reg.Add(builtinTestTool{name: "mcp__other__codegraph_search", readOnly: true})
got := CodeGraphReadTools(reg)
want := []string{"codegraph_search", "mcp__codegraph__symbols"}
if len(got) != len(want) {
t.Fatalf("CodeGraphReadTools = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("CodeGraphReadTools = %v, want %v", got, want)
}
}
}
func TestBuiltinSkillsIncludeCodeGraphHintAndToolsWhenDiscovered(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(builtinTestTool{name: "mcp__codegraph__symbols", readOnly: true})
var explore Skill
for _, sk := range builtinSkills() {
if sk.Name == "explore" {
explore = sk
break
}
}
if explore.Name == "" {
t.Fatal("explore skill not found")
}
if strings.Contains(explore.Body, "Optional installed code graph MCP tools") {
t.Fatalf("base explore body should not include session-specific codegraph hint:\n%s", explore.Body)
}
for _, name := range explore.AllowedTools {
if name == "mcp__codegraph__symbols" {
t.Fatalf("base explore allowed tools = %v, should not include session-specific codegraph tool", explore.AllowedTools)
}
}
explore = WithCodeGraphTools(explore, CodeGraphReadTools(reg))
if !strings.Contains(explore.Body, "Optional installed code graph MCP tools") {
t.Fatalf("explore body missing optional codegraph hint:\n%s", explore.Body)
}
for _, want := range []string{
"use LSP for language semantics",
"use code graph tools first for call graph, impact analysis, and architecture relationships",
"use code_index only as the built-in outline/definition-candidate fallback",
} {
if !strings.Contains(explore.Body, want) {
t.Fatalf("explore body missing priority hint %q:\n%s", want, explore.Body)
}
}
found := false
for _, name := range explore.AllowedTools {
if name == "mcp__codegraph__symbols" {
found = true
break
}
}
if !found {
t.Fatalf("explore allowed tools = %v, want codegraph tool", explore.AllowedTools)
}
}
func TestWithCodeGraphToolsOnlyTouchesCodeReadingBuiltins(t *testing.T) {
initSkill := Skill{Name: "init", Scope: ScopeBuiltin, Body: "body", AllowedTools: []string{"read_file"}}
got := WithCodeGraphTools(initSkill, []string{"mcp__codegraph__symbols"})
if strings.Contains(got.Body, "Optional installed code graph MCP tools") {
t.Fatalf("init skill should not receive codegraph hint:\n%s", got.Body)
}
if len(got.AllowedTools) != 1 || got.AllowedTools[0] != "read_file" {
t.Fatalf("init allowed tools = %v, want unchanged", got.AllowedTools)
}
}
func TestWithCodeGraphToolsSkipsUserSkillOverrides(t *testing.T) {
sk := Skill{Name: "explore", Scope: ScopeProject, Body: "user body", AllowedTools: []string{"read_file"}}
got := WithCodeGraphTools(sk, []string{"mcp__codegraph__symbols"})
if strings.Contains(got.Body, "Optional installed code graph MCP tools") {
t.Fatalf("project skill override should not receive codegraph hint:\n%s", got.Body)
}
if len(got.AllowedTools) != 1 || got.AllowedTools[0] != "read_file" {
t.Fatalf("project skill override allowed tools = %v, want unchanged", got.AllowedTools)
}
}
func TestWithCodeGraphToolsIsIdempotent(t *testing.T) {
sk := Skill{Name: "explore", Scope: ScopeBuiltin, Body: "body", AllowedTools: []string{"read_file"}}
sk = WithCodeGraphTools(sk, []string{"mcp__codegraph__symbols"})
sk = WithCodeGraphTools(sk, []string{"mcp__codegraph__symbols"})
if got := strings.Count(sk.Body, optionalCodeGraphHint); got != 1 {
t.Fatalf("codegraph hint count = %d, want 1; body:\n%s", got, sk.Body)
}
count := 0
for _, name := range sk.AllowedTools {
if name == "mcp__codegraph__symbols" {
count++
}
}
if count != 1 {
t.Fatalf("codegraph tool count = %d, want 1; allowed=%v", count, sk.AllowedTools)
}
}
+4 -4
View File
@@ -347,10 +347,10 @@ func TestBuiltinInitIsInlineSkill(t *testing.T) {
func TestBuiltinSubagentSkillsDeclareAllowedTools(t *testing.T) {
st := New(Options{HomeDir: t.TempDir()})
cases := map[string][]string{
"explore": {"read_file", "ls", "glob", "grep"},
"research": {"read_file", "ls", "glob", "grep", "web_fetch"},
"review": {"read_file", "ls", "glob", "grep", "bash"},
"security-review": {"read_file", "ls", "glob", "grep", "bash"},
"explore": {"read_file", "ls", "glob", "grep", "code_index"},
"research": {"read_file", "ls", "glob", "grep", "code_index", "web_fetch"},
"review": {"read_file", "ls", "glob", "grep", "code_index", "bash"},
"security-review": {"read_file", "ls", "glob", "grep", "code_index", "bash"},
}
for name, want := range cases {
sk, ok := st.Read(name)
+1 -1
View File
@@ -91,7 +91,7 @@ func (b bash) Description() string {
// bashToolSteer points the model at the cross-platform built-in tools instead of
// shell utilities, so it doesn't reach for grep/cat/ls/find (absent or different
// on native Windows) when a native tool already does the job everywhere.
const bashToolSteer = " Use for builds, tests, git, package managers, etc. To search/read/list/edit/move files, prefer the dedicated tools (grep, read_file, ls, glob, edit_file, move_file) over shell grep/cat/ls/find/sed/mv/Move-Item — they behave identically on every OS. For symbol search, call graphs, or architecture questions, use codegraph tools instead of grep."
const bashToolSteer = " Use for builds, tests, git, package managers, etc. To search/read/list/edit/move files, prefer the dedicated tools (grep, read_file, ls, glob, edit_file, move_file) over shell grep/cat/ls/find/sed/mv/Move-Item — they behave identically on every OS. For symbol search or architecture questions, prefer LSP/read tools and targeted grep before shell commands."
// resolved returns the bound shell, resolving lazily for the zero-value instance
// (e.g. a registry that never went through ConfineBash).
+2 -2
View File
@@ -42,7 +42,7 @@ func runTool(t *testing.T, tl tool.Tool, m map[string]any) string {
}
func TestBuiltinsRegistered(t *testing.T) {
want := []string{"bash", "edit_file", "glob", "grep", "ls", "move_file", "multi_edit", "read_file", "web_fetch", "write_file"}
want := []string{"bash", "code_index", "edit_file", "glob", "grep", "ls", "move_file", "multi_edit", "read_file", "web_fetch", "write_file"}
for _, name := range want {
if _, ok := tool.LookupBuiltin(name); !ok {
t.Errorf("built-in %q not registered", name)
@@ -57,7 +57,7 @@ func TestBuiltinsRegistered(t *testing.T) {
// many invocations are pure reads — args aren't introspected.
func TestBuiltinReadOnlyClassification(t *testing.T) {
readOnly := map[string]bool{
"read_file": true, "ls": true, "glob": true, "grep": true, "web_fetch": true,
"read_file": true, "ls": true, "glob": true, "grep": true, "code_index": true, "web_fetch": true,
"write_file": false, "edit_file": false, "multi_edit": false, "move_file": false, "bash": false,
}
for name, want := range readOnly {
+424
View File
@@ -0,0 +1,424 @@
package builtin
import (
"bufio"
"context"
"encoding/json"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"reasonix/internal/tool"
)
func init() { tool.RegisterBuiltin(codeIndex{}) }
type codeIndex struct{ workDir string }
func (codeIndex) Name() string { return "code_index" }
func (codeIndex) Description() string {
return "Lightweight built-in code symbol index. Prefer lsp_* for language semantics and installed code graph MCP tools for call graph, impact, and architecture relationships; use this as the local fallback for file outlines and symbol definition candidates, then verify with read_file or grep."
}
func (codeIndex) Schema() json.RawMessage {
return json.RawMessage(`{
"type":"object",
"properties":{
"action":{"type":"string","enum":["outline","search"],"description":"outline lists symbols under path; search finds symbol definition candidates by name."},
"path":{"type":"string","description":"File or directory path to inspect (default \".\")."},
"query":{"type":"string","description":"Symbol name or substring for action=search."},
"kind":{"type":"string","description":"Optional symbol kind filter, such as func, method, class, type, interface, const, var, struct, enum, trait."},
"limit":{"type":"integer","description":"Maximum symbols to return (default 100, max 200).","minimum":1}
},
"required":["action"]
}`)
}
func (codeIndex) ReadOnly() bool { return true }
const (
codeIndexDefaultLimit = 100
codeIndexMaxLimit = 200
codeIndexMaxFileSize = 1 << 20
codeIndexMaxFiles = 2000
)
type codeIndexArgs struct {
Action string `json:"action"`
Path string `json:"path"`
Query string `json:"query"`
Kind string `json:"kind"`
Limit int `json:"limit"`
}
type codeSymbol struct {
Name string
Kind string
File string
Line int
Parent string
Signature string
}
func (c codeIndex) Execute(ctx context.Context, args json.RawMessage) (string, error) {
p := codeIndexArgs{Path: ".", Limit: codeIndexDefaultLimit}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
p.Action = strings.ToLower(strings.TrimSpace(p.Action))
if p.Action != "outline" && p.Action != "search" {
return "", fmt.Errorf("action must be outline or search")
}
if p.Path == "" {
p.Path = "."
}
if p.Limit <= 0 {
p.Limit = codeIndexDefaultLimit
}
if p.Limit > codeIndexMaxLimit {
p.Limit = codeIndexMaxLimit
}
if p.Action == "search" && strings.TrimSpace(p.Query) == "" {
return "", fmt.Errorf("query is required for action=search")
}
root := resolveIn(c.workDir, p.Path)
symbols, truncated, err := c.collect(ctx, root, p.Limit, p.Action == "outline")
if err != nil {
return "", err
}
symbols = filterCodeSymbols(symbols, p)
if len(symbols) > p.Limit {
symbols = symbols[:p.Limit]
truncated = true
}
return formatCodeSymbols(symbols, truncated), nil
}
func (c codeIndex) collect(ctx context.Context, root string, limit int, outline bool) ([]codeSymbol, bool, error) {
info, err := os.Stat(root)
if err != nil {
return nil, false, fmt.Errorf("code_index %s: %w", root, err)
}
var files []string
if !info.IsDir() {
if supportedCodeIndexFile(root) {
files = append(files, root)
}
} else {
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if walkErr != nil {
return nil
}
if d.IsDir() {
if path != root && skipCodeIndexDir(d.Name()) {
return filepath.SkipDir
}
return nil
}
if supportedCodeIndexFile(path) {
files = append(files, path)
if len(files) >= codeIndexMaxFiles {
return filepath.SkipAll
}
}
return nil
})
if err != nil {
return nil, false, fmt.Errorf("code_index walk %s: %w", root, err)
}
}
sort.Strings(files)
var symbols []codeSymbol
truncated := len(files) >= codeIndexMaxFiles
for _, file := range files {
if ctx.Err() != nil {
return nil, truncated, ctx.Err()
}
found, err := c.parseFile(file)
if err != nil {
continue
}
symbols = append(symbols, found...)
if outline && len(symbols) >= limit {
truncated = true
break
}
}
sort.Slice(symbols, func(i, j int) bool {
if symbols[i].File != symbols[j].File {
return symbols[i].File < symbols[j].File
}
if symbols[i].Line != symbols[j].Line {
return symbols[i].Line < symbols[j].Line
}
return symbols[i].Name < symbols[j].Name
})
return symbols, truncated, nil
}
func (c codeIndex) parseFile(path string) ([]codeSymbol, error) {
if info, err := os.Stat(path); err != nil || info.Size() > codeIndexMaxFileSize {
if err != nil {
return nil, err
}
return nil, fmt.Errorf("file too large")
}
if filepath.Ext(path) == ".go" {
return c.parseGo(path)
}
return c.parseText(path)
}
func (c codeIndex) parseGo(path string) ([]codeSymbol, error) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
if err != nil {
return nil, err
}
var out []codeSymbol
add := func(name, kind string, pos token.Pos, parent, sig string) {
if name == "" {
return
}
out = append(out, codeSymbol{
Name: name,
Kind: kind,
File: c.displayPath(path),
Line: fset.Position(pos).Line,
Parent: parent,
Signature: sig,
})
}
for _, decl := range f.Decls {
switch d := decl.(type) {
case *ast.FuncDecl:
parent := ""
kind := "func"
if d.Recv != nil && len(d.Recv.List) > 0 {
parent = exprName(d.Recv.List[0].Type)
kind = "method"
}
add(d.Name.Name, kind, d.Name.Pos(), parent, goFuncSignature(d, parent))
case *ast.GenDecl:
for _, spec := range d.Specs {
switch s := spec.(type) {
case *ast.TypeSpec:
kind := "type"
switch s.Type.(type) {
case *ast.StructType:
kind = "struct"
case *ast.InterfaceType:
kind = "interface"
}
add(s.Name.Name, kind, s.Name.Pos(), "", kind+" "+s.Name.Name)
case *ast.ValueSpec:
kind := strings.ToLower(d.Tok.String())
for _, name := range s.Names {
add(name.Name, kind, name.Pos(), "", kind+" "+name.Name)
}
}
}
}
}
return out, nil
}
func (c codeIndex) parseText(path string) ([]codeSymbol, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var out []codeSymbol
sc := bufio.NewScanner(f)
line := 0
for sc.Scan() {
line++
text := sc.Text()
for _, m := range codeIndexMatchers(filepath.Ext(path)) {
if sym, ok := m.match(text); ok {
sym.File = c.displayPath(path)
sym.Line = line
out = append(out, sym)
break
}
}
}
if err := sc.Err(); err != nil {
return nil, err
}
return out, nil
}
func (c codeIndex) displayPath(path string) string {
if c.workDir != "" {
if rel, err := filepath.Rel(c.workDir, path); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return filepath.ToSlash(rel)
}
}
return filepath.ToSlash(path)
}
func filterCodeSymbols(in []codeSymbol, p codeIndexArgs) []codeSymbol {
query := strings.ToLower(strings.TrimSpace(p.Query))
kind := strings.ToLower(strings.TrimSpace(p.Kind))
out := make([]codeSymbol, 0, len(in))
for _, s := range in {
if kind != "" && strings.ToLower(s.Kind) != kind {
continue
}
if query != "" {
haystack := strings.ToLower(s.Name + " " + s.Parent + " " + s.Signature)
if !strings.Contains(haystack, query) {
continue
}
}
out = append(out, s)
}
return out
}
func formatCodeSymbols(symbols []codeSymbol, truncated bool) string {
if len(symbols) == 0 {
return "(no symbols)"
}
var b strings.Builder
for _, s := range symbols {
name := s.Name
if s.Parent != "" {
name = s.Parent + "." + name
}
if s.Signature != "" {
fmt.Fprintf(&b, "%s:%d: %s %s — %s\n", s.File, s.Line, s.Kind, name, s.Signature)
} else {
fmt.Fprintf(&b, "%s:%d: %s %s\n", s.File, s.Line, s.Kind, name)
}
}
if truncated {
b.WriteString("... (truncated; narrow path/query/kind or raise limit)\n")
}
return strings.TrimRight(b.String(), "\n")
}
func supportedCodeIndexFile(path string) bool {
switch filepath.Ext(path) {
case ".go", ".js", ".jsx", ".ts", ".tsx", ".py", ".java", ".kt", ".kts", ".cs", ".rs", ".c", ".cc", ".cpp", ".h", ".hpp":
return true
default:
return false
}
}
func skipCodeIndexDir(name string) bool {
switch name {
case ".git", "node_modules", "vendor", "__pycache__", ".idea", ".vscode", ".next", "dist", "build", "target", "coverage":
return true
default:
return false
}
}
func exprName(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.Ident:
return t.Name
case *ast.StarExpr:
return exprName(t.X)
case *ast.SelectorExpr:
return exprName(t.X) + "." + t.Sel.Name
default:
return ""
}
}
func goFuncSignature(fn *ast.FuncDecl, parent string) string {
if parent == "" {
return "func " + fn.Name.Name
}
return "func (" + parent + ") " + fn.Name.Name
}
type codeIndexMatcher struct {
kind string
re *regexp.Regexp
nameGroup int
kindGroup int
}
func (m codeIndexMatcher) match(line string) (codeSymbol, bool) {
match := m.re.FindStringSubmatch(line)
if match == nil || m.nameGroup >= len(match) {
return codeSymbol{}, false
}
name := strings.TrimSpace(match[m.nameGroup])
if name == "" {
return codeSymbol{}, false
}
kind := m.kind
if m.kindGroup > 0 && m.kindGroup < len(match) && strings.TrimSpace(match[m.kindGroup]) != "" {
kind = normalizeCodeIndexKind(match[m.kindGroup])
}
return codeSymbol{Name: name, Kind: kind, Signature: strings.TrimSpace(line)}, true
}
func normalizeCodeIndexKind(kind string) string {
kind = strings.ToLower(strings.TrimSpace(kind))
switch kind {
case "function":
return "func"
default:
return kind
}
}
var (
rePyClass = regexp.MustCompile(`^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)\b`)
rePyFunc = regexp.MustCompile(`^\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`)
reJSClass = regexp.MustCompile(`^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`)
reJSFunc = regexp.MustCompile(`^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(`)
reJSInterface = regexp.MustCompile(`^\s*(?:export\s+)?interface\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`)
reJSType = regexp.MustCompile(`^\s*(?:export\s+)?type\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`)
reJSEnum = regexp.MustCompile(`^\s*(?:export\s+)?enum\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`)
reJSArrow = regexp.MustCompile(`^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>`)
reJavaType = regexp.MustCompile(`^\s*(?:public|protected|private|abstract|final|static|sealed|data|\s)*\s*(class|interface|enum|record|object)\s+([A-Za-z_][A-Za-z0-9_]*)\b`)
reJavaMeth = regexp.MustCompile(`^\s*(?:public|protected|private|static|final|abstract|synchronized|native|\s)+[\w<>\[\], ?]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^;]*\)\s*(?:\{|$)`)
reRustItem = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?(fn|struct|enum|trait)\s+([A-Za-z_][A-Za-z0-9_]*)\b`)
reCFunc = regexp.MustCompile(`^\s*(?:[\w:*&<>\[\],]+\s+)+([A-Za-z_][A-Za-z0-9_]*)\s*\([^;]*\)\s*(?:\{|$)`)
)
func codeIndexMatchers(ext string) []codeIndexMatcher {
switch ext {
case ".py":
return []codeIndexMatcher{{kind: "class", re: rePyClass, nameGroup: 1}, {kind: "func", re: rePyFunc, nameGroup: 1}}
case ".js", ".jsx", ".ts", ".tsx":
return []codeIndexMatcher{
{kind: "class", re: reJSClass, nameGroup: 1},
{kind: "func", re: reJSFunc, nameGroup: 1},
{kind: "interface", re: reJSInterface, nameGroup: 1},
{kind: "type", re: reJSType, nameGroup: 1},
{kind: "enum", re: reJSEnum, nameGroup: 1},
{kind: "func", re: reJSArrow, nameGroup: 1},
}
case ".java", ".kt", ".kts", ".cs":
return []codeIndexMatcher{{re: reJavaType, nameGroup: 2, kindGroup: 1}, {kind: "method", re: reJavaMeth, nameGroup: 1}}
case ".rs":
return []codeIndexMatcher{{re: reRustItem, nameGroup: 2, kindGroup: 1}}
case ".c", ".cc", ".cpp", ".h", ".hpp":
return []codeIndexMatcher{{kind: "func", re: reCFunc, nameGroup: 1}}
default:
return nil
}
}
+178
View File
@@ -0,0 +1,178 @@
package builtin
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCodeIndexSearchGoSymbols(t *testing.T) {
root := t.TempDir()
mkfile(t, filepath.Join(root, "service.go"), `package demo
type Service struct{}
func NewService() *Service { return &Service{} }
func (s *Service) Handle() {}
const DefaultName = "demo"
`)
out := runTool(t, codeIndex{workDir: root}, map[string]any{
"action": "search",
"query": "Service",
})
for _, want := range []string{
"service.go:3: struct Service",
"service.go:5: func NewService",
"service.go:7: method Service.Handle",
} {
if !strings.Contains(out, want) {
t.Fatalf("code_index search missing %q; got:\n%s", want, out)
}
}
}
func TestCodeIndexOutlineTypeScriptAndSkipsNoiseDirs(t *testing.T) {
root := t.TempDir()
mkfile(t, filepath.Join(root, "src", "app.ts"), `export interface User {
name: string
}
export class Client {}
export const loadUser = async () => {}
`)
mkfile(t, filepath.Join(root, "node_modules", "dep", "dep.ts"), `export class Dependency {}`)
out := runTool(t, codeIndex{workDir: root}, map[string]any{
"action": "outline",
"path": ".",
})
for _, want := range []string{
"src/app.ts:1: interface User",
"src/app.ts:4: class Client",
"src/app.ts:5: func loadUser",
} {
if !strings.Contains(out, want) {
t.Fatalf("code_index outline missing %q; got:\n%s", want, out)
}
}
if strings.Contains(out, "Dependency") {
t.Fatalf("code_index should skip node_modules; got:\n%s", out)
}
}
func TestCodeIndexKindFiltersJavaTypes(t *testing.T) {
root := t.TempDir()
mkfile(t, filepath.Join(root, "src", "Example.java"), `package demo;
public interface Repository {}
public enum Mode { Fast }
public class Client {
public void run() {}
}
`)
out := runTool(t, codeIndex{workDir: root}, map[string]any{
"action": "search",
"query": "Repository",
"kind": "interface",
})
if !strings.Contains(out, "src/Example.java:3: interface Repository") {
t.Fatalf("code_index should return Java interface for kind=interface; got:\n%s", out)
}
out = runTool(t, codeIndex{workDir: root}, map[string]any{
"action": "search",
"query": "Mode",
"kind": "enum",
})
if !strings.Contains(out, "src/Example.java:4: enum Mode") {
t.Fatalf("code_index should return Java enum for kind=enum; got:\n%s", out)
}
out = runTool(t, codeIndex{workDir: root}, map[string]any{
"action": "search",
"query": "Client",
"kind": "class",
})
if !strings.Contains(out, "src/Example.java:5: class Client") {
t.Fatalf("code_index should return Java class for kind=class; got:\n%s", out)
}
}
func TestCodeIndexKindFiltersRustItems(t *testing.T) {
root := t.TempDir()
mkfile(t, filepath.Join(root, "src", "lib.rs"), `pub struct Store {}
pub trait Repository {}
pub enum Mode { Fast }
pub fn load_store() {}
`)
out := runTool(t, codeIndex{workDir: root}, map[string]any{
"action": "search",
"query": "Store",
"kind": "struct",
})
if !strings.Contains(out, "src/lib.rs:1: struct Store") {
t.Fatalf("code_index should return Rust struct for kind=struct; got:\n%s", out)
}
out = runTool(t, codeIndex{workDir: root}, map[string]any{
"action": "search",
"query": "Repository",
"kind": "trait",
})
if !strings.Contains(out, "src/lib.rs:2: trait Repository") {
t.Fatalf("code_index should return Rust trait for kind=trait; got:\n%s", out)
}
out = runTool(t, codeIndex{workDir: root}, map[string]any{
"action": "search",
"query": "load_store",
"kind": "fn",
})
if !strings.Contains(out, "src/lib.rs:4: fn load_store") {
t.Fatalf("code_index should return Rust fn for kind=fn; got:\n%s", out)
}
}
func TestCodeIndexRequiresQueryForSearch(t *testing.T) {
_, err := codeIndex{workDir: t.TempDir()}.Execute(context.Background(), json.RawMessage(`{"action":"search"}`))
if err == nil || !strings.Contains(err.Error(), "query is required") {
t.Fatalf("error = %v, want query required", err)
}
}
func TestCodeIndexWorkspaceBinding(t *testing.T) {
root := t.TempDir()
mkfile(t, filepath.Join(root, "main.py"), "class App:\n pass\n")
t.Chdir(t.TempDir())
tools := byName(Workspace{Dir: root}.Tools("code_index"))
tl := tools["code_index"]
if tl == nil {
t.Fatal("workspace did not expose code_index")
}
out, err := tl.Execute(context.Background(), json.RawMessage(`{"action":"outline","path":"."}`))
if err != nil {
t.Fatalf("code_index: %v", err)
}
if !strings.Contains(out, "main.py:1: class App") {
t.Fatalf("code_index should resolve relative paths against workspace; got:\n%s", out)
}
}
func TestCodeIndexIgnoresLargeFiles(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "huge.ts"), []byte(strings.Repeat("x", codeIndexMaxFileSize+1)), 0o644); err != nil {
t.Fatal(err)
}
out := runTool(t, codeIndex{workDir: root}, map[string]any{"action": "outline"})
if out != "(no symbols)" {
t.Fatalf("large files should be ignored, got:\n%s", out)
}
}
+1
View File
@@ -50,6 +50,7 @@ func (w Workspace) Tools(enabled ...string) []tool.Tool {
"notebook_edit": notebookEdit{workDir: w.Dir, roots: roots},
"delete_range": deleteRange{workDir: w.Dir, roots: roots},
"delete_symbol": deleteSymbol{workDir: w.Dir, roots: roots},
"code_index": codeIndex{workDir: w.Dir},
"bash": bash{workDir: w.Dir, sb: w.Bash, timeout: w.BashTimeout},
"ls": listDir{workDir: w.Dir},
"glob": globTool{workDir: w.Dir},
-11
View File
@@ -123,17 +123,6 @@ bash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local ca
[tools.background_jobs]
stalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables
# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),
# a built-in MCP server giving the agent codegraph_* search / context / explore /
# trace / node tools. Off by default for first-run sessions. Once enabled, the
# runtime is fetched into a per-version cache when missing (auto_install);
# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the
# background once enabled.
# [codegraph]
# enabled = false
# auto_install = true
# path = "" # empty = cache, then PATH, then a bundle beside reasonix
# Skills are invokable playbooks (SKILL.md / <name>.md with frontmatter), found
# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)
# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)
+1 -1
View File
@@ -35,7 +35,7 @@ const SYSTEM = [
'Pick labels ONLY from these fixed sets. Never invent labels.',
'area (0-2, the affected subsystem):',
' agent: core agent loop / tool-calling / reasoning',
' mcp: MCP servers, plugins, codegraph',
' mcp: MCP servers and plugins',
' config: configuration, setup wizard, .toml/.env',
' updater: auto-update, installer, release packaging',
' provider: model providers, model selection/switching',