docs: design delayed prompt injection

This commit is contained in:
2026-06-10 22:39:44 -04:00
parent 09505f0758
commit 90db743922
@@ -0,0 +1,198 @@
# Delayed prompt injection
**Date:** 2026-06-11
**Status:** Approved
## Goal
Add a TUI-only feature that schedules one delayed prompt for a specific hqt
session and injects it when the selected time arrives. The primary use case is
recovering a harness that stopped because it ran out of budget: the user can
schedule a restart/continue prompt and let hqt deliver it later.
Scheduled prompts fire only while the hqt TUI is running. If hqt is closed at
the due time, the prompt remains pending and fires on the next TUI poll after
hqt starts again.
## User Behavior
The session list gets a new keybinding, `p`, labeled "Prompt Later". Pressing
it for the selected session opens a modal that collects:
- Prompt text.
- A relative delay such as `30m`, `2h`, `1h30m`, or `90s`.
- A confirmation action.
Each session supports exactly one active delayed prompt. Scheduling a new prompt
for that session replaces the existing pending, sent, or failed prompt. A
companion cancel action, `P` / `Shift+P`, removes the selected session's active
prompt.
Visualization is intentionally compact:
- The session row shows the next pending trigger, for example
`◉ waiting @ 14:30` or `● idle @ 12m`.
- After scheduling, canceling, sending, or failing a prompt, the TUI shows a
short notification such as `Prompt scheduled for hqt-4 at 14:30`.
The row indicator is shown only for pending prompts. Sent prompts disappear from
the active indicator. Failed prompts are not retried automatically; they can be
replaced or canceled from the selected session.
## Architecture
Use a persisted database table and drive dispatch from the existing TUI poller.
This matches the current service architecture and keeps scheduling state visible
across TUI restarts without introducing a background daemon.
### Data Model
Add a `ScheduledPrompt` model and migration:
- `id` primary key.
- `session_id` foreign key to `sessions.id`, unique.
- `prompt` text, required.
- `due_at` datetime, required.
- `status` string, one of `pending`, `sending`, `sent`, or `failed`.
- `error` text, nullable.
- `created_at` datetime.
- `sent_at` datetime, nullable.
The unique `session_id` constraint enforces one active prompt record per
session. Scheduling a prompt replaces any existing prompt row for that session,
including failed or sent rows.
### Service Layer
`SessionService` owns delayed prompt behavior because it already owns session
lookup, resume/fallback behavior, status polling, and tmux interaction.
Add service methods along these lines:
```python
def schedule_prompt(self, session_id: int, prompt: str, due_at: datetime) -> ScheduledPrompt:
...
def cancel_scheduled_prompt(self, session_id: int) -> None:
...
def get_scheduled_prompt(self, session_id: int) -> ScheduledPrompt | None:
...
async def dispatch_due_prompts(self, now: datetime) -> list[DispatchResult]:
...
```
Extend `SessionInfo` with an optional `scheduled_prompt` field so `SessionList`
can render the compact row indicator from the same object it already receives
for each row.
### Tmux Layer
`TmuxRunner` already has literal `send_text()` and `send_enter()` methods. Add
thin `TmuxManager` delegates and make both send paths return success/failure
instead of fire-and-forget so the service can record failed injection.
Prompt injection sends the prompt body as literal text, then sends Enter as a
separate tmux key event. Prompt content such as `-flag`, quotes, semicolons, or
the word `Enter` must be treated as text, not as tmux commands or key names.
### TUI Runtime
The existing 3-second TUI poll loop becomes the scheduler runtime:
1. Call `dispatch_due_prompts(now)`.
2. Surface any sent/failed dispatch results as notifications.
3. Refresh session rows and tmux labels using the existing status flow.
No prompt fires while the TUI process is not running. Overdue prompts fire on
the first poll after startup.
## Dispatch Semantics
When a pending prompt is due:
1. Load the target session row.
2. Change the prompt status to `sending` and commit before interacting with
tmux, so overlapping poll workers cannot inject the same prompt twice.
3. If the session row is gone, mark the prompt `failed`.
4. If the tmux window is alive, inject directly.
5. If the window is dead or missing, resume the session without switching tmux
focus, then inject.
6. On successful text + Enter injection, mark the row `sent` and set `sent_at`.
7. On any failure, mark the row `failed` and store an error message.
The current `attach_session()` method resumes and then selects the tmux window.
Factor an internal helper, `_ensure_session_alive(db, sess) -> bool`, so due
prompt dispatch can reuse the resume/fallback ladder without changing the
user's active tmux window.
Failed prompts must not retry automatically on every poll. This avoids sending
duplicate prompts or repeatedly restarting a harness after a transient failure.
The user can replace the failed prompt by scheduling a new one, or clear it with
the cancel action.
## TUI Details
Add a new modal screen, `SchedulePromptScreen`, under `src/hqt/tui/screens/`.
It should follow existing modal conventions from the project:
- Plain Textual form controls.
- Compact dialog styling using existing CSS patterns.
- Inline validation for empty prompt or invalid delay.
- OK and Cancel actions.
Delay parsing is relative-only for the first version. Accepted units are
seconds, minutes, and hours. Combined values such as `1h30m` are accepted.
Absolute timestamps, recurring prompts, and multiple queued prompts are out of
scope.
`SessionList` row rendering includes pending prompt information when present.
Show `@ <countdown>` while the due time is less than 60 minutes away, rounded
up to whole minutes, for example `@ 12m`. Show local 24-hour time for prompts
that are at least 60 minutes away, for example `@ 14:30`.
## Error Handling
- Scheduling with no selected session shows a warning notification.
- Empty prompt text or invalid delay keeps the modal open with an inline error.
- Scheduling for a deleted session raises `ServiceError` and shows a TUI error.
- Deleted target session at dispatch time marks the prompt failed.
- Resume/fallback failure marks the prompt failed with the existing tmux/spawn
error when available.
- Text or Enter injection failure marks the prompt failed.
- Database errors are allowed to surface through existing service error handling.
## Testing
Add focused tests at the same layers the current code uses:
- Model/migration test for the `scheduled_prompts` table and unique
`session_id`.
- Service tests for schedule, replace, cancel, and retrieving pending prompt
metadata in session rows.
- Service dispatch tests for alive-window injection, dead-window auto-resume
without attach/select, missing session failure, injection failure, and failed
prompts not retrying on the next poll.
- Tmux tests for manager send delegates and literal send behavior returning
success/failure.
- TUI tests for the keybindings, modal validation, scheduling action, cancel
action, row indicator, and poll-time dispatch notifications.
After implementation changes, the project checks must pass:
```bash
uv run ruff format src tests
uv run ruff check src tests
uv run ty check
```
## Out of Scope
- Firing delayed prompts while hqt is closed.
- CLI scheduling.
- Multiple queued prompts per session.
- Absolute date/time scheduling.
- Recurring prompts.
- Prompt history or a full queue-management screen.
- Harness-specific prompt templates.