Repository navigation
Ship the bashkit Plugin and give the built-in chat a shell - #211
Merged
Merged
Conversation
The design in `runvil-design/design/2026-11-11-1-markdown-and-prompt-reads.md` names every section by a section path and reads Markdown as plain blocks. This crate is the part of that design that needs no Lua: the text-to-blocks parser, the path and selector grammars, and the writer behind `markdown.render`. Nothing uses it yet. It lands first because the functions that name a section in a prompt take the same path grammar. - `parse` reads any text into `Block`s and never fails. It works in three layers under `parse`: `events` runs `pulldown-cmark` and records each top-level heading, fence, list, and rule by its byte range, `flat` turns those marks into blocks in file order with the text between them as prose, and `tree` folds them into sections. Only `events` names the library, and only `flat` sees a byte range. `frontmatter` splits the YAML mapping off first, by the prompt parser's delimiter rule, and `serde_yaml_ng` reads it. - `parse_body` is the same parse for text with no frontmatter, with lines counted from a given line, which is what a read of one section's own content needs. - `split` is the one path grammar: steps, the three escapes, the trimming, and what makes an index. `Path` is the form over parsed text. `Selector` reads a kind or `fence` and a language, and `nth` is how an index counts. - `render` writes every list of blocks and checks nothing. What can be read back is for its caller to check, which knows where each block came from. - The grammars return their message as a `String`, because each caller puts its own function's name in front before it raises the universal `Error`. So the crate depends on no workspace crate. - Where the design is silent: an index too large for an `i64` is kept as the largest one, which no match reaches; a path that ends in a backslash raises `ends in a backslash`; and `fence` followed by two words raises `a fence selector takes one language`. - The tests hold the design's worked example, each row of its edge-case table, and two properties over 74 sample texts: every non-blank line lies in exactly one block, and parsed text is written as text that parses to the same blocks.
Every function that names a section now takes a path of titles, as `runvil-design/design/2026-11-11-1-markdown-and-prompt-reads.md` specifies. `name` is a direct child, `../name` is a sibling, and `/a/b` starts at the top of the prompt and names any section. The level is not in the address, so moving a section to another depth changes no path. - `call`, `jump`, `tasks.spawn`, and `tasks.fanout` take the two relative forms, which are a section's visible set. `prompt.section` and `prompt.list` take those and a path from the top. `require` takes only a path from the top, so one library has one name on every line of a prompt. - `section_path` in `runvil-runtime` is the one lookup, with an entry for each of those three rules. It starts from an `Origin`, the H1 or a section, and the H1's children are the top-level sections. The path grammar is `runvil_markdown::split`, shared with paths over parsed text. `section_pos` holds the tree positions that `heading_address` held beside the lookup. - A path that names no section raises one error from every function, which says where the lookup looked and lists nothing: ``section `Grand` not found: `/Main` has no child `Grand` ``. A path from the top reports the step where it stopped. - `require` needs no starting point, so `RequireOrigin`, the wider reach, and the key a loading library passed to the resolver are gone. The resolver takes the path alone. - A library is reported by its path from the top: the three library-load events carry `/shared_stuff` in `library`, and the cycle error reads `require cycle: /a -> /b -> /a`. `Library::heading` is `Library::path`. - Sibling titles are unique, so the lookup has no ambiguity error. - Every prompt and test in the repository names sections by path. The tests that asserted the reach and the listed sections are replaced by tests of the three forms, of each miss message, and of a path from the top read and required from the H1, a top-level section, and a nested one. - Left for the commits that follow: a read that names no section still returns nil, so the message for a path from the top given to `call` names `require` and `prompt.section` only; a relative path in a library's code still starts at the running section; and `prompt.list` still exists. - Where the design is silent: a path from the top that holds `..` raises `uses ".."; a path from the top takes none`.
`prompt.section` returned nil for a path that names no section, so a misspelled title read as nothing and failed later, somewhere else. It now raises the not-found error that `call` raises for the same path, and `prompt.section_or` is the read that returns nil instead. A malformed path and a target that is not a string raise from both. - The read callback returns a `SectionRead`: the section's text, or the not-found error for a path that is well-formed and names nothing. `prompt.section` raises that error, and `prompt.section_or` and `prompt.list` return nil for it. - The message for a path from the top given to `call`, `jump`, or a `tasks` function now names all three functions that take one: `require`, `prompt.section`, and `prompt.section_or`. - The tests assert that `call` and `prompt.section` raise the same text for the same path from the same section, and that only `prompt.section` raises for an absence.
A relative path in a library's code named a neighbor of whichever section was using the library, so a library could not keep templates or workers of its own, and one line meant a different section for each user. It now names a neighbor of the library section, while the library loads and when its functions run later. - `require` loads each block of a library with an environment table of its own, the facility Pack preludes use. The table holds `call`, `prompt`, and `tasks` made for the library, and sends every other global read and every global write on to the VM's globals, and so through the `_G` guard. `library_env` builds it, and its metatable is protected. - `call`, `tasks.spawn`, and `tasks.fanout` are made by factories over the library's identity, and the `call` and `spawn` requests carry it as `from`. The scheduler resolves the target from the library's position when a request names one, and from the chain's current section otherwise. The read callback takes the same optional key. - A function keeps the place it was written in: a callback a section passes to a library names the section's neighbors, and a tool handler written in a library names the library's. A name passed as a string is resolved where it is used. - `jump` is not made for the library. It moves the running walk, so its path starts at the running section in a library's code too. Inside a library, `_G.prompt`, `_G.call`, and `_G.tasks` are the running section's own. - A miss in a library's code names the library as the section it searched, and a miss through `_G` names the running section. - The `prompt` table moves from `vm/install.rs` to `prompt_reads`, which builds the VM's table and each library's from one function. - 24 tests drive a section VM and 13 run whole prompts through the scheduler. In the whole prompts the library and the section using it both have a child with the same title, so a lookup from the wrong place returns the wrong text. With the environment taken out, 20 of them fail.
A prompt had no way to read the structure of Markdown it did not write: a model's reply, a stored document, or the text of one of its own sections. `markdown` parses any text into plain Lua tables, the way `json.decode` does for JSON, as `runvil-design/design/2026-11-11-1-markdown-and-prompt-reads.md` specifies. - `markdown.parse(text)` returns a marked list of blocks. A block is a record with its `kind`, its `line`, and the fields of its kind, so a parse result passes `json.encode`, can be stored in `var`, and can be a `tasks.fanout` collection. - `markdown.section(blocks, path [, selector [, n]])` finds the one section a path names, or the one block of it a selector picks. It raises when the section or the block is not there, and `markdown.section_or` returns nil for that. Several matches, and a malformed path, selector, or index, raise from both. - `markdown.select(blocks, selector)` collects every match at any depth in file order, and `markdown.render(blocks)` writes blocks back as text. - A lookup returns the tables that are already inside the list it was given, and walks them without calling into Lua. It reads through sealed views by the rule `json.encode` uses, which moves from `json/encode.rs` to `proxy::unseal`, so blocks read back from `var` work with every function. It refuses a list on its own path and one nested more than 127 lists deep. - `runvil-markdown` gains the messages a lookup raises, beside the grammars they belong to: `Path::no_match`, `Path::ambiguous`, and `pick`, which says which of a selector's matches a lookup keeps. - `markdown.parse` takes the input check of `json.decode`, with its 8,388,608-byte limit. - Where the design is silent: a lookup counts the blocks it visits against the VM's walk budget and raises `blocks are too large to walk`, because a table shared by many parents is visited once for each; a path or a selector that is not a string raises `path must be a string` or `selector must be a string`; a block other than a section given where a list belongs raises `got a fence block`; a message that lists lines leaves them out when a block records none; a fence table with no `lang` field matches no language; `markdown.render` requires every field of a block's kind except `line`; and a Pack prelude's environment does not hold `markdown`. - One wording differs from the design's table: `markdown.parse(42)` says `got integer`, the name every other Engine message gives that type, where the table says `got number`. - The tests run every example of the design through a section VM, and four whole prompts parse a stored file, parse the text of a `prompt` read, carry a parse result to a later section in `var`, and fan out over parsed sections.
`prompt.section(path, selector [, n])` and its `prompt.section_or` twin return one block of a section's own content: a template kept in a fence, the items of a list, or any other single block. The selector and the index are the ones `markdown.section` takes, and the block is the table `markdown.parse` builds for it. - The read parses the section's own raw Markdown by the block rules and picks the only block the selector matches, or the `n`th when there is an index. The table is built for the call, and its `line` counts lines of the prompt file. - A block that is not there raises from `prompt.section` and is nil from `prompt.section_or`. Several matches with no index, and a malformed selector or index, raise from both. The selector and index are checked before the path is looked up. - The kinds `section` and `frontmatter` raise, because a section's own content holds neither. Frontmatter is never recognized there, since a prompt's is at the top of the file. - `Section` records the file line its Markdown starts on, and `SectionText` reads that Markdown in place from the prompt text the run shares. So `prompt.section(path)` copies only the string it returns, and the selector form copies only the chosen block's strings. - The tests run the design's report prompt and assert every line of its worked example from `Write`, from one level down in `Template`, from two workers under different parents, from the H1 body, and from a library that reads its own template and its user's list.
The selector form of prompt.section reads a list, a fence, or any other block with one syntax, so a second read for lists and a second notion of a list in the parser are no longer needed. A list is a list block and nothing else, and a section that holds only a list is an ordinary section. - The read is prompt.section(path, "list").items. The prompt table holds section and section_or and nothing else. - The prompt parser no longer builds list items. Section loses items() and is_list(), and the list module is deleted. - The parse error for a bullet with no content is removed with its ErrorCode, its tag, its prose, the Workshop server's wire code, and the fixture that triggered it. The number 104 is left unused. - tasks.spawn and tasks.fanout no longer refuse a section that holds only a list. It is entered like any section with no Lua and runs nothing of its own. - The error for a second parameter of tasks.fanout that is not a collection names the read that gives the items of a list. - Items now follow the block rules, so two results differ for a prompt written before this change: a nested bullet is part of its parent's item, and a bullet with no content is the item "".
A section is named by a path, so the last two places that described one as a heading now say what they hold. - The Lua test rig binds a recorded jump's target as `path`. - The event module's note on what an author controls says that a library is reported by a path made of heading text.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
[High impact] The PR appears safe to merge, with the previous fence-rendering issue fixed and no new actionable findings.
|
markdown.render rebuilt each block in one fixed style, so rendering a parse changed the text. A list that started at 3 was renumbered from 1. Markers, fences, and underlined headings were rewritten, and a title's markup and a frontmatter's comments were lost. A tilde fence whose label starts with a tilde was written with the two run together, which cost it its closing line and every block after it. Every block now holds `source`, the lines it was read from with the blank lines after them. The sources of a parse are the whole text, and markdown.render writes sources and nothing else, so a parse is written back as the text it was read from. - A block's source runs from its first line to the first line of the next block. A section's is its heading's, and blank lines at the top of the text are in the first block's. Text of blank lines alone has no block, so it is the one text that is not written back. - The selector `*` matches every block. markdown.select(blocks, "*") is a document as one flat list, and the sources of that list join to the text. `*` works in markdown.section and in the prompt reads too. - markdown.render reads `source`, and reads `kind` and `blocks` only to know what comes next. An edit of any other field changes nothing that is written, and a block with no `source` raises and names the block. - The writer adds a line ending of its own in two cases, and neither occurs inside one parse: before a block when the text so far does not end with one, and a blank line between two prose blocks. - A list now ends with its last item. The Markdown library ends a list where the next block starts, so a link definition after a list was in no field of any block. It is prose now. - The writer that rebuilt blocks is deleted, and so is the reader that checked every field for it. - A byte order mark and the line endings are read as before, so every source uses `\n` only. The tests check the 75 sample texts and 2,000 texts generated from a fixed seed: the sources join to the text, each is whole lines, each agrees with its block's line, and each reads alone as its own block. Two tests state the cases where the last does not hold. Plan: /plan/2026-11-12-1-exact-markdown-source.plan.md
The bashkit Plugin inserts the Pack `runvil/bash`, whose one tool, `Shell`, runs a command in a bash interpreter inside the run. Its code is in `cppalliance/runvil-plugin-bashkit`, pinned here by commit. `workshop-plugin-bashkit` links it and exports its entry point as `plugin_bashkit`, as `workshop-plugin-std` does for the std Plugin, and the server depends on that crate so that Cargo writes the library to `deps` with every build. Nothing places the library in a Plugin directory yet. - `bashkit-plugin` in the root `Cargo.toml` pins the Plugin at `0864f1f`, which is built against the contract commit already pinned here. - `tidy.rs` adds the crate to the `PLUGINS` tier, which only the server may name. - `deny.toml` allows the Plugin's repository as a git source, and the license `bzip2-1.0.6`. That license is the Rust port of libbzip2, `libbz2-rs-sys`, which bashkit's `bzip2` dependency builds on by default. It is a permissive BSD-style license. - A server test loads the library from a Plugin directory and runs a prompt that calls `bash/shell`. The shell reads a file of the run through the loader's file functions, its output reaches the script marked as untrusted, and a write fails and changes nothing. - A Host with no Plugin directory refuses a prompt that requires `bash` with `runvil/bash is not installed`, as it does for `std`. The startup warning for a missing shipped Pack now covers both. - `plugin_dir`, which was `std_plugin_dir`, gives the server's integration tests one shared Plugin directory with a hard link to each of the two libraries. - The lock file gains bashkit and its dependencies. No package already in it changes version.
`cargo workshop` and every installer now carry two libraries in Workshop's Plugin directory, the std Plugin's and the bashkit Plugin's, where each of them handled the std Plugin's alone. A built Workshop lists both for `--plugins`: `bashkit 0.1.0` and `std 0.1.0`. - `LIBRARIES` in `build-workshop`'s `plugin.rs` names the two. `library_names`, `built_libraries`, `bundle_config`, `check_bundled`, and `place` work on the list. - A build that left one library out places neither, so a Plugin directory never holds half of what Workshop ships. The failure names the missing file. - A macOS or Linux installer has Tauri copy both libraries into Workshop's bundle, and fails when the bundle lacks either. - `installer.nsi` installs `plugins\plugin_bashkit.dll` with the Workshop component, from `BASHKITPLUGINSRCPATH`, and removes it with that component. - `cell.ps1` and `cell.sh` require both libraries when Workshop is selected, and require `--plugins` to list `std` and `bashkit`. - A pull request that touches `crates/workshop/plugin-bashkit` runs the Installers workflow. - The NSIS template, the Tauri bundles, and the two cell scripts did not run here. The scripts were parsed for syntax, and the Installers workflow is what runs them.
The built-in chat agent now names `bash` under `uses:` and offers it with `tools.offer("bash")`, between `std` and `web`. So a chat model's tool list holds `Shell` itself, after the file tools and before the web tools, where it sat only among the extras before.
- A Pack named under `uses:` is required, so a Workshop whose Plugin directory lacks the bashkit library refuses the chat, as it does without the std library. Both libraries ship with Workshop.
- The `uses:` list is written on one line, in the form the tests use.
- A new chat gate launches the embedded chat and checks the order of the tool list its first round sends.
`C_DRIVE` in `language_guide.rs` sits beside the language guide's switch and is committed as off. Turned on, it mounts `C:\` read-only at `/c` in every run, so `C:\Users` is `/c/Users`, the path Git Bash gives it. It is there so the shell and the file tools can be tried on real files. - While the switch is on, a model can read every file the person who runs Workshop can, what it reads goes to the model's provider, and a search that starts at `/` walks the drive. - Both switches may be on: the two mounts sit side by side, and the in-memory root stays the store. - A machine with no `C:\` logs a warning and runs with its plain root, as one with no guide directory does. - The tests map two temporary directories and read both, map a drive's root on Windows, and run a prompt whose shell lists and reads two mapped directories.
vinniefalco
force-pushed
the
bash-pack
branch
from
November 12, 2026 13:48
df77bd3 to
2d40baf
Compare
vinniefalco
temporarily deployed
to
github-pages
November 12, 2026 14:45 — with
GitHub Actions
Inactive
This branch was previously deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The built-in chat now has a shell. Workshop ships a second library Plugin, the bashkit Plugin, whose Pack
runvil/bashhas one tool,Shell: it runs a command in a bash interpreter inside the run, built on bashkit. The Plugin's code is in cppalliance/runvil-plugin-bashkit, pinned here by commit. Four commits, 38 files.This pull request is stacked on #210. Its branch starts from that pull request's head, so until #210 merges, the commit list and the diff here hold its nine commits too. The four commits of this one are the last four, and this comparison shows them alone.
This is the first of two stages. The shell reads the run's files and cannot change them, because contract 0.4 has no function that writes. A command that writes fails with one fixed reason, and nothing more is built around that. The second stage adds writing, with the contract's functions for it.
What a model sees:
Shellis in the chat's tool list, after the file tools and before the web tools. Its definition is Cursor'sShelldefinition, with the editsschemas/EDITS.mdin the Plugin's repository records.gitanswerscommand not found.block_until_mswhen that is less. The call then fails with a message that says the command was stopped and the session is still usable.The commits, in order:
workshop-plugin-bashkitlinks the pinned Plugin and exports its entry point asplugin_bashkit, asworkshop-plugin-stddoes for the std Plugin. The server depends on it so that Cargo builds the library. A server test loads the library from a Plugin directory and runs a prompt that callsbash/shellover a file of its run.cargo workshop, the NSIS template, the Tauri bundles, and the two cell scripts each handled the std Plugin's library alone. They now handle both.chat.mdnamesbashunderuses:and offers it betweenstdandweb. A new chat gate checks the order of the tool list.C_DRIVEinlanguage_guide.rssits beside the language guide's switch and is committed as off. It is there so the shell can be tried on real files.Things a reviewer should know:
deny.tomlallows one more license,bzip2-1.0.6. It belongs tolibbz2-rs-sys, the Rust port of libbzip2, which bashkit'sbzip2dependency builds on by default. It is a permissive BSD-style license. No other license was needed.bashkit-pluginis atrunvil-plugin-bashkit0864f1f, which is built against the contract commit already pinned here.bash, so a Workshop whose Plugin directory lacks the bashkit library refuses the chat. A config that sets[agents] pluginsloads only that directory, so that directory must hold both libraries.C_DRIVEis on, a model can read every file the person who runs Workshop can, and what it reads goes to the model's provider.sleep, the Harness steps the call with nothing to do, because contract 0.4 has no way for a Plugin to say when to step it again.teeto a file andsed -iend the whole call instead./dev/stdoutor/dev/stderris refused as a write. This is a bug in bashkit 0.21.0:normalize_dev_pathrebuilds the path with the host's separator, so the path no longer matches the names bashkit answers itself.>&2works. It has not been reported to bashkit.Checks:
cargo nextest(4263 and 347 tests),cargo testforbuild-xtaskandbuild-workshop, the doc commands for the workspace,runvil-engine,runvil, andworkshop-serverwith-D warnings,docs-claims.mjs,cargo deny check,cargo audit,cargo xtask api --check, andcargo dylint.cargo nextest(4303 and 347 tests), clippy for the Workshop crates with warnings denied,docs-claims.mjs, andcargo deny check. The rebase had no conflict.cargo workshopplaces both libraries inplugins, and the Workshop it built printsbashkit 0.1.0andstd 0.1.0for--plugins.bashkit 0.1.0andstd 0.1.0for--plugins. So the NSIS template, the Tauri bundles, and the two cell scripts, none of which ran locally, are checked there.C:\at/cand ran the shell through the loader. It listed/c/Users, read a file, searched this checkout, and was refused a write.Not checked:
The review bot's one finding, on
crates/runvil-internal/markdown/src/render.rs, was about code from #210, which shows here only because this pull request is stacked on it. It was a real defect, and #210's ninth commit, c64f856, removes it.Pushed alongside this:
plan/2026-11-12-1-bash-shell-pack.plan.md, which ends with what the build found.