Skip to content

Commit 7acfab1

Browse files
kevinelliottclaude
andauthored
perf: parallelize version checks, tune SQLite/HTTP, fix flaky IPC test (#16)
* Add 13 new AI agent CLIs to catalog New agents: OpenClaw, Junie CLI, Mistral Vibe, Letta Code, Deep Agents CLI, Trae Agent, Kiro CLI, ForgeCode, Hermes Agent, Roo Code CLI, Tabnine CLI, Cortex Code, FetchCoder Bump catalog version to 1.0.25 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add ElevenLabs CLI to catalog Voice/conversational AI agent management CLI by ElevenLabs. Bump catalog version to 1.0.26 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add Forge CLI, nono, mcp2cli to catalog - Forge CLI: Multi-agent TDD orchestrator by Red Green Labs - nono: Kernel-enforced agent sandbox by Always Further - mcp2cli: MCP/OpenAPI/GraphQL to CLI bridge Bump catalog version to 1.0.27 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * perf: parallelize version checks, tune SQLite/HTTP, fix flaky IPC test Research + fix pass across the hot paths: Performance - Parallelize GetLatestVersion loops in CLI list/update, TUI, systray via new internal/versionfetch helper (errgroup + sem of 8); 10-20x on typical N-agent workloads - BinaryStrategy.Detect: deterministic match phase + parallel version extraction (concurrency 4) - exec.LookPath memoized via sync.Map keyed on PATH+name (all platforms) - brew info per-agent coalesced via sync.Map + sync.Once (5m TTL) - pip PyPI fallback uses shared http.Client instead of curl subprocess - catalog.Refresh coalesced via singleflight; honors stored ETag with If-None-Match / 304 - SQLite: _busy_timeout=5000, _synchronous=NORMAL, SetMaxOpenConns(1); migrations guarded by PRAGMA user_version - REST handlers share getAgentsWithCache helper honoring Detection.CacheDuration (+ ?refresh=true) - REST: ReadHeaderTimeout + MaxHeaderBytes; real /status with version and last-refresh timestamps - gRPC: keepalive, 16MiB msg caps, panic-recovery unary+stream interceptors Oddities - Fix IPC listenForNotifications not exiting on ctx cancel (SetReadDeadline poll loop); TestListenForNotificationsContextCanceled now 25/25 under -race - Spinner: go-isatty TTY check; honor NO_COLOR and TERM=dumb - Unify --no-color via output.NoColor() helper - Detector: channel buffer sized by applicable strategies - catalog: log cache-save failures via slog instead of silent drop - systray: replace shutdown time.Sleep(100ms) with ctx+WaitGroup handshake; checkUpdates actually fetches latest versions when AutoCheck=true - Config.Catalog.RefreshOnStart marked deprecated (dead flag) Tests - New: migration version guard, REST cache helper, gRPC recovery, platform lookpath cache, singleflight coalescing, 304 ETag, brew/pip caching, binary parallelism ordering, detector channel sizing, versionfetch helper Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(lint): address golangci-lint errcheck/gofmt/goimports/misspell/SA1019 - errcheck: use comma-ok form on type assertions in platform lookpath cache, catalog singleflight result, brew latest-version cache - errcheck: annotate the intentional _ = g.Wait() in versionfetch - gofmt: reformat struct tag alignment in brew.go and test case comments in versionfetch_test.go - goimports: group golang.org/x/sync/singleflight as third-party (with -local github.com/kevinelliott/agentmanager) - misspell: cancelled -> canceled, behaviour -> behavior, Honour -> Honor - SA1019: suppress deprecated RefreshOnStart read in TUI display with //nolint:staticcheck (retained for backward-compat display) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 10e1db3 commit 7acfab1

34 files changed

Lines changed: 1890 additions & 209 deletions

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Fixed
11+
12+
- `pkg/ipc`: `listenForNotifications` now responds to context cancellation
13+
promptly by using a short read deadline (500ms) around each receive.
14+
Previously the blocking `conn.Receive()` could leak the goroutine until
15+
the connection was closed, causing `TestListenForNotificationsContextCanceled`
16+
to flake on CI.
17+
- `internal/cli/output/spinner`: spinner now detects TTY via `go-isatty` at
18+
construction. When stdout is piped/redirected/not a terminal (or
19+
`TERM=dumb` / `NO_COLOR` is set), `Start` and `Stop` are no-ops so ANSI
20+
escape sequences no longer corrupt piped output.
21+
- `internal/cli`: `--no-color` handling unified via `output.NoColor(cfg, flag)`
22+
helper and `Printer.NoColor()` accessor. Previously three different code
23+
paths (agent list, agent info, catalog list/search) derived the value
24+
differently, which could leave the spinner colored while the printer was
25+
monochrome (or vice versa).
26+
27+
### Deprecated
28+
29+
- `config.CatalogConfig.RefreshOnStart` is marked deprecated. The flag is
30+
not wired to any startup behavior today (catalog refresh cadence is
31+
controlled by `RefreshInterval` + cache TTL). It is retained for
32+
backward compatibility with existing config files and will be removed
33+
once the sole remaining TUI display reference is cleaned up.
34+
35+
### Known Issues
36+
37+
- The macOS linker emits `ld: warning: ignoring duplicate libraries:
38+
'-lobjc'` when building `cmd/agentmgr-helper`. This is a cosmetic
39+
warning from ld64 caused by Apple's clang auto-linking libobjc for both
40+
`getlantern/systray` (which declares `-x objective-c` CFLAGS) and
41+
`progrium/darwinkit` (Cocoa / Foundation frameworks). Neither
42+
dependency declares `-lobjc` explicitly; the duplicate is injected by
43+
the toolchain. Suppressing this cleanly would require dropping one
44+
dependency or patching cgo directives upstream. The warning has no
45+
runtime impact.
46+
847
## [1.0.16] - 2026-01-16
948

1049
### Fixed

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ require (
1111
github.com/charmbracelet/lipgloss v1.1.0
1212
github.com/getlantern/systray v1.2.2
1313
github.com/go-chi/chi/v5 v5.2.3
14+
github.com/mattn/go-isatty v0.0.20
1415
github.com/mattn/go-sqlite3 v1.14.33
1516
github.com/muesli/termenv v0.16.0
1617
github.com/progrium/darwinkit v0.5.0
1718
github.com/spf13/cobra v1.10.2
1819
github.com/spf13/viper v1.21.0
20+
golang.org/x/sync v0.18.0
1921
golang.org/x/sys v0.40.0
2022
google.golang.org/grpc v1.78.0
2123
gopkg.in/yaml.v3 v3.0.1
@@ -43,7 +45,6 @@ require (
4345
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
4446
github.com/inconshreveable/mousetrap v1.1.0 // indirect
4547
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
46-
github.com/mattn/go-isatty v0.0.20 // indirect
4748
github.com/mattn/go-localereader v0.0.1 // indirect
4849
github.com/mattn/go-runewidth v0.0.19 // indirect
4950
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM
157157
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
158158
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
159159
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
160+
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
161+
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
160162
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
161163
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
162164
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

internal/cli/agent.go

Lines changed: 22 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/spf13/cobra"
1414

1515
"github.com/kevinelliott/agentmanager/internal/cli/output"
16+
"github.com/kevinelliott/agentmanager/internal/versionfetch"
1617
"github.com/kevinelliott/agentmanager/pkg/agent"
1718
"github.com/kevinelliott/agentmanager/pkg/catalog"
1819
"github.com/kevinelliott/agentmanager/pkg/config"
@@ -74,16 +75,19 @@ Results are cached for 1 hour by default. Use --refresh to force re-detection.`,
7475
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
7576
defer cancel()
7677

78+
// Single source of truth for no-color.
79+
noColor := output.NoColor(cfg, false)
80+
7781
// Create printer for colored output
78-
printer := output.NewPrinter(cfg, cmd.Flag("no-color").Changed && cmd.Flag("no-color").Value.String() == "true")
82+
printer := output.NewPrinter(cfg, noColor)
7983

8084
// Get current platform
8185
plat := platform.Current()
8286

8387
// Create spinner for loading
8488
spinner := output.NewSpinner(
8589
output.WithMessage("Loading..."),
86-
output.WithNoColor(!cfg.UI.UseColors),
90+
output.WithNoColor(noColor),
8791
)
8892
spinner.Start()
8993

@@ -162,20 +166,10 @@ Results are cached for 1 hour by default. Use --refresh to force re-detection.`,
162166
// Update spinner for version checking
163167
spinner.UpdateMessage("Checking for updates...")
164168

165-
// Check for latest versions
166-
for _, inst := range installations {
167-
if agentDef, ok := agentDefMap[inst.AgentID]; ok {
168-
// Find the matching install method
169-
methodStr := string(inst.Method)
170-
if method, ok := agentDef.InstallMethods[methodStr]; ok {
171-
// Get latest version from package registry
172-
latestVer, err := instMgr.GetLatestVersion(ctx, method)
173-
if err == nil {
174-
inst.LatestVersion = &latestVer
175-
}
176-
}
177-
}
178-
}
169+
// Fetch latest versions in parallel. Errors are intentionally
170+
// dropped here to preserve the existing silent-failure semantics
171+
// of `agent list`.
172+
_ = versionfetch.CheckLatestVersions(ctx, instMgr, installations, agentDefMap, versionfetch.DefaultConcurrency)
179173

180174
// Save last update check time
181175
_ = store.SetLastUpdateCheckTime(ctx, time.Now()) //nolint:errcheck // best-effort timestamp; non-critical if this fails
@@ -441,29 +435,18 @@ Use --all to update all agents at once.`,
441435

442436
spinner.UpdateMessage("Checking for updates...")
443437

444-
// Check for latest versions so HasUpdate() works correctly
445-
var versionCheckErrors []string
446-
for _, installation := range installations {
447-
if agentDef, ok := agentDefMap[installation.AgentID]; ok {
448-
methodStr := string(installation.Method)
449-
if method, ok := agentDef.InstallMethods[methodStr]; ok {
450-
latestVer, err := inst.GetLatestVersion(ctx, method)
451-
if err == nil {
452-
installation.LatestVersion = &latestVer
453-
} else {
454-
versionCheckErrors = append(versionCheckErrors, fmt.Sprintf("%s (%s): %v", installation.AgentName, methodStr, err))
455-
}
456-
}
457-
}
458-
}
438+
// Check for latest versions in parallel so HasUpdate() works correctly.
439+
// The returned per-index errors are aggregated and surfaced to the user.
440+
perIndexErrs := versionfetch.CheckLatestVersions(ctx, inst, installations, agentDefMap, versionfetch.DefaultConcurrency)
441+
versionCheckErrors := versionfetch.NonNilErrors(perIndexErrs)
459442

460443
spinner.Stop()
461444

462445
// Surface version check failures so users know why updates may not be detected
463446
if len(versionCheckErrors) > 0 && !force {
464447
printer.Warning("Could not check latest version for %d agent(s):", len(versionCheckErrors))
465448
for _, e := range versionCheckErrors {
466-
printer.Print(" - %s", e)
449+
printer.Print(" - %s", e.Error())
467450
}
468451
printer.Print("")
469452
}
@@ -494,7 +477,7 @@ func updateAllAgents(ctx context.Context, installations []*agent.Installation, c
494477

495478
spinner := output.NewSpinner(
496479
output.WithMessage("Checking for updates..."),
497-
output.WithNoColor(os.Getenv("NO_COLOR") != ""),
480+
output.WithNoColor(printer.NoColor()),
498481
)
499482
spinner.Start()
500483

@@ -546,7 +529,7 @@ func updateAllAgents(ctx context.Context, installations []*agent.Installation, c
546529

547530
spinner := output.NewSpinner(
548531
output.WithMessage(fmt.Sprintf("Updating %s via %s...", installation.AgentName, installation.Method)),
549-
output.WithNoColor(os.Getenv("NO_COLOR") != ""),
532+
output.WithNoColor(printer.NoColor()),
550533
)
551534
spinner.Start()
552535

@@ -628,7 +611,7 @@ func updateSingleAgent(ctx context.Context, agentID string, installations []*age
628611

629612
spinner := output.NewSpinner(
630613
output.WithMessage(fmt.Sprintf("Updating %s via %s...", installation.AgentName, installation.Method)),
631-
output.WithNoColor(os.Getenv("NO_COLOR") != ""),
614+
output.WithNoColor(printer.NoColor()),
632615
)
633616
spinner.Start()
634617

@@ -952,7 +935,7 @@ results. The new detection results are then cached for future use.`,
952935
// Create spinner
953936
spinner := output.NewSpinner(
954937
output.WithMessage("Clearing cache..."),
955-
output.WithNoColor(!cfg.UI.UseColors),
938+
output.WithNoColor(output.NoColor(cfg, false)),
956939
)
957940
spinner.Start()
958941

@@ -1005,18 +988,9 @@ results. The new detection results are then cached for future use.`,
1005988

1006989
spinner.UpdateMessage("Checking for updates...")
1007990

1008-
// Check for latest versions
1009-
for _, inst := range installations {
1010-
if agentDef, ok := agentDefMap[inst.AgentID]; ok {
1011-
methodStr := string(inst.Method)
1012-
if method, ok := agentDef.InstallMethods[methodStr]; ok {
1013-
latestVer, err := instMgr.GetLatestVersion(ctx, method)
1014-
if err == nil {
1015-
inst.LatestVersion = &latestVer
1016-
}
1017-
}
1018-
}
1019-
}
991+
// Check for latest versions in parallel. Errors are intentionally
992+
// dropped to preserve existing silent-failure semantics for refresh.
993+
_ = versionfetch.CheckLatestVersions(ctx, instMgr, installations, agentDefMap, versionfetch.DefaultConcurrency)
1020994

1021995
// Save to cache
1022996
if cfg.Detection.CacheEnabled {

internal/cli/catalog.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,11 @@ by platform compatibility.`,
5757
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
5858
defer cancel()
5959

60+
// Single source of truth for no-color.
61+
noColor := output.NoColor(cfg, false)
62+
6063
// Create printer for colored output
61-
printer := output.NewPrinter(cfg, cmd.Flag("no-color").Changed && cmd.Flag("no-color").Value.String() == "true")
64+
printer := output.NewPrinter(cfg, noColor)
6265

6366
// Get current platform
6467
plat := platform.Current()
@@ -69,7 +72,7 @@ by platform compatibility.`,
6972
// Create spinner
7073
spinner := output.NewSpinner(
7174
output.WithMessage("Loading catalog..."),
72-
output.WithNoColor(!cfg.UI.UseColors),
75+
output.WithNoColor(noColor),
7376
)
7477
spinner.Start()
7578

@@ -151,7 +154,7 @@ in configuration.`,
151154
// Create spinner
152155
spinner := output.NewSpinner(
153156
output.WithMessage("Refreshing catalog from GitHub..."),
154-
output.WithNoColor(!cfg.UI.UseColors),
157+
output.WithNoColor(output.NoColor(cfg, false)),
155158
)
156159
spinner.Start()
157160

@@ -215,16 +218,19 @@ func newCatalogSearchCommand(cfg *config.Config) *cobra.Command {
215218
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
216219
defer cancel()
217220

221+
// Single source of truth for no-color.
222+
noColor := output.NoColor(cfg, false)
223+
218224
// Create printer for colored output
219-
printer := output.NewPrinter(cfg, cmd.Flag("no-color").Changed && cmd.Flag("no-color").Value.String() == "true")
225+
printer := output.NewPrinter(cfg, noColor)
220226

221227
// Get current platform
222228
plat := platform.Current()
223229

224230
// Create spinner
225231
spinner := output.NewSpinner(
226232
output.WithMessage("Searching catalog..."),
227-
output.WithNoColor(!cfg.UI.UseColors),
233+
output.WithNoColor(noColor),
228234
)
229235
spinner.Start()
230236

internal/cli/doctor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Examples:
5555
agentmgr doctor # Run all health checks
5656
agentmgr doctor --verbose # Show detailed output`,
5757
RunE: func(cmd *cobra.Command, args []string) error {
58-
printer := output.NewPrinter(cfg, noColor)
58+
printer := output.NewPrinter(cfg, output.NoColor(cfg, noColor))
5959
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
6060
defer cancel()
6161

internal/cli/output/colors.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,28 @@ import (
1212
"github.com/kevinelliott/agentmanager/pkg/config"
1313
)
1414

15+
// NoColor returns the single source of truth for "should output be
16+
// colorless?" given a config and an explicit flag. Callers should use this
17+
// helper instead of reading cfg.UI.UseColors, NO_COLOR, and --no-color
18+
// separately, which historically drifted between call sites.
19+
//
20+
// Order of precedence (any -> true):
21+
// - NO_COLOR environment variable is set to a non-empty value
22+
// - --no-color flag (caller passes this as explicitNoColor)
23+
// - cfg.UI.UseColors is false
24+
func NoColor(cfg *config.Config, explicitNoColor bool) bool {
25+
if explicitNoColor {
26+
return true
27+
}
28+
if os.Getenv("NO_COLOR") != "" {
29+
return true
30+
}
31+
if cfg != nil && !cfg.UI.UseColors {
32+
return true
33+
}
34+
return false
35+
}
36+
1537
// Printer handles colorized output for CLI commands.
1638
type Printer struct {
1739
cfg *config.Config
@@ -49,6 +71,13 @@ func NewPrinter(cfg *config.Config, noColor bool) *Printer {
4971
return p
5072
}
5173

74+
// NoColor returns true if the printer is configured to render without color.
75+
// Use this to propagate the same setting to spinners and other output helpers,
76+
// avoiding the need to re-derive from env / config at each call site.
77+
func (p *Printer) NoColor() bool {
78+
return p.noColor
79+
}
80+
5281
// SetOutput sets the output writer.
5382
func (p *Printer) SetOutput(w io.Writer) {
5483
p.out = w

0 commit comments

Comments
 (0)