Liking cljdoc? Tell your friends :D

com.blockether.vis-python-runtime

Embedded CPython for the Vis sandbox: the whole Clojure API.

Vis runs sandbox Python — packages/vis-agent plus every shim in resources/vis-shims/ — in vendored CPython reached through the JDK Foreign Function & Memory API and the first-party C ABI in native/vispython.

The bridge itself is JAVA — src/java/com/blockether/vispython/ — and this namespace is a thin skin over it: Clojure argument shapes and keyword maps, and nothing else. The reason is the native image the result is linked into. Every downcall there is an invokeExact against a signature the compiler knows and the host upcall's target is a static method found by name, while the same code as interop is a reflective invocation an image only keeps if somebody remembered to register it — the failure that does not show up in a green JVM suite, only in a user's terminal. Java also owns the process pinning, the upcall stub, the trust export and pip, because none of that is made clearer by being written in Clojure.

Nothing links at build time. The library is resolved when it is first needed: a path the host named through use-library! wins, then VIS_PYTHON_NATIVE_PATH, then the classpath resource prebuilds/<platform>/<file> a checkout has after a native build. The published platform artifact is a release archive, not a jar, so a host that unpacked one names it here. A failure anywhere below is a VisPythonException whose .data names the symbol, status, platform or path it is about.

Embedded CPython for the Vis sandbox: the whole Clojure API.

Vis runs sandbox Python — `packages/vis-agent` plus every shim in
`resources/vis-shims/` — in vendored CPython reached through the JDK Foreign
Function & Memory API and the first-party C ABI in `native/vispython`.

The bridge itself is JAVA — `src/java/com/blockether/vispython/` — and this
namespace is a thin skin over it: Clojure argument shapes and keyword maps,
and nothing else. The reason is the native image the result is linked
into. Every downcall there is an `invokeExact` against a signature the
compiler knows and the host upcall's target is a static method found by name,
while the same code as interop is a reflective invocation an image only keeps
if somebody remembered to register it — the failure that does not show up in
a green JVM suite, only in a user's terminal. Java also owns the process
pinning, the upcall stub, the trust export and pip, because none of that is
made clearer by being written in Clojure.

Nothing links at build time. The library is resolved when it is first needed:
a path the host named through `use-library!` wins, then
`VIS_PYTHON_NATIVE_PATH`, then the classpath resource
`prebuilds/<platform>/<file>` a checkout has after a native build. The
published platform artifact is a release archive, not a jar, so a host that
unpacked one names it here. A failure anywhere below is a
`VisPythonException` whose `.data`
names the symbol, status, platform or path it is about.
raw docstring

com.blockether.vis.core

vis - broad host facade.

This is the ONLY namespace extensions, channel adapters, embedded callers, and tests should import. It deliberately re-exports host, registry, runtime, persistence, prompt, diagnostic, and sandbox helpers from com.blockether.vis.internal.*. The internal tree is not stable; the names exposed here are the host contract.

Canonical runtime language: Session -> Turn -> Iteration -> Block.

A Turn is one user request plus assistant answer inside a Session. New code and documentation should use turn/user-request language.

Primary surfaces:

  • Session / turn runtime: create!, send!, turn!, by-id, by-channel, env-for, close!, delete!, set-title!.
  • Environment runtime: create-environment, dispose-environment!, get-router, rebuild-router!, resolve-effective-model.
  • Extension contract: extension, symbol, value, render-prompt, register-extension!, registered-extensions, discovery, reload.
  • Registries: command, channel, provider, and backend registration helpers for host-owned and embedded use.
  • Persistence facade: db-* functions and connection helpers. The implementation namespace / extension slot are spelled persistance; human-facing language is Persistence.
  • Prompt / Python-sandbox / formatting / cancellation / notifications / doctor helpers shared by channels and extensions.

Not every export is equally high-level. send!, extension maps, registry builders, read-side persistence helpers, and Markdown export are the preferred integration surface. Low-level sandbox, parse-repair, dispatcher, and write-side db helpers are exported because this is a host facade, but ordinary extensions should avoid depending on them unless they are implementing host-level behavior.

Binary entry: -main (invoked by clojure -M:vis, AOT'd to a Java entry class via :gen-class for the GraalVM native-image build).

vis - broad host facade.

This is the ONLY namespace extensions, channel adapters, embedded
callers, and tests should import. It deliberately re-exports host,
registry, runtime, persistence, prompt, diagnostic, and sandbox
helpers from `com.blockether.vis.internal.*`. The internal tree is
not stable; the names exposed here are the host contract.

Canonical runtime language:
  Session -> Turn -> Iteration -> Block.

A Turn is one user request plus assistant answer inside a Session.
New code and documentation should use turn/user-request language.

Primary surfaces:
  - Session / turn runtime: create!, send!, turn!, by-id,
    by-channel, env-for, close!, delete!, set-title!.
  - Environment runtime: create-environment, dispose-environment!,
    get-router, rebuild-router!, resolve-effective-model.
  - Extension contract: extension, symbol, value, render-prompt,
    register-extension!, registered-extensions, discovery, reload.
  - Registries: command, channel, provider, and backend registration
    helpers for host-owned and embedded use.
  - Persistence facade: db-* functions and connection helpers. The
    implementation namespace / extension slot are spelled
    `persistance`; human-facing language is Persistence.
  - Prompt / Python-sandbox / formatting / cancellation /
    notifications / doctor helpers shared by channels and extensions.

Not every export is equally high-level. `send!`, extension maps,
registry builders, read-side persistence helpers, and Markdown export
are the preferred integration surface. Low-level sandbox, parse-repair,
dispatcher, and write-side db helpers are exported because this is a
host facade, but ordinary extensions should avoid depending on them
unless they are implementing host-level behavior.

Binary entry: -main (invoked by `clojure -M:vis`, AOT'd to a Java
entry class via `:gen-class` for the GraalVM native-image build).
raw docstring

com.blockether.vis.internal.activity.core

Lossless reducer from immutable lifecycle events to channel-neutral Activity.

Wrapper-entry sequence owns row placement and terminal events update rows in place.

Lossless reducer from immutable lifecycle events to channel-neutral Activity.

Wrapper-entry sequence owns row placement and terminal events update rows in place.
raw docstring

com.blockether.vis.internal.activity.event

Bounded, ownerless lifecycle events for host-observed tool invocations.

Events are presentation input, never operation control. Construction redacts before measuring, collectors reject broken lifecycle order, and a sink failure must never change the value or exception the Python caller observes.

Bounded, ownerless lifecycle events for host-observed tool invocations.

Events are presentation input, never operation control. Construction redacts
before measuring, collectors reject broken lifecycle order, and a sink failure
must never change the value or exception the Python caller observes.
raw docstring

com.blockether.vis.internal.activity.presenter

Closed semantic presenter registry for Activity rows. Presenters return data, never channel markup, and never inspect Python source.

Closed semantic presenter registry for Activity rows. Presenters return data,
never channel markup, and never inspect Python source.
raw docstring

com.blockether.vis.internal.attachment.audio-transcribe

Borrowed EARS: a recording somebody attached, as text.

No provider wire carries audio. A voice memo dropped into a message is therefore stored, played back for the human, and NAMED to the model — which is the same dead end a blind model meets in front of a screenshot, and the reason vision-describe exists. The answer here is the same shape, with one difference that decides everything about where it runs: the transcript is not a second-hand report bought from another provider, it is the recording's own WORDS, produced locally by the speech engine this build already carries (com.blockether.vis.internal.speech.core, normally Parakeet on this machine). It costs no quota, it leaves no bytes on anybody's wire, and it is true of the file forever.

WHEN it runs is the whole design. A surface that STAGES a recording — the composer rail, an upload, the gateway's own intake — calls request! the moment the file arrives and paints outcome while the human is still typing. By the time the turn is sent the words are normally already in hand; a turn that finds the work still running JOINS it under a deadline instead of starting its own. Nothing is ever transcribed twice, and nobody waits for a recording that was attached a minute ago.

Four properties keep it affordable and honest:

  • CONTENT-KEYED. Attachments replay on every later request of the session, so the digest of the bytes — not the position, not the filename — is the registry key, and a memo is transcribed exactly once per process.
  • ONE AT A TIME. Local speech saturates a core, so five memos in one message queue on one daemon worker rather than starting five decoders at once.
  • NEVER SILENT. Every miss is an OUTCOME carrying a reason — pending, unavailable, silent (statuses) — logged once and spelled on the wire as transcription_status, because a blank band under a player must never be indistinguishable from a recording that had no words in it. A failure is remembered as a FAILURE and never as "no words"; a reason that describes this moment rather than the file (a model still downloading, a toggle that is off) is not remembered at all.
  • TOTAL. No failure escapes: a recording that could not be transcribed is still stored, still played, and still named to the model.

A LEAF: attachments + voice + toggles, never back on the loop.

Borrowed EARS: a recording somebody attached, as text.

No provider wire carries audio. A voice memo dropped into a message is therefore
stored, played back for the human, and NAMED to the model — which is the same
dead end a blind model meets in front of a screenshot, and the reason
`vision-describe` exists. The answer here is the same shape, with one difference
that decides everything about where it runs: the transcript is not a second-hand
report bought from another provider, it is the recording's own WORDS, produced
locally by the speech engine this build already carries
(`com.blockether.vis.internal.speech.core`, normally Parakeet on this machine). It costs
no quota, it leaves no bytes on anybody's wire, and it is true of the file forever.

WHEN it runs is the whole design. A surface that STAGES a recording — the composer
rail, an upload, the gateway's own intake — calls [[request!]] the moment the file
arrives and paints [[outcome]] while the human is still typing. By the time the
turn is sent the words are normally already in hand; a turn that finds the work
still running JOINS it under a deadline instead of starting its own. Nothing is
ever transcribed twice, and nobody waits for a recording that was attached a
minute ago.

Four properties keep it affordable and honest:

- CONTENT-KEYED. Attachments replay on every later request of the session, so the
  digest of the bytes — not the position, not the filename — is the registry key,
  and a memo is transcribed exactly once per process.
- ONE AT A TIME. Local speech saturates a core, so five memos in one message queue
  on one daemon worker rather than starting five decoders at once.
- NEVER SILENT. Every miss is an OUTCOME carrying a reason — `pending`,
  `unavailable`, `silent` ([[statuses]]) — logged once and spelled on the wire as
  `transcription_status`, because a blank band under a player must never be
  indistinguishable from a recording that had no words in it. A failure is
  remembered as a FAILURE and never as "no words"; a reason that describes this
  moment rather than the file (a model still downloading, a toggle that is off) is
  not remembered at all.
- TOTAL. No failure escapes: a recording that could not be transcribed is still
  stored, still played, and still named to the model.

A LEAF: attachments + voice + toggles, never back on the loop.
raw docstring

com.blockether.vis.internal.attachment.core

User-message image, video, audio, document and gzip attachments.

Dropping a file onto the terminal pastes its PATH into the input (the terminal's drop behavior — same mechanism pi relies on). At turn start the engine scans the user message for path-shaped tokens that resolve to real image files, reads them, and attaches them to the initial user message as multimodal content blocks. Channel-neutral: every channel gets the same behavior because the scan runs in the engine, not the channel.

What a file IS is sniffed from magic bytes (pi-parity: jpeg / non-animated png / gif / webp / bmp, plus MP4/QuickTime clips, mp3/m4a/wav/ogg/flac recordings, PDF/HTML documents and gzip streams) or, for SVG, from the markup head. Markdown uploads use a filename or MIME hint plus UTF-8 validation; an extension alone never blesses a payload. A container the model cannot consume is kept for the HUMAN and NAMED to the model (model-blind-media-type?).

Storing and SENDING are deliberately separate concerns:

  • STORE — the original bytes under their sniffed container, nothing converted, nothing downscaled, nothing re-compressed. What the user supplied is what the session keeps, and a file over max-image-bytes is skipped with a reason the prompt assembler surfaces.
  • SEND — wire-image is the one gate every image crosses on its way to a provider: it decodes the payload to prove it is pixels, re-containers what no wire accepts (BMP/SVG -> PNG via image-convert, on com.blockether/imaging, so it behaves identically in the native image) and REFUSES what it cannot turn into a picture.

The split is what makes a bad attachment survivable. Attachments replay on every later turn, so a row blessed once on the way IN is shipped forever — one corrupt PNG that way is a permanent provider 400. Judged on the way OUT, the same row is simply dropped and the session keeps working.

User-message image, video, audio, document and gzip attachments.

Dropping a file onto the terminal pastes its PATH into the input (the
terminal's drop behavior — same mechanism pi relies on). At turn start
the engine scans the user message for path-shaped tokens that resolve
to real image files, reads them, and attaches them to the initial user
message as multimodal content blocks. Channel-neutral: every channel
gets the same behavior because the scan runs in the engine,
not the channel.

What a file IS is sniffed from magic bytes (pi-parity: jpeg / non-animated
png / gif / webp / bmp, plus MP4/QuickTime clips, mp3/m4a/wav/ogg/flac
recordings, PDF/HTML documents and gzip streams) or, for SVG, from the
markup head. Markdown uploads use a filename or MIME hint plus UTF-8 validation;
an extension alone never blesses a payload. A container the model cannot consume
is kept for the HUMAN and NAMED to the model ([[model-blind-media-type?]]).

Storing and SENDING are deliberately separate concerns:

  * STORE — the original bytes under their sniffed container, nothing
    converted, nothing downscaled, nothing re-compressed. What the user
    supplied is what the session keeps, and a file over `max-image-bytes`
    is skipped with a reason the prompt assembler surfaces.
  * SEND — [[wire-image]] is the one gate every image crosses on its way to
    a provider: it decodes the payload to prove it is pixels, re-containers
    what no wire accepts (BMP/SVG -> PNG via `image-convert`, on
    `com.blockether/imaging`, so it behaves identically in the native image)
    and REFUSES what it cannot turn into a picture.

The split is what makes a bad attachment survivable. Attachments replay on
every later turn, so a row blessed once on the way IN is shipped forever —
one corrupt PNG that way is a permanent provider 400. Judged on the way OUT,
the same row is simply dropped and the session keeps working.
raw docstring

com.blockether.vis.internal.attachment.image-convert

Container conversion for attachment payloads -- NOT optimization.

vis stores and replays the attachment PICTURE verbatim: whatever the user dropped is what is stored and what the provider sees, pixel for pixel (the stored ENCODING may be shrunk losslessly -- compact). The one thing that cannot be passed through is a container no vision wire accepts (attachments/ provider-image-media-types): a BMP is a perfectly good image and an SVG is a perfectly good figure, and both are a hard 400 on every provider. Since an attachment REPLAYS on every later turn, one such row kills the whole session.

So this namespace does exactly one thing: turn bytes a provider REFUSES into the same picture in a container it ACCEPTS.

  • raster (BMP, TIFF, or anything else the decoder reads) -> PNG, 1:1
  • vector (.svg / gzipped .svgz) -> rendered PNG
  • video (.mp4 / .mov) -> animated GIF

Everything a renderer can answer is ASKED, not re-implemented. com.blockether/imaging (Rust image + resvg over FFM) decodes, sniffs, gunzips .svgz, resolves width="100%"/cm/viewBox letterboxing, and rasterizes. It also owns the two BROWSER repairs resvg alone refuses, behind imaging/svg-canvas:

  • a zero or negative declared size -- resvg refuses the document outright ("SVG has an invalid size"); browsers fall back to the viewBox.
  • a document that declares NO size -- resvg's bounds run from the ORIGIN (a figure at x=50 gains a 50px margin) and collapse to a bare 100x100 when content sits at negative coordinates, so the canvas is framed by the ink actually painted.

Both used to be hand-written Clojure HERE. They are Rust in the library now -- one implementation for every caller, and vis only asks for the answer. renderer-delegation-test still pins that answer document by document, so a change in resvg or in the repair shows up as a vis test failure.

Conversion is 1:1 and never re-compresses what the wire already takes: no quality knob, no size heuristics on the way through. There are exactly two exceptions, both narrow and both named: fit-within, which the send gate calls ONLY for a payload over the wire's byte cap that would otherwise be DROPPED, and compact, which the storage rail calls on the way into the session_attachment BLOB and which is LOSSLESS -- same picture, fewer bytes, verified by a re-probe. Nothing here ever throws: a failure comes back as {:reason <why>} -- no :bytes, so the caller still SKIPS the attachment, but it can TELL the user why instead of dropping a perfectly valid picture in silence.

Pixels come from com.blockether/imaging, never from AWT/Java2D/ImageIO -- which is why this works identically in the native image, on every platform, with no headless or fontconfig bootstrap. Tests do the opposite on purpose: independent-decoder-test re-reads every payload this namespace emits with javax.imageio, so "it decodes" is never just the encoder agreeing with itself -- the provider's decoder is a third implementation again.

Container conversion for attachment payloads -- NOT optimization.

vis stores and replays the attachment PICTURE verbatim: whatever the user
dropped is what is stored and what the provider sees, pixel for pixel (the
stored ENCODING may be shrunk losslessly -- [[compact]]). The one thing that
cannot be passed through is a container no vision wire accepts (`attachments/
provider-image-media-types`): a BMP is a perfectly good image and an SVG is
a perfectly good figure, and both are a hard 400 on every provider. Since an
attachment REPLAYS on every later turn, one such row kills the whole session.

So this namespace does exactly one thing: turn bytes a provider REFUSES into
the same picture in a container it ACCEPTS.

  * raster (BMP, TIFF, or anything else the decoder reads) -> PNG, 1:1
  * vector (`.svg` / gzipped `.svgz`)                      -> rendered PNG
  * video (`.mp4` / `.mov`)                                -> animated GIF

Everything a renderer can answer is ASKED, not re-implemented.
`com.blockether/imaging` (Rust `image` + resvg over FFM) decodes, sniffs,
gunzips `.svgz`, resolves `width="100%"`/`cm`/`viewBox` letterboxing, and
rasterizes. It also owns the two BROWSER repairs resvg alone refuses, behind
`imaging/svg-canvas`:

  * a zero or negative declared size -- resvg refuses the document outright
    ("SVG has an invalid size"); browsers fall back to the `viewBox`.
  * a document that declares NO size -- resvg's bounds run from the ORIGIN
    (a figure at x=50 gains a 50px margin) and collapse to a bare 100x100
    when content sits at negative coordinates, so the canvas is framed by the
    ink actually painted.

Both used to be hand-written Clojure HERE. They are Rust in the library now --
one implementation for every caller, and vis only asks for the answer.
`renderer-delegation-test` still pins that answer document by document, so a
change in resvg or in the repair shows up as a vis test failure.

Conversion is 1:1 and never re-compresses what the wire already takes: no
quality knob, no size heuristics on the way through. There are exactly two
exceptions, both narrow and both named: [[fit-within]], which the send gate
calls ONLY for a payload over the wire's byte cap that would otherwise be
DROPPED, and [[compact]], which the storage rail calls on the way into the
`session_attachment` BLOB and which is LOSSLESS -- same picture, fewer bytes,
verified by a re-probe. Nothing here ever throws: a
failure comes back as `{:reason <why>}` -- no `:bytes`, so the caller still
SKIPS the attachment, but it can TELL the user why instead of dropping a
perfectly valid picture in silence.

Pixels come from `com.blockether/imaging`, never from AWT/Java2D/ImageIO --
which is why this works identically in the native image, on every platform,
with no headless or fontconfig bootstrap. Tests do the opposite on purpose:
`independent-decoder-test` re-reads every payload this namespace emits with
`javax.imageio`, so "it decodes" is never just the encoder agreeing with
itself -- the provider's decoder is a third implementation again.
raw docstring

com.blockether.vis.internal.attachment.linked-reports

Snapshot deliberately linked workspace reports before persisting assistant prose.

Only the session's primary workspace is eligible, never other host roots. Hidden paths, credential-like names, policy exclusions, traversal, symlinks, directories and unsupported report types fail closed. Reads walk directory handles without following symlinks; platforms without SecureDirectoryStream refuse publication. At most eight distinct links and 8 MiB per file are delivered per iteration. Attachments are human-only snapshots, not live paths. Existing attachment and remote links, code examples and images are not republished. Failed local links become plain text with a recovery action; no host exception or file bytes leak.

Snapshot deliberately linked workspace reports before persisting assistant prose.

Only the session's primary workspace is eligible, never other host roots. Hidden
paths, credential-like names, policy exclusions, traversal, symlinks, directories
and unsupported report types fail closed. Reads walk directory handles without
following symlinks; platforms without SecureDirectoryStream refuse publication.
At most eight distinct links and 8 MiB per file are delivered per iteration.
Attachments are human-only snapshots, not live paths. Existing attachment and
remote links, code examples and images are not republished. Failed local links
become plain text with a recovery action; no host exception or file bytes leak.
raw docstring

com.blockether.vis.internal.attachment.storage

Attachment storage-offload rail: a registry of storage BACKENDS plus the pure OFFLOAD DECISION that routes one attachment's payload either INLINE (bytes in the session_attachment.bytes BLOB) or EXTERNAL (bytes handed to a backend, which returns a storage_uri -- scheme://... -- kept in the row instead).

Zero SQL, zero schema change: the V4 session_attachment table already carries a nullable storage_uri with an exactly-one(bytes, storage_uri) CHECK. This namespace only decides WHICH of the two a given attachment takes, PUTs/GETs the external bytes through the scheme-dispatched backend, and hydrates a read-back envelope's :base64 from its :storage-uri on demand.

The decision is a PURE predicate hot? AND size (see default-offload?): an image replays to a vision model every turn its iteration stays live, so it is HOT -- kept inline even when large; a non-image artifact (PDF/CSV/wav/ download) is fetched at most once by a human, so it is COLD -- a good offload candidate past a size floor. A backend may override the predicate wholesale via :storage/offload?. Precedence, engine-owned so the loop never learns a storage dialect:

  1. active backend's :storage/offload? (the backend knows its own cost)
  2. else default-offload? (engine default policy)
  3. no active backend -> always inline (zero regression)

Either way the bytes are COMPACTED first (image-convert/compact): a PNG / JPEG / GIF payload is re-compressed LOSSLESSLY -- oxipng, jpegtran-style marker stripping, gifsicle differencing -- before it becomes a BLOB or a backend PUT. That is the only transformation this rail performs, it is verified per payload (strictly smaller AND the same format at the same dimensions and frame count, else the original is kept), and it is why a matplotlib figure costs a fraction of a row.

Attachment storage-offload rail: a registry of storage BACKENDS plus the
pure OFFLOAD DECISION that routes one attachment's payload either INLINE
(bytes in the `session_attachment.bytes` BLOB) or EXTERNAL (bytes handed to
a backend, which returns a `storage_uri` -- `scheme://...` -- kept in the row
instead).

Zero SQL, zero schema change: the V4 `session_attachment` table already
carries a nullable `storage_uri` with an exactly-one(bytes, storage_uri)
CHECK. This namespace only decides WHICH of the two a given attachment takes,
PUTs/GETs the external bytes through the scheme-dispatched backend, and
hydrates a read-back envelope's `:base64` from its `:storage-uri` on demand.

The decision is a PURE predicate `hot? AND size` (see `default-offload?`):
an image replays to a vision model every turn its iteration stays live, so it
is HOT -- kept inline even when large; a non-image artifact (PDF/CSV/wav/
download) is fetched at most once by a human, so it is COLD -- a good offload
candidate past a size floor. A backend may override the predicate wholesale
via `:storage/offload?`.
Precedence, engine-owned so the loop never learns a storage dialect:
  1. active backend's `:storage/offload?`  (the backend knows its own cost)
  2. else `default-offload?`               (engine default policy)
  3. no active backend                     -> always inline (zero regression)

Either way the bytes are COMPACTED first (`image-convert/compact`): a PNG /
JPEG / GIF payload is re-compressed LOSSLESSLY -- oxipng, jpegtran-style
marker stripping, gifsicle differencing -- before it becomes a BLOB or a
backend PUT. That is the only transformation this rail performs, it is
verified per payload (strictly smaller AND the same format at the same
dimensions and frame count, else the original is kept), and it is why a
matplotlib figure costs a fraction of a row.
raw docstring

com.blockether.vis.internal.attachment.vision-describe

Borrowed EYES for a blind target model.

A session routes to ONE model, and that model may have no :vision capability (a Copilot tier without vision, glm-5-turbo, deepseek, most coding plans). The images are still real — the user attached a screenshot, an earlier iteration plotted a figure — so today they are dropped from the wire and the model is told to open them with PIL. PIL answers (1920, 1080) RGB and nothing else: pixel size is not meaning, and the agent burns a tool call to learn nothing.

This namespace closes that gap WITHOUT switching the turn's model: one cheap side-channel ask! prefers a :vision model from the foreground provider, then the last provider whose eyes worked, then the cheapest+fastest eyes elsewhere in the same fleet. Svar owns that ordered provider preference and capability filter. The pinned model, its thinking chain and its tool continuity are untouched.

Three properties make it affordable rather than wasteful:

  • CONTENT-KEYED CACHE. Attachments replay on every request for the rest of the session, so a per-request description would be re-billed forever. Keyed by the digest of the bytes, an image is described exactly ONCE per process.
  • OWN FAILURE POLICY. Like titling, the describe call never waits out a 429 and never fails over provider chains: the foreground turn owns the quota. A refusal or a deadline returns nil and the caller keeps today's behaviour.
  • SECOND-HAND BY CONTRACT. The description is labelled as another model's report in the prompt, never as the agent's own sight, so pixel-exact work still goes through the imaging path.

A LEAF: svar + attachments + config + runtime-settings + toggles, never back on the loop.

Borrowed EYES for a blind target model.

A session routes to ONE model, and that model may have no `:vision` capability
(a Copilot tier without vision, glm-5-turbo, deepseek, most coding plans). The
images are still real — the user attached a screenshot, an earlier iteration
plotted a figure — so today they are dropped from the wire and the model is told
to open them with PIL. PIL answers `(1920, 1080) RGB` and nothing else: pixel
size is not meaning, and the agent burns a tool call to learn nothing.

This namespace closes that gap WITHOUT switching the turn's model: one cheap
side-channel `ask!` prefers a `:vision` model from the foreground provider, then
the last provider whose eyes worked, then the cheapest+fastest eyes elsewhere in
the same fleet. Svar owns that ordered provider preference and capability filter.
The pinned model, its thinking chain and its tool continuity are untouched.

Three properties make it affordable rather than wasteful:

- CONTENT-KEYED CACHE. Attachments replay on every request for the rest of the
  session, so a per-request description would be re-billed forever. Keyed by the
  digest of the bytes, an image is described exactly ONCE per process.
- OWN FAILURE POLICY. Like titling, the describe call never waits out a 429 and
  never fails over provider chains: the foreground turn owns the quota. A refusal
  or a deadline returns nil and the caller keeps today's behaviour.
- SECOND-HAND BY CONTRACT. The description is labelled as another model's report
  in the prompt, never as the agent's own sight, so pixel-exact work still goes
  through the imaging path.

A LEAF: svar + attachments + config + runtime-settings + toggles, never back on the loop.
raw docstring

com.blockether.vis.internal.channel.events

Process-local channel event bus.

Extensions use this to talk to mounted channels without depending on their implementation namespaces. Channels subscribe while running and translate events into their local state/events. No listener failure may take down the publisher or sibling listeners.

Process-local channel event bus.

Extensions use this to talk to mounted channels without depending on their
implementation namespaces. Channels subscribe while running and translate
events into their local state/events. No listener failure may take down the
publisher or sibling listeners.
raw docstring

com.blockether.vis.internal.channel.file-picker

Backend for file-picking UIs (the @ mention picker, TUI + web).

Everything here rides the ONE canonical pooled fff index (internal.fff-index) that the grep tool use: fff owns the tree walk, the gitignore policy, the git-status metadata and the frecency-ranked fuzzy match. This namespace only leases that index and turns fff rows into display rows.

There is deliberately NO Clojure-side directory walk, git-status subprocess, ignore matcher or scoring heuristic left in here — reintroducing one means the picker and the search tools would rank and see different files.

Backend for file-picking UIs (the `@` mention picker, TUI + web).

Everything here rides the ONE canonical pooled fff index
(`internal.fff-index`) that the `grep` tool use: fff owns the
tree walk, the gitignore policy, the git-status metadata and the
frecency-ranked fuzzy match. This namespace only leases that index and turns
fff rows into display rows.

There is deliberately NO Clojure-side directory walk, git-status subprocess,
ignore matcher or scoring heuristic left in here — reintroducing one means the
picker and the search tools would rank and see different files.
raw docstring

com.blockether.vis.internal.channel.form

The canonical per-form DISPLAY contract — ONE source of truth for the fields a channel reads to render an executed form, live (via the gateway) and restored (via the DB).

Why this exists: the SAME field set used to be hand-listed in independent allowlists across the loop, persistence, gateway, progress, and restored display paths. Now every layer projects the WHOLE set through ->display (outbound) / <-wire (inbound), so a new display field is a ONE-line change to display-keys and form-roundtrip-test fails if a boundary stops carrying it.

Transformed fields (:stdout/:error bounded, :silent/:duration_ms renamed) stay as explicit gateway overrides — they are not carried verbatim, so they are NOT in this set.

The canonical per-form DISPLAY contract — ONE source of truth for the fields a
channel reads to render an executed form, live (via the gateway) and restored
(via the DB).

Why this exists: the SAME field set used to be hand-listed in independent
allowlists across the loop, persistence, gateway, progress, and restored display
paths. Now every layer projects the WHOLE set through `->display` (outbound) /
`<-wire` (inbound), so a new display field is a ONE-line change to
`display-keys` and `form-roundtrip-test` fails if a boundary stops carrying it.

Transformed fields (`:stdout`/`:error` bounded, `:silent`/`:duration_ms`
renamed) stay as explicit gateway overrides — they are not carried verbatim, so
they are NOT in this set.
raw docstring

com.blockether.vis.internal.channel.header

Channel-agnostic header layout & content spec.

Every channel — the terminal TUI and future surfaces — renders the same conceptual header band:

[LEFT 30%]   [CENTER 40% workspace switcher]   [RIGHT 30%]

The decisions a channel cannot make on its own (slot ratios, workspace switcher sizing/visibility caps, default labels, copy id length, glyphs) live here as plain Clojure data. A channel imports this namespace, reads the values, and projects them onto its medium — TextGraphics cells for Lanterna, and so on.

No graphics. No I/O. No channel-specific deps. Pure data + tiny pure helpers, written as .cljc so a future ClojureScript client can require it directly.

Channel-agnostic header layout & content spec.

Every channel — the terminal TUI and future surfaces —
renders the same conceptual header band:

    [LEFT 30%]   [CENTER 40% workspace switcher]   [RIGHT 30%]

The decisions a channel cannot make on its own (slot ratios, workspace
switcher sizing/visibility caps, default labels, copy id length, glyphs)
live here as plain Clojure data. A channel imports this namespace,
reads the values, and projects them onto its medium — TextGraphics
cells for Lanterna, and so on.

No graphics. No I/O. No channel-specific deps. Pure data + tiny
pure helpers, written as `.cljc` so a future ClojureScript client can
require it directly.
raw docstring

com.blockether.vis.internal.channel.notifications

Cross-channel ephemeral notifications.

A single in-memory pub-sub the host runtime, every extension, and every channel can use to surface a transient signal - "copied to clipboard", "tests passed", "provider switched" - without embedding it in the answer body or polluting Telemere logs.

Surface (re-exported on com.blockether.vis.core):

(notify! text) (notify! text :level :info|:success|:warn|:error :ttl-ms <long>|nil) (notifications) ;; vec of currently-active entries (dismiss! id) ;; force-clear by id (clear-expired!) ;; prune; called on every read (watch! key (fn [vec] ...)) ;; cross-channel reactivity (unwatch! key)

Entry shape: {:id <uuid> :text <string> :level :info | :success | :warn | :error :created-at <inst> :until <epoch-ms> | nil ;; nil = sticky / manual dismiss

Levels are advisory metadata for channels: TUI uses them for colour, the CLI could prefix [notice] / [warn]. The host stores them but never interprets them.

Why a flat module instead of a generic event bus: notifications are a single, narrow concern. A 50-line atom + watcher map serves it without introducing a generic pub-sub abstraction nobody asked for. If we ever grow more event types, this becomes one consumer of a richer system.

Cross-channel ephemeral notifications.

A single in-memory pub-sub the host runtime, every extension, and
every channel can use to surface a transient signal - "copied to
clipboard", "tests passed", "provider switched" - without
embedding it in the answer body or polluting Telemere logs.

Surface (re-exported on `com.blockether.vis.core`):

  (notify! text)
  (notify! text :level :info|:success|:warn|:error
                :ttl-ms <long>|nil)
  (notifications)              ;; vec of currently-active entries
  (dismiss! id)                ;; force-clear by id
  (clear-expired!)             ;; prune; called on every read
  (watch! key (fn [vec] ...))    ;; cross-channel reactivity
  (unwatch! key)

Entry shape:
  {:id          <uuid>
   :text        <string>
   :level       :info | :success | :warn | :error
   :created-at  <inst>
   :until       <epoch-ms> | nil   ;; nil = sticky / manual dismiss

Levels are advisory metadata for channels: TUI uses them for
colour, the CLI could prefix `[notice]` /
`[warn]`. The host stores them but never interprets them.

Why a flat module instead of a generic event bus: notifications
are a single, narrow concern. A 50-line atom + watcher map serves
it without introducing a generic pub-sub abstraction nobody asked
for. If we ever grow more event types, this becomes one consumer
of a richer system.
raw docstring

com.blockether.vis.internal.channel.render

Transient Markdown parsing and renderer projections.

Canonical answers are role-labelled, string-keyed content blocks from com.blockether.vis.internal.content. Parsed Markdown trees are created only inside renderers and are never transported or persisted.

markdown->ast parses prose for renderer-local layout. render, extract-code, extract-text, and session->markdown are disposable projections; none of their intermediate trees are canonical message data.

Transient Markdown parsing and renderer projections.

Canonical answers are role-labelled, string-keyed content blocks from
`com.blockether.vis.internal.content`. Parsed Markdown trees are created only
inside renderers and are never transported or persisted.

`markdown->ast` parses prose for renderer-local layout. `render`,
`extract-code`, `extract-text`, and `session->markdown` are disposable
projections; none of their intermediate trees are canonical message data.
raw docstring

com.blockether.vis.internal.channel.slash

Channel-agnostic slash dispatch.

Slashes are DECLARATIVE: every extension carries :ext/slash-commands on its manifest; the engine derives the active slash set by walking (active-extensions environment) at lookup time. NO global atom, NO register-slash! imperative call.

Public surface (re-exported through core.clj):

(active-slashes env) -> vec of slash specs (slash-by-path env path) -> slash spec or nil (slash-children env parent) -> vec of slash specs whose :slash/parent = parent (parse text) -> {:path :args :raw} | nil (raw tokenisation only; does NOT consult any registry) (dispatch env ctx text) -> envelope (see below)

The dispatch envelope is the contract every channel renders against:

{:handled? true :result <slash result map> :path path} {:handled? true :error msg :reason :unknown :tokens tokens} {:handled? true :error msg :reason :requires-failed :missing #{} :path} {:handled? true :error msg :reason :unavailable :path} {:handled? true :error msg :reason :no-run-fn :path} {:handled? true :error msg :reason :run-failed :ex t :path} {:handled? false} -- text was not a slash; channel forwards to LLM.

A slash text is any non-blank string starting with / followed by at least one word. Plain prose without the leading / is ALWAYS {:handled? false}.

Slash run-fns may return an EXTENDED :slash/* envelope that carries a rendered result card back to the channel:

{:slash/status :ok | :error | :nothing-to-commit | :ff-failed :slash/title short headline (string, plain) :slash/body IR (vector starting with :ir ...) OR Markdown string :slash/actions [{:label :slash}] ;; optional follow-ups :slash/data arbitrary payload (workspace-id, sha, ...)}

Channel-agnostic slash dispatch.

Slashes are DECLARATIVE: every extension carries `:ext/slash-commands`
on its manifest; the engine derives the active slash set by walking
`(active-extensions environment)` at lookup time. NO global atom, NO
`register-slash!` imperative call.

Public surface (re-exported through `core.clj`):

  (active-slashes  env)              -> vec of slash specs
  (slash-by-path   env path)         -> slash spec or nil
  (slash-children  env parent)       -> vec of slash specs whose
                                        `:slash/parent` = parent
  (parse           text)             -> {:path :args :raw} | nil
                                        (raw tokenisation only;
                                         does NOT consult any registry)
  (dispatch        env ctx text)     -> envelope (see below)

The dispatch envelope is the contract every channel renders against:

  {:handled? true  :result <slash result map> :path path}
  {:handled? true  :error msg :reason :unknown :tokens tokens}
  {:handled? true  :error msg :reason :requires-failed :missing #{} :path}
  {:handled? true  :error msg :reason :unavailable :path}
  {:handled? true  :error msg :reason :no-run-fn :path}
  {:handled? true  :error msg :reason :run-failed :ex t :path}
  {:handled? false}  -- text was not a slash; channel forwards to LLM.

A slash text is any non-blank string starting with `/` followed by
at least one word. Plain prose without the leading `/` is ALWAYS
{:handled? false}.

Slash run-fns may return an EXTENDED `:slash/*` envelope that carries
a rendered result card back to the channel:

  {:slash/status :ok | :error | :nothing-to-commit | :ff-failed
   :slash/title  short headline (string, plain)
   :slash/body   IR (vector starting with :ir ...) OR Markdown string
   :slash/actions [{:label :slash}]   ;; optional follow-ups
   :slash/data    arbitrary payload (workspace-id, sha, ...)}
raw docstring

com.blockether.vis.internal.commandline

CLI command parsing, lookup, help rendering, dispatch.

The command spec, builder, and global registry now live in com.blockether.vis.internal.extension.registry alongside the channel and provider registries. This namespace provides the operations OVER those command maps:

Lookup find-leaf walk the tree consuming tokens until a match find-named same, but ignoring the root command's name

Argument parsing parse-args parse residual tokens against :cmd/args validate-args nil on success, error string on missing required

Help rendering render-command detailed help for one command render-tree top-level overview shown on vis with no args color-enabled? dynamic toggle (TTY auto-detect) pad-right width-padding helper pad-left width-padding helper

Dispatch dispatch! resolve, parse, validate, invoke :cmd/run-fn

The registry surface (command, register-cmd!, deregister-cmd!, registered-commands, registered-under, resolve-subcommands) is reachable through com.blockether.vis.internal.extension.registry.

CLI command parsing, lookup, help rendering, dispatch.

The command spec, builder, and global registry now live in
`com.blockether.vis.internal.extension.registry` alongside the channel and provider
registries. This namespace provides the operations OVER those
command maps:

  Lookup
    find-leaf       walk the tree consuming tokens until a match
    find-named      same, but ignoring the root command's name

  Argument parsing
    parse-args      parse residual tokens against `:cmd/args`
    validate-args   nil on success, error string on missing required

  Help rendering
    render-command  detailed help for one command
    render-tree     top-level overview shown on `vis` with no args
    *color-enabled?*  dynamic toggle (TTY auto-detect)
    pad-right       width-padding helper
    pad-left        width-padding helper

  Dispatch
    dispatch!       resolve, parse, validate, invoke `:cmd/run-fn`

The registry surface (`command`, `register-cmd!`, `deregister-cmd!`,
`registered-commands`, `registered-under`, `resolve-subcommands`)
is reachable through `com.blockether.vis.internal.extension.registry`.
raw docstring

com.blockether.vis.internal.config.core

Configuration: paths, JVM lifecycle, provider presets, svar-native coercion, config file I/O, and the active-provider state every channel reads through.

Two halves:

  • On-disk config under ~/.vis/: state.yml (machine-written), vis.mdb/, and this process's role/start-time/pid-stamped file under logs/ (log-path). init! / init-cli! / shutdown! redirect stdout/stderr into that log file and bring up Telemere's file handler.
  • Live process state: the active-config atom holds the currently-selected provider config; current-config, active-provider, active-model, provider-ids, has-provider? are the read API. reload-config! re-reads from disk.

The ->svar-provider helper resolves :api-key lazily by calling the registered provider's :provider/get-token-fn, so the token-refresh policy stays inside each provider implementation instead of leaking up here.

Configuration: paths, JVM lifecycle, provider presets, svar-native
coercion, config file I/O, and the active-provider state every
channel reads through.

Two halves:

  - On-disk config under `~/.vis/`: `state.yml` (machine-written), `vis.mdb/`, and
    this process's role/start-time/pid-stamped file under `logs/` (`log-path`).
    `init!` / `init-cli!` / `shutdown!` redirect stdout/stderr into
    that log file and bring up Telemere's file handler.
  - Live process state: the `active-config` atom holds the
    currently-selected provider config; `current-config`,
    `active-provider`, `active-model`, `provider-ids`,
    `has-provider?` are the read API. `reload-config!` re-reads
    from disk.

The `->svar-provider` helper resolves `:api-key` lazily by calling
the registered provider's `:provider/get-token-fn`, so the
token-refresh policy stays inside each provider implementation
instead of leaking up here.
raw docstring

com.blockether.vis.internal.config.runtime-settings

Per-eval / per-call runtime knobs for the loop: Python-sandbox eval timeouts (with clamping and a shell-timeout-aware widener), the svar/ask-code! stream watchdog defaults, and the dynamic vars the loop binds per call.

Also the HOST half of the cancel contract: park-blocking-wall stops a wall's clock while host code legitimately blocks.

A LEAF — depends on nothing else in the engine, so the loop and its tests read these settings from one place instead of carrying them in the loop namespace.

Per-eval / per-call runtime knobs for the loop: Python-sandbox eval timeouts
(with clamping and a shell-timeout-aware widener), the `svar/ask-code!` stream
watchdog defaults, and the dynamic vars the loop binds per call.

Also the HOST half of the cancel contract: [[park-blocking-wall]] stops a
wall's clock while host code legitimately blocks.

A LEAF — depends on nothing else in the engine, so the loop and its tests read
these settings from one place instead of carrying them in the loop namespace.
raw docstring

com.blockether.vis.internal.config.toggles

Process-wide feature-toggle registry.

Replaces the two parallel toggle plumbings we had drifting apart:

  • hard-coded TUI booleans (:show-thinking, :show-iterations, :show-silent) wired into state/default-settings,
  • per-render if (some-flag …) … else … checks scattered through internal/render.clj and channel-tui's render layer.

A toggle has stable metadata (id, label, description, default, owner) and a current ON/OFF value. Anyone — internal modules, extensions, channels — registers their toggles into the same registry; any caller flips a toggle through the same set!. The TUI settings dialog walks the registry to render the list, so adding a new toggle from an extension shows up in the user's UI without any TUI patch.

Persistence is opt-in: register-toggle! accepts :persist? true and on set! the wrapper writes {:toggles {id value}} into the machine store ~/.vis/state.yml via vis.config/save-config!. Hand-authored vis.yml / config.yml may ALSO declare a toggles: block. Ids are plain snake_case strings there (reasoning_level: deep), identical to the registered id. coerce-config-value maps YAML strings onto each toggle's type, so a config-declared toggle behaves exactly like a UI flip. Hydration happens at process start (call hydrate-from-config! once after config/load-config-raw) and again on /reload, so editing the YAML applies without a restart.

Contract:

  • Toggle ids are non-blank snake_case strings (reasoning_level, shell, ...) — no keywords, namespaces, slashes, or kebab-case. YAML config uses the same string verbatim (reasoning_level: deep).
  • A :description is ONE line within the portable bound owned by com.blockether.vis.contract.toggle — the settings row is a label plus a single sentence of help in every channel; longer rationale lives in the owning namespace's docstring.
  • enabled? is cheap (single atom deref + string lookup), called per-paint per-row by the render layer; do not turn it into a function-call indirection.
  • Defaults are immutable once registered. Re-registering the same id is allowed (idempotent boot path) and merges over prior metadata; the live VALUE in state is left alone so a user override survives a reload.
Process-wide feature-toggle registry.

Replaces the two parallel toggle plumbings we had drifting apart:
  - hard-coded TUI booleans (`:show-thinking`, `:show-iterations`,
    `:show-silent`) wired into `state/default-settings`,
  - per-render `if (some-flag …) … else …` checks scattered through
    `internal/render.clj` and channel-tui's render layer.

A toggle has stable metadata (`id`, label, description, default,
owner) and a current ON/OFF value. Anyone — internal modules,
extensions, channels — registers their toggles into the same
registry; any caller flips a toggle through the same `set!`. The
TUI settings dialog walks the registry to render the list, so
adding a new toggle from an extension shows up in the user's UI
without any TUI patch.

Persistence is opt-in: `register-toggle!` accepts `:persist? true`
and on `set!` the wrapper writes `{:toggles {id value}}` into the
machine store `~/.vis/state.yml` via `vis.config/save-config!`.
Hand-authored `vis.yml` / `config.yml` may ALSO declare a `toggles:`
block. Ids are plain snake_case strings there (`reasoning_level: deep`),
identical to the registered id. `coerce-config-value` maps YAML strings
onto each toggle's type, so a config-declared toggle behaves exactly like
a UI flip. Hydration happens at process start (call
`hydrate-from-config!` once after `config/load-config-raw`) and again on
`/reload`, so editing the YAML applies without a restart.

Contract:
  - Toggle ids are non-blank snake_case strings (`reasoning_level`,
    `shell`, ...) — no keywords, namespaces, slashes, or kebab-case.
    YAML config uses the same string verbatim (`reasoning_level: deep`).
  - A `:description` is ONE line within the portable bound owned by
    `com.blockether.vis.contract.toggle` — the settings row is a label
    plus a single sentence of help in every channel; longer rationale
    lives in the owning namespace's docstring.
  - `enabled?` is cheap (single atom deref + string lookup), called
    per-paint per-row by the render layer; do not turn it into a
    function-call indirection.
  - Defaults are immutable once registered. Re-registering the
    same id is allowed (idempotent boot path) and merges over
    prior metadata; the live VALUE in `state` is left alone so a
    user override survives a reload.
raw docstring

com.blockether.vis.internal.content

Canonical-content builders, normalization and disposable text projection.

com.blockether.vis.contract.content owns the persisted and transported shapes. This namespace constructs those JSON-ready, snake_case values and converts final engine answers into them; Markdown exists only as the payload of a prose block.

Canonical-content builders, normalization and disposable text projection.

`com.blockether.vis.contract.content` owns the persisted and transported shapes.
This namespace constructs those JSON-ready, snake_case values and converts final
engine answers into them; Markdown exists only as the payload of a prose block.
raw docstring

com.blockether.vis.internal.context.agents

Project-guidance discovery — internal, no extension required.

STACKED context files, pi-style: guidance is collected from THREE layers and all of them ride into the PROJECT-INSTRUCTIONS system block, outermost first:

  1. user-global ~/.vis/AGENTS.md (or ~/.vis/CLAUDE.md)
  2. ancestors AGENTS.md / CLAUDE.md in every ancestor directory of the workspace root (outermost first)
  3. workspace AGENTS.md / CLAUDE.md at the workspace root
  4. added roots AGENTS.md / CLAUDE.md at each ADDED filesystem root's own directory (folders granted beyond the primary workspace — no ancestor walk)

Per DIRECTORY precedence is strict: AGENTS.md wins; CLAUDE.md is only consulted when AGENTS.md is absent in that directory. Across directories nothing is dropped — nearer files are rendered LATER so they positionally override outer rules on conflict.

Size policy: NO truncation. Every file goes into the system prompt verbatim. Provider prompt caching amortizes the cost across every iter in the session; trimming would risk dropping the very rule the user is testing. The cwd + per-file (path, mtime, length) marker cache below ensures files are re-read at most once per change.

Failure modes (file unreadable, permissions, I/O error) land in the read-warning vec, NOT in the rendered prompt. The model isn't bound by rules it can't see, but the host knows something is broken.

This namespace replaces the foundation-core/environment/agents.clj that used to live in the extension. Project-guidance discovery is core functionality (drives the system prompt + slim ctx digest); the extension layer no longer owns it.

Project-guidance discovery — internal, no extension required.

STACKED context files, pi-style: guidance is collected from THREE
layers and all of them ride into the PROJECT-INSTRUCTIONS system
block, outermost first:

  1. user-global   `~/.vis/AGENTS.md` (or `~/.vis/CLAUDE.md`)
  2. ancestors     `AGENTS.md` / `CLAUDE.md` in every ancestor
                   directory of the workspace root (outermost first)
  3. workspace     `AGENTS.md` / `CLAUDE.md` at the workspace root
4. added roots   `AGENTS.md` / `CLAUDE.md` at each ADDED filesystem
                 root's own directory (folders granted beyond the
                 primary workspace — no ancestor walk)

Per DIRECTORY precedence is strict: AGENTS.md wins; CLAUDE.md is
only consulted when AGENTS.md is absent in that directory. Across
directories nothing is dropped — nearer files are rendered LATER so
they positionally override outer rules on conflict.

Size policy: NO truncation. Every file goes into the system prompt
verbatim. Provider prompt caching amortizes the cost across every
iter in the session; trimming would risk dropping the very rule the
user is testing. The cwd + per-file (path, mtime, length) marker
cache below ensures files are re-read at most once per change.

Failure modes (file unreadable, permissions, I/O error) land in the
read-warning vec, NOT in the rendered prompt. The model isn't bound
by rules it can't see, but the host knows something is broken.

This namespace replaces the foundation-core/environment/agents.clj
that used to live in the extension. Project-guidance discovery is
core functionality (drives the system prompt + slim ctx digest); the
extension layer no longer owns it.
raw docstring

com.blockether.vis.internal.context.engine

Pure helpers for the model-facing context snapshot.

The mutable session state lives outside this namespace; these functions advance the turn/iteration cursor and compute utilization metadata.

Pure helpers for the model-facing context snapshot.

The mutable session state lives outside this namespace; these functions advance
the turn/iteration cursor and compute utilization metadata.
raw docstring

com.blockether.vis.internal.context.env-digest

Slim "session_env" digest. Internal, not extension-owned. STRING-KEYED — crosses the Python boundary as session["env"], so keys AND enum values (os/shell/kind/primary_language) are strings, never keywords.

Produces a bounded map the model reads each iter:

{"host" {"os" "shell" "clock"} ; cwd lives in session["workspace"]["root"] "project" {"kind" "primary_language"} "extensions" {"active_count" "aliases"}}

Each slice is small (~50 bytes), so the section costs <200 bytes/turn. Extensions deep-merge their own slices via :ext/ctx-fn returning {"session_env" {their-key {…}}}; the merge happens in ctx-loop/render-block! so internal owns the base section, extensions layer on top.

Heavy environment scans (full byte-counted language tables, polylith brick listings, multi-repo git status) live in the foundation-core focused project-shape helpers for explicit deep-dives. The digest never calls into extensions — host facts come from System/getProperty, project shape from a single directory peek. AGENTS.md / CLAUDE.md contents ride in their own system block (internal.prompt), not here.

Slim `"session_env"` digest. Internal, not extension-owned. STRING-KEYED —
crosses the Python boundary as `session["env"]`, so keys AND enum values
(os/shell/kind/primary_language) are strings, never keywords.

Produces a bounded map the model reads each iter:

  {"host"       {"os" "shell" "clock"}   ; cwd lives in session["workspace"]["root"]
   "project"    {"kind" "primary_language"}
   "extensions" {"active_count" "aliases"}}

Each slice is small (~50 bytes), so the section costs <200 bytes/turn.
Extensions deep-merge their own slices via `:ext/ctx-fn` returning
`{"session_env" {their-key {…}}}`; the merge happens in
`ctx-loop/render-block!` so internal owns the base section, extensions
layer on top.

Heavy environment scans (full byte-counted language tables, polylith
brick listings, multi-repo git status) live in the foundation-core
focused project-shape helpers for explicit deep-dives. The digest never calls
into extensions — host facts come from `System/getProperty`,
project shape from a single directory peek. AGENTS.md / CLAUDE.md
contents ride in their own system block (`internal.prompt`), not here.
raw docstring

com.blockether.vis.internal.context.loop

Loop integration layer for context management.

The loop keeps a per-session :ctx-atom for stable model-facing context and a separate :turn-state-atom for live execution state and counters. Council publication identity and references live there only during execution; activation resources and retry input belong to the session runtime registry. This namespace stamps the cursor, enriches context with env/access/routing, and renders the standing context block. Live resources (background shells, managed REPLs) are deliberately NOT part of ctx: a handle or repl_status answers for them, so nothing about them is reprinted on every request.

Loop integration layer for context management.

The loop keeps a per-session `:ctx-atom` for stable model-facing context
and a separate `:turn-state-atom` for live execution state and counters.
Council publication identity and references live there only during execution;
activation resources and retry input belong to the session runtime registry.
This namespace stamps the cursor, enriches context with env/access/routing,
and renders the standing context block. Live resources (background shells,
managed REPLs) are deliberately NOT part of ctx: a handle or `repl_status`
answers for them, so nothing about them is reprinted on every request.
raw docstring

com.blockether.vis.internal.context.prompt

Prompt assembly.

Provider messages are explicit blocks in send order: core system rules, project instructions (AGENTS.md / CLAUDE.md when present), extension fragments, current user message. Per-iteration user-role context is the engine snapshot rendered as a Python dict (session) by the loop.

Prompt assembly.

Provider messages are explicit blocks in send order: core system rules,
project instructions (AGENTS.md / CLAUDE.md when present), extension
fragments, current user message. Per-iteration user-role context is the
engine snapshot rendered as a Python dict (`session`) by the loop.
raw docstring

com.blockether.vis.internal.context.prompt-templates

File-based prompt templates — pi-style slash-expandable markdown prompts.

A template is a *.md file whose body becomes the user message when the user types /<name> [args…]. Discovery, project wins over global:

  1. <workspace>/.vis/prompts/*.md (project)
  2. ~/.vis/prompts/*.md (user-global)

Frontmatter is the same minimal --- fenced key: value block the harness discovery reads: name (defaults to the filename stem) and description. The body is the template.

Argument handling matches the common harness convention: when the body contains $ARGUMENTS every occurrence is substituted with the raw argument string (empty when none given); otherwise non-blank args are appended after the body on their own paragraph.

Extensions can contribute DYNAMIC templates through register-provider! — e.g. the harness extension exposes every discovered skill as /skill:<name>. File templates win on a name collision; among providers, registration order wins.

Dispatch: the engine consults expand ONLY for slash texts no registered extension slash claimed (slash/dispatch returned :reason :unknown), so real slash commands always win — same precedence pi uses.

File-based prompt templates — pi-style slash-expandable markdown prompts.

A template is a `*.md` file whose body becomes the user message when
the user types `/<name> [args…]`. Discovery, project wins over global:

  1. `<workspace>/.vis/prompts/*.md`   (project)
  2. `~/.vis/prompts/*.md`             (user-global)

Frontmatter is the same minimal `---` fenced `key: value` block the
harness discovery reads: `name` (defaults to the filename stem) and
`description`. The body is the template.

Argument handling matches the common harness convention: when the
body contains `$ARGUMENTS` every occurrence is substituted with the
raw argument string (empty when none given); otherwise non-blank
args are appended after the body on their own paragraph.

Extensions can contribute DYNAMIC templates through
`register-provider!` — e.g. the harness extension exposes every
discovered skill as `/skill:<name>`. File templates win on a name
collision; among providers, registration order wins.

Dispatch: the engine consults `expand` ONLY for slash texts no
registered extension slash claimed (`slash/dispatch` returned
`:reason :unknown`), so real slash commands always win — same
precedence pi uses.
raw docstring

com.blockether.vis.internal.context.renderer

Pure renderer for the standing agent-facing session snapshot.

render-ctx-static projects the session view and serializes it as a Python literal with pure JVM code. The live sandbox session dict is built from the same boundary projection, and render-ctx-delta emits executable updates.

Pure renderer for the standing agent-facing `session` snapshot.

`render-ctx-static` projects the session view and serializes it as a Python
literal with pure JVM code. The live sandbox `session` dict is built from the
same boundary projection, and `render-ctx-delta` emits executable updates.
raw docstring

com.blockether.vis.internal.council.host

Python host surface. Identity comes from the environment, never the mutable Python session dict.

Python host surface. Identity comes from the environment, never the mutable Python session dict.
raw docstring

com.blockether.vis.internal.docs.core

Embedded documentation from the explicit records listed by META-INF/vis/manifest.edn.

Each documentation record names one exact Markdown resource; no classpath enumeration or alternate docs manifest exists. The same records feed the live docs site and the sandbox doc/apropos surface, and how a page is TITLED, grouped and ordered — everything only this site reads — is vis-docs/site.edn. THE PAGE CONTRACT — one canonical shape for every page, enforced by docs-test/docs-page-canon-test:

  • The :title in vis-docs/site.edn IS the page's # H1, spelled identically, on the FIRST line of the file: the sidebar, the browser tab and the page itself must never disagree about a page's name. index.md is the ONE exception — its title is rendered from the site navigation, so it carries no # at all.
  • Under the H1 comes a LEAD paragraph, before the first ##: what this page covers, so a reader who stops there still knows what they found.
  • ## and ### only. A deeper heading gets no id and no on-this-page entry (see anchors+toc), so nothing — not even this page — can link to it.
  • Anchors are unique within a page, and every relative page.md#anchor link resolves against the TARGET page's own toc.
  • Every fenced block declares a language, one of bash, clojure, edn, ini, java, json, markdown, python, text, toml, xml, yaml.
  • index.md is the MAP: it links every other page under ## Learn more, with that page's title as the link text, so a page nobody can reach from the landing page does not exist for a reader.
  • The last ## of every page is See also — two or more sibling pages, each with the reason to follow it. That web is what keeps ONE topic in ONE page: a topic explained twice is a cross-link somebody never wrote.
  • Every page carries a :blurb in vis-docs/site.edn, the one sentence the sidebar and the index cards show.
  • NO WALL OF TEXT: one paragraph — or one list item with its continuation lines — stays under 800 characters. Past that the reader is handed a table or a list as prose, and the structure is usually already in the sentence (A; B; C, first … then … finally): write it as the list it is.

One renderer, two outputs:

  • build-site! writes a static, themed HTML bundle for the public Worker.
  • handle serves the same pages live (HTMX nav), mountable on the gateway via its :gateway.slot/http-routes slot.

Markdown → HTML uses commonmark-java. Static and live pages share a responsive layout with navigation, article content and a table of contents.

Embedded documentation from the explicit records listed by
`META-INF/vis/manifest.edn`.

Each documentation record names one exact Markdown resource; no classpath
enumeration or alternate docs manifest exists. The same records feed the live
docs site and the sandbox `doc`/`apropos` surface, and how a page is TITLED,
grouped and ordered — everything only this site reads — is `vis-docs/site.edn`.
THE PAGE CONTRACT — one canonical shape for every page, enforced by
`docs-test/docs-page-canon-test`:

  * The `:title` in `vis-docs/site.edn` IS the page's `# H1`, spelled
    identically, on the FIRST line of the file: the sidebar, the browser tab
    and the page itself must never disagree about a page's name. `index.md` is
    the ONE exception — its title is rendered from the site navigation, so it
    carries no `#` at all.
  * Under the H1 comes a LEAD paragraph, before the first `##`: what this page
    covers, so a reader who stops there still knows what they found.
  * `##` and `###` only. A deeper heading gets no `id` and no on-this-page
    entry (see `anchors+toc`), so nothing — not even this page — can link to it.
  * Anchors are unique within a page, and every relative `page.md#anchor` link
    resolves against the TARGET page's own toc.
  * Every fenced block declares a language, one of `bash`, `clojure`, `edn`,
    `ini`, `java`, `json`, `markdown`, `python`, `text`, `toml`, `xml`, `yaml`.
  * `index.md` is the MAP: it links every other page under `## Learn more`,
    with that page's title as the link text, so a page nobody can reach from
    the landing page does not exist for a reader.
  * The last `##` of every page is `See also` — two or more sibling pages, each
    with the reason to follow it. That web is what keeps ONE topic in ONE page:
    a topic explained twice is a cross-link somebody never wrote.
  * Every page carries a `:blurb` in `vis-docs/site.edn`, the one sentence the
    sidebar and the index cards show.
  * NO WALL OF TEXT: one paragraph — or one list item with its continuation
    lines — stays under 800 characters. Past that the reader is handed a
    table or a list as prose, and the structure is usually already in the
    sentence (`A; B; C`, `first … then … finally`): write it as the list it is.

One renderer, two outputs:
  * `build-site!` writes a static, themed HTML bundle for the public Worker.
  * `handle` serves the same pages live (HTMX nav), mountable on the gateway
    via its `:gateway.slot/http-routes` slot.

Markdown → HTML uses commonmark-java. Static and live pages share a
responsive layout with navigation, article content and a table of contents.
raw docstring

com.blockether.vis.internal.docs.corpus

The one ordered document corpus behind apropos(pattern) and doc(name).

Static resources and live sources contribute the same closed record shape. Invalid static records fail at load; invalid dynamic records are logged and dropped.

entries is the whole corpus in source order, deduplicated by EXACT name; pages is the documentation subset the docs site renders. apropos applies one regular expression to record names and preserves corpus order — there is no ranking, tokenization, search index or classpath discovery. doc retrieves the same record by name and prints its whole text.

The one ordered document corpus behind `apropos(pattern)` and `doc(name)`.

Static resources and live sources contribute the same closed record shape.
Invalid static records fail at load; invalid dynamic records are logged and dropped.

`entries` is the whole corpus in source order, deduplicated by EXACT name; `pages`
is the documentation subset the docs site renders. `apropos` applies one regular
expression to record names and preserves corpus order — there is no ranking,
tokenization, search index or classpath discovery. `doc` retrieves the same record
by name and prints its whole text.
raw docstring

com.blockether.vis.internal.doctor

Doctor protocol: aggregates :ext/doctor-fn from every registered extension into a single cross-cutting diagnostic surface. vis-agent doctor invokes run-checks then format-output + exit-code.

Plan §1 Q19 + §10:

  • One fn per extension. The fn returns a seq of message maps; the extension self-stamps :check-id on each message when it wants the formatter's per-section prefix.
  • Output ordering: extensions in registration order; messages in fn-return order. Levels NOT re-sorted within a section - cause-and-effect narrative preserved.
  • Activation contract: the fn runs for EVERY registered extension regardless of :ext/activation-fn. Doctor fns must defensively handle missing env keys.
  • Exit codes: 0 if only :info or empty; 1 if any :warn (no :error); 2 if any :error.
  • TTY-detected ANSI colors. UTF-8 icons by default.
Doctor protocol: aggregates `:ext/doctor-fn` from every
registered extension into a single cross-cutting diagnostic
surface. `vis-agent doctor` invokes [[run-checks]] then
[[format-output]] + [[exit-code]].

Plan §1 Q19 + §10:
  - One fn per extension. The fn returns a seq of message maps;
    the extension self-stamps `:check-id` on each message when it
    wants the formatter's per-section prefix.
  - Output ordering: extensions in registration order; messages
    in fn-return order. Levels NOT re-sorted within a section -
    cause-and-effect narrative preserved.
  - Activation contract: the fn runs for EVERY registered
    extension regardless of `:ext/activation-fn`. Doctor fns must
    defensively handle missing env keys.
  - Exit codes: 0 if only :info or empty; 1 if any :warn (no
    :error); 2 if any :error.
  - TTY-detected ANSI colors. UTF-8 icons by default.
raw docstring

com.blockether.vis.internal.error

Error formatting - leaf module.

Pure functions for turning exceptions, anomaly maps, and ad-hoc error values into human-readable strings. Lives in its own namespace so any layer (SDK facade, channels, extensions, the iteration loop) can format an error without dragging in the rest of the SDK.

Public API: error-message - raw text from a Throwable / map / string format-error - prefix "ERROR: " idempotently final-answer-code-error-message - the "Final-answer code error: ..." prefix used by the iteration loop when an (done ...) form's own code throws.

Error formatting - leaf module.

Pure functions for turning exceptions, anomaly maps, and ad-hoc
error values into human-readable strings. Lives in its own
namespace so any layer (SDK facade, channels, extensions, the
iteration loop) can format an error without dragging in the rest
of the SDK.

Public API:
  `error-message`                 - raw text from a Throwable / map / string
  `format-error`                  - prefix "ERROR: " idempotently
  `final-answer-code-error-message` - the "Final-answer code error: ..." prefix
                                     used by the iteration loop when an
                                     `(done ...)` form's own code throws.
raw docstring

com.blockether.vis.internal.extension.aggregate

Extension-owned durable sidecar API.

Public ext-* helpers are for code running inside an extension callback. They never accept :extension-id from callers; the currently executing extension identity is supplied by com.blockether.vis.internal.extension.

The db-* persistence facade remains the privileged/admin surface for inspecting rows across extensions.

Extension-owned durable sidecar API.

Public ext-* helpers are for code running inside an extension callback.
They never accept :extension-id from callers; the currently executing
extension identity is supplied by com.blockether.vis.internal.extension.

The db-* persistence facade remains the privileged/admin surface for
inspecting rows across extensions.
raw docstring

com.blockether.vis.internal.extension.capability

What THIS machine can actually run, asked once and answered as data.

A pack that REGISTERED exists in this process. Whether the machine behind it works is a different question, asked at a different time: landing a pack costs an atom write and happens in manifest order for every session, while a capability costs whatever its probe costs and is paid by the FIRST caller that needs it — so a session that never speaks never loads sherpa's 8-13 MB of natives, and a session that never speaks never waits for them either.

No heuristic decides this. maxMemory, an OS name and an arch say nothing about a glibc version, a missing libstdc++, or a CPU without the instructions a wheel was built for: they refuse machines that would have worked and admit machines that die inside the linker. The probe asks the real question by doing the real thing, once.

A verdict is remembered only when asking again cannot answer differently:

  • :ready — it worked, and nothing in this process will unwork it.
  • :unavailable :kind :terminal — the JVM already froze this answer. A class whose static initializer failed can NEVER load again in the same process, so probing again can only re-download a library and reprint a linker error nobody can act on. That state is the reported "voice only works after restarting Vis", which is why the verdict, not the caller, is what says so.
  • :unavailable :kind :transient — no network, a missing file, a full disk. NOT remembered: the next ask retries.

The state atom starts EMPTY and stays empty at load. graal-build-time initializes every Clojure namespace inside the BUILDER, so a value computed by a top-level form is baked into the image heap of every installed binary: a probed verdict would ship the BUILD machine's answer to every user. That cost is already paid for once — sherpa/default-native-dir is a function for this reason, and TTYDeviceControl is the single --initialize-at-run-time in build.clj after a build-time SUPPORTED=true SIGSEGV'd v0.1.33-35.

What THIS machine can actually run, asked once and answered as data.

A pack that REGISTERED exists in this process. Whether the machine behind it
works is a different question, asked at a different time: landing a pack costs
an atom write and happens in manifest order for every session, while a
capability costs whatever its probe costs and is paid by the FIRST caller that
needs it — so a session that never speaks never loads sherpa's 8-13 MB of
natives, and a session that never speaks never waits for them either.

No heuristic decides this. `maxMemory`, an OS name and an arch say nothing
about a glibc version, a missing `libstdc++`, or a CPU without the instructions
a wheel was built for: they refuse machines that would have worked and admit
machines that die inside the linker. The probe asks the real question by doing
the real thing, once.

A verdict is remembered only when asking again cannot answer differently:

- `:ready` — it worked, and nothing in this process will unwork it.
- `:unavailable` `:kind :terminal` — the JVM already froze this answer. A class
  whose static initializer failed can NEVER load again in the same process, so
  probing again can only re-download a library and reprint a linker error
  nobody can act on. That state is the reported "voice only works after
  restarting Vis", which is why the verdict, not the caller, is what says so.
- `:unavailable` `:kind :transient` — no network, a missing file, a full disk.
  NOT remembered: the next ask retries.

The state atom starts EMPTY and stays empty at load. `graal-build-time`
initializes every Clojure namespace inside the BUILDER, so a value computed by
a top-level form is baked into the image heap of every installed binary: a
probed verdict would ship the BUILD machine's answer to every user. That cost
is already paid for once — `sherpa/default-native-dir` is a function for this
reason, and `TTYDeviceControl` is the single `--initialize-at-run-time` in
`build.clj` after a build-time `SUPPORTED=true` SIGSEGV'd v0.1.33-35.
raw docstring

com.blockether.vis.internal.extension.core

Extension subsystem: spec, builders, hook execution, the global registry, and parse-error rescue.

An extension is the SINGLE entry point for everything a third-party bundle contributes to vis. Whatever surfaces it populates - Python sandbox symbols, CLI commands, channels, providers, persistence backends - it does so by listing them in the matching :ext/<surface> slot, and register-extension! dispatches each slot to its concrete sub-registry. The same data feeds:

  • the active-extensions list every iteration consults
  • the system-prompt block rendered from :ext.engine/symbols
  • the per-iteration :ext/hooks checks
  • the parse-error rescue chain

Channel and provider registries live in internal.registry; the persistence backend table lives in internal.persistance. The one ordered distribution manifest invokes each extension's explicit registration function.

Extension subsystem: spec, builders, hook execution, the global registry,
and parse-error rescue.

An extension is the SINGLE entry point for everything a third-party
bundle contributes to vis. Whatever surfaces it populates - Python
sandbox symbols, CLI commands, channels, providers, persistence
backends - it does so by listing them in the matching `:ext/<surface>`
slot, and `register-extension!` dispatches each slot to its concrete
sub-registry. The same data feeds:

  - the active-extensions list every iteration consults
  - the system-prompt block rendered from `:ext.engine/symbols`
  - the per-iteration `:ext/hooks` checks
  - the parse-error rescue chain

Channel and provider registries live in `internal.registry`; the persistence
backend table lives in `internal.persistance`. The one ordered distribution manifest invokes
each extension's explicit registration function.
raw docstring

com.blockether.vis.internal.extension.manifest

The ordered registration list for Vis' built-in modules, not a user extension loader.

META-INF/vis/manifest.edn has one key, :initialization. Each entry is a qualified registration symbol, or {:register qualified.ns/register! :apropos "META-INF/vis/apropos/docs.edn"} when the module owns static docs. Keeping documents beside their owner preserves discovery order.

Every built-in is required. Registration runs once in dependency order; a failure stops initialization rather than leaving a partial engine. Successful entries are remembered so a retry does not register them again. User extensions are Python files, loaded separately by internal.python.extensions.

Nothing scans the classpath and there is no alternate manifest format.

The ordered registration list for Vis' built-in modules, not a user extension loader.

`META-INF/vis/manifest.edn` has one key, `:initialization`. Each entry is a
qualified registration symbol, or `{:register qualified.ns/register!
:apropos "META-INF/vis/apropos/docs.edn"}` when the module owns static docs.
Keeping documents beside their owner preserves discovery order.

Every built-in is required. Registration runs once in dependency order; a failure
stops initialization rather than leaving a partial engine. Successful entries are
remembered so a retry does not register them again. User extensions are Python
files, loaded separately by `internal.python.extensions`.

Nothing scans the classpath and there is no alternate manifest format.
raw docstring

com.blockether.vis.internal.extension.native-preload

Build-time reachability for everything the engine loads DYNAMICALLY.

The manifest resolves an entrypoint with requiring-resolve, a pack reaches its own optional half the same way (voice's ASR), and a native image contains only what the BUILDER saw. A namespace nobody loads while the image is built therefore has no class in the binary, and the first command that wants it dies with "Could not locate ...__init.class on classpath" - on a binary whose JVM run is perfectly healthy. Loading them here puts them in clojure.lang.RT's namespace map, which IS part of the image heap, so the same resolve at run time finds them already loaded.

NOTHING requires this namespace. Vis' native-image.properties names its __init class in --initialize-at-build-time, so the builder loads it outside any other namespace's load: an entrypoint requiring the engine back would otherwise create a cycle. A JVM run never loads this file, so manifest registration stays lazy.

The engine namespace list is derived from the manifest's entrypoints and the compiled namespace tree. Formatter and analyzer dependencies are required here: JVM registration defers them, but the native image must retain their code.

Build-time reachability for everything the engine loads DYNAMICALLY.

The manifest resolves an entrypoint with `requiring-resolve`, a pack reaches
its own optional half the same way (voice's ASR), and a
native image contains only what the BUILDER saw. A namespace nobody loads
while the image is built therefore has no class in the binary, and the first
command that wants it dies with "Could not locate ...__init.class on
classpath" - on a binary whose JVM run is perfectly healthy. Loading them
here puts them in `clojure.lang.RT`'s namespace map, which IS part of the
image heap, so the same resolve at run time finds them already loaded.

NOTHING requires this namespace. Vis' `native-image.properties` names its
`__init` class in `--initialize-at-build-time`, so the builder loads it outside
any other namespace's load: an entrypoint requiring the engine back would
otherwise create a cycle. A JVM run never
loads this file, so manifest registration stays lazy.

The engine namespace list is derived from the manifest's entrypoints and the
compiled namespace tree. Formatter and analyzer dependencies are required here:
JVM registration defers them, but the native image must retain their code.
raw docstring

com.blockether.vis.internal.extension.registry

Three global registries in one place: channels, providers, commands.

Each descriptor is checked by a local predicate before entering its process registry.

Channel registry (:channel/id keyword): channel build + validate a descriptor register-channel! register, idempotent on :channel/id deregister-channel! remove by id registered-channels all entries, vec channel-by-id lookup by id by-cmd lookup by :channel/cmd

Provider registry (:provider/id keyword): provider build + validate a descriptor register-provider! register, idempotent on :provider/id deregister-provider! remove by id registered-providers all entries, vec provider-by-id lookup by id

Command registry ([:cmd/parent :cmd/name] tuple key): command build + validate a descriptor resolve-subcommands static vec or dynamic 0-arg fn -> vec register-cmd! register, idempotent on [parent name] deregister-cmd! remove by [parent name] registered-commands all entries, vec (registration order) registered-under filter by parent path

Channel mounting: channel-subcommands compose vis-agent channels subcommand vec from the channel registry + any commands registered with :cmd/parent ["channels"]. Loading this ns also registers the vis-agent channels parent itself. Parsing / help rendering / dispatch utilities live in com.blockether.vis.internal.commandline. The closed initialization manifest lives in com.blockether.vis.internal.extension.manifest.

Three global registries in one place: channels, providers, commands.

Each descriptor is checked by a local predicate before entering its process registry.

Channel registry (`:channel/id` keyword):
  channel                  build + validate a descriptor
  register-channel!        register, idempotent on :channel/id
  deregister-channel!      remove by id
  registered-channels      all entries, vec
  channel-by-id            lookup by id
  by-cmd                   lookup by :channel/cmd

Provider registry (`:provider/id` keyword):
  provider                 build + validate a descriptor
  register-provider!       register, idempotent on :provider/id
  deregister-provider!     remove by id
  registered-providers     all entries, vec
  provider-by-id           lookup by id

Command registry (`[:cmd/parent :cmd/name]` tuple key):
  command                  build + validate a descriptor
  resolve-subcommands      static vec or dynamic 0-arg fn -> vec
  register-cmd!            register, idempotent on [parent name]
  deregister-cmd!          remove by [parent name]
  registered-commands      all entries, vec (registration order)
  registered-under         filter by parent path

Channel mounting:
  channel-subcommands      compose `vis-agent channels` subcommand vec
                           from the channel registry + any commands
                           registered with `:cmd/parent ["channels"]`.
                           Loading this ns also registers the
                           `vis-agent channels` parent itself.
Parsing / help rendering / dispatch utilities live in
`com.blockether.vis.internal.commandline`. The closed initialization manifest
lives in `com.blockether.vis.internal.extension.manifest`.
raw docstring

com.blockether.vis.internal.external-opener

Shell out to the host OS opener so Vis can hand a URL or local file path off to the user's preferred external browser/viewer.

Responsibilities, in order:

  1. Classify the candidate target into a whitelisted scheme keyword: :http, :https, :file, :rel, or :rejected.

  2. Resolve the target to a host-friendly form. Relative paths are anchored at the current working directory and re-checked for .. traversal. Returns nil when the path escapes.

  3. Build the OS-appropriate command vector (open / xdg-open / cmd /c start) for ProcessBuilder.

  4. Spawn it with stdio redirected to /dev/null so a chatty opener cannot corrupt terminal output.

Pure-ish: every step except open! itself is a function of its args plus os.name and the current working directory. open! shells out and never throws; errors land in the returned result map.

Shell out to the host OS opener so Vis can hand a URL or local file
path off to the user's preferred external browser/viewer.

Responsibilities, in order:

  1. Classify the candidate target into a whitelisted scheme keyword:
     `:http`, `:https`, `:file`, `:rel`, or `:rejected`.

  2. Resolve the target to a host-friendly form. Relative paths are
     anchored at the current working directory and re-checked for
     `..` traversal. Returns nil when the path escapes.

  3. Build the OS-appropriate command vector
     (`open` / `xdg-open` / `cmd /c start`) for `ProcessBuilder`.

  4. Spawn it with stdio redirected to /dev/null so a chatty opener
     cannot corrupt terminal output.

Pure-ish: every step except `open!` itself is a function of its
args plus `os.name` and the current working directory. `open!`
shells out and never throws; errors land in the returned result map.
raw docstring

com.blockether.vis.internal.format

Format helpers - leaf module.

Small, dependency-light formatters used by the SDK facade, the TUI footer and the CLI status output. Each one is a pure transform over basic Clojure / Java values.

format-date - java.util.Date to dd-MM-yyyy HH:mm (local TZ) format-clojure - pass-through (code is shown as written, not reformatted) format-duration - millisecond duration to 2.3s, 1m 15s, etc. format-bytes - byte count to 441B / 1.2KB / 150 KB format-tokens - :input/:output token counts to 'tok 11461→35' format-cost - dollar cost to '~$0.006954' format-iterations- iteration count to '1 iter' / '3 iters' format-meta-line - canonical ' / '-joined turn-summary line (used identically by CLI / TUI)

No external pretty-printer: data renders through clojure.pprint (built-in) and source code is shown verbatim. The namespace is free of state - safe to require from any layer.

Format helpers - leaf module.

Small, dependency-light formatters used by the SDK facade, the
TUI footer and the CLI status output. Each one
is a pure transform over basic Clojure / Java values.

  `format-date`      - `java.util.Date` to `dd-MM-yyyy HH:mm` (local TZ)
  `format-clojure`   - pass-through (code is shown as written, not reformatted)
  `format-duration`  - millisecond duration to `2.3s`, `1m 15s`, etc.
  `format-bytes`     - byte count to `441B` / `1.2KB` / `150 KB`
  `format-tokens`    - `:input`/`:output` token counts to 'tok 11461→35'
  `format-cost`      - dollar cost to '~$0.006954'
  `format-iterations`- iteration count to '1 iter' / '3 iters'
  `format-meta-line` - canonical ' / '-joined turn-summary line
                       (used identically by CLI / TUI)

No external pretty-printer: data renders through `clojure.pprint`
(built-in) and source code is shown verbatim. The namespace is free of
state - safe to require from any layer.
raw docstring

com.blockether.vis.internal.foundation.doctor

Foundation's contribution to the host vis-agent doctor aggregator. ONE fn (doctor-fn) returns the full message stream from two logical sections, each stamping its own :check-id so the formatter groups them under the same banner the original four-checks-vec shape produced (plan §1 Q18 / §10):

::agents-md AGENTS.md presence / source / size; one :info line when found, one :warn line when neither AGENTS.md nor CLAUDE.md exists (rules silently absent is worth flagging even though it isn't an error per se).

::provider-env providers whose ${NAME} config references point at environment variables that are not set. Config loads leniently by design, so vis-agent doctor is the moment a user actually LOOKS — the right place to fail fast without failing the gateway.

::image-render one real SVG rasterized through the attachment path, so a build whose imaging cdylib is missing or unloadable SAYS SO instead of silently dropping every diagram.

These section fns are pure data -> message-seq; they don't mutate anything and don't depend on the runtime environment beyond what's needed to read the existing scanners. Activation contract per plan: every registered extension's :ext/doctor-fn runs regardless of :ext/activation-fn, so the section fns must NOT assume :db-info or other env keys are present.

Foundation's contribution to the host `vis-agent doctor` aggregator. ONE fn
(`doctor-fn`) returns the full message stream from two logical
sections, each stamping its own `:check-id` so the formatter
groups them under the same banner the original four-checks-vec
shape produced (plan §1 Q18 / §10):

  ::agents-md         AGENTS.md presence / source / size; one
                       :info line when found, one :warn line
                       when neither AGENTS.md nor CLAUDE.md exists
                       (rules silently absent is worth flagging
                       even though it isn't an error per se).

  ::provider-env      providers whose `${NAME}` config references point at
                       environment variables that are not set. Config loads
                       leniently by design, so `vis-agent doctor` is the moment a
                       user actually LOOKS — the right place to fail fast
                        without failing the gateway.

::image-render      one real SVG rasterized through the attachment path, so
                     a build whose imaging cdylib is missing or unloadable
                     SAYS SO instead of silently dropping every diagram.

These section fns are pure data -> message-seq; they don't mutate
anything and don't depend on the runtime environment beyond
what's needed to read the existing scanners. Activation
contract per plan: every registered extension's `:ext/doctor-fn`
runs regardless of `:ext/activation-fn`, so the section fns must
NOT assume `:db-info` or other env keys are present.
raw docstring

com.blockether.vis.internal.foundation.drafts

Drafts as the model reaches them: the draft_create, draft_status, draft_approve and draft_discard sandbox symbols and the draft_backend toggle. Only the agent manages drafts — no channel offers a slash command or a picker for them. Each symbol is a thin layer over workspace.drafts, the boundary the daemon's HTTP routes use too, so an extension hook on :draft/* sees every surface alike.

Drafts as the model reaches them: the `draft_create`, `draft_status`,
`draft_approve` and `draft_discard` sandbox symbols and the `draft_backend`
toggle. Only the agent manages drafts — no channel offers a slash command
or a picker for them. Each symbol is a thin layer over `workspace.drafts`,
the boundary the daemon's HTTP routes use too, so an extension hook on
`:draft/*` sees every surface alike.
raw docstring

com.blockether.vis.internal.foundation.editing.core

Host-side file reading, search, listing and anchored editing.

cat and grep return line/hash addresses consumed by patch. One patch validates every edit and the resulting syntax before replacing one file. list-directories supplies the Python ls shim. Standard Python owns file creation, copying, moving and deletion.

Paths are confined to the session's allowed roots. Host operations also consult extension-owned :fs/access gates.

Host-side file reading, search, listing and anchored editing.

`cat` and `grep` return line/hash addresses consumed by `patch`. One patch
validates every edit and the resulting syntax before replacing one file.
`list-directories` supplies the Python `ls` shim. Standard Python owns file
creation, copying, moving and deletion.

Paths are confined to the session's allowed roots. Host operations also
consult extension-owned `:fs/access` gates.
raw docstring

com.blockether.vis.internal.foundation.editing.diff

Unified-diff rendering and line accounting for anchored patches. Independent of the tool and activity namespaces.

Unified-diff rendering and line accounting for anchored patches.
Independent of the tool and activity namespaces.
raw docstring

com.blockether.vis.internal.foundation.editing.escapes

Unicode-escape hygiene for model-authored edit TEXT.

Public surface: decode-unicode-escapes — undo the \uXXXX drift a model writes when it means the character itself, and nothing else.

Unicode-escape hygiene for model-authored edit TEXT.

Public surface: `decode-unicode-escapes` — undo the `\uXXXX` drift a model
writes when it means the character itself, and nothing else.
raw docstring

com.blockether.vis.internal.foundation.editing.hashline

Pure hashline primitives: the ANCHOR vocabulary cat mints, grep echoes and patch spends.

An anchor is <1-based line>:<3-hex content hash> (Can Bölük's original hashline shape). The LINE NUMBER locates the line; the CONTENT HASH verifies it. A write requires BOTH coordinates to match exactly: any contradiction is REFUSED (:anchor-mismatch) instead of relocating the edit. Only the non-destructive read path may follow matching content through small line drift.

This namespace is pure — no IO, no tool wiring, no extension envelope. Every surface that addresses a line routes here so the scheme is never recomputed:

split-content-lines / char-offset-at-line blob <-> line/char coordinates line-hash / line-anchor / anchor->line text -> <line>:<hash> render-hashline-block [[ln text]…] -> gutter text anchor-token / parse-anchor rendered line -> bare anchor indices-matching-hash content-only hash lookup resolve-one-anchor / resolve-anchor-range exact write resolution resolve-anchor-range-read tolerant read resolution resolve-anchor-edit-span anchor span -> char span

Pure hashline primitives: the ANCHOR vocabulary `cat` mints, `grep` echoes and
`patch` spends.

An anchor is `<1-based line>:<3-hex content hash>` (Can Bölük's original
hashline shape). The LINE NUMBER locates the line; the CONTENT HASH verifies
it. A write requires BOTH coordinates to match exactly: any contradiction is
REFUSED (`:anchor-mismatch`) instead of relocating the edit. Only the
non-destructive read path may follow matching content through small line drift.

This namespace is pure — no IO, no tool wiring, no extension envelope. Every
surface that addresses a line routes here so the scheme is never recomputed:

  split-content-lines / char-offset-at-line   blob <-> line/char coordinates
  line-hash / line-anchor / anchor->line      text  -> `<line>:<hash>`
  render-hashline-block                       [[ln text]…] -> gutter text
  anchor-token / parse-anchor                 rendered line -> bare anchor
  indices-matching-hash                       content-only hash lookup
  resolve-one-anchor / resolve-anchor-range   exact write resolution
  resolve-anchor-range-read                   tolerant read resolution
  resolve-anchor-edit-span                    anchor span -> char span
raw docstring

com.blockether.vis.internal.foundation.editing.parse

Language detection and PARSE VERDICTS for the anchored patch gate.

Two questions, answered through tree-sitter (com.blockether/tree-sitter-language-pack, which sources Clojure from our own grammar fork):

  1. what language is this file, and is it a language where a parse error means the file is genuinely broken (code-languages)?
  2. where exactly does the new content fail to parse (error-nodes)?

patch spends both: it re-parses what a write would produce and refuses an edit that introduces a syntax error the file did not already have, naming the line and the unpaired delimiter instead of a bare error count.

All native handles (Parser/Tree/Node) are opened and closed inside each call; only plain Clojure data escapes. Requiring this namespace also requires the native resolver, which selects the right per-platform FFI library at runtime.

Language detection and PARSE VERDICTS for the anchored `patch` gate.

Two questions, answered through tree-sitter (com.blockether/tree-sitter-language-pack,
which sources Clojure from our own grammar fork):

  1. what language is this file, and is it a language where a parse error
     means the file is genuinely broken (`code-languages`)?
  2. where exactly does the new content fail to parse (`error-nodes`)?

`patch` spends both: it re-parses what a write would produce and refuses an
edit that introduces a syntax error the file did not already have, naming the
line and the unpaired delimiter instead of a bare error count.

All native handles (Parser/Tree/Node) are opened and closed inside each call;
only plain Clojure data escapes. Requiring this namespace also requires the
native resolver, which selects the right per-platform FFI library at runtime.
raw docstring

com.blockether.vis.internal.foundation.environment.core

vis-foundation — the agent's environment-awareness layer.

Owns the environment facts: cwd, user, platform, shell, plus:

  • git repository facts via the git binary (root, branch, dirty status, submodules, worktree),
  • a bounded language scan over the working tree (top languages by file count, primary language),
  • monorepo / multi-package shape detection (polylith, workspace, submodules) by counting per-ecosystem manifests.

Model-facing VCS/workspace truth lives in session['workspace']; session['env']['project'] supplies project kind and primary language. Detailed scans remain host data for context and language-tool dispatch.

Runtime facts are computed lazily on first access and cached per working-directory. The cache is invalidated automatically when cwd changes between calls, and explicitly by the HOST-ONLY refresh! — which /reload runs and the sandbox cannot call.

vis-foundation — the agent's environment-awareness layer.

Owns the environment facts: cwd, user, platform, shell, plus:

  * git repository facts via the git binary (root, branch, dirty status,
    submodules, worktree),
  * a bounded language scan over the working tree (top languages
    by file count, primary language),
  * monorepo / multi-package shape detection (polylith, workspace,
    submodules) by counting per-ecosystem manifests.

Model-facing VCS/workspace truth lives in `session['workspace']`;
`session['env']['project']` supplies project kind and primary language.
Detailed scans remain host data for context and language-tool dispatch.

Runtime facts are computed lazily on first access and cached per
working-directory. The cache is invalidated automatically when
`cwd` changes between calls, and explicitly by the HOST-ONLY
`refresh!` — which `/reload` runs and the sandbox cannot call.
raw docstring

com.blockether.vis.internal.foundation.environment.git

Git introspection for the environment block, backed by the native git binary (via internal.workspace.git).

Returns a snapshot map for the repository that contains start (typically the JVM working directory). nil when start is not inside any git repository. Never throws — every git call is guarded; on any failure we degrade gracefully to nil or a reduced-shape map.

The expensive call is git status (working-tree walk). When it fails or is suppressed, the snapshot drops the dirty-status fields instead of stalling the system-prompt build.

Git introspection for the environment block, backed by the native `git`
binary (via `internal.workspace.git`).

Returns a snapshot map for the repository that contains `start`
(typically the JVM working directory). nil when `start` is not inside any
git repository. Never throws — every git call is guarded; on any failure we
degrade gracefully to nil or a reduced-shape map.

The expensive call is `git status` (working-tree walk). When it fails or is
suppressed, the snapshot drops the dirty-status fields instead of stalling
the system-prompt build.
raw docstring

com.blockether.vis.internal.foundation.environment.host

Host-side facts read from JDK system properties and process environment variables.

No I/O, no shell-out, no third-party deps. Cheap to compute, safe to call from any thread, never throws.

Host-side facts read from JDK system properties and process
environment variables.

No I/O, no shell-out, no third-party deps. Cheap to compute, safe
to call from any thread, never throws.
raw docstring

com.blockether.vis.internal.foundation.environment.languages

Bounded language scan over a directory tree.

Walks the tree with Files/walkFileTree, skipping common non-source subdirectories (.git, node_modules, target, ...) via FileVisitResult/SKIP_SUBTREE. Counts files and bytes per language using a small extension-to-language map.

The walk has TWO hard guards: a max-file count and a wall-time deadline. Either one stops the walk via TERMINATE. Callers get a possibly-partial result; on a small repo the result is exact.

No third-party deps. Reflection-clean.

Bounded language scan over a directory tree.

Walks the tree with `Files/walkFileTree`, skipping common
non-source subdirectories (`.git`, `node_modules`, `target`, ...)
via `FileVisitResult/SKIP_SUBTREE`. Counts files and bytes per
language using a small extension-to-language map.

The walk has TWO hard guards: a max-file count and a wall-time
deadline. Either one stops the walk via `TERMINATE`. Callers get
a possibly-partial result; on a small repo the result is exact.

No third-party deps. Reflection-clean.
raw docstring

com.blockether.vis.internal.foundation.environment.monorepo

Monorepo / multi-package detection.

Walks the tree once (bounded), counting per-language manifest files at any depth below the root. >=2 manifests of the same kind in distinct subdirectories signals a multi-package workspace; we report the kind, the count, and a best-guess shape label (e.g. "polylith", "workspace", "submodules").

Reflection-clean. Honors the same skip-directory list as the language scanner.

Monorepo / multi-package detection.

Walks the tree once (bounded), counting per-language manifest
files at any depth below the root. >=2 manifests of the same
kind in distinct subdirectories signals a multi-package
workspace; we report the kind, the count, and a best-guess
shape label (e.g. "polylith", "workspace", "submodules").

Reflection-clean. Honors the same skip-directory list as the
language scanner.
raw docstring

com.blockether.vis.internal.foundation.environment.render

Build compact foundation environment data for ctx. No prompt labels.

Build compact foundation environment data for `ctx`. No prompt labels.
raw docstring

com.blockether.vis.internal.foundation.environment.repositories

Bounded discovery of multiple Git repositories below the current project root. This catches multirepo workspaces where the user's cwd is a parent directory or a primary repo that vendors sibling/nested repos outside .gitmodules.

Returns compact per-repo Git summaries for the system prompt. Full status walks are bounded per repo by git/snapshot; the repository scan itself is bounded by max files, max repos, and a wall-clock deadline. Never throws.

Bounded discovery of multiple Git repositories below the current
project root. This catches multirepo workspaces where the user's cwd
is a parent directory or a primary repo that vendors sibling/nested
repos outside `.gitmodules`.

Returns compact per-repo Git summaries for the system prompt. Full
status walks are bounded per repo by `git/snapshot`; the repository
scan itself is bounded by max files, max repos, and a wall-clock
deadline. Never throws.
raw docstring

com.blockether.vis.internal.foundation.gif

Multi-frame GIF (87a/89a) codec, delegating every byte of the GIF format to the com.blockether/imaging native cdylib (Rust image crate): LZW, palette quantization, interlace, disposal and NETSCAPE looping -- all of it lives there now.

This used to be a hand-rolled pure-Clojure codec (its own LZW decode/encode, sub-block framing, disposal compositor, median-cut-free palette path). That could not survive the image crate's decode-limit changes and duplicated work the cdylib already does; the native path composites every frame onto a full-size canvas honouring GIF disposal (the image crate's own GifFrameIterator), so a caller only ever sees plain packed-0xAARRGGBB pixels -- exactly the shape the old decode produced.

Multi-frame GIF (87a/89a) codec, delegating every byte of the GIF format to
the `com.blockether/imaging` native cdylib (Rust `image` crate): LZW, palette
quantization, interlace, disposal and NETSCAPE looping -- all of it lives there
now.

This used to be a hand-rolled pure-Clojure codec (its own LZW decode/encode,
sub-block framing, disposal compositor, median-cut-free palette path). That
could not survive the `image` crate's decode-limit changes and duplicated work
the cdylib already does; the native path composites every frame onto a
full-size canvas honouring GIF disposal (the `image` crate's own
`GifFrameIterator`), so a caller only ever sees plain packed-0xAARRGGBB
pixels -- exactly the shape the old [[decode]] produced.
raw docstring

com.blockether.vis.internal.foundation.harness.core

harness compatibility layer — a BUILT-IN foundation module (ships in the main jar, always present, gated by toggles) that exposes the SKILLS vis' own project dir and other AI coding HARNESSES (Claude Code, pi, opencode, the agents standard, …) leave on disk to the vis model. The sibling of the shell layer's POSIX compat. Vis reads its OWN project-local skills from .vis/skills (highest precedence).

  • SKILLS are DOCUMENTS, never a verb: the prompt lists every skill name — description (cheap — always present) and the WHOLE SKILL.md is one document in the doc/apropos corpus. apropos(pattern) filters skill names and doc(name) prints one whole. Reading a skill has no session effect: there is nothing to activate, nothing to re-read and no activation receipt.

  • The USER's /skill:<name> slash is that same document with a POINTER, never a copy: it expands to one sentence naming the skill (plus the owning project and any bundled resource paths, which the body does not carry) and leaves fetching it to the model, which is the only party that knows whether the text is still in front of it. No injected body means nothing to remember between two /skill:<name>s: every skill surface is stateless.

Skills and commands have no user toggle; the layer is always active.

`harness` compatibility layer — a BUILT-IN foundation module (ships in the
main jar, always present, gated by toggles) that exposes the SKILLS vis'
own project dir and other AI coding HARNESSES (Claude Code, pi, opencode, the agents
standard, …) leave on disk to the vis model. The sibling of the shell
layer's POSIX compat. Vis reads its OWN project-local skills from
`.vis/skills` (highest precedence).

- SKILLS are DOCUMENTS, never a verb: the prompt lists every skill
  `name — description` (cheap — always present) and the WHOLE `SKILL.md` is
  one document in the `doc`/`apropos` corpus. `apropos(pattern)` filters skill
  names and `doc(name)` prints one whole. Reading a skill has no session effect:
  there is nothing to activate, nothing to re-read and no activation receipt.

- The USER's `/skill:<name>` slash is that same document with a POINTER, never a
  copy: it expands to one sentence naming the skill (plus the owning project
  and any bundled resource paths, which the body does not carry) and leaves
  fetching it to the model, which is the only party that knows whether the
  text is still in front of it. No injected body means nothing to remember
  between two `/skill:<name>`s: every skill surface is stateless.

Skills and commands have no user toggle; the layer is always active.
raw docstring

com.blockether.vis.internal.foundation.harness.discovery

Cross-HARNESS discovery of agents + skills — the sibling of the shell layer's POSIX compat, for the agent/skill definitions vis' OWN project dir and OTHER AI coding harnesses (Claude Code, pi, opencode, the agents standard, …) leave on disk.

An AGENT is a markdown file with YAML-ish --- frontmatter (name, description, model, tools) + a body that IS a system prompt. A SKILL is a SKILL.md (same frontmatter, name+description) in its own directory, alongside bundled resource files.

Discovery is PURE except for the directory scan: parse-frontmatter, parse-agent, parse-skill-meta, and dedup-by-name take strings and are unit-tested without the filesystem; the discover-* fns walk the known source roots. Precedence is source ORDER, first-name-wins (vis project-local > other harnesses' project > user > plugin; Vis and Claude before pi/agents/opencode).

Cross-HARNESS discovery of agents + skills — the sibling of the shell
layer's POSIX compat, for the agent/skill definitions vis' OWN project dir
and OTHER AI coding harnesses (Claude Code, pi, opencode, the agents
standard, …) leave on disk.

An AGENT is a markdown file with YAML-ish `---` frontmatter
(`name`, `description`, `model`, `tools`) + a body that IS a system
prompt. A SKILL is a `SKILL.md` (same frontmatter, name+description) in
its own directory, alongside bundled resource files.

Discovery is PURE except for the directory scan: `parse-frontmatter`,
`parse-agent`, `parse-skill-meta`, and `dedup-by-name` take strings and
are unit-tested without the filesystem; the `discover-*` fns walk the
known source roots. Precedence is source ORDER, first-name-wins
(vis project-local > other harnesses' project > user > plugin; Vis and
Claude before pi/agents/opencode).
raw docstring

com.blockether.vis.internal.foundation.housekeeping

Retention for the Vis-owned directories that grow without bound — the one nobody may delete for you, and the eight that delete themselves.

ADVISORY (scan observes, purge! acts, vis-agent doctor renders): the drafts store (~/.vis/drafts). A draft clone is a full copy of a trunk and survives until someone applies or abandons it, so a machine that drafts daily and never abandons accumulates gigabytes of dead clones. It holds recoverable work, so nothing here deletes it on its own: scan is pure observation (no mutation, never throws) and purge! is the explicit operator action behind vis-agent doctor --purge. scan reports the gateway journals the same way, because an operator asking what is reclaimable today should see them.

SELF-DELETING (sweep-stale!, once per process at startup): diagnostic logs, the gateway journals, the display caches, the rewind stores and the embedded Python runtimes of versions this binary no longer pins. Those are DERIVED — a log of a process that exited, the wire replay of a turn the DB already owns, a picture whose bytes are already DB-owned, the pre-image of an edit nobody will rewind a fortnight later, an interpreter the next start refetches from its release — so they carry a window instead of a report. sweep-targets is the one list of them. Journals also self-sweep inside the tailer loop (gateway.bus/sweep!) after a single idle day, but that is a LIVENESS rule and it only runs while a daemon does — journals from crashed or never-restarted daemons used to stay forever, and startup is exactly when no daemon is running.

purge! routes deletions through workspace/abandon! for live draft rows so the DB transition, hooks, and backend root release all use the canonical engine path. Only rows already :discarded, directories with no row at all, and journal files are removed directly — and every direct delete is confined to a path under the drafts store or the events dir.

Retention for the Vis-owned directories that grow without bound — the one
nobody may delete for you, and the eight that delete themselves.

ADVISORY (`scan` observes, `purge!` acts, `vis-agent doctor` renders): the
drafts store (`~/.vis/drafts`). A draft clone is a full copy of a trunk and
survives until someone applies or abandons it, so a machine that drafts daily
and never abandons accumulates gigabytes of dead clones. It holds recoverable
work, so nothing here deletes it on its own: `scan` is pure observation (no
mutation, never throws) and `purge!` is the explicit operator action behind
`vis-agent doctor --purge`. `scan` reports the gateway journals the same way,
because an operator asking what is reclaimable today should see them.

SELF-DELETING (`sweep-stale!`, once per process at startup): diagnostic logs,
the gateway journals, the display caches, the rewind stores and the embedded
Python runtimes of versions this binary no longer pins. Those are DERIVED — a
log of a process that exited, the wire replay of a turn the DB already owns,
a picture whose bytes are already DB-owned, the pre-image of an edit nobody
will rewind a fortnight later, an interpreter the next start refetches from
its release — so they carry a window instead of
a report. `sweep-targets` is the one list of them. Journals also self-sweep
inside the tailer loop (`gateway.bus/sweep!`) after a single idle day, but
that is a LIVENESS rule and it only runs while a daemon does — journals from
crashed or never-restarted daemons used to stay forever, and startup is
exactly when no daemon is running.

`purge!` routes deletions through `workspace/abandon!` for live draft rows so
the DB transition, hooks, and backend root release all use the canonical engine
path. Only rows already `:discarded`, directories with
no row at all, and journal files are removed directly — and every direct
delete is confined to a path under the drafts store or the events dir.
raw docstring

com.blockether.vis.internal.foundation.introspection

Programmatic introspection of the agent's own state from inside :code. The public state surface is deliberately small:

  • (read-session [target]) -> canonical data map, including usage and raw LLM diagnostics
  • (get-session [target]) -> ONE session's descriptor, no transcript
  • (list-sessions [search]) -> metadata-only index, RANKED when search is given

Everything else in this namespace is implementation detail. The agent gets the data once and manipulates it with ordinary Python collection operations when filtering or presentation is needed.

Every function is a pure read off the same DB tables the projection layer reads from (or a classpath read for the doc accessors). Failures return nil/[], never throw, so a misbehaving introspection call cannot break iteration execution.

Gated: the extension registered at the bottom of this namespace binds its symbols and prompt only while the introspection toggle is ON (default OFF).

Programmatic introspection of the agent's own state from inside
`:code`. The public state surface is deliberately small:

- `(read-session [target])` -> canonical data map, including usage and raw LLM diagnostics
- `(get-session [target])` -> ONE session's descriptor, no transcript
- `(list-sessions [search])` -> metadata-only index, RANKED when `search` is given

Everything else in this namespace is implementation detail. The agent
gets the data once and manipulates it with ordinary Python collection
operations when filtering or presentation is needed.

Every function is a pure read off the same DB tables the projection
layer reads from (or a classpath read for the doc accessors).
Failures return nil/[], never throw, so a misbehaving introspection
call cannot break iteration execution.

Gated: the extension registered at the bottom of this namespace binds its
symbols and prompt only while the `introspection` toggle is ON (default OFF).
raw docstring

com.blockether.vis.internal.foundation.language-surface

Language-neutral FORMAT / TEST / REPL_EVAL / REPL-LIFECYCLE dispatch.

Language extensions register handlers under :ext/language-tools; this foundation surface exposes stable bare tool names and dispatches to the active handler for the requested/current language. REPL lifecycle is resource backed: repl_start creates a language-owned session resource, repl_status reports it and repl_stop ends one. Live REPLs also surface in the ctx resources block.

Language-neutral FORMAT / TEST / REPL_EVAL / REPL-LIFECYCLE dispatch.

Language extensions register handlers under `:ext/language-tools`; this
foundation surface exposes stable bare tool names and dispatches to the
active handler for the requested/current language. REPL lifecycle is resource
backed: `repl_start` creates a language-owned session resource, `repl_status`
reports it and `repl_stop` ends one. Live REPLs also surface in the ctx
`resources` block.
raw docstring

com.blockether.vis.internal.foundation.mcp.client

Minimal Model Context Protocol (MCP) client. Speaks JSON-RPC 2.0 over two transports:

:stdio — spawn the server process and frame newline-delimited JSON-RPC on its stdin/stdout (the dominant local-server pattern). A daemon thread drains stderr into the vis log so a chatty server never deadlocks on a full pipe.

:http — Streamable HTTP: POST each JSON-RPC message to one endpoint; the reply is either application/json (one response) or text/event-stream (SSE) — both handled. The Mcp-Session-Id handed back by initialize rides on every later request, a DELETE frees it on shutdown, and an optional GET listen loop reacts to server-pushed notifications/tools/list_changed.

OAuth 2.1 (spec 2025-06-18) is supported for HTTP transports via oauth.clj: pass :bearer-fn (a 0/1-arg fn yielding the current Bearer token, called with the just-rejected token on 401). A 401 triggers a single-flight refresh and one automatic retry.

A conn is a plain map of closures + state; the extension treats it opaquely. Lifecycle: connect (which performs the initialize handshake) → list-tools / call-toolclose.

Minimal Model Context Protocol (MCP) client. Speaks JSON-RPC 2.0 over two
transports:

  :stdio  — spawn the server process and frame newline-delimited JSON-RPC on
            its stdin/stdout (the dominant local-server pattern). A daemon
            thread drains stderr into the vis log so a chatty server never
            deadlocks on a full pipe.

  :http   — Streamable HTTP: POST each JSON-RPC message to one endpoint; the
            reply is either `application/json` (one response) or
            `text/event-stream` (SSE) — both handled. The `Mcp-Session-Id`
            handed back by `initialize` rides on every later request, a
            `DELETE` frees it on shutdown, and an optional GET listen loop
            reacts to server-pushed `notifications/tools/list_changed`.

OAuth 2.1 (spec `2025-06-18`) is supported for HTTP transports via
`oauth.clj`: pass `:bearer-fn` (a 0/1-arg fn yielding the current Bearer
token, called with the just-rejected token on 401). A 401 triggers a
single-flight refresh and one automatic retry.

A `conn` is a plain map of closures + state; the extension treats it
opaquely. Lifecycle: `connect` (which performs the `initialize` handshake) →
`list-tools` / `call-tool` → `close`.
raw docstring

com.blockether.vis.internal.foundation.mcp.core

Built-in Model Context Protocol (MCP) surface. The gateway daemon owns ONE shared pool of MCP connections ({server {:conn spec}}); every session sees the same live tools. Always on: MCP is core infrastructure, not a droppable plug-in and not gated by any toggle. The pool is empty (and costs nothing) until at least one server is declared in config.

Servers are declared natively in ~/.vis/state.yml:

{:mcp {:servers {"filesystem" {:transport :stdio :command "npx" :args ["-y" "@modelcontextprotocol/server-filesystem" "/path"]} "remote" {:transport :streamable-http :url "https://.../mcp" :headers {"Authorization" "Bearer ${MY_TOKEN}"} :timeout_ms 60000} "stale" {:enabled false :url "https://.../mcp"}}}}

Every string in :headers / :env / :args / :url / :command / :cwd supports ${ENV_VAR} interpolation from the host environment. :enabled false skips the server without deleting the entry. HTTP servers with no static bearer transparently negotiate OAuth 2.1 on first 401 (RFC 9728 discovery + RFC 7591 dynamic client registration + PKCE loopback).

ONE model-facing verb under alias mcp (flat sandbox renders alias_name): mcp__call(server, tool, args) - call a tool mcp__call(server) - that server's descriptions + input schemas

There is deliberately NO connect/disconnect verb. The daemon connects every enabled server, health-checks the pool on its own clock, and reaps/respawns a dead one; a tool call self-heals its connection too. Starting or stopping a server is a human admin action on the gateway API (save/enable/kill/start), never something one session does to a resource every other session shares.

Every visible server - its status and the NAMES of the tools it exposes - rides in ctx under env.mcp, keyed by server name so a change diffs per server. That IS the inventory: no listing verb spends a turn re-fetching what the session object already carries.

Built-in Model Context Protocol (MCP) surface. The gateway daemon owns ONE
shared pool of MCP connections (`{server {:conn spec}}`); every session sees
the same live tools. Always on: MCP is core infrastructure, not a droppable
plug-in and not gated by any toggle. The pool is empty (and costs nothing)
until at least one server is declared in config.

Servers are declared natively in `~/.vis/state.yml`:

  {:mcp {:servers {"filesystem" {:transport :stdio :command "npx"
                                 :args ["-y" "@modelcontextprotocol/server-filesystem" "/path"]}
                   "remote"     {:transport :streamable-http :url "https://.../mcp"
                                 :headers {"Authorization" "Bearer ${MY_TOKEN}"}
                                 :timeout_ms 60000}
                   "stale"      {:enabled false :url "https://.../mcp"}}}}

Every string in `:headers` / `:env` / `:args` / `:url` / `:command` / `:cwd`
supports `${ENV_VAR}` interpolation from the host environment. `:enabled
false` skips the server without deleting the entry. HTTP servers with no
static bearer transparently negotiate OAuth 2.1 on first 401 (RFC 9728
discovery + RFC 7591 dynamic client registration + PKCE loopback).

ONE model-facing verb under alias `mcp` (flat sandbox renders `alias_name`):
  mcp__call(server, tool, args) - call a tool
  mcp__call(server)             - that server's descriptions + input schemas

There is deliberately NO connect/disconnect verb. The daemon connects every
enabled server, health-checks the pool on its own clock, and reaps/respawns a
dead one; a tool call self-heals its connection too. Starting or stopping a
server is a human admin action on the gateway API (save/enable/kill/start),
never something one session does to a resource every other session shares.

Every visible server - its status and the NAMES of the tools it exposes -
rides in ctx under `env.mcp`, keyed by server name so a change diffs per
server. That IS the inventory: no listing verb spends a turn re-fetching what
the session object already carries.
raw docstring

com.blockether.vis.internal.foundation.mcp.http

One lazy babashka.http-client instance shared by every MCP HTTP subsystem.

One lazy babashka.http-client instance shared by every MCP HTTP subsystem.
raw docstring

com.blockether.vis.internal.foundation.mcp.oauth

OAuth 2.1 client for Model Context Protocol servers (spec 2025-06-18).

Flow — on HTTP 401 from an MCP server, we:

  1. read WWW-Authenticate: Bearer resource_metadata="..." (RFC 9728), fall back to ${origin}/.well-known/oauth-protected-resource;
  2. GET the resource-metadata JSON → pick an authorization_servers[0];
  3. GET its .well-known/oauth-authorization-server (RFC 8414) or .well-known/openid-configuration for endpoints + capabilities;
  4. dynamic-client-register (RFC 7591) if the AS supports it, or use the caller-supplied client_id;
  5. prepare an allowed callback and register it, then hand the adapter to provider.flow, the SAME lifecycle used by model-provider authentication. The initiating client opens the browser and returns directly to its gateway;
  6. exchange code → access + refresh tokens; persist to ~/.vis/mcp-tokens/<server>.edn;
  7. on later expiry / 401, refresh the token single-flight through com.blockether.vis.internal.provider.oauth/make-file-refresher.

The returned bearer-fn is a 0/1-arg function: 0-arg yields the current bearer token; 1-arg (with the token the server just rejected) forces a refresh. It never opens a browser and never waits — with nothing to refresh it throws :mcp/oauth-required, which callers turn into sign in.

OAuth 2.1 client for Model Context Protocol servers (spec `2025-06-18`).

Flow — on HTTP 401 from an MCP server, we:
  1. read `WWW-Authenticate: Bearer resource_metadata="..."` (RFC 9728),
     fall back to `${origin}/.well-known/oauth-protected-resource`;
  2. GET the resource-metadata JSON → pick an `authorization_servers[0]`;
  3. GET its `.well-known/oauth-authorization-server` (RFC 8414) or
     `.well-known/openid-configuration` for endpoints + capabilities;
  4. dynamic-client-register (RFC 7591) if the AS supports it, or use
     the caller-supplied `client_id`;
  5. prepare an allowed callback and register it, then hand the adapter to
     `provider.flow`, the SAME lifecycle used by model-provider authentication.
     The initiating client opens the browser and returns directly to its gateway;
  6. exchange code → access + refresh tokens; persist to
     `~/.vis/mcp-tokens/<server>.edn`;
  7. on later expiry / 401, refresh the token single-flight through
     `com.blockether.vis.internal.provider.oauth/make-file-refresher`.

The returned `bearer-fn` is a 0/1-arg function: 0-arg yields the current
bearer token; 1-arg (with the token the server just rejected) forces a
refresh. It never opens a browser and never waits — with nothing to refresh
it throws `:mcp/oauth-required`, which callers turn into `sign in`.
raw docstring

com.blockether.vis.internal.foundation.mpl-capture

Per-block collection of explicit attachments and rendered matplotlib figures.

Producers call record-attachment! with bytes they already hold. run-python-block binds *attachment-sink* and drains it into the block's :attachments; the loop passes those records to iteration persistence. Stdout is used for display, not attachment persistence. Ordinary filesystem writes are not collected.

This namespace does not depend on the renderer or tool namespaces.

Per-block collection of explicit attachments and rendered matplotlib figures.

Producers call `record-attachment!` with bytes they already hold.
`run-python-block` binds `*attachment-sink*` and drains it into the block's
`:attachments`; the loop passes those records to iteration persistence.
Stdout is used for display, not attachment persistence. Ordinary filesystem
writes are not collected.

This namespace does not depend on the renderer or tool namespaces.
raw docstring

com.blockether.vis.internal.foundation.pty

The PTY adapter for libvisjail.

Native descriptor ownership, terminal setup, process groups, waiting and signals live in vis-python-runtime; this namespace keeps only the handle map consumed by shell and its passthrough bridge.

The PTY adapter for `libvisjail`.

Native descriptor ownership, terminal setup, process groups, waiting and
signals live in `vis-python-runtime`; this namespace keeps only the handle
map consumed by shell and its passthrough bridge.
raw docstring

com.blockether.vis.internal.foundation.pty-bridge

Passthrough bridge on top of the libvisjail pseudo-terminal adapter.

The problem it solves: a background shell child owns a native PTY whose master descriptor is managed by Vis. That is convenient for the agent (shell send/logs), but a human cannot jump into the live terminal to finish a browser authorization or answer an interactive prompt. tmux gets that from a separate server; libvisjail does not expose such a user-facing attachment endpoint.

This namespace restores that capability WITHOUT tmux: each background PTY optionally exposes a per-shell UNIX-DOMAIN SOCKET. vis is the server (it holds the master fd); vis-agent extension shell attach <id> is a thin client the human runs in their OWN Terminal.app. On connect the server (a) tees live master output to the socket and (b) forwards the socket's bytes to the master (stdin) — a genuine bidirectional passthrough. Multiple humans can attach at once; detaching just drops the socket and leaves the child running (exactly like tmux detach).

Everything here is stdlib: java.nio.channels AF_UNIX sockets (JDK 16+, already in vis's native-image reachability metadata) on the server side, and stty for raw mode on the client side (the human's interactive shell always has it). No JNA, no new dep, native-image clean.

Passthrough bridge on top of the libvisjail pseudo-terminal adapter.

The problem it solves: a background `shell` child owns a native PTY whose master
descriptor is managed by Vis. That is convenient for the agent (shell send/logs),
but a human cannot jump into the live terminal to finish a browser authorization or
answer an interactive prompt. tmux gets that from a separate server; libvisjail does
not expose such a user-facing attachment endpoint.

This namespace restores that capability WITHOUT tmux: each background PTY
optionally exposes a per-shell UNIX-DOMAIN SOCKET. vis is the server (it holds
the master fd); `vis-agent extension shell attach <id>` is a thin client the human runs in
their OWN Terminal.app. On connect the server (a) tees live master output to the
socket and (b) forwards the socket's bytes to the master (stdin) — a genuine
bidirectional passthrough. Multiple humans can attach at once; detaching just
drops the socket and leaves the child running (exactly like `tmux detach`).

Everything here is stdlib: `java.nio.channels` AF_UNIX sockets (JDK 16+,
already in vis's native-image reachability metadata) on the server side, and
`stty` for raw mode on the client side (the human's interactive shell always
has it). No JNA, no new dep, native-image clean.
raw docstring

com.blockether.vis.internal.foundation.rewind

DURABLE file-state rewind: put the working tree back the way it was before a turn, without owning a git/branch/commit lifecycle.

Two independent coverage sources, combined:

  1. SNAPSHOT POOL — an :around op-hook on every mutating tool (patch/fs/format_code) captures each touched path's PRE-mutation state before the op runs. Content lands in a content-addressed pool (objects/aa/<sha256>), so the same bytes are stored once no matter how many turns touch them. The first capture of a path in a turn WINS — later writes in the same turn never overwrite the turn-start pre-image.

  2. GIT BASELINE — at the FIRST hooked op of a turn we record HEAD plus the full dirty set (git status --porcelain -z -uall) and snapshot the pre-image of every DIRTY file. That closes the hole every other agent's rewind leaves open: a sed -i, a formatter, a build step, any shell write. A file that was CLEAN at turn start is recoverable from git show <baseline-head>:<path>; a file that was DIRTY at turn start already has its bytes in the pool. Coverage is therefore COMPLETE for a git workspace and honestly reported as PARTIAL otherwise.

Everything is journalled as NDJSON (journal.ndjson) under ~/.vis/rewind/<session>/, so rewind survives a restart — the history is NOT process-scoped. Entries are append-only and keyed by turn; a truncated/corrupt trailing line (crash mid-append) is skipped, never fatal.

Restore semantics for turn T: every path touched in turns >= T is set back to the EARLIEST recorded pre-image at or after T. A file created inside the rewound region is deleted; a deleted file is recreated; a symlink is recreated as a symlink; a recursively deleted directory is rebuilt and any file created inside it since is pruned.

This layer owns FILES ONLY. Conversation truncation is the channel's job — points exposes the turn ids to truncate to. Because that boundary is invisible to a user typing /rewind, the slash READS the session store for each turn's context size and says out loud, in every branch, that the conversation stays.

DURABLE file-state rewind: put the working tree back the way it was before a
turn, without owning a git/branch/commit lifecycle.

Two independent coverage sources, combined:

  1. SNAPSHOT POOL — an `:around` op-hook on every mutating tool
     (`patch`/`fs`/`format_code`)
     captures each touched path's PRE-mutation state before the op runs.
     Content lands in a content-addressed pool (`objects/aa/<sha256>`), so
     the same bytes are stored once no matter how many turns touch them.
     The first capture of a path in a turn WINS — later writes in the same
     turn never overwrite the turn-start pre-image.

  2. GIT BASELINE — at the FIRST hooked op of a turn we record `HEAD` plus
     the full dirty set (`git status --porcelain -z -uall`) and snapshot the
     pre-image of every DIRTY file. That closes the hole every other agent's
     rewind leaves open: a `sed -i`, a formatter, a build step, any `shell`
     write. A file that was CLEAN at turn start is recoverable from
     `git show <baseline-head>:<path>`; a file that was DIRTY at turn start
     already has its bytes in the pool. Coverage is therefore COMPLETE for a
     git workspace and honestly reported as PARTIAL otherwise.

Everything is journalled as NDJSON (`journal.ndjson`) under
`~/.vis/rewind/<session>/`, so rewind survives a restart — the history is
NOT process-scoped. Entries are append-only and keyed by turn; a
truncated/corrupt trailing line (crash mid-append) is skipped, never fatal.

Restore semantics for `turn` T: every path touched in turns >= T is set back
to the EARLIEST recorded pre-image at or after T. A file created inside the
rewound region is deleted; a deleted file is recreated; a symlink is
recreated as a symlink; a recursively deleted directory is rebuilt and any
file created inside it since is pruned.

This layer owns FILES ONLY. Conversation truncation is the channel's job —
`points` exposes the turn ids to truncate to. Because that boundary is
invisible to a user typing `/rewind`, the slash READS the session store for
each turn's context size and says out loud, in every branch, that the
conversation stays.
raw docstring

com.blockether.vis.internal.foundation.session-slashes

Declarative session-level slash commands shared by every channel.

These are channel-agnostic: the engine dispatches them for every channel through the same slash/dispatch path, and each handler mutates state via the gateway so the change fans out everywhere.

/rename <new title> set this session's title

/rename routes through titling/set-title-with-broadcast! — the single title mutation point.

Declarative session-level slash commands shared by every channel.

These are channel-agnostic: the engine dispatches them for every channel
through the same `slash/dispatch` path, and each
handler mutates state via the gateway so the change fans out everywhere.

  /rename <new title>   set this session's title

`/rename` routes through `titling/set-title-with-broadcast!` — the single
title mutation point.
raw docstring

com.blockether.vis.internal.foundation.shell

Foundation-core's shell implementation. Bound only when the user-owned shell toggle is ON (default ON; flip it OFF in Settings or in vis.yml via toggles: {shell: false} to drop the tools). The OS process jail is the containment layer while active.

ONE model-facing entry point — the shell PYTHON verb, bound BARE in the flat sandbox next to ls / grep. A process is started from Python, and every verb after the spawn is a method on the handle the call returns. EVERY run is a background run: the call spawns under a real pty and returns the HANDLE now, so there is no wait on the request and no number that can select a second mode. ONE call runs ONE command: an ordered batch was a second budget, a second result shape and a second failure mode for what && already says.

  1. sh = await shell("ls")bash -lc in the workspace root, spawned under a REAL pty, its merged output streamed verbatim to a log FILE and registered as a session RESOURCE. sh.wait(30) is what fills exit/out. Output is bounded at READ time to a head+tail budget per stream, so only the MIDDLE of a huge stream is dropped, never its start or end. A non-zero exit is DATA the model reads, not an error.

  2. A server, watcher or long build is the SAME call — you simply do not wait for it, or you wait for less than it takes. A wait that expires is never a lost process: it keeps running under its id and its log keeps filling.

  3. The log OUTLIVES the run. Every shell keeps its log file and its index row by id for as long as the session does, so "what did that build print" is answerable a turn later from the id alone. That retention is the feature, not a leak to reap.

The result IS the HANDLE: every shell answer is a dict-with-methods in the sandbox, so the process is driven on the object the call already returned — sh.logs(-50) reads the last 50 LINES (or a byte OFFSET, or lines=10 for a ten-line window) and returns a page NOW; page[-4000:] slices that page's out text directly, next(page) continues the read and page.pages() walks its ready pages lazily, bounded to ten by default, while lines=-10 walks back up. sh.wait(30) is the bounded poll loop written once in the engine, sh.type("y") types into the pty and sh.stop() kills the tree. There are no id-taking verbs to re-type an id into; re-issuing a LIVE id gives the same handle back.

STATUS is not a stage of its own: EVERY answer of EVERY stage already says what the shell is doing — status/exit, started_at/finished_at/uptime_ms, log_path, and the live cpu_ms/cpu_percent/rss_bytes of its process tree — so "is it done yet" is read off the result already in hand and never costs a second call.

shell-dispatch survives as the INTERNAL grammar the Python-extension entry points use, since those hand-author an options map and genuinely need an op.

EVERY result of EVERY stage — including an argv run, which uses the same runner — is the one [[shell-result-base]] key set: stage names the producer and is the only thing that varies. A key a stage has nothing to say about is nil / false / 0 instead of absent, so model Python indexes any of them without a KeyError, and a run answers with its command and that command's own bytes at the TOP level — there is no entry to unwrap and no second shape to learn. The shell toggle is registered HERE and owned by Vis core. It closes the MODEL's door only: an installed extension keeps its own trusted process boundary (vis.shell, subprocess), which the toggle does not gate.

Foundation-core's shell implementation. Bound only when the user-owned `shell`
toggle is ON (default ON; flip it OFF in Settings or in `vis.yml` via
`toggles: {shell: false}` to drop the tools). The OS process jail is the
containment layer while active.

ONE model-facing entry point — the `shell` PYTHON verb, bound BARE in the flat
sandbox next to `ls` / `grep`. A process is started from Python, and every verb
after the spawn is a method on the handle the call returns. EVERY run is a
background run: the call spawns
under a real pty and returns the HANDLE now, so there is no `wait` on the
request and no number that can select a second mode. ONE call runs ONE command:
an ordered batch was a second budget, a second result shape and a second failure
mode for what `&&` already says.

1. `sh = await shell("ls")` — `bash -lc` in the workspace root, spawned under
   a REAL pty, its merged output streamed verbatim to a log FILE and registered
   as a session RESOURCE. `sh.wait(30)` is what fills `exit`/`out`. Output is
   bounded at READ time to a head+tail budget per stream, so only the MIDDLE of a
   huge stream is dropped, never its start or end. A non-zero exit is DATA the
   model reads, not an error.

2. A server, watcher or long build is the SAME call — you simply do not wait for
   it, or you wait for less than it takes. A wait that expires is never a lost
   process: it keeps running under its id and its log keeps filling.

3. The log OUTLIVES the run. Every shell keeps its log file and its index row by
   id for as long as the session does, so "what did that build print" is
   answerable a turn later from the id alone. That retention is the feature, not
   a leak to reap.

The result IS the HANDLE: every shell answer is a dict-with-methods in the
sandbox, so the process is driven on the object the call already returned —
`sh.logs(-50)` reads the last 50 LINES (or a byte OFFSET, or `lines=10` for a
ten-line window) and returns a page NOW; `page[-4000:]` slices that page's `out`
text directly, `next(page)` continues the read and `page.pages()` walks its ready
pages lazily, bounded to ten by default, while
`lines=-10` walks back up. `sh.wait(30)` is the bounded poll loop written once
in the engine, `sh.type("y")` types into the pty and `sh.stop()` kills the tree.
There are no id-taking verbs to re-type an id into; re-issuing a LIVE id gives
the same handle back.

STATUS is not a stage of its own: EVERY answer of EVERY stage already says what the
shell is doing — `status`/`exit`, `started_at`/`finished_at`/`uptime_ms`, `log_path`,
and the live `cpu_ms`/`cpu_percent`/`rss_bytes` of its process tree — so "is it done
yet" is read off the result already in hand and never costs a second call.

`shell-dispatch` survives as the INTERNAL grammar the Python-extension entry
points use, since those hand-author an options map and genuinely need an `op`.

EVERY result of EVERY stage — including an argv run, which uses the same runner
— is the one [[shell-result-base]] key set: `stage` names the producer and is
the only thing that varies. A key a stage has nothing to say about is nil /
false / 0 instead of absent, so model Python indexes any of them without a
KeyError, and a run answers with its `command` and that command's own bytes at
the TOP level — there is no entry to unwrap and no second shape to learn.
The `shell` toggle is registered HERE and owned by Vis core. It closes the
MODEL's door only: an installed extension keeps its own trusted process boundary
(`vis.shell`, `subprocess`), which the toggle does not gate.
raw docstring

com.blockether.vis.internal.foundation.shell-log

The output of ONE background shell, stored as a FILE and read by BYTE OFFSET.

The file is the STORAGE and the in-memory ring buffer is only a VIEW. A ring is a display convenience: it answers "what is on screen now", and the moment a command prints more than the ring holds, the head is gone before the first poll and no sequence of reads can recover it. That is the whole reported bug, and it is a storage bug, so the fix is storage: every byte the pump reads is appended to ~/.vis/logs/shell/<session>/<id>.log, and a read names the byte it starts at.

A chunk is the paging contract for a growing file, key for key: give an offset, get the bytes and the next-offset to continue from; a NEGATIVE offset names LINES from the end instead (-50 is the last 50). Feeding next-offset back in a loop yields the WHOLE stream with no overlap and no gap, which is why there is no dropped count anywhere in this namespace — nothing is dropped, so nothing has to be reported as lost.

is-eof means "you have read everything WRITTEN so far", never "the command finished": the process's own status belongs to its handle, not to a read of its log. is-truncated is a cap on THIS read alone and never on the file.

The log is PERSISTENT and belongs to the SESSION: it outlives the process's exit, a daemon restart, and the turn that started the command. It dies with the session — delete-session-logs! runs where the session record is deleted, and the DB index row is scoped to the session soul, so the database cascade retires it in the same breath.

Bytes on disk, index in the DB. The log never becomes a row: it is an append-only stream read by offset, and sqlite would turn every pump flush into a blob rewrite and every cursor read into a substring over that blob. What the DB carries is the ROW that makes a log FINDABLE without holding a handle — the command, the path, the start/end and the exit — on the extension_aggregate sidecar rail under index-extension-id.

The output of ONE background shell, stored as a FILE and read by BYTE OFFSET.

The file is the STORAGE and the in-memory ring buffer is only a VIEW. A ring
is a display convenience: it answers "what is on screen now", and the moment a
command prints more than the ring holds, the head is gone before the first
poll and no sequence of reads can recover it. That is the whole reported bug,
and it is a storage bug, so the fix is storage: every byte the pump reads is
appended to `~/.vis/logs/shell/<session>/<id>.log`, and a read names the byte
it starts at.

A chunk is the paging contract for a growing file, key for key: give an
`offset`, get the bytes and the `next-offset` to continue from; a NEGATIVE
`offset` names LINES from the end instead (`-50` is the last 50). Feeding
`next-offset` back in a loop yields the WHOLE stream with no overlap and no
gap, which is why there is no `dropped` count anywhere in this namespace —
nothing is dropped, so nothing has to be reported as lost.

`is-eof` means "you have read everything WRITTEN so far", never "the command
finished": the process's own status belongs to its handle, not to a read of
its log. `is-truncated` is a cap on THIS read alone and never on the file.

The log is PERSISTENT and belongs to the SESSION: it outlives the process's
exit, a daemon restart, and the turn that started the command. It dies with
the session — [[delete-session-logs!]] runs where the session record is
deleted, and the DB index row is scoped to the session soul, so the database
cascade retires it in the same breath.

Bytes on disk, index in the DB. The log never becomes a row: it is an
append-only stream read by offset, and sqlite would turn every pump flush into
a blob rewrite and every cursor read into a substring over that blob. What
the DB carries is the ROW that makes a log FINDABLE without holding a
handle — the command, the path, the start/end and the exit — on the
`extension_aggregate` sidecar rail under [[index-extension-id]].
raw docstring

com.blockether.vis.internal.foundation.shim-attach

Built-in sandbox SHIM: attach — the GENERIC producer twin of the matplotlib capture. A tool running in python_execution writes any artifact (a PNG it rendered, a CSV/JSON/PDF/wav it built, whatever) and hands it to attach(path) (or attach(data, filename) for bytes it never wrote out), getting back the stored artifact's DESCRIPTOR; the engine then OWNS the bytes as a durable session_iteration_attachment row, exactly like a matplotlib figure — surviving a web/TUI restart and (for image media-types) replayable to a vision model cross-turn.

No parsing, no round-trip through the model-facing stdout: we control the whole boundary. The Python side reads the file through the sandbox's OWN confined open (so filesystem-root confinement is enforced for free — a path outside the roots raises the normal sandbox error), sniffs the media-type (magic bytes then extension then utf-8 probe), base64-encodes, and calls the tiny host bridge __vis_record_attachment__, which appends the attachment map to the per-block *image-sink* (mpl-capture/record-attachment!). run-python-block drains that sink into the block outcome's :attachments; the loop stamps each with the producing block's tool-call-id and hands them to db-store-iteration!'s :attachments. The artifact's :id and :version are minted at the sink, so the block that produced it can address it immediately.

Registered unconditionally as a foundation shim (like shim-yaml / shim-matplotlib): its :ext/sandbox-shims entry autoloads attach into every sandbox.

Built-in sandbox SHIM: `attach` — the GENERIC
producer twin of the matplotlib capture. A tool running in `python_execution`
writes any artifact (a PNG it rendered, a CSV/JSON/PDF/wav it built, whatever)
and hands it to `attach(path)` (or `attach(data, filename)` for bytes it
never wrote out), getting back the stored artifact's DESCRIPTOR; the
engine then OWNS the bytes as a durable `session_iteration_attachment` row,
exactly like a matplotlib figure — surviving a web/TUI restart and (for image
media-types) replayable to a vision model cross-turn.

No parsing, no round-trip through the model-facing stdout: we control the whole
boundary. The Python side reads the file through the sandbox's OWN confined
`open` (so filesystem-root confinement is enforced for free — a path outside
the roots raises the normal sandbox error), sniffs the media-type (magic bytes
then extension then utf-8 probe), base64-encodes, and calls the tiny host
bridge `__vis_record_attachment__`, which appends the attachment map to the
per-block `*image-sink*` (`mpl-capture/record-attachment!`). `run-python-block`
drains that sink into the block outcome's `:attachments`; the loop stamps each with
the producing block's tool-call-id and hands them to `db-store-iteration!`'s
`:attachments`. The artifact's `:id` and `:version` are minted at the sink, so
the block that produced it can address it immediately.

Registered unconditionally as a foundation shim (like shim-yaml /
shim-matplotlib): its `:ext/sandbox-shims` entry autoloads `attach` into
every sandbox.
raw docstring

com.blockether.vis.internal.foundation.shim-ls

Built-in sandbox SHIM: ls — the DIRECTORY listing available inside Python.

Mapping a tree is the cheapest question there is and the one a model asks most, so ls(dir) runs inside the python_execution block already in flight. It answers with one compact tree STRING, ready to print. Structured rows would cost the reader a second rendering step and the context every quoted brace; the tree is the shortest form that still says name, kind, size and shape.

The walk itself stays on the HOST: editing/list-directories is fff's ignore-aware listing (.gitignore, .ignore, cache directories, the vis.yml overlay), an order of magnitude faster than a guest os.scandir recursion that would honour none of those rules. Rows cross as JSON so the shim renders native Python dicts, not foreign proxies. Failures use the standard host-tool boundary and the same declarative error hook as the editing tools.

:fs/access is asked by list-directories itself, so an extension that hides a tree hides it from the listing exactly as it hides it from every read.

Built-in sandbox SHIM: `ls` — the DIRECTORY listing available inside Python.

Mapping a tree is the cheapest question there is and the one a model asks
most, so `ls(dir)` runs inside the `python_execution` block already in flight.
It answers with one compact tree STRING, ready to print. Structured rows would
cost the reader a second rendering step and the context every quoted brace; the
tree is the shortest form that still says name, kind, size and shape.

The walk itself stays on the HOST: `editing/list-directories` is fff's
ignore-aware listing (`.gitignore`, `.ignore`, cache directories, the `vis.yml`
overlay), an order of magnitude faster than a guest `os.scandir` recursion that
would honour none of those rules. Rows cross as JSON so the shim renders native
Python dicts, not foreign proxies. Failures use the standard host-tool boundary
and the same declarative error hook as the editing tools.

`:fs/access` is asked by `list-directories` itself, so an extension that hides
a tree hides it from the listing exactly as it hides it from every read.
raw docstring

com.blockether.vis.internal.foundation.transcript

Full session transcript - DATA first, presentation second.

transcript returns one canonical Clojure map with every turn, every iteration, every executed block plus the LLM-side context (system prompt, message envelope, reasoning trace, top-level provider error, per-iteration vars, answer-form pointer, returned-empty-blocks flag) and the per-block forensic detail (code, comment, stdout, error, duration, timeout?, repaired?). Pure data. The agent can pattern-match on it; the CLI renders Markdown on top; a future TUI screen, JSON exporter, or analytics extension consumes the same shape.

Lives in foundation because it's an introspection surface, not host plumbing. The sandbox-visible public surface is (session-state) for data (including compact usage) and (sessions) for metadata lookup; this namespace owns the transcript portion behind that deeper interface.

Public Clojure surface:

(transcript db-info session-id) -> transcript data map (transcript->md data) -> Markdown string (transcript-md db-info session-id) -> DB lookup + Markdown string

Canonical data shape:

{:session {:id :title :channel :model :provider :created-at} :totals {:turns N :iterations N :tokens {:input :output :reasoning :cached} :cost-usd D} :timeline [{:kind :ref :turn-id :iteration-id :content :code :status :duration-ms}] :turns [{:id :user-request :status :prior-outcome :provider :model :iteration-count :failure-count :tokens :cost-usd :content :iterations [{:id :position :status :duration-ms :provider :model :thinking :error :tokens :cost-usd :answer-position :returned-empty-blocks? :vars [{:name :code :value :version}] :attachments [{:id :source :tool-call-id :position :kind :media-type :filename :version :size :stored}] :blocks [{:position :code :comment :stdout :error :duration-ms :timeout? :repaired?}]}]}]}

The Markdown renderer renders thinking, iteration-level errors, vars, per-block forensic previews, and final answer text. Large fields are bounded so reports stay safe to open.

Full session transcript - DATA first, presentation second.

`transcript` returns one canonical Clojure map with every turn,
every iteration, every executed block plus the LLM-side context
(system prompt, message envelope, reasoning trace, top-level
provider error, per-iteration vars, answer-form pointer,
returned-empty-blocks flag) and the per-block forensic detail
(code, comment, stdout, error, duration, timeout?, repaired?).
Pure data. The agent can pattern-match on it; the CLI
renders Markdown on top; a future TUI screen, JSON exporter, or
analytics extension consumes the same shape.

Lives in foundation because it's an introspection surface, not host
plumbing. The sandbox-visible public surface is `(session-state)` for data
(including compact usage) and `(sessions)` for metadata lookup; this namespace
owns the transcript portion behind that deeper interface.

Public Clojure surface:

  `(transcript      db-info session-id)`  -> transcript data map
  `(transcript->md  data)`             -> Markdown string
  `(transcript-md   db-info session-id)`  -> DB lookup + Markdown string

Canonical data shape:

  {:session {:id :title :channel :model :provider :created-at}
   :totals       {:turns N :iterations N
                  :tokens {:input :output :reasoning :cached}
                  :cost-usd D}
   :timeline    [{:kind :ref :turn-id :iteration-id :content :code
                  :status :duration-ms}]
   :turns
    [{:id :user-request :status :prior-outcome :provider :model
      :iteration-count :failure-count
      :tokens :cost-usd :content
      :iterations
       [{:id :position :status :duration-ms
         :provider :model :thinking :error
         :tokens :cost-usd
         :answer-position :returned-empty-blocks?
         :vars
         [{:name :code :value :version}]
         :attachments
         [{:id :source :tool-call-id :position :kind
           :media-type :filename :version :size :stored}]
         :blocks
         [{:position :code :comment :stdout :error
           :duration-ms :timeout? :repaired?}]}]}]}

The Markdown renderer renders thinking, iteration-level errors,
vars, per-block forensic previews, and final answer text. Large
fields are bounded so reports stay safe to open.
raw docstring

com.blockether.vis.internal.foundation.workspace-ctx

Pre-turn "session_workspace" CTX block (STRING-KEYED — crosses the Python boundary as session["workspace"]).

Sessions may work directly in trunk or inside an isolated backend workspace. That distinction is reported on "isolated" (the word sandbox names the Python sandbox, and confinement is jail), NOT as a VCS. "vcs_kind" reports the underlying repository VCS ("git" when the root is inside a git repo, else "none") so it matches the git/ extension surface, which activates on the same predicate. The model reads the workspace block to know the active root and what it has changed since the fork. The block is stamped once per turn at engine start; ctx_renderer serialises it verbatim.

Pre-turn `"session_workspace"` CTX block (STRING-KEYED — crosses the
Python boundary as `session["workspace"]`).

Sessions may work directly in trunk or inside an isolated backend
workspace. That distinction is reported on `"isolated"` (the word
`sandbox` names the Python sandbox, and confinement is `jail`), NOT
as a VCS. `"vcs_kind"` reports the underlying repository VCS (`"git"`
when the root is inside a git repo, else `"none"`) so it matches the
`git/` extension surface, which activates on the same predicate. The
model reads the workspace block to know the active root and what it has
changed since the fork. The block is stamped once per turn at engine
start; ctx_renderer serialises it verbatim.
raw docstring

com.blockether.vis.internal.foundation.workspace-slashes

Declarative filesystem-root slash command.

/cd is session-scoped and available in every channel. What the jail ALLOWS comes from jail.filesystem in merged config; the command only moves the session's primary live root within that grant.

Declarative filesystem-root slash command.

`/cd` is session-scoped and available in every channel. What the jail ALLOWS
comes from `jail.filesystem` in merged config; the command only moves the
session's primary live root within that grant.
raw docstring

com.blockether.vis.internal.gateway.bus

Cross-process gateway event bus.

The gateway's live event log + SSE fan-out (gateway.state) is a PROCESS-LOCAL in-memory registry: append-event! only reaches subscribers inside the SAME JVM. That is why a turn streaming in the TUI process is invisible to another process watching the SAME conversation — each process has its own registry, and the only thing they share is the persisted DB (which lands whole turns, not the live token stream). So two watchers never stream together.

This bus closes that gap with the simplest transport that needs no schema change and no always-on daemon: a shared append-only journal under ~/.vis/gateway/events/<sid>.ndjson. Every LOCALLY-produced gateway event is publish!ed there (one JSON line, tagged with this process's producer id). A background tailer in each process follows those files and re-delivers FOREIGN events (producer != self) into the local registry via a delivery fn wired by gateway.state — so every process's subscribers see the same stream, live.

Ordering/seq: exactly ONE turn runs per session at a time, so at any moment a single producer owns the stream and its monotonic :seq is authoritative for every watcher. The producer truncates the journal at each turn.started, bounding a file to one turn's worth of deltas; consumers detect the truncation (offset past EOF) and rewind.

Degrades safely: any IO failure is swallowed and the process falls back to today's in-process-only behavior.

Cross-process gateway event bus.

The gateway's live event log + SSE fan-out (`gateway.state`) is a
PROCESS-LOCAL in-memory registry: `append-event!` only reaches
subscribers inside the SAME JVM. That is why a turn streaming in the
TUI process is invisible to another process watching the SAME
conversation — each process has its own registry, and the only thing
they share is the persisted DB (which lands whole turns, not the live
token stream). So two watchers never stream together.

This bus closes that gap with the simplest transport that needs no
schema change and no always-on daemon: a shared append-only journal
under `~/.vis/gateway/events/<sid>.ndjson`. Every LOCALLY-produced
gateway event is `publish!`ed there (one JSON line, tagged with this
process's `producer` id). A background tailer in each process follows
those files and re-delivers FOREIGN events (producer != self) into the
local registry via a delivery fn wired by `gateway.state` — so every
process's subscribers see the same stream, live.

Ordering/seq: exactly ONE turn runs per session at a time, so at any
moment a single producer owns the stream and its monotonic `:seq` is
authoritative for every watcher. The producer truncates the journal at
each `turn.started`, bounding a file to one turn's worth of deltas;
consumers detect the truncation (offset past EOF) and rewind.

Degrades safely: any IO failure is swallowed and the process falls
back to today's in-process-only behavior.
raw docstring

com.blockether.vis.internal.gateway.client

HTTP/SSE client for the long-lived gateway daemon.

Interactive channels call this facade instead of gateway.state directly. It discover-or-starts the one daemon for the current DB, then speaks the same HTTP/SSE API every other client uses. This is the thin-client half of the gateway-daemon plan: token refresh, turn execution, and live streaming happen in ONE process.

WHICH daemon is a policy of this namespace: normally the one this machine manages for the current DB, or — through connect-remote! (the --gateway flag / VIS_GATEWAY_URL) — a gateway on another machine, attached to over HTTP and never spawned, restarted or stopped from here.

HTTP/SSE client for the long-lived gateway daemon.

Interactive channels call this facade instead of `gateway.state` directly. It
discover-or-starts the one daemon for the current DB, then speaks the same
HTTP/SSE API every other client uses. This is the thin-client half of the
gateway-daemon plan: token refresh, turn execution, and live streaming happen
in ONE process.

WHICH daemon is a policy of this namespace: normally the one this machine
manages for the current DB, or — through `connect-remote!` (the `--gateway`
flag / `VIS_GATEWAY_URL`) — a gateway on another machine, attached to over HTTP
and never spawned, restarted or stopped from here.
raw docstring

com.blockether.vis.internal.gateway.discovery

Gateway daemon discovery + registry (build order step 1).

One long-lived gateway per DB owns execution; every TUI/web/whatever is a thin client of it. This namespace answers the boot-time question: "is a gateway already running for my DB — attach; else spawn one, DETACHED (nobody's child), then hand back where to connect."

Registry: one EDN file per DB at ~/.vis/gateway/registry/<sha256(db)>.edn holding {:pid :port :host :secret :db :created-at}. Freshness = the recorded :pid is still alive AND a caller-supplied probe confirms the port+secret are really OUR daemon (guards OS pid reuse — see D4/Q3 in TODO-gateway-daemon).

Design decisions (locked, see TODO-gateway-daemon.md):

  • Q2 registry key = the DB path (two dirs sharing --db share one daemon).
  • Q3 race = port-bind winner is the daemon; the loser attaches. The daemon SELF-REGISTERS on startup (via register-self! from serve-main!), so a spawner never needs the child pid — it just polls for a fresh registry.
  • Q5 :memory never registers/spawns (headless one-shot stays in-process).

Effects (spawn, probe, pid-liveness) are injectable so the orchestration in discover-or-start! is unit-testable without a real process.

Gateway daemon discovery + registry (build order step 1).

One long-lived gateway per DB owns execution; every TUI/web/whatever is a
thin client of it. This namespace answers the boot-time question: "is a
gateway already running for my DB — attach; else spawn one, DETACHED (nobody's
child), then hand back where to connect."

Registry: one EDN file per DB at `~/.vis/gateway/registry/<sha256(db)>.edn`
holding `{:pid :port :host :secret :db :created-at}`. Freshness = the recorded
`:pid` is still alive AND a caller-supplied `probe` confirms the port+secret
are really OUR daemon (guards OS pid reuse — see D4/Q3 in TODO-gateway-daemon).

Design decisions (locked, see TODO-gateway-daemon.md):
- Q2 registry key = the DB path (two dirs sharing `--db` share one daemon).
- Q3 race = port-bind winner is the daemon; the loser attaches. The daemon
  SELF-REGISTERS on startup (via [[register-self!]] from `serve-main!`), so a
  spawner never needs the child pid — it just polls for a fresh registry.
- Q5 `:memory` never registers/spawns (headless one-shot stays in-process).

Effects (`spawn`, `probe`, pid-liveness) are injectable so the orchestration
in [[discover-or-start!]] is unit-testable without a real process.
raw docstring

com.blockether.vis.internal.gateway.fcm

Android push (Firebase Cloud Messaging HTTP v1).

Apple's APNs lives in gateway.push; this is its Android twin and the two are dispatched on the registered device's :platform. Credentials are a Google service-account JSON — from the macOS keychain (service vis-fcm, account service_account), from the environment, or from a file under ~/.vis/fcm/. Key material is never returned, logged or sent over the wire.

Android push (Firebase Cloud Messaging HTTP v1).

Apple's APNs lives in `gateway.push`; this is its Android twin and the two
are dispatched on the registered device's `:platform`. Credentials are a
Google service-account JSON — from the macOS keychain (service `vis-fcm`,
account `service_account`), from the environment, or from a file under
`~/.vis/fcm/`. Key material is never returned, logged or sent over the wire.
raw docstring

com.blockether.vis.internal.gateway.keychain

The ONE reader of a push credential out of the macOS login keychain.

Both push transports keep their signing secret there rather than in a file on disk: APNs its ES256 .p8 (service vis-apns), FCM its Google service-account JSON (service vis-fcm). The secret never sits in a world-readable file.

Each answer is held in memory for CACHE_TTL_MS before security is asked again. It used to be read on demand and never cached, so locking the keychain revoked access immediately - but push/config asks for five secrets, and it runs inside capabilities, a request every connected client makes: a JFR profile of a live gateway caught the daemon forking security at a steady rate, ~21 ms of wall and ~10 ms of CPU a fork, to re-read five values that change only when a human edits the keychain. Locking the keychain now revokes access within the TTL rather than instantly; an unlocked-again keychain is likewise noticed within it.

nil anywhere but macOS, and nil while vis.push.home is set: a redirected push home means a test fixture, and the developer's real keychain must never leak into it.

The ONE reader of a push credential out of the macOS login keychain.

Both push transports keep their signing secret there rather than in a file on
disk: APNs its ES256 `.p8` (service `vis-apns`), FCM its Google service-account
JSON (service `vis-fcm`). The secret never sits in a world-readable file.

Each answer is held in memory for `CACHE_TTL_MS` before `security` is asked
again. It used to be read on demand and never cached, so locking the keychain
revoked access immediately - but `push/config` asks for five secrets, and it
runs inside `capabilities`, a request every connected client makes: a JFR
profile of a live gateway caught the daemon forking `security` at a steady
rate, ~21 ms of wall and ~10 ms of CPU a fork, to re-read five values that
change only when a human edits the keychain. Locking the keychain now revokes
access within the TTL rather than instantly; an unlocked-again keychain is
likewise noticed within it.

nil anywhere but macOS, and nil while `vis.push.home` is set: a redirected push
home means a test fixture, and the developer's real keychain must never leak
into it.
raw docstring

com.blockether.vis.internal.gateway.pairing

Gateway pairing helpers for remote clients.

The QR payload is deliberately tiny and URL-shaped so native apps can scan it without an HTTP round trip:

vis://gateway?url=http%3A%2F%2F100.64.0.10%3A7890&token=...

Tailscale fits naturally: if a 100.64.0.0/10 interface is present we prefer it over LAN addresses, otherwise we fall back to site-local IPv4 addresses.

Gateway pairing helpers for remote clients.

The QR payload is deliberately tiny and URL-shaped so native apps can scan it
without an HTTP round trip:

  vis://gateway?url=http%3A%2F%2F100.64.0.10%3A7890&token=...

Tailscale fits naturally: if a 100.64.0.0/10 interface is present we prefer it
over LAN addresses, otherwise we fall back to site-local IPv4 addresses.
raw docstring

com.blockether.vis.internal.gateway.push

Native push notifications (Apple Push Notification service).

ONE job: when a turn finishes on this gateway, wake the phone that asked to be woken. Everything here is server-side; the app only ever hands us a device token.

Three moving parts:

  1. Credentials. A token-based APNs auth key (.p8, ES256) plus its key id, the Apple team id and the app's bundle id (the APNs topic). Resolved from VIS_APNS_KEY_PATH / VIS_APNS_KEY_ID / VIS_APNS_TEAM_ID / VIS_APNS_TOPIC, else auto-discovered from ~/.vis/apns/AuthKey_<kid>.p8 (the key id is read off the filename) with the team/topic still from env or ~/.vis/apns/apns.edn. No credentials = push silently OFF; the gateway keeps working exactly as before.

  2. A device registry at ~/.vis/devices.edn — device token -> platform, APNs environment, client label/version, timestamps. Registration is idempotent on the token. Tokens are SECRETS: nothing here logs more than a masked prefix.

  3. A sender — a signed ES256 JWT (cached, refreshed well inside Apple's one-hour window) over HTTP/2 to api.push.apple.com. A device that registered with the wrong environment is retried once against the other host, and an APNs BadDeviceToken/Unregistered verdict evicts the device so a stale token cannot accumulate.

The wire surface lives in gateway.server (/v1/devices); this namespace knows nothing about Ring.

Native push notifications (Apple Push Notification service).

ONE job: when a turn finishes on this gateway, wake the phone that asked
to be woken. Everything here is server-side; the app only ever hands us a
device token.

Three moving parts:

1. **Credentials.** A token-based APNs auth key (`.p8`, ES256) plus its key
   id, the Apple team id and the app's bundle id (the APNs *topic*).
   Resolved from `VIS_APNS_KEY_PATH` / `VIS_APNS_KEY_ID` / `VIS_APNS_TEAM_ID`
   / `VIS_APNS_TOPIC`, else auto-discovered from `~/.vis/apns/AuthKey_<kid>.p8`
   (the key id is read off the filename) with the team/topic still from env
   or `~/.vis/apns/apns.edn`. No credentials = push silently OFF; the gateway
   keeps working exactly as before.

2. **A device registry** at `~/.vis/devices.edn` — device token -> platform,
   APNs environment, client label/version, timestamps. Registration is
   idempotent on the token. Tokens are SECRETS: nothing here logs more than
   a masked prefix.

3. **A sender** — a signed ES256 JWT (cached, refreshed well inside Apple's
   one-hour window) over HTTP/2 to `api.push.apple.com`. A device that
   registered with the wrong environment is retried once against the other
   host, and an APNs `BadDeviceToken`/`Unregistered` verdict evicts the
   device so a stale token cannot accumulate.

The wire surface lives in `gateway.server` (`/v1/devices`); this namespace
knows nothing about Ring.
raw docstring

com.blockether.vis.internal.gateway.relay

Relayed push — how a gateway wakes a phone WITHOUT holding Apple's or Google's signing key.

APNs binds a topic to the Apple team that owns it: a key minted by anyone else, aimed at someone else's bundle id, is refused forever (403 InvalidProviderToken / TopicDisallowed). So a self-hosted gateway can never push to a companion built and signed by somebody else — unless the signing key stays on infrastructure the app's publisher runs, and the gateway is given a capability instead of a credential.

That capability is a GRANT. The device asks the relay for one and hands it to this gateway during "notify this device"; the gateway POSTs {grant, title, body} and the relay signs and forwards. Consequences worth the indirection:

  • this gateway holds no .p8, no service-account JSON, nothing revocable only by breaking push for everyone else;
  • this gateway never learns the raw APNs/FCM device token, so a gateway you do not trust cannot fingerprint the device it notifies;
  • a grant expires by itself. Its expiry travels inside it, sealed, so the relay keeps no list of anybody and an abandoned gateway simply goes mute.

The relay itself lives in apps/vis-companion-relay (a Cloudflare Worker), and every gateway names the publisher's one by DEFAULT (DEFAULT-URL): a machine nobody configured is already able to push. A device may name another and is believed — which relay can sign for a build is a property of the BUILD, so the app mints its grant at the relay serving the app it is and posts {grant, relay_url} to /v1/devices. VIS_PUSH_RELAY_URL, or ~/.vis/relay.edn {:url "https://push.example.com"}, replaces the default on one machine; the direct gateway.push / gateway.fcm credentials still work exactly as before.

Relayed push — how a gateway wakes a phone WITHOUT holding Apple's or
Google's signing key.

APNs binds a topic to the Apple team that owns it: a key minted by anyone
else, aimed at someone else's bundle id, is refused forever (`403
InvalidProviderToken` / `TopicDisallowed`). So a self-hosted gateway can
never push to a companion built and signed by somebody else — unless the
signing key stays on infrastructure the app's publisher runs, and the
gateway is given a capability instead of a credential.

That capability is a GRANT. The *device* asks the relay for one and hands
it to this gateway during "notify this device"; the gateway POSTs
`{grant, title, body}` and the relay signs and forwards. Consequences worth
the indirection:

* this gateway holds no `.p8`, no service-account JSON, nothing revocable
  only by breaking push for everyone else;
* this gateway never learns the raw APNs/FCM device token, so a gateway you
  do not trust cannot fingerprint the device it notifies;
* a grant expires by itself. Its expiry travels inside it, sealed, so the
  relay keeps no list of anybody and an abandoned gateway simply goes mute.

The relay itself lives in `apps/vis-companion-relay` (a Cloudflare Worker),
and every gateway names the publisher's one by DEFAULT (`DEFAULT-URL`): a
machine nobody configured is already able to push. A device may name another
and is believed — which relay can sign for a build is a property of the
BUILD, so the app mints its grant at the relay serving the app it is and
posts `{grant, relay_url}` to `/v1/devices`. `VIS_PUSH_RELAY_URL`, or
`~/.vis/relay.edn` `{:url "https://push.example.com"}`, replaces the default
on one machine; the direct `gateway.push` / `gateway.fcm` credentials still
work exactly as before.
raw docstring

com.blockether.vis.internal.gateway.resources

Canonical in-memory registry of gateway-managed stateful resources. Every long-lived thing vis spawns registers here so the agent, footer, and shutdown all share one live view. Resources are deliberately not persisted: their processes belong to the gateway and die when the gateway dies.

Canonical in-memory registry of gateway-managed stateful resources. Every
long-lived thing vis spawns registers here so the agent, footer, and shutdown
all share one live view. Resources are deliberately not persisted: their
processes belong to the gateway and die when the gateway dies.
raw docstring

com.blockether.vis.internal.gateway.runtime

Runtime identity, daemon staleness and compatibility diagnostics.

Canonical protocol numbers, headers, handshake parsing and the pure compatibility verdict live in com.blockether.vis.contract.gateway. This namespace contributes the release/build identity of the running process and the concrete client/server adapters that combine it with that contract.

Runtime identity, daemon staleness and compatibility diagnostics.

Canonical protocol numbers, headers, handshake parsing and the pure compatibility
verdict live in `com.blockether.vis.contract.gateway`. This namespace contributes
the release/build identity of the running process and the concrete client/server
adapters that combine it with that contract.
raw docstring

com.blockether.vis.internal.gateway.server

Gateway HTTP/SSE server.

Clojure-native stack: reitit-ring routes -> Ring middleware -> a Jetty 12 CORE handler (ring.adapter.jetty9 — no servlet layer) on JDK virtual threads (:virtual-threads? true). SSE is a Ring StreamableResponseBody whose virtual thread is the connection's SINGLE socket writer: replay rides first, then it drains a bounded per-connection event queue that state/fan-out! enqueues onto, emitting a heartbeat comment on idle to keep the pipe warm and detect dead clients.

This is internal plumbing, not a channel: it registers no channel descriptor and owns no renderer - it ships canonical IR and the client renders (§4.1). Any host process (the vis-agent gateway start daemon, a TUI run, an embedded caller) can start it alongside whatever else it is doing via start!.

Gateway HTTP/SSE server.

Clojure-native stack: reitit-ring routes -> Ring middleware -> a Jetty 12
CORE handler (`ring.adapter.jetty9` — no servlet layer) on JDK virtual
threads (`:virtual-threads? true`).
SSE is a Ring `StreamableResponseBody` whose virtual thread is the
connection's SINGLE socket writer: replay rides first, then it drains a
bounded per-connection event queue that `state/fan-out!` enqueues onto,
emitting a heartbeat comment on idle to keep the pipe warm and detect
dead clients.

This is internal plumbing, not a channel: it registers no channel
descriptor and owns no renderer - it ships canonical IR and the
client renders (§4.1). Any host process (the `vis-agent gateway start` daemon, a
TUI run, an embedded caller) can start it alongside whatever else it
is doing via `start!`.
raw docstring

com.blockether.vis.internal.gateway.server.transport.sse

Concrete SSE framing for the gateway's Ring server transport.

Concrete SSE framing for the gateway's Ring server transport.
raw docstring

com.blockether.vis.internal.gateway.state

Gateway session manager.

One process-global registry over the live session fleet: per-session ordered event log (monotonic :seq, ring-buffered), SSE subscriber fan-out, async turn submission with idempotency keys, cancellation, and turn/cost metrics.

The engine is reached ONLY through the same internal surfaces the TUI channel uses: loop/create!-send!-close! for the lifecycle, :hooks {:on-chunk ...} phased chunks for the live stream, ctx-loop/session-snapshot for the context. No engine state lives here - this namespace owns wire bookkeeping (events, turn records, subscribers), nothing else.

Gateway session manager.

One process-global registry over the live session fleet: per-session
ordered event log (monotonic `:seq`, ring-buffered), SSE subscriber
fan-out, async turn submission with idempotency keys, cancellation,
and turn/cost metrics.

The engine is reached ONLY through the same internal surfaces the
TUI channel uses: `loop/create!`-`send!`-`close!` for the
lifecycle, `:hooks {:on-chunk ...}` phased chunks for the live
stream, `ctx-loop/session-snapshot` for the context. No engine state
lives here - this namespace owns wire bookkeeping (events, turn
records, subscribers), nothing else.
raw docstring

com.blockether.vis.internal.gateway.stdio

Gateway-free, serial NDJSON transport for the Python SDK. Uses the same SDK handlers and protocol as HTTP; never starts Jetty or performs discovery. The owning process must select its own database before entering this loop.

Gateway-free, serial NDJSON transport for the Python SDK. Uses the same SDK
handlers and protocol as HTTP; never starts Jetty or performs discovery.
The owning process must select its own database before entering this loop.
raw docstring

com.blockether.vis.internal.gateway.view

Gateway transport for the shared View lifecycle.

The engine publishes :view/open, :view/patch and :view/close envelopes on every selected channel. This namespace projects the :app copy into the matching view.open, view.patch and view.close session events, preserving :kind so clients choose capability policy without guessing from event names.

An :input View blocks while a :live View streams coalesced patches on [[live-flush-ms]]. Operator intent for either kind enters through one action! and one kind-independent REST resource; the engine owns the closed action vocabulary and capability checks.

Gateway transport for the shared View lifecycle.

The engine publishes `:view/open`, `:view/patch` and `:view/close` envelopes on
every selected channel. This namespace projects the `:app` copy into the matching
`view.open`, `view.patch` and `view.close` session events, preserving `:kind` so
clients choose capability policy without guessing from event names.

An `:input` View blocks while a `:live` View streams coalesced patches on
[[live-flush-ms]]. Operator intent for either kind enters through one [[action!]]
and one kind-independent REST resource; the engine owns the closed action
vocabulary and capability checks.
raw docstring

com.blockether.vis.internal.gateway.web-push

Gateway-local Web Push (RFC 8291 and RFC 8292).

Each gateway owns one generated VAPID P-256 key pair in its own ~/.vis home. The browser gets this gateway's public key, registers its subscription here, and this gateway encrypts and sends notifications directly to the browser's push service. There is no publisher URL or shared web relay.

Java interop is kept at the cryptographic and file boundaries. The rest of the namespace passes ordinary Clojure maps and byte arrays between small helpers so the protocol steps remain visible and testable.

Gateway-local Web Push (RFC 8291 and RFC 8292).

Each gateway owns one generated VAPID P-256 key pair in its own `~/.vis`
home. The browser gets this gateway's public key, registers its subscription
here, and this gateway encrypts and sends notifications directly to the
browser's push service. There is no publisher URL or shared web relay.

Java interop is kept at the cryptographic and file boundaries. The rest of
the namespace passes ordinary Clojure maps and byte arrays between small
helpers so the protocol steps remain visible and testable.
raw docstring

com.blockether.vis.internal.import

In-house import-vars — re-export public vars from other namespaces under the current namespace, carrying their :doc/:arglists so the facade reads like the originals and doc/editor help work on a re-export. A tiny, dependency-free stand-in for potemkin's import-vars (the project keeps no separate lib for it).

Value semantics are identical to a plain (def alias src): the alias captures the source var's value at load time — it re-exports a fn/const, it does NOT track later redefinitions, and (like a def-alias always did) it is not a binding-rebindable handle for a dynamic source var.

In-house `import-vars` — re-export public vars from other namespaces under the
current namespace, carrying their :doc/:arglists so the facade reads like the
originals and `doc`/editor help work on a re-export. A tiny, dependency-free
stand-in for potemkin's import-vars (the project keeps no separate lib for it).

Value semantics are identical to a plain `(def alias src)`: the alias captures
the source var's value at load time — it re-exports a fn/const, it does NOT
track later redefinitions, and (like a def-alias always did) it is not a
`binding`-rebindable handle for a dynamic source var.
raw docstring

com.blockether.vis.internal.jfr

Opt-in Java Flight Recorder profiling — ONE recording per PROCESS.

Turned on per-process by the VIS_JFR env var (set by bin/vis-agent --jfr). The var is INHERITED by the detached gateway daemon that a client spawns, so both the client (TUI/web) process AND the gateway daemon each start their OWN recording into a role+pid-tagged file under ~/.vis/logs/:

vis-client-<pid>-<ts>.jfr ← the TUI / web / one-shot process vis-gateway-<pid>-<ts>.jfr ← the long-lived gateway daemon

That gives you two SEPARATE readings to compare when a client seems to be waiting on the gateway (jfr print --events jdk.ExecutionSample <file>).

Works on the JVM always; on the compiled native binary only when it was built with --enable-monitoring=jfr (see the app's native-image.properties). Never throws and never blocks startup — if JFR is unavailable it just no-ops.

Opt-in Java Flight Recorder profiling — ONE recording per PROCESS.

Turned on per-process by the `VIS_JFR` env var (set by `bin/vis-agent --jfr`). The
var is INHERITED by the detached gateway daemon that a client spawns, so both
the client (TUI/web) process AND the gateway daemon each start their OWN
recording into a role+pid-tagged file under `~/.vis/logs/`:

  vis-client-<pid>-<ts>.jfr     ← the TUI / web / one-shot process
  vis-gateway-<pid>-<ts>.jfr    ← the long-lived gateway daemon

That gives you two SEPARATE readings to compare when a client seems to be
waiting on the gateway (`jfr print --events jdk.ExecutionSample <file>`).

Works on the JVM always; on the compiled native binary only when it was built
with `--enable-monitoring=jfr` (see the app's native-image.properties). Never
throws and never blocks startup — if JFR is unavailable it just no-ops.
raw docstring

com.blockether.vis.internal.language.clojure.core

Clojure language handlers for Vis.

Format/test/REPL are exposed through the generic language facade (format, test, repl_eval, repl_start, repl_stop) — format here does parinfer delimiter repair + cljfmt, and the same repair is registered as the pack's :balance-fn: the foundation's editors call it with the WHOLE spliced file when an edit would not parse, and write the repair only when it stays inside the lines that edit wrote. Both doors are ADD-ONLY: a delimiter you omitted is added back, one you WROTE is never deleted — a lost opening ( and one ) too many are the same string, so deleting is a guess that rewrites code, and the text an edit REPLACED is the only thing that tells the two apart.

Clojure language handlers for Vis.

Format/test/REPL are exposed through the generic language facade
(`format`, `test`, `repl_eval`, `repl_start`, `repl_stop`) —
`format` here does parinfer delimiter repair + cljfmt, and the same repair is
registered as the pack's `:balance-fn`: the foundation's editors call it with
the WHOLE spliced file when an edit would not parse, and write the repair only
when it stays inside the lines that edit wrote. Both doors are ADD-ONLY: a delimiter
 you omitted is added back, one you WROTE is never deleted — a lost opening `(` and
 one `)` too many are the same string, so deleting is a guess that rewrites code, and
 the text an edit REPLACED is the only thing that tells the two apart.
raw docstring

com.blockether.vis.internal.language.clojure.format

Config-driven Clojure source formatter used by clj/edit for format-on-write and by the format_code language-surface verb.

TWO backends live here, and the choice is TRANSPARENT to the language surface — callers just format; this namespace picks the formatter from the config files present around the target path:

  • zprint — when a .zprint.edn/.zprintrc is found walking UP from the path. The project's zprint options map is applied. This is the canonical, reflowing formatter — for this repo it is THE formatter, applied through the repo's own .zprint.edn.
  • cljfmt — when only a .cljfmt.edn/.cljfmt.clj is found (no zprint), or when neither config exists (cljfmt defaults). Conservative: normalizes indentation + whitespace of MULTI-LINE forms but does NOT reflow a one-liner into multiple lines.

When BOTH configs are present, zprint WINS. Backends and their config loaders are resolved on first use, not while registering the language pack at gateway startup. Registering formatting handlers does not need either implementation.

Failure mode: if a backend refuses (parse error, unfamiliar reader macro, anything that throws), the formatter returns the original source unchanged. We never silently corrupt a file because the formatter choked.

Config-driven Clojure source formatter used by `clj/edit` for format-on-write
and by the `format_code` language-surface verb.

TWO backends live here, and the choice is TRANSPARENT to the language
surface — callers just format; this namespace picks the formatter from the
config files present around the target path:

  * zprint  — when a `.zprint.edn`/`.zprintrc` is found walking UP from the
              path. The project's zprint options map is applied. This is the
              canonical, reflowing formatter — for this repo it is THE
              formatter, applied through the repo's own `.zprint.edn`.
  * cljfmt  — when only a `.cljfmt.edn`/`.cljfmt.clj` is found (no zprint), or
              when neither config exists (cljfmt defaults). Conservative:
              normalizes indentation + whitespace of MULTI-LINE forms but does
              NOT reflow a one-liner into multiple lines.

When BOTH configs are present, zprint WINS. Backends and their config loaders
are resolved on first use, not while registering the language pack at gateway
startup. Registering formatting handlers does not need either implementation.

Failure mode: if a backend refuses (parse error, unfamiliar reader macro,
anything that throws), the formatter returns the original source unchanged.
We never silently corrupt a file because the formatter choked.
raw docstring

com.blockether.vis.internal.language.clojure.lint

clj-kondo linting for the Vis language surface.

Runs clj-kondo's programmatic API (clj-kondo.core/run!) — never shells out — over a code string (fed on stdin as -), explicit path(s), or the workspace's default source paths, and returns a uniform result map (STRING keys — crosses the strings-only boundary as a tool :result): {"op" "clj-lint" "error" N "warning" N "info" N "files" N "findings" [...]} where each finding is {"file" "row" "col" "level" "type" "message" "provider"} (every finding names clj-kondo as its provider).

Resolve the analyzer on the first lint request, not when a language pack is registered: non-Clojure sessions do not need its compiler and analysis tables.

clj-kondo linting for the Vis language surface.

Runs clj-kondo's programmatic API (`clj-kondo.core/run!`) — never shells out —
over a code string (fed on stdin as `-`), explicit path(s), or the workspace's
default source paths, and returns a uniform result map (STRING keys — crosses
the strings-only boundary as a tool `:result`):
`{"op" "clj-lint" "error" N "warning" N "info" N "files" N "findings" [...]}`
where each finding is `{"file" "row" "col" "level" "type" "message" "provider"}`
(every finding names clj-kondo as its provider).

Resolve the analyzer on the first lint request, not when a language pack is
registered: non-Clojure sessions do not need its compiler and analysis tables.
raw docstring

com.blockether.vis.internal.language.clojure.nrepl-client

Thin, observable nREPL client for clj/eval.

Connection model:

  • One nrepl.core/connect socket per [host port] key, cached on a defonce atom so we survive (require :reload) during development.
  • ONE long-lived nREPL session per connection, cloned lazily on first use and cached beside the socket — then REUSED by every eval!. This is how Cider/Calva/every editor drives nREPL: no per-eval clone/close round-trip on the hot path (those were what blew the run_tests budget under JVM load), nothing to leak, and session-local state — *1/*2/*3/*e and dynamic set!s — PERSISTS across calls like a real REPL ((def …) was already global; now the whole session is).
  • Stale / closed sockets are detected (IOException / nil message stream) and the entry is evicted — closing the socket and dropping the cached session — so the next call re-dials + re-clones.

Returned shape (success) — STRING keys (crosses the strings-only boundary as a tool :result; enrichment adds "error_message"/"error_data"/"trace"): {"value" "42" ; pr-str of the LAST form's value, or nil "values" ["1" "42"] ; pr-str of every emitted value "out" "hello\n" ; stdout aggregated "err" "" ; stderr aggregated "ns" "user" ; final ns name "status" #{"done"} ; nREPL status set (strings) "ex" nil ; exception class name, when status :ex "root_ex" nil ; root exception class name "ms" 12 ; wall-clock duration "port" 7888 "timed_out" false}

Failure paths throw ex-info with :type :clj/nrepl-* so the Vis tool wrapper can surface a clean error to the model.

Thin, observable nREPL client for `clj/eval`.

Connection model:
  * One `nrepl.core/connect` socket per `[host port]` key, cached
    on a `defonce` atom so we survive `(require :reload)` during
    development.
  * ONE long-lived nREPL session per connection, cloned lazily on
    first use and cached beside the socket — then REUSED by every
    `eval!`. This is how Cider/Calva/every editor drives nREPL: no
    per-eval `clone`/`close` round-trip on the hot path (those were
    what blew the `run_tests` budget under JVM load), nothing
    to leak, and session-local state — `*1`/`*2`/`*3`/`*e` and dynamic
    `set!`s — PERSISTS across calls like a real REPL (`(def …)` was
    already global; now the whole session is).
  * Stale / closed sockets are detected (`IOException` / `nil`
    message stream) and the entry is evicted — closing the socket and
    dropping the cached session — so the next call re-dials + re-clones.

Returned shape (success) — STRING keys (crosses the strings-only boundary
as a tool `:result`; enrichment adds "error_message"/"error_data"/"trace"):
  {"value"      "42"          ; pr-str of the LAST form's value, or nil
   "values"     ["1" "42"]   ; pr-str of every emitted value
   "out"        "hello\n"     ; stdout aggregated
   "err"        ""             ; stderr aggregated
   "ns"         "user"         ; final *ns* name
   "status"     #{"done"}      ; nREPL status set (strings)
   "ex"         nil              ; exception class name, when status :ex
   "root_ex"    nil              ; root exception class name
   "ms"         12               ; wall-clock duration
   "port"       7888
   "timed_out" false}

Failure paths throw `ex-info` with `:type :clj/nrepl-*` so the
Vis tool wrapper can surface a clean error to the model.
raw docstring

com.blockether.vis.internal.language.clojure.nrepl-ctx

Per-turn nREPL resource synchronization for the Clojure pack.

Live state has ONE model-facing home: repl_status. This extension hook probes owned nREPLs and mirrors them into the generic session resource registry — what repl_status and the footer answer from; nothing about a resource rides in ctx. It returns no legacy session["env"]["languages"] contribution.

OWNERSHIP: we surface ONLY the REPLs THIS session started + owns, PLUS any external nREPL the user EXPLICITLY attached via connect (both from repl-manager/session-repls). There is still NO external-port discovery and no .nrepl-port scanning — attachment is explicit consent, never a scan.

Eval defaults to the workspace-root REPL (else the first) when several exist; the result reports which REPL ran. Each mirror carries liveness status and diagnostics from a per-turn probe.

All best-effort: any failure degrades to an empty contribution and never blocks the render.

Per-turn nREPL resource synchronization for the Clojure pack.

Live state has ONE model-facing home: `repl_status`. This extension hook probes
owned nREPLs and mirrors them into the generic session resource registry —
what `repl_status` and the footer answer from; nothing about a resource rides in
ctx. It returns no legacy `session["env"]["languages"]` contribution.

OWNERSHIP: we surface ONLY the REPLs THIS session started + owns, PLUS any
external nREPL the user EXPLICITLY attached via `connect` (both from
`repl-manager/session-repls`). There is still NO external-port discovery and
no `.nrepl-port` scanning — attachment is explicit consent, never a scan.

Eval defaults to the workspace-root REPL (else the first) when several exist;
the result reports which REPL ran. Each mirror carries liveness status and
diagnostics from a per-turn probe.

All best-effort: any failure degrades to an empty contribution and never
blocks the render.
raw docstring

com.blockether.vis.internal.language.clojure.paren-repair

Delimiter repair for Clojure source the model hand-wrote.

Ported from bhauman/clojure-mcp-light (clojure-mcp-light.delimiter-repair, Apache-2.0): repair via parinfer indent-mode, which trusts the INDENTATION to place the missing / extra ( [ { and so matches how the model intended the code to nest. The parinfer-rust shell path + stats/json bits from upstream are dropped; this is the pure JVM path only, over com.blockether/parinferish — Blockether's linear-time rewrite of parinferish 0.8.0.

Two readers, two questions, and neither answers the other's. parinferish says whether the DELIMITERS balance, so the gate is the same reader that performs the repair and a whole file costs one linear scan. edamame says whether text READS as Clojure, which balanced delimiters do not promise: source cut mid-token comes back closed as (:) — balanced, and not a keyword.

fix-delimiters is the entry point, and it repairs WHOLE Clojure source: format runs it before cljfmt, and the pack publishes it as the editors' :balance-fn, which the foundation applies to the whole file an edit would write and keeps only when the repair stays on that edit's own lines. Handing it a partial form instead balances the fragment into a complete one that means something else.

Delimiter repair for Clojure source the model hand-wrote.

Ported from bhauman/clojure-mcp-light (`clojure-mcp-light.delimiter-repair`,
Apache-2.0): repair via parinfer indent-mode, which trusts the INDENTATION to
place the missing / extra `( [ {` and so matches how the model intended the
code to nest. The parinfer-rust shell path + stats/json bits from upstream are
dropped; this is the pure JVM path only, over `com.blockether/parinferish` —
Blockether's linear-time rewrite of parinferish 0.8.0.

Two readers, two questions, and neither answers the other's. parinferish says
whether the DELIMITERS balance, so the gate is the same reader that performs
the repair and a whole file costs one linear scan. edamame says whether text
READS as Clojure, which balanced delimiters do not promise: source cut
mid-token comes back closed as `(:)` — balanced, and not a keyword.

`fix-delimiters` is the entry point, and it repairs WHOLE Clojure source: `format`
runs it before cljfmt, and the pack publishes it as the editors' `:balance-fn`, which
the foundation applies to the whole file an edit would write and keeps only when the
repair stays on that edit's own lines. Handing it a partial form instead balances the
fragment into a complete one that means something else.
raw docstring

com.blockether.vis.internal.language.clojure.reflection

The :general lint provider: Clojure COMPILER warnings — reflection and boxed math.

Unlike clj-kondo (static analysis over source text), these warnings only exist at COMPILE time: the compiler emits them while it resolves interop / code. So this provider COMPILES whatever the lint TARGETS — a lint_code code string, or each source file being linted — in a throwaway namespace that is torn down afterwards, so the running system is never mutated and nothing leaks.

It compiles the code in a throwaway namespace with *warn-on-reflection* and *unchecked-math* :warn-on-boxed bound, captures the compiler's *err* stream, and parses each warning line

Reflection warning, <file>:<row>:<col> - <message> Boxed math warning, <file>:<row>:<col> - <message>

into the uniform lint finding map, tagged "provider" "general": {"file" "row" "col" "level" "warning" "type" "reflection"|"boxed-math" "message" "provider" "general"}.

The `:general` lint provider: Clojure COMPILER warnings — reflection and
boxed math.

Unlike clj-kondo (static analysis over source text), these warnings only
exist at COMPILE time: the compiler emits them while it resolves interop /
code. So this provider COMPILES whatever the lint TARGETS — a `lint_code` code
string, or each source file being linted — in a throwaway namespace that is
torn down afterwards, so the running system is never mutated and nothing leaks.

It compiles the code in a throwaway namespace with `*warn-on-reflection*` and
`*unchecked-math* :warn-on-boxed` bound, captures the compiler's `*err*`
stream, and parses each warning line

  `Reflection warning, <file>:<row>:<col> - <message>`
  `Boxed math warning, <file>:<row>:<col> - <message>`

into the uniform lint finding map, tagged `"provider" "general"`:
`{"file" "row" "col" "level" "warning" "type" "reflection"|"boxed-math"
  "message" "provider" "general"}`.
raw docstring

com.blockether.vis.internal.language.clojure.repl-manager

Owned, session-scoped nREPL lifecycle for the Clojure pack.

OWNERSHIP: each vis SESSION owns its own nREPL subprocess(es). The processes atom is keyed by [session-id dir], so two sessions in the same directory get two independent REPLs and neither can see or stop the other's. A managed REPL lives and dies with THIS vis process — there is NO persistent registry and NO PID re-attach across a vis restart. Restarting vis means a fresh REPL, exactly like the Python pack.

PORT: we PICK a free ephemeral port ourselves and pass it to the launcher EXPLICITLY (nrepl.cmdline --port N, lein repl :headless :port N, bb nrepl-server N), so we always KNOW our port without ever reading a .nrepl-port file back. Any stray .nrepl-port a tool drops in the project is deleted after boot — vis never depends on it and never leaves it behind.

ALIASES: a REPL is ALWAYS booted with the project's :dev :test deps + paths on its classpath (full dependency spec), with the user's :main-opts dropped (our synthetic :vis/nrepl-launch alias appends last so -m nrepl.cmdline wins). Unknown :dev/:test aliases are silently ignored by tools.deps, so this is safe in any project.

ATTACHMENTS: connect! registers an EXTERNAL nREPL the user already runs in a SEPARATE attachments atom, never in processes. They are different kinds: one is a process we own and must eventually kill, the other is an address we were invited to use. Keeping them apart is what lets ONE project have both at once — the managed JVM REPL repl start booted for its .clj, and the shadow-cljs nREPL its own watch runs for the .cljs — instead of the second connect answering "already-running" about the first and handing back a JVM REPL nobody asked for.

Starting/stopping is CORE and ALWAYS allowed — never gated behind a flag.

Owned, session-scoped nREPL lifecycle for the Clojure pack.

OWNERSHIP: each vis SESSION owns its own nREPL subprocess(es). The `processes`
atom is keyed by `[session-id dir]`, so two sessions in the same directory get
two independent REPLs and neither can see or stop the other's. A managed REPL
lives and dies with THIS vis process — there is NO persistent registry and NO
PID re-attach across a vis restart. Restarting vis means a fresh REPL, exactly
like the Python pack.

PORT: we PICK a free ephemeral port ourselves and pass it to the launcher
EXPLICITLY (`nrepl.cmdline --port N`, `lein repl :headless :port N`,
`bb nrepl-server N`), so we always KNOW our port without ever reading a
`.nrepl-port` file back. Any stray `.nrepl-port` a tool drops in the project is
deleted after boot — vis never depends on it and never leaves it behind.

ALIASES: a REPL is ALWAYS booted with the project's `:dev :test` deps + paths
on its classpath (full dependency spec), with the user's `:main-opts` dropped
(our synthetic `:vis/nrepl-launch` alias appends last so `-m nrepl.cmdline`
wins). Unknown `:dev`/`:test` aliases are silently ignored by tools.deps, so
this is safe in any project.

ATTACHMENTS: `connect!` registers an EXTERNAL nREPL the user already runs in a
SEPARATE `attachments` atom, never in `processes`. They are different kinds:
one is a process we own and must eventually kill, the other is an address we
were invited to use. Keeping them apart is what lets ONE project have both at
once — the managed JVM REPL `repl start` booted for its `.clj`, and the
shadow-cljs nREPL its own `watch` runs for the `.cljs` — instead of the second
`connect` answering "already-running" about the first and handing back a JVM
REPL nobody asked for.

Starting/stopping is CORE and ALWAYS allowed — never gated behind a flag.
raw docstring

com.blockether.vis.internal.language.clojure.shadow-cljs

shadow-cljs as the ClojureScript TEST RUNNER: which build runs the tests, how THIS machine invokes shadow-cljs, and the exact argv that runs a narrowed selection. A *_test.cljs never loads on the JVM, so clojure -M:test can no more run it than node can run a .clj — the build is not a preference here, it is the only runtime that exists.

Three facts decide the command, and each has its own honest refusal instead of a guess:

  1. HOW shadow-cljs is installed. node_modules/.bin/shadow-cljs (npm) wins because it is what the project's own npm test runs; a thheller/shadow-cljs dependency in deps.edn runs as clojure -M[:alias] -m shadow.cljs.devtools.cli — the SAME project may carry it either way, and an alias-only dependency needs that alias on the command line or the classpath lacks the namespace being -m'd. Declared in package.json but not installed is answered as npm install, not as "no ClojureScript runner".
  2. WHICH build runs tests. Resolve an explicit build or the sole test build; never guess between suites. :node-test runs headless, :karma drives its own browser, and :browser-test needs a browser RUNTIME that this runner cannot supply. Compiling alone is never evidence of passing tests.
  3. WHAT the run is narrowed to. --config-merge carries namespace focus and disables Node autorun. Compile first, then run Node separately: shadow's autorun does not propagate the child exit status. Print overrides with pr-str; hand-built regexp escapes can make shadow print help and exit ZERO without compiling anything.
shadow-cljs as the ClojureScript TEST RUNNER: which build runs the tests, how
THIS machine invokes shadow-cljs, and the exact argv that runs a narrowed
selection. A `*_test.cljs` never loads on the JVM, so `clojure -M:test` can no
more run it than `node` can run a `.clj` — the build is not a preference here,
it is the only runtime that exists.

Three facts decide the command, and each has its own honest refusal instead of
a guess:

1. HOW shadow-cljs is installed. `node_modules/.bin/shadow-cljs` (npm) wins
   because it is what the project's own `npm test` runs; a
   `thheller/shadow-cljs` dependency in `deps.edn` runs as
   `clojure -M[:alias] -m shadow.cljs.devtools.cli` — the SAME project may
   carry it either way, and an alias-only dependency needs that alias on the
   command line or the classpath lacks the namespace being `-m`'d. Declared in
   `package.json` but not installed is answered as `npm install`, not as
   "no ClojureScript runner".
2. WHICH build runs tests. Resolve an explicit build or the sole test build;
   never guess between suites. :node-test runs headless, :karma drives its
   own browser, and :browser-test needs a browser RUNTIME that this runner
   cannot supply. Compiling alone is never evidence of passing tests.
3. WHAT the run is narrowed to. `--config-merge` carries namespace focus and
   disables Node autorun. Compile first, then run Node separately: shadow's
   autorun does not propagate the child exit status. Print overrides with
   `pr-str`; hand-built regexp escapes can make shadow print help and exit
   ZERO without compiling anything.
raw docstring

com.blockether.vis.internal.language.clojure.shadow-repl

shadow-cljs as the ClojureScript REPL RUNTIME: attaching to the nREPL a shadow-cljs watch already runs, SELECTING the build whose JS runtime an eval must land in, and keeping that selection true for every later eval.

Five facts about that server decide everything here, and every one of them was learned from a live shadow-cljs, not from its wire protocol:

  1. A shadow-cljs nREPL is INDISTINGUISHABLE from a plain JVM one over describe: it advertises no cljs/shadow op and reports only a :clojure version, so dialect detection from describe metadata answers :clj for it. The only honest question is a read-only eval that RESOLVES shadow.cljs.devtools.api inside the server.
  2. The build is selected PER nREPL SESSION — (shadow…api/nrepl-select :app) — and ONE session serves EVERY build of that server, so a selection belongs to the CONNECTION and never to an attachment: selecting :worker makes the next eval that believes it targets :app answer from the worker's runtime, silently and with no error. Every eval therefore re-checks both the session and the build selected in it, never a boolean.
  3. Selecting from a session ALREADY sitting in a build FAILS — the select form is compiled as ClojureScript and dies on No such namespace: shadow.cljs.devtools.api. :cljs/quit first is what makes selection total: it returns such a session to CLJ, and in a session that never left CLJ it is an ordinary keyword evaluating to itself.
  4. nrepl-select answers the SAME watch for build not running whether the build id does not exist or is merely not being watched. The two are told apart BEFORE selecting — by the server's own build config and worker-running? — because they need opposite fixes.
  5. A selected build with NO connected JS runtime neither errors nor hangs: every eval answers No available JS runtime. on :err with a done status. Nothing but a started runtime fixes it, so that answer is turned into the instruction that starts one.
shadow-cljs as the ClojureScript REPL RUNTIME: attaching to the nREPL a
`shadow-cljs watch` already runs, SELECTING the build whose JS runtime an eval
must land in, and keeping that selection true for every later eval.

Five facts about that server decide everything here, and every one of them was
learned from a live shadow-cljs, not from its wire protocol:

1. A shadow-cljs nREPL is INDISTINGUISHABLE from a plain JVM one over
   `describe`: it advertises no cljs/shadow op and reports only a `:clojure`
   version, so dialect detection from describe metadata answers `:clj` for it.
   The only honest question is a read-only eval that RESOLVES
   `shadow.cljs.devtools.api` inside the server.
2. The build is selected PER nREPL SESSION — `(shadow…api/nrepl-select :app)` —
   and ONE session serves EVERY build of that server, so a selection belongs
   to the CONNECTION and never to an attachment: selecting `:worker` makes
   the next eval that believes it targets `:app` answer from the worker's
   runtime, silently and with no error. Every eval therefore re-checks both
   the session and the build selected in it, never a boolean.
3. Selecting from a session ALREADY sitting in a build FAILS — the select form
   is compiled as ClojureScript and dies on `No such namespace:
   shadow.cljs.devtools.api`. `:cljs/quit` first is what makes selection
   total: it returns such a session to CLJ, and in a session that never left
   CLJ it is an ordinary keyword evaluating to itself.
4. `nrepl-select` answers the SAME `watch for build not running` whether the
   build id does not exist or is merely not being watched. The two are told
   apart BEFORE selecting — by the server's own build config and
   `worker-running?` — because they need opposite fixes.
5. A selected build with NO connected JS runtime neither errors nor hangs:
   every eval answers `No available JS runtime.` on `:err` with a `done`
   status. Nothing but a started runtime fixes it, so that answer is turned
   into the instruction that starts one.
raw docstring

com.blockether.vis.internal.language.clojure.test-runner

Run a namespace's tests in the session's ALREADY-RUNNING nREPL (the fast inner loop) or -- the default, and whenever there is no such REPL -- by shelling the project's own test command in a clean JVM. Nothing here EVER starts a REPL.

The in-REPL path is FRAMEWORK-AGNOSTIC: a ns whose vars carry clojure.test :test metadata runs through clojure.test/run-tests; otherwise it is treated as lazytest and run through lazytest.runner/run-tests. Either way the result is a uniform STRING-keyed map (crosses the strings-only boundary) with "mode" (repl or cli), "framework", "ns", "total", "pass", "fail" and "failures" [{"ns" "test" "message" "file" "line"} ...]. CLI and shadow-cljs retain complete stdout/stderr in "output" (ANSI-stripped) and collect per-fault diagnostics before rendering. Only the display preview may be bounded; the result data is never reduced to a log tail.

run-form is the code EVALED on the target nREPL. It is a quoted form (not a call into this namespace) so it works against ANY project's nREPL, including hosts that do not have the vis-agent extension on their classpath.

Run a namespace's tests in the session's ALREADY-RUNNING nREPL (the fast inner
loop) or -- the default, and whenever there is no such REPL -- by shelling the
project's own test command in a clean JVM. Nothing here EVER starts a REPL.

The in-REPL path is FRAMEWORK-AGNOSTIC: a ns whose vars carry clojure.test
:test metadata runs through clojure.test/run-tests; otherwise it is treated
as lazytest and run through lazytest.runner/run-tests. Either way the result
is a uniform STRING-keyed map (crosses the strings-only boundary) with
"mode" (repl or cli), "framework", "ns", "total", "pass", "fail" and
"failures" [{"ns" "test" "message" "file" "line"} ...].
CLI and shadow-cljs retain complete stdout/stderr in "output" (ANSI-stripped)
and collect per-fault diagnostics before rendering. Only the display preview
may be bounded; the result data is never reduced to a log tail.

run-form is the code EVALED on the target nREPL. It is a quoted form (not a
call into this namespace) so it works against ANY project's nREPL, including
hosts that do not have the vis-agent extension on their classpath.
raw docstring

com.blockether.vis.internal.language.python.core

A managed Python REPL exposed through the generic language facade (repl_start / repl_status / repl_stop / repl_eval). Activates only when the workspace looks like a Python project. The REPL is a subprocess on a project-aware interpreter (uv / poetry / .venv / python3), registered as a session resource so it shows in ctx + the footer and is stoppable by id.

A managed Python REPL exposed through the generic
language facade (repl_start / repl_status / repl_stop / repl_eval). Activates
only when the workspace looks like a Python project. The REPL is a subprocess
on a project-aware interpreter (uv / poetry / .venv / python3), registered as
a session resource so it shows in ctx + the footer and is stoppable by id.
raw docstring

com.blockether.vis.internal.language.python.interpreter

Detect WHICH Python launches a REPL or a run_tests shell-out, mirroring how the Clojure pack picks deps.edn / lein / bb. The project-managed environment is detected so the interpreter sees the project's dependencies.

Detect WHICH Python launches a REPL or a `run_tests` shell-out, mirroring how
the Clojure pack picks deps.edn / lein / bb. The project-managed environment
is detected so the interpreter sees the project's dependencies.
raw docstring

com.blockether.vis.internal.language.python.repl-manager

A MANAGED Python REPL: a persistent interpreter subprocess running a tiny line-framed eval server — one JSON request per line in, one JSON response per line out. Globals persist across evals (real REPL state). One process per session and canonical directory; the cached Process handle owns teardown.

A MANAGED Python REPL: a persistent interpreter subprocess running a tiny
line-framed eval server — one JSON request per line in, one JSON response per
line out. Globals persist across evals (real REPL state). One process per
session and canonical directory; the cached `Process` handle owns teardown.
raw docstring

com.blockether.vis.internal.language.python.ruff

format_code / lint_code for Python, backed by ruff (com.blockether/ruff).

ruff runs IN-PROCESS: the Rust ruff_python_formatter / ruff_linter crates are linked as a cdylib and called over the FFM API. There is no ruff binary to install, no subprocess, no virtualenv, and no PATH lookup — the same code path works from the native image. Configuration is RUFF'S OWN discovery: for every source, ruff walks up from the file to the nearest .ruff.toml / ruff.toml / pyproject.toml with a [tool.ruff] table and honours it whole — extend, per-file-ignores, target-version, formatter options — so a run here and a ruff CLI run agree on every source it reads. With no config anywhere the tool still runs on ruff's defaults and SAYS SO in a hint.

ONE divergence, the same one the sandbox ruff shim documents: the FFI is a one-source-one-call linter/formatter, so the WALK is ours and a config's exclude / extend-exclude globs are NOT applied to it — the noise directories in skip-dirs are pruned instead. per-file-ignores DO apply: ruff matches them itself against the path we hand it.

Both handlers accept the SAME argument shapes as the Clojure pack: a code string, {"code"}, {"path"}, {"paths"}, or nothing (whole project).

`format_code` / `lint_code` for Python, backed by ruff (com.blockether/ruff).

ruff runs IN-PROCESS: the Rust ruff_python_formatter / ruff_linter crates are
linked as a cdylib and called over the FFM API. There is no `ruff` binary to
install, no subprocess, no virtualenv, and no PATH lookup — the same code path
works from the native image. Configuration is RUFF'S OWN discovery: for every
source, ruff walks up from the file to the nearest `.ruff.toml` / `ruff.toml` /
`pyproject.toml` with a `[tool.ruff]` table and honours it whole — `extend`,
`per-file-ignores`, `target-version`, formatter options — so a run here and a
`ruff` CLI run agree on every source it reads. With no config anywhere the
tool still runs on ruff's defaults and SAYS SO in a `hint`.

ONE divergence, the same one the sandbox `ruff` shim documents: the FFI is a
one-source-one-call linter/formatter, so the WALK is ours and a config's
`exclude` / `extend-exclude` globs are NOT applied to it — the noise
directories in `skip-dirs` are pruned instead. `per-file-ignores` DO apply:
ruff matches them itself against the path we hand it.

Both handlers accept the SAME argument shapes as the Clojure pack:
a code string, {"code"}, {"path"}, {"paths"}, or nothing (whole project).
raw docstring

com.blockether.vis.internal.main

vis-agent CLI binary - :db Telemere handler, one-shot agent helper, built-in CLI commands, and the -main dispatcher entry point.

Everything in this file is binary-only. The library surface (iteration loop, turn engine, environment lifecycle, session cache) lives in com.blockether.vis.internal.loop; this namespace requires that one and wires it into the command tree the vis-agent wrapper exposes.

Public entry point:

(-main & args) - invoked by the :vis alias / bin/vis-agent. Configures logging, discovers Clojure extensions, loads Python extensions before one-shot dispatches, redirects stderr to this process's role/start-time/pid-stamped file under ~/.vis/logs/ for any TTY-owning channel, then dispatches to the resolved command's :cmd/run-fn.

Built-in commands registered here: vis-agent providers - provider inspection, auth, and limits vis-agent sessions - list persisted sessions vis-agent projects - list projects, or delete one with its sessions vis-agent extension list - list registered extensions vis-agent channels <name> - auto-mounted via the channel registry

vis-agent doctor is host-owned. Extensions plug diagnostics into it with :ext/doctor-fn; extension-owned CLI commands stay under vis-agent extension.

vis-agent CLI binary - :db Telemere handler, one-shot agent helper,
built-in CLI commands, and the `-main` dispatcher entry point.

Everything in this file is binary-only. The library surface
(iteration loop, turn engine, environment lifecycle, session
cache) lives in `com.blockether.vis.internal.loop`; this namespace requires
that one and wires it into the command tree the `vis-agent` wrapper
exposes.

Public entry point:

  (-main & args)   - invoked by the `:vis` alias / `bin/vis-agent`.
                    Configures logging, discovers Clojure extensions, loads Python
                    extensions before one-shot dispatches, redirects stderr to this
                    process's role/start-time/pid-stamped file under `~/.vis/logs/` for
                    any TTY-owning channel, then dispatches to the resolved
                    command's `:cmd/run-fn`.

Built-in commands registered here:
  vis-agent providers          - provider inspection, auth, and limits
  vis-agent sessions      - list persisted sessions
  vis-agent projects           - list projects, or delete one with its sessions
  vis-agent extension list     - list registered extensions
  vis-agent channels <name>    - auto-mounted via the channel registry

`vis-agent doctor` is host-owned. Extensions plug diagnostics into it
with `:ext/doctor-fn`; extension-owned CLI commands stay under
`vis-agent extension`.
raw docstring

com.blockether.vis.internal.parse-diagnose

Cheap heuristics that turn opaque parse / eval errors into precise diagnostic hints. Engine wires the catalogue into the per-form trailer so the model sees actionable repair instructions instead of a stack trace.

Catalogue today:

diagnose-quote-balance Odd number of unescaped double quotes in the source. Pinpoints the 1-based line where the running count first becomes odd. parinferish does indent-mode paren balancing only; string-quote imbalance needs its own walker.

diagnose-bracket-balance Unbalanced (), [], {}. Walks a bracket stack skipping string/char literals (incl. triple-quoted, with escapes) and # comments; reports the FIRST wrong-type / extra / unclosed bracket + 1-based line/col. repair-bracket-balance offers a single-candidate auto-fix, but ONLY when one edit rebalances the WHOLE form. Python's mixed (), [], {} are NOT indentation-determined, so parinfer (the Clojure paren repairer) cannot be reused here.

unresolved-symbol-hint The Python eval raised a NameError for an undefined name X. Suggests the closest name(s) in the user's sandbox bindings (Levenshtein-style score), so the model sees 'did you mean ...?' instead of 'X is undefined'.

Cheap heuristics that turn opaque parse / eval errors into precise
diagnostic hints. Engine wires the catalogue into the per-form trailer so
the model sees actionable repair instructions instead of a stack trace.

Catalogue today:

  diagnose-quote-balance
    Odd number of unescaped double quotes in the source. Pinpoints the
    1-based line where the running count first becomes odd. parinferish
    does indent-mode paren balancing only; string-quote imbalance needs
    its own walker.

  diagnose-bracket-balance
    Unbalanced (), [], {}. Walks a bracket stack skipping string/char
    literals (incl. triple-quoted, with escapes) and `#` comments; reports
    the FIRST wrong-type / extra / unclosed bracket + 1-based line/col.
    `repair-bracket-balance` offers a single-candidate auto-fix, but ONLY
    when one edit rebalances the WHOLE form. Python's mixed (), [], {} are
    NOT indentation-determined, so parinfer (the Clojure paren repairer)
    cannot be reused here.

  unresolved-symbol-hint
    The Python eval raised a NameError for an undefined name X. Suggests
    the closest name(s) in the user's sandbox bindings (Levenshtein-style score),
    so the model sees 'did you mean ...?' instead of 'X is undefined'.
raw docstring

com.blockether.vis.internal.paths

Cross-platform path helpers. A LEAF namespace (no project deps) so any layer — core, extensions, tests — can normalize without a require cycle.

Cross-platform path helpers. A LEAF namespace (no project deps) so any
layer — core, extensions, tests — can normalize without a require cycle.
raw docstring

com.blockether.vis.internal.persistance.core

Persistence facade: the backend table, connection lifecycle, and every delegated store-*/db-* fn.

SQLite is the backend Vis ships and the default; backends is the closed table a second dialect would join. The facade dispatches each delegated call by resolving the matching var on the chosen backend namespace ((ns-resolve ns-sym 'db-store-iteration!) etc.) and applying it to the original args. This keeps the facade dialect-agnostic - every migration runner / driver-specific oddity stays inside the backend adapter.

Frontends still call db-error->user-message here, but the actual translation is offered by backend adapters. Same for store-staleness checks used by the process-wide shared connection.

Persistence facade: the backend table, connection lifecycle, and every
delegated `store-*`/`db-*` fn.

SQLite is the backend Vis ships and the default; `backends` is the closed
table a second dialect would join. The facade dispatches each delegated call
by resolving the matching var on the chosen backend namespace
(`(ns-resolve ns-sym 'db-store-iteration!)` etc.) and applying it to the
original args. This keeps the facade dialect-agnostic - every migration
runner / driver-specific oddity stays inside the backend adapter.

Frontends still call `db-error->user-message` here, but the actual
translation is offered by backend adapters. Same for store-staleness
checks used by the process-wide shared connection.
raw docstring

com.blockether.vis.internal.persistance.sqlite.core

SQLite store - V1 schema implementation.

Every public defn in this file is dispatched dynamically by vis-sdk.core/defdelegate via ns-resolve; clj-kondo never sees the call sites. The ns-level config above silences :unused-public-var for the whole file. The actual call surface is verified through the storage facade tests.

Tables (V1__schema.sql): session_soul, session_state, session_turn_soul, session_turn_state, session_turn_iteration, llm_routing_event, extension_aggregate, log

Connection lifecycle: (db-open! db-spec) -> {:datasource ds :path ...} (db-close! store) -> idempotent dispose

SQLite store - V1 schema implementation.

Every public defn in this file is dispatched dynamically by
`vis-sdk.core/defdelegate` via `ns-resolve`; clj-kondo never sees
the call sites. The ns-level config above silences
`:unused-public-var` for the whole file. The actual call surface
is verified through the storage facade tests.

Tables (V1__schema.sql):
  session_soul, session_state,
  session_turn_soul, session_turn_state,
  session_turn_iteration, llm_routing_event,
  extension_aggregate,
  log

Connection lifecycle:
  (db-open! db-spec)   -> {:datasource ds :path ...}
  (db-close! store)    -> idempotent dispose
raw docstring

com.blockether.vis.internal.persistance.sqlite.maintenance

Space reclamation for a file-backed SQLite store.

SQLite never hands the file back on its own: auto_vacuum is off, so pages a delete or a DROP COLUMN frees go on the FREELIST and are only reused by later writes. That is the right default — reuse costs nothing — but a one-off bulk reclaim (retiring a column across a whole transcript) leaves hundreds of megabytes the file keeps until someone runs VACUUM.

maybe-vacuum! is that someone, on the same fortnight window as foundation.housekeeping retention: at most once per vacuum-interval-days per store, and only when the freelist is worth the rewrite — at least vacuum-min-free-bytes AND vacuum-min-free-fraction of the file. A compact store is never rewritten, so the usual answer is three PRAGMAs and no I/O.

VACUUM takes SQLite's exclusive lock and rewrites the whole file (measured: 15 s and 2.4 GB -> 2.0 GB on a real store), so it runs OFF the open path — vacuum-async! on a lowest-priority daemon thread after a settling delay, never inside a transaction. Readers and writers in this process or another one block for its duration and then continue, which the 30 s busy timeout and the write-retry ladder in core absorb. A process that exits first, or a db-close! that aborts the lease mid-rewrite, simply leaves the store due at the next start: the rewrite is transactional, so an interrupted VACUUM rolls back rather than damaging anything.

The vis.db.vacuum marker beside the store is BOTH the clock (its mtime is the last successful vacuum) and the cross-process mutex (an exclusive FileLock held for the rewrite). It is created only when a vacuum is actually attempted and deleted again when one fails, so a store that never needed reclaiming carries no marker and stays due.

Space reclamation for a file-backed SQLite store.

SQLite never hands the file back on its own: `auto_vacuum` is off, so pages a
delete or a `DROP COLUMN` frees go on the FREELIST and are only reused by
later writes. That is the right default — reuse costs nothing — but a one-off
bulk reclaim (retiring a column across a whole transcript) leaves hundreds of
megabytes the file keeps until someone runs `VACUUM`.

`maybe-vacuum!` is that someone, on the same fortnight window as
`foundation.housekeeping` retention: at most once per `vacuum-interval-days`
per store, and only when the freelist is worth the rewrite — at least
`vacuum-min-free-bytes` AND `vacuum-min-free-fraction` of the file. A compact
store is never rewritten, so the usual answer is three PRAGMAs and no I/O.

VACUUM takes SQLite's exclusive lock and rewrites the whole file (measured:
15 s and 2.4 GB -> 2.0 GB on a real store), so it runs OFF the open path —
`vacuum-async!` on a lowest-priority daemon thread after a settling delay,
never inside a transaction. Readers and writers in this process or another
one block for its duration and then continue, which the 30 s busy timeout and
the write-retry ladder in `core` absorb. A process that exits first, or a
`db-close!` that aborts the lease mid-rewrite, simply leaves the store due at
the next start: the rewrite is transactional, so an interrupted VACUUM rolls
back rather than damaging anything.

The `vis.db.vacuum` marker beside the store is BOTH the clock (its mtime is
the last successful vacuum) and the cross-process mutex (an exclusive
`FileLock` held for the rewrite). It is created only when a vacuum is
actually attempted and deleted again when one fails, so a store that never
needed reclaiming carries no marker and stays due.
raw docstring

com.blockether.vis.internal.persistance.sqlite.migration

Flyway-backed schema migration runner.

Lives in the SQLite extension because:

  1. The dialect-specific Flyway driver (flyway-database-nc-sqlite) is required to recognize jdbc:sqlite: URLs - already declared in this extension's deps.edn.
  2. Flyway is the only backend-side concern using flyway-core; making it a per-backend dep keeps the root package free of the migration toolchain.
  3. The previous arrangement shipped a generic migrate! from com.blockether.vis.sdk, but it had exactly one caller
    • this extension. Other backends will ship their own migration entry point in their own jar.

Public API:

(migrate! datasource locations) - apply every Flyway migration found at the given classpath locations to the supplied DataSource. Returns the datasource for thread-style chaining.

:baseline-on-migrate true so existing databases without a flyway_schema_history table get one on first run. :mixed true so SQL files with mixed transactional + DDL statements work under SQLite.

GraalVM native-image note: Flyway discovers migrations by LISTING the classpath location directory, which native-image does not support (it can getResource a specific file but not enumerate a dir). So build.clj writes an _index.edn of filenames next to each migration dir, and here we feed Flyway an explicit ResourceProvider built from those exact paths. On the JVM (no index) we fall back to Flyway's normal location scanning.

Flyway-backed schema migration runner.

Lives in the SQLite extension because:

  1. The dialect-specific Flyway driver (`flyway-database-nc-sqlite`)
     is required to recognize `jdbc:sqlite:` URLs - already
     declared in this extension's deps.edn.
  2. Flyway is the only backend-side concern using
     `flyway-core`; making it a per-backend dep keeps the root
     package free of the migration toolchain.
  3. The previous arrangement shipped a generic `migrate!` from
     `com.blockether.vis.sdk`, but it had exactly one caller
     - this extension. Other backends will ship their own
     migration entry point in their own jar.

Public API:

  `(migrate! datasource locations)` - apply every Flyway
  migration found at the given classpath `locations` to the
  supplied `DataSource`. Returns the datasource for thread-style
  chaining.

`:baseline-on-migrate true` so existing databases without a
`flyway_schema_history` table get one on first run. `:mixed true`
so SQL files with mixed transactional + DDL statements work under
SQLite.

GraalVM native-image note: Flyway discovers migrations by LISTING the
classpath location directory, which native-image does not support (it can
`getResource` a specific file but not enumerate a dir). So `build.clj` writes
an `_index.edn` of filenames next to each migration dir, and here we feed
Flyway an explicit `ResourceProvider` built from those exact paths. On the
JVM (no index) we fall back to Flyway's normal location scanning.
raw docstring

com.blockether.vis.internal.provider.auth

Model-provider adapter to the shared gateway authentication lifecycle in flow. Registered providers own protocol start/complete/await and credential persistence; flow owns callback transport, expiry, cancellation, single exchange and verdicts. This adapter handles provider eligibility, API-key storage and fleet invalidation. No browser, relay, token or PKCE verifier crosses the public flow view.

Model-provider adapter to the shared gateway authentication lifecycle in `flow`.
Registered providers own protocol start/complete/await and credential persistence;
`flow` owns callback transport, expiry, cancellation, single exchange and verdicts.
This adapter handles provider eligibility, API-key storage and fleet invalidation.
No browser, relay, token or PKCE verifier crosses the public flow view.
raw docstring

com.blockether.vis.internal.provider.callback

Short-lived OAuth loopback receivers. They carry only a callback, never tokens.

listen! binds a numeric loopback address, validates the exact destination and state before accepting ONE response, and closes on completion, stop or expiry. The browser sees a receipt, not a claim that token exchange has succeeded. No request values are reflected into HTML or logged. Shared by provider and MCP auth.

Short-lived OAuth loopback receivers. They carry only a callback, never tokens.

`listen!` binds a numeric loopback address, validates the exact destination and
state before accepting ONE response, and closes on completion, stop or expiry.
The browser sees a receipt, not a claim that token exchange has succeeded.
No request values are reflected into HTML or logged. Shared by provider and MCP auth.
raw docstring

com.blockether.vis.internal.provider.credential-command

Command-backed provider credentials — the api_key_command config key.

A static api_key (or a ${NAME} reference to one) is only good for a long-lived secret. Short-lived SSO/gateway tokens come from a credential HELPER instead: a small program that prints a fresh token on stdout. This namespace runs that helper for a provider and hands the trimmed stdout back as the API key.

Three contracts hold this together:

  • No shell, ever. The configured value is a structured argv that is passed to ProcessBuilder verbatim. It is never joined, never split on whitespace, and never handed to sh -c, so a token containing shell metacharacters — or a config written by someone else — cannot become command injection.
  • The credential is write-once, in memory. Resolved stdout is returned to the caller and cached HERE. It is never persisted (nothing writes it back into :api-key), never logged, and never placed in an error message. Every diagnostic this namespace produces is built from argv[0], the exit code, and the helper's stderr — never its stdout.
  • Bounded and single-flight. One helper invocation per provider at a time, bounded by timeout-ms, with a successful token cached for success-ttl-ms and a failure remembered for failure-ttl-ms. A long-running gateway must not fork a token helper per turn, and an interactive helper must never be launched twice concurrently.

resolve! never throws: callers use its :error to render a provider as unavailable (providers/provider-status, doctor) or to drop it from the router build, exactly as an unresolved ${NAME} is handled today.

Command-backed provider credentials — the `api_key_command` config key.

A static `api_key` (or a `${NAME}` reference to one) is only good for a
long-lived secret. Short-lived SSO/gateway tokens come from a credential
HELPER instead: a small program that prints a fresh token on stdout. This
namespace runs that helper for a provider and hands the trimmed stdout back
as the API key.

Three contracts hold this together:

  - **No shell, ever.** The configured value is a structured argv that is
    passed to `ProcessBuilder` verbatim. It is never joined, never split on
    whitespace, and never handed to `sh -c`, so a token containing shell
    metacharacters — or a config written by someone else — cannot become
    command injection.
  - **The credential is write-once, in memory.** Resolved stdout is returned
    to the caller and cached HERE. It is never persisted (nothing writes it
    back into `:api-key`), never logged, and never placed in an error
    message. Every diagnostic this namespace produces is built from argv[0],
    the exit code, and the helper's stderr — never its stdout.
  - **Bounded and single-flight.** One helper invocation per provider at a
    time, bounded by `timeout-ms`, with a successful token cached for
    `success-ttl-ms` and a failure remembered for `failure-ttl-ms`. A
    long-running gateway must not fork a token helper per turn, and an
    interactive helper must never be launched twice concurrently.

`resolve!` never throws: callers use its `:error` to render a provider as
unavailable (`providers/provider-status`, `doctor`) or to drop it from the
router build, exactly as an unresolved `${NAME}` is handled today.
raw docstring

com.blockether.vis.internal.provider.error

Single source of truth for provider-error presentation.

Typed provider-error content and per-iteration trace rows derive their wording and facts from this namespace, so a failure reads identically everywhere.

err is the error map carried on a trace entry / ex-info: {:message .. :data {:status .. :body .. :request-id ..} ..}. Every helper tolerates the bare ex-info shape too (via ex-message).

CLASSIFICATION IS SVAR'S. svar-classification wraps svar.internal.failure/classify — the single owner of failure families, retry safety and :reached-model? for everything svar transports. This namespace owns WORDING, plus the handful of failures svar cannot see (its typed empty-content and stream-watchdog outcomes, gateway tool-field rejections, tool-schema defects). Never grow a second copy of svar's heuristics here.

Single source of truth for provider-error presentation.

Typed provider-error content and per-iteration trace rows derive their wording
and facts from this namespace, so a failure reads identically everywhere.

`err` is the error map carried on a trace entry / ex-info:
`{:message .. :data {:status .. :body .. :request-id ..} ..}`. Every
helper tolerates the bare ex-info shape too (via `ex-message`).

CLASSIFICATION IS SVAR'S. `svar-classification` wraps
`svar.internal.failure/classify` — the single owner of failure families,
retry safety and `:reached-model?` for everything svar transports. This
namespace owns WORDING, plus the handful of failures svar cannot see (its
typed empty-content and stream-watchdog outcomes, gateway tool-field
rejections, tool-schema defects). Never grow a second copy of svar's
heuristics here.
raw docstring

com.blockether.vis.internal.provider.flow

Gateway-owned authentication lifecycle shared by MCP and model adapters.

start! takes an owner [domain id] and protocol legs :start (0-arg), :complete (private flow, input), :await (private flow), :settle (0-arg). Start returns :kind, private :flow, and allowlisted presentation fields. An adapter doing dynamic registration can allocate callback-transport! before constructing its authorization URL; all other adapters let us do it.

One live attempt per owner. All completion paths serialize on the same state; terminal verdicts remain pollable until expiry/cancellation. Private flow data and adapter results never cross the public allowlist. No browser or relay here. Cancellation stops workers and rejects late verdicts; adapters must also obey interruption/expiry before persisting credentials during an in-flight exchange.

Gateway-owned authentication lifecycle shared by MCP and model adapters.

`start!` takes an owner `[domain id]` and protocol legs `:start` (0-arg),
`:complete` (private flow, input), `:await` (private flow), `:settle` (0-arg).
Start returns `:kind`, private `:flow`, and allowlisted presentation fields.
An adapter doing dynamic registration can allocate `callback-transport!`
before constructing its authorization URL; all other adapters let us do it.

One live attempt per owner. All completion paths serialize on the same state;
terminal verdicts remain pollable until expiry/cancellation. Private flow data
and adapter results never cross the public allowlist. No browser or relay here.
Cancellation stops workers and rejects late verdicts; adapters must also obey
interruption/expiry before persisting credentials during an in-flight exchange.
raw docstring

com.blockether.vis.internal.provider.key-store

The STATIC API-KEY provider shape, owned once.

A vendor that authenticates with a plain key per plan (Alibaba Model Studio, Z.ai) needs the same things: a file under ~/.vis, a per-plan slice inside it, one lookup order (TUI/config key, env var, that file), a token envelope for the router, a status report that never prints the key, the interactive vis-agent providers auth flow, a per-plan logout and the extension entry map. Only the STRINGS and the plan table differ, so a provider extension declares a BOOK and this namespace owns the behaviour:

{:vendor "Alibaba" ; how a message names it :file "alibaba-auth.json" ; lives under ~/.vis :file-shape :flat ; or :by-plan (the default) :key-hint "<your-alibaba-api-key>" ; the export line's value :error-type :vis/alibaba-not-authenticated ; ex-info :type when no key :auth-notes [" The key is plan-scoped …"] ; extra prompt lines, optional :plans {:coding {:provider-id :alibaba-coding-plan :label "Alibaba (Coding Plan)" :base-url "https://…" :default-models ["…"] :env-keys ["ALIBABA_CODING_PLAN_API_KEY"]}}}

The plan TAG (:coding) is local to the file and the :provider-id is the catalog id; the two never merge and no lookup ever falls back to a sibling plan, because a key issued for one plan is rejected by the other's endpoint.

:file-shape decides where a slice LIVES. :by-plan (the default) nests each plan under its tag, because those keys are separate credentials. :flat hands the whole file to a book with ONE credential: the key sits at the root ({"api_key" …}), the plan tag never reaches disk, and no message shows a plan vocabulary the user has nothing to choose between. Declared, never inferred - growing a second plan is a deliberate change of file shape, not a silent one that orphans every key already stored.

What a provider still owns: its plan table, its :provider/limits-fn (a quota endpoint is vendor-specific) and its own namespace docstring.

The STATIC API-KEY provider shape, owned once.

A vendor that authenticates with a plain key per plan (Alibaba Model Studio,
Z.ai) needs the same things: a file under `~/.vis`, a per-plan slice inside
it, one lookup order (TUI/config key, env var, that file), a token envelope
for the router, a status report that never prints the key, the interactive
`vis-agent providers auth` flow, a per-plan logout and the extension entry
map. Only the STRINGS and the plan table differ, so a provider extension
declares a BOOK and this namespace owns the behaviour:

  {:vendor     "Alibaba"                      ; how a message names it
   :file       "alibaba-auth.json"            ; lives under ~/.vis
   :file-shape :flat                          ; or :by-plan (the default)
   :key-hint   "<your-alibaba-api-key>"       ; the export line's value
   :error-type :vis/alibaba-not-authenticated ; ex-info :type when no key
   :auth-notes ["  The key is plan-scoped …"]  ; extra prompt lines, optional
   :plans      {:coding {:provider-id :alibaba-coding-plan
                         :label "Alibaba (Coding Plan)"
                         :base-url "https://…"
                         :default-models ["…"]
                         :env-keys ["ALIBABA_CODING_PLAN_API_KEY"]}}}

The plan TAG (`:coding`) is local to the file and the `:provider-id` is the
catalog id; the two never merge and no lookup ever falls back to a sibling
plan, because a key issued for one plan is rejected by the other's endpoint.

`:file-shape` decides where a slice LIVES. `:by-plan` (the default) nests each
plan under its tag, because those keys are separate credentials. `:flat` hands
the whole file to a book with ONE credential: the key sits at the root
(`{"api_key" …}`), the plan tag never reaches disk, and no message shows a
plan vocabulary the user has nothing to choose between. Declared, never
inferred - growing a second plan is a deliberate change of file shape, not a
silent one that orphans every key already stored.

What a provider still owns: its plan table, its `:provider/limits-fn` (a
quota endpoint is vendor-specific) and its own namespace docstring.
raw docstring

com.blockether.vis.internal.provider.limits

Normalized provider limits surface.

Providers may optionally expose :provider/limits-fn in the global registry. The function returns provider-specific limit/quota data; this namespace wraps it in one validated envelope and augments it with static provider metadata from svar's catalog (currently RPM / TPM).

The limits vocabulary and the report shape belong to com.blockether.vis.contract.provider; what stays here is fetching, caching and normalizing whatever a provider answered.

Goals:

  • one host-level shape for all providers,
  • explicit support for providers that only know static limits,
  • contract validation of every returned report,
  • graceful error envelopes instead of exploding the caller when a provider-specific implementation is absent or malformed.
Normalized provider limits surface.

Providers may optionally expose `:provider/limits-fn` in the global
registry. The function returns provider-specific limit/quota data;
this namespace wraps it in one validated envelope and augments it
with static provider metadata from svar's catalog (currently RPM /
TPM).

The limits vocabulary and the report shape belong to
`com.blockether.vis.contract.provider`; what stays here is fetching, caching and
normalizing whatever a provider answered.

Goals:
- one host-level shape for all providers,
- explicit support for providers that only know static limits,
- contract validation of every returned report,
- graceful error envelopes instead of exploding the caller when a
  provider-specific implementation is absent or malformed.
raw docstring

com.blockether.vis.internal.provider.limits-format

Channel-neutral {:dynamic {:limits [...]}} row formatters.

Hoisted from the TUI extension (channel_tui/limits_fmt.clj) so every channel — TUI footer, TUI provider cards — renders the SAME compact account-quota summary from a provider's normalized limits report. The TUI namespace now aliases these vars; other channels consume them through vis.core.

The interesting account-level rows (:zai-coding-plan-5h, :codex-7d, :premium_interactions, ...) live under [:dynamic :limits]; static :rpm/:tpm are svar catalog defaults, identical for every provider, useful only as fallback.

Channel-neutral `{:dynamic {:limits [...]}}` row formatters.

Hoisted from the TUI extension (`channel_tui/limits_fmt.clj`) so
every channel — TUI footer, TUI provider cards — renders the SAME
compact account-quota summary from a provider's normalized limits
report. The TUI namespace now aliases these vars; other channels
consume them through `vis.core`.

The interesting account-level rows (`:zai-coding-plan-5h`,
`:codex-7d`, `:premium_interactions`, ...) live under
`[:dynamic :limits]`; static `:rpm`/`:tpm` are svar catalog
defaults, identical for every provider, useful only as fallback.
raw docstring

com.blockether.vis.internal.provider.oauth

Unified OAuth token-refresh facade, shared by every provider.

WHY THIS EXISTS — Providers whose token endpoint ROTATES the refresh_token on every exchange (Anthropic, OpenAI Codex) must never run two refresh exchanges at once: the second reuses an already-rotated refresh token and the server answers HTTP 400 invalid_grant. Under a 401 "storm" — the turn loop's per-iteration retry PLUS a usage/limits poll, all sharing one credential — that race killed whole turns (~10 refreshes/min: one lost the rotation race and the turn died). Providers that mint a short-lived token from a STABLE credential (GitHub Copilot) don't 400, but still benefit: concurrent 401s otherwise stampede the exchange endpoint with redundant calls.

THE MODEL — refresh is serialized PER CREDENTIAL STORE, never globally. Each make-file-refresher / refresher call mints its OWN lock, so a refresh to Anthropic and a refresh to Codex run fully in parallel; only two refreshes to the SAME store (e.g. two sessions both hitting Anthropic) serialize — which is the whole point. Once the lock is held, a caller REUSES a result another thread just produced (creds persisted within default-reuse-window-ms, or an already-valid cache) so a burst of N concurrent 401s collapses into ONE exchange.

USE — file-backed rotating stores (Anthropic, Codex): make-file-refresher. Cache-backed / bespoke stores (Copilot): refresher with custom reuse/refresh fns. Both return a 0-arg fn yielding the provider-token map, owning their own lock; drop them straight into :provider/get-token-fn / :provider/refresh-token-fn.

Unified OAuth token-refresh facade, shared by every provider.

WHY THIS EXISTS — Providers whose token endpoint ROTATES the
refresh_token on every exchange (Anthropic, OpenAI Codex) must never run
two refresh exchanges at once: the second reuses an already-rotated
refresh token and the server answers HTTP 400 `invalid_grant`. Under a
401 "storm" — the turn loop's per-iteration retry PLUS a usage/limits
poll, all sharing one credential — that race killed whole turns (~10 refreshes/min: one lost the
rotation race and the turn died). Providers that mint a short-lived token from a STABLE
credential (GitHub Copilot) don't 400, but still benefit: concurrent
401s otherwise stampede the exchange endpoint with redundant calls.

THE MODEL — refresh is serialized PER CREDENTIAL STORE, never globally.
Each `make-file-refresher` / `refresher` call mints its OWN lock, so a
refresh to Anthropic and a refresh to Codex run fully in parallel; only
two refreshes to the SAME store (e.g. two sessions both hitting
Anthropic) serialize — which is the whole point. Once the lock is held,
a caller REUSES a result another thread just produced (creds persisted
within `default-reuse-window-ms`, or an already-valid cache) so a burst
of N concurrent 401s collapses into ONE exchange.

USE — file-backed rotating stores (Anthropic, Codex): `make-file-refresher`.
Cache-backed / bespoke stores (Copilot): `refresher` with custom
reuse/refresh fns. Both return a 0-arg fn yielding the provider-token
map, owning their own lock; drop them straight into
`:provider/get-token-fn` / `:provider/refresh-token-fn`.
raw docstring

com.blockether.vis.internal.provider.service

Channel-neutral provider management service.

Everything a channel needs to render and mutate the provider fleet — status probing, account limits, live model catalogs, presets, and config persistence — WITHOUT any UI. Hoisted from the TUI extension (channel_tui/provider.clj) so any future surface manages the SAME fleet through the SAME primitives; the channels keep only their interaction layer (lanterna dialogs, ...).

Auth is classified, not implemented, here: auth-kind tells a channel whether a provider wants an API key, an interactive OAuth flow (owned by the provider extension + channel), or nothing (local). The registry's :provider/*-fn contract stays the single integration point for provider extensions, so a provider extension automatically works in every channel.

Channel-neutral provider management service.

Everything a channel needs to render and mutate the provider fleet
— status probing, account limits, live model catalogs, presets, and
config persistence — WITHOUT any UI. Hoisted from the TUI extension
(`channel_tui/provider.clj`) so any future surface manages the SAME
fleet through the SAME primitives; the channels keep only their
interaction layer (lanterna dialogs, ...).

Auth is classified, not implemented, here: `auth-kind` tells a
channel whether a provider wants an API key, an interactive OAuth
flow (owned by the provider extension + channel), or nothing
(local). The registry's `:provider/*-fn` contract stays the single
integration point for provider extensions, so a provider extension
automatically works in every channel.
raw docstring

com.blockether.vis.internal.provider.vendor.alibaba

Alibaba Model Studio static-API-key providers. Each plan is registered as its own extension:

:alibaba-coding-plan -> Coding Plan subscription (https://coding-intl.dashscope.aliyuncs.com/v1). Env var: ALIBABA_CODING_PLAN_API_KEY.

:alibaba-token-plan -> Token Plan prepaid token bundle (https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1). Env var: ALIBABA_TOKEN_PLAN_API_KEY.

Both endpoints speak the OpenAI-compatible chat wire (/chat/completions, SSE streaming with stream_options.include_usage, native tool_calls, reasoning_content for thinking models), so svar's default :openai-compatible-chat api-style drives them unchanged.

The plans are SEPARATE credentials: a Token Plan key is rejected by the Coding Plan endpoint and vice versa (HTTP 401 InvalidApiKey). Hence one provider id, one env var and one auth-file slice per plan - never a shared ALIBABA_API_KEY fallback that would silently authenticate as the wrong plan.

The provider ids match their models.dev slugs, which is what lets svar resolve pricing, context windows and capabilities for the catalog models listed in each preset; svar's KNOWN_PROVIDERS has no Alibaba entry, so the preset here owns :base-url (svar accepts an unknown provider id whenever a base URL is supplied).

Auth lifecycle:

  1. vis-agent providers auth alibaba-coding-plan (or vis-agent providers auth alibaba-token-plan) takes the API key once and persists it under ~/.vis/alibaba-auth.json, as canonical snake_case JSON - top-level plan tag, then api_key / saved_at (never kebab, never keyword keys).
  2. Subsequent runs read the configured provider key, env var, or persisted key. A TUI/config :api-key wins so status/limits match the key used for model calls; the env vars override the auth file when present so CI / scripted setups stay home-directory-free.
  3. vis-agent providers status alibaba-coding-plan reports the source (config / env / file) without exposing the full key.
  4. vis-agent providers logout alibaba-coding-plan clears the persisted key for that plan only; the other plan stays intact.
Alibaba Model Studio static-API-key providers. Each plan is registered as its own extension:

  :alibaba-coding-plan -> Coding Plan subscription
                (https://coding-intl.dashscope.aliyuncs.com/v1).
                Env var: `ALIBABA_CODING_PLAN_API_KEY`.

  :alibaba-token-plan  -> Token Plan prepaid token bundle
                (https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1).
                Env var: `ALIBABA_TOKEN_PLAN_API_KEY`.

Both endpoints speak the OpenAI-compatible chat wire (`/chat/completions`,
SSE streaming with `stream_options.include_usage`, native `tool_calls`,
`reasoning_content` for thinking models), so svar's default
`:openai-compatible-chat` api-style drives them unchanged.

The plans are SEPARATE credentials: a Token Plan key is rejected by the
Coding Plan endpoint and vice versa (HTTP 401 `InvalidApiKey`). Hence one
provider id, one env var and one auth-file slice per plan - never a shared
`ALIBABA_API_KEY` fallback that would silently authenticate as the wrong
plan.

The provider ids match their models.dev slugs, which is what lets svar
resolve pricing, context windows and capabilities for the catalog models
listed in each preset; svar's `KNOWN_PROVIDERS` has no Alibaba entry, so
the preset here owns `:base-url` (svar accepts an unknown provider id
whenever a base URL is supplied).

Auth lifecycle:
  1. `vis-agent providers auth alibaba-coding-plan` (or
     `vis-agent providers auth alibaba-token-plan`) takes the API key once
     and persists it under `~/.vis/alibaba-auth.json`, as canonical
     snake_case JSON - top-level plan tag, then `api_key` / `saved_at`
     (never kebab, never keyword keys).
  2. Subsequent runs read the configured provider key, env var, or
     persisted key. A TUI/config `:api-key` wins so status/limits match the
     key used for model calls; the env vars override the auth file when
     present so CI / scripted setups stay home-directory-free.
  3. `vis-agent providers status alibaba-coding-plan` reports the source
     (config / env / file) without exposing the full key.
  4. `vis-agent providers logout alibaba-coding-plan` clears the persisted
     key for that plan only; the other plan stays intact.
raw docstring

com.blockether.vis.internal.provider.vendor.anthropic

Anthropic providers.

Providers:

  • :anthropic - normal Anthropic API key provider. API key lives in Vis config.
  • :anthropic-coding-plan - Claude subscription OAuth provider. OAuth credentials live in ~/.vis/anthropic-auth.json.

Runtime calls hand the OAuth access token to svar; svar handles only the Anthropic Messages API wire differences for subscription tokens.

Anthropic providers.

Providers:
- `:anthropic` - normal Anthropic API key provider. API key lives in Vis config.
- `:anthropic-coding-plan` - Claude subscription OAuth provider. OAuth
  credentials live in `~/.vis/anthropic-auth.json`.

Runtime calls hand the OAuth access token to svar; svar handles only the
Anthropic Messages API wire differences for subscription tokens.
raw docstring

com.blockether.vis.internal.provider.vendor.github-copilot

GitHub Copilot OAuth provider - device flow authentication + token lifecycle.

Auth flow:

  1. Device flow -> user visits github.com/login/device, enters code
  2. Poll until authorized -> receive OAuth token (ghu_...)
  3. Exchange OAuth token for short-lived Copilot API token via api.github.com/copilot_internal/v2/token
  4. Auto-refresh the API token before expiry

Token detection priority (same as Copilot CLI):

  1. Persisted OAuth token in ~/.vis/github-copilot-auth.json
  2. COPILOT_GITHUB_TOKEN env var
  3. GH_TOKEN env var
  4. GITHUB_TOKEN env var
  5. macOS Keychain (copilot-cli service) - if security CLI available

Works with both Individual and Business/Enterprise plans. Enterprise users can pass :enterprise-domain for GHE.

GitHub Copilot OAuth provider - device flow authentication + token lifecycle.

Auth flow:
1. Device flow -> user visits github.com/login/device, enters code
2. Poll until authorized -> receive OAuth token (`ghu_...`)
3. Exchange OAuth token for short-lived Copilot API token
   via `api.github.com/copilot_internal/v2/token`
4. Auto-refresh the API token before expiry

Token detection priority (same as Copilot CLI):
1. Persisted OAuth token in `~/.vis/github-copilot-auth.json`
2. `COPILOT_GITHUB_TOKEN` env var
3. `GH_TOKEN` env var
4. `GITHUB_TOKEN` env var
5. macOS Keychain (`copilot-cli` service) - if `security` CLI available

Works with both Individual and Business/Enterprise plans.
Enterprise users can pass `:enterprise-domain` for GHE.
raw docstring

com.blockether.vis.internal.provider.vendor.lmstudio

LM Studio local provider preset extension.

LM Studio local provider preset extension.
raw docstring

com.blockether.vis.internal.provider.vendor.mistral

Mistral.ai provider preset extension. API keys are configured by channels.

Mistral.ai provider preset extension. API keys are configured by channels.
raw docstring

com.blockether.vis.internal.provider.vendor.ollama

Ollama local provider preset extension.

Ollama local provider preset extension.
raw docstring

com.blockether.vis.internal.provider.vendor.openai

OpenAI provider preset extension. API keys are configured by channels.

OpenAI provider preset extension. API keys are configured by channels.
raw docstring

com.blockether.vis.internal.provider.vendor.openai-codex

OpenAI Codex (ChatGPT OAuth) provider.

Headless clients use Codex's official device authorization flow, so the browser can be on a phone or desktop while the gateway stays behind NAT. The interactive CLI also retains the registered loopback PKCE flow. Neither path rewrites a provider-registered redirect URI.

Tokens are persisted at ~/.vis/openai-codex-auth.json. The access token is a JWT; Codex requests require the embedded ChatGPT account id, so this namespace validates/extracts it during login/refresh.

The dynamic quota report lives here too (dynamic-limits!): it fetches https://chatgpt.com/backend-api/wham/usage, selects the regular Codex bucket (or the nested Codex Spark bucket) and exposes the 5h and 7d percentage windows as normalized Vis limit rows.

OpenAI Codex (ChatGPT OAuth) provider.

Headless clients use Codex's official device authorization flow, so the
browser can be on a phone or desktop while the gateway stays behind NAT.
The interactive CLI also retains the registered loopback PKCE flow.
Neither path rewrites a provider-registered redirect URI.

Tokens are persisted at `~/.vis/openai-codex-auth.json`. The access
token is a JWT; Codex requests require the embedded ChatGPT account
id, so this namespace validates/extracts it during login/refresh.

The dynamic quota report lives here too (`dynamic-limits!`): it fetches
`https://chatgpt.com/backend-api/wham/usage`, selects the regular Codex
bucket (or the nested Codex Spark bucket) and exposes the 5h and 7d
percentage windows as normalized Vis limit rows.
raw docstring

com.blockether.vis.internal.provider.vendor.opencode-go

OpenCode Go (https://opencode.ai/go) static-API-key provider.

OpenCode Go is a flat-rate ($10/month) subscription gateway serving a curated set of open-source coding models from ONE endpoint (https://opencode.ai/zen/go/v1) over TWO wire dialects. Vis surfaces them as a SINGLE first-class provider — :opencode-go — that routes each model to the correct wire automatically:

OpenAI chat wire (/chat/completions, svar default): GLM, Kimi, DeepSeek, MiMo, Hy3.

Anthropic Messages wire (/messages, per-model :api-style :anthropic): MiniMax, Qwen.

svar reads (or (:api-style model-map) (:api-style provider)) at request build time, so a per-model :api-style override on the Anthropic models inside :default-models is all that is needed — one provider, one key, one endpoint, two wires.

Authentication is the shared static-API-key shape, owned by com.blockether.vis.internal.provider.key-store and declared by BOOK below: lookup order, status, logout, the token envelope and the interactive vis-agent providers auth opencode-go flow all come from there.

What stays here is what only OpenCode Go knows: which model rides which wire, and the live /usage quota report (dynamic-limits! below).

OpenCode Go (https://opencode.ai/go) static-API-key provider.

OpenCode Go is a flat-rate ($10/month) subscription gateway serving a curated
set of open-source coding models from ONE endpoint
(`https://opencode.ai/zen/go/v1`) over TWO wire dialects. Vis surfaces them
as a SINGLE first-class provider — `:opencode-go` — that routes each model to
the correct wire automatically:

  OpenAI chat wire (/chat/completions, svar default):
    GLM, Kimi, DeepSeek, MiMo, Hy3.

  Anthropic Messages wire (/messages, per-model `:api-style :anthropic`):
    MiniMax, Qwen.

svar reads `(or (:api-style model-map) (:api-style provider))` at request
build time, so a per-model `:api-style` override on the Anthropic models
inside `:default-models` is all that is needed — one provider, one key, one
endpoint, two wires.

Authentication is the shared static-API-key shape, owned by
`com.blockether.vis.internal.provider.key-store` and declared by `BOOK` below:
lookup order, status, logout, the token envelope and the interactive
`vis-agent providers auth opencode-go` flow all come from there.

What stays here is what only OpenCode Go knows: which model rides which wire,
and the live `/usage` quota report (`dynamic-limits!` below).
raw docstring

com.blockether.vis.internal.provider.vendor.openrouter

OpenRouter static-API-key provider (https://openrouter.ai/api/v1).

OpenRouter is a multi-provider gateway speaking the OpenAI chat wire, so no :api-style override is needed - svar's default OpenAI transport handles it. Model names are vendor/model slugs (anthropic/claude-sonnet-4.5, openai/gpt-5.1, ...).

Authentication is the shared static-API-key shape, owned by com.blockether.vis.internal.provider.key-store and declared by BOOK below: lookup order, status, logout, the token envelope and the interactive vis-agent providers auth openrouter flow all come from there.

What stays here is what only OpenRouter knows: the starter catalog, the live model enrichment, and the credit report from GET /api/v1/key - the credits this key has consumed and, for capped keys, its limit.

OpenRouter static-API-key provider (https://openrouter.ai/api/v1).

OpenRouter is a multi-provider gateway speaking the OpenAI chat wire, so no
`:api-style` override is needed - svar's default OpenAI transport handles it.
Model names are `vendor/model` slugs (`anthropic/claude-sonnet-4.5`,
`openai/gpt-5.1`, ...).

Authentication is the shared static-API-key shape, owned by
`com.blockether.vis.internal.provider.key-store` and declared by `BOOK` below:
lookup order, status, logout, the token envelope and the interactive
`vis-agent providers auth openrouter` flow all come from there.

What stays here is what only OpenRouter knows: the starter catalog, the live
model enrichment, and the credit report from `GET /api/v1/key` - the credits
this key has consumed and, for capped keys, its limit.
raw docstring

com.blockether.vis.internal.provider.vendor.zai

Z.ai (ZhipuAI) static-API-key provider helpers. Each plan is registered as its own extension:

:zai-coding-plan -> coding-plan subscription (https://api.z.ai/api/coding/paas/v4). Env var: ZAI_CODING_API_KEY.

:zai -> pay-as-you-go / Pass gateway (https://api.z.ai/api/paas/v4). Env var: ZAI_API_KEY.

Both endpoints serve the same GLM model family (glm-5.3-flash, glm-5.3, glm-5-turbo, glm-5.1, ...) with effort-based reasoning on GLM-5.3 models and binary thinking on older models (handled by svar). They share helper code, but the runtime extension registry sees one extension entry per provider id.

Auth lifecycle:

  1. vis-agent providers auth zai-coding (or vis-agent providers auth zai) prompts for the API key once and persists it under ~/.vis/zai-auth.json, as canonical snake_case JSON - top-level plan tag, then api_key / saved_at (never kebab, never keyword keys).
  2. Subsequent runs read the configured provider key, env var, or persisted key. A TUI/config :api-key wins so status/limits match the key used for model calls; env vars (ZAI_CODING_API_KEY, ZAI_API_KEY) override the auth file when present so CI / scripted setups stay home-directory-free.
  3. vis-agent providers status zai-coding reports the source (config / env / file) without exposing the full key.
  4. vis-agent providers logout zai-coding clears the persisted key for that plan only; the other plan stays intact.
Z.ai (ZhipuAI) static-API-key provider helpers. Each plan is registered as its own extension:

  :zai-coding-plan -> coding-plan subscription
                (https://api.z.ai/api/coding/paas/v4).
                Env var: `ZAI_CODING_API_KEY`.

  :zai        -> pay-as-you-go / `Pass` gateway
                (https://api.z.ai/api/paas/v4).
                Env var: `ZAI_API_KEY`.

Both endpoints serve the same GLM model family (`glm-5.3-flash`,
`glm-5.3`, `glm-5-turbo`, `glm-5.1`, ...) with effort-based reasoning
on GLM-5.3 models and binary thinking on older models (handled by svar). They
share helper code, but the runtime extension registry sees one
extension entry per provider id.

Auth lifecycle:
  1. `vis-agent providers auth zai-coding` (or `vis-agent providers auth zai`) prompts for the API
     key once and persists it under `~/.vis/zai-auth.json`,
     as canonical snake_case JSON - top-level plan tag, then
     `api_key` / `saved_at` (never kebab, never keyword keys).
  2. Subsequent runs read the configured provider key, env var, or
     persisted key. A TUI/config `:api-key` wins so status/limits
     match the key used for model calls; env vars
     (`ZAI_CODING_API_KEY`, `ZAI_API_KEY`) override the auth file when
     present so CI / scripted setups stay home-directory-free.
  3. `vis-agent providers status zai-coding` reports the source
     (config / env / file) without exposing the full key.
  4. `vis-agent providers logout zai-coding` clears the persisted key for
     that plan only; the other plan stays intact.
raw docstring

com.blockether.vis.internal.python.env

The agent's action substrate: an embedded CPython the model writes Python for.

A SESSION here owns one worker process and one embedded interpreter. Its sandbox namespace and trusted extension namespaces share that interpreter, while another session has another process, import table and native-library cache. A one-shot CLI session may run in the process it already owns.

Three rules shape everything below. One dialect crosses the boundary: JSON, in both directions — host to guest is a json.loads of a literal this namespace renders, guest to host is python-host's envelope. The guest's Python is a FILE, never a string built here: the runtime ships the sandbox runtime, auto-imports, network probe and process redirect as modules it imports, and Vis' own guest code lives under resources/vis-guest/. The boundaries are native: the worker's OS jail owns filesystem and egress confinement, while the runtime audit hook is the fail-closed file/socket backstop before policy setup. Python modules provide ergonomics, never security.

Public surface used by the loop:

create-python-context / dispose-python-context! / retire-python-context! / interrupt-guest! / take-partial-block-stdout! / python-worker-pids / set-python-binding! / bind-and-bump! / count-top-level-forms / validate-no-banned-defs! / run-python-block / persist-session-defs! / restore-session-defs! / forget-session-defs! / SYSTEM_VAR_NAMES / system-var-sym? / boundary-view / ctx->python-str / bind-ctx!

The agent's action substrate: an embedded CPython the model writes Python for.

A SESSION here owns one worker process and one embedded interpreter. Its
sandbox namespace and trusted extension namespaces share that interpreter,
while another session has another process, import table and native-library
cache. A one-shot CLI session may run in the process it already owns.

Three rules shape everything below. **One dialect crosses the boundary:**
JSON, in both directions — host to guest is a `json.loads` of a literal this
namespace renders, guest to host is `python-host`'s envelope. **The guest's
Python is a FILE**, never a string built here: the runtime ships the sandbox
runtime, auto-imports, network probe and process redirect as modules it imports,
and Vis' own guest code lives under `resources/vis-guest/`. **The boundaries are
native:** the worker's OS jail owns filesystem and egress confinement, while the
runtime audit hook is the fail-closed file/socket backstop before policy setup.
Python modules provide ergonomics, never security.

Public surface used by the loop:

  create-python-context / dispose-python-context! / retire-python-context! /
  interrupt-guest! / take-partial-block-stdout! / python-worker-pids /
  set-python-binding! / bind-and-bump! / count-top-level-forms /
  validate-no-banned-defs! / run-python-block / persist-session-defs! /
  restore-session-defs! / forget-session-defs! /
  SYSTEM_VAR_NAMES / system-var-sym? / boundary-view / ctx->python-str / bind-ctx!
raw docstring

com.blockether.vis.internal.python.extensions

Project-local Python extensions — trusted-context plug-ins.

Vis extensions are normally Clojure libraries baked into the binary at build time. This namespace adds a second, fully dynamic authoring path: drop a *.py file into

~/.vis/extensions/ (global — every project) <project>/.vis/extensions/ (project-local — this project only)

and it loads at startup (and on /reload) in BOTH the JVM and the native image — the extension is Python all the way down, so nothing here defines a class at runtime.

Each file is first evaluated in a TRUSTED gateway-wide registration namespace. When one of its callables runs for a gateway session, the extension is realized again in the session's separate trusted worker. The sandbox has its own process, interpreter and host-call connection. Extension imports and native-library state are shared only with other trusted extensions in that session. Another session owns another pair of processes.

The model can call an extension TOOL through the ordinary host wrapper, envelope- checked like any tool, but cannot choose the trusted identity or evaluate code in the extension namespace. The blockether.vis.extension API exposes host capabilities; only sealed data crosses the boundary, never live Python or host objects. Startup, reload and calls with no owning session use the shared registration worker.

A context is only a NAMESPACE in its owning embedded interpreter: opening one costs a dict, and closing one drops the extension's Python with it. Calls into an extension (tool, activation, prompt, slash, op hook) are serialized with locking on its session name, the same proven pattern as the printer context.

The file's top-level vis.register(vis.Extension(...)) call registers through the ordinary register-extension! — from the registry's perspective a Python extension is indistinguishable from a Clojure one (activation, prompt assembly, slash dispatch, vis-agent extension list all just work). A file that fails to load becomes a load-failure warning (surfaced via vis-agent doctor), never a crash.

Project-local Python extensions — trusted-context plug-ins.

Vis extensions are normally Clojure libraries baked into the binary at
build time. This namespace adds a second, fully dynamic authoring path:
drop a `*.py` file into

  ~/.vis/extensions/           (global — every project)
  <project>/.vis/extensions/   (project-local — this project only)

and it loads at startup (and on `/reload`) in BOTH the JVM and the
native image — the extension is Python all the way down, so nothing here
defines a class at runtime.

Each file is first evaluated in a TRUSTED gateway-wide registration namespace.
When one of its callables runs for a gateway session, the extension is realized
again in the session's separate trusted worker. The sandbox has its own process,
interpreter and host-call connection. Extension imports and native-library state
are shared only with other trusted extensions in that session. Another session
owns another pair of processes.

The model can call an extension TOOL through the ordinary host wrapper, envelope-
checked like any tool, but cannot choose the trusted identity or evaluate code in
the extension namespace. The `blockether.vis.extension` API exposes host capabilities;
only sealed data crosses the boundary, never live Python or host objects.
Startup, reload and calls with no owning session use the shared registration worker.

A context is only a NAMESPACE in its owning embedded interpreter: opening one costs
a dict, and closing one drops the extension's Python with it. Calls into an extension
(tool, activation, prompt, slash, op hook) are serialized with `locking` on its
session name, the same proven pattern as the printer context.

The file's top-level `vis.register(vis.Extension(...))` call registers through the
ordinary `register-extension!` — from the registry's perspective a
Python extension is indistinguishable from a Clojure one (activation,
prompt assembly, slash dispatch, `vis-agent extension list` all just work).
A file that fails to load becomes a load-failure warning (surfaced via
`vis-agent doctor`), never a crash.
raw docstring

com.blockether.vis.internal.python.format

Beautify model-emitted Python with ruff (com.blockether/ruff — in-process via the JDK FFM API, black-compatible) before it is shown. Used by the gateway's code renderer so the trace shows tidy, consistently-wrapped Python instead of the model's raw one-liners.

CACHED: ruff output is deterministic for a given input, and the same code block is rendered many times (pinned trace + live SSE re-emits + reconnect replay), so an LRU memo means each distinct block formats exactly once.

SAFE: ruff/format-or returns the source verbatim if ruff is unavailable (e.g. the native lib isn't bundled in a particular build) or the code doesn't parse — the original is never lost.

Beautify model-emitted Python with ruff (com.blockether/ruff — in-process via
the JDK FFM API, black-compatible) before it is shown. Used by the gateway's
code renderer so the trace shows tidy, consistently-wrapped Python instead of
the model's raw one-liners.

CACHED: ruff output is deterministic for a given input, and the same code
block is rendered many times (pinned trace + live SSE re-emits + reconnect
replay), so an LRU memo means each distinct block formats exactly once.

SAFE: `ruff/format-or` returns the source verbatim if ruff is unavailable
(e.g. the native lib isn't bundled in a particular build) or the code doesn't
parse — the original is never lost.
raw docstring

com.blockether.vis.internal.python.host

THE door from the sandbox back into Vis, and the only one.

The embedded interpreter calls out through ONE function it was handed, so everything a block can ask the host for arrives here as two strings: the name of a tool and a JSON envelope of its arguments. What crosses is DATA - a tool takes plain values and answers plain values, because the boundary carries text and nothing else. A live object (an interpreter handle, a stream, a file) crossed it and never will; a handle the model holds is a PYTHON object built over calls that come back through here.

One interpreter serves every session in this process, so the envelope names the session that called (vis-python-runtime's install) and this registry answers per session: shell bound for two workspaces is two different functions under one name, and a session that never bound a name gets a refusal that says so instead of a neighbour's tool.

The host is bound ONCE per process. Binding is idempotent because the interpreter is: a second bind! would replace a live upcall stub while a block sits inside it.

THE door from the sandbox back into Vis, and the only one.

The embedded interpreter calls out through ONE function it was handed, so
everything a block can ask the host for arrives here as two strings: the name
of a tool and a JSON envelope of its arguments. What crosses is DATA - a tool
takes plain values and answers plain values, because the boundary carries
text and nothing else. A live object (an interpreter handle, a stream, a file)
crossed it and never will; a handle the model holds is a PYTHON object built
over calls that come back through here.

One interpreter serves every session in this process, so the envelope names
the session that called (`vis-python-runtime`'s `install`) and this registry
answers per session: `shell` bound for two workspaces is two different
functions under one name, and a session that never bound a name gets a
refusal that says so instead of a neighbour's tool.

The host is bound ONCE per process. Binding is idempotent because the
interpreter is: a second `bind!` would replace a live upcall stub while a
block sits inside it.
raw docstring

com.blockether.vis.internal.python.project

What a Python project DECLARES about its own layout: import roots (the src layout every packaging backend spells differently) and pytest's testpaths.

The metadata is parsed by PYTHON'S OWN parsers inside a Python session -- tomllib for pyproject.toml, configparser for setup.cfg / pytest.ini / tox.ini -- never a regex over the file text. The Python side (resources/vis-python/project_config.py) returns RAW declared strings; everything host-shaped (~ expansion, resolution against the project dir, existence, canonicalisation, dedup, python.source_paths config) lives here.

Inference is purely declarative: a project without such metadata gets nothing inferred, and python.source_paths is how a user says it outright. Every failure degrades to nothing rather than breaking the caller.

What a Python project DECLARES about its own layout: import roots (the `src`
layout every packaging backend spells differently) and pytest's `testpaths`.

The metadata is parsed by PYTHON'S OWN parsers inside a Python session --
`tomllib` for `pyproject.toml`, `configparser` for `setup.cfg` / `pytest.ini`
/ `tox.ini` -- never a regex over the file text. The Python side
(`resources/vis-python/project_config.py`) returns RAW declared strings;
everything host-shaped (`~` expansion, resolution against the project dir,
existence, canonicalisation, dedup, `python.source_paths` config) lives here.

Inference is purely declarative: a project without such metadata gets
nothing inferred, and `python.source_paths` is how a user says it outright.
Every failure degrades to nothing rather than breaking the caller.
raw docstring

com.blockether.vis.internal.python.runtime

Getting the embedded CPython onto THIS machine.

The interpreter is a DIRECTORY — a cdylib plus the vendored standard library beside it, tens of megabytes — so it is neither a maven dependency nor a resource inside a jar. A native distribution stages it beside the binary and the wrapper points VIS_PYTHON_NATIVE_PATH at it; everywhere else (a source checkout, a gateway running from clojure -M:vis) it is fetched ONCE from the runtime's own GitHub release into ~/.vis/python/runtime/<version>/<platform>/ and named through runtime/use-library!, because a JVM cannot set its own environment.

Resolution that already answers is never disturbed: an environment variable, a staged distribution and a built checkout all win over the network. The archive is unpacked into a sibling directory and MOVED into place, so a second process sees a complete installation or none — never a half-written standard library — and tar does the unpacking because the tree carries symlinks and execute bits that no jar or zip round-trips.

Getting the embedded CPython onto THIS machine.

The interpreter is a DIRECTORY — a cdylib plus the vendored standard library
beside it, tens of megabytes — so it is neither a maven dependency nor a
resource inside a jar. A native distribution stages it beside the binary and
the wrapper points `VIS_PYTHON_NATIVE_PATH` at it; everywhere else (a source
checkout, a gateway running from `clojure -M:vis`) it is fetched ONCE from the
runtime's own GitHub release into `~/.vis/python/runtime/<version>/<platform>/`
and named through `runtime/use-library!`, because a JVM cannot set its own
environment.

Resolution that already answers is never disturbed: an environment variable,
a staged distribution and a built checkout all win over the network. The
archive is unpacked into a sibling directory and MOVED into place, so a second
process sees a complete installation or none — never a half-written standard
library — and `tar` does the unpacking because the tree carries symlinks and
execute bits that no jar or zip round-trips.
raw docstring

com.blockether.vis.internal.python.test-runner

Runs an extension author's Python tests (test_*.py / *_test.py) through real pytest, each in its own TRUSTED Python worker session (same trust level as the extension it covers). Tests import the extension's own package through the SAME sys.path sugar the loader gives extension.py, so an author ships real Python tests next to the code and runs them with the project's own tooling. The pytest collector emits structured records, and the test host refuses session live views so a test cannot publish artifacts.

Split out of python-extensions (which owns loading/registration) so the runner is a single, testable responsibility. It depends on that namespace's trusted-context builder; the reverse /test wiring is resolved lazily there to avoid a require cycle.

The source of truth for the outcome is the collector's PER-TEST record list (nodeid, outcome, message). Counts and pass/fail are DERIVED from those records on the host side — never a separate tally that could drift, and never scraped from stdout.

Runs an extension author's Python tests (`test_*.py` / `*_test.py`) through
real `pytest`, each in its own TRUSTED Python worker session
(same trust level as the extension it covers). Tests import the extension's
own package through the SAME `sys.path` sugar the loader gives `extension.py`,
so an author ships real Python tests next to the code and runs them with the
project's own tooling. The pytest collector emits structured records, and
the test host refuses session live views so a test cannot publish artifacts.

Split out of `python-extensions` (which owns loading/registration) so the
runner is a single, testable responsibility. It depends on that namespace's
trusted-context builder; the reverse `/test` wiring is resolved lazily there
to avoid a require cycle.

The source of truth for the outcome is the collector's PER-TEST record list
(nodeid, outcome, message). Counts and pass/fail are DERIVED from those
records on the host side — never a separate tally that could drift, and
never scraped from stdout.
raw docstring

com.blockether.vis.internal.python.worker

Process boundaries for model Python and trusted Python extensions.

Each session's sandbox owns a confined runtime worker. Its trusted extension namespaces run in a separate worker, preserving their host APIs and native library support without sharing interpreter memory or host-call authority with model code. Registration outside a session uses the shared trusted worker.

Confinement, imports, native libraries and interpreter state are process-wide. A per-session extension worker keeps that state separate from other sessions as well as from the sandbox. Only the host chooses a worker's role.

The wire is ONE line of JSON per message over a unix socket, both ways. The parent asks (install-runtime, install-tool, exec, run, run-block, eval, confine, network, stdin, interrupt, close); the child asks back with host, because the registry that knows what a name may call, the persistence handle and the caller's dynamic binding frame all live in the parent (python-host/dispatch). stdout is NOT the wire: Python that prints, or a native library writing to fd 1, would corrupt it, so a child's own stdio goes to a log file instead.

A message carrying op is a request, one without is its reply, so each side numbers its own requests and no id can collide. Work has no timeout: a block or extension tool may legitimately run for minutes. CONTROL is different: an interrupt that cannot reach the child is bounded, because cancellation must be able to retire that process instead of parking its caller forever. A child that DIES is what the pump reports — every call waiting on it fails at once with the child's log to read.

An interrupt reaches BOTH ends of a host call. The child's async exception lands only when the guest next runs Python, and a guest parked in host (a shell handle's wait, a long tool) runs none until the parent answers — so interrupt! also fails every host call the worker has in flight, and the tool thread serving it is interrupted. Measured before that: the unwind watch expired, the worker was killed, and the next block silently started a FRESH interpreter that had the runtime but none of the session's tools. A retired key now refuses to restart until the session is rebuilt.

Process boundaries for model Python and trusted Python extensions.

Each session's sandbox owns a confined runtime worker. Its trusted extension
namespaces run in a separate worker, preserving their host APIs and native
library support without sharing interpreter memory or host-call authority
with model code. Registration outside a session uses the shared trusted worker.

Confinement, imports, native libraries and interpreter state are process-wide.
A per-session extension worker keeps that state separate from other sessions
as well as from the sandbox. Only the host chooses a worker's role.

The wire is ONE line of JSON per message over a unix socket, both ways. The
parent asks (`install-runtime`, `install-tool`, `exec`, `run`, `run-block`,
`eval`, `confine`, `network`, `stdin`, `interrupt`, `close`); the child asks
back with `host`, because the registry that knows what a name may call, the
persistence handle and the caller's dynamic binding frame all live in the
parent (`python-host/dispatch`). stdout is NOT the wire: Python that prints,
or a native library writing to fd 1, would corrupt it, so a child's own stdio
goes to a log file instead.

A message carrying `op` is a request, one without is its reply, so each side
numbers its own requests and no id can collide. Work has no timeout: a block
or extension tool may legitimately run for minutes. CONTROL is different:
an interrupt that cannot reach the child is bounded, because cancellation
must be able to retire that process instead of parking its caller forever.
A child that DIES is what the pump reports — every call waiting on it fails
at once with the child's log to read.

An interrupt reaches BOTH ends of a host call. The child's async exception
lands only when the guest next runs Python, and a guest parked in `host`
(a shell handle's `wait`, a long tool) runs none until the parent answers —
so `interrupt!` also fails every host call the worker has in flight, and the
tool thread serving it is interrupted. Measured before that: the unwind
watch expired, the worker was killed, and the next block silently started a
FRESH interpreter that had the runtime but none of the session's tools. A
retired key now refuses to restart until the session is rebuilt.
raw docstring

com.blockether.vis.internal.python.worker-peer

Host-side client of the runtime worker's JSON-line protocol. Dispatch and cancellation stay in Vis; interpreter operations live in the runtime.

Host-side client of the runtime worker's JSON-line protocol.
Dispatch and cancellation stay in Vis; interpreter operations live in the runtime.
raw docstring

com.blockether.vis.internal.sandbox.egress-proxy

Gateway-hosted loopback EGRESS PROXY — the one door a jailed shell child may use to reach the network. Paired with the OS jail's net-off-except-loopback wall (process-jail), it turns vis.yml :network (allowed/denied domains + verb/path :rules) into REAL enforcement for shell children — curl, wget, a script, subprocess — not a cooperative env-var hint.

How the wall + door fit together:

  • The jail denies ALL sockets except TCP to 127.0.0.1:<this-port>, so a raw socket() / /dev/tcp in a script has nowhere to go — the kernel, not politeness, forces every byte through here.
  • http_proxy/https_proxy/ALL_PROXY point the child's HTTP clients at this port, so ordinary tools proxy voluntarily; the wall covers the rest.

What it enforces (no MITM — no new deps, no CA):

  • Plain HTTP (absolute-form proxy request): FULL host + method + path, because the request line is cleartext. GET-not-POST works here.
  • HTTPS (CONNECT host:443): HOST allow/deny always; FULL method + path too when a MITM capability is supplied and the policy asks for it (:mitm?) — the proxy terminates the child's TLS with an ephemeral per-host leaf cert (see internal.tls-mitm), reads the real verb/path, then re-encrypts to the real upstream (whose real cert it still validates). Without MITM it is a raw byte tunnel (verb opaque) — the documented CONNECT-only ceiling.

The policy is a plain VALUE (per session), read fresh per connection via policy-fn so /reload + config edits take effect with no restart. One request per upstream connection (we force Connection: close), so HTTP keep-alive can't smuggle a second, unfiltered verb onto an already-approved socket.

Gateway-hosted loopback EGRESS PROXY — the one door a jailed shell child may use
to reach the network. Paired with the OS jail's *net-off-except-loopback* wall
(`process-jail`), it turns vis.yml `:network` (allowed/denied domains + verb/path
`:rules`) into REAL enforcement for shell children — `curl`, `wget`, a script,
`subprocess` — not a cooperative env-var hint.

How the wall + door fit together:
  - The jail denies ALL sockets except TCP to `127.0.0.1:<this-port>`, so a raw
    `socket()` / `/dev/tcp` in a script has nowhere to go — the kernel, not
    politeness, forces every byte through here.
  - `http_proxy`/`https_proxy`/`ALL_PROXY` point the child's HTTP clients at this
    port, so ordinary tools proxy voluntarily; the wall covers the rest.

What it enforces (no MITM — no new deps, no CA):
  - Plain HTTP  (absolute-form proxy request): FULL host + method + path, because
    the request line is cleartext. GET-not-POST works here.
  - HTTPS (`CONNECT host:443`): HOST allow/deny always; FULL method + path too when
    a MITM capability is supplied and the policy asks for it (`:mitm?`) — the proxy
    terminates the child's TLS with an ephemeral per-host leaf cert (see
    `internal.tls-mitm`), reads the real verb/path, then re-encrypts to the real
    upstream (whose real cert it still validates). Without MITM it is a raw byte
    tunnel (verb opaque) — the documented CONNECT-only ceiling.

The policy is a plain VALUE (per session), read fresh per connection via `policy-fn`
so `/reload` + config edits take effect with no restart. One request per upstream
connection (we force `Connection: close`), so HTTP keep-alive can't smuggle a second,
unfiltered verb onto an already-approved socket.
raw docstring

com.blockether.vis.internal.sandbox.gateway

Gateway-lifecycle SANDBOX CAPABILITY: ONE shared loopback egress proxy and ONE ephemeral MITM CA for the WHOLE daemon, keyed PER SESSION.

Why shared, not per-session (turn 32): the gateway is multi-tenant — many clients/sessions hit one daemon. A per-session proxy+CA thrashes listeners and mints a fresh CA per session; worse, every child would carry a different trust root. Instead there is ONE listener and ONE CA (the "same certs" property — every child trusts the same root), plus a REGISTRY mapping a per-session TOKEN → that session's live policy fn.

How a connection is attributed to a session: the jailed child's proxy env carries its unguessable token in the proxy URL userinfo (http://<token>@127.0.0.1:<port>); curl/git/requests/… send it back as Proxy-Authorization: Basic base64(<token>:); the proxy hands the token to resolve-policy, which looks up the registry.

FAIL-CLOSED: a request whose token is missing or not registered to a LIVE session is DENIED (a :deny-all? sentinel policy) — the shared door never serves a policy it cannot attribute. The token is a random UUID, so one session cannot reach another's (broader) policy by guessing.

Lazy: the proxy listener and the CA keygen happen only on first ensure-proxy! / ensure-ca! — a gateway that never jails a shell child opens neither.

Gateway-lifecycle SANDBOX CAPABILITY: ONE shared loopback egress proxy and ONE
ephemeral MITM CA for the WHOLE daemon, keyed PER SESSION.

Why shared, not per-session (turn 32): the gateway is multi-tenant — many
clients/sessions hit one daemon. A per-session proxy+CA thrashes listeners and
mints a fresh CA per session; worse, every child would carry a different trust
root. Instead there is ONE listener and ONE CA (the "same certs" property —
every child trusts the same root), plus a REGISTRY mapping a per-session TOKEN →
that session's live policy fn.

How a connection is attributed to a session: the jailed child's proxy env carries
its unguessable token in the proxy URL userinfo (`http://<token>@127.0.0.1:<port>`);
curl/git/requests/… send it back as `Proxy-Authorization: Basic base64(<token>:)`;
the proxy hands the token to `resolve-policy`, which looks up the registry.

FAIL-CLOSED: a request whose token is missing or not registered to a LIVE session
is DENIED (a `:deny-all?` sentinel policy) — the shared door never serves a policy
it cannot attribute. The token is a random UUID, so one session cannot reach
another's (broader) policy by guessing.

Lazy: the proxy listener and the CA keygen happen only on first `ensure-proxy!` /
`ensure-ca!` — a gateway that never jails a shell child opens neither.
raw docstring

com.blockether.vis.internal.sandbox.jail

OS-level process CONTAINMENT — the 'jail' — that wraps the shell executors' argv so an allowed child is physically confined to the session workspace roots and, when network is off, cannot open a socket. This is a real containment boundary — not a cooperative name/argv check, which can be walked around since argv[0] is bash and the real binary hides inside the -lc string; the jail constrains what the child can DO once it runs, regardless of what a script inside it tries (curl, python -c, /dev/tcp — all hit the same wall).

POLICY, NOT GUARDS. The jail is driven by a declarative policy compiled from vis.yml + the LIVE session roots, not by hand-written guard functions. The policy is a plain VALUE passed per spawn (never a process-global singleton, so many concurrent sessions in one gateway never stomp each other). Its shape:

{:roots-fn (fn [] [root-strings]) ; live session RW roots, re-read/spawn :net-enabled? <bool> ; whole shell-child network on/off :allow-read-write [<path> …] ; full read+write grant :deny-write [<path> …] ; protect within writable (deny wins) :allow-read [<path> …] ; additional read-only paths :deny-read [<path> …] ; protect a read region (deny wins) :deny-exec [<path> …] ; readable but never executable :keychain? <bool> ; the OS credential store is reachable :inbound-ports [<int> …] ; ports a child may ACCEPT on from ; other hosts (loopback is always open) :env-values {<NAME> <value>} ; RESOLVED project env (.env + ; environment:) with ONE call's own ; env delta merged on, per spawn :env-removals #{<NAME> …} ; names THAT call asked to UNSET :inherit-host-env? <bool>} ; jail.environment: inherit — the child ; also keeps the operator's ambient env

The filesystem model mirrors Anthropic's sandbox-runtime:

  • WRITE is allow-only: denied everywhere except the session roots + tmp + :allow-read-write; :deny-write wins.
  • READ is default-deny here (workspace-focused, stronger than srt's read-everywhere default): system code/config + RW paths + :allow-read are readable; :deny-read wins.

This namespace owns WHAT a session's child may do: it turns the session's configuration, live roots, proxy endpoint and call environment into one platform-neutral policy value and the complete child environment. HOW the operating system enforces that value belongs to com.blockether/vis-python-runtime (spawn-process! with :policy): the per-platform enforcement, the already-confined marker and the refusal on a host that cannot enforce all live there, beside the process launcher, so no enforcement text is assembled here.

OS-level process CONTAINMENT — the 'jail' — that wraps the shell executors'
argv so an allowed child is physically confined to the session workspace roots
and, when network is off, cannot open a socket. This is a real containment
boundary — not a cooperative name/argv check, which can be walked around since
argv[0] is `bash` and the real binary hides inside the `-lc` string; the jail
constrains what the child can DO once it runs, regardless of what a script
inside it tries (curl, python -c, /dev/tcp — all hit the same wall).

POLICY, NOT GUARDS. The jail is driven by a declarative *policy* compiled from
vis.yml + the LIVE session roots, not by hand-written guard functions. The
policy is a plain VALUE passed per spawn (never a process-global singleton, so
many concurrent sessions in one gateway never stomp each other). Its shape:

  {:roots-fn     (fn [] [root-strings])  ; live session RW roots, re-read/spawn
   :net-enabled? <bool>                  ; whole shell-child network on/off
   :allow-read-write [<path> …]           ; full read+write grant
   :deny-write       [<path> …]           ; protect within writable (deny wins)
   :allow-read       [<path> …]           ; additional read-only paths
   :deny-read        [<path> …]           ; protect a read region (deny wins)
   :deny-exec        [<path> …]           ; readable but never executable
   :keychain?        <bool>               ; the OS credential store is reachable
   :inbound-ports    [<int> …]            ; ports a child may ACCEPT on from
                                          ; other hosts (loopback is always open)
   :env-values       {<NAME> <value>}     ; RESOLVED project env (`.env` +
                                          ; `environment:`) with ONE call's own
                                          ; `env` delta merged on, per spawn
   :env-removals     #{<NAME> …}          ; names THAT call asked to UNSET
   :inherit-host-env? <bool>}             ; `jail.environment: inherit` — the child
                                          ; also keeps the operator's ambient env

The filesystem model mirrors Anthropic's sandbox-runtime:
  - WRITE is allow-only: denied everywhere except the session roots + tmp +
    `:allow-read-write`; `:deny-write` wins.
  - READ is default-deny here (workspace-focused, stronger than srt's
    read-everywhere default): system code/config + RW paths + `:allow-read`
    are readable; `:deny-read` wins.

This namespace owns WHAT a session's child may do: it turns the session's
configuration, live roots, proxy endpoint and call environment into one
platform-neutral policy value and the complete child environment. HOW the
operating system enforces that value belongs to `com.blockether/vis-python-runtime`
(`spawn-process!` with `:policy`): the per-platform enforcement, the
already-confined marker and the refusal on a host that cannot enforce all live
there, beside the process launcher, so no enforcement text is assembled here.
raw docstring

com.blockether.vis.internal.sandbox.policy

Canonical immutable security-policy snapshots and their model-facing view.

A snapshot is created once for a root environment, inherited unchanged by child environments, and replaced only by an explicit environment rebuild. Enforcement and context both derive from this value.

Canonical immutable security-policy snapshots and their model-facing view.

A snapshot is created once for a root environment, inherited unchanged by
child environments, and replaced only by an explicit environment rebuild.
Enforcement and context both derive from this value.
raw docstring

com.blockether.vis.internal.sandbox.tls-mitm

Ephemeral CA + per-host leaf minting for the egress proxy's TLS-terminating (MITM) tier — the piece that gives a jailed shell child GET-not-POST fidelity over HTTPS, matching what the interpreter method-guard already does pre-TLS.

Why it exists: a plain CONNECT proxy only sees CONNECT host:443 — the method and path live inside the TLS the proxy never opens, so HTTPS verb/path is opaque. To read them the proxy must TERMINATE the child's TLS: present the child a leaf cert for the requested host, decrypt, inspect method+path, then re-encrypt to the real upstream (whose real cert the proxy still validates).

Trust model:

  • The CA is EPHEMERAL and per-session — born in this JVM, never written to the host trust store. Its cert PEM is written to a temp file whose path is injected into the jailed child's trust env (CURL_CA_BUNDLE/SSL_CERT_FILE /REQUESTS_CA_BUNDLE/NODE_EXTRA_CA_CERTS/GIT_SSL_CAINFO). Only children inside the jail ever see or trust it.
  • Upstream (proxy -> real server) uses the SYSTEM trust store by default, so the real server's real certificate is still validated end to end.

No JCA provider is registered globally: bcpkix's Jca* builders use the default platform signer (SHA256withRSA via SunRsaSign), which keeps this native-image friendly and side-effect free.

Ephemeral CA + per-host leaf minting for the egress proxy's TLS-terminating
(MITM) tier — the piece that gives a jailed shell child GET-not-POST fidelity
over HTTPS, matching what the interpreter method-guard already does pre-TLS.

Why it exists: a plain CONNECT proxy only sees `CONNECT host:443` — the method
and path live inside the TLS the proxy never opens, so HTTPS verb/path is
opaque. To read them the proxy must TERMINATE the child's TLS: present the
child a leaf cert for the requested host, decrypt, inspect method+path, then
re-encrypt to the real upstream (whose real cert the proxy still validates).

Trust model:
  - The CA is EPHEMERAL and per-session — born in this JVM, never written to
    the host trust store. Its cert PEM is written to a temp file whose path is
    injected into the jailed child's trust env (`CURL_CA_BUNDLE`/`SSL_CERT_FILE`
    /`REQUESTS_CA_BUNDLE`/`NODE_EXTRA_CA_CERTS`/`GIT_SSL_CAINFO`). Only children
    inside the jail ever see or trust it.
  - Upstream (proxy -> real server) uses the SYSTEM trust store by default, so
    the real server's real certificate is still validated end to end.

No JCA provider is registered globally: bcpkix's Jca* builders use the default
platform signer (SHA256withRSA via SunRsaSign), which keeps this native-image
friendly and side-effect free.
raw docstring

com.blockether.vis.internal.session.cancellation

Cancellation token - leaf module.

The cancellation token is a tiny two-atom record that lets a UI thread (TUI, REPL caller) cooperatively abort an in-flight turn! AND interrupt the worker future hosting the blocking provider call. The cooperative side is checked at every iteration boundary; the future side hard-cancels any HTTP call that has already started.

Public API:

(cancellation-token) - fresh token (cancellation-atom token) - cooperative flag atom (pass to turn!) (cancellation-set-future! token fut) - register the worker future (cancel! token) - set flag + interrupt registered future (cancel! token reason) - same, stamping WHO cancelled (cancel-reason token) - the recorded origin of the cancel (cancelled? token) - true once cancel! has been called (cancellation? throwable) - true if exception was caused by cancel! (preserve-interrupt! t) - re-arm the interrupt flag a catch-all ate

This namespace has zero side effects at load time and depends only on Java interop - channels and the runtime can require it directly without pulling in the rest of the SDK.

Cancellation token - leaf module.

The cancellation token is a tiny two-atom record that lets a UI
thread (TUI, REPL caller) cooperatively abort an
in-flight `turn!` AND interrupt the worker future hosting the
blocking provider call. The cooperative side is checked at every
iteration boundary; the future side hard-cancels any HTTP call
that has already started.

Public API:

  `(cancellation-token)`       - fresh token
  `(cancellation-atom token)`  - cooperative flag atom (pass to `turn!`)
  `(cancellation-set-future! token fut)` - register the worker future
  `(cancel! token)`            - set flag + interrupt registered future
  `(cancel! token reason)`     - same, stamping WHO cancelled
  `(cancel-reason token)`      - the recorded origin of the cancel
  `(cancelled? token)`         - true once `cancel!` has been called
  `(cancellation? throwable)`  - true if exception was caused by `cancel!`
  `(preserve-interrupt! t)`    - re-arm the interrupt flag a catch-all ate

This namespace has zero side effects at load time and depends only
on Java interop - channels and the runtime can require it
directly without pulling in the rest of the SDK.
raw docstring

com.blockether.vis.internal.session.goals

Explicit, durable session goals. Only the slash/SDK user path creates or replaces an objective. The model can mark that exact objective complete or blocked, but cannot resume, replace, cancel or increase its budget. Every mutation is a CAS on the persisted revision; the lifecycle version also rejects stale model work. Completion is a model declaration supported by evidence, not an independent judge.

Explicit, durable session goals. Only the slash/SDK user path creates or replaces
an objective. The model can mark that exact objective complete or blocked, but
cannot resume, replace, cancel or increase its budget. Every mutation is a CAS
on the persisted revision; the lifecycle version also rejects stale model work.
Completion is a model declaration supported by evidence, not an independent judge.
raw docstring

com.blockether.vis.internal.session.iteration

Canonical iteration-entry shape — the single source of truth shared by the LIVE progress tracker (internal/progress) and the RESUME projection (channel-tui/chat).

Background: the live-vs-resume split was the root cause of every TUI regression. The live tracker accumulated chunks into one map shape; the resume path rebuilt a different map shape from persisted rows. This ns pins ONE shape both paths populate.

Vocabulary

form envelope — one block = one form record (the engine :forms BLOB), carrying :code, :stdout, :error, and its settled Activity snapshot. Printed stdout is the single success surface; op cards / render-fns are gone.

Canonical iteration-entry

{:position n ;; 0-based display position of the iteration :scope "tN/iM" ;; BLOCK-level scope, never /fK :thinking string-or-nil ;; reasoning text for this iteration :code "<block source>" :forms [<form> ...] ;; form envelopes (engine :forms BLOB) :status :ok|:error|:running|:cancelled|:timeout :duration-ms long :error error-map-or-nil}

Canonical iteration-entry shape — the single source of truth shared by
the LIVE progress tracker (`internal/progress`) and the RESUME projection
(`channel-tui/chat`).

Background: the live-vs-resume split was the root cause of every TUI
regression. The live tracker accumulated chunks into one map shape; the
resume path rebuilt a *different* map shape from persisted rows. This ns
pins ONE shape both paths populate.

## Vocabulary

  form envelope — one block = one form record (the engine `:forms`
                  BLOB), carrying `:code`, `:stdout`, `:error`, and its
                  settled Activity snapshot. Printed stdout is the single
                  success surface; op cards / render-fns are gone.

## Canonical iteration-entry

  {:position    n              ;; 0-based display position of the iteration
   :scope       "tN/iM"        ;; BLOCK-level scope, never /fK
   :thinking    string-or-nil  ;; reasoning text for this iteration
   :code        "<block source>"
   :forms       [<form> ...]   ;; form envelopes (engine :forms BLOB)
   :status      :ok|:error|:running|:cancelled|:timeout
   :duration-ms long
   :error       error-map-or-nil}
raw docstring

com.blockether.vis.internal.session.model

Persistent, channel-NEUTRAL per-session model preference.

ONE source of truth — session_soul.llm_pref_provider + llm_pref_model in the DB — for every channel (web gateway + TUI), so a session routes through the same PROVIDER + MODEL wherever it's opened and the choice survives restarts. Provider + model (not just a model name) mirrors how a turn records its route and disambiguates a model name shared by >1 provider.

The engine reads it at turn start (prepare-turn-context in loop.clj) as the default route when the caller passes none; router-for-model hoists the chosen model (the provider follows, since it's the one carrying that model).

DEBOUNCED WRITE-BACK: set-model! updates an in-memory value IMMEDIATELY (footer + engine see it at once) and coalesces the DB write, so cycling the model (TUI Ctrl+T) many times in a row produces a SINGLE write. Reads prefer the pending in-memory value, falling back to the DB.

Values are {:provider <id-string-or-nil> :model <name>} or nil. Keyed by the session-soul id (the gateway's sid and the engine env's :session-id).

Persistent, channel-NEUTRAL per-session model preference.

ONE source of truth — `session_soul.llm_pref_provider` + `llm_pref_model`
in the DB — for every channel (web gateway + TUI), so a session routes
through the same PROVIDER + MODEL wherever it's opened and the choice
survives restarts. Provider + model (not just a model name) mirrors how a
turn records its route and disambiguates a model name shared by >1 provider.

The engine reads it at turn start (`prepare-turn-context` in loop.clj) as
the default route when the caller passes none; `router-for-model` hoists the
chosen model (the provider follows, since it's the one carrying that model).

DEBOUNCED WRITE-BACK: `set-model!` updates an in-memory value IMMEDIATELY
(footer + engine see it at once) and coalesces the DB write, so cycling the
model (TUI Ctrl+T) many times in a row produces a SINGLE write. Reads prefer
the pending in-memory value, falling back to the DB.

Values are `{:provider <id-string-or-nil> :model <name>}` or nil. Keyed by
the session-soul id (the gateway's `sid` and the engine env's `:session-id`).
raw docstring

com.blockether.vis.internal.session.progress

Streaming progress tracker - leaf module.

Channels (TUI, CLI agent) consume the iteration loop's PHASED chunks via this tracker. Every chunk carries a :phase keyword that tells the tracker what to do with it; the tracker accumulates the chunks into a per-iteration timeline that the channel re-renders incrementally.

Phases (every chunk has exactly one):

:reasoning LLM is streaming reasoning text. Updates the iteration entry's :thinking field.

:tool-preview LLM is streaming the block's code. Carries the cumulative source separately from reasoning/content; the first real form replaces this ephemeral slot.

:form-start One block is about to evaluate. Carries :position and :code. The tracker writes the code immediately so channels can show the currently-running block before the result lands.

:form-result One block finished evaluating. Carries :position, :code, :stdout/:error, and :envelope timestamps. The tracker writes the completed form record into :forms at the chunk's display index. Chunks tagged :silent? keep their flag so channels can toggle visibility.

:form-activity One block's bounded Activity snapshot — what it has DONE beside what it will return. Running and settled revisions are full REPLACEMENTS into the form slot :position names, so a dropped running frame costs a repaint and never a wrong picture. :iteration-final Iteration is complete. Carries :final (nil when the turn isn't done yet) and :done? (true when this iteration produced the turn-terminal answer). The block chunk has already streamed; this is the trim "iteration done" marker.

:iteration-error Iteration aborted before forms could run (e.g. LLM call failed). Carries :thinking and :error.

:provider-retry-reset Provider stream failed before code eval and Vis is retrying the provider call. Clears stale live reasoning/content for this attempt and keeps a retry recap in :provider-fallbacks.

Public API:

(make-progress-tracker) - fresh tracker, no callback (make-progress-tracker {:on-update}) - invokes (on-update timeline chunk) on every chunk

Returns {:on-chunk fn :get-timeline fn}. Pass the :on-chunk fn under :hooks {:on-chunk ...} of sessions/send!. Each timeline entry has the shape:

{:iteration N :thinking str-or-nil :forms [{:code str :comment str-or-nil :render-segments [{:kind ...} ...] ;; source classification :stdout str-or-nil ;; canonical printed output :result-kind :tool|:value|:error :result-detail map-or-nil ;; tool metadata :error map-or-nil :duration-ms int :success? bool :silent? bool :started-at-ms int-or-nil :activity map-or-nil ;; bounded tool-call trace} ...] :provider-fallbacks [map ...] ;; routed provider fallback notices :activity nil-or-keyword ;; live coarse phase (:provider-call/:response-parse) :elided-form-idxs #{int ...} ;; original loop indices hidden from :forms :error nil-or-iteration-error :final nil-or-{:answer :iteration-count :status} :done? bool}

The pre-existing :events interleaving log was removed: it lived only in memory (never persisted), and resumed bubbles re-render from this single flat layout. One layout path is enough.

Streaming progress tracker - leaf module.

Channels (TUI, CLI agent) consume the iteration loop's
PHASED chunks via this tracker. Every chunk carries a `:phase`
keyword that tells the tracker what to do with it; the tracker
accumulates the chunks into a per-iteration timeline that the
channel re-renders incrementally.

Phases (every chunk has exactly one):

  :reasoning        LLM is streaming reasoning text. Updates the
                    iteration entry's `:thinking` field.

  :tool-preview     LLM is streaming the block's code. Carries the
                    cumulative source separately from reasoning/content;
                    the first real form replaces this ephemeral slot.

  :form-start       One block is about to evaluate. Carries
                    `:position` and `:code`. The tracker writes the
                    code immediately so channels can show the
                    currently-running block before the result lands.

  :form-result      One block finished evaluating. Carries
                    `:position`, `:code`, `:stdout`/`:error`,
                    and `:envelope` timestamps. The tracker writes
                    the completed form record into `:forms` at the
                    chunk's display index. Chunks tagged `:silent?`
                    keep their flag so channels can toggle visibility.

  :form-activity    One block's bounded Activity snapshot — what it has DONE
                    beside what it will return. Running and settled revisions are
                    full REPLACEMENTS into the form slot `:position` names, so a
                    dropped running frame costs a repaint and never a wrong picture.
  :iteration-final  Iteration is complete. Carries `:final` (nil
                    when the turn isn't done yet) and `:done?`
                    (true when this iteration produced the
                    turn-terminal answer). The block chunk has
                    already streamed; this is the trim
                    "iteration done" marker.

  :iteration-error  Iteration aborted before forms could run
                    (e.g. LLM call failed). Carries `:thinking`
                    and `:error`.

  :provider-retry-reset
                    Provider stream failed before code eval and Vis is
                    retrying the provider call. Clears stale live
                    reasoning/content for this attempt and keeps a retry
                    recap in `:provider-fallbacks`.

Public API:

  `(make-progress-tracker)`              - fresh tracker, no callback
  `(make-progress-tracker {:on-update})` - invokes `(on-update timeline chunk)`
                                           on every chunk

Returns `{:on-chunk fn :get-timeline fn}`. Pass the `:on-chunk` fn
under `:hooks {:on-chunk ...}` of `sessions/send!`. Each timeline
entry has the shape:

  {:iteration N
   :thinking  str-or-nil
   :forms     [{:code            str
                :comment         str-or-nil
                :render-segments [{:kind ...} ...] ;; source classification
               :stdout          str-or-nil        ;; canonical printed output
               :result-kind     :tool|:value|:error
                :result-detail   map-or-nil        ;; tool metadata
                :error           map-or-nil
                :duration-ms     int
                :success?        bool
                :silent?         bool
                 :started-at-ms   int-or-nil
                 :activity        map-or-nil        ;; bounded tool-call trace} ...]
   :provider-fallbacks [map ...]   ;; routed provider fallback notices
   :activity           nil-or-keyword ;; live coarse phase (:provider-call/:response-parse)
   :elided-form-idxs   #{int ...}  ;; original loop indices hidden from :forms
   :error              nil-or-iteration-error
   :final              nil-or-{:answer :iteration-count :status}
   :done?              bool}

The pre-existing `:events` interleaving log was removed: it lived
only in memory (never persisted), and resumed bubbles re-render
from this single flat layout. One layout path is enough.
raw docstring

com.blockether.vis.internal.session.titling

Session-title subsystem, lifted out of the loop namespace: the three listener registries (per-session value, global, and the pending/spinner channel), the single set-title-with-broadcast! mutation point, and the async auto-title side-channel (an off-surface ask! that names a session on its first real turn). A LEAF — depends only on persistance + svar + runtime-settings, never back on the loop.

Session-title subsystem, lifted out of the loop namespace: the three listener
registries (per-session value, global, and the pending/spinner channel), the
single `set-title-with-broadcast!` mutation point, and the async auto-title
side-channel (an off-surface `ask!` that names a session on its first
real turn). A LEAF — depends only on persistance + svar + runtime-settings,
never back on the loop.
raw docstring

com.blockether.vis.internal.speech.assets

Where a speech asset comes from, what it is licensed under, and how it lands on disk.

resources/vis-models/manifest.edn is the only answer to "may we ship this": every model and data directory Vis installs has an entry there with an SPDX id, an attribution line and :is-commercial-ok true. An artifact that is not in the manifest cannot be downloaded, because nothing else carries a URL.

Each entry lists its sources in preference order and every source delivers the SAME bytes, so one :sha256 verifies them all:

:hf Hugging Face. SKIPPED unless a token is configured, tried FIRST when one is. A token is never required — it only changes WHERE the same bytes come from. :pack the Vis VOICE_ASSETS_PACK release, the default, so a first run needs no account anywhere. :upstream the project that published the artifact, last, as a fallback.

Sources are tried in that order and the first that installs cleanly wins, so an asset host being down is a slower install rather than a dead feature.

Where a speech asset comes from, what it is licensed under, and how it lands on
disk.

`resources/vis-models/manifest.edn` is the only answer to "may we ship
this": every model and data directory Vis installs has an entry there with an
SPDX id, an attribution line and `:is-commercial-ok true`. An artifact that is
not in the manifest cannot be downloaded, because nothing else carries a URL.

Each entry lists its sources in preference order and every source delivers the
SAME bytes, so one `:sha256` verifies them all:

  :hf        Hugging Face. SKIPPED unless a token is configured, tried FIRST
             when one is. A token is never required — it only changes WHERE
             the same bytes come from.
  :pack      the Vis VOICE_ASSETS_PACK release, the default, so a first run
             needs no account anywhere.
  :upstream  the project that published the artifact, last, as a fallback.

Sources are tried in that order and the first that installs cleanly wins, so
an asset host being down is a slower install rather than a dead feature.
raw docstring

com.blockether.vis.internal.speech.attribution

THIRD_PARTY_MODELS.md is nobody's prose: it is resources/vis-models/manifest.edn rendered, so the credits a reader checks and the sources the installer obeys can never say different things.

markdown is the whole file. assets-test fails when the copy in the tree drifts from it, and vis-agent speech models licenses --markdown reprints it.

`THIRD_PARTY_MODELS.md` is nobody's prose: it is
`resources/vis-models/manifest.edn` rendered, so the credits a reader checks
and the sources the installer obeys can never say different things.

`markdown` is the whole file. `assets-test` fails when the copy in the tree
drifts from it, and `vis-agent speech models licenses --markdown`
reprints it.
raw docstring

com.blockether.vis.internal.speech.cli

Commands for the gateway-owned speech subsystem. This namespace never loads a model or Sherpa: every runtime operation crosses the canonical gateway client.

Commands for the gateway-owned speech subsystem. This namespace never loads a
model or Sherpa: every runtime operation crosses the canonical gateway client.
raw docstring

com.blockether.vis.internal.speech.engine

The built-in local Parakeet transcription engine used by the gateway.

The built-in local Parakeet transcription engine used by the gateway.
raw docstring

com.blockether.vis.internal.speech.files

Streaming download, checksum and archive helpers for the gateway's speech assets.

Streaming download, checksum and archive helpers for the gateway's speech assets.
raw docstring

com.blockether.vis.internal.speech.sherpa

sherpa-onnx's native libraries, for THIS platform and no other.

sherpa publishes one native jar per platform, each 8-13 MB and each carrying BOTH libsherpa-onnx-jni and the exact libonnxruntime it was linked against, side by side under sherpa-onnx/native/<platform>/. Depending on all five in deps.edn makes every machine download 51 MB to use one of them, so core depends on the 187 KB API jar alone and the pair arrives here, one of three ways:

  • Already loadablesherpa_onnx.native.path names a directory holding both libraries. This is sherpa's own first loading method, so it is also the seam for a self-built native (an espeak-free one, say) and nothing is downloaded or checked out from under the user.
  • Embedded — the libraries are classpath resources. That is the native image, where build.clj puts the BUILD HOST's native jar on the image classpath and -H:IncludeResources bakes that one directory in, and any JVM run that puts a sherpa-onnx-native-lib-* jar on the classpath.
  • Downloaded — the host platform's jar is fetched once from the same JitPack coordinate deps.edn pins, unpacked into ~/.vis, and handed to sherpa through sherpa_onnx.native.path.

No digest is pinned for that download: JitPack rebuilds a tag when its cache evicts, so a pinned digest would eventually break every user rather than catch anything. Integrity comes from the transfer being length-checked (files/download!), from the install being atomic, and from the loaded library having to answer version — which sherpa-native-test asserts.

;; JNI and not java.lang.foreign, because the choice is upstream's: the library ;; Vis ships, libsherpa-onnx-jni, exports 133 Java_* entry points and not one ;; SherpaOnnx* C symbol, so a Panama downcall has nothing to bind to. sherpa's C ;; API is a separate artifact — per-platform tarballs under no Maven coordinate — ;; whose 156 functions over 86 structs would leave us owning their layouts. Vis ;; does use FFM where it owns the boundary (internal/foundation/pty); here the ;; image registers the API jar's types for JNI instead ;; (reachability-metadata.json, pinned by sherpa-test).

sherpa-onnx's native libraries, for THIS platform and no other.

sherpa publishes one native jar per platform, each 8-13 MB and each carrying
BOTH `libsherpa-onnx-jni` and the exact `libonnxruntime` it was linked
against, side by side under `sherpa-onnx/native/<platform>/`. Depending on
all five in `deps.edn` makes every machine download 51 MB to use one of them,
so core depends on the 187 KB API jar alone and the pair arrives
here, one of three ways:

- **Already loadable** — `sherpa_onnx.native.path` names a directory holding
  both libraries. This is sherpa's own first loading method, so it is also
  the seam for a self-built native (an espeak-free one, say) and nothing is
  downloaded or checked out from under the user.
- **Embedded** — the libraries are classpath resources. That is the native
  image, where `build.clj` puts the BUILD HOST's native jar on the image
  classpath and `-H:IncludeResources` bakes that one directory in, and any
  JVM run that puts a `sherpa-onnx-native-lib-*` jar on the classpath.
- **Downloaded** — the host platform's jar is fetched once from the same
  JitPack coordinate `deps.edn` pins, unpacked into `~/.vis`, and handed to
  sherpa through `sherpa_onnx.native.path`.

No digest is pinned for that download: JitPack rebuilds a tag when its cache
evicts, so a pinned digest would eventually break every user rather than
catch anything. Integrity comes from the transfer being length-checked
(`files/download!`), from the install being atomic, and from the loaded
library having to answer `version` — which `sherpa-native-test` asserts.

;; JNI and not `java.lang.foreign`, because the choice is upstream's: the library
;; Vis ships, `libsherpa-onnx-jni`, exports 133 `Java_*` entry points and not one
;; `SherpaOnnx*` C symbol, so a Panama downcall has nothing to bind to. sherpa's C
;; API is a separate artifact — per-platform tarballs under no Maven coordinate —
;; whose 156 functions over 86 structs would leave us owning their layouts. Vis
;; does use FFM where it owns the boundary (`internal/foundation/pty`); here the
;; image registers the API jar's types for JNI instead
;; (`reachability-metadata.json`, pinned by `sherpa-test`).
raw docstring

com.blockether.vis.internal.speech.synthesis

The gateway's built-in local Piper and pocket-tts speaking engines.

The gateway's built-in local Piper and pocket-tts speaking engines.
raw docstring

com.blockether.vis.internal.speech.transcode

Whatever somebody recorded, as the 16-bit PCM WAV every local model reads.

A recorder never asks what the model wants. An iPhone memo is .m4a (AAC in an MP4 box), an Android one .amr, .aac or .m4a, a browser records .ogg or .webm, a shared clip arrives as .mp3, and sherpa-onnx reads exactly ONE container: RIFF/WAVE, 16-bit PCM. So every path that turns audio into text meets the SAME question — voice INPUT in the TUI, a recording ATTACHED to a message, a clip IMPORTED as a voice — and it is answered once, here.

Vis ships no decoder of its own: AAC, Opus and AMR are a codec suite rather than a namespace, and a wrong decoder is silence that transcribes into confident words. ffmpeg is the one converter a machine reliably has, so a non-WAV recording goes through it into a TEMP mono 16 kHz file that is deleted the moment the work is done.

Whatever somebody recorded, as the 16-bit PCM WAV every local model reads.

A recorder never asks what the model wants. An iPhone memo is `.m4a` (AAC in an
MP4 box), an Android one `.amr`, `.aac` or `.m4a`, a browser records `.ogg` or
`.webm`, a shared clip arrives as `.mp3`, and sherpa-onnx reads exactly ONE
container: RIFF/WAVE, 16-bit PCM. So every path that turns audio into text meets
the SAME question — voice INPUT in the TUI, a recording ATTACHED to a message,
a clip IMPORTED as a voice — and it is answered once, here.

Vis ships no decoder of its own: AAC, Opus and AMR are a codec suite rather than
a namespace, and a wrong decoder is silence that transcribes into confident
words. `ffmpeg` is the one converter a machine reliably has, so a non-WAV
recording goes through it
into a TEMP mono 16 kHz file that is deleted the moment the work is done.
raw docstring

com.blockether.vis.internal.speech.tts

Local speech synthesis through sherpa-onnx.

Two families, one shape: make the assets present, build one OfflineTts, generate. They differ only in what a VOICE is.

:piper a voice IS a model — one VITS network per speaker, phonemized through eSpeak NG tables. Vis uses a system copy when present or the verified tables carried by a downloaded publisher model archive. :pocket-tts a voice is a reference CLIP the model clones, so the catalogue is a WAV per voice: the clips the bundle ships and the ones somebody imported through voices.clj. Vis exports those weights itself, so they arrive with every other model.

Which assets exist, where they come from and what they are licensed under is assets.clj's question; this namespace only asks for them by id.

Local speech synthesis through sherpa-onnx.

Two families, one shape: make the assets present, build one `OfflineTts`,
generate. They differ only in what a VOICE is.

  :piper       a voice IS a model — one VITS network per speaker, phonemized
               through eSpeak NG tables. Vis uses a system copy when present or the
               verified tables carried by a downloaded publisher model archive.
  :pocket-tts  a voice is a reference CLIP the model clones, so the catalogue
               is a WAV per voice: the clips the bundle ships and the ones
                somebody imported through `voices.clj`. Vis exports those
                weights itself, so they arrive with every other model.

Which assets exist, where they come from and what they are licensed under is
`assets.clj`'s question; this namespace only asks for them by id.
raw docstring

com.blockether.vis.internal.speech.voices

Reference clips somebody brought: the voices Vis did not ship.

pocket-tts clones a RECORDING instead of selecting a baked speaker, so a voice in that engine IS a WAV file - which means a voice is something a person can make and hand to Vis, and no licence anywhere can stop them. This namespace is where such a clip lives: checked and normalized once on the way in, listed beside the clips the bundle carries, and readable by id from every surface (CLI, gateway, app).

Imported clips deliberately do NOT live under the models directory. An asset is something Vis can fetch again; a recording is the user's own, and clearing a model cache must never take somebody's voice with it.

Reference clips somebody brought: the voices Vis did not ship.

pocket-tts clones a RECORDING instead of selecting a baked speaker, so a
voice in that engine IS a WAV file - which means a voice is something a
person can make and hand to Vis, and no licence anywhere can stop them. This
namespace is where such a clip lives: checked and normalized once on the way
in, listed beside the clips the bundle carries, and readable by id from every
surface (CLI, gateway, app).

Imported clips deliberately do NOT live under the models directory. An asset
is something Vis can fetch again; a recording is the user's own, and clearing
a model cache must never take somebody's voice with it.
raw docstring

com.blockether.vis.internal.system-trust

Runtime TLS trust merged from a launcher-resolved PEM certificate bundle.

The native image and JVM both retain their configured default roots. A PEM bundle discovered by bin/vis-agent is an additional trust source, which is required on WSL because Windows and the Linux distribution have independent certificate stores.

Runtime TLS trust merged from a launcher-resolved PEM certificate bundle.

The native image and JVM both retain their configured default roots. A PEM
bundle discovered by `bin/vis-agent` is an additional trust source, which is
required on WSL because Windows and the Linux distribution have independent
certificate stores.
raw docstring

com.blockether.vis.internal.util

The engine's one shared leaf: the primitives every namespace kept re-rolling — a millisecond clock, the two blank-string idioms, a trimmed environment read, UTF-8 bytes, SHA-256 and the hex fold.

It requires NOTHING from the rest of vis and never will. That is the whole contract: a leaf can be required from anywhere — specs that load during namespace initialization, the gateway, the sandbox — without a cycle to reason about. Everything here is a pure function of its arguments (or of one process-wide reading), so nothing here may become a top-level value: a def that CALLS one of these freezes the BUILDER's answer into the native image (native-image-env-capture-test is the gate).

A name earns a place here when a THIRD namespace needs it. A helper with one caller belongs beside its caller, not in this file.

The engine's one shared leaf: the primitives every namespace kept re-rolling —
a millisecond clock, the two blank-string idioms, a trimmed environment read,
UTF-8 bytes, SHA-256 and the hex fold.

It requires NOTHING from the rest of vis and never will. That is the whole
contract: a leaf can be required from anywhere — specs that load during
namespace initialization, the gateway, the sandbox — without a cycle to
reason about. Everything here is a pure function of its arguments (or of one
process-wide reading), so nothing here may become a top-level value: a `def`
that CALLS one of these freezes the BUILDER's answer into the native image
(`native-image-env-capture-test` is the gate).

A name earns a place here when a THIRD namespace needs it. A helper with one
caller belongs beside its caller, not in this file.
raw docstring

com.blockether.vis.internal.view.core

The one lifecycle for every operator-facing View.

A View has a CLOSED semantic document, a stable id, and the same open, patch and close rail on every channel. Its :kind declares the capability policy:

  • :input is Human Input — a typed form that BLOCKS until a submit or cancel action, timeout, or interruption;
  • :live is a non-blocking picture driven by its producer and optionally ended by an interrupt action.

The distinction belongs in policy, not transport. Both kinds share the pending registry, publish :view/open / :view/patch / :view/close envelopes, and receive operator intent through action!. Renderers dispatch on :kind and never infer behavior from an event name or resource path.

This namespace PARSES extension data against the CLOSED vocabulary declared by [[com.blockether.vis.contract.view]]. Input answers are coerced and checked once at the settle seam; live patches are normalized and materialized once before any surface sees them. Unknown keys are refused, while every declared key is preserved through normalization.

Secrets never travel as plaintext. A :password and an :otp field resolve to an opaque vis-secret:<uuid> handle; plaintext stays in a process-local vault and is readable only through reveal-secret from the trusted extension side.

The one lifecycle for every operator-facing View.

A View has a CLOSED semantic document, a stable id, and the same `open`, `patch`
and `close` rail on every channel. Its `:kind` declares the capability policy:

- `:input` is Human Input — a typed form that BLOCKS until a `submit` or
  `cancel` action, timeout, or interruption;
- `:live` is a non-blocking picture driven by its producer and optionally ended
  by an `interrupt` action.

The distinction belongs in policy, not transport. Both kinds share the pending
registry, publish `:view/open` / `:view/patch` / `:view/close` envelopes, and
receive operator intent through [[action!]]. Renderers dispatch on `:kind` and
never infer behavior from an event name or resource path.

This namespace PARSES extension data against the CLOSED vocabulary declared by
[[com.blockether.vis.contract.view]]. Input answers are coerced and checked
once at the settle seam; live patches are normalized and materialized once before
any surface sees them. Unknown keys are refused, while every declared key is
preserved through normalization.

Secrets never travel as plaintext. A `:password` and an `:otp` field resolve to
an opaque `vis-secret:<uuid>` handle; plaintext stays in a process-local vault
and is readable only through [[reveal-secret]] from the trusted extension side.
raw docstring

com.blockether.vis.internal.view.materializer

What a live view IS after patch N — and what the MODEL reads when it ends.

A form is answered once, so nobody has to own its state; a live view is a stream of operations against nodes addressed by id, so somebody does. apply-patch is that owner: pure, total, and the ONE place the surfaces read from. The terminal pane, the companion screen and the model all paint the same materialized map, so none of them can disagree about a row.

picture is the MODEL's surface: the finished view as DATA. Ids and tones come with it, so a node the model read is a node it can patch, and a state is never recovered from a sentence — the model acts on the same materialized map the surfaces paint, budgeted, never on a description of it.

->markdown is the DOCUMENT that state renders into: what a human reopens, what an artifact stores, what a transcript embeds. Markdown is a RENDERING, never a fourth vocabulary: every node type has exactly one markdown form here, so a view that reads well in the terminal already reads well on a page. Colour is the only thing that cannot cross — where a surface paints red, the page reads a [tone] token.

parse-markdown is that document read BACK. One markdown form per node type in one direction is one form per node type in the other, so a whole view can be AUTHORED as markdown and a rendered one re-read. What a budget left behind is named, never guessed.

Nothing here evicts the RECORD. Bounds on keyed collections are REFUSALS (spec/item-bounds) naming the bound and the node, a log's :window-lines is only how much a surface holds hot, and the model's own budget always says how many lines it left behind.

What a live view IS after patch N — and what the MODEL reads when it ends.

A form is answered once, so nobody has to own its state; a live view is a
stream of operations against nodes addressed by id, so somebody does.
[[apply-patch]] is that owner: pure, total, and the ONE place the surfaces
read from. The terminal pane, the companion screen and the model all paint
the same materialized map, so none of them can disagree about a row.

[[picture]] is the MODEL's surface: the finished view as DATA. Ids and tones
come with it, so a node the model read is a node it can patch, and a state is
never recovered from a sentence — the model acts on the same materialized map
the surfaces paint, budgeted, never on a description of it.

[[->markdown]] is the DOCUMENT that state renders into: what a human reopens,
what an artifact stores, what a transcript embeds. Markdown is a RENDERING,
never a fourth vocabulary: every node type has exactly one markdown form here,
so a view that reads well in the terminal already reads well on a page. Colour
is the only thing that cannot cross — where a surface paints red, the page
reads a `[tone]` token.

[[parse-markdown]] is that document read BACK. One markdown form per node type
in one direction is one form per node type in the other, so a whole view can be
AUTHORED as markdown and a rendered one re-read. What a budget left behind is
named, never guessed.

Nothing here evicts the RECORD. Bounds on keyed collections are REFUSALS
(`spec/item-bounds`) naming the bound and the node, a log's `:window-lines`
is only how much a surface holds hot, and the model's own budget always says
how many lines it left behind.
raw docstring

com.blockether.vis.internal.view.sink

The STORE OF RECORD of one live view: an append-only NDJSON file per view.

A live view is a STREAM, and nothing else in this process keeps one. The channel bus is a ring of 2000 events and the session journal is truncated per turn and past 16 MB, so a log that ran for twenty minutes would already be gone by the time the human scrolls back to its beginning. Here every line the engine ACCEPTED is on disk, in the order it accepted them.

One file, three kinds of line: the OPEN line is the view as it was declared, one PATCH line per accepted patch, and the CLOSE line is the verdict. A patch is appended BEFORE it is published, so a crash keeps everything the engine accepted rather than everything a surface managed to paint, and the file is opened in APPEND mode, so a resumed process never truncates a view it did not open.

Lines are wire JSON, which is what lets the file BE the artifact instead of a re-encoded copy of one, and what lets a reader hand a patch line straight back to view/normalize-patch: every live vocabulary is a closed table read in either spelling.

The STORE OF RECORD of one live view: an append-only NDJSON file per view.

A live view is a STREAM, and nothing else in this process keeps one. The
channel bus is a ring of 2000 events and the session journal is truncated per
turn and past 16 MB, so a log that ran for twenty minutes would already be
gone by the time the human scrolls back to its beginning. Here every line the
engine ACCEPTED is on disk, in the order it accepted them.

One file, three kinds of line: the OPEN line is the view as it was declared,
one PATCH line per accepted patch, and the CLOSE line is the verdict. A patch
is appended BEFORE it is published, so a crash keeps everything the engine
accepted rather than everything a surface managed to paint, and the file is
opened in APPEND mode, so a resumed process never truncates a view it did not
open.

Lines are wire JSON, which is what lets the file BE the artifact instead of a
re-encoded copy of one, and what lets a reader hand a patch line straight back
to `view/normalize-patch`: every live vocabulary is a closed table read in
either spelling.
raw docstring

com.blockether.vis.internal.view.validation

Field validation for input View requests: a validator is a FUNCTION.

A field carries :validate — one function or a list of them. Each one is called with the COERCED value, or with the value and the whole coerced field id -> value map when it asks for a second argument, and answers:

  • nil or true — the value is fine.
  • a string — that string IS the field's error message.
  • false — "is not valid", the message of last resort. Say something better.
  • a throw — "could not be validated: …", because swallowing it would ACCEPT the value the validator was written to refuse.

Validators run in the order they were written and the FIRST message wins, so a field's :validate list reads like a checklist.

Validation is code, not data, and it never crosses the wire. The ENGINE is the only authority: it runs every validator when a form is CONFIRMED (com.blockether.vis.internal.view.core/submit!) and hands the surfaces the errors it found, keyed by field. Neither the TUI band nor the companion app owns a validation rule or re-checks anything as the operator types — they show the engine's verdict, and drop a field's error the moment that field is touched again, so nobody is scolded mid-word about a value they are still typing.

A validator NEVER fires on a blank value. Emptiness is :is-required's single job; a check that also rejected "" would quietly make every optional field mandatory.

Field validation for input View requests: a validator is a FUNCTION.

A field carries `:validate` — one function or a list of them. Each one is
called with the COERCED value, or with the value and the whole coerced
`field id -> value` map when it asks for a second argument, and answers:

- `nil` or `true` — the value is fine.
- a string — that string IS the field's error message.
- `false` — "is not valid", the message of last resort. Say something better.
- a throw — "could not be validated: …", because swallowing it would ACCEPT
  the value the validator was written to refuse.

Validators run in the order they were written and the FIRST message wins, so
a field's `:validate` list reads like a checklist.

Validation is code, not data, and it never crosses the wire. The ENGINE is
the only authority: it runs every validator when a form is CONFIRMED
([[com.blockether.vis.internal.view.core/submit!]]) and hands the surfaces
the errors it found, keyed by field. Neither the TUI band nor the companion
app owns a validation rule or re-checks anything as the operator types —
they show the engine's verdict, and drop a field's error the moment that
field is touched again, so nobody is scolded mid-word about a value they are
still typing.

A validator NEVER fires on a blank value. Emptiness is `:is-required`'s
single job; a check that also rejected "" would quietly make every optional
field mandatory.
raw docstring

com.blockether.vis.internal.workspace.core

Backend-neutral workspaces, DB-pinned to session_state 1:1.

The user's real cwd is trunk — Vis never mutates it, and Vis no longer requires it to be a git repo. A session works in trunk by default. Isolation operations are backend primitives reserved for engine-managed work. Vis ships two draft backends: a linked Git worktree (git worktree add on a fresh vis/<label> branch, sharing the repository's objects and refs) and a Rift copy-on-write clone for projects without Git history. draft-backend-for selects between them under the draft_backend toggle; the capability matrix is a diagnostics/feature-discovery surface.

'What changed since the fork' is computed git-free: clonefile preserves source mtimes, so files the agent touches in the clone get a fresh mtime greater than the fork timestamp we capture at clone time. apply! lands exactly those files back into cwd, uncommitted; the workspace.drafts/approve! instead commits the draft and merges it into the repository's local default branch.

Vis never mutates JVM user.dir. Channels rebind workspace-root per turn from the active workspace; tools resolve paths via (workspace/cwd). There is NO process-cwd fallback in production - the env carries :workspace/root from create-environment onward.

Backend-neutral workspaces, DB-pinned to session_state 1:1.

The user's real cwd is *trunk* — Vis never mutates it, and Vis no
longer requires it to be a git repo. A session works in trunk by default.
Isolation operations are backend primitives reserved for engine-managed work.
Vis ships two draft backends: a linked Git worktree (`git worktree add` on a
fresh `vis/<label>` branch, sharing the repository's objects and refs) and a
Rift copy-on-write clone for projects without Git history. `draft-backend-for`
selects between them under the `draft_backend` toggle; the capability matrix is
a diagnostics/feature-discovery surface.

'What changed since the fork' is computed git-free: `clonefile`
preserves source mtimes, so files the agent touches in the clone get
a fresh mtime greater than the fork timestamp we capture at clone
time. `apply!` lands exactly those files back into cwd, uncommitted; the
`workspace.drafts/approve!` instead commits the draft and merges it into the
repository's local default branch.

Vis never mutates JVM user.dir. Channels rebind *workspace-root* per
turn from the active workspace; tools resolve paths via
(workspace/cwd). There is NO process-cwd fallback in production -
the env carries `:workspace/root` from `create-environment` onward.
raw docstring

com.blockether.vis.internal.workspace.drafts

Draft lifecycle above the workspace primitives: create, approve, discard and status. Each mutation crosses the extension op-hook boundary (:draft/create, :draft/approve, :draft/discard), so an extension can veto it with a :before guard or observe it with an :after hook.

Approval requires the draft to contain the local and fetched origin target. It fast-forwards locally, restores saved local work, then pushes to origin when configured. Conflicts must be resolved in the draft before approval. Every new commit crosses the :git/commit boundary.

Draft lifecycle above the workspace primitives: create, approve, discard and
status. Each mutation crosses the extension op-hook boundary (`:draft/create`,
`:draft/approve`, `:draft/discard`), so an extension can veto it with a
`:before` guard or observe it with an `:after` hook.

Approval requires the draft to contain the local and fetched origin target.
It fast-forwards locally, restores saved local work, then pushes to origin
when configured. Conflicts must be resolved in the draft before approval.
Every new commit crosses the `:git/commit` boundary.
raw docstring

com.blockether.vis.internal.workspace.fff-index

THE canonical way vis talks to fff.

Every fff instance in this process is born here and lives in ONE pool keyed by [canonical-root respect-ignore-files? ignore-overlay]. Nothing else may call fff/create — a second, unpooled instance would duplicate a whole tree's native path+content index, spin its own watcher threads, and go stale on our own writes.

Contract for callers:

(fff-index/with-index [idx (fff-index/lease root respect-ignore-files? overlay)] (fff/search idx …) (fff/grep idx …))

  • the index is watcher-live (:watch? true) and resynced before the body runs when this process wrote anything since it was last synced,
  • the body must NOT close idx; the pool owns it (LRU + idle TTL),
  • every filesystem mutation this process performs must call note-fs-write! so the next search reads its own writes.
THE canonical way vis talks to fff.

Every fff instance in this process is born here and lives in ONE pool keyed
by `[canonical-root respect-ignore-files? ignore-overlay]`. Nothing else may call
`fff/create` — a second, unpooled instance would duplicate a whole tree's
native path+content index, spin its own watcher threads, and go stale on our
own writes.

Contract for callers:

  (fff-index/with-index [idx (fff-index/lease root respect-ignore-files? overlay)]
    (fff/search idx …) (fff/grep idx …))

- the index is watcher-live (`:watch? true`) and resynced before the body
  runs when this process wrote anything since it was last synced,
- the body must NOT close `idx`; the pool owns it (LRU + idle TTL),
- every filesystem mutation this process performs must call `note-fs-write!`
  so the next search reads its own writes.
raw docstring

com.blockether.vis.internal.workspace.git

Shared workspace inspection, backed by the native git binary.

UI surfaces and extensions depend on this namespace instead of embedding their own git calls. It reports small, renderable facts about the current repository (branch, dirty buckets, ahead/behind, porcelain entries) by shelling out to git status --porcelain=v2 and git rev-parse, parsed into stable Clojure maps. No JGit — the only git implementation is the one already on the user's PATH, so behaviour matches their shell exactly.

Workspace lifecycle mutations stay in com.blockether.vis.internal.workspace.core.

Shared workspace inspection, backed by the native `git` binary.

UI surfaces and extensions depend on this namespace instead of embedding
their own git calls. It reports small, renderable facts about the current
repository (branch, dirty buckets, ahead/behind, porcelain entries) by
shelling out to `git status --porcelain=v2` and `git rev-parse`, parsed
into stable Clojure maps. No JGit — the only git implementation is the one
already on the user's PATH, so behaviour matches their shell exactly.

Workspace lifecycle mutations stay in
`com.blockether.vis.internal.workspace.core`.
raw docstring

com.blockether.vis.internal.workspace.gitignore

Minimal, pure-Clojure .gitignore matcher — the JGit-free replacement for IgnoreNode. Parses ONE .gitignore file (the one at a walk root) into ordered rules and evaluates a /-separated relative path against them with git's semantics: last matching rule wins, ! negates, a trailing / restricts to directories, a leading/embedded / anchors to the root, and a slash-free pattern matches at any depth.

Minimal, pure-Clojure `.gitignore` matcher — the JGit-free replacement for
`IgnoreNode`. Parses ONE `.gitignore` file (the one at a walk root) into
ordered rules and evaluates a `/`-separated relative path against them with
git's semantics: last matching rule wins, `!` negates, a trailing `/`
restricts to directories, a leading/embedded `/` anchors to the root, and a
slash-free pattern matches at any depth.
raw docstring

com.blockether.vis.view

Builders for the two things an extension shows the operator: the typed pause it WAITS on — com.blockether.vis.core/request-human-input! — and the live view it does not, com.blockether.vis.core/with-live-view!.

A request is plain data, and it stays plain data: every builder here returns the very map you could have typed by hand. What it adds is that the two things a hand-typed map gets wrong cannot happen. The node TYPE is the function you called, so :type "plaintxt" is a compile-time unresolved symbol instead of a refused request at run time; and the node is VALIDATED the moment it is built, by the engine's own com.blockether.vis.internal.view.core/normalize-node seam, so a bad :default, an unknown key or a :select with no options throws at the line that built it rather than in front of the human.

(require '[com.blockether.vis.core :as vis]
         '[com.blockether.vis.view :as view])

(vis/request-human-input!
  (view/form {:title "Deploy" :description "Where this build lands."}
             (view/heading "Target")
             (view/paragraph "Staging pages nobody.")
             (view/row (view/select "env" ["staging" "prod"] {:label "Environment"
                                                               :is-required true})
                       (view/slider "canary" {:label "Canary %" :min 0 :max 100 :step 5}))
             (view/password "token" {:label "Deploy token" :is-required true})))

Three node contracts, exactly as the engine sees them: a FIELD holds one answer and is keyed by its name, a GROUP (row / column) only arranges the nodes below it, and a DECORATION (heading / paragraph) is ink — no name, never focusable, never in the answer map.

Every optional key is the one the engine documents, in either spelling (:is-required or "is_required"): builders pass options through untouched instead of keeping a second copy of the vocabulary.

A LIVE VIEW is the same discipline one :kind further: view and the node builders under it declare a picture the human WATCHES while the work runs. It asks nothing and parks no thread; it is patched by node id and ends in a verdict carrying the markdown the model reads.

(vis/with-live-view!
  (view/view {:title "CI"}
             (view/status "now" "Polling GitHub…" {:tone "running"})
             (view/table "jobs" [(view/table-column "job" "Job")
                                 (view/table-column "took" "Took" {:align "right"})]))
  (fn [view-id]
    (vis/patch-live-view!
      view-id
      [{:op "set" :node-id "now" :text "18 jobs" :tone "ok"}
       {:op "append" :node-id "jobs"
        :rows [(view/table-row "build" ["tests / ubuntu" "13m0s"] {:tone "ok"})]}])))

Python extensions get the SAME names on the vis module — vis.select('env', ['staging', 'prod'], label='Environment') — built by the same engine seam across the JSON boundary.

Builders for the two things an extension shows the operator: the typed pause
it WAITS on — `com.blockether.vis.core/request-human-input!` — and the live
view it does not, `com.blockether.vis.core/with-live-view!`.

A request is plain data, and it stays plain data: every builder here returns
the very map you could have typed by hand. What it adds is that the two
things a hand-typed map gets wrong cannot happen. The node TYPE is the
function you called, so `:type "plaintxt"` is a compile-time unresolved
symbol instead of a refused request at run time; and the node is VALIDATED
the moment it is built, by the engine's own [[com.blockether.vis.internal.view.core/normalize-node]]
seam, so a bad `:default`, an unknown key or a `:select` with no options
throws at the line that built it rather than in front of the human.

    (require '[com.blockether.vis.core :as vis]
             '[com.blockether.vis.view :as view])

    (vis/request-human-input!
      (view/form {:title "Deploy" :description "Where this build lands."}
                 (view/heading "Target")
                 (view/paragraph "Staging pages nobody.")
                 (view/row (view/select "env" ["staging" "prod"] {:label "Environment"
                                                                   :is-required true})
                           (view/slider "canary" {:label "Canary %" :min 0 :max 100 :step 5}))
                 (view/password "token" {:label "Deploy token" :is-required true})))

Three node contracts, exactly as the engine sees them: a FIELD holds one
answer and is keyed by its name, a GROUP ([[row]] / [[column]]) only arranges
the nodes below it, and a DECORATION ([[heading]] / [[paragraph]]) is ink —
no name, never focusable, never in the answer map.

Every optional key is the one the engine documents, in either spelling
(`:is-required` or `"is_required"`): builders pass options through
untouched instead of keeping a second copy of the vocabulary.

A LIVE VIEW is the same discipline one `:kind` further: [[view]] and the node
builders under it declare a picture the human WATCHES while the work runs. It
asks nothing and parks no thread; it is patched by node id and ends in a
verdict carrying the markdown the model reads.

    (vis/with-live-view!
      (view/view {:title "CI"}
                 (view/status "now" "Polling GitHub…" {:tone "running"})
                 (view/table "jobs" [(view/table-column "job" "Job")
                                     (view/table-column "took" "Took" {:align "right"})]))
      (fn [view-id]
        (vis/patch-live-view!
          view-id
          [{:op "set" :node-id "now" :text "18 jobs" :tone "ok"}
           {:op "append" :node-id "jobs"
            :rows [(view/table-row "build" ["tests / ubuntu" "13m0s"] {:tone "ok"})]}])))

Python extensions get the SAME names on the `vis` module —
`vis.select('env', ['staging', 'prod'], label='Environment')` — built by
the same engine seam across the JSON boundary.
raw docstring

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close