7d69a1f320
Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.
- internal/mode: the Mode contract (Kind/Command/View/Active) plus optional
EvidenceConsumer and Expirer ports, and the surfacing Envelope.
- internal/mode/focus: the former session/domain/statemachine packages moved
under the mode, now satisfying the harness contracts unchanged.
- internal/mode/offscreen: a one-shot away-from-desk mode built on the new
ai.Proposer, which turns a life-domain brief into one off-screen action.
- cmd/keeld replaces cmd/antidriftd; daemon wires focus + offscreen factories
with per-mode persistence under ~/.keel/modes/<kind>.
- Finish the rename: KEEL_* env refs in the README, /keeld build artifact
ignored, stale antidriftd binary removed.
Include the design + implementation plan this refactor was built from under
docs/superpowers/{specs,plans}/2026-06-04-controller-refactor*.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
117 lines
3.3 KiB
Go
117 lines
3.3 KiB
Go
package ai
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// claudeBackend shells out to `claude --print` with the prompt on stdin and the
|
|
// answer on stdout.
|
|
type claudeBackend struct {
|
|
cmd string
|
|
args []string
|
|
}
|
|
|
|
func newClaudeBackend() claudeBackend {
|
|
return claudeBackend{
|
|
cmd: "claude",
|
|
args: []string{"--print", "--tools", "", "--no-session-persistence", "--output-format", "text"},
|
|
}
|
|
}
|
|
|
|
func (b claudeBackend) Name() string { return "claude" }
|
|
|
|
func (b claudeBackend) Run(ctx context.Context, prompt string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, b.cmd, b.args...)
|
|
cmd.Stdin = strings.NewReader(prompt)
|
|
var out, errb bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &errb
|
|
if err := cmd.Run(); err != nil {
|
|
return "", cmdError("claude", err, errb.String())
|
|
}
|
|
return out.String(), nil
|
|
}
|
|
|
|
// cmdError wraps a CLI failure, appending stderr only when it is non-empty so
|
|
// the message does not end in a dangling ": ".
|
|
func cmdError(name string, err error, stderr string) error {
|
|
if s := strings.TrimSpace(stderr); s != "" {
|
|
return fmt.Errorf("%s: %w: %s", name, err, s)
|
|
}
|
|
return fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
|
|
// codexBackend shells out to `codex exec` headlessly. codex's stdout includes
|
|
// session preamble, so the final answer is written to a temp file via -o and
|
|
// read back. The --ignore-* and read-only flags stop it from running shell
|
|
// commands driven by local config.
|
|
type codexBackend struct {
|
|
cmd string
|
|
baseArgs []string
|
|
}
|
|
|
|
func newCodexBackend() codexBackend {
|
|
return codexBackend{
|
|
cmd: "codex",
|
|
baseArgs: []string{
|
|
"exec", "--skip-git-repo-check", "--ignore-user-config",
|
|
"--ignore-rules", "-s", "read-only", "-a", "never", "--ephemeral",
|
|
},
|
|
}
|
|
}
|
|
|
|
func (b codexBackend) Name() string { return "codex" }
|
|
|
|
// args builds the full argument list, writing the answer to outfile and reading
|
|
// the prompt from stdin (trailing "-").
|
|
func (b codexBackend) args(outfile string) []string {
|
|
out := append([]string{}, b.baseArgs...)
|
|
return append(out, "-o", outfile, "-")
|
|
}
|
|
|
|
func (b codexBackend) Run(ctx context.Context, prompt string) (string, error) {
|
|
f, err := os.CreateTemp("", "keel-codex-*.out")
|
|
if err != nil {
|
|
return "", fmt.Errorf("codex: temp file: %w", err)
|
|
}
|
|
path := f.Name()
|
|
_ = f.Close()
|
|
defer os.Remove(path)
|
|
|
|
cmd := exec.CommandContext(ctx, b.cmd, b.args(path)...)
|
|
cmd.Stdin = strings.NewReader(prompt)
|
|
var errb bytes.Buffer
|
|
cmd.Stderr = &errb // stdout is noisy and intentionally discarded
|
|
if err := cmd.Run(); err != nil {
|
|
return "", cmdError("codex", err, errb.String())
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("codex: read output: %w", err)
|
|
}
|
|
// A zero-exit run that wrote nothing means the -o file was never populated
|
|
// (e.g. flag/version skew); surface that as a codex error instead of letting
|
|
// it masquerade downstream as an empty model answer.
|
|
if len(bytes.TrimSpace(data)) == 0 {
|
|
return "", fmt.Errorf("codex: empty output file: %s", strings.TrimSpace(errb.String()))
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
// NewBackend returns the named backend. "" defaults to "claude".
|
|
func NewBackend(name string) (Backend, error) {
|
|
switch name {
|
|
case "", "claude":
|
|
return newClaudeBackend(), nil
|
|
case "codex":
|
|
return newCodexBackend(), nil
|
|
default:
|
|
return nil, fmt.Errorf("ai: unknown backend %q", name)
|
|
}
|
|
}
|