fix(passthrough): deliver prompt to custom TUI agents via stdin (#2335)

This commit is contained in:
Nicolas Lenepveu
2026-08-07 16:24:58 +02:00
committed by GitHub
parent be01f7a73a
commit a35dd6d22e
6 changed files with 92 additions and 37 deletions
+4 -2
View File
@@ -336,8 +336,10 @@ type PassthroughConfig struct {
// without touching the user's global config. Nil means no MCP injection.
MCPStrategy mcpconfig.PassthroughMCPStrategy
WaitForTerminal bool
// AutoInjectPrompt enables writing the task description to the PTY stdin
// after the first idle window. Default false preserves today's behavior.
// AutoInjectPrompt is retained in discovered passthrough metadata for
// compatibility. Initial prompt delivery is selected by PromptFlag: when it
// is empty, the lifecycle manager writes the task description to PTY stdin.
// The value no longer gates that delivery.
AutoInjectPrompt bool
// SubmitSequence is appended after the prompt text when auto-injecting
// and when routing chat-compose messages to the PTY. "\r" for most TUIs.
@@ -218,13 +218,23 @@ func (m *Manager) resolvePassthroughAgent(ctx context.Context, execution *AgentE
}
// promptForPassthroughCommand returns the prompt that should be passed to
// BuildPassthroughCommand. When the agent uses idle-based auto-inject and has
// no PromptFlag, the prompt would otherwise be appended as a positional arg
// (putting TUIs like Claude into non-interactive `-p` mode and exiting before
// auto-inject fires). In that case we return "" so the prompt is delivered via
// PTY stdin in autoInjectInitialPrompt.
// BuildPassthroughCommand.
//
// When the agent has a PromptFlag, the prompt rides that flag and is returned
// here. When it does not, the prompt is suppressed: BuildPassthroughCommand
// would otherwise append it as a positional argument, which interactive TUIs
// (claude, codex, fuelclaude, …) ignore or misinterpret — e.g. `zsh -ic
// "fuelclaude --model opus" "<prompt>"` drops the prompt as an unreferenced
// positional and the agent starts at an empty prompt. In the no-flag case the
// prompt is delivered instead via PTY stdin in autoInjectInitialPrompt.
//
// AutoInjectPrompt alone no longer gates suppression: a custom TUI agent with
// neither a PromptFlag nor AutoInjectPrompt (the default for agents built from
// a tui_config) would otherwise have no delivery path at all, so suppression is
// keyed on the absence of a PromptFlag, which is the actual "can't carry the
// prompt on the CLI" condition.
func promptForPassthroughCommand(pt agents.PassthroughConfig, taskDescription string) string {
if pt.AutoInjectPrompt && pt.PromptFlag.IsEmpty() {
if pt.PromptFlag.IsEmpty() {
return ""
}
return taskDescription
@@ -1415,10 +1425,10 @@ type passthroughRunner interface {
WriteStdin(processID string, data string) error
}
// autoInjectInitialPrompt writes the task description to the PTY stdin once
// the agent is idle (ready for input). Opt-in per agent via PassthroughConfig.
// Called from startPassthroughSession and attemptResumeFallback only — never
// from ResumePassthroughSession (would duplicate the prompt in agent history).
// autoInjectInitialPrompt writes the task description to PTY stdin once a
// passthrough agent without a PromptFlag is idle (ready for input). Called from
// startPassthroughSession and attemptResumeFallback only — never from
// ResumePassthroughSession (would duplicate the prompt in agent history).
func (m *Manager) autoInjectInitialPrompt(execution *AgentExecution, pt agents.PassthroughConfig) {
runner := m.GetInteractiveRunner()
if runner == nil {
@@ -1430,15 +1440,19 @@ func (m *Manager) autoInjectInitialPrompt(execution *AgentExecution, pt agents.P
// autoInjectInitialPromptWith is the testable inner of autoInjectInitialPrompt,
// taking a runner seam so unit tests can avoid spawning a real PTY.
func (m *Manager) autoInjectInitialPromptWith(runner passthroughRunner, execution *AgentExecution, pt agents.PassthroughConfig) {
if !pt.AutoInjectPrompt {
return
}
if !pt.PromptFlag.IsEmpty() {
// The agent already received the prompt as a CLI flag.
// The agent already received the prompt as a CLI flag — no stdin
// delivery. This mirrors promptForPassthroughCommand, which only
// passes the prompt to BuildPassthroughCommand when a PromptFlag
// exists; the two no-flag cases (built-in AutoInjectPrompt agents
// and custom TUI agents) both land here for stdin delivery.
return
}
description := getTaskDescriptionFromMetadata(execution)
if description == "" {
// Nothing to inject (e.g. a non-LLM TUI tool with no task
// description). Generic passthrough tools launched without a task
// take this exit and never receive spurious stdin.
return
}
processID := execution.PassthroughProcessID
@@ -70,17 +70,45 @@ func newAutoInjectExecution(description string) *AgentExecution {
return exec
}
// TestAutoInject_disabled_does_nothing covers the only remaining no-write
// case under the new gating: a generic passthrough tool with no PromptFlag and
// no task description must not receive spurious stdin. (A non-LLM TUI like k9s
// launched without a task lands here.) AutoInjectPrompt is irrelevant now —
// delivery is keyed on the absence of a PromptFlag, and a description is what
// makes stdin delivery fire.
func TestAutoInject_disabled_does_nothing(t *testing.T) {
mgr := newTestManager(t)
runner := &fakePassthroughRunner{}
mgr.autoInjectInitialPromptWith(runner, newAutoInjectExecution(""), agents.PassthroughConfig{
AutoInjectPrompt: false,
SubmitSequence: "\r",
})
if runner.writeCalled {
t.Fatalf("expected no stdin write when task description is empty, got data=%q", runner.writtenData)
}
}
// TestAutoInject_writes_without_auto_inject_flag is the custom-TUI regression:
// an agent with neither AutoInjectPrompt nor a PromptFlag (the default for
// agents built from a tui_config, e.g. fuelclaude-opus) must still receive its
// task description via PTY stdin. Before the fix the prompt was appended as a
// positional arg that zsh -ic dropped, so the agent started at an empty prompt.
func TestAutoInject_writes_without_auto_inject_flag(t *testing.T) {
mgr := newTestManager(t)
runner := &fakePassthroughRunner{}
mgr.autoInjectInitialPromptWith(runner, newAutoInjectExecution("do a thing"), agents.PassthroughConfig{
AutoInjectPrompt: false,
SubmitSequence: "\r",
})
if runner.writeCalled {
t.Fatalf("expected no stdin write when AutoInjectPrompt is false, got data=%q", runner.writtenData)
if !runner.writeCalled {
t.Fatalf("expected WriteStdin to be called for a no-flag custom TUI agent")
}
if runner.writtenData != "do a thing\r" {
t.Errorf("WriteStdin data = %q, want %q", runner.writtenData, "do a thing\r")
}
}
@@ -7,10 +7,13 @@ import (
)
// TestPromptForPassthroughCommand asserts the gating used by passthroughAgentCommand:
// AutoInjectPrompt + empty PromptFlag suppresses the positional prompt arg, so the
// description is delivered exclusively via PTY stdin in autoInjectInitialPrompt.
// Without this guard, BuildPassthroughCommand would append the description as a
// positional arg and put Claude (and similar TUIs) into non-interactive `-p` mode.
// a prompt is passed to BuildPassthroughCommand only when the agent declares a
// PromptFlag. Without one, the prompt is suppressed (returned as "") so it is
// delivered exclusively via PTY stdin in autoInjectInitialPrompt — appending it
// as a positional arg instead drops it for interactive TUIs (e.g. zsh -ic
// "fuelclaude --model opus" "<prompt>" ignores the positional), which is the
// bug custom TUI agents hit. AutoInjectPrompt no longer gates suppression: the
// no-PromptFlag condition is what actually determines CLI-vs-stdin delivery.
func TestPromptForPassthroughCommand(t *testing.T) {
tests := []struct {
name string
@@ -19,22 +22,30 @@ func TestPromptForPassthroughCommand(t *testing.T) {
want string
}{
{
name: "auto-inject suppresses prompt arg",
name: "no prompt flag suppresses prompt arg (auto-inject agent)",
pt: agents.PassthroughConfig{AutoInjectPrompt: true},
desc: "refactor cron handler",
want: "",
},
{
name: "auto-inject off keeps prompt",
name: "no prompt flag suppresses prompt arg (custom TUI agent)",
pt: agents.PassthroughConfig{AutoInjectPrompt: false},
desc: "refactor cron handler",
want: "",
},
{
name: "explicit PromptFlag keeps prompt — flag delivery wins",
pt: agents.PassthroughConfig{
AutoInjectPrompt: true,
PromptFlag: agents.NewParam("--prompt", "{prompt}"),
},
desc: "refactor cron handler",
want: "refactor cron handler",
},
{
name: "auto-inject with explicit PromptFlag keeps prompt — flag delivery wins",
name: "explicit PromptFlag without auto-inject keeps prompt",
pt: agents.PassthroughConfig{
AutoInjectPrompt: true,
PromptFlag: agents.NewParam("--prompt", "{prompt}"),
PromptFlag: agents.NewParam("--prompt", "{prompt}"),
},
desc: "refactor cron handler",
want: "refactor cron handler",
@@ -66,8 +66,8 @@ type interactiveProcess struct {
// firstIdleCh is closed the first time the idle detector fires for this
// process — i.e. the CLI has finished its startup output and is ready for
// input. Used by the lifecycle manager to auto-inject the task description
// at the right moment when AutoInjectPrompt is enabled.
// input. Used by the lifecycle manager to inject initial task descriptions
// for passthrough agents without a PromptFlag.
firstIdleOnce sync.Once
firstIdleCh chan struct{}
}
+8 -8
View File
@@ -31,12 +31,12 @@ The task-create dialog's prompt textarea is **enabled** when a CLI/passthrough-c
When a passthrough session starts for a task that has a description:
1. Kandev launches the PTY as today.
2. After the existing **idle detector** in `InteractiveRunner` fires for the first time (i.e. the CLI has stopped emitting output and is presumed to be at its input prompt), kandev writes the task description plus a configurable **submit sequence** to the PTY's stdin.
3. Auto-injection is opt-in per agent via a new `PassthroughConfig.AutoInjectPrompt` flag (default false). Agents that already use `PromptFlag` (headless one-shot mode) are unaffected — their prompt is already on the CLI before launch.
2. After the existing **idle detector** in `InteractiveRunner` fires for the first time (i.e. the CLI has stopped emitting output and is presumed to be at its input prompt), kandev writes the task description plus a configurable **submit sequence** to the PTY's stdin when the agent has no `PromptFlag`.
3. Agents that use `PromptFlag` (headless one-shot mode) are unaffected — their prompt is already on the CLI before launch. `PassthroughConfig.AutoInjectPrompt` remains in discovered metadata for compatibility, but the presence of `PromptFlag` is the runtime delivery boundary.
No per-agent pattern matchers. The existing idle window is the only readiness signal. If an agent's CLI is unusual enough that an idle window misfires (writes a banner, then waits 5 seconds, then prompts), we make the idle window per-agent-configurable in `PassthroughConfig` (already exists as `IdleTimeout`). No new detection machinery.
For the Claude case: `claude_acp.go` sets `AutoInjectPrompt: true`, `DisableBracketedPaste: true` (Claude Code already enables bracketed-paste *mode* in its Ink TUI — injecting `ESC[200~`…`ESC[201~` delimiters breaks the prompt), and `SubmitViaBackslashEnter: true` (PTY writes: prompt, then `\`, then `\r` per [Claude terminal docs](https://code.claude.com/docs/en/terminal-config)). Ink may still treat programmatic Enter as newline only ([anthropics/claude-code#15553](https://github.com/anthropics/claude-code/issues/15553)) — if auto-submit fails, the user confirms with Enter. Other passthrough-capable agents use bracketed-paste delimiters for multi-line prompts and stay default-off for auto-inject unless configured.
For the Claude case: `claude_acp.go` retains `AutoInjectPrompt: true` as compatibility metadata, sets `DisableBracketedPaste: true` (Claude Code already enables bracketed-paste *mode* in its Ink TUI — injecting `ESC[200~`…`ESC[201~` delimiters breaks the prompt), and `SubmitViaBackslashEnter: true` (PTY writes: prompt, then `\`, then `\r` per [Claude terminal docs](https://code.claude.com/docs/en/terminal-config)). Ink may still treat programmatic Enter as newline only ([anthropics/claude-code#15553](https://github.com/anthropics/claude-code/issues/15553)) — if auto-submit fails, the user confirms with Enter. Other passthrough-capable agents without a `PromptFlag` use the same idle-based stdin route.
### Follow-up prompts via PTY stdin
@@ -64,7 +64,7 @@ Users can still press Ctrl-C directly inside the xterm terminal. A dedicated too
- THEN the prompt textarea accepts input (today it is disabled / hidden)
### Prompt injection on fresh start
- GIVEN a CLI-mode task whose agent has `AutoInjectPrompt: true` and a non-empty description
- GIVEN a CLI-mode task whose agent has no `PromptFlag` and a non-empty description
- WHEN the task starts
- THEN the PTY launches, the idle detector fires once after the CLI settles, and the task description is written to stdin followed by `SubmitSequence`
- AND the description appears in the terminal output
@@ -96,10 +96,10 @@ Users can still press Ctrl-C directly inside the xterm terminal. A dedicated too
- THEN `\x03` is written to the PTY
- AND DB reconciliation still completes so the session unsticks
### Agent without AutoInjectPrompt
- GIVEN a passthrough-capable agent with `AutoInjectPrompt: false`
### Passthrough agent without PromptFlag
- GIVEN a passthrough-capable agent with no `PromptFlag`, regardless of its compatibility metadata
- WHEN a task starts
- THEN no stdin write happens (today's behavior preserved); the user pastes their prompt manually
- THEN the task description is written to stdin after the first idle window
## Kandev toolbar above the passthrough terminal
@@ -167,7 +167,7 @@ Pressing Ctrl-C inside the passthrough terminal writes `\x03` to PTY stdin. A fu
## Follow-ups
- **Stop button overlay in `PassthroughTerminal`.** Small affordance that calls the existing cancel route. Today users press Ctrl-C inside xterm directly.
- **AutoInjectPrompt on other passthrough agents** (Codex CLI, OpenCode TUI, etc.) — only Claude is opted in for v1. Adding others requires verifying their submit sequence and idle behavior.
- **Agent-specific prompt-injection tuning** — passthrough agents without a `PromptFlag` use the idle-based stdin route; adding agent-specific submit behavior requires verifying its submit sequence and idle behavior.
## Open questions