diff --git a/internal/cmd/agent.go b/internal/cmd/agent.go index c6a1ad6..d0130fa 100644 --- a/internal/cmd/agent.go +++ b/internal/cmd/agent.go @@ -14,7 +14,7 @@ import ( ) var agentCmd = &cobra.Command{ - Use: "agent [agent_name]", + Use: "agent [agent_name] [-- ...]", Short: "Start an agent session in an existing instance", Long: `Start an agent session within an existing instance for the specified branch. @@ -25,6 +25,8 @@ to it unless --detached is specified. If agent_name is not specified, the default agent from configuration is used. Set the default with 'hjk config default.agent '. +Additional flags can be passed to the agent CLI by placing them after a -- separator. + All session output is captured to a log file regardless of attached/detached mode.`, Example: ` # Start default agent on existing instance hjk agent feat/auth @@ -40,8 +42,11 @@ All session output is captured to a log file regardless of attached/detached mod hjk agent feat/auth gemini --name auth-session # Start agent in detached mode (run in background) - hjk agent feat/auth -d --prompt "Refactor the auth module"`, - Args: cobra.RangeArgs(1, 2), + hjk agent feat/auth -d --prompt "Refactor the auth module" + + # Pass additional flags to the agent CLI + hjk agent feat/auth claude -- --dangerously-skip-permissions`, + Args: cobra.MinimumNArgs(1), RunE: runAgentCmd, } @@ -50,10 +55,11 @@ type agentFlags struct { sessionName string detached bool prompt string + agentFlags []string // flags to pass to the agent CLI (after --) } // parseAgentFlags extracts and validates flags from the command. -func parseAgentFlags(cmd *cobra.Command) (*agentFlags, error) { +func parseAgentFlags(cmd *cobra.Command, args []string) (*agentFlags, error) { sessionName, err := cmd.Flags().GetString("name") if err != nil { return nil, fmt.Errorf("get name flag: %w", err) @@ -71,6 +77,7 @@ func parseAgentFlags(cmd *cobra.Command) (*agentFlags, error) { sessionName: sessionName, detached: detached, prompt: prompt, + agentFlags: parsePassthroughArgs(cmd, args), }, nil } @@ -136,17 +143,22 @@ func injectAuthCredential(agent string, cfg *instance.CreateSessionConfig) error } // buildAgentCommand builds the command for launching an agent. -func buildAgentCommand(agent, prompt string) []string { +// The command is: agent [prompt] [flags...] +func buildAgentCommand(agent, prompt string, flags []string) []string { cmd := []string{agent} if prompt != "" { cmd = append(cmd, prompt) } + cmd = append(cmd, flags...) return cmd } // resolveAgentName determines the agent name from args or config default. -func resolveAgentName(ctx context.Context, args []string) (string, error) { - if len(args) > 1 { +// dashIdx is the index of the -- separator (-1 if not present). +func resolveAgentName(ctx context.Context, args []string, dashIdx int) (string, error) { + // Check if args[1] exists and is before the -- separator (or no separator) + hasAgentArg := len(args) > 1 && (dashIdx < 0 || dashIdx > 1) + if hasAgentArg { return args[1], nil } @@ -173,12 +185,12 @@ func runAgentCmd(cmd *cobra.Command, args []string) error { return err } - flags, err := parseAgentFlags(cmd) + flags, err := parseAgentFlags(cmd, args) if err != nil { return err } - agentName, err := resolveAgentName(cmd.Context(), args) + agentName, err := resolveAgentName(cmd.Context(), args, cmd.ArgsLenAtDash()) if err != nil { return err } @@ -194,11 +206,18 @@ func runAgentCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid agent %q (valid: %s)", agentName, formatList(config.ValidAgentNames())) } + // Merge config flags with CLI flags + var configFlags []string + if loader := LoaderFromContext(cmd.Context()); loader != nil { + configFlags = loader.GetAgentFlags(agentName) + } + mergedFlags := mergeFlags(configFlags, flags.agentFlags) + // Build session config sessionCfg := &instance.CreateSessionConfig{ Type: agentName, Name: flags.sessionName, - Command: buildAgentCommand(agentName, flags.prompt), + Command: buildAgentCommand(agentName, flags.prompt, mergedFlags), } // Inject agent-specific environment variables from config diff --git a/internal/cmd/helpers.go b/internal/cmd/helpers.go index cbb30bd..92503e9 100644 --- a/internal/cmd/helpers.go +++ b/internal/cmd/helpers.go @@ -73,6 +73,29 @@ func formatNotRunningHint(cmd *cobra.Command, err *instance.NotRunningError) str return fmt.Sprintf("container %s is %s; check logs with `%s`", err.ContainerID, err.Status, logsCmd) } +// parsePassthroughArgs extracts arguments after the -- separator from a cobra command. +// Returns nil if no -- separator was used. +func parsePassthroughArgs(cmd *cobra.Command, args []string) []string { + dashIdx := cmd.ArgsLenAtDash() + if dashIdx < 0 || dashIdx >= len(args) { + return nil + } + return args[dashIdx:] +} + +// mergeFlags combines base flags with additional flags. +// Base flags come first, additional flags are appended. +// Returns nil if both inputs are empty. +func mergeFlags(base, additional []string) []string { + if len(base) == 0 && len(additional) == 0 { + return nil + } + result := make([]string, 0, len(base)+len(additional)) + result = append(result, base...) + result = append(result, additional...) + return result +} + // getInstanceByBranch gets an existing instance by branch, returning an error with hint if not found. // If the instance is stopped, it will be automatically restarted. func getInstanceByBranch(ctx context.Context, mgr *instance.Manager, branch string) (*instance.Instance, error) { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 38af073..18e0037 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -17,7 +17,6 @@ import ( "github.com/jmgilman/headjack/internal/config" "github.com/jmgilman/headjack/internal/container" hjexec "github.com/jmgilman/headjack/internal/exec" - "github.com/jmgilman/headjack/internal/flags" "github.com/jmgilman/headjack/internal/git" "github.com/jmgilman/headjack/internal/instance" "github.com/jmgilman/headjack/internal/multiplexer" @@ -178,17 +177,11 @@ func initManager() error { // Map runtime name to RuntimeType runtimeType := runtimeNameToType(runtimeName) - // Parse config flags - configFlags, err := getConfigFlags() - if err != nil { - return err - } - - mgr = instance.NewManager(store, runtime, opener, mux, instance.ManagerConfig{ + mgr = instance.NewManager(store, runtime, opener, mux, &instance.ManagerConfig{ WorktreesDir: worktreesDir, LogsDir: logsDir, RuntimeType: runtimeType, - ConfigFlags: configFlags, + ConfigFlags: getConfigFlags(), Executor: executor, }) @@ -205,16 +198,12 @@ func runtimeNameToType(name string) instance.RuntimeType { } } -// getConfigFlags parses runtime flags from config. -func getConfigFlags() (flags.Flags, error) { - if appConfig == nil || appConfig.Runtime.Flags == nil { - return make(flags.Flags), nil - } - configFlags, err := flags.FromConfig(appConfig.Runtime.Flags) - if err != nil { - return nil, fmt.Errorf("parse runtime flags: %w", err) +// getConfigFlags returns runtime flags from config. +func getConfigFlags() []string { + if appConfig == nil { + return nil } - return configFlags, nil + return appConfig.Runtime.Flags } // formatList joins strings with commas and "and" before the last item. diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 7370d1f..60c2305 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -13,7 +13,7 @@ import ( ) var runCmd = &cobra.Command{ - Use: "run ", + Use: "run [-- ...]", Short: "Create a new instance for the specified branch", Long: `Create a new instance (worktree + container) for the specified branch. @@ -25,6 +25,9 @@ stopped). The container environment is determined by: 2. Base image: Use --image to specify a container image directly, bypassing devcontainer detection. +Additional flags can be passed to the container runtime (or devcontainer CLI) +by placing them after a -- separator. + This command only creates the instance. To start a session, use: - 'hjk agent ' to start an agent session - 'hjk exec ' to start a shell session`, @@ -34,21 +37,25 @@ This command only creates the instance. To start a session, use: # Use a specific container image (bypasses devcontainer) hjk run feat/auth --image my-registry.io/custom-image:latest + # Pass additional flags to the container runtime + hjk run feat/auth -- --memory=4g --privileged + # Typical workflow: create instance, then start agent hjk run feat/auth hjk agent feat/auth claude --prompt "Implement JWT authentication"`, - Args: cobra.ExactArgs(1), + Args: cobra.MinimumNArgs(1), RunE: runRunCmd, } // runFlags holds parsed flags for the run command. type runFlags struct { image string - imageExplicit bool // true if --image was explicitly passed + imageExplicit bool // true if --image was explicitly passed + runtimeFlags []string // flags to pass to the container runtime (after --) } // parseRunFlags extracts and validates flags from the command. -func parseRunFlags(cmd *cobra.Command) (*runFlags, error) { +func parseRunFlags(cmd *cobra.Command, args []string) (*runFlags, error) { image, err := cmd.Flags().GetString("image") if err != nil { return nil, fmt.Errorf("get image flag: %w", err) @@ -60,6 +67,7 @@ func parseRunFlags(cmd *cobra.Command) (*runFlags, error) { return &runFlags{ image: image, imageExplicit: imageExplicit, + runtimeFlags: parsePassthroughArgs(cmd, args), }, nil } @@ -71,7 +79,7 @@ func runRunCmd(cmd *cobra.Command, args []string) error { return err } - flags, err := parseRunFlags(cmd) + flags, err := parseRunFlags(cmd, args) if err != nil { return err } @@ -81,7 +89,7 @@ func runRunCmd(cmd *cobra.Command, args []string) error { return err } - inst, err := getOrCreateInstance(cmd, mgr, repoPath, branch, flags.image, flags.imageExplicit) + inst, err := getOrCreateInstance(cmd, mgr, repoPath, branch, flags) if err != nil { return err } @@ -93,7 +101,7 @@ func runRunCmd(cmd *cobra.Command, args []string) error { // getOrCreateInstance retrieves an existing instance or creates a new one. // If the instance exists but is stopped, it restarts the container. // If imageExplicit is false and a devcontainer.json exists, devcontainer mode is used. -func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, branch, image string, imageExplicit bool) (*instance.Instance, error) { +func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, branch string, flags *runFlags) (*instance.Instance, error) { // Try to get existing instance inst, err := mgr.GetByBranch(cmd.Context(), repoPath, branch) if err == nil { @@ -116,13 +124,13 @@ func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, br } // Build create config - detect devcontainer mode if applicable - createCfg, err := buildCreateConfig(cmd, repoPath, branch, image, imageExplicit) + createCfg, err := buildCreateConfig(cmd, repoPath, branch, flags) if err != nil { return nil, err } // Create new instance - inst, err = mgr.Create(cmd.Context(), repoPath, createCfg) + inst, err = mgr.Create(cmd.Context(), repoPath, &createCfg) if err != nil { return nil, fmt.Errorf("create instance: %w", err) } @@ -138,14 +146,15 @@ func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, br // - The devcontainer CLI is available (or can be installed) // // Returns an error if no devcontainer.json is found and no image is configured. -func buildCreateConfig(cmd *cobra.Command, repoPath, branch, image string, imageExplicit bool) (instance.CreateConfig, error) { +func buildCreateConfig(cmd *cobra.Command, repoPath, branch string, flags *runFlags) (instance.CreateConfig, error) { cfg := instance.CreateConfig{ - Branch: branch, - Image: image, + Branch: branch, + Image: flags.image, + RuntimeFlags: flags.runtimeFlags, } // If image was explicitly passed, use vanilla mode - if imageExplicit { + if flags.imageExplicit { return cfg, nil } @@ -190,7 +199,7 @@ func buildCreateConfig(cmd *cobra.Command, repoPath, branch, image string, image } // No devcontainer.json - need an image - if image == "" { + if flags.image == "" { return cfg, errors.New("no devcontainer.json found and no image configured\n\nTo fix this, either:\n 1. Add a devcontainer.json to your repository\n 2. Use --image to specify a container image\n 3. Set default.base_image in your config") } diff --git a/internal/config/config.go b/internal/config/config.go index d792f4e..4102f61 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,7 +69,8 @@ type DefaultConfig struct { // AgentConfig holds agent-specific configuration. type AgentConfig struct { - Env map[string]string `mapstructure:"env"` + Env map[string]string `mapstructure:"env"` + Flags []string `mapstructure:"flags"` } // StorageConfig holds storage location configuration. @@ -81,8 +82,8 @@ type StorageConfig struct { // RuntimeConfig holds container runtime configuration. type RuntimeConfig struct { - Name string `mapstructure:"name" validate:"omitempty,oneof=podman docker"` - Flags map[string]any `mapstructure:"flags"` + Name string `mapstructure:"name" validate:"omitempty,oneof=podman docker"` + Flags []string `mapstructure:"flags"` } // DevcontainerConfig holds devcontainer CLI configuration. @@ -152,10 +153,13 @@ func (l *Loader) setDefaults() { l.v.SetDefault("storage.catalog", "~/.local/share/headjack/catalog.json") l.v.SetDefault("storage.logs", "~/.local/share/headjack/logs") l.v.SetDefault("agents.claude.env", map[string]string{"CLAUDE_CODE_MAX_TURNS": "100"}) + l.v.SetDefault("agents.claude.flags", []string{}) l.v.SetDefault("agents.gemini.env", map[string]string{}) + l.v.SetDefault("agents.gemini.flags", []string{}) l.v.SetDefault("agents.codex.env", map[string]string{}) + l.v.SetDefault("agents.codex.flags", []string{}) l.v.SetDefault("runtime.name", "docker") - l.v.SetDefault("runtime.flags", map[string]any{}) + l.v.SetDefault("runtime.flags", []string{}) l.v.SetDefault("devcontainer.path", "") } @@ -211,6 +215,13 @@ func (l *Loader) GetAgentEnv(agent string) map[string]string { return raw } +// GetAgentFlags returns the CLI flags for a specific agent. +// Returns nil if the agent has no flags configuration. +func (l *Loader) GetAgentFlags(agent string) []string { + key := fmt.Sprintf("agents.%s.flags", agent) + return l.v.GetStringSlice(key) +} + // Set sets a configuration value by dot-notation key. func (l *Loader) Set(key, value string) error { if err := ValidateKey(key); err != nil { diff --git a/internal/devcontainer/runtime.go b/internal/devcontainer/runtime.go index 872f612..942ac88 100644 --- a/internal/devcontainer/runtime.go +++ b/internal/devcontainer/runtime.go @@ -58,6 +58,9 @@ func (r *Runtime) Run(ctx context.Context, cfg *container.RunConfig) (*container "--docker-path", r.dockerPath, } + // Append any additional flags (passed via --) + args = append(args, cfg.Flags...) + result, err := r.exec.Run(ctx, &exec.RunOptions{ Name: r.cliPath, Args: args, @@ -136,22 +139,14 @@ func (r *Runtime) Exec(ctx context.Context, id string, cfg *container.ExecConfig return r.execInteractive(ctx, args) } - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.cliPath, - Args: args, + // Non-interactive mode: connect stdout/stderr directly + _, err = r.exec.Run(ctx, &exec.RunOptions{ + Name: r.cliPath, + Args: args, + Stdout: os.Stdout, + Stderr: os.Stderr, }) - if err != nil { - stderr := "" - if result != nil { - stderr = strings.TrimSpace(string(result.Stderr)) - } - if stderr != "" { - return fmt.Errorf("devcontainer exec: %s", stderr) - } - return fmt.Errorf("devcontainer exec: %w", err) - } - - return nil + return err } // execInteractive runs a devcontainer exec command with TTY support. diff --git a/internal/flags/flags.go b/internal/flags/flags.go deleted file mode 100644 index 089e15d..0000000 --- a/internal/flags/flags.go +++ /dev/null @@ -1,132 +0,0 @@ -// Package flags provides parsing, merging, and reconstruction of container runtime flags. -package flags - -import ( - "errors" - "fmt" - "sort" -) - -// Flags represents runtime flags as a key-value map. -// Values can be: -// - string: generates --key=value -// - bool: true generates --key, false omits the flag -// - []string: generates --key=v for each element -type Flags map[string]any - -// Sentinel errors for flag operations. -var ( - // ErrInvalidFlagValue is returned when a flag value has an unsupported type. - ErrInvalidFlagValue = errors.New("invalid flag value type") -) - -// FromConfig validates and normalizes config values into Flags. -// Accepts string, bool, []string, and []any (converted to []string). -func FromConfig(cfg map[string]any) (Flags, error) { - if cfg == nil { - return make(Flags), nil - } - - result := make(Flags, len(cfg)) - for k, v := range cfg { - switch val := v.(type) { - case string: - result[k] = val - case bool: - result[k] = val - case []string: - result[k] = val - case []any: - // Convert []any to []string (common from YAML parsing) - strs := make([]string, 0, len(val)) - for _, item := range val { - s, ok := item.(string) - if !ok { - return nil, fmt.Errorf("%w: %s array contains non-string value %T", ErrInvalidFlagValue, k, item) - } - strs = append(strs, s) - } - result[k] = strs - default: - return nil, fmt.Errorf("%w: %s has unsupported type %T", ErrInvalidFlagValue, k, v) - } - } - return result, nil -} - -// Merge combines two Flags maps with override taking precedence. -// Keys in override replace keys in base. -func Merge(base, override Flags) Flags { - if base == nil && override == nil { - return make(Flags) - } - if base == nil { - return copyFlags(override) - } - if override == nil { - return copyFlags(base) - } - - result := make(Flags, len(base)+len(override)) - - // Copy base - for k, v := range base { - result[k] = v - } - - // Override with higher precedence values - for k, v := range override { - result[k] = v - } - - return result -} - -// copyFlags creates a shallow copy of Flags. -func copyFlags(f Flags) Flags { - result := make(Flags, len(f)) - for k, v := range f { - result[k] = v - } - return result -} - -// ToArgs reconstructs Flags into CLI arguments. -// Output is sorted by key for deterministic ordering. -// -// Conversion rules: -// - string: "--key=value" -// - bool true: "--key" -// - bool false: (omitted) -// - []string: "--key=v1", "--key=v2", ... -func ToArgs(f Flags) []string { - if len(f) == 0 { - return nil - } - - // Sort keys for deterministic output - keys := make([]string, 0, len(f)) - for k := range f { - keys = append(keys, k) - } - sort.Strings(keys) - - var args []string - for _, k := range keys { - v := f[k] - switch val := v.(type) { - case string: - args = append(args, fmt.Sprintf("--%s=%s", k, val)) - case bool: - if val { - args = append(args, "--"+k) - } - // false: omit entirely - case []string: - for _, s := range val { - args = append(args, fmt.Sprintf("--%s=%s", k, s)) - } - } - } - return args -} diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go deleted file mode 100644 index d7dcbd7..0000000 --- a/internal/flags/flags_test.go +++ /dev/null @@ -1,279 +0,0 @@ -package flags - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFromConfig(t *testing.T) { - t.Run("nil input returns empty flags", func(t *testing.T) { - result, err := FromConfig(nil) - - require.NoError(t, err) - assert.Empty(t, result) - }) - - t.Run("string values", func(t *testing.T) { - input := map[string]any{ - "memory": "2g", - "systemd": "always", - } - - result, err := FromConfig(input) - - require.NoError(t, err) - assert.Equal(t, "2g", result["memory"]) - assert.Equal(t, "always", result["systemd"]) - }) - - t.Run("bool values", func(t *testing.T) { - input := map[string]any{ - "privileged": true, - "debug": false, - } - - result, err := FromConfig(input) - - require.NoError(t, err) - assert.Equal(t, true, result["privileged"]) - assert.Equal(t, false, result["debug"]) - }) - - t.Run("string slice values", func(t *testing.T) { - input := map[string]any{ - "volume": []string{"/path1:/cont1", "/path2:/cont2"}, - } - - result, err := FromConfig(input) - - require.NoError(t, err) - assert.Equal(t, []string{"/path1:/cont1", "/path2:/cont2"}, result["volume"]) - }) - - t.Run("any slice converted to string slice", func(t *testing.T) { - input := map[string]any{ - "volume": []any{"/path1:/cont1", "/path2:/cont2"}, - } - - result, err := FromConfig(input) - - require.NoError(t, err) - assert.Equal(t, []string{"/path1:/cont1", "/path2:/cont2"}, result["volume"]) - }) - - t.Run("mixed types", func(t *testing.T) { - input := map[string]any{ - "memory": "2g", - "privileged": true, - "volume": []string{"/a:/b"}, - } - - result, err := FromConfig(input) - - require.NoError(t, err) - assert.Equal(t, "2g", result["memory"]) - assert.Equal(t, true, result["privileged"]) - assert.Equal(t, []string{"/a:/b"}, result["volume"]) - }) - - t.Run("error on unsupported type", func(t *testing.T) { - input := map[string]any{ - "invalid": 123, // int not supported - } - - _, err := FromConfig(input) - - require.ErrorIs(t, err, ErrInvalidFlagValue) - assert.Contains(t, err.Error(), "invalid") - }) - - t.Run("error on non-string in array", func(t *testing.T) { - input := map[string]any{ - "volume": []any{"/path", 123}, // int in array - } - - _, err := FromConfig(input) - - require.ErrorIs(t, err, ErrInvalidFlagValue) - assert.Contains(t, err.Error(), "volume") - }) -} - -func TestMerge(t *testing.T) { - t.Run("nil inputs return empty flags", func(t *testing.T) { - result := Merge(nil, nil) - - assert.NotNil(t, result) - assert.Empty(t, result) - }) - - t.Run("nil base returns copy of override", func(t *testing.T) { - override := Flags{"memory": "2g"} - - result := Merge(nil, override) - - assert.Equal(t, "2g", result["memory"]) - // Verify it's a copy - override["memory"] = "4g" - assert.Equal(t, "2g", result["memory"]) - }) - - t.Run("nil override returns copy of base", func(t *testing.T) { - base := Flags{"memory": "2g"} - - result := Merge(base, nil) - - assert.Equal(t, "2g", result["memory"]) - // Verify it's a copy - base["memory"] = "4g" - assert.Equal(t, "2g", result["memory"]) - }) - - t.Run("override takes precedence for same key", func(t *testing.T) { - base := Flags{"memory": "1g", "cpus": "2"} - override := Flags{"memory": "4g"} - - result := Merge(base, override) - - assert.Equal(t, "4g", result["memory"]) - assert.Equal(t, "2", result["cpus"]) - }) - - t.Run("combines keys from both", func(t *testing.T) { - base := Flags{"memory": "2g"} - override := Flags{"cpus": "4"} - - result := Merge(base, override) - - assert.Equal(t, "2g", result["memory"]) - assert.Equal(t, "4", result["cpus"]) - }) - - t.Run("override bool replaces base string", func(t *testing.T) { - base := Flags{"flag": "value"} - override := Flags{"flag": true} - - result := Merge(base, override) - - assert.Equal(t, true, result["flag"]) - }) - - t.Run("override array replaces base string", func(t *testing.T) { - base := Flags{"volume": "/a:/b"} - override := Flags{"volume": []string{"/c:/d", "/e:/f"}} - - result := Merge(base, override) - - assert.Equal(t, []string{"/c:/d", "/e:/f"}, result["volume"]) - }) -} - -func TestToArgs(t *testing.T) { - t.Run("nil input returns nil", func(t *testing.T) { - result := ToArgs(nil) - - assert.Nil(t, result) - }) - - t.Run("empty input returns nil", func(t *testing.T) { - result := ToArgs(Flags{}) - - assert.Nil(t, result) - }) - - t.Run("string value", func(t *testing.T) { - result := ToArgs(Flags{"memory": "2g"}) - - assert.Equal(t, []string{"--memory=2g"}, result) - }) - - t.Run("bool true", func(t *testing.T) { - result := ToArgs(Flags{"privileged": true}) - - assert.Equal(t, []string{"--privileged"}, result) - }) - - t.Run("bool false omitted", func(t *testing.T) { - result := ToArgs(Flags{"debug": false}) - - assert.Empty(t, result) - }) - - t.Run("string array", func(t *testing.T) { - result := ToArgs(Flags{"volume": []string{"/a:/b", "/c:/d"}}) - - assert.Equal(t, []string{"--volume=/a:/b", "--volume=/c:/d"}, result) - }) - - t.Run("mixed types sorted by key", func(t *testing.T) { - result := ToArgs(Flags{ - "privileged": true, - "memory": "2g", - "cpus": "4", - }) - - // Should be sorted: cpus, memory, privileged - assert.Equal(t, []string{"--cpus=4", "--memory=2g", "--privileged"}, result) - }) - - t.Run("complex example", func(t *testing.T) { - result := ToArgs(Flags{ - "systemd": "always", - "privileged": true, - "debug": false, // omitted - "volume": []string{"/a:/b", "/c:/d"}, - }) - - // Sorted: privileged, systemd, volume (debug omitted) - assert.Equal(t, []string{ - "--privileged", - "--systemd=always", - "--volume=/a:/b", - "--volume=/c:/d", - }, result) - }) - - t.Run("value with equals sign", func(t *testing.T) { - result := ToArgs(Flags{"env": "FOO=bar"}) - - assert.Equal(t, []string{"--env=FOO=bar"}, result) - }) -} - -func TestRoundTrip(t *testing.T) { - t.Run("config to flags to args", func(t *testing.T) { - cfg := map[string]any{ - "memory": "2g", - "privileged": true, - "volume": []string{"/a:/b"}, - } - - flags, err := FromConfig(cfg) - require.NoError(t, err) - - args := ToArgs(flags) - - assert.Contains(t, args, "--memory=2g") - assert.Contains(t, args, "--privileged") - assert.Contains(t, args, "--volume=/a:/b") - }) - - t.Run("merge then to args", func(t *testing.T) { - baseFlags, err := FromConfig(map[string]any{"systemd": "always", "memory": "1g"}) - require.NoError(t, err) - configFlags, err := FromConfig(map[string]any{"memory": "4g", "privileged": true}) - require.NoError(t, err) - - merged := Merge(baseFlags, configFlags) - args := ToArgs(merged) - - // Config should win for memory - assert.Contains(t, args, "--memory=4g") - assert.NotContains(t, args, "--memory=1g") - // Both sources contribute - assert.Contains(t, args, "--systemd=always") - assert.Contains(t, args, "--privileged") - }) -} diff --git a/internal/instance/instance.go b/internal/instance/instance.go index d45dc00..853dd42 100644 --- a/internal/instance/instance.go +++ b/internal/instance/instance.go @@ -63,6 +63,7 @@ type CreateConfig struct { Image string // OCI image to use for container (vanilla mode) WorkspaceFolder string // Path to folder with devcontainer.json (devcontainer mode) Runtime container.Runtime // Optional runtime override (for devcontainer) + RuntimeFlags []string // Additional flags to pass to the container runtime } // AttachConfig configures instance attachment. diff --git a/internal/instance/manager.go b/internal/instance/manager.go index 8fcd5fa..f5c4c82 100644 --- a/internal/instance/manager.go +++ b/internal/instance/manager.go @@ -14,7 +14,6 @@ import ( "github.com/jmgilman/headjack/internal/catalog" "github.com/jmgilman/headjack/internal/container" "github.com/jmgilman/headjack/internal/exec" - "github.com/jmgilman/headjack/internal/flags" "github.com/jmgilman/headjack/internal/git" "github.com/jmgilman/headjack/internal/logging" "github.com/jmgilman/headjack/internal/multiplexer" @@ -73,7 +72,7 @@ type ManagerConfig struct { WorktreesDir string // Directory for storing worktrees (e.g., ~/.local/share/headjack/git) LogsDir string // Directory for storing logs (e.g., ~/.local/share/headjack/logs) RuntimeType RuntimeType // Container runtime type (docker or podman) - ConfigFlags flags.Flags // Flags from config file (take precedence over image labels) + ConfigFlags []string // Additional flags to pass to the container runtime Executor exec.Executor // Command executor (for devcontainer runtime creation) } @@ -88,11 +87,11 @@ type Manager struct { logPaths *logging.PathManager worktreesDir string runtimeType RuntimeType - configFlags flags.Flags + configFlags []string } // NewManager creates a new instance manager. -func NewManager(store catalogStore, runtime containerRuntime, opener gitOpener, mux sessionMultiplexer, cfg ManagerConfig) *Manager { +func NewManager(store catalogStore, runtime containerRuntime, opener gitOpener, mux sessionMultiplexer, cfg *ManagerConfig) *Manager { runtimeType := cfg.RuntimeType if runtimeType == "" { runtimeType = RuntimeDocker @@ -131,7 +130,7 @@ func (m *Manager) Executor() exec.Executor { } // Create creates a new instance for the given repository and branch. -func (m *Manager) Create(ctx context.Context, repoPath string, cfg CreateConfig) (*Instance, error) { +func (m *Manager) Create(ctx context.Context, repoPath string, cfg *CreateConfig) (*Instance, error) { // Open the repository repo, err := m.git.Open(ctx, repoPath) if err != nil { @@ -251,7 +250,11 @@ func (m *Manager) selectRuntime(override containerRuntime) containerRuntime { // buildRunConfig creates a container.RunConfig based on the creation mode. // For devcontainer mode (WorkspaceFolder set), it configures for devcontainer CLI. // For vanilla mode, it applies config flags. -func (m *Manager) buildRunConfig(cfg CreateConfig, containerName, worktreePath string) *container.RunConfig { +// CLI flags (from --) are appended after config flags for both modes. +func (m *Manager) buildRunConfig(cfg *CreateConfig, containerName, worktreePath string) *container.RunConfig { + // Merge config flags with CLI flags (config first, CLI flags appended) + flags := m.mergeFlags(cfg.RuntimeFlags) + // Devcontainer mode: minimal config, devcontainer CLI handles the rest if cfg.WorkspaceFolder != "" { return &container.RunConfig{ @@ -260,18 +263,32 @@ func (m *Manager) buildRunConfig(cfg CreateConfig, containerName, worktreePath s Mounts: []container.Mount{ {Source: worktreePath, Target: "/workspace", ReadOnly: false}, }, + Flags: flags, } } - // Vanilla mode: use config flags only + // Vanilla mode: use merged flags return &container.RunConfig{ Name: containerName, Image: cfg.Image, Mounts: []container.Mount{ {Source: worktreePath, Target: "/workspace", ReadOnly: false}, }, - Flags: flags.ToArgs(m.configFlags), + Flags: flags, + } +} + +// mergeFlags combines config flags with CLI flags. +// Config flags come first, CLI flags are appended (allowing override via runtime behavior). +func (m *Manager) mergeFlags(cliFlags []string) []string { + if len(m.configFlags) == 0 && len(cliFlags) == 0 { + return nil } + + result := make([]string, 0, len(m.configFlags)+len(cliFlags)) + result = append(result, m.configFlags...) + result = append(result, cliFlags...) + return result } // Get retrieves an instance by ID, including live container status. diff --git a/internal/instance/manager_test.go b/internal/instance/manager_test.go index 81133c9..2388b4a 100644 --- a/internal/instance/manager_test.go +++ b/internal/instance/manager_test.go @@ -28,20 +28,20 @@ const ( func TestNewManager(t *testing.T) { t.Run("sets worktrees directory", func(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) + mgr := NewManager(nil, nil, nil, nil, &ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) require.NotNil(t, mgr) assert.Equal(t, "/data/worktrees", mgr.worktreesDir) }) t.Run("defaults RuntimeType to Docker when not specified", func(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(nil, nil, nil, nil, &ManagerConfig{}) assert.Equal(t, RuntimeDocker, mgr.runtimeType) }) t.Run("respects explicit RuntimeType", func(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, ManagerConfig{RuntimeType: RuntimePodman}) + mgr := NewManager(nil, nil, nil, nil, &ManagerConfig{RuntimeType: RuntimePodman}) assert.Equal(t, RuntimePodman, mgr.runtimeType) }) @@ -85,9 +85,9 @@ func TestManager_Create(t *testing.T) { }, } - mgr := NewManager(store, runtime, opener, nil, ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) + mgr := NewManager(store, runtime, opener, nil, &ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) - inst, err := mgr.Create(ctx, "/path/to/repo", CreateConfig{ + inst, err := mgr.Create(ctx, "/path/to/repo", &CreateConfig{ Branch: "feature/auth", Image: "myimage:latest", }) @@ -124,9 +124,9 @@ func TestManager_Create(t *testing.T) { }, } - mgr := NewManager(store, nil, opener, nil, ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) + mgr := NewManager(store, nil, opener, nil, &ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) - _, err := mgr.Create(ctx, testRepoPath, CreateConfig{Branch: "main"}) + _, err := mgr.Create(ctx, testRepoPath, &CreateConfig{Branch: "main"}) assert.ErrorIs(t, err, ErrAlreadyExists) }) @@ -156,9 +156,9 @@ func TestManager_Create(t *testing.T) { }, } - mgr := NewManager(store, nil, opener, nil, ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) + mgr := NewManager(store, nil, opener, nil, &ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) - _, err := mgr.Create(ctx, testRepoPath, CreateConfig{Branch: "main"}) + _, err := mgr.Create(ctx, testRepoPath, &CreateConfig{Branch: "main"}) require.Error(t, err) assert.Contains(t, err.Error(), "worktree error") @@ -199,9 +199,9 @@ func TestManager_Create(t *testing.T) { }, } - mgr := NewManager(store, runtime, opener, nil, ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) + mgr := NewManager(store, runtime, opener, nil, &ManagerConfig{WorktreesDir: "/data/worktrees", LogsDir: "/data/logs"}) - _, err := mgr.Create(ctx, testRepoPath, CreateConfig{Branch: "main"}) + _, err := mgr.Create(ctx, testRepoPath, &CreateConfig{Branch: "main"}) require.Error(t, err) assert.Contains(t, err.Error(), "container error") @@ -236,7 +236,7 @@ func TestManager_Get(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, &ManagerConfig{}) inst, err := mgr.Get(ctx, "abc123") @@ -253,7 +253,7 @@ func TestManager_Get(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) _, err := mgr.Get(ctx, "nonexistent") @@ -293,7 +293,7 @@ func TestManager_GetByBranch(t *testing.T) { }, } - mgr := NewManager(store, runtime, opener, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, opener, nil, &ManagerConfig{}) inst, err := mgr.GetByBranch(ctx, "/path/to/repo", "main") @@ -317,7 +317,7 @@ func TestManager_GetByBranch(t *testing.T) { }, } - mgr := NewManager(store, nil, opener, nil, ManagerConfig{}) + mgr := NewManager(store, nil, opener, nil, &ManagerConfig{}) _, err := mgr.GetByBranch(ctx, "/path/to/repo", "nonexistent") @@ -346,7 +346,7 @@ func TestManager_List(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, &ManagerConfig{}) instances, err := mgr.List(ctx, ListFilter{}) @@ -369,7 +369,7 @@ func TestManager_List(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, &ManagerConfig{}) instances, err := mgr.List(ctx, ListFilter{Status: StatusRunning}) @@ -402,7 +402,7 @@ func TestManager_Stop(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, &ManagerConfig{}) err := mgr.Stop(ctx, "abc123") @@ -418,7 +418,7 @@ func TestManager_Stop(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) err := mgr.Stop(ctx, "nonexistent") @@ -462,7 +462,7 @@ func TestManager_Remove(t *testing.T) { }, } - mgr := NewManager(store, runtime, opener, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, opener, nil, &ManagerConfig{}) err := mgr.Remove(ctx, "abc123") @@ -480,7 +480,7 @@ func TestManager_Remove(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) err := mgr.Remove(ctx, "nonexistent") @@ -514,7 +514,7 @@ func TestManager_Attach(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, &ManagerConfig{}) err := mgr.Attach(ctx, "abc123", AttachConfig{ Command: []string{"bash", "-c", "echo hello"}, @@ -546,7 +546,7 @@ func TestManager_Attach(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, &ManagerConfig{}) err := mgr.Attach(ctx, "abc123", AttachConfig{}) @@ -571,7 +571,7 @@ func TestManager_Attach(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, &ManagerConfig{}) err := mgr.Attach(ctx, "abc123", AttachConfig{}) @@ -652,7 +652,7 @@ func TestManager_CreateSession(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, mux, ManagerConfig{LogsDir: logsDir}) + mgr := NewManager(store, runtime, nil, mux, &ManagerConfig{LogsDir: logsDir}) session, err := mgr.CreateSession(ctx, "abc12345", &CreateSessionConfig{}) @@ -698,7 +698,7 @@ func TestManager_CreateSession(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, mux, ManagerConfig{LogsDir: logsDir}) + mgr := NewManager(store, runtime, nil, mux, &ManagerConfig{LogsDir: logsDir}) session, err := mgr.CreateSession(ctx, "abc12345", &CreateSessionConfig{Name: "my-session"}) @@ -724,7 +724,7 @@ func TestManager_CreateSession(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, &ManagerConfig{}) _, err := mgr.CreateSession(ctx, "abc12345", &CreateSessionConfig{Name: "existing-session"}) @@ -747,7 +747,7 @@ func TestManager_CreateSession(t *testing.T) { }, } - mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) + mgr := NewManager(store, runtime, nil, nil, &ManagerConfig{}) _, err := mgr.CreateSession(ctx, "abc12345", &CreateSessionConfig{}) @@ -761,7 +761,7 @@ func TestManager_CreateSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) _, err := mgr.CreateSession(ctx, "nonexistent", &CreateSessionConfig{}) @@ -786,7 +786,7 @@ func TestManager_GetSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) session, err := mgr.GetSession(ctx, "abc12345", "second-session") @@ -806,7 +806,7 @@ func TestManager_GetSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) _, err := mgr.GetSession(ctx, "abc12345", "nonexistent") @@ -820,7 +820,7 @@ func TestManager_GetSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) _, err := mgr.GetSession(ctx, "nonexistent", "any") @@ -845,7 +845,7 @@ func TestManager_ListSessions(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) sessions, err := mgr.ListSessions(ctx, "abc12345") @@ -865,7 +865,7 @@ func TestManager_ListSessions(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) sessions, err := mgr.ListSessions(ctx, "abc12345") @@ -880,7 +880,7 @@ func TestManager_ListSessions(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) _, err := mgr.ListSessions(ctx, "nonexistent") @@ -919,7 +919,7 @@ func TestManager_KillSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, mux, ManagerConfig{LogsDir: logsDir}) + mgr := NewManager(store, nil, nil, mux, &ManagerConfig{LogsDir: logsDir}) err := mgr.KillSession(ctx, "abc12345", "my-session") @@ -951,7 +951,7 @@ func TestManager_KillSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, mux, ManagerConfig{}) + mgr := NewManager(store, nil, nil, mux, &ManagerConfig{}) err := mgr.KillSession(ctx, "abc12345", "my-session") @@ -968,7 +968,7 @@ func TestManager_KillSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) err := mgr.KillSession(ctx, "abc12345", "nonexistent") @@ -982,7 +982,7 @@ func TestManager_KillSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) err := mgr.KillSession(ctx, "nonexistent", "any") @@ -1022,7 +1022,7 @@ func TestManager_AttachSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, mux, ManagerConfig{}) + mgr := NewManager(store, nil, nil, mux, &ManagerConfig{}) err := mgr.AttachSession(ctx, "abc12345", "my-session") @@ -1041,7 +1041,7 @@ func TestManager_AttachSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) err := mgr.AttachSession(ctx, "abc12345", "nonexistent") @@ -1081,7 +1081,7 @@ func TestManager_AttachSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, mux, ManagerConfig{}) + mgr := NewManager(store, nil, nil, mux, &ManagerConfig{}) err := mgr.AttachSession(ctx, "abc12345", "my-session") @@ -1111,7 +1111,7 @@ func TestManager_GetMRUSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) session, err := mgr.GetMRUSession(ctx, "abc12345") @@ -1129,7 +1129,7 @@ func TestManager_GetMRUSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) _, err := mgr.GetMRUSession(ctx, "abc12345") @@ -1143,7 +1143,7 @@ func TestManager_GetMRUSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) _, err := mgr.GetMRUSession(ctx, "nonexistent") @@ -1183,7 +1183,7 @@ func TestManager_GetGlobalMRUSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) result, err := mgr.GetGlobalMRUSession(ctx) @@ -1202,7 +1202,7 @@ func TestManager_GetGlobalMRUSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) _, err := mgr.GetGlobalMRUSession(ctx) @@ -1216,7 +1216,7 @@ func TestManager_GetGlobalMRUSession(t *testing.T) { }, } - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) + mgr := NewManager(store, nil, nil, nil, &ManagerConfig{}) _, err := mgr.GetGlobalMRUSession(ctx) diff --git a/internal/multiplexer/tmux.go b/internal/multiplexer/tmux.go index 3f6aaf6..ca3f5a2 100644 --- a/internal/multiplexer/tmux.go +++ b/internal/multiplexer/tmux.go @@ -9,6 +9,7 @@ import ( "os/signal" "strings" "syscall" + "time" "golang.org/x/term" @@ -128,7 +129,14 @@ func (t *tmux) AttachSession(ctx context.Context, sessionName string) error { if err != nil { return fmt.Errorf("set terminal raw mode: %w", err) } - defer func() { _ = term.Restore(stdinFd, oldState) }() + defer func() { + // Drain stdin with timeout BEFORE restoring terminal mode. + // This catches in-flight terminal responses (escape sequences) that + // arrive asynchronously after tmux exits for short-lived sessions. + // We do this while still in raw mode so responses are consumed properly. + drainStdinWithTimeout(stdinFd, 100*time.Millisecond) + _ = term.Restore(stdinFd, oldState) + }() // Handle window resize signals sigCh := make(chan os.Signal, 1) @@ -222,3 +230,28 @@ func shellEscape(s string) string { escaped := strings.ReplaceAll(s, "'", `'\''`) return "'" + escaped + "'" } + +// drainStdinWithTimeout reads and discards input from stdin for the specified duration. +// This is used to consume stray terminal escape sequence responses that arrive +// asynchronously after tmux exits. The timeout allows in-flight responses to arrive. +func drainStdinWithTimeout(fd int, timeout time.Duration) { + // Set stdin to non-blocking temporarily + if err := syscall.SetNonblock(fd, true); err != nil { + return + } + //nolint:errcheck // best-effort cleanup + defer func() { _ = syscall.SetNonblock(fd, false) }() + + deadline := time.Now().Add(timeout) + buf := make([]byte, 1024) + + for time.Now().Before(deadline) { + //nolint:errcheck // best-effort drain, errors expected when no data + n, _ := syscall.Read(fd, buf) + if n <= 0 { + // No data available, wait briefly and try again + time.Sleep(10 * time.Millisecond) + } + // If we read data, continue immediately to drain more + } +}