Skip to content
Merged
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
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.6 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/log v0.4.2 // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
Expand All @@ -41,6 +42,7 @@ require (
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
Expand Down Expand Up @@ -74,6 +76,7 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxD
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
Expand Down Expand Up @@ -70,6 +72,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
Expand Down
3 changes: 2 additions & 1 deletion internal/cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/jmgilman/headjack/internal/config"
"github.com/jmgilman/headjack/internal/instance"
"github.com/jmgilman/headjack/internal/keychain"
"github.com/jmgilman/headjack/internal/slogger"
)

var agentCmd = &cobra.Command{
Expand Down Expand Up @@ -249,7 +250,7 @@ func runAgentCmd(cmd *cobra.Command, args []string) error {
}

if flags.detached {
fmt.Printf("Created session %s in instance %s (detached)\n", session.Name, inst.ID)
slogger.L(cmd.Context()).Info("created session", "session", session.Name, "instance", inst.ID, "mode", "detached")
return nil
}

Expand Down
8 changes: 8 additions & 0 deletions internal/cmd/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package cmd

import (
"context"
"log/slog"

"github.com/jmgilman/headjack/internal/config"
"github.com/jmgilman/headjack/internal/instance"
"github.com/jmgilman/headjack/internal/slogger"
)

type contextKey string
Expand Down Expand Up @@ -56,3 +58,9 @@ func ManagerFromContext(ctx context.Context) *instance.Manager {
}
return mgr
}

// LoggerFromContext retrieves the logger from context.
// Returns a discarding logger if none is set (never returns nil).
func LoggerFromContext(ctx context.Context) *slog.Logger {
return slogger.FromContext(ctx)
}
2 changes: 1 addition & 1 deletion internal/cmd/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ func runExecCmd(cmd *cobra.Command, args []string) error {
}

if flags.detached {
fmt.Printf("Created session %s in instance %s (detached)\n", session.Name, inst.ID)
fmt.Printf("Created session %s in %s (detached)\n", session.Name, inst.ID)
return nil
}

Expand Down
8 changes: 2 additions & 6 deletions internal/cmd/ps.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,7 @@ func listInstances(cmd *cobra.Command) error {
}

if len(instances) == 0 {
if all {
fmt.Println("No instances found")
} else {
fmt.Println("No instances found for this repository")
}
fmt.Println("No instances found")
return nil
}

Expand Down Expand Up @@ -133,7 +129,7 @@ func listSessions(cmd *cobra.Command, branch string) error {
}

if len(sessions) == 0 {
fmt.Printf("No sessions found for instance %s\n", branch)
fmt.Println("No sessions found")
return nil
}

Expand Down
10 changes: 10 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/jmgilman/headjack/internal/git"
"github.com/jmgilman/headjack/internal/instance"
"github.com/jmgilman/headjack/internal/multiplexer"
"github.com/jmgilman/headjack/internal/slogger"
)

// baseDeps lists the external binaries that must always be available.
Expand All @@ -40,6 +41,9 @@ var appConfig *config.Config
// configLoader is used for accessing agent-specific configuration.
var configLoader *config.Loader

// verbosity controls log level: 0=error, 1=info, 2+=debug.
var verbosity int

var rootCmd = &cobra.Command{
Use: "headjack",
Short: "Spawn isolated LLM coding agents",
Expand All @@ -50,6 +54,9 @@ Each agent runs in its own VM-isolated container with a dedicated git worktree,
enabling safe parallel development across multiple branches.`,
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Initialize logger first so it's available for dependency/manager errors
logger := slogger.New(slogger.Config{Verbosity: verbosity})

if err := checkDependencies(); err != nil {
return err
}
Expand All @@ -60,6 +67,7 @@ enabling safe parallel development across multiple branches.`,

// Store dependencies in context for subcommands
ctx := cmd.Context()
ctx = slogger.WithLogger(ctx, logger)
ctx = WithConfig(ctx, appConfig)
ctx = WithLoader(ctx, configLoader)
ctx = WithManager(ctx, mgr)
Expand All @@ -76,6 +84,8 @@ func Execute() error {

func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().CountVarP(&verbosity, "verbose", "v",
"increase verbosity (-v for info, -vv for debug)")
}

func initConfig() {
Expand Down
8 changes: 5 additions & 3 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ package cmd
import (
"errors"
"fmt"
"log/slog"

"github.com/spf13/cobra"

"github.com/jmgilman/headjack/internal/container"
"github.com/jmgilman/headjack/internal/devcontainer"
"github.com/jmgilman/headjack/internal/instance"
"github.com/jmgilman/headjack/internal/prompt"
"github.com/jmgilman/headjack/internal/slogger"
)

var runCmd = &cobra.Command{
Expand Down Expand Up @@ -110,7 +112,7 @@ func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, br
if startErr := mgr.Start(cmd.Context(), inst.ID); startErr != nil {
return nil, fmt.Errorf("start stopped instance: %w", startErr)
}
fmt.Printf("Restarted instance %s for branch %s\n", inst.ID, inst.Branch)
slogger.L(cmd.Context()).Debug("restarted stopped instance", slog.String("id", inst.ID), slog.String("branch", inst.Branch))
// Refresh the instance to get updated status
inst, err = mgr.GetByBranch(cmd.Context(), repoPath, branch)
if err != nil {
Expand All @@ -135,7 +137,7 @@ func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, br
return nil, fmt.Errorf("create instance: %w", err)
}

fmt.Printf("Created instance %s for branch %s\n", inst.ID, inst.Branch)
slogger.L(cmd.Context()).Debug("created new instance", slog.String("id", inst.ID), slog.String("branch", inst.Branch))
return inst, nil
}

Expand Down Expand Up @@ -189,7 +191,7 @@ func buildCreateConfig(cmd *cobra.Command, repoPath, branch string, flags *runFl
return cfg, errors.New("failed to create devcontainer runtime")
}

fmt.Println("Detected devcontainer.json, using devcontainer mode")
slogger.L(cmd.Context()).Debug("detected devcontainer.json, using devcontainer mode")

cfg.WorkspaceFolder = repoPath
cfg.Runtime = dcRuntime
Expand Down
17 changes: 17 additions & 0 deletions internal/container/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package container
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"strings"
Expand All @@ -12,6 +13,7 @@ import (
"golang.org/x/term"

"github.com/jmgilman/headjack/internal/exec"
"github.com/jmgilman/headjack/internal/slogger"
)

// containerParser handles runtime-specific JSON parsing for container inspect and list operations.
Expand Down Expand Up @@ -48,6 +50,9 @@ func cliError(operation string, result *exec.Result, err error) error {

// Run creates and starts a new container.
func (r *baseRuntime) Run(ctx context.Context, cfg *RunConfig) (*Container, error) {
log := slogger.L(ctx)
log.Debug("running container", slog.String("name", cfg.Name), slog.String("image", cfg.Image))

args := buildRunArgs(cfg)

result, err := r.exec.Run(ctx, &exec.RunOptions{
Expand All @@ -64,6 +69,7 @@ func (r *baseRuntime) Run(ctx context.Context, cfg *RunConfig) (*Container, erro

// Container ID is returned on stdout
containerID := strings.TrimSpace(string(result.Stdout))
log.Debug("container started", slog.String("id", containerID))

return &Container{
ID: containerID,
Expand All @@ -76,6 +82,9 @@ func (r *baseRuntime) Run(ctx context.Context, cfg *RunConfig) (*Container, erro

// Exec executes a command in a running container.
func (r *baseRuntime) Exec(ctx context.Context, id string, cfg *ExecConfig) error {
log := slogger.L(ctx)
log.Debug("executing in container", slog.String("id", id), slog.Any("command", cfg.Command))

// Verify container exists and is running
container, err := r.Get(ctx, id)
if err != nil {
Expand Down Expand Up @@ -103,6 +112,9 @@ func (r *baseRuntime) Exec(ctx context.Context, id string, cfg *ExecConfig) erro

// Stop stops a running container gracefully.
func (r *baseRuntime) Stop(ctx context.Context, id string) error {
log := slogger.L(ctx)
log.Debug("stopping container", slog.String("id", id))

// Verify container exists
c, err := r.Get(ctx, id)
if err != nil {
Expand All @@ -111,6 +123,7 @@ func (r *baseRuntime) Stop(ctx context.Context, id string) error {

// No-op if already stopped
if c.Status == StatusStopped {
log.Debug("container already stopped", slog.String("id", id))
return nil
}

Expand All @@ -127,6 +140,9 @@ func (r *baseRuntime) Stop(ctx context.Context, id string) error {

// Start starts a stopped container.
func (r *baseRuntime) Start(ctx context.Context, id string) error {
log := slogger.L(ctx)
log.Debug("starting container", slog.String("id", id))

// Verify container exists
c, err := r.Get(ctx, id)
if err != nil {
Expand All @@ -135,6 +151,7 @@ func (r *baseRuntime) Start(ctx context.Context, id string) error {

// No-op if already running
if c.Status == StatusRunning {
log.Debug("container already running", slog.String("id", id))
return nil
}

Expand Down
10 changes: 10 additions & 0 deletions internal/git/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"bufio"
"context"
"fmt"
"log/slog"
"strings"

"github.com/jmgilman/headjack/internal/exec"
"github.com/jmgilman/headjack/internal/slogger"
)

type repository struct {
Expand Down Expand Up @@ -70,6 +72,9 @@ func (r *repository) remoteBranchExists(ctx context.Context, branch string) (boo
}

func (r *repository) CreateWorktree(ctx context.Context, path, branch string) error {
log := slogger.L(ctx)
log.Debug("creating worktree", slog.String("path", path), slog.String("branch", branch))

// Check if branch already exists
exists, err := r.BranchExists(ctx, branch)
if err != nil {
Expand All @@ -79,9 +84,11 @@ func (r *repository) CreateWorktree(ctx context.Context, path, branch string) er
var args []string
if exists {
// Use existing branch
log.Debug("using existing branch", slog.String("branch", branch))
args = []string{"worktree", "add", path, branch}
} else {
// Create new branch from HEAD
log.Debug("creating new branch from HEAD", slog.String("branch", branch))
args = []string{"worktree", "add", "-b", branch, path}
}

Expand All @@ -106,6 +113,9 @@ func (r *repository) CreateWorktree(ctx context.Context, path, branch string) er
}

func (r *repository) RemoveWorktree(ctx context.Context, path string) error {
log := slogger.L(ctx)
log.Debug("removing worktree", slog.String("path", path))

result, err := r.exec.Run(ctx, &exec.RunOptions{
Name: "git",
Args: []string{"worktree", "remove", path},
Expand Down
Loading