fix(passthrough): deliver prompt to custom TUI agents via stdin (#2335)
This commit is contained in:
@@ -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
|
||||
|
||||
+30
-2
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user