# RFD 019: Non-Interactive Mode

- **Status**: Abandoned
- **Category**: Design
- **Authors**: Jean Mertz <git@jeanmertz.com>
- **Date**: 2026-03-01

> [!IMPORTANT]
> This RFD is **Abandoned**.
> 
> Split into two focused RFDs:
> 
> - [RFD 048: Four-Channel Output Model][RFD 048] — stdout/stderr/tty/log
>   separation and Printer integration.
> - [RFD 049: Non-Interactive Mode and Detached Prompt Policy][RFD 049] —
>   `--non-interactive` flag, detached policies, `exclusive` property, prompt
>   routing.
> 
> The original text below is preserved for historical context.

## Summary

This RFD introduces a configurable detached prompt policy that controls what
happens when an inquiry arrives and no interactive client is available, a
`--non-interactive` CLI flag, the `exclusive` question property, and a
four-channel output model (stdout for assistant output, stderr for chrome,
`/dev/tty` for interactive prompts, and a log file for tracing).

## Motivation

JP handles non-TTY situations today, but the behavior is implicit and not
configurable:

- Permission prompts (`RunMode::Ask|Edit`) are auto-approved when `!is_tty`.
- User-targeted tool questions are rerouted to the LLM inquiry backend.
- Result delivery prompts are auto-delivered.

This works for simple cases, but users cannot:

- Explicitly opt into non-interactive mode from a terminal (e.g., scripting).
- Choose different fallback strategies for different prompt types.
- Mark certain questions as human-only to prevent LLM auto-answering.
- Pipe JP's output cleanly (`echo "fix it" | jp query | jq` gets polluted with
  progress indicators and tool call headers).

## Design

### Detached Prompt Policy

When an inquiry (see [RFD 018]) arrives and no interactive client is attached,
JP applies the **detached prompt policy**.
The policy is configurable per inquiry kind.

Three policy modes:

| Mode       | Behavior                                                 |
| ---------- | -------------------------------------------------------- |
| `auto`     | Auto-approve (`RunTool`/`DeliverToolResult`) or route to |
|            | LLM inquiry (`ToolQuestion`). Fails if `exclusive=true`. |
| `defaults` | Use the question's `default` value. Fail if none.        |
| `deny`     | Fail the tool call with a descriptive error.             |

Default: **`deny`**.
Nothing runs unattended unless the user explicitly opts in.
This is the safe default — a non-interactive run that hits a prompt it cannot
resolve fails with a clear error message, rather than silently auto-approving.

### The `exclusive` Property

Some tool questions cannot be meaningfully answered by the LLM:

- "Enter your SSH passphrase"
- "Confirm deletion of production database"
- "Choose which local git identity to use"

The `exclusive` property marks a question as human-only.
When the detached policy is `auto`, exclusive questions fail instead of being
routed to the LLM.

At the type level, `RunTool` and `DeliverToolResult` inquiries are inherently
exclusive — this is encoded in the `Inquiry::exclusive()` method (see [RFD
018]).
`ToolQuestion` inquiries are non-exclusive by default.

Tool authors set the default:

```rust
Question {
    id: "confirm_force_push".into(),
    text: "Force push to remote?".into(),
    answer_type: AnswerType::Boolean,
    default: None,
    exclusive: true,
}
```

Users override per-question in config:

```toml
[conversation.tools.git.questions.confirm_force_push]
target = "user"
exclusive = true
```

The user has final say — they can set `exclusive = false` even for questions
the tool author marked as exclusive.

### Prompt Routing

The `route_prompt` function from [RFD 018] is extended with the detached policy:

```rust
fn route_prompt(
    inquiry: &Inquiry,
    has_client: bool,
    policy: DetachedMode,
    config_exclusive: Option<bool>,
) -> PromptAction {
    if has_client {
        return PromptAction::PromptClient;
    }

    let exclusive = config_exclusive.unwrap_or_else(|| inquiry.exclusive());

    match policy {
        DetachedMode::Auto if exclusive => PromptAction::Fail,
        DetachedMode::Auto => match inquiry {
            Inquiry::RunTool { .. } => PromptAction::AutoApprove,
            Inquiry::DeliverToolResult { .. } => PromptAction::AutoDeliver,
            Inquiry::ToolQuestion { .. } => PromptAction::LlmInquiry,
        },
        DetachedMode::Defaults => PromptAction::UseDefault,
        DetachedMode::Deny => PromptAction::Fail,
    }
}
```

The scattered `is_tty` checks in the coordinator collapse into calls to this
function.
Each call site provides the inquiry and its `exclusive` override from config (if
any).

#### Standard Input

`stdin` is exclusively for query content and context injection (e.g., `cat
file.rs | jp query "fix this"`).
It is **never** used for answering prompts.
Prompt input always comes from `/dev/tty` (interactive mode) or the detached
policy (non-interactive mode).

This means `echo "y" | jp query "do the thing"` does not answer a tool
permission prompt with "y."
The "y" is treated as query content.
If the query hits a prompt and `/dev/tty` is available, the user is prompted on
the terminal.
If `/dev/tty` is unavailable, the detached policy applies.

#### Determining `has_client`

`has_client` is `true` when an interactive user can answer prompts.
This is determined by:

1. If `--non-interactive` is passed, `has_client` is `false`.
2. Otherwise, JP attempts to open `/dev/tty`.
   If it succeeds, `has_client` is `true`.
3. If `/dev/tty` cannot be opened (no controlling terminal — cron, systemd, SSH
   without `-t`, daemonized processes), `has_client` is `false`.

This is independent of whether stdout is a TTY.
A piped command like `jp query | less` has stdout connected to a pipe, but
`/dev/tty` is still available because the user is at a terminal.
The user can answer prompts.
Conversely, `echo foo | jp query` with stdout as a TTY might appear interactive,
but if `/dev/tty` is unavailable, it is not.

The current implementation uses `stdout.is_terminal()` as the heuristic.
This RFD replaces it with `/dev/tty` availability, which correctly handles piped
scenarios.

### Configuration

#### Scalar Shorthand

One policy for all inquiry kinds:

```toml
[conversation.tools.defaults]
detached = "deny"
```

This sets `run`, `deliver`, and `tool` to `"deny"`.

#### Per Inquiry Kind

```toml
[conversation.tools.defaults.detached]
run = "auto"
deliver = "auto"
tool = "deny"
```

The scalar-or-struct pattern follows the existing convention in JP (e.g.,
`command` accepts a string or `{ program, args, shell }`).
`detached = "auto"` is shorthand for `detached = { run = "auto", deliver =
"auto", tool = "auto" }`.

#### Per Tool

```toml
[conversation.tools.fs_modify_file]
detached = "deny"

[conversation.tools.cargo_check.detached]
run = "auto"
tool = "auto"
```

#### Resolution Order

```
1. tools.<name>.detached.<kind>     (per-tool, per-kind)
2. tools.<name>.detached            (per-tool scalar)
3. tools.defaults.detached.<kind>   (global per-kind)
4. tools.defaults.detached          (global scalar)
5. "deny"                           (hardcoded safe default)
```

First match wins.
This follows the same merge pattern `ToolConfigWithDefaults` uses for
`run`/`result`.

### Relationship to Existing Config

Existing config fields remain the **attached** (interactive) policies.
The new `detached` config only covers the detached case:

| Inquiry kind        | Attached policy          | Detached policy               |
| ------------------- | ------------------------ | ----------------------------- |
| `RunTool`           | `run` (ask/unattended/…) | `detached.run`                |
| `DeliverToolResult` | `result` (unattended/…)  | `detached.deliver`            |
| `ToolQuestion`      | `questions.<id>.target`  | `detached.tool` + `exclusive` |

Zero breaking changes to existing configs.

### CLI Flag

```
jp query --non-interactive "Fix the bug"
```

`--non-interactive` forces detached prompt routing even when a TTY is present.
Useful for scripting in a terminal where you don't want prompts to block.

TTY detection remains the default heuristic: when no TTY is detected, JP behaves
as if `--non-interactive` was passed.

### Output Channel Separation

JP uses four output channels, each with a single purpose:

| Channel        | Purpose                                |
| -------------- | -------------------------------------- |
| **stdout**     | Assistant responses, structured data   |
| **stderr**     | Chrome: progress, tool headers, status |
| **`/dev/tty`** | Interactive prompts (see below)        |
| **Log file**   | Tracing logs (`-v`)                    |

**stdout** always contains only assistant output.
This makes `jp query | jq`, `jp query > answer.txt`, and `jp query | less` work
without special-casing based on whether stdout is a TTY.
When `--format json` is used, stdout contains the structured JSON response.

**stderr** always contains chrome: progress indicators, tool call headers,
status messages.
In a normal terminal session, stdout and stderr both display on the same screen,
so the user sees the same interleaved experience as today.
The separation only matters when redirecting.

**Tracing logs** (`-v` through `-vvvvv`) are written to a log file, not to
stderr.
This prevents tracing output from mixing with chrome when a user redirects
stderr (e.g., `jp query 2> chrome.log` captures chrome only, not tracing data).
The log file location is configurable via `--log-file` or `JP_LOG_FILE`,
defaulting to `~/.local/share/jp/logs/`.
The `--log-format` flag controls the format of the log file (text or JSON).
`--log-file=-` writes tracing to stderr, for users who want logs and chrome on
the same stream.

This replaces the current behavior where tracing is written to stderr and all
other output goes to stdout.

**Output formatting** is controlled by `--format` and applies to both stdout and
stderr.
When `--format json` is set, assistant output on stdout and chrome on stderr are
both rendered as NDJSON.
When `--format auto` resolves to `text-pretty` (stdout is a terminal), both
channels use ANSI-formatted text.

Note that `stdout.is_terminal()` still controls output format resolution
(`--format auto` → `text-pretty` for terminals, `text` otherwise).
This is independent of `/dev/tty` availability, which controls interactivity
(`has_client`).
The two checks serve different purposes:

- `stdout.is_terminal()` → can the consumer handle ANSI escape codes?
- `/dev/tty` available → can a user answer prompts?

### Prompt I/O Channel

Inquiry prompts (tool permissions, tool questions, result delivery
confirmations) use `/dev/tty` for both rendering and input when available.
This is the fourth output channel, independent of stdout, stderr, and the log
file.

`/dev/tty` is the controlling terminal device.
It bypasses all redirections — even `jp query > out.txt 2> err.txt` still
renders prompts on the terminal.
This is the same pattern used by git (password prompts), fzf (interactive UI),
and sudo (password entry).

This means `jp query | less` works correctly: assistant output goes to `less`
via stdout, chrome goes to stderr, and tool prompts appear on the terminal via
`/dev/tty`.
The channels do not interfere.

When `/dev/tty` is not available, prompts cannot be rendered and `has_client` is
`false`, so the detached policy applies.

### Integration with `Printer`

All output channels except tracing are managed through the `Printer` type.
This preserves the single-point-of-output invariant for testing and mocking.

`Printer` gains a `Tty` output target alongside the existing `Out` and `Err`:

```rust
pub enum PrintTarget {
    Out,    // stdout — assistant output, structured data
    Err,    // stderr — chrome, progress, status
    Tty,    // /dev/tty — interactive prompts
}
```

The `Tty` target:

- Always renders with ANSI (it is a terminal by definition).
- Ignores `--format` (prompts are not data output).
- Is opened lazily (first call to `tty_writer()`).
  Most commands never prompt, so `/dev/tty` is not opened unless needed.
- Returns an error if `/dev/tty` is unavailable, which feeds into `has_client =
  false`.

The `PromptBackend` trait already accepts a `writer` parameter.
The change is to wire `TerminalPromptBackend` to `printer.tty_writer()` instead
of `printer.out_writer()`.
Input reading via `inquire` similarly uses `/dev/tty` instead of stdin.

In tests, the mock `Printer` provides an in-memory buffer for the `Tty` target,
allowing prompt rendering to be asserted independently of stdout/stderr output.

Tracing logs are handled separately via the `tracing` subscriber, configured to
write to the log file.
They do not flow through `Printer`.

### Platform Portability

The `/dev/tty` and `flock` APIs are Unix-specific, but have Windows equivalents:

| Unix          | Windows                  | Rust crate               |
| ------------- | ------------------------ | ------------------------ |
| `/dev/tty`    | `CONIN$` / `CONOUT$`     | `crossterm`, `termwiz`   |
| `ttyname(fd)` | Console handle detection | `$JP_SESSION` as primary |

The `Printer` and `PromptBackend` abstractions hide the platform-specific
details.
The `/dev/tty` path is an implementation detail of `tty_writer()` on Unix; on
Windows, the same method opens `CONIN$`/`CONOUT$`.

## Drawbacks

**Config surface.** The detached policy adds a new config dimension with a
scalar-or-struct pattern and four-level resolution cascade.
This is powerful but adds documentation and mental overhead.

**Breaking change in non-TTY behavior.** The current implicit behavior
(auto-approve permissions, reroute questions to LLM) is replaced by `deny` as
the default.
Users who rely on the current piped behavior need to add `detached = "auto"` to
their config.
This is intentional — the current behavior is unsafe as a default — but it is
a breaking change.

**Output separation changes rendering.** Chrome (progress, tool headers) goes to
stderr, assistant output goes to stdout.
In a terminal, both streams interleave on the same screen.
When redirecting, the streams separate.
Users who redirect stdout to a file only see the assistant's final answer, not
the interleaved rendering they see in the terminal.

## Alternatives

### Single detached policy for all inquiry kinds

A single `detached = "auto"` covering permissions, result delivery, and tool
questions.
Rejected because these are fundamentally different: auto-approving a permission
prompt (the LLM already decided to call the tool) has different risk
characteristics than auto-answering a tool question (the LLM might not have
enough context).
Users need independent control.

### `exclusive` as a third `QuestionTarget` variant

Add `QuestionTarget::UserOnly` instead of a boolean flag.
Rejected because exclusivity is orthogonal to target — it describes whether the
target can be overridden when unavailable, not who the target is.
At the type level, `RunTool` and `DeliverToolResult` are inherently exclusive
via the `Inquiry::exclusive()` method.
A separate `QuestionTarget` variant would not express this.

### `auto` as the default detached policy

Default to `auto` to preserve current non-TTY behavior.
Rejected because the current behavior silently auto-approves tool execution
without user consent.
`deny` is the safe default — users opt into automation explicitly.

### Environment variable instead of CLI flag

Use `JP_FRONTEND=noninteractive` (like `DEBIAN_FRONTEND`).
This could be offered as an alias, but a CLI flag is more discoverable and
consistent with JP's existing flag conventions.
Both could coexist.

## Non-Goals

- **Background execution and prompt queuing.** Running conversations as detached
  background processes, the `queue` detached policy, and attach IPC are future
  work that builds on the detached policy infrastructure established here.
  
  > [!TIP]
  > [RFD 027] introduces a client-server query architecture that makes detached
  > background execution and live re-attachment the default execution model,
  > building directly on the detached policies defined here.
  > The `queue` policy is renamed `defer`.

- **New inquiry variants.** This RFD uses the `Inquiry` enum from [RFD 018]
  as-is.

## Risks and Open Questions

### Interaction with the stateful tool protocol

[RFD 009] introduces stateful tools with `spawn`/`fetch`/`apply` actions.
The per-action permission model (prompt on `spawn`, auto-run `fetch`/`apply`)
maps to the detached policy, but the details need alignment during
implementation.

### Config cascade complexity

The four-level resolution is powerful but may be hard to debug.
A `jp config show --effective <tool>` command that displays the resolved
detached policy per inquiry kind would help.

### `exclusive` override direction

Users can override `exclusive = true` (set by tool authors) to `false`.
This is intentional — the user has final say — but could lead to unsafe
behavior for questions that genuinely require human judgment.
Documentation should make the implications clear.

### `result` vs `deliver` naming

The `DeliverToolResult` inquiry's config key is `deliver`, but the existing
attached config field is `result`.
Options: (a) use `deliver` everywhere and alias `result` via serde, (b) keep
`result` as the config key to match the existing field.
To be resolved during implementation.

## Implementation Plan

### Phase 1: Detached Policy Config

Add the `detached` config field (scalar-or-struct) to `ToolsDefaultsConfig` and
`ToolConfig`.
Implement the `DetachedMode` enum (`auto`, `defaults`, `deny`).
Implement the config resolution cascade.

Add `exclusive` field to `Question` in `jp_tool` and `QuestionConfig` in
`jp_config`.

Can be merged independently.
No behavioral changes yet — the config is parsed but not consulted.

### Phase 2: Routing Integration

Replace `is_tty` checks in the coordinator with `route_prompt()` calls that
consult the resolved detached config.
Add `--non-interactive` CLI flag.

Depends on [RFD 018] (the `Inquiry` enum) and Phase 1.

### Phase 3: Output Channel Separation

Route assistant output to stdout and chrome to stderr unconditionally.
Move tracing logs from stderr to a log file.
Wire `TerminalPromptBackend` to `/dev/tty` via `Printer::tty_writer()`.
Add `PrintTarget::Tty` to `Printer`.

Independent of Phases 1–2.
Can be merged at any point.

## References

- [RFD 018: Typed Inquiry System][RFD 018] — the `Inquiry` enum this RFD's
  routing logic is built on.
- [RFD 009: Stateful Tool Protocol][RFD 009] — per-action permission model
  interacts with detached policy.
- `DEBIAN_FRONTEND=noninteractive` — precedent for non-interactive policy.
- ssh `BatchMode` — precedent for "fail on prompt" policy.
- `curl` / `git` — precedent for stdout/stderr output separation.

[RFD 009]: 009-stateful-tool-protocol.md
[RFD 018]: 018-typed-prompt-routing-enum.md
[RFD 027]: 027-client-server-query-architecture.md
[RFD 048]: 048-four-channel-output-model.md
[RFD 049]: 049-non-interactive-mode-and-detached-prompt-policy.md
