A terminal coding agent you drive from your shell. juno streams a live LLM turn into an Ink transcript, lets the model call workspace-jailed tools, and gates every risky call behind an interactive permission prompt — built ground-up in TypeScript + React, no build step.
juno v0.1.0
Claude Fable 5 (subscription) · ~/src/acme-api
/ commands · ? shortcuts
▌ add a rate-limit guard to the login handler
● I'll add a token-bucket check before the handler runs, then
wire it into the router.
⚙ edit_file src/routes/auth.ts
╭─ ⚠ permission required ───────────────────────────╮
│ edit_file risk: risky │
│ │
│ + import { rateLimit } from './mw/limit' │
│ + router.post('/login', limit(5,'1m'), │
│ - loginHandler); │
│ + loginHandler); │
│ │
│ [y] allow [a] always [d] deny [!] bypass │
╰────────────────────────────────────────────────────╯
Claude Fable 5 (subscription) · ~/src/acme-api · ctx 48.5k (5%) · medium
(Illustration of the streaming transcript and a live permission prompt — the box is rendered by Ink at runtime, colorized by risk tier.)
juno is a from-scratch reimplementation of a terminal AI coding agent — the loop
that takes a user message, streams a model's thinking and tool calls into a live
TUI, runs those tools against your files under an explicit permission gate, feeds
the results back, and repeats until the turn ends. It is a single-runtime
TypeScript + React application rendered with Ink: .ts/.tsx run directly
under tsx on Node 22+ — no Python, no
compile step, no cross-language surface.
The engineering worth looking at:
- A frozen event seam. Every model backend translates its wire format into
one normalized
AgentEventdiscriminated union (src/core/events.ts); a pureeventToActionmaps each event 1:1 onto a reducer action (src/core/reducer.ts). The UI never sees a provider-specific shape — swapping backends changes nothing downstream. - A turn coordinator (
src/agent/turnRunner.ts) that drives one submission to completion, loops ontool_use, runs each call through an executor that owns the permission round-trip, and re-enters the results — with an abort path that always settles parked permission prompts so nothing hangs. - A workspace-jail sandbox (
src/tools/fileTools.ts): every file path isrealpath-resolved against the working directory and any..escape, absolute-out-of-root path, or symlink pointing outside is rejected before the syscall. - Per-tool risk classification (
safe/risky/dangerous) with a headless, pure policy (src/permissions/policy.ts): reads auto-allow, writes prompt, the shell is always prompted,denybeatsallow, and adangerouscall can never be satisfied by an ordinary remembered rule. - A multi-backend model layer behind one
ModelClientinterface (src/providers/) — HTTP APIs plus Claude and Codex subscription CLI transports normalized to the same stream (see below). - MCP integration (
src/services/mcpManager.ts) — external Model Context Protocol servers discovered at startup, their tools surfaced through the same risk gate. - A privacy-by-default stance: the OpenRouter transport tags every request
with a no-train directive (
data_collection: 'deny'), and the subscription backend runs render-only with shell, network, and sub-agent tools hard-denied.
- Multi-provider model layer. One
ModelClientseam, four transport families:- Anthropic Messages API — streaming SSE against
/v1/messages(anthropicClient.ts). - OpenAI-compatible / OpenRouter — chat-completions streaming, OpenRouter
carrying the no-train directive
(
openaiCompatClient.ts). - Subscription CLI seam — spawns the
claudeCLI headless (claude -p --output-format stream-json) on the logged-in Max subscription (no API key), translating its NDJSON into the sameAgentEventstream, with server-side session reuse across turns (claudeCliClient.ts). - Codex subscription seam — spawns
codex exec --json, preserves resumable thread ids, classifies stalls and context overflows, and can bridge Juno-managed subagents and MCP tools under the same permission policy (codexCliClient.ts).
- Anthropic Messages API — streaming SSE against
- Streaming TUI. Assistant text and extended-thinking stream token-by-token;
finished turns commit into an Ink
<Static>region so they are never redrawn. A model picker, slash-command palette, and a responsive status strip (model, cwd, context-window gauge, effort, cost) round it out. - Observatory orchestration workspace. Press Down from an empty composer or
run
/agentsto enter a dedicated alternate-screen surface: a responsive agent rail beside the selected agent's ordered prose, reasoning, tool, steering, permission, and lifecycle stream. Narrow terminals use a one-pane drill-in; available steer, cancel, and permission actions appear only when the live runner can actually perform them. - Risk-tiered tool approval. An interactive prompt shows the tool, its risk
tint, and a colorized unified diff for file writes;
y/a/ddecide,!is an explicit dangerous bypass, and always-allow patterns are remembered. - Tool suite. Five workspace-jailed file tools (
read_file,list_files,grep,write_file,edit_file), an on-demand skill loader, a depth-limitedspawn_subagent, adangerous-tierrun_shell, a preset-bound parent-onlyrun_verification, a bounded session-memory tier, and any MCP server tools — assembled per session. - Sessions & resume. Committed turns persist to
~/.config/juno/sessions/(JSON snapshot + append-only JSONL log); a/resumepalette lists past sessions newest-first and rehydrates the transcript. - Durable bounded background agents. Detached children persist task state and write-through output, queue FIFO above the concurrency cap, surface elapsed and last-activity timing, and reconcile unfinished work honestly after a restart.
provider adapters normalized stream UI
───────────────── ───────────────── ──
anthropicClient ┐
openaiCompat ├─► AgentEvent ─► turnRunner ─► reducer ─► Ink <Static>
claudeCliClient ┘ (events.ts) (loops on (state) transcript
mcp tools ───────┘ tool_use) ─► executor ─► permission gate
src/providers/— the model backends + registry.src/core/— theAgentEventunion, reducer, and selectors.src/agent/— the turn coordinator and event bus.src/tools/+src/permissions/— tools, the executor, and the permission policy.src/ui/— the Ink components (transcript, status line, permission prompt, palettes).docs/ARCHITECTURE.md·docs/SECURITY.md·docs/DECISIONS.md— the deeper write-ups.
Requires Node.js 22+ (the package is ESM-only).
npm install
npm start # launch the TUI (tsx src/cli.ts)
npm run dev # launch with file-watch reload
npm start -- --cwd ./my-project # pin tools and providers to one project root--help / --version go through tsx directly:
npx tsx src/cli.ts --helpThe default backend reuses a logged-in claude CLI session and needs no API
key. Codex subscription models require an installed, logged-in codex CLI.
HTTP transports read ANTHROPIC_API_KEY, OPENAI_API_KEY, or
OPENROUTER_API_KEY at call time. Pick a model in the TUI or with
JUNO_MODEL=<id> npm start.
Interactive launches open with a short, skippable ASCII orbit sequence after
startup is genuinely ready. Press any key to enter immediately, or set
JUNO_NO_INTRO=1 to disable it.
Launch from the project directory you want Juno to control, or pass --cwd
explicitly. The path is canonicalized before startup and becomes the shared root
for providers, native tools, agents, hooks, and verification. Choosing a broad
directory such as ~/src intentionally places its sibling projects in scope.
Codex can reach Juno's managed process sessions and structured verification
through the in-process MCP bridge. Because codex exec is headless, it cannot
pause an MCP call for Juno's interactive permission overlay. Safe calls continue
to follow the normal policy; calls that would prompt fail closed unless their
exact tool name is explicitly preauthorized:
JUNO_CODEX_BRIDGE_ALLOW=start_process,poll_process,write_process_stdin,terminate_process,run_verification \
juno --cwd ./my-projectThe allowlist accepts exact names only—unknown names and wildcards are ignored—and
never overrides a configured Juno deny rule. Grant only the capabilities needed
for the session. JUNO_CODEX_SPAWN_BRIDGE=1 separately enables Juno subagents for
a Codex parent; a managed-tool grant does not silently enable delegation.
Settings resolve built-in defaults → ~/.config/juno/config.json →
environment variables (last wins). External MCP servers are registered under
mcpServers, keyed by id; each tool's risk is classified per tool so only tools
you deliberately mark safe are auto-allowed:
{
"defaultModel": "claude-fable-5",
"backgroundAgentMaxConcurrent": 3,
"backgroundAgentTimeoutMs": 1800000,
"codexIdleTimeoutMs": 180000,
"codexStaleStreamMs": 300000,
"mcpServers": {
"docs": {
"command": ["my-docs-mcp", "--stdio"],
"toolRisk": { "search_docs": "safe", "get_doc": "safe" }
}
}
}Here mcp__docs__search_docs and mcp__docs__get_doc auto-allow (reads), while
any write tool the server exposes falls through to the prompt-gated default.
Extra background agents remain visibly queued until a slot opens; an executing
child that exceeds its wall-clock limit is aborted and settled as an error. The
Codex values are silence guards, not per-item command deadlines. Corresponding
env overrides are JUNO_BACKGROUND_AGENT_MAX_CONCURRENT,
JUNO_BACKGROUND_AGENT_TIMEOUT_MS, JUNO_CODEX_IDLE_TIMEOUT_MS, and
JUNO_CODEX_STALE_STREAM_MS; the common overrides remain JUNO_PROVIDER,
JUNO_MODEL, JUNO_CWD, and JUNO_MAX_CONTEXT.
maxToolCalls is an optional per-turn iteration guard. It is intentionally
unset by default because subscription and raw-API workloads have different
budgets; set a positive integer in config.json or
JUNO_MAX_TOOL_CALLS=<n> when a hard tool-loop ceiling is appropriate.
Session tracing is deliberately off by default. Enable it with "trace": true
in config.json or JUNO_TRACE=1. Juno then writes versioned NDJSON under
~/.config/juno/traces/, at the single reducer dispatch funnel. Each line carries
a monotonic sequence, timestamp, session id, turn id, and the exact action shape
accepted by the reducer.
Raw user prompts are replaced with their character count. Tool arguments/results
are depth-, collection-, and string-bounded, and secret-looking keys are redacted;
resumed transcripts are not copied into traces. Model output remains diagnostic
content but is string-bounded, so treat the directory as private user data.
Serialization and append I/O run behind a bounded asynchronous queue; tracing is
fail-soft and never changes a turn outcome. Graceful shutdown and session changes
flush/close their recorder. Startup retains the 20 newest .ndjson files (including
the new session); hard process termination can leave only the final line incomplete,
which the line-oriented reader reports without hiding other records.
replayTraceFile / replayTraceNdjson in src/services/sessionTrace.ts provide the
first executable replay seam: records are validated and folded through the pure
reducer. Issues are classified as trace (NDJSON/envelope/ordering), action
(unknown action version), or reducer (application failure). Full provider/tool
selftest playback is intentionally follow-up work; this seam replays state evolution
without performing model calls, tools, permissions, or terminal rendering.
npm test# vitest, run once
npm run typecheck # tsc --noEmit (strict)
npm run verify:polish # 16 responsive Observatory frames + focused tests + real-PTY selftestThe suite covers the reducer, permission policy, provider adapters, workspace jail,
Ink components, and end-to-end PTY behavior. The polish gate writes inspectable
frames and a machine-readable report under .polish/; the PTY lane writes its real
terminal framebuffer and scrollback evidence under .selftest/.
juno was produced by an autonomous multi-agent build system — a "forge" that planned the port, decomposed it into sealed seams, and had agents implement, test, and review each one against a frozen contract. The tight event/reducer seams and the exhaustively-tested contracts throughout are a direct consequence of that build discipline.
Active development. The architecture and test coverage are mature; the surface is
still moving (new providers and tools land regularly), and the version reflects
that (0.1.0).
MIT © 2026 Aiden Angel