diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..c2171a4 --- /dev/null +++ b/Procfile @@ -0,0 +1,2 @@ +ingestion: cd ingestion && INGEST_BRAIN_DIR=../brain INGEST_PORT=3300 go run ./cmd/server/ +supervisor: SUPERVISOR_CONFIG_DIR=./config/supervisor SUPERVISOR_MODELS_FILE=./config/models.yaml SUPERVISOR_SESSIONS_DIR=./brain/sessions INGEST_BASE_URL=http://localhost:3300 go run ./cmd/supervisor/ diff --git a/Taskfile.yml b/Taskfile.yml index 8f0f33c..9caf4e3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -66,6 +66,11 @@ tasks: cmds: - go build -o bin/supervisor ./cmd/supervisor + bridge:build: + desc: Build stdio↔HTTP bridge for Claude Code MCP integration + cmds: + - go build -o bin/supervisor-bridge ./cmd/bridge + supervisor:test:smoke: desc: Smoke test supervisor via MCP (requires supervisor:dev running) cmds: @@ -75,22 +80,15 @@ tasks: -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq . start: - desc: Start the full hyperguild (ingestion + supervisor) in a tmux session + desc: Start ingestion + supervisor (requires goreman — go install github.com/mattn/goreman@latest) cmds: - - | - tmux new-session -d -s hyperguild -x 220 -y 50 2>/dev/null || true - tmux rename-window -t hyperguild:0 'ingestion' - tmux send-keys -t hyperguild:ingestion "cd {{.ROOT_DIR}} && INGEST_BRAIN_DIR={{.ROOT_DIR}}/brain INGEST_PORT=3300 go run ./ingestion/cmd/server/" Enter - tmux new-window -t hyperguild -n 'supervisor' - tmux send-keys -t hyperguild:supervisor "cd {{.ROOT_DIR}} && SUPERVISOR_CONFIG_DIR={{.ROOT_DIR}}/config/supervisor SUPERVISOR_MODELS_FILE={{.ROOT_DIR}}/config/models.yaml SUPERVISOR_SESSIONS_DIR={{.ROOT_DIR}}/brain/sessions INGEST_BASE_URL=http://localhost:3300 go run ./cmd/supervisor/" Enter - tmux new-window -t hyperguild -n 'shell' - tmux select-window -t hyperguild:shell - tmux attach -t hyperguild + - goreman start stop: - desc: Stop the hyperguild tmux session + desc: Stop all hyperguild processes (Ctrl-C in the goreman terminal, or kill by port) cmds: - - tmux kill-session -t hyperguild 2>/dev/null || true + - lsof -ti:3300 | xargs kill -9 2>/dev/null || true + - lsof -ti:3200 | xargs kill -9 2>/dev/null || true - echo "hyperguild stopped" ingestion:build: diff --git a/cmd/bridge/main.go b/cmd/bridge/main.go new file mode 100644 index 0000000..8d7282d --- /dev/null +++ b/cmd/bridge/main.go @@ -0,0 +1,59 @@ +// bridge is a stdio↔HTTP adapter that lets Claude Code connect to the +// supervisor MCP server via the stdio transport. +// +// Claude Code spawns this binary as a subprocess and communicates over +// stdin/stdout. Each newline-delimited JSON-RPC message from stdin is +// forwarded to the supervisor HTTP server and the response is written back. +// +// Usage: +// +// SUPERVISOR_URL=http://localhost:3200/mcp bridge +package main + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net/http" + "os" +) + +func main() { + url := os.Getenv("SUPERVISOR_URL") + if url == "" { + url = "http://localhost:3200/mcp" + } + + client := &http.Client{} + scanner := bufio.NewScanner(os.Stdin) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + + for scanner.Scan() { + line := scanner.Bytes() + if len(bytes.TrimSpace(line)) == 0 { + continue + } + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(line)) + if err != nil { + fmt.Fprintf(os.Stderr, "bridge: build request: %v\n", err) + continue + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "bridge: request failed: %v\n", err) + continue + } + io.Copy(os.Stdout, resp.Body) + resp.Body.Close() + os.Stdout.Write([]byte("\n")) + } + + if err := scanner.Err(); err != nil { + fmt.Fprintf(os.Stderr, "bridge: scanner: %v\n", err) + os.Exit(1) + } +} diff --git a/internal/exec/executor.go b/internal/exec/executor.go index d7391f2..eed6474 100644 --- a/internal/exec/executor.go +++ b/internal/exec/executor.go @@ -68,11 +68,10 @@ func (e *Executor) Run(ctx context.Context, req Request) (Result, error) { args := []string{ "--print", - "--bare", "--permission-mode", "bypassPermissions", "--tools", tools, "--json-schema", Schema, - "--output-format", "text", + "--output-format", "json", prompt, } @@ -89,12 +88,21 @@ func (e *Executor) Run(ctx context.Context, req Request) (Result, error) { return Result{}, fmt.Errorf("claude exited with error: %w — stderr: %s", err, stderr.String()) } - var r Result - if err := json.Unmarshal(stdout.Bytes(), &r); err != nil { - return Result{}, fmt.Errorf("parse result JSON: %w — raw output: %s", err, stdout.String()) + // --output-format json wraps the response in an envelope; structured output + // from --json-schema is in the "structured_output" field. + var envelope struct { + StructuredOutput *Result `json:"structured_output"` + IsError bool `json:"is_error"` + Result string `json:"result"` // fallback text result for error messages } - if err := r.Validate(); err != nil { + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + return Result{}, fmt.Errorf("parse envelope JSON: %w — raw: %s — stderr: %s", err, stdout.String(), stderr.String()) + } + if envelope.StructuredOutput == nil { + return Result{}, fmt.Errorf("no structured_output in response — result: %s — stderr: %s", envelope.Result, stderr.String()) + } + if err := envelope.StructuredOutput.Validate(); err != nil { return Result{}, fmt.Errorf("invalid result: %w", err) } - return r, nil + return *envelope.StructuredOutput, nil }