All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
C-x g status buffer for EVERY repository a session works on: the project, every Git
repository nested below it (a mega-repo's repositories/ clones) and every read-write
repository declared in vis.yml's workspace.filesystem catalog. Each earns a header carrying
its branch and dirty counts — a clean one folded to that single line — and every verb (s/u,
S/U, c, P, F, b, z) acts on the repository under the cursor. Nothing caps how many
are shown; only DISCOVERY is bounded, and a walk that stops early says scan truncated in the
title instead of quietly listing fewer.jail.environment — one MODE, not a list, over the operator's ambient environment in a
confined child: declared (the default: only the project's own .env + environment: plus a
non-secret basics allowlist) or inherit (the whole ambient environment, secrets included,
with filesystem, network, exec and Mach confinement untouched). The escape hatch for a
toolchain that needs a pile of host variables instead of naming each one. LD_*, DYLD_*,
PERL*, BASH_ENV and friends are refused under inherit too — that scrub protects the
jail's own installation, not the child.Trim makes that
region the picture — cut at the ORIGINAL resolution, with any strokes flattened into it — so
the pen, Copy, Share and Use edit all act on the detail. Undo trim brings the whole picture
back.The Companion's Notifications panel answers ONE question: is this device connected to this
machine. It used to list every push token the gateway holds — one iPhone reinstalled three times
stood in it four times, under four masked tokens and two APNs environments — while the only state
a reader wants was left to the verb printed on a button. One row now states it (Connected /
Not connected, naming the machine in the sentence, Checking… before the first answer) and one
Switch is both verbs, in the same place either way; native APNs/FCM and Web Push ask it with the
same row. A permission turned off in the OS is never reported as connected, and on iOS the blocked
state offers the door to system Settings.
Copying a session id — the TUI header chip and the Companion chip beside the title — now puts
vis_session_id#<uuid> on the clipboard instead of a bare UUID. The marker says WHAT the id
addresses, so whoever it is pasted to recognises a Vis session, and read_session /
get_session accept the marked form verbatim (the introspection prompt names the format).
The Companion composer has ONE microphone. Tap it and it acts in the mode you are in; press and
hold it for 450ms and the mode flips — dictation writes into the box, voice conversation sends
what you said and reads the answer aloud. The disclosure beside it, the two-item mode menu, the
rule that welded them and the separate leave button are gone: the mode was always one piece of
state, and holding is now both the way in and the way out (the exit runs the full teardown —
lease, queued utterance, speech, an in-flight recording and the audio route). Voice conversation
wears its own drawn mark and the accent square instead of a V badge, the accessible name spells
the gesture out in both modes, and a pointer that cannot hold gets the same switch from a
right-click or Shift+Enter. Entering the conversation now only ARMS it; the next tap is what
starts talking.
The Vis JVM heap ceiling is an explicit 5 GiB instead of a share of host RAM. -XX:MaxRAMPercentage=75.0
scaled with the machine — ~27 GiB on a 48 GB host — so a gateway running for hours sat at ~5 GB resident
with a 3.2 GiB live set and shrank nothing: the tight free ratios beside it only uncommit once the heap
looks full, and the engine's memory-pressure gates (heap watermark 85%, heap budget 2 GB, RSS budget
3 GB) were either unreachable or shedding idle sessions that were not what held the memory. -Xmx5g is
the same ceiling on every machine, so the periodic concurrent GC has something to give back and the
pressure gates sit at a meaningful fraction of the cap.
One Android release lands on EVERY Play tester track. release:android:store wrote a single
--track, so the channels drifted apart — internal served 0.1.21 (2861) while beta already
served 0.1.35 (4075), and lining them up again was a second, manual promote afterwards.
--track now takes a LIST (comma-separated, or the flag repeated) and defaults to
internal,alpha,beta, all assigned inside ONE transactional Play edit: either every track gets
the build or none does, so no channel can be left a version behind. production is never
implied, and a staged --rollout — which Play defines per track — refuses more than one track
BEFORE the build rather than after a signed .aab.
One iOS release reaches EVERY TestFlight audience, the same rule Android just got. An upload
reaches only the internal groups by itself, and the public-link group had to be asked for with
--public — so the public link served build 4042 while the team group already had 4075 and
every Play tester track served 4090. release:ios:store now fans out by default (internal
groups PLUS the public group after Beta App Review) and --audience internal keeps a build
inside the team; the audience is planned BEFORE the archive, so an unknown one costs a second
instead of a signed .ipa. The product release workflow no longer narrows Android to beta
alone either: a vX.Y.Z tag ships one build to every tester channel of both stores.
all — every tester channel each STORE has, not the list this repo happens to know. The
fan-out was still three track names frozen in the release script and repeated in two workflow
files, so a closed testing track created in the Play Console could never be released to
(--track qa was refused as unknown) and an internal TestFlight group created WITHOUT "access
to all builds" — the one kind Apple does not hand new builds to — was skipped on every run.
--track all now asks Play which tracks this listing HAS and writes them all in the one edit
(production still never implied, a typo still refused against the real names, before the
build), --audience all links every external group and every internal group that needs the
build assigned, and both GitHub workflows pass all — so a channel added in a console is
served by the next release with no change to this repository.
run_tests selects by paths and nothing else, in every language. The ns / namespace /
namespaces selector is gone: name a test file, a directory, or the SOURCE file whose *-test
namespace should run, and the clojure pack does that translation itself (a *_test.clj file is
read for the namespace it declares, a source file maps to its *-test namespace, a directory is
walked for both). Two vocabularies for one selection meant the same run could be named two ways
and only one of them reached a pack; the old spellings are refused by name rather than silently
running the WHOLE suite.
A paths entry may name ONE TEST: <path>::<test-name> — pytest's own node-id grammar, now the
single way to say it in every pack. The name half translates the way the file half already did
(src/a/core.clj::adds runs adds-test in com.example.core-test, just as core.clj runs
core-test), a namespace-less ::adds-test finds that var wherever it lives, and each id is
paired with its OWN file instead of cross-producting names over namespaces. Clojure's only and
bun's filter are deleted — a second key that narrowed a run could disagree with the path beside
it, and only one pack understood each spelling. Python passes node ids straight to pytest and
turns a pathless ::name into -k; the hermetic GraalPy backend runs whole files, so it REFUSES
a node id (naming {"environment": "project"}) instead of quietly running the rest of the file.
Every language pack now emits the run_tests result vocabulary ITSELF — pass, fail,
errored, command, is_pass. The surface's translation table (passed/failed -> pass/
fail, ok -> is_pass, an argv cmd -> command) is deleted: it guessed each runner's
arithmetic from outside the pack that knew it (pytest's failed and errors are DISJOINT,
lazytest's are not), and a fact reached a result under two spellings. Python's runner /
interpreter call aliases are gone too: a CALL says environment, config says python.runner.
The session introspection surface reads as VERBS instead of storage nouns: session_state is
now read_session, sessions is list_sessions, session_fold is fold_session, and the
read that was missing between them is get_session(target) — ONE descriptor row (identity,
turn count, last activity, provider/model, the last turn) so asking what a session IS no longer
costs a whole transcript. session_state was the name of a DB TABLE, and a bare plural noun is
not a verb. list_sessions drops the channel filter for search=, which is the SAME ranked
answer the TUI and the companion app paint: the server ranks title (0), request (1), reply (2)
and thinking (3), and each matched row carries rank,
is_in_title/is_in_request/is_in_reply/is_in_thinking plus the request/reply snippet
windows. There are no aliases — the old names are gone.
Android voice capture now opens a connected Bluetooth headset's HFP/SCO microphone before WebView starts recording, then restores the normal audio route when recording ends.
Voice-conversation playback on Android now uses the system text-to-speech engine when WebView does not expose the Web Speech API.
Voice conversation requests the spoken projection for an idle session as well as a queued one; previously the usual idle path returned only the full on-screen answer.
Prepare vis-foundation-bridge for Bridge 0.3: migrate the project profile and policy to the
reduced YAML schema, recognize every 0.3 profile filename, preserve exact validation paths, and
keep malformed Bridge configuration from crashing ordinary filesystem access.
The native release build is verifiable without spending a tag. Native Release dispatched
from a BRANCH is now a dry run — it builds the image, stages the bundle and runs every smoke
test and the native suite, and attaches nothing; only a v* ref publishes. It used to refuse
a non-tag dispatch outright, so the only way to learn whether a release would build was to cut
the tag and watch.
The macOS arm64 release build runs on GITHUB's hosted runner and on nobody's laptop. The
self-hosted Apple-silicon runner is deregistered, its launchd service uninstalled and its
working directory deleted; the macos job of .github/workflows/native-release.yml now
defaults to the free hosted macos-26 — free and unlimited on public repositories, because
only LARGER runners are billed and this workflow uses none. That builder is 3 cores / 7 GiB
against a ~13.7 GiB points-to live set, so every heap that fits in RAM is a known OOM: the job
pins -J-Xmx14g -J-Xms2g -J-XX:+UseParallelGC and lets macOS dynamic swap back it, and its
first step prints cores, RAM, free disk and swap so a failure is a NUMBER on the log. The
repository variable VIS_MACOS_ARM64_RUNNER survives only as an OPTIONAL override naming a
bigger CLOUD Apple-silicon label; by hand it is still bin/release-native --tag vX.Y.Z --upload on any 32 GB+ Mac. The job also caches dependencies and smoke-tests the gateway,
because a hosted runner is ephemeral.
A shell result has NO stderr field (issue #137). Every command runs under a real pty, where
stdout and stderr are physically ONE stream, so stderr could only ever answer nil — a
caller reading it to diagnose a failure got nothing while the message sat in stdout. The
internal blocking runner merges the two streams the same way, so one shape and one reading of
"the bytes" hold everywhere; stderr_omitted_chars and the card's STDERR section are gone with
it.
The agent prompt and the shell docstrings spell the keystroke method sh.type("y"), never a
bare sh.type() (issue #137): it SENDS text and its argument is required, so the old spelling
among the status accessors raised a TypeError for anyone who followed it.
The process-jail doc page is jail.md ("Process jail & egress"), not sandbox.md: in this
repo sandbox now names only the in-process GraalPy sandbox, and jail names OS confinement —
the same split the config keys and session["access"] already use. Every in-tree link moved
with it.
grep and struct_nodes take ONE options map and nothing else — grep({"query": q, "paths": ["src"]}), struct_nodes({"path": p, "line": n}) — the same shape struct_index and
struct_patch already had, and the shape Python kwargs (grep(query=q, paths=["src"])) fold
into. The positional forms are gone: grep("q"), grep("q", {opts}) and struct_nodes("p")
made the SECOND argument mean options, so the obvious grep(["a", "b"], ["src", "tools"]) —
needles, then scopes — failed on argument shape instead of searching. The refusal now names the
one canonical call.
/<name> NAMES a skill instead of pasting it. The expansion is one sentence — use this skill,
read it with doc("name") unless its SKILL.md is already in the conversation — plus your
task, the owning project of a nested skill, and the paths of its bundled resources. Whether the
instructions still have to be fetched is the model's call, since only the model can see whether
that text is still in front of it. Every skill surface is now stateless: two /<name>s expand
identically and nothing is recorded between them.
The workspace's .env / .env.local are now loaded BY DEFAULT, whole, with nothing declared,
and reach every child Vis spawns — shell(...) (jailed or not), managed REPLs, test runners and
Python extensions. Resolution order everywhere is environment: declaration, then .env, then
the environment that started Vis. environment: is now only for what a dotenv file cannot say:
a rename, a keychain item, a helper command, or re-admitting an ambient variable to a confined
child.
The jail no longer withholds the project's .env from a confined child. The child was granted
the workspace and can read that file itself, so dropping the values confined nothing; what
jail.enabled draws a line around is the OPERATOR's ambient environment, which is still
deny-by-default. LD_*, DYLD_*, PERL*, BASH_ENV and friends are refused from a project
.env exactly as they are from a declaration.
jail is the ONLY word for confinement, in config and in the model's own session map.
The read-only session["access"] view reports is_jailed instead of sandboxed, and
session["workspace"] reports isolated (a backend workspace copy, which was never about
confinement) instead of sandbox. "Sandbox" now means only the Python sandbox.
sandbox: and filesystem: config keys. Both were silently REWRITTEN into
jail: before the schema saw the file, so an operator's key became a different one and a
sandbox: false written next to a jail: block was quietly ignored. Write jail.enabled and
jail.filesystem — anything else is refused by name, loudly, on load.jail.env. It was a second list of the same names that could only ever re-admit an AMBIENT
variable — never a dotenv:/keychain:/command: value — so the two blocks disagreed exactly
where it mattered. Declare the variable in environment: instead (CI: {env: CI} is the old
jail.env: [CI]). Everything undeclared is still dropped for a confined child, and LD_*,
DYLD_*, PERL*, BASH_ENV and friends are still refused even when declared.extensions.env-passthrough, a third list of the same names: extensions was never a valid
top-level config key, so the block was rejected before anything could read it.skill verb. A skill is a document like any other: apropos(text) finds it, doc("name")
prints the whole SKILL.md, and reading it is the whole of using it. There is no activation, no
status/scope/note receipt, no idempotent re-read and no fold protection for an "active"
body. Skill DISCOVERY is unchanged — every skill is still listed in the prompt by name,
description and owning project.git tool. There is no model-facing Git schema, no git binding in the sandbox and no
foundation-git extension: a Git command is an ordinary shell command, run by the same
jail, capture and timeout as everything else. Workspace Git FACTS (footer status, environment
block, file picker) and the TUI Magit surface are unchanged.$VIS_OUTBOX no longer exists in the sandbox, a file the
sandbox writes into system temp is no longer harvested, and neither is a write_file that
lands in /tmp — the session, the transcript and the Companion stop filling with scratch,
build chips and half-finished files nobody asked for. attach is how an artifact is kept:
a producer that wants a human to SEE something names it and hands over the bytes. Writing to
temp still WORKS everywhere it did; it is simply not collected. The machinery stays in the
tree, dormant and tested behind mpl-capture/incidental-capture-enabled?, in case a future
feature wants an engine-owned capture directory again.patch and cat refused the anchor they had just printed. Every addressable line these tools
render is <line>:<hash>│ <text>, but the anchor parser read EVERYTHING after the colon as the
hash — the gutter and the line's own text included — so a row pasted back whole hashed to
5af│ /**, matched no line in the file, and came back as no line within 40 lines carries 5af│ /** with a "current anchor" identical to the one just refused (its text lower-cased on the
way out, since the hash is folded case-insensitively). The gutter and everything behind it are
now cut before parsing, so a cat line, a grep hit row (indent and all) or a struct_index
anchor addresses a line exactly as printed. A REPLACEMENT carrying a gutter is unchanged: it is
still written verbatim, with the note that says so.
Sandbox HTTPS could not skip certificate verification, and the escape hatch was deleted behind
the caller's back. requests.get(url, verify=False), Session.verify, cert=, httpx's
verify=/cert= and urllib3's cert_reqs / ca_certs / cert_file / assert_hostname /
ssl_context were all accepted and thrown away — every request used urlopen's default verified
context, so an expired, self-signed or internal-CA host was unreachable from a block — while a
top-level import ssl (and select / selectors) was silently DELETED from the source by the
import preprocessor, so even the stdlib workaround died on an unexplainable NameError. Those
options now build the TLS context for the request, warning with urllib3's own
InsecureRequestWarning (which urllib3.disable_warnings() really silences); only asyncio is
still rewritten, and every other stdlib import reaches the block verbatim.
Sandbox TLS options were honoured only in their most common spelling, and every other one
failed QUIETLY. A pathlib.Path CA bundle fell through the shim's str check and restored the
DEFAULT store, so the narrow bundle a caller pinned silently became the wide one; a missing
bundle or client certificate surfaced as a bare FileNotFoundError from inside ssl instead of
requests' own message naming the file; REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE were never read;
urllib3 reported a certificate failure as a bare ProtocolError, so
except urllib3.exceptions.SSLError never fired, and dressed an unreadable CA path as a
transport error; cert_reqs="NONE" — upstream's own bare spelling — and an unknown name both
verified anyway; and assert_fingerprint, ciphers and the TLS version bounds were swallowed
whole, reporting a guarantee nothing enforced. Paths are now any str / bytes / os.PathLike,
the environment's bundle is read unless Session.trust_env is off, a certificate failure is
urllib3.exceptions.SSLError, a configuration error is raised verbatim,
ssl_minimum_version / ssl_maximum_version reach the context,
urllib3.util.ssl_.create_urllib3_context is published, and the two options this transport
cannot honour are REFUSED with a message instead of ignored.
A committed vis.yml forced one developer's provider and model on every clone. The visible
project file merges LAST, over ~/.vis, so default_provider, default_model,
fallback_provider and fallback_model written there silently replaced each teammate's own
selection — a teammate without that entitlement got a broken session on first run, and
validation reported no problem at all. Those four keys are now dropped from
<project>/vis.yml (and vis.yaml) with one warning naming the file and the right home.
They still decide routing in every file a person owns: ~/.vis/config.yml, the
machine-written ~/.vis/state.yml, and the gitignored <project>/.vis/config.yml overlay.
The native binary crashed the moment the TUI painted. Lanterna's TTYDeviceControl builds its
termios/ioctl MethodHandles in a class initializer that ran in the image BUILDER, where
java.lang.foreign works — so the binary inherited SUPPORTED = true and handles with no
downcall stubs behind them, and segfaulted inside DowncallStubsHolder on the first
open("/dev/tty") (v0.1.33-v0.1.35, x64 and arm64). The class now initializes at RUN time, so
the binary decides for itself: the termios fast path where the descriptors are registered, and
lanterna's own fallback to forking /bin/stty where they are not.
The native binary could not open its own terminal UI or touch its database. The TUI's screen
and chat, the sqlite backend's core and voice's input are reached BY NAME
(requiring-resolve) on first use, so nothing required them at discovery and the image never
build-time initialized them — a native image cannot define classes at run time, so vis died
with Could not locate …channel_tui/screen__init.class and any DB command with Backend :sqlite … failed to load, while every JVM test stayed green. An extension manifest now
declares such a namespace under :image-nses: compiled INTO the image, still not required at
startup, so discovery keeps paying nothing for Lanterna or JDBC. native-reachability-test
now fails when a by-name namespace is undeclared.
The Android release preflight asks the question the build asks. It probed
/usr/libexec/java_home -v 21, which only reports JDKs registered under
/Library/Java/JavaVirtualMachines and is blind to SDKMAN — so a machine whose only
stock Temurin 21 lives in SDKMAN was declared unfit for the Play leg, even though
release:android:store searches SDKMAN itself and builds there happily.
apps/vis-companion/scripts/jdk.mjs now owns the rule (exactly 21, never GraalVM,
whose jlink breaks AGP's JdkImageTransform) and the search; android-release.mjs
imports it instead of carrying its own copy, and node scripts/jdk.mjs prints the JDK
Gradle will really use — the preflight and the build can no longer disagree.
The pty bridge is tested against a REAL pseudo-terminal. pty_bridge_test drove a hand-written
{:add-listener :send} stand-in, which could only prove that serve! called two functions —
never that a byte typed into the socket reaches a terminal and comes back. It now spawns cat
under pty/spawn! (the exact handle production hands the bridge) and asserts the whole loop:
replay, live tee, and typed input echoed back out of the master.
The native binary starts again. build.clj kept its own copy of the built-in extension
namespaces vis requires at runtime, and every built-in added since — foundation.introspection
and the whole shim family — was missing from it, so the image never initialized them and the
binary died on its first line with "Could not locate …introspection__init.class on classpath".
The copy is complete, and a reachability test now fails when the two lists drift.
The Linux native binaries ship again. Every release since v0.1.33 built them and then died in
the TUI smoke test with a SIGSEGV inside the generated FFM stub for lanterna's
open("/dev/tty", …), so v0.1.33, v0.1.34 and v0.1.35 attached no Linux distribution at all.
The build feature no longer registers those downcall descriptors on Linux, which leaves
lanterna's own catch Throwable to mark the native TTY unsupported and drive the terminal by
forking /bin/stty — exactly what shipped through v0.1.32. macOS keeps the fast path.
Push capability named the wrong provider. Web Push MINTS its own VAPID identity the first time
it is asked, so its half is "configured" on every gateway — and it sat AHEAD of the relay in
/v1/capabilities, hiding the one provider a machine with no credentials at all actually
delivers through. The relay is named first now, Web Push last; a browser device also takes the
real Web Push path instead of being reported as an unsupported platform.
The providers router-rebuild hook held the FUNCTION, and defonce skips its body on a
(require … :reload) — so after any reload of the agent loop the hook still pointed at the
definition from the first load, and a default-model change reached a stale router. It holds the
VAR now.
On Linux, pasta's own diagnostics ("No routable interface for IPv6: IPv6 is disabled") were read
back as the jailed command's output: pasta is the argv PREFIX, so it writes to the child's stdio.
It now runs --quiet (which drops only the informational half) with --log-file pointing at
this process' own ~/.vis/logs.
A cancel that landed inside a best-effort (catch Throwable _ …) is no longer
swallowed. The JVM clears the interrupt flag as it throws, so every catch-all
around a blocking call — git, the workspace's git, rewind, the credential
helper, stty, the jail's detacher probe, the RSS sampler, the shell's tree
teardown, the gateway client's port poll, the MCP listen thread, gh auth token, the Copilot keychain read — answered its fallback value with the
cancellation gone, and the turn polled on to its own deadline.
cancellation/preserve-interrupt! re-arms the flag (never for a
CancellationException, which interrupted nothing), and the MCP listen thread
now ENDS on an interrupt instead of sleeping through it.
The prompt's Clojure run_tests note said the opposite of what the runner does. It claimed the
managed REPL does "NOT reload namespaces automatically" and told a session to reload every
changed test namespace, while the runner already (require … :reload)s (or load-files)
every namespace it RUNS — and never their dependencies. Proven at runtime: a poisoned Var in a
test namespace came back restored from a run, the poisoned Var in the production namespace that
test requires did not. The prompt line, run-form's docstring and the repo guidance now name
the real trap: a changed PRODUCTION namespace keeps the Vars the reused REPL already holds.
A cancelled turn no longer keeps polling a shell wait to its own deadline. sh.wait samples the
process tree's usage on every iteration, and that sampler spawns ps and calls .waitFor inside
a best-effort catch Throwable — which caught the InterruptedException the JVM throws with the
interrupt flag already CLEARED, so a cancellation landing in that window was swallowed and the
wait ran on for up to ten minutes. The sampler now restores the flag and answers nil: the
measurement is worthless after a cancel, the cancellation is not.
The artifacts gallery no longer reads a wire key nobody sends. collectArtifacts asked each
iteration for tool_name — a field that left the wire when the 21 native tools did — so it
always answered "" and the tile's screen-reader caption silently degraded from "produced in
turn 6 by ..." to "produced in turn 6". The dead read, the SessionArtifact.tool field and the
caption branch are gone rather than re-pointed at the iteration's op: with one door, naming the
producer on every row is noise, and the turn plus the iteration it hangs off is the provenance
that is real.
capfd captures the REAL file descriptor in the sandbox pytest shim (issue #138): it used to
be a second name for capsys — a sys.stdout/sys.stderr swap — so os.write(1, ...), a
C-level write or a child process's output never came back from readouterr(). Fd 1 and fd 2 are
now redirected onto a drained pipe for the test's lifetime (no filesystem needed, so it works in
a Context granted none), the descriptor's bytes follow the stream text in the same
CaptureResult, and a tail nobody read is still replayed under the failure. capsys stays
stream-only, exactly as real pytest does.
Every model-facing document names a call the runtime accepts. run_tests("python") /
repl_eval("python") never selected the Python pack: the language surface reads the pack from
{"language": "python"} (or as the FIRST of two arguments), so a lone string was the PAYLOAD —
run_tests("python") asked the Clojure runner for a python namespace, and
repl_eval("python") evaluated python as Clojure. The token-optimization page also showed
struct_index({"path": …}) (the key is paths), a project-wide rename through
struct_patch({"paths": ["."]}) (there is no paths key — a rename batch is edits, whose
entries inherit the shared top-level keys), a JSON true where Python needs True, and the
positional struct_index(paths) / struct_nodes(nodes) spellings the one-options-map contract
refuses. A corpus test now scans every document doc/apropos can hand back for those shapes.
run_tests({"language": "python"}) NAMES the tests that failed. Both backends now return every fault in
failures / errors as {ns, test, message, file, line} — the project backend reads pytest's
own --junitxml report, the hermetic GraalPy backend maps its per-test records — where before a
run could report fail: 1 and not one node id, because pytest's summary line carries counts
only. The transcript cap in output is also cut in the MIDDLE now, keeping the session header
AND the FAILURES section, the short test summary and the counts line, behind a marker that
says how many characters it dropped; the old tail slice hid a whole FAILURES section behind a
bare ellipsis.
attach(img, 'crop.png') takes a PIL image, the way it already took a matplotlib figure. A
picture cropped or composed in the sandbox fell through to the PATH branch and died with
attach: no such file: <PIL.Image.Image ...>, so it had to be written to a temp file first.
The FILENAME chooses the encoder (shot.jpg really stores a JPEG, converting an alpha mode the
encoder cannot take; anything else is lossless PNG), and a source that is neither a path nor a
producer is now refused by SHAPE — attach: source must be a path, bytes, a PIL image or a matplotlib figure, got dict — instead of having its repr reported as a missing file.
shellwait the only difference between a run and a background shellshell (25089f16f)wait the only difference between a run and a background shell (634c0476c)shell (25089f16f)wait the only difference between a run and a background shell (634c0476c)ls native tool with a sandbox ls() helperls native tool with a sandbox ls() helper (d3db4c514)shell and fs mega-tools into named verbsshell and fs mega-tools into named verbs (5846c9dc9)match a sub-expression selector under every locatormatch a sub-expression selector under every locator (6d78ec191)human-input: an extension can pause its run and ask the operator a typed
question. vis.ask(title, fields, **options) (Clojure:
vis/request-human-input!) blocks the calling extension until a human answers,
and the request rides every channel at once — the TUI paints a form dialog, the
gateway publishes human_input.request / human_input.close as session events
and serves GET /v1/sessions/:sid/human-input plus per-request submit and
cancel actions, and the companion app renders the same fields (and gets a push
notification for the block). Field types are plaintext, password,
multiline, select, multiselect, and checkbox, each with name,
label, description, default, placeholder, max_length, and an
is_required that both dialogs and the HTTP seam enforce. Every key is a
snake_case string; a camelCase or kebab-case spelling is refused with the right
name rather than silently ignored, so a required field can never turn optional.
Cancelling or timing out never raises: it returns a falsey Answer whose
reason says which. A password answers with an opaque vis-secret: handle —
transcript, logs, and the model see only the handle, answer.reveal(name)
resolves the plaintext in-process, and vis.forget(handle) drops it.
Documented in resources/vis-docs/extending.md.
config: a workspace.filesystem entry can say WHERE it mounts. when.os
(macos, linux, wsl, windows) and when.exists: <path> gate a root on
the host, and optional: true skips a declared root whose own path is absent,
so one vis.yml serves a laptop, a workstation and CI. A gated id may stay in
jail.filesystem.allow on every machine: roots this host does not mount are
dropped before the jail is built instead of failing the config as an unknown
id. doctor — and the startup hint that reuses it — now reports every root
that did not mount as written: info for a conditional root the host skipped,
a warning for an admitted root whose path is missing. Documented in
resources/vis-docs/sandbox.md.
config: jail.mach_services opens macOS Mach lookups to a confined child.
keychain: true allows com.apple.SecurityServer, com.apple.ocspd and
com.apple.trustd.agent and grants read access to ~/Library/Keychains and
/Library/Keychains (kept out of the default search sweep) — which is what
makes security, gh auth token and git credential-osxkeychain get work
inside a Seatbelt jail; allow names any further service by global name.
Deny stays the default, and a command that fails on a lookup the jail did not
grant now carries a note naming the denial and the setting that lifts it,
instead of only the opaque Security-framework message. Documented in
resources/vis-docs/sandbox.md.
change(drafts): a draft now records HOW it was physically made, not just who
made it. rift 0.0.10-10 reports the copy mechanism it actually used
(btrfs, reflink, apfs, worktree, copy) and vis persists it as the
workspace's workspace_mechanism, so a clone on a filesystem without
copy-on-write is labelled worktree — the linked Git worktree it really is —
instead of being described by the backend's name. A backend that reports no
mechanism (or an older native library) stores NULL, and every existing draft
keeps working unchanged.
change(drafts): the draft itself now says what its fork skipped. rift 0.0.10-9
records every pruned path in the workspace marker at the clone root, and
deleted-paths reads that record instead of mirroring the backend's filter
rules, which vis had to keep in sync by hand and twice failed to (a tracked
dist, a committed .yarn/cache). Same release keeps a git-TRACKED artifact
directory in the clone, so a draft's git status matches its source's. The
mirrored name lists are gone; a clone with no marker falls back to trunk's own
ignore rules, so older drafts behave exactly as before.
fix(native-image): the native binary no longer aborts with "Cannot reflectively
invoke constructor 'public java.math.BigInteger(java.lang.String)'". Loading any
YAML document that contains a plain integer runs
yamlstar.numbers/parse-safe-integer, i.e. clojure.core/bigint on the raw
scalar STRING, and that ends in an untyped (BigInteger. x) — a
clojure.lang.Reflector call the image had no metadata for, so vis doctor,
vis sessions list, vis providers status and one-shot prompts died on startup
in every workspace but the vis repository root. vis's own
reachability-metadata.json now registers BigInteger(String) plus the
BigInteger(String,int) that clojure.tools.reader uses for integer literals,
and com.blockether.vis.native-reachability-test pins both.
fix(drafts): /draft apply no longer deletes the trees the fork never copied.
Since rift 0.0.10-8 a clone is gitignore-aware, so every ignored path (and every
regenerable artifact directory such as dist, build, coverage, or a
virtualenv) is missing from the draft by construction — deleted-paths read that
absence as an agent deletion and apply! erased those files from the user's real
repository. Trunk paths the backend cannot have cloned are now excluded from the
deletion diff, and resources/vis-docs/drafts.md documents what a fork copies.
fix(drafts): a draft's own generated output is no longer reported as an agent
change. changed-paths now prunes whatever the CLONE's repository ignores and
does not track: a gitignore-aware fork never copies those trees, so an ignored
file inside a draft was built there — a regenerated native project alone pushed
one session to 8,426 changed files, all of which /draft apply would have
dumped into the real repository. Force-added ignored files stay tracked and
still land.
fix(drafts): /draft apply no longer deletes a committed .yarn/cache. The fork
drops the .yarn/<artifact> pairs (cache, unplugged, install-state.gz,
build-state.yml) that a Yarn zero-install repository commits, and the deletion
guard matched single directory names only, so every file under a tracked
.yarn/cache read as an agent deletion. The guard now mirrors the pair rule;
.yarn/patches and .yarn/releases keep reporting real deletions.
draft policy (shared, copy-only,
copy-and-apply, not-allowed) in resources/vis-docs/configuration.md,
resources/vis-docs/sandbox.md, and resources/vis-docs/drafts.md, replacing
the stale claim that filesystem roots are not draft-specific.n and until actually bound in shell waituntil for wait and never report a dead process as runningn and until actually bound in shell wait (a0680bbb7)until for wait and never report a dead process as running (4d01e8db2)ios/
build) is pruned instead of copy-on-write cloned file by file. Forking this
repository drops from ~3.8s to ~0.7s; force-added paths and .git are still
cloned, so a fresh draft's git status matches its trunkbs4 shim: BeautifulSoup(None, ...) now raises
TypeError: object of type 'NoneType' has no len() exactly like upstream bs4
4.12 instead of quietly parsing as empty markup, plus soupsieve and
tree-builder parity work cross-validated probe-by-probe against real
beautifulsoup4 4.12.3 + soupsieve 2.5.
release: the workflow's "Commit release notes" step rebases onto
origin/main and retries, so a main that moved during the run no longer
fails the release job and skips the mobile release with it.
persistence: the shared SQLite pool is no longer torn down underneath live
queries. The snapshot behind "was ~/.vis/vis.mdb/vis.db replaced under this
JVM?" compared the file's size and mtime, and SQLite rewrites vis.db in
place on every WAL checkpoint — so ordinary write traffic made the store look
replaced forever after. The gateway answered by closing its connection pool
and opening a new one, over and over: a crashed 3h21m process had reached
pool generation 351, leaked seven housekeeper threads, and died with SIGBUS
inside NativeDB.step, taking every live session with it. The check now
compares the filesystem (dev, ino) identity only, which moves exactly when
a reopen is the right answer.
cancelled when the cancel itself throws, and bind cancels to turn numbers/draft clean <label> (and the companion's "A new draft, without my
uncommitted changes") forks the project and rewinds the copy to the committed
HEAD, so a fresh draft starts from your last commit while modified tracked
files, untracked files, and staged-but-uncommitted ones stay in the real
project. The skipped paths are recorded, so applying the draft later never
deletes work it never received; a repo without a commit is refused instead of
silently forking a dirty tree.vis-agent is one coherent surface — run Vis, vis-agent runtime show|use native|jvm|dev|auto, and vis-agent update [--native|--jvm|--dev] [--rebuild] [vX.Y.Z|<ref>]. Vis follows releases by default and dev is the
only runtime that follows a moving branch.bin/install-vis-agent --runtime native|jvm installs the wrapper
and lets it acquire its own runtime, so wrapper and runtime cannot drift.--source alias, the --jar tombstone, the wrapper-owned
native/uber build commands, VIS_SOURCE_DIR, and VIS_LOCAL_BIN_DIR.bin/install-source (use install-vis-agent --runtime jvm).~/.vis/source-dir, ~/.vis/sourcecode, ~/.vis/install/mode, and
~/.vis/install/sha. Runtime state is now ~/.vis/runtime plus
~/.vis/install/{vis-agent-native,src,ref}.open(path, "w").write(text) reaches the disk. The
sandbox runs on GraalPy, which does not refcount, so a handle dropped without
close() was never finalized at the end of the statement — the bytes stayed
in the buffer and the file was EMPTY, so the next tool (git commit -F) read
nothing. Writable handles are now tracked weakly and flushed before every tool
call and at the end of the block.git add -- <paths> stages again. The --verbose the tool appends
so add reports what it staged landed AFTER the -- separator, where git
reads it as a pathspec (fatal: pathspec '--verbose' did not match any files); it is now inserted before the separator.vis-agent update --native|--jvm|--dev reached the update path
again (the launch-flag loop used to swallow them), and a [[ … ]] && cmd
tail no longer makes a successful runtime use exit 1.dev runtime names one checkout. When $VIS_DEV_CHECKOUT does
not hold one, vis-agent now says so instead of silently running whichever
checkout the launcher happens to sit in.runtime and update are listed by vis-agent --help and are owned by
the launcher, so the binary no longer advertised a second, different update
that failed on its own documented flag.vis-agent --modle gpt-5 "task" used to run with the DEFAULT model
and a polluted prompt; it now exits 2 naming the flag. A value flag left
without a value is refused the same way, --verbose/-v are consumed as
debug flags, and -- ends flag parsing for prompts that start with dashes.runtime use and JVM/dev launches report a missing runtime,
missing clojure, or missing java with the command that fixes it, instead
of exiting 127 from exec.-- ends the wrapper's own flag parsing too. vis-agent -- --dev
used to switch runtime (and -- --measure/-- --jfr used to turn on
profiling) while handing the app an empty prompt; those tokens are now prompt
text. runtime use <name> combined with --native|--jvm|--dev is refused
instead of silently dropping the flag.VIS_HOME that is a
file, a ~/.vis/runtime that is a directory, or an unwritable home reports
the path it could not write instead of claiming "runtime is now …".--, or flag-shaped value. --model ""
used to run the default model and --model --json task used to request a
model literally named --json.--json --code, --stream-json --code, …) are
refused instead of silently honouring one and dropping the other, and an
unusable --db path is named instead of surfacing a raw SQLite pool error.--help described --persist as the opposite of what it does, twice.cwd across the tool surfaceis_stateless for gateways that reject replayed item idscwd across the tool surface (4df25f19e)is_stateless for gateways that reject replayed item ids (41f87ea34)cwd across the tool surface (4df25f19e)cwd across the tool surface (4df25f19e)cwd across the tool surface (4df25f19e)br/check and br/run-evidence.:git/commit operation for the model-facing Git tool
and TUI Magit. The generic Git adapter resolves Git-global repository
options, rejects index-changing commit forms, rechecks the staged tree, and
verifies the resulting commit tree; Bridge contributes only the
lifecycle-owned approval hook.vis-foundation-bridge now targets the Bridge 0.2.2
candidate-verification API.add_doc /
replace_doc work for the 26 languages whose docs are comments, not strings.struct_patch moves no longer drop the file's trailing newline (or \r):
the structural editor now splits lines without collapsing the final empty
line, and only collapses a seam when there is one.python.source_pathsvis python -m ruff, formatted shimsvis update explain and recover from diverged history (#53)python.source_paths (f8ac1d4ec)vis python -m ruff, formatted shims (83082fdb9)vis update explain and recover from diverged history (#53) (f369fdaed)vis python -m ruff, formatted shims (83082fdb9)compatibility key, svar model limits, vis.yml model ordercompatibility key, svar model limits, vis.yml model order (1c2478678)/draft-blank under the draft tree as /draft blank <label>/draft-fresh slash command to /draft-blank (empty drafts start with no HEAD files)vis python standalone interpreter exposing GraalPy sandbox + shimsvis gateway start and refresh statusvis python standalone interpreter exposing GraalPy sandbox + shims (1afcec09)vis gateway start and refresh status (2144797e)gpt-5.6-terra) their real context window via a
:provider/enrich-models-fn hook, instead of svar's 8192 default that
rejected normal turns with "Context overflow … has 8192 context". Only fills
the gap — models svar knows keep their catalog window and explicit config
:context still wins. gpt-5.6-terra also added to the provider's
default-models.:github-copilot-enterprise). The
provider extension already shipped the enterprise base-url, provider id,
label, and account type, but only registered :individual + :business,
so Copilot Enterprise users could not select Claude Opus 4.8 / Sonnet 4.6 /
Haiku 4.5 at all. Enterprise now registers alongside the other tiers and
inherits the same curated catalog: dotted models.dev ids
(claude-opus-4.8, claude-sonnet-4.6, claude-haiku-4.5) over the native
Anthropic /v1/messages wire (never /chat/completions).:ext/nudge-fn for per-iteration system nudges from extensions:ext/requires for extension dependency declaration:ext/version, :ext/author, :ext/license metadataregister-global!, load-extension!, reload-extension!extensions/common/vis-foundation package (read, list, grep, patch):balanced (was :quick)create-env -> create-environmentdispose-env! -> dispose-environment!vis! -> query!register-env-def! removed (use extensions)loop/nudges.clj to loop/runtime/prompt.cljsession/shared.clj folded into session/core.cljvar-diff (dead code)restore-var references (never existed as callable tool)resources/docs/)[system_nudge] ("You repeated the same expression ...").
<journal> + the dedup cache (:cached? true) already give the model
enough signal to change strategy; the nudge was noise. Drops
repetition-warning, REPETITION_THRESHOLD, the call-counts-atom
plumbing, and the :call-counts-atom arg to prompt/build-iteration-context.404 page not found. The token
exchange's authoritative endpoints.api (and the account fallback hosts)
are bare roots with no /v1, so provider-token-base-url handed svar a
versionless base and Claude hit {host}/messages instead of
{host}/v1/messages. The token's LLM base is now suffixed with /v1 at
exchange time (idempotent ensure-api-version) and reused from cache, while
the model-policy call still targets the root host. Affects all Copilot tiers
(individual/business/enterprise), since every account's token endpoint
resolves to the same versionless host.github-copilot-provider-id? omitted :github-copilot-enterprise, so
enterprise models were filtered out of the visible catalog mapping.Can you improve this documentation? These fine people already did:
Karol Wojcik, blockether-deployer, Wojciech Franke & vis-agentEdit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |