Skip to content

Commit dc69aaa

Browse files
committed
feat: add a plugin-first runtime
1 parent 56088ee commit dc69aaa

38 files changed

+3157
-262
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ This split matters. The agent loop stays small and reusable, while long-running
5454
- Non-interactive print mode for pipes and scripts (`-p`)
5555
- Slash commands: `/model`, `/compact`, `/plan`, `/resume`, `/copy`, ...
5656

57+
**Extensibility**
58+
- Plugin-first architecture with project and user plugin scopes
59+
- Plugin contributions: skills, commands, MCP servers
60+
- `/plugins create`, `/plugins install`, and `/plugins remove` for local plugin lifecycle
61+
- Trust / enable / disable governance with runtime reload
62+
5763
## Installation
5864

5965
**Pre-built binary (recommended):**
@@ -145,6 +151,8 @@ Config files: `~/.codebot/settings.json` (global) or `.codebot/settings.json` (p
145151

146152
All fields are optional. See [settings.example.jsonc](settings.example.jsonc) for the full reference with comments.
147153

154+
Plugin authoring guide: [docs/plugins.md](docs/plugins.md). Real example plugins live under `docs/examples/plugins/`, including `review-assistant`, `release-ops`, and `docs-context`.
155+
148156
## Requirements
149157

150158
- API key for at least one provider

README_zh.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@
5454
- 非交互管道模式(`-p`
5555
- 斜杠命令:`/model`, `/compact`, `/plan`, `/resume`, `/copy`, ...
5656

57+
**扩展**
58+
- plugin-first 架构,支持 project / user 两级 plugin
59+
- plugin 贡献:skills、commands、MCP servers
60+
- `/plugins create``/plugins install``/plugins remove` 管理本地 plugin 生命周期
61+
- trust / enable / disable 治理与运行时热刷新
62+
5763
## 安装
5864

5965
**预编译二进制(推荐):**
@@ -145,6 +151,8 @@ Codebot 采用分层的 Coding Agent 架构:
145151

146152
所有字段可选,参考 [settings.example.jsonc](settings.example.jsonc) 了解完整配置项及说明。
147153

154+
Plugin 开发参考 [docs/plugins.md](docs/plugins.md)。真实示例 plugin 放在 `docs/examples/plugins/`,目前包含 `review-assistant``release-ops``docs-context`
155+
148156
## 环境要求
149157

150158
- 至少一个 Provider 的 API Key

cmd/codebot/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func main() {
5757
if rt.Session != nil && rt.Session.ModelName() != "" {
5858
modelName = rt.Session.ModelName()
5959
}
60-
if err := ui.RunTUI(rt.Session, rt.Cwd, rt.GitBranch, modelName, version, rt.ApprovalEngine, rt.TaskRuntime, rt.MCPManager, rt.MCPServers, rt.SkillCatalog, rt.EnvHint, rt.SessionStore, rt.PlanSlug, rt.PlanTitle, rt.PlanPhase, rt.PlanPreMode, rt.PlanAllowedCommands); err != nil {
60+
if err := ui.RunTUI(rt.Session, rt.Cwd, rt.GitBranch, modelName, version, rt.ApprovalEngine, rt.TaskRuntime, rt.MCPManager, rt.MCPServers, rt.PluginCatalog, rt.SkillCatalog, rt.EnvHint, rt.SessionStore, rt.PlanSlug, rt.PlanTitle, rt.PlanPhase, rt.PlanPreMode, rt.PlanAllowedCommands); err != nil {
6161
fmt.Fprintf(os.Stderr, "error: %v\n", err)
6262
os.Exit(1)
6363
}

internal/agent/session.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,6 @@ type skillRuntimeState struct {
133133
baseModel string
134134
baseChatModel agentcore.ChatModel
135135
baseThinking string
136-
hooks config.HooksConfig
137136
invoked []invokedSkillSnapshot
138137
invocationCount map[string]int
139138
}

internal/agent/session_compaction.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ func (c *sessionContextController) compactWithReason(reason string) (result Comp
3030
result = CompactionResult{Reason: reason}
3131
c.session.recordCompactionAttempt(CompactionKindFull)
3232
c.session.emit(SessionEvent{Type: SEAutoCompactionStart, CompactionReason: reason, CompactionKind: CompactionKindFull})
33+
if c.session.hookRunner != nil {
34+
tokensBefore := agentctx.EstimateTotal(c.session.agent.Messages())
35+
c.session.hookRunner.RunPreCompact(context.Background(), reason, tokensBefore)
36+
}
3337
defer func() {
3438
if err != nil {
3539
return
@@ -48,6 +52,9 @@ func (c *sessionContextController) compactWithReason(reason string) (result Comp
4852
KeptCount: result.KeptCount,
4953
SplitTurn: result.SplitTurn,
5054
})
55+
if c.session.hookRunner != nil {
56+
c.session.hookRunner.RunPostCompact(context.Background(), reason, result.TokensBefore, result.TokensAfter)
57+
}
5158
}()
5259

5360
msgs := c.session.agent.Messages()

internal/agent/session_prompt.go

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ func (s *Session) ReplaceAllTools(tools []agentcore.Tool) {
3333
s.prompts.replaceAllTools(tools)
3434
}
3535

36+
func (s *Session) ReplaceMCPTools(tools []agentcore.Tool) {
37+
s.prompts.replaceMCPTools(tools)
38+
}
39+
3640
func (s *Session) SetSystemSuffix(suffix string) {
3741
s.prompts.setApprovedPlanPrompt(suffix)
3842
}
@@ -65,6 +69,18 @@ func (s *Session) SkillCatalog() *skill.Catalog {
6569
return s.skillCatalog
6670
}
6771

72+
func (s *Session) SetSkillCatalog(catalog *skill.Catalog) {
73+
s.skillCatalog = catalog
74+
if catalog != nil {
75+
s.skills = catalog.List()
76+
} else {
77+
s.skills = nil
78+
}
79+
if s.prompts != nil {
80+
s.prompts.rebuildPrompt()
81+
}
82+
}
83+
6884
func (s *Session) Reload() {
6985
s.prompts.reload()
7086
}
@@ -117,6 +133,25 @@ func (m *sessionPromptManager) replaceAllTools(tools []agentcore.Tool) {
117133
m.rebuildPrompt()
118134
}
119135

136+
func (m *sessionPromptManager) replaceMCPTools(tools []agentcore.Tool) {
137+
wasAllTools := sameToolSet(m.session.activeTools, m.session.allTools)
138+
activeHasMCP := hasMCPTools(m.session.activeTools)
139+
140+
m.session.allTools = replaceMCPToolsInSlice(m.session.allTools, tools)
141+
142+
switch {
143+
case wasAllTools:
144+
m.session.activeTools = m.session.allTools
145+
case activeHasMCP:
146+
m.session.activeTools = replaceMCPToolsInSlice(m.session.activeTools, tools)
147+
default:
148+
return
149+
}
150+
151+
m.session.agent.SetTools(m.session.activeTools...)
152+
m.rebuildPrompt()
153+
}
154+
120155
func (m *sessionPromptManager) setMCPInstructions(text string) {
121156
m.session.overlays.MCP = text
122157
m.rebuildPrompt()
@@ -142,7 +177,7 @@ func (m *sessionPromptManager) reload() {
142177
m.session.skillCatalog.Reload()
143178
m.session.skills = m.session.skillCatalog.List()
144179
} else {
145-
m.session.skills = skill.NewCatalog(m.session.cwd).List()
180+
m.session.skills = skill.NewCatalog(m.session.cwd, nil).List()
146181
}
147182
m.rebuildPrompt()
148183
}
@@ -215,6 +250,41 @@ func (m *sessionPromptManager) refreshSkillReminders() {
215250
m.session.mu.Unlock()
216251
}
217252

253+
const mcpToolPrefix = "mcp__"
254+
255+
func replaceMCPToolsInSlice(base []agentcore.Tool, mcpTools []agentcore.Tool) []agentcore.Tool {
256+
out := make([]agentcore.Tool, 0, len(base)+len(mcpTools))
257+
for _, tool := range base {
258+
if strings.HasPrefix(tool.Name(), mcpToolPrefix) {
259+
continue
260+
}
261+
out = append(out, tool)
262+
}
263+
out = append(out, mcpTools...)
264+
return out
265+
}
266+
267+
func hasMCPTools(tools []agentcore.Tool) bool {
268+
for _, tool := range tools {
269+
if strings.HasPrefix(tool.Name(), mcpToolPrefix) {
270+
return true
271+
}
272+
}
273+
return false
274+
}
275+
276+
func sameToolSet(a, b []agentcore.Tool) bool {
277+
if len(a) != len(b) {
278+
return false
279+
}
280+
for i := range a {
281+
if a[i].Name() != b[i].Name() {
282+
return false
283+
}
284+
}
285+
return true
286+
}
287+
218288
func (s *Session) skillUsageScoresLocked() map[string]float64 {
219289
if s.skillUsage != nil {
220290
return s.skillUsage.Scores(time.Now())

internal/agent/session_runtime.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import (
1717
var errStaleSessionGeneration = errors.New("stale session generation")
1818

1919
func (s *Session) Prompt(text string) error {
20+
if s.hookRunner != nil {
21+
if err := s.hookRunner.RunUserPromptSubmit(context.Background(), text); err != nil {
22+
return err
23+
}
24+
}
2025
s.beginTurn()
2126
if s.beforePrompt != nil {
2227
s.beforePrompt()

internal/agent/session_state.go

Lines changed: 0 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"time"
99

1010
"github.com/voocel/agentcore"
11-
"github.com/voocel/codebot/internal/config"
1211
"github.com/voocel/codebot/internal/skill"
1312
)
1413

@@ -123,12 +122,6 @@ func (s *Session) ApplySkillDelta(name string, delta skill.Delta) error {
123122
}
124123
s.applyTemporarySkillThinking(delta.Effort)
125124

126-
s.mu.Lock()
127-
if len(delta.Hooks) > 0 {
128-
s.skillRuntime.hooks = mergeHooksConfig(s.skillRuntime.hooks, toConfigHooks(delta.Hooks))
129-
}
130-
s.mu.Unlock()
131-
132125
if s.skillAllowsSetter != nil {
133126
s.skillAllowsSetter(delta.AllowedTools)
134127
}
@@ -143,12 +136,6 @@ func (s *Session) clearSkillDelta() {
143136
s.clearTemporarySkillOverrides()
144137
}
145138

146-
func (s *Session) CurrentSkillHooks() config.HooksConfig {
147-
s.mu.Lock()
148-
defer s.mu.Unlock()
149-
return cloneHooksConfig(s.skillRuntime.hooks)
150-
}
151-
152139
func (s *Session) recordInvokedSkill(name, promptText string, paths []string) {
153140
name = strings.TrimSpace(name)
154141
promptText = strings.TrimSpace(promptText)
@@ -193,89 +180,6 @@ func (s *Session) recordInvokedSkill(name, promptText string, paths []string) {
193180
}
194181
}
195182

196-
func cloneHooksConfig(src config.HooksConfig) config.HooksConfig {
197-
if len(src) == 0 {
198-
return nil
199-
}
200-
dst := make(config.HooksConfig, len(src))
201-
for event, entries := range src {
202-
cp := make([]config.HookEntry, len(entries))
203-
copy(cp, entries)
204-
dst[event] = cp
205-
}
206-
return dst
207-
}
208-
209-
func toConfigHooks(src skill.HooksConfig) config.HooksConfig {
210-
if len(src) == 0 {
211-
return nil
212-
}
213-
dst := make(config.HooksConfig, len(src))
214-
for event, entries := range src {
215-
cp := make([]config.HookEntry, len(entries))
216-
for i, entry := range entries {
217-
cp[i] = config.HookEntry{
218-
Type: entry.Type,
219-
Command: entry.Command,
220-
Matcher: entry.Matcher,
221-
Blocking: entry.Blocking,
222-
Timeout: entry.Timeout,
223-
}
224-
}
225-
dst[event] = cp
226-
}
227-
return dst
228-
}
229-
230-
func mergeHooksConfig(base, add config.HooksConfig) config.HooksConfig {
231-
if len(add) == 0 {
232-
return cloneHooksConfig(base)
233-
}
234-
merged := cloneHooksConfig(base)
235-
if merged == nil {
236-
merged = make(config.HooksConfig, len(add))
237-
}
238-
for event, entries := range add {
239-
for _, entry := range entries {
240-
if !containsHookEntry(merged[event], entry) {
241-
merged[event] = append(merged[event], entry)
242-
}
243-
}
244-
}
245-
return merged
246-
}
247-
248-
func containsHookEntry(entries []config.HookEntry, target config.HookEntry) bool {
249-
for _, entry := range entries {
250-
if hookEntryEqual(entry, target) {
251-
return true
252-
}
253-
}
254-
return false
255-
}
256-
257-
func hookEntryEqual(a, b config.HookEntry) bool {
258-
return a.Type == b.Type &&
259-
a.Command == b.Command &&
260-
a.Matcher == b.Matcher &&
261-
boolPtrValue(a.Blocking) == boolPtrValue(b.Blocking) &&
262-
intPtrValue(a.Timeout) == intPtrValue(b.Timeout)
263-
}
264-
265-
func boolPtrValue(v *bool) bool {
266-
if v == nil {
267-
return false
268-
}
269-
return *v
270-
}
271-
272-
func intPtrValue(v *int) int {
273-
if v == nil {
274-
return 0
275-
}
276-
return *v
277-
}
278-
279183
func cloneInvocationCounts(src map[string]int) map[string]int {
280184
if len(src) == 0 {
281185
return nil

internal/agent/session_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,40 @@ func TestSetToolsRebuildsPrompt(t *testing.T) {
591591
}
592592
}
593593

594+
func TestReplaceMCPToolsUpdatesAllToolsWithoutBreakingFilteredActiveTools(t *testing.T) {
595+
t.Parallel()
596+
597+
allTools := []agentcore.Tool{
598+
&stubTool{name: "read", desc: "Read file contents"},
599+
&stubTool{name: "write", desc: "Write file contents"},
600+
&stubTool{name: "mcp__docs__search", desc: "Search docs"},
601+
}
602+
603+
ag := agentcore.NewAgent(agentcore.WithModel(&stubChatModel{}), agentcore.WithTools(allTools...))
604+
s := NewSession(SessionConfig{
605+
Agent: ag,
606+
Settings: config.Resolved{MaxTurns: 30},
607+
Cwd: t.TempDir(),
608+
Tools: allTools,
609+
})
610+
t.Cleanup(s.Close)
611+
612+
s.SetTools(s.ToolsByName("read")...)
613+
s.ReplaceMCPTools([]agentcore.Tool{
614+
&stubTool{name: "mcp__ops__deploy", desc: "Deploy service"},
615+
})
616+
617+
if got := len(s.ToolsByName("mcp__ops__deploy")); got != 1 {
618+
t.Fatalf("expected refreshed MCP tool in all tools, got %d", got)
619+
}
620+
if got := len(s.ToolsByName("mcp__docs__search")); got != 0 {
621+
t.Fatalf("expected stale MCP tool removed from all tools, got %d", got)
622+
}
623+
if len(s.activeTools) != 1 || s.activeTools[0].Name() != "read" {
624+
t.Fatalf("expected filtered active tools to remain unchanged, got %#v", s.activeTools)
625+
}
626+
}
627+
594628
func TestBuildUserMessagePrependsRuntimeRemindersBeforeStaticReminders(t *testing.T) {
595629
t.Parallel()
596630

0 commit comments

Comments
 (0)