Skip to content

Commit 14b2535

Browse files
halfaipgclaude
andcommitted
feat: permission system, git tools, language diagnostics
Add three Claude Code-inspired features: 1. Permission system — prompts user (y/n/a) before executing mutating tools (write_file, edit_file, shell mutations, git commits). Supports per-tool and session-wide trust levels. 2. Git tools — 5 first-class tools (git_status, git_diff, git_log, git_commit, git_branch) with parallel-safe read ops, permission gating on mutations, and subagent access to read-only git tools. 3. Diagnostics engine — auto-detects Go/TypeScript/Python checkers at startup, runs them after file edits, injects errors into agent context so it self-corrects. New slash commands: /trust, /diagnostics 33 new tests (117 total), all passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0ca631c commit 14b2535

12 files changed

Lines changed: 1874 additions & 15 deletions

agent.go

Lines changed: 139 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const (
2424
EventTurnStart // new agentic turn
2525
EventDone // agent finished all turns
2626
EventError // error occurred
27+
EventPermission // permission request for TUI
2728
)
2829

2930
type TokenUsage struct {
@@ -37,11 +38,12 @@ type AgentEvent struct {
3738
Tool string // EventToolStart / EventToolResult — tool name
3839
ToolID string // EventToolStart / EventToolResult — tool call ID
3940
Args map[string]any // EventToolStart — parsed arguments
40-
Output string // EventToolResult — tool output
41-
Success bool // EventToolResult
42-
Tokens TokenUsage // EventUsage
43-
Turn int // EventTurnStart
44-
Error error // EventError
41+
Output string // EventToolResult — tool output
42+
Success bool // EventToolResult
43+
Tokens TokenUsage // EventUsage
44+
Turn int // EventTurnStart
45+
Error error // EventError
46+
Permission *PermissionRequest // EventPermission
4547
}
4648

4749
// ──────────────────────────────────────────────────────────────
@@ -64,24 +66,34 @@ Available tools:
6466
- search_files: Regex search across files (powered by ripgrep). Find definitions, usages, etc.
6567
- web_search: Search the web. Use for current info, docs, versions, error solutions, or anything not in local files.
6668
- dispatch_agent: Spawn a read-only research subagent to investigate questions in isolated context.
67-
- shell: Run any shell command. Use for builds, tests, git, package management.
69+
- shell: Run any shell command. Use for builds, tests, package management.
70+
- git_status: Show working tree status (staged, unstaged, untracked files).
71+
- git_diff: Show file diffs (staged, unstaged, or between refs).
72+
- git_log: Show recent commit history.
73+
- git_commit: Stage files and create a commit.
74+
- git_branch: List, create, or switch branches.
6875
6976
Guidelines:
7077
- You can call multiple tools in parallel — read_file, list_files, search_files, web_search, and dispatch_agent all run concurrently
7178
- Use list_files and search_files to explore the project before making changes
7279
- Read files before editing them — understand existing code first
7380
- Make targeted, minimal changes — don't rewrite entire files unnecessarily
7481
- For multiple related edits, prefer multi_edit over separate edit_file calls
82+
- Use git tools instead of shell for git operations — they provide structured output
83+
- After you edit files, the system may report diagnostics (errors, warnings) from language tools. If diagnostics appear, fix the issues before moving on.
7584
- If a tool fails, read the error and try a different approach
7685
- When finished, briefly summarize what you changed and why`
7786

7887
type Agent struct {
79-
client *LLMClient
80-
workDir string
81-
history []ChatMessage
82-
events chan<- AgentEvent
83-
stopCh <-chan struct{}
84-
files int // count of files created/modified
88+
client *LLMClient
89+
workDir string
90+
history []ChatMessage
91+
events chan<- AgentEvent
92+
stopCh <-chan struct{}
93+
files int // count of files created/modified
94+
permCh chan PermissionResponse
95+
permState *PermissionState
96+
diag *DiagnosticsEngine
8597
}
8698

8799
func NewAgent(client *LLMClient, workDir string, events chan<- AgentEvent, stopCh <-chan struct{}) *Agent {
@@ -94,6 +106,9 @@ func NewAgent(client *LLMClient, workDir string, events chan<- AgentEvent, stopC
94106
history: []ChatMessage{
95107
{Role: "system", Content: strPtr(sysContent)},
96108
},
109+
permCh: make(chan PermissionResponse, 1),
110+
permState: &PermissionState{TrustedTools: map[string]bool{}},
111+
diag: NewDiagnosticsEngine(workDir),
97112
}
98113
}
99114

@@ -412,6 +427,32 @@ func (a *Agent) executeToolCalls(toolCalls []ToolCall, consecutiveErrors *int) {
412427
argsMap = map[string]any{"_raw": tc.Function.Arguments}
413428
}
414429

430+
// Check permission before executing
431+
if !a.checkPermission(tc.Function.Name, argsMap) {
432+
output := "Skipped: permission denied by user"
433+
a.events <- AgentEvent{
434+
Type: EventToolStart,
435+
Tool: tc.Function.Name,
436+
ToolID: tc.ID,
437+
Args: argsMap,
438+
}
439+
a.events <- AgentEvent{
440+
Type: EventToolResult,
441+
Tool: tc.Function.Name,
442+
ToolID: tc.ID,
443+
Args: argsMap,
444+
Output: output,
445+
Success: false,
446+
}
447+
a.history = append(a.history, ChatMessage{
448+
Role: "tool",
449+
ToolCallID: tc.ID,
450+
Name: tc.Function.Name,
451+
Content: strPtr(output),
452+
})
453+
continue
454+
}
455+
415456
a.events <- AgentEvent{
416457
Type: EventToolStart,
417458
Tool: tc.Function.Name,
@@ -442,6 +483,7 @@ func (a *Agent) executeToolCalls(toolCalls []ToolCall, consecutiveErrors *int) {
442483
allErrors = false
443484
if tc.Function.Name == "write_file" || tc.Function.Name == "edit_file" || tc.Function.Name == "multi_edit" {
444485
a.files++
486+
a.maybeInjectDiagnostics(tc.Function.Name, argsMap)
445487
}
446488
}
447489

@@ -473,3 +515,88 @@ func (a *Agent) executeToolCalls(toolCalls []ToolCall, consecutiveErrors *int) {
473515
func (a *Agent) FilesChanged() int {
474516
return a.files
475517
}
518+
519+
// maybeInjectDiagnostics runs language checkers after file modifications
520+
// and injects a system message with errors if found.
521+
func (a *Agent) maybeInjectDiagnostics(toolName string, args map[string]any) {
522+
if a.diag == nil || !a.diag.Enabled {
523+
return
524+
}
525+
526+
// Determine which files were modified
527+
var files []string
528+
switch toolName {
529+
case "write_file", "edit_file":
530+
if p, ok := args["path"].(string); ok {
531+
files = []string{p}
532+
}
533+
case "multi_edit":
534+
if edits, ok := args["edits"]; ok {
535+
if arr, ok := edits.([]interface{}); ok {
536+
seen := map[string]bool{}
537+
for _, e := range arr {
538+
if m, ok := e.(map[string]interface{}); ok {
539+
if p, ok := m["path"].(string); ok && !seen[p] {
540+
files = append(files, p)
541+
seen[p] = true
542+
}
543+
}
544+
}
545+
}
546+
}
547+
}
548+
549+
if len(files) == 0 {
550+
return
551+
}
552+
553+
diags := a.diag.CheckFiles(files)
554+
if len(diags) == 0 {
555+
return
556+
}
557+
558+
// Inject as system message so the LLM sees errors
559+
msg := formatDiagnosticsMessage(diags)
560+
a.history = append(a.history, ChatMessage{
561+
Role: "system",
562+
Content: strPtr(msg),
563+
})
564+
}
565+
566+
// checkPermission asks the TUI for permission if needed.
567+
// Returns true if the tool should execute, false to skip.
568+
func (a *Agent) checkPermission(toolName string, args map[string]any) bool {
569+
// Check session-level trust
570+
if a.permState.Level == PermTrustAll {
571+
return true
572+
}
573+
if a.permState.TrustedTools[toolName] {
574+
return true
575+
}
576+
577+
// Check if this tool needs permission
578+
if !NeedsPermission(toolName, args) {
579+
return true
580+
}
581+
582+
// Send permission request to TUI
583+
req := &PermissionRequest{
584+
Tool: toolName,
585+
Args: args,
586+
Summary: PermissionSummary(toolName, args),
587+
}
588+
a.events <- AgentEvent{Type: EventPermission, Permission: req}
589+
590+
// Block waiting for TUI response (or stop signal)
591+
select {
592+
case resp := <-a.permCh:
593+
if resp.TrustLevel == PermTrustTool {
594+
a.permState.TrustedTools[toolName] = true
595+
} else if resp.TrustLevel == PermTrustAll {
596+
a.permState.Level = PermTrustAll
597+
}
598+
return resp.Allowed
599+
case <-a.stopCh:
600+
return false
601+
}
602+
}

chat.go

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const (
2323
chatPlanning // Q&A planning phase
2424
chatPlanReview // reviewing generated plan
2525
chatStreaming // agent is working
26+
chatPermission // waiting for permission approval
2627
chatDoneFlash // brief green flash after completion
2728
)
2829

@@ -66,6 +67,9 @@ type chatModel struct {
6667
lastStreamRebuild time.Time // debounce streaming viewport rebuilds
6768
inThink bool // inside <think> tags (MiniMax reasoning)
6869

70+
// Permission
71+
permRequest *PermissionRequest // current pending permission prompt
72+
6973
// Glue + notifications
7074
glue *GlueClient
7175
notify *notifyManager
@@ -203,6 +207,21 @@ func (m chatModel) Update(msg tea.Msg) (chatModel, tea.Cmd) {
203207
m.rebuildViewport()
204208
return m, nil
205209
}
210+
if m.state == chatPermission {
211+
// Deny the pending permission and return to streaming
212+
if m.agent != nil {
213+
m.agent.permCh <- PermissionResponse{Allowed: false}
214+
}
215+
m.permRequest = nil
216+
m.state = chatStreaming
217+
m.input.Placeholder = "describe what you want to build..."
218+
m.segments = append(m.segments, segment{
219+
kind: "text",
220+
text: styleWarn.Render(" ✗ denied") + "\n",
221+
})
222+
m.rebuildViewport()
223+
return m, m.waitForEvent()
224+
}
206225
if m.state == chatPlanning || m.state == chatPlanReview {
207226
m.state = chatIdle
208227
m.planState = nil
@@ -224,6 +243,30 @@ func (m chatModel) Update(msg tea.Msg) (chatModel, tea.Cmd) {
224243
m.input.SetValue("")
225244

226245
switch m.state {
246+
case chatPermission:
247+
resp := parsePermissionInput(prompt)
248+
if m.agent != nil {
249+
m.agent.permCh <- resp
250+
}
251+
// Show what user decided
252+
decision := styleOK.Render(" ✓ allowed")
253+
if !resp.Allowed {
254+
decision = styleWarn.Render(" ✗ denied")
255+
} else if resp.TrustLevel == PermTrustTool {
256+
decision = styleOK.Render(" ✓ allowed (always for this tool)")
257+
} else if resp.TrustLevel == PermTrustAll {
258+
decision = styleOK.Render(" ✓ allowed (trusting all)")
259+
}
260+
m.segments = append(m.segments, segment{
261+
kind: "text",
262+
text: decision + "\n",
263+
})
264+
m.permRequest = nil
265+
m.state = chatStreaming
266+
m.input.Placeholder = "describe what you want to build..."
267+
m.rebuildViewport()
268+
return m, m.waitForEvent()
269+
227270
case chatPlanning:
228271
// User answering a planning question
229272
cmds = append(cmds, m.handlePlanAnswer(prompt))
@@ -756,7 +799,7 @@ func (m *chatModel) handlePlanReview(input string) tea.Cmd {
756799

757800
func (m *chatModel) handleAgentEvent(evt AgentEvent) tea.Cmd {
758801
// Guard: ignore events if we're no longer streaming (e.g. after ctrl+c)
759-
if m.state != chatStreaming && evt.Type != EventDone {
802+
if m.state != chatStreaming && m.state != chatPermission && evt.Type != EventDone {
760803
return nil
761804
}
762805

@@ -920,6 +963,21 @@ func (m *chatModel) handleAgentEvent(evt AgentEvent) tea.Cmd {
920963
tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return narrateTickMsg{} }),
921964
)
922965

966+
case EventPermission:
967+
m.flushStreamingText()
968+
m.state = chatPermission
969+
m.permRequest = evt.Permission
970+
// Render the permission prompt
971+
summary := evt.Permission.Summary
972+
m.segments = append(m.segments, segment{
973+
kind: "text",
974+
text: "\n" + styleWarn.Render(" ⚡ Permission required: ") + styleMuted.Render(summary) + "\n" +
975+
styleDim.Render(" y/n/a (yes, no, always for this tool): "),
976+
})
977+
m.input.Placeholder = "y/n/a..."
978+
m.rebuildViewport()
979+
return m.waitForEvent() // keep listening for other events while waiting
980+
923981
case EventError:
924982
m.flushStreamingText()
925983
errStr := "unknown error"
@@ -983,6 +1041,8 @@ func (m chatModel) View() string {
9831041
} else {
9841042
statusParts = append(statusParts, m.spinner.View()+styleMuted.Render(" working"))
9851043
}
1044+
case chatPermission:
1045+
statusParts = append(statusParts, styleWarn.Render("⚡ permission"))
9861046
case chatPlanning:
9871047
statusParts = append(statusParts, lipgloss.NewStyle().Foreground(colPurple).Render("◆ planning"))
9881048
case chatPlanReview:
@@ -1035,7 +1095,7 @@ func (m chatModel) View() string {
10351095
// ── Frame ────────────────────────────────────────────────
10361096
var frame lipgloss.Style
10371097
switch m.state {
1038-
case chatStreaming:
1098+
case chatStreaming, chatPermission:
10391099
frame = styleFrameActive.Width(m.width - 2)
10401100
case chatDoneFlash:
10411101
frame = styleFrameDone.Width(m.width - 2)
@@ -1066,6 +1126,8 @@ func (m chatModel) View() string {
10661126
switch m.state {
10671127
case chatStreaming:
10681128
hint = styleDim.Render("ctrl+c stop") + sep + styleDim.Render("↑↓ scroll")
1129+
case chatPermission:
1130+
hint = styleDim.Render("y allow") + sep + styleDim.Render("n deny") + sep + styleDim.Render("a always") + sep + styleDim.Render("ctrl+c deny")
10691131
case chatPlanning:
10701132
hint = styleDim.Render("ctrl+c cancel") + sep + styleDim.Render("enter answer")
10711133
case chatPlanReview:

0 commit comments

Comments
 (0)