Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Copy to .env and fill in values. Kilroy auto-loads .env from the current
# working directory and from the directory containing the kilroy binary.
#
# cp .env.example .env

# Cursor Agent SDK (https://cursor.com/docs/sdk)
CURSOR_API_KEY=

# Composer 2.5 Fast + max mode for Kilroy cursor CLI runs
KILROY_CURSOR_MAX_MODE=true
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ jobs:
with:
go-version-file: go.mod

- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"

- name: Build Cursor SDK bridge
run: npm ci && npm run build
working-directory: scripts/cursor-agent

- name: Check formatting
run: |
unformatted=$(gofmt -l .)
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@

# GoReleaser build output
/dist/
/scripts/cursor-agent/node_modules/
/scripts/cursor-agent/dist/
firebase-debug.log
.claude/worktrees/
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,11 @@ Runs live under `~/.local/state/kilroy/attractor/runs/<run_id>/`. Key files:

### Agent Backend Configuration

Agent nodes (`shape=box`, `agent_tool="claude"`) require specific backend and handler configuration for proper agent log capture:
Agent nodes (`shape=box`, `agent_tool="cursor"` or legacy `claude`/`codex`/`gemini`) require specific backend and handler configuration for proper agent log capture:

- **`backend: cli`** in the run config — invokes the actual CLI binary (`claude`, `codex`, `opencode`) with `--output-format stream-json`, producing `agent_output.jsonl` with full conversation logs (tool calls, thinking, responses). The server parses this into structured agent events for the UI.
- **`backend: api`** — uses the Anthropic HTTP API directly. Produces `events.ndjson` in a different format. The server does NOT currently parse this into UI-visible agent events. Use `backend: cli` for runs where you want the UI to show agent conversation detail.
- **`--tmux` flag** — required for agent nodes that use CLI backends. Registers `TmuxAgentHandler` which runs agent CLIs in tmux sessions for reliable headless execution. Without `--tmux`, the default `AgentHandler` is used (API-only path).
- **`backend: cli`** in the run config — invokes `kilroy-cursor-agent` (`@cursor/sdk`) with `--stream-json`, producing stdout NDJSON compatible with Kilroy's CLI stream parser and CXDB turns. Requires `CURSOR_API_KEY`.
- **`backend: api`** — uses provider HTTP APIs directly. Produces `events.ndjson` in a different format. The server does NOT currently parse this into UI-visible agent events. Use `backend: cli` for runs where you want the UI to show agent conversation detail.
- **`--tmux` flag** — required for agent nodes that use CLI backends. Registers `TmuxAgentHandler` which runs the cursor SDK bridge in tmux sessions for reliable headless execution. Without `--tmux`, the default `AgentHandler` is used (API-only path).
- **`--package` flag** — points to a workflow package directory (e.g., `workflows/pr-review/`). Copies scripts, prompts, and graph into the worktree at `.kilroy/package/`.

Example production PR review launch:
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ This implementation is based on the Attractor specification by StrongDM at `http
- Clean working tree before `attractor run`/`resume`
- CXDB reachable over binary + HTTP endpoints (or configure `cxdb.autostart`)
- Provider access for any provider used in your graph
- `claude` CLI for `attractor ingest` (or set `KILROY_CLAUDE_PATH`)
- `CURSOR_API_KEY` and Node.js 20+ for CLI backends and `attractor ingest` (via `@cursor/sdk` bridge; override with `KILROY_CURSOR_AGENT_PATH`)

## Quickstart

Expand Down Expand Up @@ -205,7 +205,7 @@ Important:
Real run (recommended/default profile):

```bash
unset KILROY_CODEX_PATH KILROY_CLAUDE_PATH KILROY_GEMINI_PATH
unset KILROY_CURSOR_AGENT_PATH KILROY_CODEX_PATH KILROY_CLAUDE_PATH KILROY_GEMINI_PATH
./kilroy attractor run --graph pipeline.dot --config run.yaml
```

Expand Down Expand Up @@ -291,16 +291,17 @@ Provider runtime architecture:
- `kimi`, `zai`, `cerebras`, and `minimax` are API-only in this release.
- `profile_family` selects agent behavior/tooling profile only; API requests still route by `llm_provider` (native provider key).

CLI backend command mappings:
CLI backend (Cursor TypeScript SDK):

- `openai` -> `codex exec --json --sandbox workspace-write ...`
- `anthropic` -> `claude -p --output-format stream-json ...`
- `google` -> `gemini -p --output-format stream-json --yolo ...`
- `openai`, `anthropic`, and `google` CLI backends invoke `kilroy-cursor-agent`, a headless bridge around [`@cursor/sdk`](https://cursor.com/docs/sdk/typescript).
- Requires `CURSOR_API_KEY` and Node.js 20+. Build the bridge once: `npm install && npm run build --prefix scripts/cursor-agent`.
- Invocation shape: `kilroy-cursor-agent run --cwd <worktree> --model <cursor-model> --stream-json` (prompt on stdin).
- Legacy graph model IDs (for example `claude-sonnet-4-6`, `gemini-3-flash-preview`) are mapped to Cursor SDK models (for example `composer-2.5`, `composer-2-fast`).

Execution policy:

- `llm.cli_profile` defaults to `real`.
- In `real`, Kilroy uses canonical binaries (`codex`, `claude`, `gemini`) and rejects `KILROY_CODEX_PATH`, `KILROY_CLAUDE_PATH`, `KILROY_GEMINI_PATH`.
- In `real`, Kilroy resolves the bundled `scripts/kilroy-cursor-agent` wrapper and rejects `KILROY_CURSOR_AGENT_PATH`, `KILROY_CODEX_PATH`, `KILROY_CLAUDE_PATH`, and `KILROY_GEMINI_PATH`.
- For fake/shim binaries, set `llm.cli_profile: test_shim`, configure `llm.providers.<provider>.executable`, and run with `--allow-test-shim`.

API backend environment variables:
Expand Down
12 changes: 6 additions & 6 deletions cmd/kilroy/attractor_runs.go
Original file line number Diff line number Diff line change
Expand Up @@ -568,11 +568,11 @@ type runShowDetail struct {

// runShowOutputRef points at a declared output file on disk.
type runShowOutputRef struct {
Name string `json:"name"`
Path string `json:"path,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Found bool `json:"found"`
Source string `json:"source,omitempty"` // "collected" or "worktree"
Name string `json:"name"`
Path string `json:"path,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Found bool `json:"found"`
Source string `json:"source,omitempty"` // "collected" or "worktree"
}

func attractorRunsShow(args []string) {
Expand Down Expand Up @@ -853,7 +853,7 @@ func attractorRunsWait(args []string) {
var latest bool
var asJSON bool
labelFilters := map[string]string{}
timeout := time.Duration(0) // 0 = no timeout
timeout := time.Duration(0) // 0 = no timeout
interval := 2 * time.Second

for i := 0; i < len(args); i++ {
Expand Down
11 changes: 9 additions & 2 deletions cmd/kilroy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,12 @@ func attractorRun(args []string) {
Labels: labels,
GitOps: gitOps,
Invocation: os.Args,
PackageDir: func() string { if pkg != nil { return pkg.Dir }; return "" }(),
PackageDir: func() string {
if pkg != nil {
return pkg.Dir
}
return ""
}(),
OnCXDBStartup: func(info *engine.CXDBStartupInfo) {
if info == nil {
return
Expand Down Expand Up @@ -1066,7 +1071,9 @@ func attractorResume(args []string) {
)
switch {
case logsRoot != "":
res, err = engine.Resume(ctx, logsRoot)
res, err = engine.Resume(ctx, logsRoot, engine.ResumeOverrides{
Registry: newLayeredRegistry(true),
})
case cxdbBaseURL != "" && contextID != "":
res, err = engine.ResumeFromCXDB(ctx, cxdbBaseURL, contextID)
case runBranch != "":
Expand Down
8 changes: 4 additions & 4 deletions internal/attractor/agents/agentlog/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,10 @@ func claudeProjectDir(workDir string) string {

// AgentEvent represents a parsed event from a CLI agent's conversation log.
type AgentEvent struct {
Type string `json:"type"` // tool_call, tool_result, text, thinking
Tool string `json:"tool"` // tool name (for tool_call/tool_result)
Message string `json:"msg"` // human-readable summary
Data map[string]any `json:"data"` // structured payload
Type string `json:"type"` // tool_call, tool_result, text, thinking
Tool string `json:"tool"` // tool name (for tool_call/tool_result)
Message string `json:"msg"` // human-readable summary
Data map[string]any `json:"data"` // structured payload
}

// ParseClaudeLog reads a Claude JSONL conversation file and returns structured events.
Expand Down
8 changes: 4 additions & 4 deletions internal/attractor/agents/templates/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ func Codex() Template {
env["CODEX_HOME"] = codexHome
return nil
},
PromptPrefix: "›",
BusyIndicators: []string{"Working", "esc to interrupt"},
ProcessNames: []string{"codex", "node"},
PromptPrefix: "›",
BusyIndicators: []string{"Working", "esc to interrupt"},
ProcessNames: []string{"codex", "node"},
StructuredOutput: true,
ExitsOnComplete: true, // exec mode exits on completion
StartupTimeout: 30 * time.Second,
StartupTimeout: 30 * time.Second,
}
}
43 changes: 43 additions & 0 deletions internal/attractor/agents/templates/cursor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Cursor SDK invocation template (kilroy-cursor-agent bridge).
package templates

import (
"os"
"time"

"github.com/danshapiro/kilroy/internal/cursoragent"
)

// Cursor returns an invocation template for the @cursor/sdk local agent bridge.
func Cursor() Template {
return Template{
Name: "cursor",
Binary: cursoragent.ResolveExecutable(),
LogLocator: nil,
BuildArgs: func(prompt, workDir, model string) []string {
args := []string{
"run",
"--cwd", workDir,
"--model", cursoragent.ToCursorModelID("", model),
"--stream-json",
}
_ = prompt // prompt is passed on stdin by the session manager
return args
},
BuildEnv: func() map[string]string {
env := map[string]string{}
if key := os.Getenv("CURSOR_API_KEY"); key != "" {
env["CURSOR_API_KEY"] = key
}
return env
},
PromptFileFlag: "--prompt-file",
StructuredOutput: true,
PromptPrefix: "",
BusyIndicators: []string{"[tool]", "[status]"},
ProcessNames: []string{"node", "kilroy-cursor-agent"},
ExitsOnComplete: true,
StartupDialogs: nil,
StartupTimeout: 30 * time.Second,
}
}
8 changes: 4 additions & 4 deletions internal/attractor/agents/templates/opencode.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ func OpenCode() Template {
env["OPENCODE_CONFIG_CONTENT"] = string(data)
return nil
},
PromptPrefix: ">",
BusyIndicators: []string{},
ProcessNames: []string{"opencode"},
PromptPrefix: ">",
BusyIndicators: []string{},
ProcessNames: []string{"opencode"},
StructuredOutput: true,
ExitsOnComplete: true,
StartupTimeout: 15 * time.Second,
StartupTimeout: 15 * time.Second,
}
}
1 change: 1 addition & 0 deletions internal/attractor/agents/templates/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type Registry struct {
// DefaultRegistry returns a registry with all built-in tool templates.
func DefaultRegistry() *Registry {
r := &Registry{templates: map[string]Template{}}
r.Register(Cursor())
r.Register(Claude())
r.Register(Codex())
r.Register(Gemini())
Expand Down
27 changes: 14 additions & 13 deletions internal/attractor/agents/templates/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,20 @@ import (

// Template defines how to invoke a specific CLI agent tool.
type Template struct {
Name string // tool name (e.g. "claude", "codex")
Binary string // executable name
BuildArgs func(prompt, workDir, model string) []string
BuildEnv func() map[string]string
PrepareSession func(stageDir string, env map[string]string) error // optional pre-session setup (e.g. write config files)
StructuredOutput bool // when true, command output is JSONL; handler redirects to agent_output.jsonl
PromptPrefix string // prompt prefix for readiness detection
BusyIndicators []string // strings indicating the agent is busy
ProcessNames []string // expected process names for liveness
ExitsOnComplete bool // true if tool exits after finishing (e.g. --print mode)
StartupDialogs []StartupDialog // dialogs to dismiss at startup
StartupTimeout time.Duration // max time to wait for initial readiness
LogLocator LogLocator // fallback: finds the CLI tool's conversation log after execution
Name string // tool name (e.g. "claude", "codex")
Binary string // executable name
BuildArgs func(prompt, workDir, model string) []string
BuildEnv func() map[string]string
PrepareSession func(stageDir string, env map[string]string) error // optional pre-session setup (e.g. write config files)
StructuredOutput bool // when true, command output is JSONL; handler redirects to agent_output.jsonl
PromptFileFlag string // when set, append flag + stageDir/prompt.md to the command (e.g. "--prompt-file")
PromptPrefix string // prompt prefix for readiness detection
BusyIndicators []string // strings indicating the agent is busy
ProcessNames []string // expected process names for liveness
ExitsOnComplete bool // true if tool exits after finishing (e.g. --print mode)
StartupDialogs []StartupDialog // dialogs to dismiss at startup
StartupTimeout time.Duration // max time to wait for initial readiness
LogLocator LogLocator // fallback: finds the CLI tool's conversation log after execution
}

// LogLocator finds and parses the conversation log written by a CLI tool.
Expand Down
25 changes: 14 additions & 11 deletions internal/attractor/agents/tmux_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,13 @@ func (h *TmuxAgentHandler) Execute(ctx context.Context, exec *engine.Execution,
}, nil
}
}
promptPath := filepath.Join(stageDir, "prompt.md")
_ = os.WriteFile(promptPath, []byte(prompt), 0o644)
if flag := strings.TrimSpace(tmpl.PromptFileFlag); flag != "" {
command = command + " " + flag + " " + shellQuoteSimple(promptPath)
}
_ = os.WriteFile(filepath.Join(stageDir, "tmux_command.txt"), []byte(command), 0o644)

// Write prompt for debugging.
_ = os.WriteFile(filepath.Join(stageDir, "prompt.md"), []byte(prompt), 0o644)

// Emit progress event.
if exec.Engine != nil {
exec.Engine.AppendProgress(map[string]any{
Expand Down Expand Up @@ -326,20 +328,21 @@ func (h *TmuxAgentHandler) handleStartupDialog(session string, dialog templates.
func resolveToolName(node *model.Node) string {
// Check explicit node attribute first.
if tool := strings.TrimSpace(node.Attr("agent_tool", "")); tool != "" {
return tool
switch strings.ToLower(tool) {
case "claude", "codex", "gemini", "opencode":
return "cursor"
default:
return tool
}
}
// Check llm_provider for provider-based routing.
if provider := strings.TrimSpace(node.Attr("llm_provider", "")); provider != "" {
switch strings.ToLower(provider) {
case "anthropic":
return "claude"
case "openai":
return "codex"
case "google", "gemini":
return "gemini"
case "anthropic", "openai", "google", "gemini":
return "cursor"
}
}
return "claude" // default
return "cursor"
}

// buildTmuxAgentEnv constructs the environment variables passed to a tmux-run
Expand Down
Loading