* feat(extensions): add OnLLMUsage, SetState, enriched AgentEndEvent (#53)
Three additive primitives to the extension API:
- OnLLMUsage event: per-LLM-call token + cost deltas attributed to the
specific model/provider used for each round-trip. Derived from the SDK
StepFinishEvent in the extension bridge. Enables accurate budget
enforcement between calls instead of only at turn boundaries.
- ctx.SetState / GetState / DeleteState / ListState: session-scoped,
last-write-wins key-value store backed by a sidecar file
(<session>.ext-state.json) outside the conversation tree. Reads are
O(1), writes don't grow the JSONL, and the store is not duplicated on
fork. State is preserved across hot-reloads.
- Enriched AgentEndEvent: ToolCallCount, ToolNames, LLMCallCount, token
deltas (input/output/cache-read/cache-write), CostDelta, and
DurationMs populated by a per-turn aggregator. Existing handlers
reading only Response/StopReason are unaffected.
Includes unit tests for the state store, LLMUsage registration,
enriched AgentEndEvent, turn aggregator, llmUsageMeta, and sidecar path
derivation. Adds examples/extensions/usage-budget.go demoing all three
primitives together. Documents the additions in README, the docs site
(extensions overview, capabilities, examples), and the kit-extensions
and kit-sdk skill guides.
Fixes#53
* fix(extensions): address review feedback on state store and llmUsageMeta
- Serialize SetState/DeleteState saver invocations through a new saverMu
so overlapping atomic-rename writes can no longer race on the shared
.tmp file and persist an older snapshot after a newer one.
- LoadStateFromFile now clears the in-memory store when the sidecar is
missing or empty, matching the documented "replace … with its
contents" contract. This makes session-switching safe by preventing
keys from a prior session leaking into a new one. Tests updated to
cover both the missing-file and empty-file cases.
- llmUsageMeta now detects Anthropic OAuth credentials and returns
Cost=0, matching the comment and the existing usage_tracker behavior
for OAuth users. Mirrors the OAuth detection already used in
cmd/extension_context.go.
- Document the single-in-flight-turn assumption baked into the
per-turn aggregator with a clear migration path (per-turn ID) for if
concurrent turns ever become a supported use case.
* fix(extensions): release saverMu on panic in state store
Extract a runSaver helper that locks saverMu and defers Unlock before
invoking the persistence callback. Without the deferred Unlock, a panic
inside the saver (e.g. disk full mid-write) would leave saverMu held
forever and deadlock the next SetState/DeleteState. Both SetState and
DeleteState now route through the helper. New TestRunner_State_Saver
PanicReleasesSaverMu reproduces the deadlock window with a 2s deadline
and proves the mutex is released after a panic.
Switch from standard log.Printf to charmbracelet/log for extension loading
messages. This ensures DEBUG output only appears when explicitly enabled.
- Remove unconditional WARN log for failed extension loads
- Convert DEBUG loaded extension message to structured log.Debug call
- Add tea.LogToFile in runInteractiveModeBubbleTea to send stdlib log
output to /tmp/kit/kit.log instead of stderr
- Replace charmbracelet/log with stdlib log in extensions loader,
runner, watcher, prompts loader, and pkg/kit so all log calls go
through the redirected stdlib logger
- Leave charmbracelet/log in CLI-only commands (install, acp) and
acpserver where stderr logging is correct
- Filter out _test.go files in findExtensionsInDir, findExtensionsInRepo,
and ScanForExtensions to prevent Yaegi from loading test files
- Narrow examples/ traversal so only recognized extension directories
(extensions/, ext/, *-ext/, *-extensions/) are scanned, not arbitrary
subdirs like examples/sdk/ that import pkg/kit
- Extract isShellTool() helper in tool_renderers.go to eliminate
duplicated shell tool matching logic
- Replace bannedCommands slice with compiled regex in bash.go for
cleaner security validation
- Extract pathSet helper type in loader.go for reusable path
deduplication
- Consolidate ac()/acOr() helpers in themes.go for better organization
- Total reduction: ~34 lines across 4 files
All tests pass (go test -race ./...) and build succeeds.
Extensions can now subscribe to streaming tool output events using
OnToolOutput(), giving them the same power as the internal TUI to
observe and react to tool execution in real-time.
Changes:
- Add ToolOutputEvent struct to extensions API
- Add ToolOutput constant to EventType
- Add OnToolOutput() handler registration method
- Add event bridging from kit to extensions runner
- Export ToolOutputEvent in Yaegi symbols
- Add OnToolOutput() to public SDK (pkg/kit)
Example usage in an extension:
api.OnToolOutput(func(e ext.ToolOutputEvent, ctx ext.Context) {
ctx.PrintInfo(fmt.Sprintf("%s: %s", e.ToolName, e.Chunk))
})
- Replace custom multi-select with huh.NewMultiSelect for kit install --select
- Replace raw bufio prompts in cmd/auth.go with huh.NewConfirm and huh.NewInput
- Fix extension discovery to use opinionated conventions (only scan root,
extensions/, ext/, examples/extensions/ directories, skip cmd/internal/pkg/)
- Fix loader to use same convention-based scanning for installed git repos
- Fix errcheck lint warning in loader.go
- ctx.SuspendTUI(callback): releases terminal for interactive subprocesses
(vim, shell, htop), automatically restores TUI when callback returns.
Uses BubbleTea v2 ReleaseTerminal/RestoreTerminal.
- api.RegisterMessageRenderer(config) + ctx.RenderMessage(name, content):
named render functions for branded/styled extension output. Renderers
receive content and terminal width, return ANSI-styled strings.
- ctx.ReloadExtensions(): hot-reloads all extensions from disk. Emits
SessionShutdown to old extensions, reloads source, emits SessionStart
to new. Event handlers, commands, renderers, shortcuts update immediately.
TUI command list refreshes via WidgetUpdateEvent. Extension tools are
NOT updated (baked into agent at creation, documented limitation).
New example extensions: interactive-shell.go, branded-output.go, dev-reload.go
- RegisterShortcut(ShortcutDef, handler) for global keyboard shortcuts
that fire across all non-modal app states (after ctrl+c, before
component dispatch). Handlers run in goroutines for safe blocking calls.
- ToolContext with IsCancelled/OnProgress for rich tool execution;
ExecuteWithContext on ToolDef takes priority over simple Execute.
- Source field on ToolCallEvent (currently "llm", forward-compatible
with future user-initiated tool calls).
- Fix missing //go:build ignore on context-inject.go.
- Update plan-mode.go to register ctrl+alt+p shortcut.
Add three new extension events that allow extensions to gate destructive
session operations and compaction:
- OnBeforeFork: fires before branching in the tree selector; handler can
cancel with reason (e.g. dirty-repo guard)
- OnBeforeSessionSwitch: fires before /new resets the session branch;
handler can cancel with reason
- OnBeforeCompact: fires before context compaction (auto or manual);
handler receives token stats and IsAutomatic flag, can cancel
Includes SDK hook registry (beforeCompact), extension bridge, UI
callbacks threaded through AppModelOptions, and two example extensions:
- confirm-destructive.go: git dirty check + fork confirmation
- compact-notify.go: compaction notification + auto-compact gating
Extensions can now register an OnContextPrepare handler that fires after
the context window is built from the session tree and before messages are
sent to the LLM. Handlers receive ContextMessage entries with positional
indices and can filter, reorder, or inject messages. Original messages
referenced by index preserve tool calls, reasoning, and other complex
parts. New context-inject example extension demonstrates injecting a
local .kit/context.md file as an ephemeral system message every turn.
Extensions can now override how tool calls are displayed in the TUI via
API.RegisterToolRenderer(). Supports custom display name, border color,
background color, header parameter formatting, body rendering, and
optional markdown processing of custom body output.
os/exec lives in yaegi's stdlib/unrestricted package, not the default
stdlib.Symbols. Without it, extensions importing os/exec fail with
'unable to find source'. This is needed for the /run command example
and any extension that spawns subprocesses.
SendMessage lets extensions inject messages into the conversation and
trigger new agent turns, enabling async patterns like background
subagent execution. It delegates to app.Run() which handles queueing.
CommandDef.Execute now receives Context so commands can access
SendMessage, Print*, and session metadata. The UI layer wraps the
call via runner.GetContext() at the boundary.
Also fixes all 20+ golangci-lint issues across the codebase:
errcheck, modernize (min/max/slices.Contains/SplitSeq/range-over-int),
staticcheck (error string casing), and unused code removal.