Skip to content

feat(pi): TUI launch mode + persona/model forwarding#182

Open
TheReaperJay wants to merge 8 commits into
Cotal-AI:feat/land-connector-pifrom
TheReaperJay:feat/pi-tui-mode
Open

feat(pi): TUI launch mode + persona/model forwarding#182
TheReaperJay wants to merge 8 commits into
Cotal-AI:feat/land-connector-pifrom
TheReaperJay:feat/pi-tui-mode

Conversation

@TheReaperJay

@TheReaperJay TheReaperJay commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

feat(pi): TUI launch mode + persona/model forwarding

Stacked on #126 — merge #126 first, then retarget this PR's base to main.

This adds a robust persona/model forwarding for an interactive TUI mode, allowing each terminal to be watched and manually interacted with as opposed to just the default headless behavior. This is useful for micromanaging agents, or for when the user wants more control or oversight of what is produced. These do not overwrite the existing #126 but instead extend the available control options.

Any pi extension that uses ctx.ui is now able to be natively surfaced in Cotal, allowing for wider compatibility of workflows and easier onboarding for devs.

The original headless mode from #126 is not edited at all, the TUI mode is a synergistic extension.

It also introduces a robust fix for model and prompt injection that was overlooked in the original PR.

Usage

Using this is very simple, just update your agent with the peerMode:

   ---                                                                                                                                                                                             
   name: worker                                                                                                                                                                                    
   role: builder                                                                                                                                                                                   
   allowPublish: [builds]                                                                                                                                                                          
   peerMode: headless    ## Run headless as usual                                                                                                                                                                          
   ---                                                                                                                                                                                             
   Runs unattended; no UI gates.                                                                                                                                                                   
  ---                                                                                                                                                                                             
  name: helper                                                                                                                                                                                    
  role: pair                                                                                                                                                                                      
  allowPublish: [general]                                                                                                                                                                         
  peerMode: tui    ## Run with interactive TUI                                                                                                                                                                               
  ---                                                                                                                                                                                             
  Edit-approval gates render live in your tmux pane.                                                                                                                                              

You can also run it ad hoc without a configuration change:

   cotal up --detach                                # mesh + delivery + manager (no env needed here for a foreground spawn)                                                                        
   PI_PEER_MODE=tui cotal spawn pi1      # foreground TUI pi peer in THIS terminal, assuming "pi1" is a pi agent
   cotal console                                                                                                                                                                                   
   cotal down                                                                                                                                                                                      

If you do not activate TUI mode, it simply falls back to using headless Pi, making the original PR the default behavior.

What

Two additions to the @cotal-ai/pi connector introduced in #126, both closing gaps where a spawned pi peer ran as a generic pi instead of its declared persona/model, with no operator-answerable surface.

1. TUI launch mode (PI_PEER_MODE=tui)

The headless peer in #126 embeds pi in-process and never binds a UI context, so ctx.hasUI is false and any installed extension that calls ctx.ui.* (approval gates, prompts, selects, editors) silently no-ops. This adds a second entry point that acts as a host process: it spawns stock pi --mode rpc as a child and bridges to it over the NDJSON stdin/stdout protocol pi already exposes, rendering every ctx.ui.* call live in the peer's own tmux pane. Any pi extension becomes operator-answerable per-pane (an edit-approval gate is the canonical use case). Zero coupling to @earendil-works/pi-tui — the renderer uses Node builtins only.

Three Esc behaviors: in-dialog = cancel, while streaming = {type:"abort"}, idle = no-op. Persistent raw mode so Esc is captured by the keypress handler rather than eaten by cooked-mode line discipline. The connector uses node (not tsx) for built dist/ so cotal stop's SIGTERM reaches the peer and triggers graceful shutdown + cooked-mode restore.

2. Persona + model forwarding (both paths)

The pi connector was the lone holdout among the harness connectors — claude-code, opencode, and hermes all translate AgentDef.persona → system prompt and AgentDef.model → the host's --model. Now buildLaunch is the single parse point (loadAgentFile at launch → fail-loud on a malformed persona; cotal start --model beats the file's model:); it forwards only the short resolved model string (COTAL_MODEL), and each runtime path reads the persona body from the already-forwarded COTAL_AGENT_FILE:

  • TUI: body → 0600 tempfile → --append-system-prompt <path> (pi reads the file via resolvePromptInput, so a large persona never hits an argv ARG_MAX ceiling); --model COTAL_MODEL; tempfile unlinked on every exit path.
  • Headless: DefaultResourceLoader({appendSystemPrompt:[body]}) (pi's in-process equivalent, preserving extension/skill/theme loading) + a resolveModel helper on public ModelRegistry.getAvailable() / Model.provider / Model.id, mirroring pi's internal findExactModelReferenceMatch contract. Throws on not-found/ambiguous — no fuzzy, no silent fallback.

Intentional asymmetry, documented in code: TUI accepts fuzzy model patterns (pi's CLI resolves them); headless requires an exact reference and throws on fuzzy/ambiguous (a declarative persona model: pin should be exact; pi doesn't export a public fuzzy resolver).

docs/agent-frameworks.md is updated in the same change — corrects the now-stale "pi has no permission gate" and "launches via tsx" lines from #126, and adds the persona/model forwarding.

Verification

End-to-end through the real supervisor→connector→spawned-peer path: a haiku persona produces haiku replies in the TUI pane and lands in the headless session's system prompt; the pinned model is forwarded (COTAL_MODEL in the child env) and honored by pi; a bogus or ambiguous model makes headless throw at startup (no fallback to pi's default); persona tempfiles cleaned on peer exit; Esc aborts a streaming turn; SIGTERM (cotal stop) triggers graceful shutdown + cooked-mode restore; pnpm smoke:ci green; pnpm typecheck + pnpm build clean; no @earendil-works/pi-tui coupling; no deep import of pi internals.

Commits

  • feat(pi): PI_PEER_MODE dispatch seam (tui/headless)
  • feat(pi): rpc bridge + child spawn for the TUI launch mode
  • feat(pi): add TUI launch mode for operator-answerable mesh peers
  • feat(pi): forward persona + model into spawned pi peers (TUI + headless)
  • feat(pi): read peerMode from agent file frontmatter]

Lanzelot1 and others added 8 commits June 26, 2026 13:58
Embeds MeshAgent in a pi createAgentSession() and drives the loop off the
inbox via InboxTurn (ack-on-surface; mid-turn steer). Adds the shared
connector-core InboxTurn + ackInbox the embed adapters build on, an
examples/03-pi manager, and docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
context, so ctx.hasUI is false and any installed extension that calls
ctx.ui.\* silently no-ops. To enable a variant where the agent's UI calls
become answerable from the pane the peer runs in, the connector gains a
second entry that acts as a host process: it spawns stock `pi --mode rpc`
as a child and bridges to it over the NDJSON stdin/stdout protocol pi
already exposes.

This commit lands the host's spawn + protocol layer, dispatched through
the PI_PEER_MODE flag already wired in main.ts. The child is unmodified
upstream pi, so installed extensions load exactly as they do in a normal
pi session. API keys reach the child via launchEnv({ providerKeys })
rather than inherited wholesale, matching the connector's existing
chokepoint. rpc-frames.ts carries the wire shapes (AgentSessionEvent,
command acks, extension_ui_request) without importing pi's internal
rpc-types module.

This is incremental on purpose: the host only spawns a stock rpc child
and logs the frames (a test prompt round-trips through agent_start and
the full message lifecycle), with mesh driving and the dialog renderer
to follow. Locking the wire layer first keeps the next commits scoped
to a single concern each.
PI_PEER_MODE=tui makes a mesh-driven pi peer host a live renderer in its
own tmux pane, so ctx.ui.* extension calls (confirm / select / editor /
notify) from the agent become operator-answerable per-pane instead of
silently denied by the edit guard's hasUI check.

The TUI host (tui-client) is a frame-loop state machine that spawns a pi
rpc child, drives the mesh inbox turn-by-turn, and renders to a
dependency-free terminal renderer (tui-render — Node builtins only, no
@earendil-works/pi-tui coupling). It routes every ctx.ui method through
the rpc bridge, handles Esc three ways (in-dialog cancel, streaming
abort, idle no-op), and on prompt-preflight failure abandons the turn
(so JetStream redelivers once auth is restored), surfaces a visible
"waiting/misconfigured" status, and halts the pump.

Connector fix: run the built dist with node directly instead of tsx.
The manager's pty runtime sends SIGTERM on `cotal stop`; a tsx wrapper
does not forward that signal to its node child, so the peer was
SIGKILLed after the grace window without running its shutdown handler
(no mesh disconnect, no raw-mode restore). node receives SIGTERM
directly.

Verified end-to-end through the real path (supervisor → connector →
spawned peer → renderer → edit guard):

- inbound DMs drive the agent and stream line-buffered text into the pane
- edit / write / bash tool calls surface confirm dialogs answerable via
  the pane stdin (y approves and the edit runs, n blocks it, Esc cancels)
- Esc while the agent is streaming sends an abort and interrupts the turn
- killing the rpc child tears the peer host down (presence drops)
- `cotal stop` now runs the graceful shutdown path (renderer offline +
  raw mode restored) instead of SIGKILLing the peer
- a reply addressed to a live persistent peer is delivered (no "no
  peer" error when the sender is present), confirming the round-trip
- headless mode (PI_PEER_MODE unset) is unchanged

The preflight-fail handler (abandon + halt) guards OAuth credentials
that expire mid-session; it is not reachable through normal startup
because pi resolves --model only to providers with configured auth and
falls back to a scoped model otherwise, and zero configured models
makes pi exit before rpc mode starts.
Both runtime paths ignored the agent file's persona body and pinned model —
the pi connector was the lone holdout among the harness connectors (claude-code,
opencode, and hermes all translate AgentDef.persona → system prompt and
AgentDef.model → the host's --model via loadAgentFile). A `cotal start --agent pi
--name dave` produced a peer that ran as a generic pi, ignoring dave's persona
and any pinned model. This closes both paths with one shared parse point and
per-path translation.

buildLaunch (connector.ts) is the single parse point: it calls loadAgentFile at
launch (so a malformed persona fails loud at `cotal start`, matching the
siblings) and resolves `opts.model ?? def.model` (`cotal start --model` beats the
file's `model:`). It forwards only the short resolved model string (COTAL_MODEL);
the persona body never rides in env (ARG_MAX ceiling on spawn) — each runtime
path reads it from the already-forwarded COTAL_AGENT_FILE.

TUI (tui-client.ts): writes the frontmatter-stripped body to a 0600 tempfile and
passes `--append-system-prompt <path>` — pi reads the file contents (its
resolvePromptInput: existsSync → readFileSync), so only the short path is in
argv and there is no ARG_MAX ceiling for a large persona. Passes `--model
COTAL_MODEL`. The tempfile is unlinked on every exit path (child exit, SIGINT/
SIGTERM shutdown, process exit).

Headless (peer.ts): hands the body to DefaultResourceLoader({appendSystemPrompt:
[body]}) — pi's in-process equivalent of `--append-system-prompt`, constructed
with the same defaults createAgentSession would have used so extension/skill/
theme loading is preserved. Resolves the model via a small resolveModel helper
on public ModelRegistry.getAvailable() + Model.provider/Model.id, mirroring pi's
internal findExactModelReferenceMatch contract (canonical provider/id → split
provider+id → unique bare id, reject ambiguous). Throws on not-found/ambiguous —
no fuzzy matching and no silent fallback.

Intentional asymmetry, documented in code: TUI accepts fuzzy model patterns
(pi's CLI resolves them); headless requires an exact reference and throws on
fuzzy/ambiguous. A declarative persona `model:` pin should be an exact reference;
fuzzy is an interactive-CLI convenience, and pi does not export a public fuzzy
resolver (resolveCliModel is internal and CLI-coupled). If pi later exports one,
resolveModel can swap to it.

docs/agent-frameworks.md: corrected the now-stale "pi has no permission gate, so
spawned peers run unattended" (false since the TUI launch mode made ctx.ui.*
operator-answerable per-pane) and "launches the peer via tsx" (now node for built
dist, so SIGTERM from `cotal stop` reaches the peer) lines, and added the
persona-body→system-prompt + COTAL_MODEL forwarding.

Verified end-to-end through the real supervisor→connector→spawned-peer path: a
haiku persona produces haiku replies in the TUI pane and lands in the headless
session's system prompt; the pinned model is forwarded (COTAL_MODEL in the
child env) and honored by pi; a bogus or ambiguous model makes headless throw at
startup (no fallback to pi's default); persona tempfiles are cleaned up on peer
exit; pnpm smoke:ci green; no @earendil-works/pi-tui render coupling; no deep
import of pi internals.
The pi connector's launch mode (TUI vs headless) was only settable via
the PI_PEER_MODE env var, forcing the operator to type it on every
spawn. Add a persistent per-agent knob: an unmodelled `peerMode:`
frontmatter key swept into AgentDef.meta — the same passthrough the
OpenCode connector uses for `face:` — read by buildLaunch alongside
the model it already parses from the agent file.

PI_PEER_MODE env still wins as the operator one-off override and is
forwarded verbatim (the peer validates it); only "tui" is forwarded,
since "headless" or unset leaves the var absent and the peer defaults
to headless (main.ts). An invalid file value fails loud at buildLaunch,
naming the agent file, rather than silently degrading.

Core is untouched — AgentDef.meta exists precisely so a connector can
read its own launcher hints without core knowing each one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants