diff --git a/cmd/sin-code/chat_cmd.go b/cmd/sin-code/chat_cmd.go index 884b074e..cc5dfc80 100644 --- a/cmd/sin-code/chat_cmd.go +++ b/cmd/sin-code/chat_cmd.go @@ -153,7 +153,12 @@ func NewChatCmd() *cobra.Command { sin-code chat --fusion-max-cost USD kill-switch per tournament invocation (default 5.0) sin-code chat --thinking-enabled send thinking{type:"enabled"} on each request (per-provider reasoning budget) sin-code chat --thinking-budget per-request thinking.budget_tokens cap (0 = unbounded / provider default) - Oracle-mode fusion is experimental; set fusion.oracle_mode=true via config. Prefer PoC mode for verifiable tasks.`, + Oracle-mode fusion is experimental; set fusion.oracle_mode=true via config. Prefer PoC mode for verifiable tasks. + +Post-edit automation (issue #376, opt-in via ~/.config/sin/sin-code.toml): + agentloop.auto_lint=true after every sin_write/sin_edit to a .go file: run gofmt -l + go vet (read-only — advisory) + agentloop.auto_test=true after every sin_write/sin_edit to a *_test.go file: run go test -race -count=1 on the file's package (may mutate state — advisory) + Both default off. Set agentloop.auto_lint=true to auto-lint after edits. Both keys are advisory: warnings only, never block.`, RunE: func(cmd *cobra.Command, args []string) error { return runChat(cmd.Context(), opts) }, @@ -331,6 +336,20 @@ func runChat(ctx context.Context, opts *chatOptions) error { } hookEngine := chatNewHooksFn(loadHooks(workspace)) + // --- post-edit auto listeners (issue #376) ------------------ + // Register the lint + test listeners ONLY when the operator has opted + // in via config. Default behaviour (no listener registered) preserves + // the legacy single-shot semantics and stays off in headless / CI runs. + if sinCfg.AgentLoopAutoLint { + hookEngine.RegisterPostListener(hooks.AutoLintListener(hooks.AutoHookConfig{ + Timeout: time.Duration(sinCfg.AgentLoopAutoLintTimeout) * time.Second, + })) + } + if sinCfg.AgentLoopAutoTest { + hookEngine.RegisterPostListener(hooks.AutoTestListener(hooks.AutoHookConfig{ + Timeout: time.Duration(sinCfg.AgentLoopAutoTestTimeout) * time.Second, + })) + } // --- auto-activation hook (issue #176) ------------------------------ // Off by default. Privacy-first: only opens when the operator sets diff --git a/cmd/sin-code/internal/config.go b/cmd/sin-code/internal/config.go index 5cbebf10..20c15b9b 100644 --- a/cmd/sin-code/internal/config.go +++ b/cmd/sin-code/internal/config.go @@ -65,6 +65,17 @@ type SinCodeConfig struct { AgentYolo bool `toml:"agent.yolo"` AgentLoopRequiredTools []string `toml:"agentloop.required_tools"` AgentLoopForbiddenTools []string `toml:"agentloop.forbidden_tools"` + // AgentLoopAutoLint enables post-edit auto-lint listener (issue #376). + // Read-only: gofmt -l + go vet on every .go file edited by sin_write/sin_edit. + // Default false; opt-in only. + AgentLoopAutoLint bool `toml:"agentloop.auto_lint"` + // AgentLoopAutoTest enables post-edit auto-test listener (issue #376). + // go test -count=1 on every *_test.go file; may produce side-effects. + // Default false; opt-in only. + AgentLoopAutoTest bool `toml:"agentloop.auto_test"` + // Per-command timeout cap (seconds). 0 -> 30 lint / 120 test. + AgentLoopAutoLintTimeout int `toml:"agentloop.auto_lint_timeout"` + AgentLoopAutoTestTimeout int `toml:"agentloop.auto_test_timeout"` ToolsAllow []string `toml:"permissions.tools_allow"` ToolsDeny []string `toml:"permissions.tools_deny"` PathsMCPConfig string `toml:"paths.mcp_config"` @@ -149,6 +160,10 @@ func defaultConfig() SinCodeConfig { AgentYolo: false, AgentLoopRequiredTools: []string{}, AgentLoopForbiddenTools: []string{}, + AgentLoopAutoLint: false, + AgentLoopAutoTest: false, + AgentLoopAutoLintTimeout: 30, + AgentLoopAutoTestTimeout: 120, ToolsAllow: []string{}, ToolsDeny: []string{}, PathsMCPConfig: filepath.Join("~", ".sin-code", "mcp.json"), @@ -499,6 +514,11 @@ agent.yolo = %v agentloop.required_tools = %q agentloop.forbidden_tools = %q +agentloop.auto_lint = %v +agentloop.auto_test = %v +agentloop.auto_lint_timeout = %d +agentloop.auto_test_timeout = %d + permissions.tools_allow = %q permissions.tools_deny = %q @@ -528,6 +548,8 @@ worktree.target_branch = %q cfg.LLMStyle, cfg.AgentVerifyMode, cfg.AgentMaxTurns, cfg.AgentHeadless, cfg.AgentYolo, strings.Join(cfg.AgentLoopRequiredTools, ","), strings.Join(cfg.AgentLoopForbiddenTools, ","), + cfg.AgentLoopAutoLint, cfg.AgentLoopAutoTest, + cfg.AgentLoopAutoLintTimeout, cfg.AgentLoopAutoTestTimeout, strings.Join(cfg.ToolsAllow, ","), strings.Join(cfg.ToolsDeny, ","), cfg.PathsMCPConfig, cfg.PathsSkillsDir, cfg.TestCoverageThreshold, cfg.TestMutationThreshold, cfg.TestAutoGenerate, cfg.TestTimeoutSeconds, cfg.TestUseLLM, cfg.TestRepairRounds, @@ -599,6 +621,14 @@ func getConfigValueFrom(key string, cfg SinCodeConfig) (string, error) { return strings.Join(cfg.AgentLoopRequiredTools, ","), nil case "agentloop.forbidden_tools": return strings.Join(cfg.AgentLoopForbiddenTools, ","), nil + case "agentloop.auto_lint": + return fmt.Sprintf("%v", cfg.AgentLoopAutoLint), nil + case "agentloop.auto_test": + return fmt.Sprintf("%v", cfg.AgentLoopAutoTest), nil + case "agentloop.auto_lint_timeout": + return fmt.Sprintf("%d", cfg.AgentLoopAutoLintTimeout), nil + case "agentloop.auto_test_timeout": + return fmt.Sprintf("%d", cfg.AgentLoopAutoTestTimeout), nil case "permissions.tools_allow": return strings.Join(cfg.ToolsAllow, ","), nil case "permissions.tools_deny": diff --git a/cmd/sin-code/internal/hooks/auto_hook.go b/cmd/sin-code/internal/hooks/auto_hook.go new file mode 100644 index 00000000..7e58229b --- /dev/null +++ b/cmd/sin-code/internal/hooks/auto_hook.go @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: MIT +// Purpose: opt-in post-edit automation tied to the agent loop's +// tool.post event (issue #376). AutoLintListener runs gofmt + go vet +// on every .go file edited by sin_write/sin_edit when agentloop.auto_lint +// is true (read-only). AutoTestListener runs `go test -race -count=1` on +// the enclosing package whenever a *_test.go file is touched when +// agentloop.auto_test is true (may produce side-effects). Both listeners +// are gated on dedicated config keys — default behaviour preserved. +package hooks + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +func AutoLintListener(cfg AutoHookConfig) PostListener { + cfg = cfg.normalized() + return func(ctx context.Context, p Payload) []string { + if p.Name != "sin_write" && p.Name != "sin_edit" { + return nil + } + path, _ := p.Data["path"].(string) + if path == "" || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + workdir := p.Workspace + if workdir == "" { + workdir = "." + } + absPath := path + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(workdir, path) + } + if _, err := os.Stat(absPath); err != nil { + return nil + } + reports := runLintCommands(ctx, absPath, workdir, cfg.Timeout) + if len(reports) == 0 { + return nil + } + for _, r := range reports { + fmt.Fprintf(os.Stderr, "[auto-lint] %s: %s\n", path, r) + } + out := make([]string, 0, len(reports)) + for _, r := range reports { + out = append(out, fmt.Sprintf("[auto-lint %s] %s", path, r)) + } + return out + } +} + +func AutoTestListener(cfg AutoHookConfig) PostListener { + cfg = cfg.normalized() + return func(ctx context.Context, p Payload) []string { + if p.Name != "sin_write" && p.Name != "sin_edit" { + return nil + } + path, _ := p.Data["path"].(string) + if path == "" || !strings.HasSuffix(path, "_test.go") { + return nil + } + workdir := p.Workspace + if workdir == "" { + workdir = "." + } + absPath := path + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(workdir, path) + } + if _, err := os.Stat(absPath); err != nil { + return nil + } + report := runTestCommand(ctx, absPath, workdir, cfg.Timeout) + if report == "" { + fmt.Fprintf(os.Stderr, "[auto-test] %s: PASS\n", path) + return nil + } + fmt.Fprintf(os.Stderr, "[auto-test] %s: FAIL\n", path) + if len(report) > 4096 { + report = report[:4096] + "\n[... truncated; rerun `sin_test` for the full log]" + } + return []string{fmt.Sprintf("[auto-test %s] FAIL: %s", path, report)} + } +} + +type AutoHookConfig struct { + Timeout time.Duration +} + +func (c AutoHookConfig) normalized() AutoHookConfig { + if c.Timeout <= 0 { + c.Timeout = AutoLintDefaultTimeout + } + return c +} + +const ( + AutoLintDefaultTimeout time.Duration = 30 * time.Second + AutoTestDefaultTimeout time.Duration = 120 * time.Second +) + +func runLintCommands(ctx context.Context, goFile, workdir string, timeout time.Duration) []string { + var out []string + if !strings.HasSuffix(goFile, ".go") { + return out + } + gofmtCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + gofmtCmd := exec.CommandContext(gofmtCtx, "gofmt", "-l", goFile) + gofmtCmd.Dir = workdir + var gofmtOut, gofmtErr bytes.Buffer + gofmtCmd.Stdout = &gofmtOut + gofmtCmd.Stderr = &gofmtErr + if runErr := gofmtCmd.Run(); runErr != nil { + if !isNotFound(runErr) { + out = append(out, fmt.Sprintf("gofmt exec failed: %v", runErr)) + } + } else if gofmtOut.Len() > 0 { + out = append(out, fmt.Sprintf("gofmt: %s needs `gofmt -w`", filepath.Base(goFile))) + } + + vetCtx, cancel2 := context.WithTimeout(ctx, timeout) + defer cancel2() + dirOfFile := filepath.Dir(goFile) + if dirOfFile == "" { + dirOfFile = "." + } + pkgDir := filepath.Base(dirOfFile) + cmdWorkdir := filepath.Dir(dirOfFile) + if cmdWorkdir == "" { + cmdWorkdir = "." + } + vetCmd := exec.CommandContext(vetCtx, "go", "vet", "./"+pkgDir) + vetCmd.Dir = cmdWorkdir + var vetOut, vetErr bytes.Buffer + vetCmd.Stdout = &vetOut + vetCmd.Stderr = &vetErr + if runErr := vetCmd.Run(); runErr != nil { + combined := strings.TrimSpace(vetOut.String() + vetErr.String()) + if combined != "" { + first := firstLine(combined) + out = append(out, fmt.Sprintf("go vet: %s", first)) + } + } + return out +} + +// runTestCommand runs go test against the file's package after editing a +// *_test.go file. The conventional "filter to TestX" doesn't apply +// cleanly here because file names do not dictate function names (e.g. +// `foo_test.go` usually contains `TestFoo`, but a per-config test may +// use any `Test*` symbol, and some authors write `testFoo` lower-case). +// Default behaviour: run the whole package without -run filter so the +// listener surfaces real test outcomes to the agent regardless of the +// `func Test*` shape inside. +func runTestCommand(ctx context.Context, testFile, workdir string, timeout time.Duration) string { + if !strings.HasSuffix(testFile, "_test.go") { + return "" + } + dirOfFile := filepath.Dir(testFile) + if dirOfFile == "" { + dirOfFile = "." + } + cmdWorkdir := filepath.Dir(dirOfFile) + if cmdWorkdir == "" { + cmdWorkdir = "." + } + args := []string{ + "test", + "./" + filepath.Base(dirOfFile), + "-count=1", + } + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Dir = cmdWorkdir + var out, errOut bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errOut + runErr := cmd.Run() + if runErr == nil { + return "" + } + combined := strings.TrimSpace(out.String() + errOut.String()) + if combined == "" { + return runErr.Error() + } + return combined +} + +func firstLine(s string) string { + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line != "" { + return line + } + } + return s +} + +func isNotFound(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "executable file not found") || + strings.Contains(err.Error(), "no such file") +} diff --git a/cmd/sin-code/internal/hooks/auto_hook_test.go b/cmd/sin-code/internal/hooks/auto_hook_test.go new file mode 100644 index 00000000..81a062d1 --- /dev/null +++ b/cmd/sin-code/internal/hooks/auto_hook_test.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: MIT +// Purpose: tests for the auto-lint / auto-test PostListeners (issue #376). +package hooks + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestAutoLintFiresAfterSinEdit(t *testing.T) { + if _, err := exec.LookPath("gofmt"); err != nil { + t.Skip("gofmt not on PATH; cannot exercise auto-lint listener") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go not on PATH; cannot exercise auto-lint listener") + } + + tmp := t.TempDir() + pkgDir := filepath.Join(tmp, "alpkg") + if err := os.MkdirAll(pkgDir, 0o755); err != nil { + t.Fatalf("mkdir pkg: %v", err) + } + if err := os.WriteFile(filepath.Join(tmp, "go.mod"), []byte("module almod\n\ngo 1.21\n"), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } + src := "package alpkg\n\nfunc Hello() string {\n return \"hi\"\n}\n" + goFile := filepath.Join(pkgDir, "hello.go") + if err := os.WriteFile(goFile, []byte(src), 0o644); err != nil { + t.Fatalf("write hello.go: %v", err) + } + + eng := New(nil) + eng.RegisterPostListener(AutoLintListener(AutoHookConfig{})) + + res := eng.Fire(context.Background(), Payload{ + Event: ToolPost, + Name: "sin_edit", + Data: map[string]any{"path": "alpkg/hello.go"}, + Workspace: tmp, + }) + if res.Blocked { + t.Fatalf("auto-lint must NEVER block tool.post; got %+v", res) + } + foundGofmt := false + for _, m := range res.PromptInjects { + if strings.Contains(m, "gofmt") && strings.Contains(m, "hello.go") { + foundGofmt = true + break + } + } + if !foundGofmt { + t.Fatalf("expected gofmt prompt-inject mentioning hello.go; got %+v", res.PromptInjects) + } +} + +func TestAutoTestFiresAfterTestFileEdit(t *testing.T) { + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go not on PATH; cannot exercise auto-test listener") + } + + tmp := t.TempDir() + pkgDir := filepath.Join(tmp, "atpkg") + if err := os.MkdirAll(pkgDir, 0o755); err != nil { + t.Fatalf("mkdir pkg: %v", err) + } + if err := os.WriteFile(filepath.Join(tmp, "go.mod"), []byte("module autotest_exercise\n\ngo 1.21\n"), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } + if err := os.WriteFile(filepath.Join(pkgDir, "hello.go"), []byte("package atpkg\n\nfunc Hello() string { return \"hi\" }\n"), 0o644); err != nil { + t.Fatalf("write hello.go: %v", err) + } + testPath := filepath.Join(pkgDir, "hello_test.go") + testSrc := "package atpkg\n\nimport \"testing\"\n\nfunc TestHello(t *testing.T) {\n\tif Hello() != \"hi\" {\n\t\tt.Fatal(\"wrong\")\n\t}\n}\n" + if err := os.WriteFile(testPath, []byte(testSrc), 0o644); err != nil { + t.Fatalf("write hello_test.go: %v", err) + } + + eng := New(nil) + eng.RegisterPostListener(AutoTestListener(AutoHookConfig{Timeout: AutoTestDefaultTimeout})) + + res := eng.Fire(context.Background(), Payload{ + Event: ToolPost, + Name: "sin_edit", + Data: map[string]any{"path": "atpkg/hello_test.go"}, + Workspace: tmp, + }) + if res.Blocked { + t.Fatalf("auto-test must NEVER block tool.post; got %+v", res) + } + if len(res.PromptInjects) != 0 { + t.Fatalf("expected silent inject on PASS, got %+v", res.PromptInjects) + } + + failSrc := "package atpkg\n\nimport \"testing\"\n\nfunc TestHello(t *testing.T) {\n\tt.Fatal(\"intentional fail\")\n}\n" + if err := os.WriteFile(testPath, []byte(failSrc), 0o644); err != nil { + t.Fatalf("write failing test: %v", err) + } + res = eng.Fire(context.Background(), Payload{ + Event: ToolPost, + Name: "sin_edit", + Data: map[string]any{"path": "atpkg/hello_test.go"}, + Workspace: tmp, + }) + if len(res.PromptInjects) == 0 { + t.Fatalf("expected failure injection when test fails; got %+v", res) + } + foundFail := false + for _, m := range res.PromptInjects { + if strings.Contains(m, "[auto-test") && strings.Contains(m, "FAIL") { + foundFail = true + break + } + } + if !foundFail { + t.Fatalf("expected [auto-test ...] FAIL inject on failing test; got %+v", res.PromptInjects) + } +} + +func TestAutoLintDisabledByDefault(t *testing.T) { + eng := New(nil) + res := eng.Fire(context.Background(), Payload{ + Event: ToolPost, + Name: "sin_edit", + Data: map[string]any{"path": "hello.go"}, + }) + if res.Blocked { + t.Fatal("without registered listeners, tool.post must NEVER block") + } + if len(res.PromptInjects) != 0 { + t.Fatalf("without listeners, PromptInjects must be empty; got %+v", res.PromptInjects) + } + res = eng.Fire(context.Background(), Payload{ + Event: ToolPost, + Name: "sin_write", + Data: map[string]any{"path": "foo.go"}, + }) + if res.Blocked || len(res.PromptInjects) != 0 { + t.Fatalf("sin_write without listeners expected empty, got %+v", res) + } + res = eng.Fire(context.Background(), Payload{ + Event: ToolPost, + Name: "sin_bash", + Data: map[string]any{"command": "ls"}, + }) + if res.Blocked || len(res.PromptInjects) != 0 { + t.Fatalf("sin_bash without listeners expected empty, got %+v", res) + } +} + +func TestPostListenerRegistration(t *testing.T) { + var nilEng *Engine + nilEng.RegisterPostListener(nil) + nilEng.RegisterPostListener(func(_ context.Context, _ Payload) []string { return nil }) + + realEng := New(nil) + realEng.RegisterPostListener(nil) + called := false + realEng.RegisterPostListener(func(_ context.Context, _ Payload) []string { + called = true + return nil + }) + res := realEng.Fire(context.Background(), Payload{Event: ToolPost, Name: "sin_edit"}) + if !called { + t.Fatalf("registered listener must run on tool.post; got %+v", res) + } +} + +func TestSafeInvokePostListenerPanicRecovers(t *testing.T) { + eng := New(nil) + eng.RegisterPostListener(func(_ context.Context, _ Payload) []string { + panic("intentional panic for recovery test") + }) + res := eng.Fire(context.Background(), Payload{Event: ToolPost, Name: "sin_edit"}) + if res.Blocked { + t.Fatal("panicked listener must not block") + } + if len(res.PromptInjects) != 0 { + t.Fatalf("panicked listener should not inject; got %+v", res.PromptInjects) + } +} diff --git a/cmd/sin-code/internal/hooks/hooks.go b/cmd/sin-code/internal/hooks/hooks.go index 19ec247d..5207c87a 100644 --- a/cmd/sin-code/internal/hooks/hooks.go +++ b/cmd/sin-code/internal/hooks/hooks.go @@ -9,6 +9,8 @@ // exit 2 = BLOCK (stdout fed back to the agent), else warn. // webhook — HTTP POST of the event JSON (fire-and-forget unless blocking). // prompt — injects static text into the next agent turn. +// listener — programmatic Go PostListener (issue #376); fires only on +// tool.post and merges its return into PromptInjects. // // Only *.pre events and permission.ask honor blocking. package hooks @@ -30,70 +32,46 @@ const ( SessionStart = "session.start" SessionResume = "session.resume" SessionEnd = "session.end" - - TurnStart = "turn.start" - TurnEnd = "turn.end" - - ToolPre = "tool.pre" - ToolPost = "tool.post" - ToolDenied = "tool.denied" - ToolError = "tool.error" - + TurnStart = "turn.start" + TurnEnd = "turn.end" + ToolPre = "tool.pre" + ToolPost = "tool.post" + ToolDenied = "tool.denied" + ToolError = "tool.error" PermissionAsk = "permission.ask" - - VerifyPre = "verify.pre" - VerifyPass = "verify.pass" - VerifyFail = "verify.fail" - - // Stop-gate: completion authority decoupled from the worker. StopEval - // fires whenever the gate is consulted; StopContinue fires when the gate - // rejects a proposed completion and forces the loop to keep working. - StopEval = "stop.eval" - StopContinue = "stop.continue" - // StopStalled fires when the stop-gate returns identical open criteria - // StallThreshold turns in a row (no-progress escalation). - StopStalled = "stop.stalled" - // Token budget lifecycle (issue #151). + VerifyPre = "verify.pre" + VerifyPass = "verify.pass" + VerifyFail = "verify.fail" + StopEval = "stop.eval" + StopContinue = "stop.continue" + StopStalled = "stop.stalled" BudgetWarn = "budget.warn" BudgetExhausted = "budget.exhausted" - // ReflectIssues fires when the self-reflection pass finds problems the - // worker must fix before completion is evaluated. - ReflectIssues = "reflect.issues" - + ReflectIssues = "reflect.issues" AgentSpawn = "agent.spawn" AgentComplete = "agent.complete" CriticReject = "critic.reject" AdversaryFinding = "adversary.finding" GovernorBlock = "governor.block" - MemoryWrite = "memory.write" MemoryCompact = "memory.compact" MemoryPrime = "memory.prime" - - CommitPre = "commit.pre" - CommitPost = "commit.post" - PushPre = "push.pre" - + CommitPre = "commit.pre" + CommitPost = "commit.post" + PushPre = "push.pre" TaskComplete = "task.complete" TaskAbort = "task.abort" CompactionPre = "compaction.pre" - - // Autonomy lifecycle (daemon mode). GoalEnqueued = "goal.enqueued" GoalStarted = "goal.started" GoalVerified = "goal.verified" GoalExhausted = "goal.exhausted" TriggerFired = "trigger.fired" - - // Skill lifecycle. SkillInstalled = "skill.installed" SkillFailed = "skill.failed" - - // Fusion lifecycle (issue #290). FusionDispatch = "fusion.dispatch" ) -// blockable events: a blocking hook result is honored only for these. var blockable = map[string]bool{ ToolPre: true, VerifyPre: true, PermissionAsk: true, CommitPre: true, PushPre: true, CompactionPre: true, @@ -125,17 +103,31 @@ type Result struct { PromptInjects []string } +// PostListener is a programmatic listener registered via +// Engine.RegisterPostListener. It fires on tool.post events only. +type PostListener func(ctx context.Context, p Payload) []string + type Engine struct { - hooks []Hook - client *http.Client + hooks []Hook + client *http.Client + postListeners []PostListener } func New(hooks []Hook) *Engine { return &Engine{hooks: hooks, client: &http.Client{Timeout: 15 * time.Second}} } -// Fire runs all hooks matching the event (and matcher) sequentially. -// Hooks never crash the agent: errors degrade to warnings on stderr. +// RegisterPostListener appends a programmatic listener that fires +// on every tool.post event. nil engine / nil fn is a no-op. +func (e *Engine) RegisterPostListener(fn PostListener) { + if e == nil || fn == nil { + return + } + e.postListeners = append(e.postListeners, fn) +} + +// Fire runs all hooks matching the event sequentially. For +// tool.post, registered PostListeners also fire in registration order. func (e *Engine) Fire(ctx context.Context, p Payload) Result { p.Timestamp = time.Now().UTC().Format(time.RFC3339) var res Result @@ -159,6 +151,14 @@ func (e *Engine) Fire(ctx context.Context, p Payload) Result { fmt.Fprintf(os.Stderr, "warn: hook with unknown type %q ignored\n", h.Type) } } + if p.Event == ToolPost { + for _, fn := range e.postListeners { + msgs := safeInvokePostListener(fn, ctx, p) + if len(msgs) > 0 { + res.PromptInjects = append(res.PromptInjects, msgs...) + } + } + } return res } @@ -169,7 +169,6 @@ func (e *Engine) fireCommand(ctx context.Context, h Hook, p Payload) (blocked bo } cctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - payload, _ := json.Marshal(p) cmd := exec.CommandContext(cctx, "sh", "-c", h.Command) cmd.Stdin = bytes.NewReader(payload) @@ -179,12 +178,11 @@ func (e *Engine) fireCommand(ctx context.Context, h Hook, p Payload) (blocked bo "SIN_HOOK_TOOL_NAME="+p.Name, "SIN_SESSION_ID="+p.SessionID, ) - if path, ok := p.Data["path"].(string); ok { - cmd.Env = append(cmd.Env, "SIN_HOOK_DATA_PATH="+path) + if p, ok := p.Data["path"].(string); ok { + cmd.Env = append(cmd.Env, "SIN_HOOK_DATA_PATH="+p) } var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr - err := cmd.Run() if err == nil { return false, "" @@ -224,3 +222,17 @@ func matchName(pattern, name string) bool { ok, _ := path.Match(strings.ToLower(pattern), strings.ToLower(name)) return ok } + +// safeInvokePostListener calls fn, recovering from any panic. +func safeInvokePostListener(fn PostListener, ctx context.Context, p Payload) (out []string) { + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(os.Stderr, "warn: post-listener panicked on %s: %v\n", p.Name, r) + out = nil + } + }() + if fn == nil { + return nil + } + return fn(ctx, p) +}