Declarative description of an interactive process so ripple can drive it over a PTY with the same runtime. Covers shells (pwsh, bash, zsh, cmd, fish, nu…) and REPLs (python, node, clojure, ghci, sbcl, iex…) under one contract.
Status: draft. Not frozen. Expect breaking changes until schema: 1 is
stamped on a shipped ripple release.
- Declarative, not procedural. Every adapter is data. The runtime knows how to drive a PTY; the adapter just tells it what strings to send and what patterns to look for.
- Marker-first prompt detection. Regex-based prompt matching is a fallback.
The primary strategy is to inject a unique marker string (OSC 633 for shells,
\u0001RIPPLE\u0001for REPLs) at startup so the runtime can locate prompt boundaries without risking false positives from command output. - Exec-form commands only.
process.command_templateis expanded with named placeholders. Shell-interpolated strings are forbidden — no quoting holes, no injection. - Versioned. Every adapter declares
schema: 1. Future versions are additive-first; breaking changes bump the schema major. - Testable. Every adapter ships its own contract tests in
tests:. CI runs them against the declared interpreter binary before merge.
| Field | Required | Type | Purpose |
|---|---|---|---|
schema |
yes | int | Schema major version. Currently 1. |
name |
yes | string | Canonical short name (e.g. pwsh, python). Used for adapter lookup. |
version |
yes | semver | Adapter file version (independent of interpreter version). |
description |
yes | string | One-line human description. |
homepage |
no | URL | Upstream project URL. |
license |
no | SPDX | License of the underlying interpreter (not of the YAML). |
family |
yes | enum | shell | repl | debugger. Affects defaults and UI labeling. |
aliases |
no | [string] | Additional names this adapter responds to (e.g. powershell for pwsh). |
process |
yes | object | How to launch the process. See §3. |
ready |
yes | object | How to detect that the process is ready for input. See §4. |
init |
yes | object | Integration injection strategy. See §5. |
prompt |
yes | object | Prompt detection strategy. See §6. |
output |
yes | object | Output post-processing. See §7. |
input |
yes | object | Input delivery strategy. See §8. |
modes |
no | [object] | REPL modes (Julia pkg, SBCL debugger, iex pry). See §9. |
commands |
no | object | Meta-commands / helpers. See §10. |
signals |
yes | object | Signal bytes (interrupt, eof, suspend). See §11. |
lifecycle |
yes | object | Shutdown and restart policy. See §12. |
capabilities |
yes | object | Feature flags. See §13. |
probe |
yes | object | Single sanity-check eval. See §14. |
tests |
yes | [object] | Contract tests run by CI. See §15. |
integration_script |
no | string | Inline script body (alternative to external script_resource). See §5. |
process:
command_template: '"{shell_path}" -NoExit -Command "{init_invocation}"'
executable: dotnet
executable_candidates:
- jdb
- '%JAVA_HOME%\bin\jdb.exe'
- 'C:\Program Files\Eclipse Adoptium\jdk-21\bin\jdb.exe'
- /usr/bin/jdb
inherit_environment: false
env:
POWERSHELL_TELEMETRY_OPTOUT: "1"
encoding: utf-8
line_ending: "\r"command_template— string template with{placeholders}. The runtime substitutes{shell_path}(resolved via PATH),{init_invocation}(expanded frominit),{tempfile_path},{pid},{guid},{temp_dir}as needed.executable— optional single-string override for the launcher binary when the adapter name doesn't match a PATH-discoverable executable. Used byfsi(→dotnet),jshell(→java), etc. The string is env-expanded and PATH-resolved before being substituted into{shell_path}. When present alongsideexecutable_candidates, candidates win.executable_candidates— ordered list of launcher candidates tried left-to-right; the first entry that resolves to an existing file on disk wins. Each entry goes throughEnvironment.ExpandEnvironmentVariablesso Windows-style%VAR%references resolve against the worker's environment before path search. Bare names are resolved via registry PATH + PATHEXT; rooted paths are used as-is after env expansion. Solves the "single absolute path doesn't port across distributions" problem: adapters for interpreters with multiple well-known install locations (Perl's Strawberry / ActivePerl / Git-bundled, Java's Temurin / Corretto / Zulu / Microsoft OpenJDK, Python's python.org / Windows Store / Anaconda) declare every plausible location once and the worker picks whichever is present on the host. Falls back toexecutable, then to the adapter name, when the list is null, empty, or fully unresolvable.inherit_environment— iffalse, the runtime callsCreateEnvironmentBlock(bInherit=false)(Windows) /env -i(Unix) so the child sees only the OS-default environment. pwsh uses this to avoid inheriting MCP server variables; bash/zsh needtrueon Windows because MSYS2/Git Bash requireHOME,PATH,MSYSTEMfrom the parent.env— additional environment variables (merged on top of inherited).encoding— stdin/stdout encoding. Alwaysutf-8in v1.line_ending— bytes to append when writing a line of input. pwsh/cmd use\r(ConPTY cooked-mode translates to CRLF), bash/zsh use\n.
ready:
wait_for_event: prompt_start
timeout_ms: 0
settle_before_inject_ms: 2000
suppress_mirror_during_inject: true
kick_enter_after_ready: true
delay_after_inject_ms: 500
output_settled_min_ms: 2000
output_settled_stable_ms: 1000
output_settled_max_ms: 30000wait_for_event—prompt_start|marker|regex|custom. For shell-integration adapters this is alwaysprompt_start(the first OSC A event). For REPL adapters it's typicallymarker.timeout_ms— Reserved / not consumed by the runtime (as of v0.14.0).WaitForReady(Services/ConsoleWorker.cs) waits on the first prompt signal indefinitely by design — interpreters with cold-start costs (pwsh + PSReadLine + Defender first-scan, slow corporate UNCPSModulePath) can legitimately take many seconds, and a startup timeout would mis-fire on exactly that slow-host population (see issue #9: a reporter setready.timeout_ms: 100believing it was a fix — it does nothing). A non-zero value is silently ignored;0is the only meaningful value. Kept in the schema as a forward-compat placeholder; if a real startup-timeout is ever wired, decide canonical-vs-lifecycle.ready_timeout_msat that time (the two are redundant — see §12).settle_before_inject_ms— quiet period before injecting the integration script. Only meaningful wheninit.delivery: pty_inject.suppress_mirror_during_inject— hide thesourceecho from the visible console during injection.kick_enter_after_ready— send an Enter keystroke once ready to force a fresh prompt redraw (needed for shells whose initial prompt was suppressed by injection).output_settled_{min,stable,max}_ms— tuning knobs for the worker'sWaitForOutputSettleddrain phase.min_msis the absolute minimum wait before polling starts;stable_msis the consecutive quiet window required to declare output settled;max_msis the hard deadline. Defaults 2000 / 1000 / 30000 match the pre-schema hardcoded behavior. Slow-compiler REPLs (Lisp, Haskell) may need to raisemax_ms; fast REPLs can lowermin_msto speed startup.
init:
strategy: shell_integration | marker | prompt_variable | regex | none
hook_type: prompt_function | preexec | ps0 | precommand_lookup_action | debug_trap | custom | none
delivery: launch_command | pty_inject | rc_file | none
script_resource: integration.ps1 # file under ShellIntegration/
# -- OR inline:
# script: |
# $global:__rp_pending ...
init_invocation_template: "..."
tempfile:
prefix: .ripple-integration-
extension: .ps1
banner_injection:
mode: prepend_to_tempfile | write_before_pty | none
banner_template: |
Write-Host '{banner}' -ForegroundColor Green
reason_template: |
Write-Host 'Reason: {reason}' -ForegroundColor DarkYellow
rc_file: # delivery: rc_file
dir_env_var: ZDOTDIR
file_name: .zshrc
marker: # strategy: marker (REPL path)
primary: "\u0001RIPPLE\u0001>>> "
continuation: "\u0001RIPPLE\u0001... "Strategy values determine which sub-fields are relevant:
| Strategy | Who uses it | Required subfields |
|---|---|---|
shell_integration |
pwsh, bash, zsh | script_resource or inline script, hook_type, delivery |
prompt_variable |
cmd | process.prompt_template |
marker |
python, node, ghci, sbcl, iex | marker.primary, optional marker.continuation, optional script |
regex |
REPLs where PS1 can't be replaced | prompt.primary |
none |
trivial processes with no setup | — |
hook_type documents when the OSC C marker (or equivalent
"command-about-to-execute" signal) fires, relative to the command pipeline.
This matters because it determines whether input echo can be cleanly separated
from command output:
prompt_function— only prompt-time hook (cmd, old shells)preexec— zsh, fishps0— bash (reliable since bash 4.4)precommand_lookup_action— pwsh (fires inside the engine before resolution)debug_trap— legacy bash (DEBUGtrap — has subshell-visibility pitfalls)custom— adapter uses a strategy not in this enumnone— no preexec hook available (cmd); use deterministic input-echo stripping
delivery determines how the integration script reaches the shell:
launch_command— passed as part of the process command line (e.g. pwsh's-NoExit -Command ". '{path}'"). Runs before the first prompt.pty_inject— written to PTY stdin after the shell has printed its welcome banner. Used by bash because shell command-line args don't let it source arbitrary scripts silently.rc_file— staged to a per-worker temp directory as a shell-native startup file (e.g.<tmpdir>/.zshrc) and the shell's rc-directory environment variable (rc_file.dir_env_var) is set to that path beforeCreateProcess. The shell sources the file as part of its own startup, so OSC-emitting hooks are live by the time the first prompt is drawn — no PTY write needed. Used by zsh because PTY-injectedsource …bytes get swallowed by ZLE under ConPTY without ever submitting. Requiresrc_file.dir_env_varandrc_file.file_name.none— no external script; integration is entirely declarative (cmd'spromptvariable carries the OSC sequences).
prompt:
strategy: shell_integration | marker | regex
shell_integration:
protocol: osc633
markers:
prompt_start: "\x1b]633;A\x07"
command_input_start: "\x1b]633;B\x07"
command_executed: "\x1b]633;C\x07"
command_finished: "\x1b]633;D\x07"
property_update: "\x1b]633;P\x07"
property_updates:
cwd_key: Cwd
# -- OR for marker strategy:
# primary: '^\u0001RIPPLE\u0001>>> $'
# continuation: '^\u0001RIPPLE\u0001\.\.\. $'
# group_captures:
# - { name: counter, group: 1, type: int, role: monotonic_counter }
# -- OR for regex strategy:
# primary: '^> $'
# continuation: '^>> $'
# continuation_escape: "@\r"OSC 633 event contract (shell_integration strategy):
The runtime guarantees strict event ordering per command:
A → (user typing, or AI write) → B → C → <output> → D;{exit_code} → P;Cwd=... → A
A= prompt rendered, shell ready for inputB= Enter pressed / line submittedC= command about to execute (boundary between input echo and output)D;N= command finished with exit code NP;Cwd=...= property update (currently onlyCwdis defined)
Adapters that cannot emit OSC C (like cmd) must use
output.input_echo_strategy: deterministic_byte_match and accept that the
runtime will strip echo by walking the output stream.
group_captures.role — semantic tag for regex capture groups used by
REPL adapters:
monotonic_counter— IPythonIn [N]:, iexiex(N)>nesting_level— SBCL debug levelN]mode_indicator— irb nesting depth
Regex strategy continuation escape (optional). prompt.continuation +
prompt.continuation_escape together form a best-effort escape hatch for
regex-strategy REPLs that can land in an absorbing continuation state (for
example Lua's >> when an AI command ships an unclosed if … then).
continuation— regex matching the continuation prompt line.continuation_escape— literal bytes to write to the PTY on a match, chosen so the REPL emits a syntax error and returns to its primary prompt. The primary detector then picks that prompt up and the execute_command resolves through the normal path — the captured output carries the REPL's own error message explaining what went wrong, so the AI can see what happened.
Both fields must be set to activate the escape; declaring only one is
treated as a misconfiguration and ignored (with a worker log warning).
Each adapter chooses an escape that is guaranteed invalid in its host
language — Lua uses @\r, other REPLs pick their own. Adapters that do
not declare these fields keep their previous behaviour (the execute's
user timeout is the only recovery).
output:
post_prompt_settle_ms: 150
strip_ansi: false
strip_input_echo: true
input_echo_strategy: osc_boundaries | deterministic_byte_match | fuzzy_byte_match | none
line_ending: "\r\n"
async_interleave:
strategy: redraw_detect | quiesce | accept | none
capture_as: out_of_band | merge | discardpost_prompt_settle_ms— how long to wait afterAfires before declaring the command's output complete. Shells vary: pwsh ~0, bash ~50, cmd ~400 (Format-Table trailing rows, PSReadLine prediction, etc.).strip_ansi— whether to remove non-OSC-633 ANSI escape sequences. For shell adapters we keep them so the visible console stays colorized; for REPL adapters we usually strip them before regex matching.input_echo_strategy— how to separate command input echo from real output:osc_boundaries— use OSC B→C region as echo, C→D as outputdeterministic_byte_match— walk the output matching exact bytes sent to stdin, skipping ConPTY line-wrap CR/LF and any ANSI escape sequences injected by the REPL's own syntax highlighter (cmd, python, sqlite3, lua, jshell — REPLs whose echo is plain or at most color-annotated).fuzzy_byte_match— the byte-match walker plus a one-shot leading prompt-redraw skip, for linenoise-style REPLs (duckdb, psql, …) whose echo opens with "<prompt><input>" because the prompt line is rewritten on every keystroke. The prompt prefix is swallowed using the adapter's ownprompt.primaryregex (with line anchors re-anchored at the scan position), so the walker can start byte-matching on sentInput's first real byte.none— don't strip echo (REPLs where echo is cosmetically acceptable)
async_interleave— how to handle output produced by background concurrency primitives (iex BEAM processes, Python asyncio, Node EventEmitter). Defaultnone; set toredraw_detectfor BEAM-family runtimes.
input:
line_ending: "\n"
multiline_detect: prompt_based | wrapper | balanced_parens | indent_based | none
multiline_delivery: direct | tempfile | heredoc | wrapper | encoded_scriptblock
multiline_wrapper:
open: ":{"
close: ":}"
trigger: auto | always | never
balanced_parens:
open: ['(', '[', '{']
close: [')', ']', '}']
string_delims: ['"']
escape: '\'
line_comment: ';'
block_comment: ['#|', '|#']
char_literal_prefix: '#\' # reader-macro: #\( is not an open paren
datum_comment_prefix: '#;' # reader-macro: #;expr skips next datum
tempfile:
prefix: .ripple-exec-
extension: .ps1
path_template: "{temp_dir}/.ripple-exec-{pid}-{guid}.ps1"
invocation_template: ". '{path}'; Remove-Item '{path}' -ErrorAction SilentlyContinue"
history_filter: '\.ripple-exec-.*\.ps1'
cleanup_on_start: true
stale_ttl_hours: 24
chunk_delay_ms: 0
clear_line: null # opt-in per adapter after empirical verification-
clear_line— byte sequence the worker writes to the PTY right before each AI command payload, to wipe any keystrokes the user may have typed into the current prompt's line-editor buffer. Without it, user bytes sitting in the buffer get prepended to the AI command and submitted as one garbled line. Defaultnull(opt-in per adapter): the obvious-looking"\x01\x0b"(Ctrl-A + Ctrl-K) works against emacs-mode readline / PSReadLine / libedit / JLine, but several shipped REPLs deliberately run without a line editor — PythonPYTHON_BASIC_REPL=1, fsi--readline-, Racket-i, CCL, ABCL — and pass raw bytes straight to the parser, which rejectsU+0001as an invalid non-printable character. Empirical verification per adapter is the only way to know what's safe: walk the adapter in ripple, type into its console window, run execute_command, confirm the clear bytes wipe the buffer without syntax errors, then add the field. Adapters that haven't been verified should leave it null — "nothing written" means user-typed bytes still corrupt the command, but no new corruption is introduced. The primary defense against focus-induced contamination is the SW_SHOWNOACTIVATE spawn flag in ProcessLauncher, which prevents the new console window from stealing focus to begin with. -
multiline_detect— how the runtime decides whether a block of input is still incomplete:prompt_based— send lines one at a time, watch for continuation prompt (Python..., bash>, iex...(N)>)wrapper— wrap the block in open/close markers (ghci:{ ... :})balanced_parens— count syntactic brackets (Lisp family)indent_based— reserved for v2 (Python-style significant indent)none— single-line only; multi-line goes via tempfile
-
multiline_delivery— how a confirmed-complete multi-line block reaches the interpreter:direct— write line-by-line to PTY stdin (bash, zsh, most REPLs)tempfile— write the body to a temp file and dot-source it (cmd's.ripple-exec-*.cmd, any REPL that needs stable multi-line parsing)heredoc— sendcat <<EOF ... EOFconstruct (reserved)wrapper— sendwrapper.open + body + wrapper.close(ghci)encoded_scriptblock— base64-encode the body and send a single-line. ([ScriptBlock]::Create([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('<b64>'))))invocation (pwsh). Dot-sourcing keeps the caller's scope (variables and functions persist across commands) just like the tempfile path, but without disk I/O or history-filter bookkeeping. Dropped back totempfileif the encoded line would exceed PSReadLine's input cap.
-
tempfile.history_filter— regex matched against shell history entries. Lines matching this are hidden from shell history so ripple's implementation detail doesn't pollute the user'sUp-arrowrecall. -
balanced_parens.char_literal_prefix— reader-macro prefix that escapes a single character from bracket counting (Racket's#\, Common Lisp's#\, Scheme's#\). The counter consumes the prefix, the following character, and any trailing identifier characters (so named literals like#\spaceare handled), and does NOT treat embedded brackets inside the literal as syntactic parens. -
balanced_parens.datum_comment_prefix— reader-macro prefix that skips the next balanced datum entirely (Racket / R6RS Scheme#;). The counter tracks pending datum comments and resolves them when the next atom, string, or matching close bracket appears. Multiple#;may stack (#;#;(a)(b)skips two following datums).
modes:
- name: main
primary: "\u0001RIPPLE\u0001iex(?)> "
default: true
- name: pry
auto_enter: true
detect: '^Break reached:'
primary: '^pry\(\d+\)> $'
nested: false
level_capture: null
advance_commands:
- { command: "next", effect: step_over }
- { command: "step", effect: step_in }
- { command: "finish", effect: step_out }
exit_commands:
- { command: "continue", effect: resume }
- { command: "respawn()", effect: return_to_toplevel }
exit_detect: '^\u0001RIPPLE\u0001iex\(\d+\)> $'auto_enter: true— this mode is entered by the REPL itself (e.g. an unhandled exception dropping into a debugger), not by an explicit user keystroke. Runtime must re-check mode on every response.nested: true— this mode can stack on itself (SBCL debugger0] 1] 2]). Requireslevel_capturein the prompt regex.advance_commands— commands that change execution position within this mode without leaving it. After astep_inorstep_over, the debugger is still paused — just at a different source location. AI agents useadvance_commandsto distinguish "I stepped one line but I'm still paused" from "I resumed and left the breakpoint" (exit_commands).step_in— step into the next function callstep_over— execute one source line, stepping over callsstep_out— run until the current function returns to its caller
exit_commands.effect— semantic label for what happens when the exit command is run:return_to_toplevel— unwind all the way to main modeunwind_one_level— pop one level of nestinginvoke_restart— Lisp restart invocationresume— continue execution from where the mode was entered
commands:
prefix: ":" # ":" for ghci/SBCL, "" for iex (helpers are plain calls)
scope: [main, debugger] # subset of modes where commands are valid
discovery: ":help" # command to list all available commands
builtin:
- { name: type, syntax: ":type {expr}", description: Show type of expression }
- { name: load, syntax: ":load {file}", description: Load a source file }
debugger:
step_in: "s"
step_over: "n"
step_out: "r"
continue: "c"
run: null
print: "p {expr}"
dump: "x {expr}"
backtrace: "T"
source_list: "l"
locals: "V"
where: "."
args: "p @_"
breakpoint_set: "b {target}"
breakpoint_set_line: "b {line}"
breakpoint_list: "L"
breakpoint_clear_all: "B *"This is a hint-only section. The runtime uses it to populate LLM tool descriptions; it doesn't enforce or parse the commands itself.
debugger — structured command vocabulary for family: debugger
adapters. Each field is a command template string with {expr},
{target}, {line}, {file} placeholders, or null when the
operation is not supported. AI agents read this section to discover
"how do I step / print / set a breakpoint in this debugger" without
parsing help text. The vocabulary covers three areas:
| Category | Fields |
|---|---|
| Navigation | step_in, step_over, step_out, continue, run |
| Inspection | print, dump, backtrace, source_list, locals, where, args |
| Breakpoints | breakpoint_set, breakpoint_set_line, breakpoint_list, breakpoint_clear_all |
Templates use named placeholders: {expr} for expressions to evaluate,
{target} for breakpoint targets (function name, Class.method),
{line} for line numbers, {file} for file paths. Multiple
placeholders in one template are allowed (e.g.
stop at {target}:{line}).
signals:
interrupt: "\x03" # Ctrl-C — null if no safe interrupt byte exists
eof: "\x04" # Ctrl-D
suspend: "\x1a" # Ctrl-Z (null if unsupported)
interrupt_confirm: null # optional second keystroke (erl BREAK menu "a")interrupt is nullable and must be set to null when the adapter's
host has a destructive Ctrl-C handler — i.e. one that kills the
entire process instead of unwinding the running command. Setting it
to null tells MCP clients not to attempt a send_input "\x03" as
a rescue mechanism; their only recovery path is lifecycle.shutdown
or waiting for the command to finish. capabilities.interrupt must
also be false in that case, so adapter consumers have two
consistent signals for the same truth. Example: groovy sets
interrupt: null because groovysh's Ctrl-C terminates the JVM.
If the host does deliver Ctrl-C as a cooperative interrupt but
the delivery is unreliable (e.g. Node's event-loop-bound signal
handler can't fire while a sync JS loop or pending top-level await
blocks the thread), keep signals.interrupt: "\x03" and set
capabilities.interrupt: false. The split says "the byte exists
but don't count on it".
lifecycle:
ready_timeout_ms: 0
shutdown:
command: "exit"
grace_ms: 1000
force_signal: kill
restart_on: [crash] # or [crash, idle_timeout]ready_timeout_ms— Reserved / not consumed by the runtime (as of v0.14.0), and redundant withready.timeout_ms(§4): both nominally express "how long to wait for first-prompt before giving up", neither is read. Same rationale for non-consumption asready.timeout_ms. If a startup-timeout is ever implemented, one of the two fields becomes canonical and the other is removed under aschemamajor bump — they must not both stay live.shutdown/restart_on— consumed normally; only the timeout field above is the reserved one.
Feature flags that the runtime and MCP clients can query.
| Flag | Type | Meaning |
|---|---|---|
stateful |
bool | State persists across commands (always true for REPLs and shells) |
interrupt |
bool | Ctrl-C can interrupt a running command |
meta_commands |
bool | commands section is populated and usable |
auto_modes |
bool | Adapter has modes with auto_enter: true — clients must re-check mode each turn |
async_output |
bool | Background concurrency can produce output between commands |
exit_code |
true | false | unreliable |
Exit code fidelity. unreliable means always 0 (cmd's limitation) |
cwd_tracking |
bool | Adapter emits cwd updates via OSC P (or equivalent) |
cwd_format |
windows_native | posix | none |
Shape of reported cwd strings. windows_native (C:\foo) can be passed to CreateProcess's lpCurrentDirectory directly; posix (/mnt/c/foo, /home/u) forces ripple to inject a cd preamble at the command level when spawning a replacement console. Only meaningful when cwd_tracking: true. |
job_control |
bool | &, fg, bg, Ctrl-Z suspend work |
shell_integration |
string | null | Protocol name: osc633, iterm2, kitty, or null |
user_busy_detection |
enum | How to detect the user is typing: osc_b, process_polling, none |
user_busy_detection_params |
object | Tuning params when method is process_polling |
cd_command |
string | Template for a runtime cd command accepted by the already-running shell/REPL. {path} is substituted with the target cwd after quote-escape. Used for auto-route / auto-spawn / reuse on start_console. Omit for adapters that don't participate in cwd management. |
cd_command_quote |
single_quote_posix | single_quote_pwsh | double_quote_cmd | c_style_double_quote |
Quote context for the {path} substitution in cd_command. Required whenever cd_command is set. single_quote_posix escapes ' as '\'' (bash/zsh/sh-family '...'); single_quote_pwsh escapes ' as '' (pwsh/powershell '...'); double_quote_cmd escapes " as "" (cmd "..."); c_style_double_quote escapes \ as \\ and " as \" (Python / Node / other C-family double-quoted string literals). |
Single eval + expected regex. Used as a health check when the adapter is
loaded. If probe.eval doesn't match probe.expect, the adapter is
considered broken.
probe:
eval: "1 + 1"
expect: '^2$'Each test describes a (setup, eval, expect) triple. Run by CI against the declared interpreter binary. The test vocabulary is intentionally small so new adapters can be added without learning a test framework.
tests:
- name: simple_arithmetic
eval: "2 + 3"
expect: '^5$'
- name: variable_persistence
setup: "x = 42"
eval: "x * 2"
expect: '^84$'
- name: error_recovery
eval: "1/0"
expect_error: true
expect: 'ZeroDivisionError'
- name: cwd_change_tracked
setup: "cd /tmp"
eval: "pwd"
expect_cwd_update: true
- name: exit_code_propagates
eval: "false"
expect_exit_code: 1
- name: nested_sequence
setup_sequence:
- { eval: "(/ 1 0)", expect_mode: debugger, expect_level: 0 }
- { eval: ":abort", expect_mode: toplevel }Supported assertions:
expect: <regex>— stdout must matchexpect_error: bool— evaluation must fail (stderr/exception)expect_exit_code: int— for shells onlyexpect_cwd_update: bool— cwd must have been emitted since last promptexpect_mode: <name>— after eval, the REPL must be in this modeexpect_level: int— nested mode depthexpect_out_of_band: <regex>— async-emitted text must matchexit_code_is_unreliable: true— tag this test as documenting a known limitation rather than a correctness invariant
- Copy the closest existing adapter as a template.
- Replace
name,version,description,family. - Adjust
process.command_templateto launch your interpreter. - Decide on
init.strategy:- Shell with OSC 633 integration →
shell_integration - REPL where PS1 can be replaced →
marker - REPL where PS1 is hardcoded →
regex
- Shell with OSC 633 integration →
- Write the integration script (if any) with the unique marker injection.
- Fill in
tests:— aim for at least 5, covering simple eval, state persistence, multi-line input, error recovery, and any mode transitions. - Run
ripple adapter test adapters/your-adapter.yamlto verify it. - Submit a PR to the adapter registry.
schemafield is sacred. Onceschema: 1ships on a stable ripple release, additions are allowed; removals and semantic changes requireschema: 2.- Adapter
versionfield is independent of the schema version. Adapters can bump their own version when the integration script is tweaked or new tests are added. - Interpreter version compatibility — adapters should document supported
interpreter version ranges in the
descriptionfield if compatibility is narrow. The runtime does not enforce this.
- Q1: Is
balanced_parensexpressive enough for Lisp-family languages with reader macros? — Answered "yes, with the char_literal_prefix + datum_comment_prefix extensions" by the Racket adapter + runtime counter (0.1.0 → 0.2.0, 2026-04-14). The counter (Services/BalancedParensCounter.cs) is a single forward pass that tracks bracket depth, string-literal state, line/block comment state, and the two reader-macro extensions added to close Q1:char_literal_prefixconsumes the prefix + the next character (plus any identifier run for named literals like#\space), so#\(is treated as a literal char token rather than an unclosed open paren.datum_comment_prefixpushes a pending-comment marker that is resolved when the next atom / string / matching close bracket appears, so#;(long list)balances its own brackets without affecting outer depth. Multiple#;stack (#;#;(a)(b)skips two datums) via a counter. The counter ships with 26 unit tests covering all the Lisp edge cases (char literals of every bracket type, datum comments on atoms / strings / lists, nested datum comments, strings with embedded brackets, unterminated literals). The Racket adapter'smultiline_detect: balanced_parensis now wired intoConsoleWorker's execute path: an AI-sent block that fails the counter ((define (f x),(+ 1 2)), etc.) is rejected with a clear diagnostic rather than being submitted to the REPL where it would deadlock. The quoting prefixes'/`/,@don't need schema support because they don't affect bracket counting — they just annotate the next datum, which the counter already handles.
- Q2: Should
modes.exit_commands.effectenum stay closed (4 values) or allowcustomwith a free-text label? — Answered "closed is sufficient" by the python adapter's pdb mode declaration- the runtime
ModeDetector(0.2.0, 2026-04-14). pdb's exit commands (continue/c= resume,quit/q= return to Python REPL) map cleanly toresumeandreturn_to_toplevel; no need forcustomor a free-text label. Theinvoke_restartandunwind_one_levelvalues still lack a live example but are kept for CL/SBCL-style debuggers where they have prior art (SLIME's restart protocol). Add a new enum value only when a concrete adapter demands one. Runtime status:ConsoleWorkernow walks the mode graph after every command viaModeDetector(a pure forward regex pass over the captured output tail), surfacescurrentMode/currentModeLevelon execute and get_status responses, andAdapterDeclaredTestsRunnerhonoursexpect_mode/expect_levelassertions. Exit-command enforcement is still client-side (the AI / MCP client picks an exit_command and the detector confirms the post-command mode), which is the right layering — the runtime reports what mode it sees, the client decides whether to issue the exit command.
- the runtime
- Is
output.async_interleave.strategy: redraw_detectsufficient for asyncio / Go-like coroutines, or do we need per-family variants? - Should adapters be able to bundle
preset:references (e.g.balanced_parens: { preset: lisp }) to reduce duplication across Lisp family adapters?
These should be resolved before stamping schema: 1 on a shipped release.