Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ Feishu:
更多端侧截图与交互说明:
- [Web UI 使用指南](https://neocode-docs.pages.dev/guide/web-ui)
- [飞书远程接入配置](https://neocode-docs.pages.dev/guide/feishu-remote-setup)
- [Hooks CLI 指南](docs/guides/hooks-cli.md)

---

Expand Down
8 changes: 7 additions & 1 deletion cmd/neocode-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"errors"
"fmt"
"os"

Expand All @@ -11,6 +12,11 @@ import (
func main() {
if err := cli.ExecuteGatewayServer(context.Background(), os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "neocode-gateway: %v\n", err)
os.Exit(1)
exitCode := 1
var exitCoder interface{ ExitCode() int }
if errors.As(err, &exitCoder) {
exitCode = exitCoder.ExitCode()
}
os.Exit(exitCode)
}
}
8 changes: 7 additions & 1 deletion cmd/neocode/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"errors"
"fmt"
"os"

Expand All @@ -11,7 +12,12 @@ import (
func main() {
if err := cli.Execute(context.Background()); err != nil {
fmt.Fprintf(os.Stderr, "neocode: %v\n", err)
os.Exit(1)
exitCode := 1
var exitCoder interface{ ExitCode() int }
if errors.As(err, &exitCoder) {
exitCode = exitCoder.ExitCode()
}
os.Exit(exitCode)
}
if notice := cli.ConsumeUpdateNotice(); notice != "" {
fmt.Fprintln(os.Stdout, notice)
Expand Down
107 changes: 107 additions & 0 deletions docs/guides/hooks-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Hooks CLI

`neocode hook` 提供面向 Runtime Hooks 的本地调试入口:

- `neocode hook lint [path]`
- `neocode hook dry-run [path] --hook <id> --fixture <path>`
- `neocode hook trace --run-id <id>`

## lint

默认扫描两处:

- `~/.neocode/config.yaml` 中的 `runtime.hooks.items`
- `<workspace>/.neocode/hooks.yaml` 中的 `hooks.items`

可显式传入单个文件路径:

```bash
neocode hook lint
neocode hook lint .neocode/hooks.yaml
```

退出码:

- `0`:无问题
- `1`:存在 lint findings
- `2`:读取、解析或内部错误

## dry-run

使用 fixture 驱动单个 hook:

```bash
neocode hook dry-run --hook warn-bash --fixture fixture.yaml
neocode hook dry-run --hook repo-guard --fixture fixture.json --repo
neocode hook dry-run .neocode/hooks.yaml --hook repo-guard --fixture fixture.json
```

fixture 支持 YAML / JSON,字段以 hook payload schema 为准,最小示例:

```yaml
payload_version: "1"
point: before_tool_call
run_id: run-1
session_id: session-1
metadata:
tool_name: bash
tool_call_id: call-1
tool_arguments_preview: echo hello
workdir: /workspace
```

查找 hook 时的默认行为:

- 默认同时扫描 user hooks 与 repo hooks。
- 同名 hook 同时存在时,优先选择 user hook。
- 若要强制执行 repo hook,可使用 `--repo`,或直接把 repo hooks 文件路径作为 `[path]` 传入。
- fixture 的 `point` 必须与目标 hook 的 `point` 完全一致,否则 `dry-run` 直接失败。

输出中会固定打印:

- `status: pass|block|failed`
- `block: true|false`
- `duration_ms: <n>`
- 命中的 `message` / `annotations`

退出码:

- `0`:结果为 `pass`
- `3`:结果为 `block`
- `4`:结果为 `failed`
- `2`:fixture / hook 解析失败

## trace

在真实 runtime 路径上打开 `--trace-hooks` 后,hook 相关 runtime 事件会持久化到当前 workspace:

```bash
neocode gateway --trace-hooks
neocode hook trace --run-id run_123
```

trace 文件位置:

```text
~/.neocode/projects/<workspace-hash>/hook-traces/<run-id>.jsonl
```

`hook trace` 会按时间顺序回放:

- `hook_started`
- `hook_finished`
- `hook_failed`
- `hook_blocked`

并在末尾输出按 `hook_id` 聚合的简单耗时统计。

补充说明:

- `hook trace --run-id` 只读取当前 workspace 对应目录下的 trace 文件,不做跨项目全局搜索。
- workspace 优先取 `--workdir`,未传时回退到当前进程工作目录。

退出码:

- `0`:成功输出 trace
- `1`:未找到 trace
- `2`:trace 文件损坏或读取失败
6 changes: 6 additions & 0 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ type BootstrapOptions struct {
Workdir string
SessionID string
WakeInputB64 string
TraceHooks bool
}

type memoExtractorScheduler interface {
Expand Down Expand Up @@ -269,6 +270,11 @@ func BuildGatewayServerDeps(ctx context.Context, opts BootstrapOptions) (Runtime

runtimeImpl := agentruntime.Runtime(runtimeSvc)
closeFns := []func() error{toolsCleanup, checkpointStore.Close, sessionStore.Close}
if opts.TraceHooks {
recorder := agentruntime.NewHookTraceRecorder(sharedDeps.ConfigManager.BaseDir(), cfg.Workdir)
runtimeSvc.SetRuntimeEventRecorder(recorder)
closeFns = append(closeFns, recorder.Close)
}

needCleanup = false

Expand Down
89 changes: 89 additions & 0 deletions internal/app/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"neo-code/internal/provider"
providertypes "neo-code/internal/provider/types"
agentruntime "neo-code/internal/runtime"
runtimehooks "neo-code/internal/runtime/hooks"
agentsession "neo-code/internal/session"
"neo-code/internal/skills"
"neo-code/internal/tools"
Expand Down Expand Up @@ -918,6 +919,94 @@ func TestBuildRuntimeUsesWorkdirOverride(t *testing.T) {
}
}

func TestBuildRuntimeTraceHooksPersistsHookEvents(t *testing.T) {
disableBuiltinProviderAPIKeys(t)

home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

workdir := t.TempDir()
bundle, err := BuildGatewayServerDeps(context.Background(), BootstrapOptions{
Workdir: workdir,
TraceHooks: true,
})
if err != nil {
t.Fatalf("BuildGatewayServerDeps() error = %v", err)
}
t.Cleanup(func() {
if bundle.Close != nil {
if closeErr := bundle.Close(); closeErr != nil {
t.Fatalf("bundle.Close() error = %v", closeErr)
}
}
})

service, ok := bundle.Runtime.(*agentruntime.Service)
if !ok {
t.Fatalf("bundle.Runtime type = %T, want *runtime.Service", bundle.Runtime)
}
service.SetHookExecutor(traceBlockingHookExecutor{})
session, err := service.CreateSession(context.Background(), "trace-session")
if err != nil {
t.Fatalf("CreateSession() error = %v", err)
}

runID := "trace-run-1"
_, err = service.Compact(context.Background(), agentruntime.CompactInput{
SessionID: session.ID,
RunID: runID,
})
if err == nil || !strings.Contains(err.Error(), "trace guard blocked compact") {
t.Fatalf("Compact() error = %v, want hook block error", err)
}

tracePath, err := agentruntime.HookTracePath(bundle.ConfigManager.BaseDir(), bundle.Config.Workdir, runID)
if err != nil {
t.Fatalf("HookTracePath() error = %v", err)
}
data, err := os.ReadFile(tracePath)
if err != nil {
t.Fatalf("ReadFile(trace) error = %v", err)
}
text := string(data)
if !strings.Contains(text, `"event_type":"hook_blocked"`) {
t.Fatalf("trace file missing hook_blocked: %s", text)
}
if !strings.Contains(text, `"hook_id":"trace-pre-compact"`) {
t.Fatalf("trace file missing hook id: %s", text)
}
}

type traceBlockingHookExecutor struct{}

func (traceBlockingHookExecutor) Run(
ctx context.Context,
point runtimehooks.HookPoint,
input runtimehooks.HookContext,
) runtimehooks.RunOutput {
_ = ctx
_ = input
if point != runtimehooks.HookPointPreCompact {
return runtimehooks.RunOutput{}
}
return runtimehooks.RunOutput{
Blocked: true,
BlockedBy: "trace-pre-compact",
BlockedSource: runtimehooks.HookSourceUser,
Results: []runtimehooks.HookResult{
{
HookID: "trace-pre-compact",
Point: runtimehooks.HookPointPreCompact,
Scope: runtimehooks.HookScopeUser,
Source: runtimehooks.HookSourceUser,
Status: runtimehooks.HookResultBlock,
Message: "trace guard blocked compact",
},
},
}
}

func TestBuildRuntimeSucceedsWhenSkillsRootMissing(t *testing.T) {
disableBuiltinProviderAPIKeys(t)

Expand Down
46 changes: 46 additions & 0 deletions internal/cli/exit_code.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package cli

import "fmt"

// ExitCoder 描述 CLI 错误可携带的进程退出码。
type ExitCoder interface {
error
ExitCode() int
}

type commandExitError struct {
code int
err error
}

// Error 返回底层错误文案,供主入口统一打印。
func (e *commandExitError) Error() string {
if e == nil || e.err == nil {
return ""
}
return e.err.Error()
}

// Unwrap 暴露底层错误,便于测试和 errors.Is/As 复用。
func (e *commandExitError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}

// ExitCode 返回命令预期的进程退出码。
func (e *commandExitError) ExitCode() int {
if e == nil || e.code <= 0 {
return 1
}
return e.code
}

// newCommandExitError 构造带退出码的 CLI 错误。
func newCommandExitError(code int, format string, args ...any) error {
return &commandExitError{
code: code,
err: fmt.Errorf(format, args...),
}
}
5 changes: 4 additions & 1 deletion internal/cli/gateway_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type gatewayCommandOptions struct {
TokenFile string
ACLMode string
Workdir string
TraceHooks bool

MaxFrameBytes int
IPCMaxConnections int
Expand Down Expand Up @@ -108,6 +109,7 @@ func newGatewayServerCommand(use, short string, readWorkdir func(*cobra.Command)
TokenFile: strings.TrimSpace(options.TokenFile),
ACLMode: strings.TrimSpace(options.ACLMode),
Workdir: normalizedWorkdir,
TraceHooks: options.TraceHooks,

MaxFrameBytes: options.MaxFrameBytes,
IPCMaxConnections: options.IPCMaxConnections,
Expand Down Expand Up @@ -161,6 +163,7 @@ func newGatewayServerCommand(use, short string, readWorkdir func(*cobra.Command)
"gateway http shutdown timeout seconds override",
)
cmd.Flags().BoolVar(&options.MetricsEnabled, "metrics-enabled", false, "gateway metrics enable override")
cmd.Flags().BoolVar(&options.TraceHooks, "trace-hooks", false, "persist hook runtime trace events for this workspace")

return cmd
}
Expand Down Expand Up @@ -253,7 +256,7 @@ func startGatewayServer(ctx context.Context, options gatewayCommandOptions, stat
logger,
)

runtimePort, closeRuntimePort, err := buildGatewayRuntimePort(signalContext, options.Workdir)
runtimePort, closeRuntimePort, err := buildGatewayRuntimePort(signalContext, options.Workdir, options.TraceHooks)
if err != nil {
return fmt.Errorf("initialize gateway runtime: %w", err)
}
Expand Down
16 changes: 13 additions & 3 deletions internal/cli/gateway_runtime_bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,18 @@ type bridgeSessionLoader interface {

// defaultBuildGatewayRuntimePort 构建网关运行时 RuntimePort 适配器,并返回对应资源清理函数。
// 当启用多工作区时,返回 MultiWorkspaceRuntime 路由代理,每个工作区拥有独立的 RuntimeBundle。
func defaultBuildGatewayRuntimePort(ctx context.Context, workdir string) (gateway.RuntimePort, func() error, error) {
func defaultBuildGatewayRuntimePort(
ctx context.Context,
workdir string,
traceHooks bool,
) (gateway.RuntimePort, func() error, error) {
trimmedWorkdir := strings.TrimSpace(workdir)

// 先构建默认工作区的 bundle,用于获取 baseDir 和共享组件。
bundle, err := app.BuildGatewayServerDeps(ctx, app.BootstrapOptions{Workdir: trimmedWorkdir})
bundle, err := app.BuildGatewayServerDeps(ctx, app.BootstrapOptions{
Workdir: trimmedWorkdir,
TraceHooks: traceHooks,
})
if err != nil {
return nil, nil, err
}
Expand Down Expand Up @@ -140,7 +147,10 @@ func defaultBuildGatewayRuntimePort(ctx context.Context, workdir string) (gatewa
if trimmedWd != "" {
_ = os.MkdirAll(trimmedWd, 0o755)
}
b, err := app.BuildGatewayServerDeps(ctx, app.BootstrapOptions{Workdir: trimmedWd})
b, err := app.BuildGatewayServerDeps(ctx, app.BootstrapOptions{
Workdir: trimmedWd,
TraceHooks: traceHooks,
})
if err != nil {
return nil, nil, err
}
Expand Down
Loading
Loading