deepseek-harness-tui
English | 简体中文
Interactive terminal UI for deepseek-harness,
installed as a dsh profile bundle. It renders into the terminal's main screen —
no alternate screen, so the conversation stays in the scrollback after you leave
— and runs in the same process as the agent it drives.
dsh TUI demo: launch, ask, watch the agent read a file and answer
Highlights
- A transcript built for agents — streaming answers, tool cards with live
output, and a one-row collapse for runs of read-only calls (
Thought for 8s, searched for 3 patterns, read 2 files); Ctrl+O reopens the cards.
- Steer while it runs — the editor stays live under a running turn: Enter
steers, Esc or Ctrl+C cancels, and queued prompts are handed back on cancel.
A prompt the agent has not read yet says so (
Steering · pending), the prompt
row counts what is still queued, and /status lists it. Up on an empty prompt
hands the newest queued message back for editing; sending it again puts it at
the back of the queue, so pressing Up again walks the whole queue. /config
turns Enter into "queue for the next turn" instead, and Ctrl+Enter takes the
other branch for one prompt (see
Steering and queueing).
- Modes on one key — Shift+Tab cycles normal → auto-accept → plan; the
badges above the prompt and
/permission / /plan always agree, because the
key writes through the same services.
- Sessions are durable —
/resume picks up any past session, /rewind
forks back to an earlier prompt with the original left intact, /search
greps everything the session holds.
- Models and providers, in place —
/model picks a route and reasoning
effort (per session or as the saved default); /login stores a key checked
against the endpoint before it lands in the credential store.
- A terminal that adapts — light/dark/no-color themes previewed live,
English/Chinese chrome (
/lang), rebindable keys, @ file references
honoring .gitignore via fd.
- Scriptable —
--print runs one task with no UI and answers on stdout,
under the same flags, model, and preset as the interactive run.
A turn as it streams: the tool card the agent's read call renders, the
thinking that follows it, the answer arriving token by token — and the editor
still live underneath, waiting for a steer (Enter) or a cancel (Esc):
A streaming turn: tool card, thinking, partial answer, steer hint
Quick start
The dsh CLI must be installed
first — this package is a dsh plugin, not a standalone program:
npm install -g @deepseek-ai/dsh
dsh plugin --profile tui add deepseek-harness-tui
dsh --profile tui
The plugin command installs the package into a new tui profile (dsh-base +
deepseek-harness-tui) and activates it on next launch.
The welcome screen: banner, model route, workspace, and loaded plugins
Usage
dsh --profile tui # start the interactive TUI
dsh --profile tui "fix the failing test" # start and send an initial prompt
dsh --profile tui --continue # resume the most recent session
dsh --profile tui --resume <sessionId> # resume a specific session
dsh --profile tui --preset code # start on the "code" agent preset
dsh --profile tui -m deepseek-official/deepseek-v4-flash # override the model
dsh --profile tui --print "run the tests" # one task, one answer on stdout
| Flag | Action |
|---|
-m, --model <provider/model> | model selection for this run |
--preset <id> | agent preset a fresh session is composed from; a resumed session keeps the preset its own log records |
-r, --resume <sessionId> | resume a session by id |
-c, --continue | continue the most recent session in this workspace |
-p, --print <task> | run one task with no UI: the answer goes to stdout, the exit code is 0 only for a completed turn, and tool approvals are pinned to never because there is nobody to ask |
-h, --help | show this help |
[prompt...] | initial prompt, sent once the UI is up |
Both stdin and stdout must be TTYs; the bundle refuses to start on a pipe.
--print is the exception — it renders nothing, so it runs on a pipe, which is
the only place it is useful. Every other flag means the same thing with it:
--print runs against a --resumed or --continued session as readily as
against a fresh one, under the model and preset the rest of the command line
selects.
Keys
Every key below is what the terminal actually binds, at its default. ? at an
empty prompt, /hotkeys, and /help print the same list, generated from the
keybinding registry, so a deployment that moves a key sees the new one named on
all three.
| Key | Action |
|---|
| Enter | send; while a turn runs it steers or queues, whichever /config says (see Steering and queueing) |
| Ctrl+Enter | send with the opposite busy-Enter behavior for this one prompt: queue when Enter would steer, steer when Enter would queue. Needs a terminal that distinguishes it — under the Kitty keyboard protocol or xterm's modifyOtherKeys it arrives as its own key, and everywhere else it reaches the process as a plain Enter and sends normally |
| Shift+Enter / Alt+Enter / Ctrl+J | newline; a \ before Enter does the same, for terminals that cannot send Shift+Enter |
| Up / Down | prompt history from the first row of the prompt, cursor movement below it; on an empty prompt with messages still queued, Up takes the newest one back into the editor to edit |
| Tab | accept a completion |
@ | reference a file |
/ | run a command; /skill:<name> loads a skill |
? | shortcut help, on an empty prompt; never typed into the draft |
| Ctrl+R | search the prompt history backwards; it outlives the process in $DSH_HOME/history.jsonl, which DSH_SKIP_PROMPT_HISTORY=1 stops writing (see Prompt history) |
| Ctrl+G | search this session's messages; Ctrl+F stays the editor's forward-char |
| Shift+Tab | cycle mode: normal → auto-accept → plan → normal. normal and auto-accept are the workspace-write and auto-accept permission presets (same sandbox, approval asked or not); plan is plan mode, which the cycle enters on workspace-write. danger-full-access is not a rung — it is reached with /permission, and a session already on it keeps it while the key moves plan mode alone |
| Ctrl+N | expand or collapse the plan; Ctrl+Y stays the editor's kill-ring paste |
| Ctrl+O | cycle tool cards: preview, full, hidden |
| Ctrl+T | show or hide thinking blocks — off, thinking streams and goes with the step that wrote it; on, every step keeps it, history included. The model reasons either way; turns the key off with it |
While a surface holds the keyboard
| Surface | Keys |
|---|
Panel (/help, /hotkeys, /palette, /status, /mcp, /doctor, /subagents, /jobs) | Up/Down scroll · PgUp/PgDn page · g/G or Home/End top or bottom · Esc or Ctrl+C close |
| Question | Up/Down move · 1-9 answer straight away · Space toggle (multi-select) · "Type something." row for a custom answer · PgUp/PgDn page long detail · Enter submit · Esc or Ctrl+C cancel |
| Permission prompt | Up/Down move · a digit answers the row it numbers · Enter confirm · Esc or Ctrl+C deny. A fifth row appears only when the grant can be remembered (for a shell it opens the command rule to edit) |
| History search (Ctrl+R) | type to match · Ctrl+R steps to an older match · Tab or Esc accepts into the editor · Enter sends it · Ctrl+C or an emptied query restores the draft |
Session search (/search, Ctrl+G) | type to filter · Up/Down move · PgUp/PgDn page · Enter opens the message · Esc leaves the message, then clears the query, then closes |
Model picker (/model) | type to filter · Up/Down move · Left/Right or Shift+Tab adjust reasoning effort · Enter save as default · Ctrl+S use for this session only · Esc clears the filter, then closes |
Resume picker (/resume) | type to search · Up/Down move · PgUp/PgDn page · Tab switches between this workspace and all · Enter resume · Esc clears the search, then closes |
Rewind (/rewind) | Up/Down move · PgUp/PgDn page · Home/End first or last · Enter go back to that prompt · Esc close |
Plugins (/plugins) | type to filter · Up/Down move · PgUp/PgDn page · Enter expand one entry · Esc close |
Skills (/skills) | type to filter · Up/Down move · PgUp/PgDn page · Enter read one skill (Up/Down scroll · g/G or Home/End top or bottom) · Esc leaves the skill, then clears the filter, then closes |
Settings (/config) | Up/Down move · Enter flips a switch, steps a choice, or opens a submenu · Left/Right step a choice · Esc close |
Theme picker (/theme) | Up/Down preview each theme on the screen behind it · Enter keep it · Esc restore the one you opened on |
| Provider sign-in (, ) |
Ctrl+C is the one key that is never rebindable: it is how a terminal is always
left. Every other binding is configurable — see keybindings below.
Commands
| Command | Action |
|---|
/help | keyboard shortcuts and commands |
/hotkeys | the keyboard shortcuts alone |
/model [[provider/]model] | switch the model and save it as your default; without an argument it opens the picker, which can also pick for this session only |
/preset [<preset> | copy <preset> <new-id>] | show, switch, or copy this session's agent preset |
/config | this terminal's own settings — the Ctrl+T thinking pin, the tool-card phase a session opens on, what Enter does while a turn runs, theme — changed in place and saved for the next session |
/theme [auto|light|dark|no-color] | the palette this terminal paints with; without an argument it opens the picker |
/login [provider] | give a provider an API key: pick a configured route or one the adapter offers, paste the key, and it is checked against the endpoint before being stored. The key goes to the credential store; settings record only the variable name |
/provider [add] | list configured providers and the ones /login can configure; add walks through name, endpoint, protocol, key, and the models the endpoint reports |
/copy [N] | copy an answer to the system clipboard; without an argument the last one, /copy 2 the one before it |
/editor | edit the current prompt in $EDITOR; the terminal is released while the editor runs and repainted when it exits |
/new | start a blank session in this workspace; the current one keeps its history and stays resumable |
/clear | clear the transcript view; the session log is unchanged |
/rename [name] | name this session yourself; the name is pinned and stops the automatic titler. Without an argument the title is regenerated and stays automatic |
/compact | compact older conversation history into one summary; the conversation stays on screen and the model keeps the summary. Takes no arguments |
/lang [en|zh] | show or switch the interface language; the choice is remembered for the next session |
Those are this bundle's own commands. Whatever else the profile mounts registers
its own on top of them, and /help lists what the running session actually has.
/details is retired. It packed two unrelated switches into one argument syntax
— [collapsed|expanded|hidden] [reasoning [on|off]] — that had to be memorized
to be used, and it remembered neither past the process. The two things it did
belong in different places: the tool-card phase is what Ctrl+O cycles on the
spot, and the thinking display is a standing preference. Both are rows in
/config now, beside the theme row that opens /theme. Its
detailsDialogWidth was renamed settingsDialogWidth, and its argument
completions are /theme's four values.
What /config and /theme change applies at once and is written to the tui
section of the harness's own settings document ($DSH_HOME/settings.yaml),
through the same optional settings service /model saves a default model
with. Every /config row reads its value live, so Ctrl+O pressed while the
panel is open moves the tool-card row under it. A host that never mounts that
service keeps every switch working for the session and simply forgets it at
exit.
/lang switches this terminal's own chrome — the command list, the panels
(/help, /status, /config, /search, /skills, /subagents, /jobs,
/mcp, /doctor, /plugins), the prompt and status rows, the dialogs and their buttons, and the
notices those surfaces write — between English (the default) and Chinese; the
conversation itself is never translated. A few command answers are still
English-only whatever the language is: /model, /preset and /resume print
their own report text, and the turn-outcome notices the transcript folds
("Turn cancelled.", "The model reached its output-token limit.") come from the
session log rather than from the message table.
The choice is written to the Host's locale settings section when a settings
provider owns one, which is the same preference the web client reads, and to
$DSH_HOME/tui-locale.json (~/.dsh/tui-locale.json) otherwise.
Steering and queueing
The editor stays live under a running turn, so Enter always sends. What sending
means while the agent is busy is the /config row Enter while running:
- Steer the current turn (the default) hands the prompt to the running
driver, which reads it at its next step boundary — the answer in flight is
interrupted by it. The echo above the prompt is badged
Steering.
- Queue for the next turn parks it instead: the turn in flight finishes
undisturbed and the prompt opens the turn after it. Its echo is badged
Queued until the agent picks it up, and then it is an ordinary prompt row,
because that is what it became.
Ctrl+Enter sends one prompt the other way without moving the setting. It needs
a terminal that can tell it apart from Enter — the Kitty keyboard protocol or
xterm's modifyOtherKeys; elsewhere Ctrl+Enter physically sends a bare \r,
which nothing can distinguish, so it simply sends normally.
Either way the prompt is queued rather than answered, and everything the queue
offers applies to both: the prompt row counts it (2 queued), /status lists
it with the boundary that holds it, Up on an empty prompt takes it back for
editing (see Prompt history), and Esc or Ctrl+C hands the
text back to the editor when the turn is cancelled. The choice is stored per
user in $DSH_HOME/settings.yaml; the harness's web chat keeps its own copy of
the same choice and defaults it to queueing, while this terminal defaults to
steering, which is what it has always done.
Prompt history
Up and Ctrl+R reach the prompts typed before this process started. Every
submitted prompt — and a draft cleared with Esc or Ctrl+C, which is a prompt you
may want back, queue handed back by a cancel included — is appended to $DSH_HOME/history.jsonl (~/.dsh/history.jsonl) and
read at mount: newest first, this session's own before any other session's, and
only the ones typed in this workspace. A prompt over 1,024 characters keeps a
200-character preview on its line and its body moves to history-cache/ beside
the file; both are written 0600. Past 1 MB the file is compacted once to its
newest 1,000 entries, and a body nothing points at is deleted a week later.
Up has a second job while a turn runs. On an empty prompt, with messages still
queued and no completion menu open, it takes the newest queued message out of
the agent's inbox and back into the editor — the echo above the prompt goes with
it, and the badge counts one fewer. From there it is an ordinary draft: Enter
sends it again, which puts it at the back of the queue, and Esc throws it away
into this same history. Editing an older one is sending this one back and
pressing Up again, which walks the whole queue. Any of the three conditions
missing and Up means what it always meant, so a draft in progress is never
overwritten and an empty queue navigates history as before.
It is the prompt as sent, in plain text: a key or a customer name pasted at the
prompt is on disk until the file is. Overlay fields are not prompts and are
never recorded — /login never puts an API key there. Export
DSH_SKIP_PROMPT_HISTORY=1 (or true/yes/on) to stop the writing while
keeping the reading, and delete history.jsonl and history-cache/ under
$DSH_HOME to drop what is already recorded.
Context pressure
${context} on the prompt row reports how much of the model's window this
session has used — 78% context, dim — until the window gets tight. From 25%
left it reports the other number instead, 22% context left in yellow, and
turns red at 10% or less. One reading paints the row, so the two numbers never
disagree and only one of them is ever on screen.
The transcript gets one row per band as well, because a user reading a long
answer is not watching the prompt row: Context low — 25% of the window left
in yellow, and Context nearly full in red under 10%. Each band writes at most
one row, and re-arms only after the reading comes back clear of the threshold
it crossed (3 points above it), so a token count that revises itself across the
edge cannot repeat a warning you already have. The row names /compact when
this session's preset composes a compaction service and /new when it composes
none — and while a turn is running it says to compact once the turn ends, since
/compact needs an idle session.
Both thresholds are constants, not configuration: 25% is deliberately above the
20% at which @deepseek-ai/dsh-compaction-basic compacts on its own under its
shipped defaults, so the yellow row is the last moment to choose /compact
yourself, and red means the automatic path is absent, disabled, or failing.
Permission grants
Row 2 of the permission prompt ("don't ask again … this session") is remembered
in memory and forgotten when the window closes. Row 5 ("don't ask again … in
this project") is written to $DSH_HOME/approvals.json (~/.dsh/approvals.json)
and still holds after a restart or a /resume:
{
"version": 1,
"projects": {
"/home/you/code/app": {
"allow": ["edit", "bash(npm run:*)", "bash(git status)", "edit [danger-full-access]"]
}
}
}
Rules are keyed by the workspace the session was opened in, so a grant given in
one repository is never spent in another, and nothing is written into the
repository itself. A rule is either a bare tool name (edit — every ask about
that tool is granted), a command prefix (bash(npm run:*) — npm run build
and npm run test, but never npm run-evil), or one exact command
(bash(git status)). Commands are compared word by word, so the spacing a model
happens to use never decides whether a rule fires. A rule never covers a line
that runs more than one command: anything carrying ;, &&, |, a redirect, a
backquote, a parenthesis or a newline is asked about even when its first command
is allowed. A command that names a working directory outside the project is
asked about too, and the prompt says which directory that is. To revoke a grant,
delete its entry from the file — everything else under projects, including
keys this build does not write, is left alone.
A trailing [mode] is the sandbox access the grant was given at. Hosts widen a
refused call by asking again for more permission ("escalate sandbox to
danger-full-access: …"), and a rule only ever answers asks of its own kind: one
saved while widening to workspace-write leaves a later danger-full-access
request to you, and a rule saved for an ordinary call answers no escalation at
all. The answer row says the access it would stop asking about.
A shell asks about the command it is about to run, so row 5 offers a rule
instead of a blanket grant: the prompt pre-fills it (npm run build becomes
npm run:*, git status becomes git status:*), Enter saves it, and
editing it first is the point — trim it to npm:* to cover more, or to
npm run build alone to cover exactly this one. Emptying the box allows this
call and stores nothing. There is deliberately no "allow every shell command in
this project" row, and a command no rule could ever match — a compound line, or
a bare sudo/env/bash -c wrapper — leaves the prompt with its first four
answers. So does a request the terminal could not look behind, such as a
background command or a call whose arguments will not parse: a permanent grant
is offered on what the prompt could show, never on a tool name alone.
A tool that writes files shows the change it would make inside the prompt: the
file's path and the old→new hunks, taken from the pending call's own arguments
(nothing is read from disk). Long changes are clipped to keep the answers on
screen at any terminal width — the preview is budgeted in the rows it will
actually occupy, wrapped lines and extra files included — and the marker says
how many rows are not shown, with the tool's card in the transcript carrying the
whole diff once the call runs. A change past maxDiffEditLength is shown as a
whole-file replacement and labelled as approximate; a call the terminal cannot
present at all asks the plain question.
@ file references
@ lists the workspace through fd when the host has it (fd, or fdfind on
Debian and Ubuntu), so completion honors .gitignore, .ignore, and
.fdignore. Without it a built-in walker takes over and skips build output by
name — .git, node_modules, dist, build, out, coverage, .cache,
.next, .nuxt, .turbo, .venv, __pycache__, target — and withholds
*.log and *.tsbuildinfo from a query that named no extension. Set
fileSearchCommand to pin the binary's path, or to "" to always use the
walker, and fileSearchExcludedDirectories to change what the walker skips.
Commands complete their arguments too: /model offers every advertised
provider/model, /preset the roster's presets and the copy verb, /theme
its four values, and /resume this workspace's recent sessions.
Surfaces
- Chat — the main view: streaming messages, tool cards, the plan, the status
row, and the prompt with its context line. A run of consecutive read-only
calls — reads, greps, globs,
ls/cat-shaped shell commands, MCP queries —
reports as one row (Thought for 8s, searched for 3 patterns, read 2 files)
instead of one card each; Ctrl+O opens the run back into its cards. A call
that writes never joins one — cat a > b writes b, whatever its verb says —
while a call that fails stays in the run and turns its bullet red, because a
failure the reader cannot see is worse than a row that admits one. Each
fragment of that row is a whole phrase per language rather than a verb and a
noun joined at render time, so Chinese picks its own word order, measure
words, and comma.
- Thinking on that row — the run reports the thinking next to it as its
first clause (
Thinking for 12s, read 2 files…), counting up against the
clock while the model is still thinking. It is the only thinking duration a
default transcript states: the thinking block itself keeps its own rule and
goes with the step that wrote it (Ctrl+T pins it, Ctrl+O expanded brings it
back). Every clause carries its own tense — the files are read while the
thought is still running — and the row a duration appeared on is the row that
keeps it, so a thought that ends in an answer rather than in another tool call
settles in place instead of vanishing off the screen. Until the run's first
call names a file, a pattern or a command, the ⎿ line under the row shows
the newest line of the thinking; showReasoning: false keeps that line off
the row like everywhere else, and the duration — which quotes nothing —
stays.
- Workflow runs — one
workflow tool call folds into a run / phase / member
block: the run's name and member count, one header per phase, and one row per
member with its status and elapsed time. Which levels are on screen follows
the run's state rather than a toggle — a phase holding anything that is not
completed keeps its member rows, and a run whose members all completed recedes
to a single row Ctrl+O opens again. A member with no phase and a member with
an empty phase name are two different groups, because they are two different
things in the log. A run whose turn ended without a result reads as
interrupted, together with the members that never settled: nothing is coming
for them, and a row still claiming to be running would say the opposite.
- Rewind —
/rewind, or a double Esc at an empty prompt: go back to an
earlier prompt. With a host that can fork the session the conversation moves
with it and the original stays resumable; otherwise the prompt comes back to
the editor alone. Files are never restored — dsh keeps no file checkpoints.
Configuration
Values on the bundle row (tui-runner), all optional.
| Key | Default | Meaning |
|---|
welcome | — | extra dim line under the startup banner; with no key at all the wordmark sweeps in instead |
sessionId | main | shared agent/session identity this terminal drives |
initialSkill | — | skill auto-invoked as the session's first turn, as if /skill:<name> were typed; set by a launcher, not by a person |
initialDraft | — | text the editor opens with, unsent; set by a rewind handoff |
experimentalCommands | false | register the developer commands (/reload today) |
showReasoning | true | may this transcript render reasoning text at all; false hides the blocks in every phase, keeps the model's words off a collapsed row's ⎿ hint (the duration stays), and turns both Ctrl+T and the /config Thinking display row off |
markdownRenderer | claude | claude (this bundle's renderer) or pi (pi-tui's Markdown); a claude render that throws falls back to pi for the rest of the process |
maxToolOutputLines | 6 | body lines kept in a collapsed tool card's head/tail preview |
maxDiffEditLength | 1000 | added and removed lines explored while deriving an exact line diff |
maxPromptChars | 10000 | characters one submitted prompt may carry; the middle of a longer one is dropped with a ... [N characters truncated] ... marker and a notice, 0 sends every prompt whole |
maxQuestionOptions | 8 | options visible at once in a question panel |
maxModelOptions | 8 | models visible at once in the model selector |
maxResumeOptions |
Prompt templates interpolate ${name} against the values this bundle registers
— cwd, git/worktree, model, context, token_meter/cache_hit_rate,
goal, queued, jobs, symbol, indicator — and a separator next to a
value that is currently unavailable is dropped with it. context reports used or
remaining depending on how full the window is; see
Context pressure.
Bindings other than Ctrl+C are configurable: set keybindings on the bundle row
({ "app.history.search": "alt+r" }), keyed by action id and valued with one
pi-tui key id or several. This bundle's ids are app.mode.cycle,
app.tools.cycle, app.history.search, app.transcript.search,
app.todos.toggle, app.thinking.toggle, app.message.copy,
app.screen.redraw, app.cancel, and app.exit; pi-tui's own editor bindings
can be moved the same way. Shift+Ctrl+D reports what each of those ids resolved
to, any key two actions both claim, and any key an app.* action takes off
pi-tui's editor — which is the first thing to suspect when a key "does nothing".
Two keys are deliberately not what a reader might expect, because an app.*
binding is answered before the editor sees the key: search is Ctrl+G rather than
Ctrl+F (pi-tui's tui.editor.cursorRight), and the plan toggle is Ctrl+N rather
than Ctrl+Y (pi-tui's tui.editor.yank). Rebinding either onto the editor's key
takes the editor's habit away for good.
Shift+Tab is safe to take for the mode cycle for the same reason: pi-tui binds
tab and nothing else in that family, and its editor recognises no Shift+Tab of
its own. The /model picker's Shift+Tab (step the reasoning effort) is a
different scope and keeps working — while any overlay owns the screen the app's
listener returns before its first branch, so the dialog sees the key first.
The cycle itself writes nothing of its own: normal and auto-accept are
selected through ctx.permissionPresets (the auto-accept entry is added to
the table by this bundle's cordis.patch.yml) and plan mode through
ctx.planMode, so /permission, /plan, a resumed log, and the key all report
the same state. A mode that is on says so above the prompt — ⏸ plan mode on,
⏵⏵ auto-accept on — with the key that cycles it named beside the badge. Both
badges can be up at once, reached with /permission auto-accept and /plan
rather than with the key; the hint then rides the last one alone, because one
key repeated on two stacked rows reads as two keys to press. A deployment that
composes no preset table, or no plan mode, keeps the rungs it has: the key
cycles what is mounted, and says so once there is nothing left to cycle.
Development
pnpm install
pnpm typecheck
pnpm test
pnpm build
dsh plugin --profile tui add link:./path/to/dsh-tui # live-link for development
dsh --profile tui
Set experimentalCommands: true on the tui-runner row to get /reload while
editing config files.
The plugin is two Cordis plugins in one bundle:
dsh-tui/startup — parses the TUI's own command line and provides the
tuiStartup service.
dsh-tui — the runner: owns the pi-tui render loop, the in-process agent
session, the approval answerer, and the user-question provider.
Data flows one way. Events from the dsh bus (session/event, agent/status)
are folded by a per-session read model into an immutable node list; a keyed
reconciler turns that list into pi-tui components, reusing every node whose
version it has already applied, so a burst of stream chunks repaints one
assistant step rather than the transcript. The TUI runs in-process and calls
ctx.agents / ctx.approval / ctx.commands directly — no SDK, no ACP
transport, and no React.
License
MIT