49 KiB
Delayed Prompt Injection Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build TUI-only delayed prompt scheduling for one active prompt per session, with persisted state, compact row visualization, auto-resume before injection, and poll-time dispatch.
Architecture: Add a scheduled_prompts table and expose scheduling through SessionService. The existing TUI poll loop becomes the scheduler runtime: it dispatches due prompts, shows notifications, then refreshes session rows. Tmux text injection stays in the tmux layer and returns success/failure so service dispatch can mark prompts sent or failed.
Tech Stack: Python 3.12, SQLAlchemy 2, Textual 2, pytest/pytest-asyncio, tmux command wrappers.
File Structure
- Modify
src/hqt/db/models.py: addScheduledPromptORM model and relationship fromSession. - Modify
src/hqt/db/migrations.py: add v3 migration creatingscheduled_prompts. - Modify
tests/test_db.py: verify fresh schema, v2-to-v3 migration, and uniquesession_id. - Modify
src/hqt/tmux/runner.py: makesend_text()andsend_enter()returnbool. - Modify
src/hqt/tmux/manager.py: addsend_text()andsend_enter()delegates returningbool. - Modify
tests/test_tmux.py: cover send success/failure at runner and manager layers. - Modify
src/hqt/sessions/service.py: add scheduling dataclasses/methods, pending prompt hydration inSessionInfo, no-attach resume helper, and due dispatch. - Modify
tests/test_sessions.py: cover scheduling, row metadata, dispatch success/failure, auto-resume, and no repeated retry. - Create
src/hqt/tui/screens/schedule_prompt.py: modal for prompt text + relative delay. - Modify
src/hqt/tui/styles.tcss: includeSchedulePromptScreenin modal alignment/styling selectors. - Modify
src/hqt/tui/widgets/session_list.py: format pending prompt indicators. - Modify
src/hqt/tui/app.py: addp/Pbindings, schedule/cancel actions, and poll-time dispatch notifications. - Modify
tests/test_tui.py: cover modal validation, keybindings/actions, row indicator, and poll dispatch notifications.
Task 1: Persist ScheduledPrompt
Files:
-
Modify:
src/hqt/db/models.py -
Modify:
src/hqt/db/migrations.py -
Test:
tests/test_db.py -
Step 1: Write failing DB schema tests
Append these tests to tests/test_db.py:
def _scheduled_prompt_column_names(engine) -> list[str]:
with engine.connect() as conn:
rows = conn.exec_driver_sql("PRAGMA table_info(scheduled_prompts)").fetchall()
return [row[1] for row in rows]
def test_fresh_db_has_scheduled_prompts_table(tmp_path):
settings = _tmp_settings(tmp_path)
ensure_db(settings)
engine = get_engine(settings)
tables = inspect(engine).get_table_names()
assert "scheduled_prompts" in tables
cols = _scheduled_prompt_column_names(engine)
assert cols == [
"id",
"session_id",
"prompt",
"due_at",
"status",
"error",
"created_at",
"sent_at",
]
assert _user_version(engine) == 3
def test_v2_db_upgraded_to_v3_adds_scheduled_prompts(tmp_path):
settings = _tmp_settings(tmp_path)
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
engine = get_engine(settings)
Base.metadata.create_all(engine)
with engine.begin() as conn:
conn.exec_driver_sql("DROP TABLE scheduled_prompts")
conn.exec_driver_sql("PRAGMA user_version = 2")
migrate(engine)
assert "scheduled_prompts" in inspect(engine).get_table_names()
assert _user_version(engine) == 3
def test_scheduled_prompt_one_row_per_session(tmp_path):
from datetime import datetime, timedelta
from sqlalchemy.exc import IntegrityError
from hqt.db.models import ScheduledPrompt
settings = _tmp_settings(tmp_path)
ensure_db(settings)
factory = get_session_factory(get_engine(settings))
with factory() as session:
p = Project(name="sched-proj", path="/tmp/sched-proj")
h = Harness(name="claude-code-scheduled")
session.add_all([p, h])
session.flush()
s = Session(
project_id=p.id,
harness_id=h.id,
tmux_session_name="hqt-scheduled",
)
session.add(s)
session.flush()
due = datetime.now() + timedelta(minutes=30)
session.add(
ScheduledPrompt(session_id=s.id, prompt="continue", due_at=due)
)
session.commit()
session.add(
ScheduledPrompt(session_id=s.id, prompt="again", due_at=due)
)
with pytest.raises(IntegrityError):
session.commit()
Update the existing tests in tests/test_db.py that assert version 2 so they expect migrations.LATEST_VERSION or 3:
assert _user_version(engine) == migrations.LATEST_VERSION
- Step 2: Run DB tests and verify they fail
Run:
uv run pytest tests/test_db.py -q
Expected: FAIL because ScheduledPrompt and the v3 migration do not exist.
- Step 3: Add the model
In src/hqt/db/models.py, update imports and add the relationship:
from sqlalchemy import ForeignKey, String, Text, func
Inside class Session, add:
scheduled_prompt: Mapped["ScheduledPrompt | None"] = relationship(
back_populates="session"
)
After class Session, add:
class ScheduledPrompt(Base):
__tablename__ = "scheduled_prompts"
id: Mapped[int] = mapped_column(primary_key=True)
session_id: Mapped[int] = mapped_column(
ForeignKey("sessions.id"),
unique=True,
)
prompt: Mapped[str] = mapped_column(Text)
due_at: Mapped[datetime]
status: Mapped[str] = mapped_column(String, default="pending")
error: Mapped[str | None] = mapped_column(Text, default=None)
created_at: Mapped[datetime] = mapped_column(insert_default=func.now())
sent_at: Mapped[datetime | None] = mapped_column(default=None)
session: Mapped["Session"] = relationship(back_populates="scheduled_prompt")
- Step 4: Add the migration
In src/hqt/db/migrations.py, add:
def _migrate_v3(conn: Connection) -> None:
conn.exec_driver_sql(
"""
CREATE TABLE scheduled_prompts (
id INTEGER NOT NULL PRIMARY KEY,
session_id INTEGER NOT NULL UNIQUE,
prompt TEXT NOT NULL,
due_at DATETIME NOT NULL,
status VARCHAR NOT NULL,
error TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME,
FOREIGN KEY(session_id) REFERENCES sessions (id)
)
"""
)
Update MIGRATIONS:
MIGRATIONS: list[tuple[int, Callable[[Connection], None]]] = [
(2, _migrate_v2),
(3, _migrate_v3),
]
- Step 5: Run DB tests and verify they pass
Run:
uv run pytest tests/test_db.py -q
Expected: PASS.
- Step 6: Commit
git add src/hqt/db/models.py src/hqt/db/migrations.py tests/test_db.py
git commit -m "feat: persist scheduled prompts"
Task 2: Return Injection Success From Tmux Layer
Files:
-
Modify:
src/hqt/tmux/runner.py -
Modify:
src/hqt/tmux/manager.py -
Test:
tests/test_tmux.py -
Step 1: Write failing tmux send tests
Append these tests near the existing send-key tests in tests/test_tmux.py:
@pytest.mark.asyncio
async def test_send_text_returns_true_on_success(runner):
runner._exec.return_value = (0, "", "")
assert await runner.send_text("hqt-1", "hello") is True
@pytest.mark.asyncio
async def test_send_text_returns_false_on_failure(runner):
runner._exec.return_value = (1, "", "no such target")
assert await runner.send_text("hqt-1", "hello") is False
@pytest.mark.asyncio
async def test_send_enter_returns_true_on_success(runner):
runner._exec.return_value = (0, "", "")
assert await runner.send_enter("hqt-1") is True
@pytest.mark.asyncio
async def test_send_enter_returns_false_on_failure(runner):
runner._exec.return_value = (1, "", "no such target")
assert await runner.send_enter("hqt-1") is False
@pytest.mark.asyncio
async def test_manager_send_delegates(runner):
mgr = TmuxManager(runner)
runner.send_text = AsyncMock(return_value=True)
runner.send_enter = AsyncMock(return_value=True)
assert await mgr.send_text("hqt-1", "continue") is True
assert await mgr.send_enter("hqt-1") is True
runner.send_text.assert_awaited_once_with("hqt-1", "continue")
runner.send_enter.assert_awaited_once_with("hqt-1")
- Step 2: Run tmux tests and verify they fail
Run:
uv run pytest tests/test_tmux.py -q
Expected: FAIL because send_text() / send_enter() return None, and TmuxManager has no send delegates.
- Step 3: Update runner send methods
In src/hqt/tmux/runner.py, replace the send methods with:
async def send_text(self, window_name: str, text: str) -> bool:
rc, _, err = await self._exec(
"send-keys",
"-t",
self._target(window_name),
"-l",
"--",
text,
)
if rc != 0:
log.error("send_text failed for %s: %s", window_name, err)
return rc == 0
async def send_enter(self, window_name: str) -> bool:
rc, _, err = await self._exec(
"send-keys",
"-t",
self._target(window_name),
"Enter",
)
if rc != 0:
log.error("send_enter failed for %s: %s", window_name, err)
return rc == 0
- Step 4: Add manager delegates
In src/hqt/tmux/manager.py, add methods near capture_pane():
async def send_text(self, window_name: str, text: str) -> bool:
"""Send literal text to a harness window."""
return await self.runner.send_text(window_name, text)
async def send_enter(self, window_name: str) -> bool:
"""Send Enter to a harness window."""
return await self.runner.send_enter(window_name)
- Step 5: Run tmux tests and verify they pass
Run:
uv run pytest tests/test_tmux.py -q
Expected: PASS.
- Step 6: Commit
git add src/hqt/tmux/runner.py src/hqt/tmux/manager.py tests/test_tmux.py
git commit -m "feat: report tmux prompt injection failures"
Task 3: Add Scheduling Service Methods And Row Metadata
Files:
-
Modify:
src/hqt/sessions/service.py -
Test:
tests/test_sessions.py -
Step 1: Extend the tmux fixture for new manager methods
In the tmux() fixture in tests/test_sessions.py, add:
m.send_text = AsyncMock(return_value=True)
m.send_enter = AsyncMock(return_value=True)
- Step 2: Write failing service scheduling tests
Append this section after the rename/get-session tests in tests/test_sessions.py:
# ---------------------------------------------------------------------------
# delayed prompt scheduling
# ---------------------------------------------------------------------------
def _due_at():
from datetime import datetime, timedelta
return datetime.now() + timedelta(minutes=30)
def test_schedule_prompt_creates_row(service, db):
from hqt.db.models import ScheduledPrompt
sess = _seed_session(db)
scheduled = service.schedule_prompt(sess.id, "continue", _due_at())
db.expire_all()
row = db.get(ScheduledPrompt, scheduled.id)
assert row.session_id == sess.id
assert row.prompt == "continue"
assert row.status == "pending"
assert row.error is None
def test_schedule_prompt_replaces_existing_row(service, db):
from hqt.db.models import ScheduledPrompt
sess = _seed_session(db)
first = service.schedule_prompt(sess.id, "first", _due_at())
second = service.schedule_prompt(sess.id, "second", _due_at())
db.expire_all()
rows = db.query(ScheduledPrompt).filter_by(session_id=sess.id).all()
assert len(rows) == 1
assert rows[0].id == first.id
assert second.id == first.id
assert rows[0].prompt == "second"
assert rows[0].status == "pending"
def test_schedule_prompt_empty_raises(service, db):
sess = _seed_session(db)
with pytest.raises(ServiceError, match="Prompt cannot be empty"):
service.schedule_prompt(sess.id, " ", _due_at())
def test_schedule_prompt_missing_session_raises(service):
with pytest.raises(ServiceError, match="Session not found"):
service.schedule_prompt(99999, "continue", _due_at())
def test_cancel_scheduled_prompt_deletes_row(service, db):
from hqt.db.models import ScheduledPrompt
sess = _seed_session(db)
scheduled = service.schedule_prompt(sess.id, "continue", _due_at())
service.cancel_scheduled_prompt(sess.id)
db.expire_all()
assert db.get(ScheduledPrompt, scheduled.id) is None
def test_get_scheduled_prompt_pending_only(service, db):
from hqt.db.models import ScheduledPrompt
sess = _seed_session(db)
scheduled = service.schedule_prompt(sess.id, "continue", _due_at())
db.expire_all()
row = db.get(ScheduledPrompt, scheduled.id)
row.status = "failed"
db.commit()
assert service.get_scheduled_prompt(sess.id) is None
@pytest.mark.asyncio
async def test_list_sessions_includes_pending_scheduled_prompt(service, db, tmux):
from hqt.tmux.runner import WindowInfo
sess = _seed_session(db)
service.schedule_prompt(sess.id, "continue", _due_at())
tmux.poll_info.return_value = {"hqt-rename": WindowInfo(alive=True, last_activity=1000)}
infos = await service.list_sessions(project_id=1, now=1000.0)
assert infos[0].scheduled_prompt is not None
assert infos[0].scheduled_prompt.prompt == "continue"
- Step 3: Run service tests and verify they fail
Run:
uv run pytest tests/test_sessions.py -q
Expected: FAIL because service scheduling methods and SessionInfo.scheduled_prompt do not exist.
- Step 4: Add imports, constants, and dataclasses
In src/hqt/sessions/service.py, update imports:
from dataclasses import dataclass
from datetime import UTC, datetime
Update model imports:
from hqt.db.models import Harness, Project, ScheduledPrompt, Session
Add status constants near the existing module constants:
PROMPT_PENDING = "pending"
PROMPT_SENDING = "sending"
PROMPT_SENT = "sent"
PROMPT_FAILED = "failed"
Update SessionInfo:
@dataclass
class SessionInfo:
session: Session
alive: bool
status: str = "dead"
scheduled_prompt: ScheduledPrompt | None = None
- Step 5: Add scheduling methods
Inside class SessionService, add these methods after rename_session():
def schedule_prompt(
self, session_id: int, prompt: str, due_at: datetime
) -> ScheduledPrompt:
"""Create or replace the selected session's scheduled prompt."""
prompt = prompt.strip()
if not prompt:
raise ServiceError("Prompt cannot be empty")
with self.factory() as db:
sess = db.get(Session, session_id)
if sess is None:
raise ServiceError("Session not found")
row = db.query(ScheduledPrompt).filter_by(session_id=session_id).first()
if row is None:
row = ScheduledPrompt(session_id=session_id, prompt=prompt, due_at=due_at)
db.add(row)
else:
row.prompt = prompt
row.due_at = due_at
row.status = PROMPT_PENDING
row.error = None
row.sent_at = None
db.commit()
return row
def cancel_scheduled_prompt(self, session_id: int) -> None:
"""Delete any scheduled prompt row for a session."""
with self.factory() as db:
row = db.query(ScheduledPrompt).filter_by(session_id=session_id).first()
if row is None:
return
db.delete(row)
db.commit()
def get_scheduled_prompt(self, session_id: int) -> ScheduledPrompt | None:
"""Return the pending scheduled prompt for a session, if any."""
with self.factory() as db:
return (
db.query(ScheduledPrompt)
.filter_by(session_id=session_id, status=PROMPT_PENDING)
.first()
)
- Step 6: Hydrate pending prompt metadata for session rows
Add a helper inside SessionService:
def _pending_prompt_map(
self, db: DBSession, session_ids: list[int]
) -> dict[int, ScheduledPrompt]:
if not session_ids:
return {}
rows = (
db.query(ScheduledPrompt)
.filter(
ScheduledPrompt.session_id.in_(session_ids),
ScheduledPrompt.status == PROMPT_PENDING,
)
.all()
)
return {row.session_id: row for row in rows}
In both list_sessions() and sync_window_labels(), after building names, add:
pending_prompts = self._pending_prompt_map(db, [s.id for s in sessions])
When appending SessionInfo, pass:
scheduled_prompt=pending_prompts.get(s.id),
For the one-line append in sync_window_labels(), replace it with:
result.append(
SessionInfo(
session=s,
alive=alive,
status=status,
scheduled_prompt=pending_prompts.get(s.id),
)
)
- Step 7: Run service tests and verify they pass
Run:
uv run pytest tests/test_sessions.py -q
Expected: PASS.
- Step 8: Commit
git add src/hqt/sessions/service.py tests/test_sessions.py
git commit -m "feat: schedule prompts in session service"
Task 4: Dispatch Due Prompts
Files:
-
Modify:
src/hqt/sessions/service.py -
Test:
tests/test_sessions.py -
Step 1: Write failing dispatch tests
Append these tests to the delayed prompt scheduling section in tests/test_sessions.py:
@pytest.mark.asyncio
async def test_dispatch_due_prompt_alive_window_sends_and_marks_sent(
service, db, tmux
):
from datetime import datetime, timedelta
from hqt.db.models import ScheduledPrompt
sess = _seed_session(db)
due = datetime.now() - timedelta(seconds=1)
scheduled = service.schedule_prompt(sess.id, "continue", due)
tmux.is_alive = AsyncMock(return_value=True)
results = await service.dispatch_due_prompts(now=datetime.now())
assert len(results) == 1
assert results[0].ok is True
assert results[0].session_id == sess.id
tmux.send_text.assert_awaited_once_with("hqt-rename", "continue")
tmux.send_enter.assert_awaited_once_with("hqt-rename")
db.expire_all()
row = db.get(ScheduledPrompt, scheduled.id)
assert row.status == "sent"
assert row.sent_at is not None
@pytest.mark.asyncio
async def test_dispatch_due_prompt_dead_window_resumes_without_attach(
service, db, tmux, harnesses
):
from datetime import datetime, timedelta
sess = _seed_session(db)
service.schedule_prompt(sess.id, "continue", datetime.now() - timedelta(seconds=1))
tmux.is_alive = AsyncMock(return_value=False)
tmux.respawn_verified = AsyncMock(
return_value=SpawnResult(ok=True, window_id=None, error="")
)
tmux.attach = AsyncMock(return_value=True)
harnesses["claude-code"].build_resume_config = MagicMock(
return_value=MagicMock(command=["claude", "--resume"], env={}, cwd=Path("/tmp/myproj"))
)
results = await service.dispatch_due_prompts(now=datetime.now())
assert results[0].ok is True
tmux.respawn_verified.assert_awaited()
tmux.attach.assert_not_called()
tmux.send_text.assert_awaited_once_with("hqt-rename", "continue")
@pytest.mark.asyncio
async def test_dispatch_due_prompt_resume_failure_marks_failed(
service, db, tmux, harnesses
):
from datetime import datetime, timedelta
from hqt.db.models import ScheduledPrompt
sess = _seed_session(db)
scheduled = service.schedule_prompt(
sess.id, "continue", datetime.now() - timedelta(seconds=1)
)
tmux.is_alive = AsyncMock(return_value=False)
tmux.respawn_verified = AsyncMock(
return_value=SpawnResult(ok=False, window_id=None, error="resume failed")
)
harnesses["claude-code"].build_resume_config = MagicMock(
return_value=MagicMock(command=["claude", "--resume"], env={}, cwd=Path("/tmp/myproj"))
)
harnesses["claude-code"].build_spawn_config = MagicMock(
return_value=MagicMock(command=["claude"], env={}, cwd=Path("/tmp/myproj"))
)
results = await service.dispatch_due_prompts(now=datetime.now())
assert results[0].ok is False
assert "resume failed" in results[0].error
tmux.send_text.assert_not_called()
db.expire_all()
row = db.get(ScheduledPrompt, scheduled.id)
assert row.status == "failed"
assert "resume failed" in row.error
@pytest.mark.asyncio
async def test_dispatch_due_prompt_send_failure_does_not_retry_next_poll(
service, db, tmux
):
from datetime import datetime, timedelta
from hqt.db.models import ScheduledPrompt
sess = _seed_session(db)
scheduled = service.schedule_prompt(
sess.id, "continue", datetime.now() - timedelta(seconds=1)
)
tmux.is_alive = AsyncMock(return_value=True)
tmux.send_text = AsyncMock(return_value=False)
first = await service.dispatch_due_prompts(now=datetime.now())
second = await service.dispatch_due_prompts(now=datetime.now())
assert first[0].ok is False
assert second == []
assert tmux.send_text.await_count == 1
db.expire_all()
row = db.get(ScheduledPrompt, scheduled.id)
assert row.status == "failed"
assert "send text failed" in row.error
- Step 2: Run dispatch tests and verify they fail
Run:
uv run pytest tests/test_sessions.py -q
Expected: FAIL because DispatchResult, _ensure_session_alive(), and dispatch_due_prompts() do not exist.
- Step 3: Add dispatch result dataclass
In src/hqt/sessions/service.py, add after CreateSessionResult:
@dataclass
class DispatchResult:
session_id: int
window_name: str
ok: bool
error: str = ""
- Step 4: Factor no-attach resume helper
In attach_session(), replace the alive/respawn block:
if await self.tmux.is_alive(window_name):
# Window exists, process running — just switch
return await self.tmux.attach(window_name)
# Window exists but pane is dead, OR window is completely gone
log.info(
"Session %s not alive, respawning with fallback ladder", window_name
)
ok = await self._respawn_with_fallback(db, sess, window_name)
if not ok:
return False
return await self.tmux.attach(window_name)
with:
ok = await self._ensure_session_alive(db, sess, window_name)
if not ok:
return False
return await self.tmux.attach(window_name)
Add the helper before _respawn_with_fallback():
async def _ensure_session_alive(
self, db: DBSession, sess: Session, window_name: str
) -> bool:
"""Ensure a session's tmux pane is alive without selecting the window."""
if await self.tmux.is_alive(window_name):
return True
log.info(
"Session %s not alive, respawning with fallback ladder",
window_name,
)
return await self._respawn_with_fallback(db, sess, window_name)
- Step 5: Add prompt failure helper and dispatcher
Add these methods to SessionService after get_scheduled_prompt():
def _mark_prompt_failed(
self, db: DBSession, row: ScheduledPrompt, error: str
) -> DispatchResult:
row.status = PROMPT_FAILED
row.error = error
db.commit()
session = db.get(Session, row.session_id)
window_name = session.tmux_session_name if session is not None else ""
return DispatchResult(
session_id=row.session_id,
window_name=window_name,
ok=False,
error=error,
)
async def dispatch_due_prompts(
self, now: datetime | None = None
) -> list[DispatchResult]:
"""Dispatch due pending prompts once; failed prompts are not retried."""
if now is None:
now = datetime.now()
results: list[DispatchResult] = []
with self.factory() as db:
rows = (
db.query(ScheduledPrompt)
.filter(
ScheduledPrompt.status == PROMPT_PENDING,
ScheduledPrompt.due_at <= now,
)
.all()
)
for row in rows:
row.status = PROMPT_SENDING
row.error = None
db.commit()
sess = db.get(
Session,
row.session_id,
options=[selectinload(Session.harness)],
)
if sess is None:
results.append(
self._mark_prompt_failed(db, row, "Session not found")
)
continue
window_name = sess.tmux_session_name
try:
alive = await self._ensure_session_alive(db, sess, window_name)
except ServiceError as exc:
results.append(self._mark_prompt_failed(db, row, str(exc)))
continue
if not alive:
results.append(
self._mark_prompt_failed(
db, row, "Failed to resume session before prompt"
)
)
continue
if not await self.tmux.send_text(window_name, row.prompt):
results.append(self._mark_prompt_failed(db, row, "send text failed"))
continue
if not await self.tmux.send_enter(window_name):
results.append(self._mark_prompt_failed(db, row, "send enter failed"))
continue
row.status = PROMPT_SENT
row.sent_at = now
row.error = None
db.commit()
results.append(
DispatchResult(
session_id=sess.id,
window_name=window_name,
ok=True,
)
)
return results
- Step 6: Run dispatch and attach regression tests
Run:
uv run pytest tests/test_sessions.py::test_attach_session_dead_resume_ok_attaches_once tests/test_sessions.py::test_dispatch_due_prompt_alive_window_sends_and_marks_sent tests/test_sessions.py::test_dispatch_due_prompt_dead_window_resumes_without_attach tests/test_sessions.py::test_dispatch_due_prompt_resume_failure_marks_failed tests/test_sessions.py::test_dispatch_due_prompt_send_failure_does_not_retry_next_poll -q
Expected: PASS.
- Step 7: Run all session tests
Run:
uv run pytest tests/test_sessions.py -q
Expected: PASS.
- Step 8: Commit
git add src/hqt/sessions/service.py tests/test_sessions.py
git commit -m "feat: dispatch due scheduled prompts"
Task 5: Build Schedule Prompt Modal
Files:
-
Create:
src/hqt/tui/screens/schedule_prompt.py -
Modify:
src/hqt/tui/styles.tcss -
Test:
tests/test_tui.py -
Step 1: Write failing modal tests
Append this section in tests/test_tui.py near the other screen/modal tests:
# ---------------------------------------------------------------------------
# SchedulePromptScreen
# ---------------------------------------------------------------------------
def test_parse_delay_accepts_seconds_minutes_hours():
from datetime import timedelta
from hqt.tui.screens.schedule_prompt import parse_delay
assert parse_delay("90s") == timedelta(seconds=90)
assert parse_delay("30m") == timedelta(minutes=30)
assert parse_delay("2h") == timedelta(hours=2)
assert parse_delay("1h30m") == timedelta(hours=1, minutes=30)
def test_parse_delay_rejects_invalid_values():
from hqt.tui.screens.schedule_prompt import parse_delay
for value in ["", "0m", "abc", "1d", "1h 30m"]:
with pytest.raises(ValueError):
parse_delay(value)
@pytest.mark.asyncio
async def test_schedule_prompt_screen_submits_prompt_and_delay():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.schedule_prompt import SchedulePromptScreen
from textual.widgets import Input
results = []
app.push_screen(SchedulePromptScreen(), results.append)
await pilot.pause()
app.screen.query_one("#prompt-input", Input).value = "continue"
app.screen.query_one("#delay-input", Input).value = "30m"
app.screen.query_one("#prompt-input", Input).focus()
await pilot.press("enter")
await pilot.pause()
assert len(results) == 1
assert results[0][0] == "continue"
assert results[0][1].total_seconds() == 1800
@pytest.mark.asyncio
async def test_schedule_prompt_screen_invalid_delay_stays_open():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.schedule_prompt import SchedulePromptScreen
from textual.widgets import Input, Label
results = []
app.push_screen(SchedulePromptScreen(), results.append)
await pilot.pause()
app.screen.query_one("#prompt-input", Input).value = "continue"
app.screen.query_one("#delay-input", Input).value = "bogus"
await pilot.press("enter")
await pilot.pause()
assert results == []
error_text = str(app.screen.query_one("#schedule-error", Label).render())
assert "Use a delay like" in error_text
- Step 2: Run modal tests and verify they fail
Run:
uv run pytest tests/test_tui.py::test_parse_delay_accepts_seconds_minutes_hours tests/test_tui.py::test_parse_delay_rejects_invalid_values tests/test_tui.py::test_schedule_prompt_screen_submits_prompt_and_delay tests/test_tui.py::test_schedule_prompt_screen_invalid_delay_stays_open -q
Expected: FAIL because schedule_prompt.py does not exist.
- Step 3: Create the modal
Create src/hqt/tui/screens/schedule_prompt.py:
import re
from datetime import timedelta
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label
_DELAY_RE = re.compile(r"(?P<value>\d+)(?P<unit>[smh])")
def parse_delay(value: str) -> timedelta:
text = value.strip().lower()
if not text:
raise ValueError("empty delay")
pos = 0
seconds = 0
for match in _DELAY_RE.finditer(text):
if match.start() != pos:
raise ValueError("invalid delay")
amount = int(match.group("value"))
unit = match.group("unit")
if unit == "s":
seconds += amount
elif unit == "m":
seconds += amount * 60
elif unit == "h":
seconds += amount * 3600
pos = match.end()
if pos != len(text) or seconds <= 0:
raise ValueError("invalid delay")
return timedelta(seconds=seconds)
class SchedulePromptScreen(ModalScreen[tuple[str, timedelta] | None]):
"""Collect prompt text and a relative delay."""
def compose(self) -> ComposeResult:
with Vertical(id="schedule-prompt-dialog"):
yield Label("Prompt Later")
yield Label("Prompt:")
yield Input(placeholder="message to send", id="prompt-input")
yield Label("Delay:")
yield Input(placeholder="30m, 2h, 1h30m, 90s", id="delay-input")
yield Label("", id="schedule-error")
with Horizontal(classes="dialog-actions"):
yield Button("OK", variant="primary", id="ok-btn")
yield Button("Cancel", id="cancel-btn")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok-btn":
self._submit()
else:
self.dismiss(None)
def on_input_submitted(self, event: Input.Submitted) -> None:
self._submit()
def _submit(self) -> None:
prompt_input = self.query_one("#prompt-input", Input)
delay_input = self.query_one("#delay-input", Input)
error = self.query_one("#schedule-error", Label)
prompt = prompt_input.value.strip()
if not prompt:
error.update("Prompt cannot be empty")
prompt_input.focus()
return
try:
delay = parse_delay(delay_input.value)
except ValueError:
error.update("Use a delay like 30m, 2h, 1h30m, or 90s")
delay_input.focus()
return
self.dismiss((prompt, delay))
- Step 4: Style the modal
In src/hqt/tui/styles.tcss, update:
ProjectFormScreen, NewSessionScreen {
to:
ProjectFormScreen, NewSessionScreen, SchedulePromptScreen {
Update:
#project-form-dialog, #new-session-dialog {
to:
#project-form-dialog, #new-session-dialog, #schedule-prompt-dialog {
Add:
#schedule-error {
color: $error;
height: 1;
}
- Step 5: Run modal tests and verify they pass
Run:
uv run pytest tests/test_tui.py::test_parse_delay_accepts_seconds_minutes_hours tests/test_tui.py::test_parse_delay_rejects_invalid_values tests/test_tui.py::test_schedule_prompt_screen_submits_prompt_and_delay tests/test_tui.py::test_schedule_prompt_screen_invalid_delay_stays_open -q
Expected: PASS.
- Step 6: Commit
git add src/hqt/tui/screens/schedule_prompt.py src/hqt/tui/styles.tcss tests/test_tui.py
git commit -m "feat: add delayed prompt modal"
Task 6: Render Pending Prompt Indicators
Files:
-
Modify:
src/hqt/tui/widgets/session_list.py -
Test:
tests/test_tui.py -
Step 1: Write failing row-format tests
Append near the existing SessionList tests in tests/test_tui.py:
def _make_scheduled_prompt(due_at):
prompt = MagicMock()
prompt.due_at = due_at
prompt.status = "pending"
return prompt
def test_format_session_text_shows_scheduled_prompt_countdown():
from datetime import datetime, timedelta
from hqt.tui.widgets.session_list import format_session_text
now = datetime(2026, 6, 11, 14, 0, 0)
scheduled = _make_scheduled_prompt(now + timedelta(minutes=12, seconds=1))
text = format_session_text(
"mywork",
"claude",
"idle",
"●",
scheduled_prompt=scheduled,
now=now,
)
assert "@ 13m" in text
def test_format_session_text_shows_scheduled_prompt_clock_time():
from datetime import datetime, timedelta
from hqt.tui.widgets.session_list import format_session_text
now = datetime(2026, 6, 11, 14, 0, 0)
scheduled = _make_scheduled_prompt(now + timedelta(hours=2, minutes=30))
text = format_session_text(
"mywork",
"claude",
"idle",
"●",
scheduled_prompt=scheduled,
now=now,
)
assert "@ 16:30" in text
- Step 2: Run row-format tests and verify they fail
Run:
uv run pytest tests/test_tui.py::test_format_session_text_shows_scheduled_prompt_countdown tests/test_tui.py::test_format_session_text_shows_scheduled_prompt_clock_time -q
Expected: FAIL because format_session_text() has no scheduled prompt support.
- Step 3: Update formatter signature and helper
In src/hqt/tui/widgets/session_list.py, add imports:
from datetime import datetime
from math import ceil
Add helper above format_session_text():
def format_scheduled_prompt(due_at: datetime, now: datetime | None = None) -> str:
if now is None:
now = datetime.now()
seconds = max(0, (due_at - now).total_seconds())
if seconds < 3600:
return f"@ {max(1, ceil(seconds / 60))}m"
return f"@ {due_at.strftime('%H:%M')}"
Change format_session_text() signature:
def format_session_text(
nickname: str,
harness_name: str,
status_text: str,
symbol: str,
worktree_branch: str | None = None,
scheduled_prompt=None,
now: datetime | None = None,
) -> str:
Before the return, add:
schedule = ""
if scheduled_prompt is not None:
schedule = f" {format_scheduled_prompt(scheduled_prompt.due_at, now=now)}"
Replace the return with:
return f"[{color}]{symbol}[/] {label} {status_text}{schedule}"
- Step 4: Pass scheduled prompt metadata from refresh
In SessionList.refresh_sessions(), update the formatter call:
text = format_session_text(
s.nickname or s.tmux_session_name,
s.harness.name,
status_text,
symbol,
getattr(s, "worktree_branch", None),
scheduled_prompt=getattr(info, "scheduled_prompt", None),
)
- Step 5: Run TUI row tests and verify they pass
Run:
uv run pytest tests/test_tui.py::test_format_session_text_shows_scheduled_prompt_countdown tests/test_tui.py::test_format_session_text_shows_scheduled_prompt_clock_time tests/test_tui.py::test_session_list_label_includes_status -q
Expected: PASS.
- Step 6: Commit
git add src/hqt/tui/widgets/session_list.py tests/test_tui.py
git commit -m "feat: show scheduled prompts in session rows"
Task 7: Wire TUI Scheduling, Canceling, And Poll Dispatch
Files:
-
Modify:
src/hqt/tui/app.py -
Test:
tests/test_tui.py -
Step 1: Write failing TUI action tests
Append these tests to tests/test_tui.py:
@pytest.mark.asyncio
async def test_prompt_later_binding_exists():
bindings = {b.key: b for b in HqtApp.BINDINGS}
assert "p" in bindings
assert bindings["p"].action == "schedule_prompt"
assert "P" in bindings
assert bindings["P"].action == "cancel_scheduled_prompt"
@pytest.mark.asyncio
async def test_schedule_prompt_action_calls_service_and_refreshes():
from datetime import datetime
from unittest.mock import patch
from hqt.db.models import Harness, Project, Session
from hqt.tui.widgets.session_list import SessionList
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
seed_db = app._db_factory()
proj = Project(name="prompt-proj", path="/tmp/prompt-proj")
seed_db.add(proj)
seed_db.flush()
harness = seed_db.query(Harness).first()
sess = Session(
project_id=proj.id,
harness_id=harness.id,
tmux_session_name="hqt-prompt-test",
archived=False,
)
seed_db.add(sess)
seed_db.commit()
proj_id = proj.id
sess_id = sess.id
seed_db.close()
app._selected_project_id = proj_id
await app._refresh_sessions()
await pilot.pause()
app.query_one(SessionList).query_one("#session-list").focus()
mock_service = MagicMock()
mock_service.schedule_prompt = MagicMock()
mock_service.list_sessions = AsyncMock(return_value=[])
mock_service.sync_window_labels = AsyncMock(return_value=[])
mock_service.dispatch_due_prompts = AsyncMock(return_value=[])
app._session_service = mock_service
notify_calls = []
def capture_notify(message, **kwargs):
notify_calls.append((message, kwargs))
with patch.object(app, "notify", side_effect=capture_notify):
app.action_schedule_prompt()
await pilot.pause()
app.screen.dismiss(("continue", __import__("datetime").timedelta(minutes=30)))
await pilot.pause()
await app.workers.wait_for_complete()
assert mock_service.schedule_prompt.call_count == 1
args = mock_service.schedule_prompt.call_args.args
assert args[0] == sess_id
assert args[1] == "continue"
assert isinstance(args[2], datetime)
mock_service.list_sessions.assert_awaited()
assert any("Prompt scheduled" in call[0] for call in notify_calls)
@pytest.mark.asyncio
async def test_cancel_scheduled_prompt_action_calls_service_and_refreshes():
from hqt.db.models import Harness, Project, Session
from hqt.tui.widgets.session_list import SessionList
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
seed_db = app._db_factory()
proj = Project(name="cancel-proj", path="/tmp/cancel-proj")
seed_db.add(proj)
seed_db.flush()
harness = seed_db.query(Harness).first()
sess = Session(
project_id=proj.id,
harness_id=harness.id,
tmux_session_name="hqt-cancel-test",
archived=False,
)
seed_db.add(sess)
seed_db.commit()
proj_id = proj.id
sess_id = sess.id
seed_db.close()
app._selected_project_id = proj_id
await app._refresh_sessions()
await pilot.pause()
app.query_one(SessionList).query_one("#session-list").focus()
mock_service = MagicMock()
mock_service.cancel_scheduled_prompt = MagicMock()
mock_service.list_sessions = AsyncMock(return_value=[])
mock_service.sync_window_labels = AsyncMock(return_value=[])
mock_service.dispatch_due_prompts = AsyncMock(return_value=[])
app._session_service = mock_service
await app.run_action("cancel_scheduled_prompt")
await pilot.pause()
await app.workers.wait_for_complete()
mock_service.cancel_scheduled_prompt.assert_called_once_with(sess_id)
mock_service.list_sessions.assert_awaited()
@pytest.mark.asyncio
async def test_poll_sessions_dispatches_due_prompts_and_notifies():
app = HqtApp()
async with app.run_test(size=(120, 40)):
result = MagicMock()
result.ok = True
result.window_name = "hqt-1"
result.error = ""
mock_service = MagicMock()
mock_service.dispatch_due_prompts = AsyncMock(return_value=[result])
mock_service.sync_window_labels = AsyncMock(return_value=[])
app._session_service = mock_service
notify_calls = []
def capture_notify(message, **kwargs):
notify_calls.append((message, kwargs))
from unittest.mock import patch
with patch.object(app, "notify", side_effect=capture_notify):
await app._poll_sessions()
mock_service.dispatch_due_prompts.assert_awaited_once()
assert any("Prompt sent" in call[0] for call in notify_calls)
- Step 2: Run TUI action tests and verify they fail
Run:
uv run pytest tests/test_tui.py::test_prompt_later_binding_exists tests/test_tui.py::test_schedule_prompt_action_calls_service_and_refreshes tests/test_tui.py::test_cancel_scheduled_prompt_action_calls_service_and_refreshes tests/test_tui.py::test_poll_sessions_dispatches_due_prompts_and_notifies -q
Expected: FAIL because app bindings/actions/poll dispatch do not exist.
- Step 3: Import datetime and modal
In src/hqt/tui/app.py, add:
from datetime import datetime
Add the screen import:
from hqt.tui.screens.schedule_prompt import SchedulePromptScreen
- Step 4: Add bindings
In HqtApp.BINDINGS, add after rename or stop:
Binding("p", "schedule_prompt", "Prompt Later"),
Binding("P", "cancel_scheduled_prompt", "Cancel Prompt"),
- Step 5: Dispatch due prompts in the poller
At the start of _poll_sessions(), before sync_window_labels(), add:
dispatch_results = await self._sessions().dispatch_due_prompts()
for result in dispatch_results:
if result.ok:
self.notify(f"Prompt sent to {result.window_name}")
else:
target = result.window_name or f"session {result.session_id}"
self.notify(
f"Prompt failed for {target}: {result.error}",
severity="error",
)
The final method keeps the existing ServiceError handling:
async def _poll_sessions(self) -> None:
try:
dispatch_results = await self._sessions().dispatch_due_prompts()
for result in dispatch_results:
if result.ok:
self.notify(f"Prompt sent to {result.window_name}")
else:
target = result.window_name or f"session {result.session_id}"
self.notify(
f"Prompt failed for {target}: {result.error}",
severity="error",
)
infos = await self._sessions().sync_window_labels()
except ServiceError as err:
log.warning("Session poll failed: %s", err)
return
if self._selected_project_id is not None:
subset = [
i for i in infos if i.session.project_id == self._selected_project_id
]
await self.query_one(SessionList).refresh_sessions(subset)
- Step 6: Add schedule and cancel actions
At the end of HqtApp, add:
def action_schedule_prompt(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
if sid is None:
self.notify("Select a session first", severity="warning")
return
def on_dismiss(result) -> None:
if result is None:
return
prompt, delay = result
due_at = datetime.now() + delay
try:
self._sessions().schedule_prompt(sid, prompt, due_at)
except ServiceError as err:
self.notify(str(err), severity="error")
return
self.notify(f"Prompt scheduled for session {sid} at {due_at:%H:%M}")
self._run_service_worker(self._refresh_sessions())
self.push_screen(SchedulePromptScreen(), on_dismiss)
def action_cancel_scheduled_prompt(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
if sid is None:
self.notify("Select a session first", severity="warning")
return
try:
self._sessions().cancel_scheduled_prompt(sid)
except ServiceError as err:
self.notify(str(err), severity="error")
return
self.notify(f"Scheduled prompt canceled for session {sid}")
self._run_service_worker(self._refresh_sessions())
- Step 7: Run TUI action tests and verify they pass
Run:
uv run pytest tests/test_tui.py::test_prompt_later_binding_exists tests/test_tui.py::test_schedule_prompt_action_calls_service_and_refreshes tests/test_tui.py::test_cancel_scheduled_prompt_action_calls_service_and_refreshes tests/test_tui.py::test_poll_sessions_dispatches_due_prompts_and_notifies -q
Expected: PASS.
- Step 8: Run all TUI tests
Run:
uv run pytest tests/test_tui.py -q
Expected: PASS.
- Step 9: Commit
git add src/hqt/tui/app.py tests/test_tui.py
git commit -m "feat: wire delayed prompt TUI actions"
Task 8: Final Integration Verification
Files:
-
No planned source changes. Modify only files touched in earlier tasks when verification exposes a specific failure.
-
Step 1: Run focused test suite
Run:
uv run pytest tests/test_db.py tests/test_tmux.py tests/test_sessions.py tests/test_tui.py -q
Expected: PASS.
- Step 2: Run full test suite
Run:
uv run pytest -q
Expected: PASS.
- Step 3: Run required project checks
Run:
uv run ruff format src tests
uv run ruff check src tests
uv run ty check
Expected:
-
ruff format: completes with either "files left unchanged" or formatted files. -
ruff check: "All checks passed!" -
ty check: no errors. -
Step 4: Inspect final diff
Run:
git status --short
git diff --stat
Expected: only intentional source/test files are modified. No unrelated files.
- Step 5: Commit verification fixes when verification changed files
If Step 3 formatted files or Step 1/2 required small fixes, commit them:
git add src tests
git commit -m "test: verify delayed prompt injection"
If no files changed after verification, skip this commit.
Self-Review
- Spec coverage: the plan covers persisted state, one prompt per session, TUI-only runtime,
pschedule andPcancel bindings, row visualization, poll dispatch notifications, auto-resume without attach/select, literal tmux injection, failure marking, and no automatic retries. - Scope control: CLI scheduling, background dispatch while hqt is closed, absolute timestamps, recurring prompts, multiple queued prompts, prompt history, and harness-specific templates are not implemented.
- Type consistency:
ScheduledPrompt,SessionInfo.scheduled_prompt,DispatchResult,schedule_prompt(),cancel_scheduled_prompt(),get_scheduled_prompt(),dispatch_due_prompts(),SchedulePromptScreen, andparse_delay()are introduced before later tasks reference them.