Skip to content

Latest commit

 

History

History
754 lines (664 loc) · 34.7 KB

File metadata and controls

754 lines (664 loc) · 34.7 KB

ripple adapter schema — v1 draft

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.


1. Design principles

  1. 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.
  2. 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\u0001 for REPLs) at startup so the runtime can locate prompt boundaries without risking false positives from command output.
  3. Exec-form commands only. process.command_template is expanded with named placeholders. Shell-interpolated strings are forbidden — no quoting holes, no injection.
  4. Versioned. Every adapter declares schema: 1. Future versions are additive-first; breaking changes bump the schema major.
  5. Testable. Every adapter ships its own contract tests in tests:. CI runs them against the declared interpreter binary before merge.

2. Top-level fields

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.

3. process — launch spec

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 from init), {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 by fsi (→ dotnet), jshell (→ java), etc. The string is env-expanded and PATH-resolved before being substituted into {shell_path}. When present alongside executable_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 through Environment.ExpandEnvironmentVariables so 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 to executable, then to the adapter name, when the list is null, empty, or fully unresolvable.
  • inherit_environment — if false, the runtime calls CreateEnvironmentBlock(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 need true on Windows because MSYS2/Git Bash require HOME, PATH, MSYSTEM from the parent.
  • env — additional environment variables (merged on top of inherited).
  • encoding — stdin/stdout encoding. Always utf-8 in 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.

4. ready — startup detection

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: 30000
  • wait_for_eventprompt_start | marker | regex | custom. For shell-integration adapters this is always prompt_start (the first OSC A event). For REPL adapters it's typically marker.
  • timeout_msReserved / 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 UNC PSModulePath) can legitimately take many seconds, and a startup timeout would mis-fire on exactly that slow-host population (see issue #9: a reporter set ready.timeout_ms: 100 believing it was a fix — it does nothing). A non-zero value is silently ignored; 0 is 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_ms at that time (the two are redundant — see §12).
  • settle_before_inject_ms — quiet period before injecting the integration script. Only meaningful when init.delivery: pty_inject.
  • suppress_mirror_during_inject — hide the source echo 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's WaitForOutputSettled drain phase. min_ms is the absolute minimum wait before polling starts; stable_ms is the consecutive quiet window required to declare output settled; max_ms is the hard deadline. Defaults 2000 / 1000 / 30000 match the pre-schema hardcoded behavior. Slow-compiler REPLs (Lisp, Haskell) may need to raise max_ms; fast REPLs can lower min_ms to speed startup.

5. init — integration injection

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, fish
  • ps0 — bash (reliable since bash 4.4)
  • precommand_lookup_action — pwsh (fires inside the engine before resolution)
  • debug_trap — legacy bash (DEBUG trap — has subshell-visibility pitfalls)
  • custom — adapter uses a strategy not in this enum
  • none — 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 before CreateProcess. 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-injected source … bytes get swallowed by ZLE under ConPTY without ever submitting. Requires rc_file.dir_env_var and rc_file.file_name.
  • none — no external script; integration is entirely declarative (cmd's prompt variable carries the OSC sequences).

6. prompt — prompt detection

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 input
  • B = Enter pressed / line submitted
  • C = command about to execute (boundary between input echo and output)
  • D;N = command finished with exit code N
  • P;Cwd=... = property update (currently only Cwd is 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 — IPython In [N]:, iex iex(N)>
  • nesting_level — SBCL debug level N]
  • 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).


7. output — output post-processing

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 | discard
  • post_prompt_settle_ms — how long to wait after A fires 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 output
    • deterministic_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 own prompt.primary regex (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). Default none; set to redraw_detect for BEAM-family runtimes.

8. input — input delivery

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. Default null (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 — Python PYTHON_BASIC_REPL=1, fsi --readline-, Racket -i, CCL, ABCL — and pass raw bytes straight to the parser, which rejects U+0001 as 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 — send cat <<EOF ... EOF construct (reserved)
    • wrapper — send wrapper.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 to tempfile if 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's Up-arrow recall.

  • 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 #\space are 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).


9. modes — REPL modes (optional)

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 debugger 0] 1] 2]). Requires level_capture in the prompt regex.
  • advance_commands — commands that change execution position within this mode without leaving it. After a step_in or step_over, the debugger is still paused — just at a different source location. AI agents use advance_commands to 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 call
    • step_over — execute one source line, stepping over calls
    • step_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 mode
    • unwind_one_level — pop one level of nesting
    • invoke_restart — Lisp restart invocation
    • resume — continue execution from where the mode was entered

10. commands — helper/meta commands (optional)

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}).


11. signals

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".


12. lifecycle

lifecycle:
  ready_timeout_ms: 0
  shutdown:
    command: "exit"
    grace_ms: 1000
    force_signal: kill
  restart_on: [crash]        # or [crash, idle_timeout]
  • ready_timeout_msReserved / not consumed by the runtime (as of v0.14.0), and redundant with ready.timeout_ms (§4): both nominally express "how long to wait for first-prompt before giving up", neither is read. Same rationale for non-consumption as ready.timeout_ms. If a startup-timeout is ever implemented, one of the two fields becomes canonical and the other is removed under a schema major bump — they must not both stay live.
  • shutdown / restart_on — consumed normally; only the timeout field above is the reserved one.

13. capabilities

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).

14. probe

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$'

15. tests — contract tests

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 match
  • expect_error: bool — evaluation must fail (stderr/exception)
  • expect_exit_code: int — for shells only
  • expect_cwd_update: bool — cwd must have been emitted since last prompt
  • expect_mode: <name> — after eval, the REPL must be in this mode
  • expect_level: int — nested mode depth
  • expect_out_of_band: <regex> — async-emitted text must match
  • exit_code_is_unreliable: true — tag this test as documenting a known limitation rather than a correctness invariant

16. Writing a new adapter

  1. Copy the closest existing adapter as a template.
  2. Replace name, version, description, family.
  3. Adjust process.command_template to launch your interpreter.
  4. Decide on init.strategy:
    • Shell with OSC 633 integration → shell_integration
    • REPL where PS1 can be replaced → marker
    • REPL where PS1 is hardcoded → regex
  5. Write the integration script (if any) with the unique marker injection.
  6. Fill in tests: — aim for at least 5, covering simple eval, state persistence, multi-line input, error recovery, and any mode transitions.
  7. Run ripple adapter test adapters/your-adapter.yaml to verify it.
  8. Submit a PR to the adapter registry.

17. Versioning policy

  • schema field is sacred. Once schema: 1 ships on a stable ripple release, additions are allowed; removals and semantic changes require schema: 2.
  • Adapter version field 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 description field if compatibility is narrow. The runtime does not enforce this.

18. Open questions for v1 freeze

  • Q1: Is balanced_parens expressive 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_prefix consumes 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_prefix pushes 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's multiline_detect: balanced_parens is now wired into ConsoleWorker'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.effect enum stay closed (4 values) or allow custom with 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 to resume and return_to_toplevel; no need for custom or a free-text label. The invoke_restart and unwind_one_level values 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: ConsoleWorker now walks the mode graph after every command via ModeDetector (a pure forward regex pass over the captured output tail), surfaces currentMode / currentModeLevel on execute and get_status responses, and AdapterDeclaredTestsRunner honours expect_mode / expect_level assertions. 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.
  • Is output.async_interleave.strategy: redraw_detect sufficient 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.