feat(pi): TUI launch mode + persona/model forwarding#182
Open
TheReaperJay wants to merge 8 commits into
Open
Conversation
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.
This was referenced Jul 4, 2026
30b8774 to
f5699de
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
You can also run it ad hoc without a configuration change:
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/piconnector 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.hasUIisfalseand any installed extension that callsctx.ui.*(approval gates, prompts, selects, editors) silently no-ops. This adds a second entry point that acts as a host process: it spawns stockpi --mode rpcas a child and bridges to it over the NDJSON stdin/stdout protocol pi already exposes, rendering everyctx.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 usesnode(nottsx) for builtdist/socotal 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 andAgentDef.model→ the host's--model. NowbuildLaunchis the single parse point (loadAgentFileat launch → fail-loud on a malformed persona;cotal start --modelbeats the file'smodel:); it forwards only the short resolved model string (COTAL_MODEL), and each runtime path reads the persona body from the already-forwardedCOTAL_AGENT_FILE:--append-system-prompt <path>(pi reads the file viaresolvePromptInput, so a large persona never hits an argv ARG_MAX ceiling);--model COTAL_MODEL; tempfile unlinked on every exit path.DefaultResourceLoader({appendSystemPrompt:[body]})(pi's in-process equivalent, preserving extension/skill/theme loading) + aresolveModelhelper on publicModelRegistry.getAvailable()/Model.provider/Model.id, mirroring pi's internalfindExactModelReferenceMatchcontract. 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.mdis 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_MODELin 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:cigreen;pnpm typecheck+pnpm buildclean; no@earendil-works/pi-tuicoupling; 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 modefeat(pi): add TUI launch mode for operator-answerable mesh peersfeat(pi): forward persona + model into spawned pi peers (TUI + headless)feat(pi): read peerMode from agent file frontmatter]