Skip to content

Interactive Agent Sessions (HITL)

Paseka supports two ways to run a bee:

Mode CLI Use case
AFK (one-shot) paseka bee run <role> --body "…" Background work; runtime waits for exit, normalizes summary, and may auto-publish INSIGHT/run.summary
Interactive (session) paseka bee chat <role> "…" Human-in-the-loop dialogue in one long-lived agent process

Interactive sessions are a parallel runtime path. They do not change the existing Adapter.Run() contract used for AFK runs.

Honey reserve: paseka invite accept consumes 1 honey from the trace reserve (energy.consume / session.start). Ad-hoc paseka bee chat does not consume honey. Top up with paseka energy add --trace <id> --amount <n> when accept fails with honey exhausted.

See also: architecture overview (adapters, runs layout), Brief (HITL paradigm).


1. Architecture

flowchart LR
  user[User] --> queenShell[Queen Shell]
  queenShell --> sessionManager[Session Manager]
  sessionManager --> sessionAdapter[Session Adapter]
  sessionAdapter --> pty[PTY Process]
  pty --> externalAgent[External Agent]
  sessionManager --> runDir[Run Directory]
  ghostty[Ghostty UI] --> queenShell
  queenConsole[Queen Console] --> sessionManager

Core rules:

  • Adapter.Run() stays synchronous and non-interactive (agent -p for Cursor).
  • SessionAdapter only describes how to launch the external tool; the runtime owns the PTY, lifecycle, transcript, and status files.
  • Ghostty is an optional attach UI, not the session primitive. Terminal preferences live in machine-local config, not in committed .paseka/.

Packages:

Package Role
internal/adapters SessionAdapter, SessionRequest, SessionHandle
internal/adapters/cursor Interactive agent invocation (no -p)
internal/adapters/pi Interactive pi invocation (no -p, run-local session storage)
internal/adapters/claude Interactive claude invocation (no -p)
internal/adapters/opencode Interactive opencode TUI invocation (no run)
internal/sessions PTY process, attach, registry, Ghostty launcher
internal/runs session.json, transcript.ndjson
internal/colony Session registry in state.json, terminal.yaml

2. Session adapter interface

type SessionAdapter interface {
    Name() string
    SessionCommand(req SessionRequest) (SessionCommand, error)
}

SessionCommand is a plain exec spec: binary, args, env, working directory. The session manager starts it inside a PTY.

AFK adapters keep the existing interface:

type Adapter interface {
    Name() string
    Run(ctx context.Context, req RunRequest) (*RunResult, error)
}

3. Run directory layout

Interactive sessions reuse .paseka/runs/<traceId>/<agentId>/ under the colony root (same as AFK runs). Additional files:

.paseka/runs/<traceId>/<agentId>/
├── prompt.txt
├── system.txt          # optional — rendered system_template
├── summary.md
├── meta.json
├── status.json
├── request.json
├── events.ndjson
├── session.json        # live session metadata (PID, state, workspace)
└── transcript.ndjson   # NDJSON log of user/agent/system turns
File Purpose
session.json sessionId, traceId, pid, state (active → completed / failed / cancelled), optional providerSessionId, optional usage
transcript.ndjson Audit trail for dialogue (role: user | agent | system)

sessionId equals agentId in the MVP.

Token usage: a TUI reports nothing machine-readable on stdout, so after the process exits the runtime asks the adapter for the session's spend through the optional SessionUsageResolver seam and stores it as usage on session.json (paseka inspect and trace totals read it from there, since an interactive run has no result.json). Pi sums its run-scoped session file (source pi.session-jsonl); OpenCode asks a throwaway opencode serve for the session's cumulative tokens (source opencode.server-json). Adapters that cannot report usage leave the field absent, and an unreachable provider never fails teardown.


4. Session registry

Active sessions are mirrored in machine-local state:

~/.config/paseka/<slug>/state.json
{
  "sessions": [
    {
      "sessionId": "a1b2c3d4",
      "traceId": "trace-…",
      "agentId": "a1b2c3d4",
      "runDir": "/path/to/repo/.paseka/runs/trace-…/a1b2c3d4",
      "bee": "scout",
      "pid": 12345,
      "startedAt": "2026-07-05T09:00:00Z"
    }
  ]
}

Entries are removed when the session exits. paseka session stop can signal the recorded PID if the session is not owned by the current shell process.


5. Queen Shell commands

Start a chat

# Interactive session in the current terminal
paseka bee chat scout "help me design the auth flow"

# Task via template instead of positional prompt
paseka bee chat builder --body "add retry logic to the NATS client"

# Reuse a trace (e.g. continue work in the same worktree)
paseka bee chat builder --trace trace-abc123 --body "finish the PR"

Session management

paseka session list              # sessions from state.json
paseka session attach <sessionId>  # attach PTY in this process only
paseka session stop <sessionId>    # stop local or remote (by PID)

session attach only works for sessions started in the same paseka process. For a separate window, use Ghostty (below) or start the session there directly.

AFK vs chat

paseka bee run scout --body "survey the repo"   # non-interactive, exits when done
paseka bee chat scout "let's discuss the repo"  # interactive PTY session

6. Terminal configuration (Ghostty)

Terminal UI choice is machine-local:

~/.config/paseka/<slug>/terminal.yaml
terminal: ghostty       # default | ghostty
ghostty_binary: ghostty # optional override

CLI override:

paseka bee chat scout "hello" --terminal ghostty

Behavior:

terminal What happens
default (or unset) Session runs and attaches in the current terminal
ghostty Opens a Ghostty window running paseka session run <role> … — the full session lives in that window

Ghostty does not own the agent process in the default path: Paseka starts the PTY and attaches stdin/stdout. With Ghostty, the child paseka session run process owns the PTY inside the new window.

If Ghostty is not installed, set terminal: default or omit terminal.yaml.


7. Cursor adapter (interactive)

Input Maps to agent
command (optional) full argv; overrides params mapping (see architecture overview)
Workspace --workspace <path>
SystemPrompt + InitialPrompt merged into one positional prompt (newline-separated)
InitialPrompt task/kickoff portion before glue (no -p)
params.model --model <id>
params.yolo (unless params.plan) --force
params.plan --plan
API key CURSOR_API_KEY or --api-key from home config
Provider chat New HITL: agent create-chat before PTY start; UUID stored as providerSessionId; TUI launched with --resume <uuid>. Resume: skip create-chat; --resume the stored id; new Paseka sessionId / run dir; optional continue line as the only positional prompt.

Interactive invocation:

agent create-chat   # UUID → session.json / meta.json providerSessionId
agent --force \
  --workspace "$WORKSPACE" \
  --model composer-2.5 \
  --resume "$PROVIDER_SESSION_ID"
# positional prompt when system_template and/or task are set:
# "$PROMPT"   # system + task, newline-separated

command: overrides do not call create-chat or inject --resume. If the custom argv already has --resume, that value is stored as providerSessionId. If create-chat fails, the TUI still starts without a pointer.

Resume (Queen Console session detail, or paseka session resume <sessionId>) is a separate path from a new bee chat / POST /api/sessions. It is eligible for a finished Cursor or OpenCode HITL session with a stored providerSessionId and a bee that still resolves to the same adapter without a command: override (Pi, Claude, and script sources are not resumable; the API returns not_resumable). A registry row whose PID is no longer alive does not block Resume. The continuation is a new Paseka session on the same Flight Trail; session.json records resumedFrom. Honey is not charged. The Console transcript of the new session starts empty — provider history appears inside the TUI, not as NDJSON.

When system_template is set and no task/prompt is given, a new session starts without a positional prompt and waits for user input. Resume with no continue line also omits the positional prompt.

Worktrees: if the bee has worktree: true, the session cwd is .paseka/worktrees/<traceId>/ (same as bee run). Git branch follows the same ensure/rename rules as AFK: latest INSIGHT/worktree.branch, else paseka/<traceId>.


8. Pi adapter (interactive)

Bees with adapter: pi launch the Pi CLI in its normal interactive UI (no -p, no --mode).

Input Maps to pi
command (optional) full argv; overrides params mapping (see architecture overview)
Workspace process cwd
SystemPrompt --append-system-prompt <system.txt> (file path)
InitialPrompt positional prompt when provided
params.model --model <pattern>; a vendor/model[:suffix] id splits at the first / into --provider <vendor> --model <rest>
params.reasoning --thinking <level>
params.plan --plan
params.binary CLI binary name (default pi)
API key api_key_env from ~/.config/paseka/<slug>/adapters/pi.yaml → --api-key
agentId --session-id <agentId>
run directory --session-dir <runDir>/pi-sessions

Interactive invocation:

pi --session-dir "$RUN_DIR/pi-sessions" \
  --session-id "$AGENT_ID" \
  --append-system-prompt "$RUN_DIR/system.txt" \
  --model gemini-2.5-pro \
  --provider google
# optional: "$PROMPT"

Pi session artifacts stay under .paseka/runs/<traceId>/<agentId>/pi-sessions/, tied to the current agentId. That same agentId is stored as providerSessionId on session.json and meta.json at launch (or --session-id from a command: override when present).

Event publishing boundary: interactive Pi output is not parsed into domain bus events. Use paseka event emit --stdin during the session when the bee prompt requires bus events. Prefer --defer for end-of-run handoffs; runtime flushes the pending queue on successful session end (same policy as AFK). Flush loads machine-local home config (~/.config/paseka/<slug>/config.yaml) for the NATS URL — a session must not treat the bus as unset when only ColonyRoot is in memory.


9. Claude adapter (interactive)

Bees with adapter: claude launch the Claude Code CLI in its normal interactive TUI (no -p).

Input Maps to claude
command (optional) full argv; overrides params mapping (see architecture overview)
Workspace process cwd
SystemPrompt --append-system-prompt-file <system.txt> (file path, not inline)
InitialPrompt positional prompt when provided
params.model --model <id>
params.plan --permission-mode plan
params.binary CLI binary name (default claude)
API key ANTHROPIC_API_KEY or api_key_env from ~/.config/paseka/<slug>/adapters/claude.yaml

Interactive invocation:

claude --append-system-prompt-file "$RUN_DIR/system.txt" \
  --model claude-opus-4-8
# optional positional kickoff when task/prompt provided:
# "$PROMPT"

When system_template is set and no task/prompt is given, the session starts without a positional prompt and waits for user input in the TUI.


10. OpenCode adapter (interactive)

Bees with adapter: opencode launch the OpenCode TUI (no run subcommand).

Input Maps to opencode
command (optional) full argv; overrides params mapping (see architecture overview)
Workspace process cwd (TUI has no --dir; that flag is run/attach only)
SystemPrompt + InitialPrompt delivered as the first turn through the TUI's loopback control server (not --prompt, which the TUI ignores with --session)
params.model --model <id> (OpenCode consumes a native provider/model id); also sent with the first turn
params.plan --agent plan; also sent with the first turn
params.reasoning --variant; also sent with the first turn
params.binary CLI binary name (default opencode)
Auth opencode auth login / provider env / project .env
Provider chat pre-created before the TUI via opencode serve + POST /session (ses_*); stored as providerSessionId; TUI launched with --session <ses_*> --port <ephemeral> --hostname 127.0.0.1

Interactive invocation:

# pre-create (no model turn, no cost): opencode serve + POST /session -> ses_*
OPENCODE_SERVER_PASSWORD=... opencode \
  --session "$PROVIDER_SESSION_ID" \
  --port "$CONTROL_PORT" --hostname 127.0.0.1
# then: POST http://127.0.0.1:$CONTROL_PORT/session/$PROVIDER_SESSION_ID/prompt_async
#       {"parts":[{"type":"text","text":"$PROMPT"}]}

--auto, --format, --title, and --dir are AFK run-only. Permission prompts stay in the TUI. The PTY process cwd is the workspace.

Pre-create is the OpenCode analog of Cursor's create-chat: Paseka starts a short-lived opencode serve on a local ephemeral port with a generated OPENCODE_SERVER_PASSWORD, POSTs /session with the workspace, reads the ses_* id, and stops the server (the session is durable in OpenCode's store). If serve is unavailable or the call fails, the TUI still launches without --session and providerSessionId stays empty; the kickoff is then passed as --prompt, which the TUI's home route creates and submits (no stored id) — a best-effort policy with no hard failure.

The OpenCode TUI auto-submits --prompt only on its home route; with --session it opens directly on the session and ignores --prompt. Paseka therefore starts the TUI with its own loopback control server (--port + --hostname 127.0.0.1 + OPENCODE_SERVER_PASSWORD) and posts the first turn to /session/<ses_*>/prompt_async. This keeps a deterministic providerSessionId and puts the kickoff in the right chat. Delivery is best-effort and logged: if the control server never answers, the TUI is already open and the operator can type.

Resume (Queen Console session detail, or paseka session resume <sessionId>) is eligible for a finished Cursor or OpenCode HITL session with a stored providerSessionId and a bee that still resolves to the same adapter without a command: override. OpenCode skips pre-create and launches with --session <ses_*>; the optional continue line is delivered as the first new turn through the control server. Cursor keeps --resume <uuid>. Pi, Claude, and script sessions stay ineligible. session.json on the new session records resumedFrom; honey is not charged, and the Console transcript starts empty.

Event publishing boundary: interactive OpenCode output is not parsed into domain bus events. Use paseka event emit --stdin during the session when the bee prompt requires bus events.


11. Lifecycle

paseka bee chat <role> [prompt]
        │
        ▼
  Resolve colony + bee config
        │
        ▼
  Optional worktree for traceId
        │
        ▼
  Render templates → prompt.txt (and system.txt when system_template is set)
        │
        ▼
  SessionAdapter.SessionCommand()
        │
        ▼
  Start PTY process → write session.json (active)
        │
        ▼
  Register in state.json
        │
        ▼
  Attach terminal (current or Ghostty)
        │
        ▼
  User dialogues with agent in one process
        │
        ▼
  On exit → update session.json, status.json, transcript
        │
        ▼
  post_exec hook (when configured)
        │
        ▼
  Proposal finalization (successful sessions only) → proposals/<agentId>.{patch,json}
        │
        ▼
  Unregister from state.json

11a. Proposal finalization

A successfully completed session runs the same proposal gate, diff collector, patch writer, and publisher as an AFK run, so interactive work gets the same proposal history. It is not a call through the AFK dispatcher: the baseline is captured before the PTY starts (beside the comb baseline), and finalization runs after post_exec, so the captured diff describes the workspace the hook left behind.

  • Only successful sessions. A failed or cancelled session never reaches the preparation gate, so partial interactive work is not presented as a completed proposal attempt.
  • Resume is a new run. Continuing a provider session mints a fresh Paseka agentId and captures a fresh baseline, so the earlier session's patch record is left intact.
  • Explicit proposals suppress synthesis. A session that already emitted or queued a code.proposal* is passed through unchanged, so a Guard is never dispatched twice for one proposal.
  • Bus absence is honest. Without a reachable broker the record is retained as unpublished; the session cannot silently claim publication.
  • Diagnostics land in the run outcome. A collection, storage, or publication error is written to status.json as a proposal-finalization note, readable through the normal session/run surfaces, and a successful agent process is not described as a successfully published proposal when finalization failed.

The record lives at .paseka/runs/<traceId>/proposals/<agentId>.{patch,json} — the trail's proposal store, not the comb. It is what the diff collector returned, with the collector's documented limits: not a strict per-run delta, not a workspace backup, and not proof of the code a Guard approved. See Spec 039.


12. MVP limitations and next steps

Topic MVP Later
Cross-process attach session attach same process only; use Ghostty for new window Unix socket relay for sessions started outside paseka console
Queen Console PTY WebSocket relay for sessions owned by current paseka console (GET /api/sessions/:id/pty); launch uses interactive agent TUI (no -p); Sessions UI has a Widen/Restore control for full-page terminal layout Cross-process browser attach
SessionAdapter.Send Full PTY passthrough; no separate send API Structured message API for web UI
Bee YAML mode: interactive Use bee chat explicitly Optional default per bee
NATS events File-based audit only Publish session lifecycle to bus

13. Decisions (locked)

Topic Decision
Session vs AFK Separate SessionAdapter; do not overload Adapter.Run()
Session ID Same as agentId for MVP
Provider session Cursor HITL: new chat uses create-chat + --resume; Resume reuses the stored providerSessionId without create-chat. OpenCode HITL: pre-created via opencode serve + POST /session (ses_*); TUI launched with --session and the kickoff delivered via the TUI control server (prompt_async); Resume reuses the stored id without pre-create. Pi HITL: pinned --session-id. Stored as providerSessionId; never overwrites Paseka sessionId.
Run dir .paseka/runs/<traceId>/<agentId>/ — shared with AFK IPC
Terminal config ~/.config/paseka/<slug>/terminal.yaml — not committed
Ghostty Optional UI; session run runs full session inside Ghostty window