diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7615dc9 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,47 @@ +name: Build clear-herd + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: false + - run: go test ./... + + build: + needs: test + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + goos: [darwin, linux, windows] + goarch: [amd64, arm64] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: false + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + mkdir -p dist + suffix="" + if [ "$GOOS" = windows ]; then suffix=".exe"; fi + go build -trimpath -o "dist/clear-herd-${GOOS}-${GOARCH}${suffix}" ./cmd/clear-herd + - uses: actions/upload-artifact@v4 + with: + name: clear-herd-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/* + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 496ee2c..e72d9a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -.DS_Store \ No newline at end of file +.DS_Store +dist/ +clear-herd +clear-herd.exe diff --git a/README.md b/README.md index 606361e..53df7a6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,88 @@ -Remove the content below and rewrite a README.md when done completing the following tasks. Once these tasks are completed and this content in the README.md is deleted, then rewrite the README.md with instructions on how to use this repo, how to build it, and how to use the command and what it does. +# clear-herd -1. Create a repo with a Go project that will build into a CLI tool for Windows, Linux, or MacOS (ARM or Intel). Create the project infrastructure to get all of those executables from a Github build. -2. The CLI app that should be built is as follows. A CLI tool that will enable going through all of the herdr terminals that are open and closing all of the agents with their respective /clear and /exit commands, whatever those might be. Once cleared the terminal is then also "clear"ed with the respective clear command used for the terminal or terminal types that are open (i.e. Powershell, MacOS Terminal, Ubuntu Terminal, or whatever). Generally that is always just a plain old "clear" command issued. -3. When that command is run, we'll call it "clear-herd" once it is done closing all of the agents in the respective terminals it should return a bulleted list of all the sessions resume commands. i.e. for example codex when it is given an /exit returns a resume or reconnect command like "codex resume 01a13c1d-73ec-72b3-8159-5eb85647d384". Return all of those, regardless of agent, as a bulleted list and also return what kind of agent the resume command is from, i.e. is it codex or claude or cursor's agent or whatever else it might be. -4. Otherwise log an error if the command to resume isn't given. \ No newline at end of file +`clear-herd` closes supported coding agents in a [Herdr](https://herdr.dev/) session, clears their terminal panes, and prints commands to resume the saved conversations. It uses Herdr's agent and pane CLI, so the same executable works whether Herdr is displayed in Terminal, iTerm2, Windows Terminal, or a Linux terminal emulator. + +## Requirements + +- [Herdr](https://herdr.dev/docs/quick-start/) installed and running, with its `herdr` CLI on `PATH`. +- Agents running **inside Herdr panes**. Ordinary terminal windows outside Herdr are not visible to this tool. +- A native session ID reported by a Herdr agent integration, or a resume command printed in the agent's terminal output. Install available integrations with `herdr integration install ` and check `herdr agent list` for `agent_session`. + +The built-in command profiles are Codex (`/clear`, `/exit`, `codex resume ID`), Claude Code (`/clear`, `/exit`, `claude --resume ID`), and Cursor Agent (`/clear`, `/exit`, `agent --resume=ID`). Other agent kinds can be added through a profiles file. + +## Install and build + +With Go 1.23 or newer: + +```sh +go build -o clear-herd ./cmd/clear-herd +``` + +On Windows, use `go build -o clear-herd.exe ./cmd/clear-herd`. Move the resulting executable to a directory on `PATH`. + +The [Build clear-herd workflow](.github/workflows/build.yml) runs tests and uploads six executables as GitHub Actions artifacts for macOS, Linux, and Windows, each on Intel/AMD (`amd64`) and ARM (`arm64`). Open a successful workflow run's **Artifacts** section and download the archive for your operating system and CPU. On macOS and Linux, make the downloaded executable executable with `chmod +x`. + +## Use + +From a shell where the Herdr CLI can reach the desired server: + +```sh +clear-herd --dry-run +clear-herd +``` + +`--dry-run` lists agents and any resume commands already available from Herdr session metadata. The normal command: + +1. Lists agents from `herdr agent list`. +2. Saves each agent's native session ID before `/clear` can start a fresh chat. +3. Sends the agent's clear and exit commands, then waits for the agent to disappear from its pane. +4. Reads exit output for a resume command if Herdr did not report an ID. +5. Runs `clear` in the freed shell pane (`cls` on Windows). +6. Prints a bulleted list with agent kind, pane ID, and resume command. + +For example: + +```text +Resume commands: +- codex [w1:p1]: codex resume 01a13c1d-73ec-72b3-8159-5eb85647d384 +- claude [w1:p2]: claude --resume 229a1d26-70d2-4758-9b10-58eeb31d1648 +``` + +Run it from a regular shell pane. If the command runs inside a pane currently hosting an agent, that pane is skipped so the command can finish. Agents with an active approval prompt are also skipped. Working agents are given up to `--timeout` to become idle. Errors, including missing resume commands, are printed to stderr and cause exit status 1; other panes continue processing. + +Options: + +```text +--dry-run Show available commands without changing panes +--profiles FILE Load additional or replacement agent command profiles +--herdr PATH Use a Herdr CLI at PATH (default: herdr) +--timeout 15s Maximum time to wait for an agent to become idle or exit +--version Show the tool version +``` + +Herdr's `HERDR_SESSION` and `HERDR_SOCKET_PATH` environment variables are inherited by the CLI, so they can target a named/local session. This command handles one Herdr server per run. + +## Add an agent profile + +Create a JSON file mapping Herdr's `agent` kind to its interactive clear and exit commands and its resume command. The resume arguments must contain `{id}`, `{path}`, or `{session}`; those placeholders are filled from Herdr's `agent_session` field. `{id}` only accepts ID references, `{path}` only path references, and `{session}` accepts either. + +```json +{ + "myagent": { + "clear": "/reset", + "exit": "/quit", + "resume": ["myagent", "resume", "{id}"] + } +} +``` + +Then run `clear-herd --profiles profiles.json --dry-run` to check session metadata before using `clear-herd --profiles profiles.json`. A profile replaces a built-in one with the same kind. An agent without a profile is left untouched and reported as an error. + +## Development + +```sh +go test ./... +go build ./cmd/clear-herd +``` + +The CLI uses only the Go standard library. Its tests use a scripted Herdr CLI, so they do not close live agents. diff --git a/cmd/clear-herd/main.go b/cmd/clear-herd/main.go new file mode 100644 index 0000000..573608f --- /dev/null +++ b/cmd/clear-herd/main.go @@ -0,0 +1,325 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "runtime" + "sort" + "strings" + "time" +) + +type agent struct { + Kind string `json:"agent"` + Name string `json:"name"` + PaneID string `json:"pane_id"` + Status string `json:"agent_status"` + Session *struct { + Kind string `json:"kind"` + Value string `json:"value"` + } `json:"agent_session"` +} + +type profile struct { + Clear string `json:"clear"` + Exit string `json:"exit"` + Resume []string `json:"resume"` +} + +type result struct { + Agent string + Pane string + Command string + Err error +} + +type runner interface { + Run(context.Context, ...string) (string, error) +} + +type herdrCLI struct{ binary string } + +func (h herdrCLI) Run(ctx context.Context, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, h.binary, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("herdr %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return string(out), nil +} + +func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, herdrCLI{binary: "herdr"})) } + +func run(args []string, out, errOut io.Writer, defaultRunner runner) int { + flags := flag.NewFlagSet("clear-herd", flag.ContinueOnError) + flags.SetOutput(errOut) + dryRun := flags.Bool("dry-run", false, "list agents and resume commands without sending input") + profilesPath := flags.String("profiles", "", "JSON file with additional or overridden agent profiles") + herdrPath := flags.String("herdr", "herdr", "path to the Herdr CLI") + timeout := flags.Duration("timeout", 15*time.Second, "time to wait for each agent to exit") + version := flags.Bool("version", false, "print version") + if e := flags.Parse(args); e == flag.ErrHelp { + return 0 + } else if e != nil { + return 2 + } + if flags.NArg() != 0 || *timeout <= 0 { + fmt.Fprintln(errOut, "usage: clear-herd [--dry-run] [--profiles FILE] [--herdr PATH] [--timeout 15s]") + return 2 + } + if *version { + fmt.Fprintln(out, "clear-herd dev") + return 0 + } + if h, ok := defaultRunner.(herdrCLI); ok { + h.binary = *herdrPath + defaultRunner = h + } + profiles, e := loadProfiles(*profilesPath) + if e != nil { + fmt.Fprintf(errOut, "profiles: %v\n", e) + return 2 + } + ctx := context.Background() + agents, e := listAgents(ctx, defaultRunner) + if e != nil { + fmt.Fprintf(errOut, "agent list: %v\n", e) + return 1 + } + if len(agents) == 0 { + fmt.Fprintln(out, "No Herdr agents found.") + return 0 + } + sort.Slice(agents, func(i, j int) bool { return agents[i].PaneID < agents[j].PaneID }) + results := make([]result, 0, len(agents)) + for _, a := range agents { + p, ok := profiles[a.Kind] + r := result{Agent: a.Kind, Pane: a.PaneID} + if !ok { + r.Err = fmt.Errorf("no command profile for %s", a.Kind) + } else { + r.Command = resumeFromSession(a, p) + if *dryRun { + if r.Command == "" { + r.Err = errors.New("no resume command available from Herdr session metadata") + } + } else if a.PaneID == os.Getenv("HERDR_PANE_ID") { + r.Err = errors.New("cannot exit the pane running clear-herd") + } else if a.Status == "blocked" { + r.Err = errors.New("agent is blocked; resolve its prompt before clearing") + } else { + if a.Status == "working" { + _, r.Err = callWait(ctx, defaultRunner, a.PaneID, *timeout) + } + if r.Err == nil { + r.Command, r.Err = clearAgent(ctx, defaultRunner, a, p, r.Command, *timeout) + } + } + } + results = append(results, r) + } + if *dryRun { + fmt.Fprintln(out, "Preview (no input sent):") + } else { + fmt.Fprintln(out, "Resume commands:") + } + failed := false + for _, r := range results { + if r.Command != "" { + fmt.Fprintf(out, "- %s [%s]: %s\n", r.Agent, r.Pane, r.Command) + } + if r.Err != nil { + failed = true + fmt.Fprintf(errOut, "error: %s [%s]: %v\n", r.Agent, r.Pane, r.Err) + } + } + if failed { + return 1 + } + return 0 +} + +func loadProfiles(path string) (map[string]profile, error) { + profiles := map[string]profile{ + "codex": {Clear: "/clear", Exit: "/exit", Resume: []string{"codex", "resume", "{id}"}}, + "claude": {Clear: "/clear", Exit: "/exit", Resume: []string{"claude", "--resume", "{id}"}}, + "cursor": {Clear: "/clear", Exit: "/exit", Resume: []string{"agent", "--resume={id}"}}, + } + if path == "" { + return profiles, nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var extra map[string]profile + if err := json.Unmarshal(data, &extra); err != nil { + return nil, err + } + for kind, p := range extra { + if kind == "" || p.Clear == "" || p.Exit == "" || len(p.Resume) == 0 { + return nil, fmt.Errorf("profile %q needs clear, exit, and resume fields", kind) + } + hasSession := false + for _, arg := range p.Resume { + if strings.Contains(arg, "{id}") || strings.Contains(arg, "{path}") || strings.Contains(arg, "{session}") { + hasSession = true + } + } + if !hasSession { + return nil, fmt.Errorf("profile %q resume command needs a session placeholder", kind) + } + profiles[kind] = p + } + return profiles, nil +} + +func listAgents(ctx context.Context, h runner) ([]agent, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + output, err := h.Run(ctx, "agent", "list") + if err != nil { + return nil, err + } + var response struct { + Result struct { + Agents []agent `json:"agents"` + } `json:"result"` + } + if err := json.Unmarshal([]byte(output), &response); err != nil { + return nil, fmt.Errorf("invalid Herdr JSON: %w", err) + } + for _, a := range response.Result.Agents { + if a.PaneID == "" || a.Kind == "" { + return nil, errors.New("Herdr returned an agent without pane_id or agent") + } + } + return response.Result.Agents, nil +} + +func resumeFromSession(a agent, p profile) string { + if a.Session == nil || a.Session.Value == "" { + return "" + } + argv := make([]string, len(p.Resume)) + for i, part := range p.Resume { + if strings.Contains(part, "{id}") && a.Session.Kind != "id" { + return "" + } + if strings.Contains(part, "{path}") && a.Session.Kind != "path" { + return "" + } + part = strings.ReplaceAll(part, "{id}", a.Session.Value) + part = strings.ReplaceAll(part, "{path}", a.Session.Value) + argv[i] = strings.ReplaceAll(part, "{session}", a.Session.Value) + } + return joinCommand(argv) +} + +func joinCommand(argv []string) string { + parts := make([]string, len(argv)) + for i, arg := range argv { + if arg != "" && strings.IndexFunc(arg, func(r rune) bool { + return !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune("_./:=+-@", r)) + }) == -1 { + parts[i] = arg + } else if runtime.GOOS == "windows" { + parts[i] = "'" + strings.ReplaceAll(arg, "'", "''") + "'" + } else { + parts[i] = "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'" + } + } + return strings.Join(parts, " ") +} + +func clearAgent(ctx context.Context, h runner, a agent, p profile, known string, timeout time.Duration) (string, error) { + if _, err := call(ctx, h, "pane", "run", a.PaneID, p.Clear); err != nil { + return known, fmt.Errorf("clear agent: %w", err) + } + // Slash commands can redraw the screen asynchronously. Give Herdr time to settle. + time.Sleep(500 * time.Millisecond) + if _, err := call(ctx, h, "pane", "run", a.PaneID, p.Exit); err != nil { + return known, fmt.Errorf("exit agent: %w", err) + } + deadline := time.Now().Add(timeout) + for { + live, err := listAgents(ctx, h) + if err != nil { + return known, fmt.Errorf("verify exit: %w", err) + } + found := false + for _, current := range live { + if current.PaneID == a.PaneID { + found = true + break + } + } + if !found { + break + } + if time.Now().After(deadline) { + return known, errors.New("agent did not exit before timeout") + } + time.Sleep(200 * time.Millisecond) + } + output, readErr := call(ctx, h, "pane", "read", a.PaneID, "--source", "recent-unwrapped", "--lines", "120") + if known == "" && readErr == nil { + known = resumeFromOutput(output, a.Kind) + } + clearCommand := "clear" + if runtime.GOOS == "windows" { + clearCommand = "cls" + } + _, clearErr := call(ctx, h, "pane", "run", a.PaneID, clearCommand) + var problems []error + if readErr != nil { + problems = append(problems, fmt.Errorf("read exit output: %w", readErr)) + } + if clearErr != nil { + problems = append(problems, fmt.Errorf("clear terminal: %w", clearErr)) + } + if known == "" { + problems = append(problems, errors.New("no resume command was provided")) + } + return known, errors.Join(problems...) +} + +func call(ctx context.Context, h runner, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + return h.Run(ctx, args...) +} + +func callWait(ctx context.Context, h runner, pane string, timeout time.Duration) (string, error) { + ctx, cancel := context.WithTimeout(ctx, timeout+2*time.Second) + defer cancel() + output, err := h.Run(ctx, "agent", "wait", pane, "--until", "idle", "--until", "done", "--timeout", fmt.Sprint(timeout.Milliseconds())) + if err != nil { + return "", fmt.Errorf("wait for idle agent: %w", err) + } + return output, nil +} + +func resumeFromOutput(output, kind string) string { + patterns := map[string]string{ + "codex": `\bcodex resume [A-Za-z0-9_-]+\b`, + "claude": `\bclaude (?:--resume|-r) [A-Za-z0-9_-]+\b`, + "cursor": `\b(?:agent|cursor-agent) --resume(?:=| )[A-Za-z0-9_-]+\b`, + } + pattern, ok := patterns[kind] + if !ok { + return "" + } + matches := regexp.MustCompile(pattern).FindAllString(output, -1) + if len(matches) == 0 { + return "" + } + return matches[len(matches)-1] +} diff --git a/cmd/clear-herd/main_test.go b/cmd/clear-herd/main_test.go new file mode 100644 index 0000000..0e787f2 --- /dev/null +++ b/cmd/clear-herd/main_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" +) + +type response struct { + args string + out string + err error +} + +type scriptedRunner struct { + t *testing.T + responses []response + called []string +} + +func (s *scriptedRunner) Run(_ context.Context, args ...string) (string, error) { + s.t.Helper() + got := strings.Join(args, " ") + s.called = append(s.called, got) + if len(s.responses) == 0 { + s.t.Fatalf("unexpected call: %s", got) + } + r := s.responses[0] + s.responses = s.responses[1:] + if got != r.args { + s.t.Fatalf("call = %q, want %q", got, r.args) + } + return r.out, r.err +} + +const codexAgent = `{"id":"cli:agent:list","result":{"agents":[{"agent":"codex","agent_status":"idle","pane_id":"w1:p1","agent_session":{"kind":"id","value":"01a13c1d-73ec-72b3-8159-5eb85647d384"}}]}}` +const noAgents = `{"id":"cli:agent:list","result":{"agents":[]}}` + +func TestDryRunUsesNativeSession(t *testing.T) { + s := &scriptedRunner{t: t, responses: []response{{args: "agent list", out: codexAgent}}} + var out, errOut bytes.Buffer + code := run([]string{"--dry-run"}, &out, &errOut, s) + if code != 0 || errOut.Len() != 0 { + t.Fatalf("code %d, stderr %q", code, errOut.String()) + } + if !strings.Contains(out.String(), "- codex [w1:p1]: codex resume 01a13c1d-73ec-72b3-8159-5eb85647d384") { + t.Fatalf("output: %q", out.String()) + } + if len(s.responses) != 0 { + t.Fatalf("unused responses: %d", len(s.responses)) + } +} + +func TestClearAgentSequence(t *testing.T) { + s := &scriptedRunner{t: t, responses: []response{ + {args: "agent list", out: codexAgent}, + {args: "pane run w1:p1 /clear"}, + {args: "pane run w1:p1 /exit"}, + {args: "agent list", out: noAgents}, + {args: "pane read w1:p1 --source recent-unwrapped --lines 120", out: "codex resume other-id"}, + {args: "pane run w1:p1 clear"}, + }} + var out, errOut bytes.Buffer + code := run(nil, &out, &errOut, s) + if code != 0 || errOut.Len() != 0 { + t.Fatalf("code %d, stderr %q", code, errOut.String()) + } + if !strings.Contains(out.String(), "codex resume 01a13c1d-73ec-72b3-8159-5eb85647d384") { + t.Fatalf("original session lost: %q", out.String()) + } +} + +func TestMissingResumeIsErrorAfterClearingPane(t *testing.T) { + s := &scriptedRunner{t: t, responses: []response{ + {args: "agent list", out: `{"result":{"agents":[{"agent":"codex","agent_status":"idle","pane_id":"w1:p1"}]}}`}, + {args: "pane run w1:p1 /clear"}, + {args: "pane run w1:p1 /exit"}, + {args: "agent list", out: noAgents}, + {args: "pane read w1:p1 --source recent-unwrapped --lines 120", out: "no resume line here"}, + {args: "pane run w1:p1 clear"}, + }} + var out, errOut bytes.Buffer + code := run(nil, &out, &errOut, s) + if code != 1 || !strings.Contains(errOut.String(), "no resume command was provided") { + t.Fatalf("code %d, stderr %q", code, errOut.String()) + } + if len(s.responses) != 0 { + t.Fatalf("terminal was not cleared") + } +} + +func TestWorkingAgentWaitFailureDoesNotInterrupt(t *testing.T) { + s := &scriptedRunner{t: t, responses: []response{ + {args: "agent list", out: `{"result":{"agents":[{"agent":"claude","agent_status":"working","pane_id":"w1:p2"}]}}`}, + {args: "agent wait w1:p2 --until idle --until done --timeout 15000", err: errors.New("timed out")}, + }} + var out, errOut bytes.Buffer + code := run(nil, &out, &errOut, s) + if code != 1 || !strings.Contains(errOut.String(), "timed out") { + t.Fatalf("code %d, stderr %q", code, errOut.String()) + } + if len(s.called) != 2 { + t.Fatalf("unexpected calls: %v", s.called) + } +} + +func TestCommandFailureKeepsKnownResume(t *testing.T) { + s := &scriptedRunner{t: t, responses: []response{ + {args: "agent list", out: codexAgent}, + {args: "pane run w1:p1 /clear", err: errors.New("socket closed")}, + }} + var out, errOut bytes.Buffer + code := run(nil, &out, &errOut, s) + if code != 1 || !strings.Contains(errOut.String(), "socket closed") { + t.Fatalf("code %d, stderr %q", code, errOut.String()) + } + if !strings.Contains(out.String(), "codex resume 01a13c1d-73ec-72b3-8159-5eb85647d384") { + t.Fatalf("lost resume command: %q", out.String()) + } +} + +func TestResumeOutputPatterns(t *testing.T) { + cases := []struct{ kind, output, want string }{ + {"codex", "To continue: codex resume 01a13c1d-73ec-72b3-8159-5eb85647d384", "codex resume 01a13c1d-73ec-72b3-8159-5eb85647d384"}, + {"claude", "claude --resume abc-123", "claude --resume abc-123"}, + {"cursor", "agent --resume=chat-123", "agent --resume=chat-123"}, + {"codex", "no command", ""}, + } + for _, tc := range cases { + if got := resumeFromOutput(tc.output, tc.kind); got != tc.want { + t.Errorf("%s: got %q, want %q", tc.kind, got, tc.want) + } + } +} + +func TestResumeCommandQuotesSessionID(t *testing.T) { + a := agent{Session: &struct { + Kind string `json:"kind"` + Value string `json:"value"` + }{Kind: "id", Value: "id with ' quote"}} + got := resumeFromSession(a, profile{Resume: []string{"custom", "--resume", "{id}"}}) + if got != `custom --resume 'id with '\'' quote'` { + t.Fatalf("got %q", got) + } +} + +func TestExitTimeoutReturnsResume(t *testing.T) { + s := &scriptedRunner{t: t, responses: []response{ + {args: "pane run w1:p1 /clear"}, + {args: "pane run w1:p1 /exit"}, + {args: "agent list", out: codexAgent}, + }} + cmd, err := clearAgent(context.Background(), s, agent{Kind: "codex", PaneID: "w1:p1"}, profile{Clear: "/clear", Exit: "/exit"}, "codex resume old", time.Nanosecond) + if cmd != "codex resume old" || err == nil || !strings.Contains(err.Error(), "timeout") { + t.Fatalf("cmd %q, err %v", cmd, err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5760af8 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/CompositeCode/herdr-helper + +go 1.23