Repository navigation
web: LevelCode Shell — a terminal and run_command in the tab (stage 1: no programs, no network) - #107
Merged
Merged
Conversation
Workspace Trust is off in a tab, so a setting without a scope was read from the folder's .vscode/settings.json as if the user wrote it. Measured in the real workbench: levelcode.cloud.endpoint, levelcode.ai.baseURL, agent.autopilot, verify.command and commandTimeout all came back as the folder's values, and the session token goes to whatever https host the endpoint names. The browser manifest now makes every setting application-scoped except a short, double-entered list of display preferences. The desktop manifest is untouched.
runAgent built the system prompt as (skills ? buildSystem(menu) : SYSTEM_BASE). buildSystem already answers a run with no menu with the HOST's base prompt, but the fallback bypassed it: in the browser build, with levelcode.ai.skills.enabled off, the model was told about run_command, background:true and 'EVERY run_command' while run_command itself was withheld. No effect on the desktop (SYSTEM_BASE_HOST is SYSTEM_BASE there).
…and a shell's file changes through the review The browser build's shell is another extension (levelcode.levelcode-shell), activated on first need. host.js gets spawn / shellInfo / registerShell / connectShell, and caps.shell becomes a getter. agent.js reads the answer once per run, so the prompt, the tool list and the verify step agree for the whole run; the three load-time constants that baked the capability in become per-run. run_command is offered as what it is (no background argument, no read_command_output), refused with an instructive message when a model asks for background anyway, and its file changes are handed to shellCommit.js, which sends them through the same applyEdit / applyDelete as edit_file. The desktop path is unchanged: the same spawn options, the whole tool list, the same prompt (pinned by shellSeam.test.js; agentRunCommand.test.js is untouched).
The tool result was (output + status).slice(0, 8000): for long output the end — where the failure and the '[exit 1]' line are — was exactly what got cut, so the model could not tell a command had failed. Output that fits is returned byte for byte as before; longer output keeps a quarter from the start and the rest from the end, with the gap said out loud, and the status line is never what is cut. DESKTOP-VISIBLE behaviour change (outputs over 8000 characters only), in its own commit so it can be dropped on its own.
…the card has room for The registry that read_command_output serves (ring, byte count, port and readiness sniffing, preview) was fed from the chunk callback that feeds the live card, and that stops at the card's 100000-character cap: after that a server's 'listening on :4000' left port=null, ready=false and totalBytes frozen. It is fed by its own callback now, after the card's, so posts keep their order. DESKTOP-VISIBLE behaviour change (output past 100000 characters), in its own commit.
…mpt says what the shell is shellCommit.js is rewritten around one rule. A command's change set is validated as a WHOLE and applied only if every change is acceptable; there is no unreviewed path (the first draft wrote 'build output' and new binaries straight to disk, which is a way round everything edit_file refuses). Refused whole: outside the folders; .git, .vscode, .levelcode, CI workflows and instruction files (AGENTS.md, CLAUDE.md, .cursorrules); not plain UTF-8 text, a BOM, over 1 MiB; line endings the review would rewrite; unsaved editor changes; a file that changed on disk since the command read it; case-only renames; over 300 files. A review call that throws is reported, never swallowed, and the command does not exit 0. host.connectShell no longer caches a failure: an early return ran the finally block before the promise was stored, so every later run got a settled stale answer. It is bounded (10 s) and asks again next run. The shell prompt block is rewritten: the program list is the shell's own, what exists and what does not, the limits and the exit codes, that a stopped command changes nothing, what is refused, and that tool output is data. Stop/limit/timeout apply nothing (the staged writes are discarded), so the kill semantics in host.js say so.
Each SKILL.md in the browser build gets one preamble: the shell is a simulated bash with no git, npm, node, python or test runner and cannot run the code, so the steps that say to run them are skipped, no result is claimed for them, and the summary says which checks were not run. The steps and the menu entries are unchanged; the desktop's files are untouched. Also: the verify.command example means what it says (`! grep -rn TODO src`).
…dependently verified)
LevelCode Shell stage 1 — a JavaScript bash over vscode.workspace.fs, for the visitor's terminal and the
agent's run_command; no programs, no network.
engine/ just-bash 3.6.0 vendored unmodified (sha256 pinned) and patched in memory at build time
(anchor-checked); a copy-on-write overlay over a read-only RPC; compat wrappers; limits; the
worker entry. 535 golden cases recorded from GNU bash 5.1 / coreutils 8.32.
src/ runtime (engine worker lifecycle, read-only fs RPC, sessions, the agent's ChildLike, commit),
commit (the visitor's terminal), info, sanitize, extension.
src/terminal/ Pseudoterminal: input parser, line editor (checked against real xterm.js), completeness,
output allow-list filter, history, prompt, agent tab, shell integration.
Known defects found by the verifiers are fixed in the commits that follow.
…s the model's label Approval and command cards show the command and its explanation through host.displaySafe: the desktop's identity, and in a tab the shell's forDisplay, which makes bidi overrides, zero-width and other invisible characters visible so that what is approved is what runs. The shell is still handed the real string. SpawnOptions carries the model's label (at most 300 characters) for the tab that mirrors the agent's commands.
…re-verified; docs, README, e2e-shell Each role's findings were fixed with tests that failed before, then attacked again by an independent verifier. Known open findings are in the round-2 reports and are fixed in the next commit. agent.js: the review's commit hook honours the shell's cancelled(); extension.js: agent.forDisplay.
… is still open) Engine: 25 patches, cp/mv trailing-slash regression and the redirect content-write regression fixed and pinned at run level, printf %(..)T zone, grep BRE, du limit, sed labels, 682 golden cases against GNU 5.1. Runtime: planMounts is the engine's own algorithm, safeMessage, one bounded gate for the commit, Stop settles at once, resolve/stat. agent.js: the review stops between changes after a Stop, and the prompt names RE2, UTC and /tmp.
…t scales, the review's give-up cancels, /tmp folders counted)
…ize, joiners kept, linear regexes); README for the commit/Stop/mount contract; e2e-shell: height resize, emoji paste
… README to the real contract; CI sets LEVELCODE_REQUIRE_WORKER; suites that exit silently fail
…r happens (exit guard)
…pe.Unknown, a link to nothing); links are made to files only
…wners left them; engine-compat and engine-patches suites are mid-edit
…r every step, the lowest redraw over every count of dropped rows), links accept only a file, measured wording in README/NOTES
…ommand, relative dates, diff -r, option tables against GNU getopt, xargs -x -s, sort -k signs, TZ as a zoneinfo path)
…en cases, 371 runtime mutations)
…faults inside $(( )), clustered value options, for x; do, --, stdin as -, strftime flags, find -exec status and time tests, diff quoting, 11 more patches); 0 regressions over 30,270 differential units
…ry between touch and date (the third figure is rounded with an hour of slack)
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Tab completion can change file operands and corrupt shell syntax, creating correctness and data-loss risks.
5 open findings
Completion alters filenames containing zero-width spaces · New Newlines fail to reset command position for completion · New Variable assignments incorrectly disable command completion · New Adjacent redirections misclassify subsequent command arguments · New Completion corrupts commands before existing closing quotes · New
What changed in this PR
Adds a browser-only simulated shell to LevelCode’s web edition, extending the earlier browser and layout work without enabling native programs or network access.
Changes:
- Adds terminal support, agent command execution, and browser build integration.
- Restricts browser settings and updates user-facing guidance.
- Adds compatibility fixtures, terminal checks, and agent regression tests.
| File | Description |
|---|---|
web/test/unit/skills.test.js |
Tests browser skill guidance. |
web/test/unit/shell-terminal-completion.test.js |
Tests completion parsing and escaping. |
web/test/unit/shell-engine-xargs.test.js |
Checks GNU xargs compatibility. |
web/test/unit/shell-engine-support.js |
Provides an in-memory engine harness. |
web/test/unit/manifest.test.js |
Checks browser settings restrictions. |
web/test/unit/helpers/shell-terminal-xterm.js |
Locates real xterm test dependencies. |
web/test/unit/helpers/shell-terminal-timing.js |
Checks pathological-input performance. |
web/test/tsconfig.shell-terminal.json |
Configures terminal source type checking. |
web/test/tsconfig.shell-terminal-tests.json |
Configures terminal test type checking. |
web/test/helpers/shell-real-worker.js |
Provides real worker-thread testing. |
web/test/helpers/shell-exit-guard.js |
Detects unfinished test suites. |
web/test/helpers/mutation/mutate.mjs |
Runs isolated mutation checks. |
web/test/fixtures/shell/zones/generate.mjs |
Generates timezone reference data. |
web/test/fixtures/shell/xargs/generate.mjs |
Records GNU xargs results. |
web/test/fixtures/shell/xargs/cases.mjs |
Defines deterministic xargs cases. |
web/test/fixtures/shell/terminal/resize-chains.json |
Records terminal resize regressions. |
web/test/fixtures/shell/cpmv/generate.mjs |
Records GNU copy/move results. |
web/test/fixtures/shell/cpmv/cases.mjs |
Defines copy/move operand cases. |
web/test/fixtures/shell/consistency/scripts.mjs |
Generates filesystem consistency cases. |
web/test/fixtures/shell/consistency/generate.mjs |
Records reference filesystem outcomes. |
web/test/e2e.mjs |
Checks shell tool availability. |
web/shell/src/terminal/width.js |
Computes terminal cell widths. |
web/shell/src/terminal/width-table.json |
Supplies Unicode width data. |
web/shell/src/terminal/shell-integration.js |
Emits authenticated shell-integration sequences. |
web/shell/src/terminal/prompt.js |
Formats bounded workspace prompts. |
web/shell/src/terminal/output-sink.js |
Filters, caps, and queues output. |
web/shell/src/terminal/history.js |
Manages secret-aware command history. |
web/shell/src/terminal/gen-width.mjs |
Derives widths from xterm. |
web/shell/src/terminal/completion.js |
Implements shell-aware Tab completion. |
web/shell/src/extension.js |
Wires runtime, workers, and terminals. |
web/shell/package.json |
Registers shell commands and settings. |
web/shell/engine/worker.js |
Connects worker messages to the engine. |
web/shell/engine/vendor/stubs/zlib.mjs |
Stubs unavailable compression APIs. |
web/shell/engine/vendor/stubs/turndown.mjs |
Stubs unavailable HTML conversion. |
web/shell/engine/vendor/stubs/module.mjs |
Blocks Node module loading. |
web/shell/engine/vendor/sprintf-js/1.1.3/package.json |
Declares vendored formatting dependency. |
web/shell/engine/vendor/sprintf-js/1.1.3/LICENSE |
Retains dependency licensing. |
web/shell/engine/vendor/just-bash/3.6.0/VENDOR.json |
Records engine provenance and patches. |
web/shell/engine/vendor/diff/8.0.4/LICENSE |
Retains diff library licensing. |
web/shell/engine/vendor/diff/8.0.4/libesm/util/string.js |
Vendors string comparison helpers. |
web/shell/engine/vendor/diff/8.0.4/libesm/util/params.js |
Vendors option handling. |
web/shell/engine/vendor/diff/8.0.4/libesm/util/distance-iterator.js |
Vendors patch-position iteration. |
web/shell/engine/vendor/diff/8.0.4/libesm/util/array.js |
Vendors array comparison helpers. |
web/shell/engine/vendor/diff/8.0.4/libesm/types.js |
Retains upstream module structure. |
web/shell/engine/vendor/diff/8.0.4/libesm/patch/reverse.js |
Vendors patch reversal. |
web/shell/engine/vendor/diff/8.0.4/libesm/patch/parse.js |
Vendors unified-diff parsing. |
web/shell/engine/vendor/diff/8.0.4/libesm/patch/line-endings.js |
Vendors patch line-ending handling. |
web/shell/engine/vendor/diff/8.0.4/libesm/package.json |
Declares ES module packaging. |
web/shell/engine/vendor/diff/8.0.4/libesm/index.js |
Exposes vendored diff APIs. |
web/shell/engine/vendor/diff/8.0.4/libesm/diff/sentence.js |
Vendors sentence comparison. |
web/shell/engine/vendor/diff/8.0.4/libesm/diff/line.js |
Vendors line comparison. |
web/shell/engine/vendor/diff/8.0.4/libesm/diff/json.js |
Vendors JSON comparison. |
web/shell/engine/vendor/diff/8.0.4/libesm/diff/css.js |
Vendors CSS comparison. |
web/shell/engine/vendor/diff/8.0.4/libesm/diff/character.js |
Vendors character comparison. |
web/shell/engine/vendor/diff/8.0.4/libesm/diff/array.js |
Vendors array comparison. |
web/shell/engine/vendor/diff/8.0.4/libesm/convert/xml.js |
Vendors XML diff conversion. |
web/shell/engine/vendor/diff/8.0.4/libesm/convert/dmp.js |
Vendors diff-format conversion. |
web/shell/engine/shims/buffer.mjs |
Supplies browser Buffer support. |
web/shell/engine/load.mjs |
Loads patched engines for testing. |
web/shell/engine/limits.js |
Defines explicit execution limits. |
web/shell/build.mjs |
Packages shell extension and notices. |
web/serve.mjs |
Allows terminal library assets in development. |
web/lib/config.mjs |
Sets browser terminal defaults. |
web/ai-extension/skills.js |
Adds browser limitations to playbooks. |
web/ai-extension/manifest.js |
Restricts settings and updates descriptions. |
web/ai-extension/copy.js |
Rewrites browser shell UI wording. |
web/ai-extension/build.mjs |
Applies browser skill guidance during packaging. |
scripts/build-web.mjs |
Builds and fingerprints the shell extension. |
extensions/levelcode-ai/test/agentPromptHost.test.js |
Checks host-specific agent prompts. |
extensions/levelcode-ai/test/agentNoWorkspace.test.js |
Updates tool-cost assertion. |
extensions/levelcode-ai/test/agentCommandResult.test.js |
Checks output truncation preserves status. |
extensions/levelcode-ai/test/agentBackgroundRegistry.test.js |
Checks registry updates beyond display limits. |
CLAUDE.md |
Documents shell safety conventions. |
.github/workflows/test.yml |
Requires real worker tests in CI. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
# Conflicts: # docs/WEB.md # extensions/levelcode-ai/agent.js # extensions/levelcode-ai/host.js # scripts/build-web.mjs
…'s name, misreads where a command starts, or leaves a stray quote The five findings of the review on completion.js, each reproduced against the real modules first. Every new test fails on e93bc09 and passes here. 1. A name with a character the editor does not type (U+200B and the rest of cleanText's invisible list) was offered: `rm a` + Tab gave `rm ab.txt ` for `a<ZWSP>b.txt`, another file's name, with only a dim note under the line. The completer now drops a candidate whose written-back text cleanText would change (completion.js createCompleter). Names whose invisible characters the editor keeps (an emoji's selector, a ZWJ family, a Persian ZWNJ) are offered as before. 2. A line end that is not quoted or escaped was read as a blank, so in a pasted `echo hi\ngre` the word `gre` asked for paths. A line end now ends a command, as `;` does (wordAt). 3. A leading assignment took the command position: `LC_ALL=C gre` asked for paths. A word that is `NAME=`, `NAME+=` or `NAME[i]=`, judged on the raw token so the name has to be unquoted as bash requires (`"LC_ALL"=C` is a command word), keeps the command position (wordAt, ASSIGNMENT). 4. A word written against `<` or `>` was dropped without ending the command position: `cat>out sr` asked for commands. The word now ends there like any other unless it is all digits (the `2` of `2>err`). `>&`, `<&` and `>|` (an `&` or `|` right after the operator) and `&>`, `&>>` are read as redirections, not as a list or a pipe: `2>&1 gre` and `>|out gre` complete commands, `cat &>out sr` a file. Every row of the new table was checked with `bash -xc` (bash 3.2, the only bash on this machine: Docker was down, so not the golden suite's 5.1). 5. Tab just before a closing quote left the quote behind: `cat "my fi|"` became `cat "my file.txt" "`, an open quote, and the line was held for more. GNU readline does no better (`cat "my file.txt""`), so bash is not the model here. wordAt now also returns where the word ends after the cursor and what it holds there. The completer offers only names that end with that rest (any name when the rest is empty) and gives each such candidate an `end`. The editor's `_replace` replaces through that end, so the closing quote and the tail of a name typed earlier are consumed, never split off. A folder at the end of a word still keeps its quote open, with the cursor before the quote that is there. In the editor (line-editor.js `_complete`), too: a file gets no blank when one follows already; a common part that is a whole candidate (`'it'` of `it` and `it's`) takes that candidate's end (`cat 'i|'` gave `cat 'it''`); an `end` before the cursor or past the line completes nothing. A cursor at the start of a word still inserts a word of its own (`cat |foo` gives `cat x.txt foo`, as before). NOTES.md: the completion section says what counts as a command position and what Tab inside a word does, and no longer that a name it cannot type "is typed without" the character.
… dead assignment go links.js declared FILE (vscode.FileType.File) and never used it: the existence check compares the stat's type with 'file'. input-parser.js set i = s.length right before the break that leaves the loop, and nothing reads i after it. The input-parser and links suites pass unchanged.
…e is always shown in a terminal
The bug: both tabs showed only the first 8 notes of a run. A refusal is eight notes on its own (six
reasons, "and N more refused", then "NONE of this command's file changes were applied"), from the
terminal's commit (web/shell/src/commit.js) and from levelcode-ai's review (shellCommit.js) alike, and
the runtime puts the notes the run already had in front of it: a moved folder (said on every run), an
emptied /tmp or /home/user, the engine's own notes, variables not kept. With one of those the NONE line
was the ninth note and was cut, so the visitor saw six refusals and never read that nothing was applied.
The fix: notesShown() in output-sink.js, used by pty.js and agent-tab.js. Still at most 8 notes in their
order; when there are more, a note that says the changes were NOT applied ("NONE of …", "… were/was NOT
applied") takes the place of the last of the others. The agent's tab drops non-string notes before the
cap instead of letting them use a place. README section 7 and terminal/NOTES.md say so.
Tests: shell-terminal-pty ("a refused commit's NONE … line is shown behind a note the run already had")
feeds the terminal commit's real refusal notes behind an emptied-/tmp note; shell-terminal-agent-tab
("a refused review's NONE … line …") feeds levelcode-ai's real refusal notes behind one and three notes;
shell-terminal-sink pins notesShown() itself (the runtime's own NOT applied endings, a plain cut without
one, a file name with "none" in it). All three fail on the unfixed code (the NONE line missing; notesShown
not a function) and pass now.
…s real start and counts what it left out The bug: runCommand keeps only the last 262144 characters of a command's output in memory, and commandResult then took its "start" from that buffer and counted the omitted characters against it. Above 256 KiB the start the model saw came from the middle of the output, and the gap was a lie: a 2 MB `cat` was reported as "[~254000 characters of output omitted]". The desktop and the browser shell share this code, so both were affected. The fix: runCommand also keeps the first 8000 characters of the output and counts every character it was given. When the buffer has dropped the beginning, commandResult is handed that real start and the true total, so the start shown is the start and kept + omitted is the whole output. Output up to 256 KiB goes through exactly as before (nothing extra is passed). Test: agentCommandResult.test.js "OUTPUT OVER 256 KiB: the start is the command's real start, and the omitted count is everything that is not shown" drives the real runAgent -> run_command -> runCommand chain with about 2.2 MB of output. It fails on the unfixed code (the result started at line 61809) and passes now; the other agentCommandResult, agentRunCommand (untouched) and shellSeam tests still pass.
…opped command changes nothing The bug: the browser prompt told the model "A stopped command changes nothing." That holds for a command stopped while it runs (its writes were only staged), but not after it has ended: a Stop, or the agent's commandTimeout, while its file changes are in the review settles the run at once (the shell's killRun), and what the review had already applied stays in Keep/Undo (shellCommit checks for the Stop between changes). The model got "[stopped by user]" / "[timed out]" behind the shell's note "stopped while the review of its file changes was going on ... it may still apply some of them", and a prompt that contradicted it. host.js said the same thing in its SpawnOptions/ChildLike JSDoc. The fix: the prompt says a command stopped while it runs changes nothing, and one stopped while its file changes were being reviewed may have applied some of them (then in the Keep/Undo review), and that its result says so. The JSDoc of `commit`'s control and of `kill` in host.js now describe the same thing (comments only). The desktop prompt never had the sentence and is unchanged. Test: shellSeam.test.js "BROWSER, shell registered: caps.shell is live, run_command is offered as what it is" no longer pins the old sentence; it asserts it is gone and the new one is there. It fails on the unfixed prompt and passes now. agentRunCommand.test.js is untouched.
…hat what it applied is in Keep/Undo The bug: an agent run stopped (Stop, or agent.js's commandTimeout) while levelcode-ai's review of its file changes is going on is settled at once, and the review keeps what it had applied before the Stop. The note the model and the agent's tab got said "it may still apply some of them, and how many it applied is not known", but not where those changes are, so the model could not tell the user what to look at, and the prompt (fixed in the commit before) had told it nothing changes. The fix: the note ends "Any it applied are in the Keep/Undo review." It is said only in the review phase, which only an agent run (one with `commit`) has; the check and the writing phases are unchanged. The mutation table's anchor for this note follows it (the mutation is still caught by shell-runtime-commit). Tests: shell-runtime-commit.test.js "Stop while the agent's review is going on ends the run AT ONCE" and "the same for an agent that is killed while its review is going on, and for a worker that died after done on top of it" now require the sentence. Both fail on the unfixed note and pass now.
…asks of every JS file The file had no `// @ts-check` header, so an editor or a plain `tsc --allowJs` never type-checks it. It needs no other change: `tsc --noEmit --allowJs --checkJs --skipLibCheck --target es2022 --module commonjs --types node web/ai-extension/skills.js` (TypeScript 6.0 from the Code-OSS checkout, whose defaults are strict) reports nothing, before and after, and `tsc --allowJs` without --checkJs now checks it too and reports nothing. No unit test: the header changes no behaviour (web/test/unit/skills.test.js passes as before); the check is the tsc run above. The other files in web/ai-extension have no header either; they are left as they are.
…nfig or .VSCODE/tasks.json
The protected paths of shellCommit.js (.git, .vscode, .levelcode, .cursor, .husky, .github/workflows) were matched
as typed; only the instruction-file pattern ignored case. On a disk that ignores case — the usual folder from the
computer: APFS on a Mac, NTFS on Windows — `.GIT/config` is `.git/config`, so an agent's
`printf '[core]\n\tfsmonitor = sh evil.sh\n' > .GIT/config` went to the review as an ordinary file and was applied
("1 file changed, ready for Keep/Undo: ~.GIT/config"), reproduced against 187fd21.
isProtected now matches the folded path (foldCase): NFC, as the shell folds names in its own commit
(web/shell/src/commit.js), then upper case before lower case, so that U+017F (long s) and U+0131 (dotless i)
land on s and i too; U+212A (the Kelvin sign) is K once normalised. Wider than any one disk, which only refuses names
nobody would choose.
The visitor's own terminal (web/shell/src/commit.js) has no protected list, by design ("the user's own terminal is not
held to this"), so there was nothing to fold there.
Test (test/shellCommit.test.js): "protected paths are refused in any letter case" — the reported command, each
protected name in other cases and as a delete, the three Unicode letters, and names that only look similar
(.GITIGNORE, my.GIT/x) still allowed. It fails on 187fd21 (the .GIT/config write is not refused) and passes here.
…n Windows
agent.js handed the shell its folder roots and working directory as `Uri.fsPath`; the runtime mounts and matches
by `Uri.path`. On a Mac or Linux they are the same string. In a browser on Windows they are not: VS Code takes the
platform from the user agent (src/vs/base/common/platform.ts), and uriToFsPath (src/vs/base/common/uri.ts) turns every
`/` into `\` whatever the scheme, so `file:///proj` — a folder opened from the computer — is `\proj`.
normalizePath('\proj') is null, and every run_command (and the verify command) answered "the working directory \proj
is not inside a workspace folder". The mirror tab was told the same `\proj`, which its links (cwdsOf) and its
"in <folder>" line map against `uri.path` mounts.
shellSpawnOpts now names everything in the shell's terms: each folder's root is `uri.path`, the working directory is
the URI path of the folder runCommand was given (looked up by its fsPath; a folder removed while the command waited
for approval is not found, and the shell then says it is not inside a workspace folder), and the commit hook resolves
the change paths against those same roots — so a change is mapped back to its folder and readNow (host.readBytes ->
host.uriFor, which matches `uri.path`) reads the right file. touched gets those paths too, which is what
host.uriFor maps for the verify step. runCommand passes `opts.cwd` when a shell supplied one.
The desktop is unaffected: it never has a registered shell (host.shellInfo() is null, connectShell resolves false), so
shellSpawnOpts returns {} and the desktop's spawn is given `root` as before — shellSeam's "spawns exactly what it
always did" and agentRunCommand.test.js (untouched) pass. The rest of the extension still names files by fsPath
(workspaceFolderList, resolveWorkspacePath), so in a browser on Windows the file tools, and with them the review a
command's changes go through, still cannot resolve a path: a command's write is reported as not applied there. That
is the browser edition's, not the shell's, and is not changed here.
Test: web/test/unit/shell-agent-paths.test.js runs the real runAgent -> run_command -> host.spawn -> the real runtime's
spawnAgent (fake engine, scripted provider), with fsPath computed as uriToFsPath does on Windows: the command runs in
/proj and its output comes back, the mirror tab's cwd maps to a mount, run_command's `folder` picks /lib, a command's
changes come back to the review as folder-relative paths read from the right file, and the verify command runs in
/proj. All four fail on 187fd21 and pass here; reverting any one of the five changed lines fails at least one of them.
…h unsaved changes in the editor shellCommit.js refuses a change to a file with unsaved changes in the editor — but only when its io has an isDirty, and agent.js's commit hook never passed one; only the unit test injected it. So a command's change to a dirty file went to the review, which replaces the whole document (reviewSession.applyEdit) and saves it, and whose Undo restores the file as it was on disk: what the user had typed was lost. The commit hook now passes isDirty: hasUnsavedChanges finds the document by URI, as the review finds the file (host.uriFor), with the path's letter case and Unicode form folded — on a disk that ignores case (the usual folder from the computer: htmlFileSystemProvider has no PathCaseSensitive off Linux, so openTextDocument canonicalises the URI) the editor keeps one document for README.md and readme.md, and the review would replace it whichever spelling the command used. A modify and a delete are refused; a file the command created has no buffer to lose, as before. Test (test/shellSeam.test.js, through the real runAgent -> run_command -> the commit hook agent.js hands the shell): "a file with unsaved changes in the editor is not replaced by a command" — a dirty document refuses a modify (and the other file of the set), a delete, and the dirty buffer of another spelling; an open, saved document does not. It fails on 187fd21 (the dirty file reached the review) and passes here; removing the wiring, the fold, the isDirty test or the URI lookup each fails it.
… folders called proj is not written into the first
The runtime tells same-named folders apart (planMounts mounts /a/proj and /b/proj each at its own root), but a
command's change reaches the review as `name/rel` (reviewPathFor), and the review finds a folder by name:
resolveWorkspacePathAsync takes the FIRST folder called proj. So a change to /b/proj/x.txt was compared with the right
file (readNow reads /b/proj/x.txt) and written into /a/proj/x.txt; a new file was created there too. The same holds
for two folders at one path — the scratch folder and a folder from the computer called scratch
(`levelcode-scratch:/scratch`, `file:///scratch`), whose change paths name neither — and for a folder whose name has a slash in it, which
the resolver reads as a path.
The smallest correct fix is to refuse: the review and the file tools address a file by `name/rel`, and teaching them
to take a folder is a change to the review that edit_file and the desktop share. commitShellChanges now refuses a
change in a folder the review would not find again (folderMixUp): one that shares its root with another folder, whose
name has a slash, or that is not the first folder of its name. The whole set is refused, as for every other refusal,
and the note says why ('another workspace folder is also called "proj", and the review finds a folder by its name: it
would apply this change there'). The first folder of a name is the one the review finds, so its changes go through as
before; folders of distinct names, nested ones included, are unchanged.
Tests: test/shellCommit.test.js "two folders the review cannot tell apart" (same name — a write, a delete and a set
with a change in each folder —, same root, a slash in the name; the first folder of a name, a lone folder with a slash,
and distinct nested names still applied). test/shellSeam.test.js "two workspace folders called proj", through the real
commit hook, with a review stand-in that finds the file exactly as reviewSession.applyEdit does
(resolveWorkspacePathAsync, then host.uriFor): on 187fd21 the two changes to b/proj are written to a/proj/new.txt and
a/proj/x.txt; here nothing is written, and a change to a/proj still lands in a/proj. Both fail before and pass after;
removing any one of the four checks fails one of them. (shellSeam's vscode stand-in gains workspace.fs.stat and
FileType, which the resolver's existence check needs.)
…e a folder in a protected place either Found while fixing the letter-case finding: the protected-path check ran over file writes and deletes only. Folders are left to the shell to make and remove (`rest`), so a command that made an empty .vscode, or removed an empty folder under .git, had that applied: a change in a place the review says commands never change, in either direction. A mkdir or rmdir whose path is protected (folded as for files) is now a refusal like any other: the whole set is refused, and the shell is handed no folder to make or remove. Folders elsewhere are unchanged. Test: shellCommit.test.js "protected folders are not made or removed either…" (an rmdir under .git, mkdir of .vscode, .GIT/hooks, .github/workflows, an rmdir of sub/.Husky — each beside an ordinary file write that must not be applied either; and two look-alike folders that still go through). It fails on the previous shellCommit.js.
…ores case is refused whole, never a deletion
The bug. A folder on macOS or Windows (the usual File System Access folder) does not tell upper and lower case
apart, nor an accent written as one character from one written as a letter and a combining mark, and the base
answers for A.txt with what it has at a.txt. The overlay keys paths by their spelling, so `mv a.txt A.txt` staged
"A.txt, the bytes the base already has there" (judged no change) and "a.txt removed", and the change set was only
`delete a.txt`. The visitor's terminal deleted the file straight from the folder; an agent run sent the deletion
to the review. The commit layers' case checks could not see it: the write they compare with had already gone.
Reproduced through the real engine on a case-insensitive workspace before the fix: `mv a.txt A.txt` (file gone),
`mv src/a.txt SRC/a.txt`, `mv a.txt t.txt; mv t.txt A.txt`, `rm a.txt; echo new > A.txt`, `rm -r src; echo n >
SRC/new.txt`, NFD to NFC (all lose data); `mv d D` of a folder (the engine sees D as there and moves d into it:
applied 3 of 4, as d/d/f.txt); `cp a.txt A.txt` (nothing, status 0).
The fix (overlay-fs.js, spellingClash, asked by changes() before the set is worked out). The spellings a command
used (what it staged, what the base answered for, and every folder above them) are grouped by case- and
accent-folded text; only a group of two or more spellings, one of them changed by the command, is looked at, so a
command without such a pair asks the base nothing more. For those the base decides: each spelling is resolved name
by name against its folder's listing, and two spellings that are one entry there (or one that the base answers
for and that cannot be matched to exactly one listed name) refuse the whole set as `incomplete`, with both names
and how to do it instead (two commands). A folder that tells case apart keeps two names as two files, and its
case-only rename stays a write and a delete for the commit to judge. One spelling of an entry is not refused:
`echo x > A.txt` changes a.txt and `rm A.txt` removes it, as that disk does. Refused although a disk would have
managed: reading under one spelling and changing under another in one command (`cat A.txt; rm a.txt`).
Tests. shell-engine-overlay.test.js, over a base that ignores case and accent form: mv a.txt A.txt is refused and
names both; eight ways of reaching one entry by two spellings are refused and the disk is unchanged (one of them
names the folder two levels up, which only the folders-above part of the check sees); a name the
base answers for that matches no single listed name is refused ("may be"); and pins that must not move: one
spelling per entry, reads under two spellings, no extra base call without a pair, and a case-sensitive base.
shell-runtime-engine.test.js, the real engine core through the runtime and commit.js over the Map workspace with
the new `ignoreCase` (test/helpers/shell-runtime-fakes.js): the terminal's mv a.txt A.txt and mv d D are refused
and nothing moves, echo > A.txt lands in a.txt, the two-command rename works; the agent's review is never handed
the deletion. The five tests aimed at the bug fail on 187fd21 (the change set was `delete /w/a.txt`; in the
terminal the file was gone) and pass here; the pins pass on both. NOTES.md §5/§6/§8 and docs/WEB.md say what the
engine now refuses and what it still does not emulate.
… changes that were, not a folder the review kept back
The bug. After levelcode-ai's review the runtime counted what it had applied as `total - rest - failed`: every
change of the set that the review neither handed back nor said it failed. shellCommit's `failed` counts FILE
changes, and when a delete fails it keeps back the removal of the folder the file was in: that rmdir is in neither
list, so it was counted as applied. `rm -r d` with two files, one delete failing: 3 - 0 - 1 = 2 for one change made.
The count reaches the visitor and the model in the notes of the part that follows the review (the folders it leaves
to the shell, which start from that count): with a folder to make whose creation fails, the stderr said "PARTLY
applied — 1 of 2 file changes" from the review and then "applied 2 of 4 file changes; stopped at /scratch/m"; a Stop
while that folder was being made said "2 of 4 were applied". One was.
The fix (runtime.js applyChanges). What the review applied is the set's files less the ones it could not apply and
less whatever else it handed back (unchanged: an entry that is not a folder of the set is still to be done, so it
was not done by the review). Folders are never the review's to apply; one it keeps out of `rest` is applied by
nobody and is no longer counted. The answer's shape is not changed and levelcode-ai is not touched: shellCommit's
`failed` is the file changes it could not apply (host.js's SpawnOptions.commit says "how many changes it could NOT
apply", and the review applies no others); README §5 now says that, and how the shell counts what was applied.
Tests. shell-runtime-commit.test.js runs levelcode-ai's real commitShellChanges as the review, answering as agent.js
hands it on, over a set of mkdir m, two deletes in d and rmdir d, with the second delete refused by the editor: the
folder it leaves cannot be made ("applied 1 of 4 file changes; stopped at /scratch/m: quota."), and a Stop while it
is being made ("1 of 4 were applied"). Both fail on abe8a25 (they say 2 of 4) and pass here. The other runtime suites
pass unchanged.
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.


LevelCode Shell — stage 1 of running things in the tab
A terminal panel and the agent's
run_commandin the browser edition, with no programs and no network: a bashinterpreter in JavaScript (the vendored, patched
just-bash3.6.0,Apache-2.0) over
vscode.workspace.fs. It parses bash and runs 82 coreutils-like commands (ls cat grep sed awk jq find sort uniq xargs diff tree …).npm testis "command not found", 127, and the agent is told so in its prompt.Stacked on #106 (the look), which is stacked on #105 (the edition). Draft. Nothing changes for the desktop app: the
commits that touch shared
extensions/levelcode-aicode are separable (host.jsseam,agent.js,shellCommit.js),and the desktop path is byte-for-byte what it was (pinned by
shellSeam.test.js;agentRunCommand.test.jsuntouched).What a visitor gets
folder and exported variables, history, Tab completion, Ctrl+C that really stops a runaway loop (the engine runs in a
nested Worker and is
terminate()d), andpath:line:colin output is a link to the file at that line.tab, and what they change goes through the same review as
edit_file: Keep and Undo.The safety rules (what to look at in review)
set, everything reviewed or the whole command refused (
extensions/levelcode-ai/shellCommit.jsfor theagent,
web/shell/src/commit.jsfor the visitor's own terminal): outside the folders, protected paths (.git .vscode .levelcode .cursor .husky .github/workflows AGENTS.md CLAUDE.md …), binary/BOM/>1 MiB, line-ending changes, dirtybuffers, changed on disk, case-only renames, >300 files — one refusal and none of the command's changes are
applied. A stopped, timed-out or limit-hit command applies nothing. The commit is bounded in time and a Stop ends
it at once.
allow-list filter and a card only through
forDisplay; replies xterm would type into the prompt are swallowed;OSC 633 only from one module with a per-terminal nonce.
levelcode.*setting isapplication-scopedexcept an explicit display allow-list — a folder's
.vscode/settings.jsoncan no longer redirect the session tokenor turn Autopilot on (found while reviewing this, measured in the real workbench).
Verification — what was run, and what was not
./scripts/test-extensions.sh, 117 files): macOS Node 24 with the real xterm.js 6.1 (headless) as theoracle for the line editor and
LEVELCODE_REQUIRE_XTERM=1/LEVELCODE_REQUIRE_WORKER=1; macOS Node 18.20.5; and, on anearlier commit of this branch, Linux Node 24 and 18 in docker (the Docker engine became unavailable before the last
engine commit, so those two were not re-run on it). The runtime suite runs the real engine in a worker thread;
CI sets
LEVELCODE_REQUIRE_WORKER=1.listed with a reason in a shrink-only table), 1,778 cp/mv operand shapes, 568 xargs groups, 596 time zones; a seeded
change-set consistency fixture; 4,600 fuzz scripts under three process time zones. Final independent corpus:
30,270 differential units, 0 regressions against the last verified checkpoint, 207 wrong-to-right.
Linux Node 18; engine mutants for every patch and wrapper branch.
regressions that every suite passed (
cp -r d d2/,xargsover 16k files, a swallowedteeerror, a check budgetscaled without the write budget); each has a red-before-fix test now.
web/test/e2e-shell.mjs, 38 checks, 39 with the extension host on its own origin): lazyactivation, a terminal from the palette, typing, cd, 127, Ctrl+C on
sleep 60(~0.3 s), a link click opening thefile at
Ln 2, Col 3, pasted multi-line buffers through width, height and width-and-height resizes, an emoji name,hostile output that does not type into the prompt or rename the terminal,
run_commandoffered withoutbackground, a file written through the review with Keep/Undo, a.vscodewrite refused whole, a timeout thatapplies nothing, the mirror tab.
e2e.mjs --dist(now assertsrun_commandis offered),e2e-look.mjs,e2e-signin-tab.mjs,e2e-byok.mjsgreen.a provider call that hangs behind a permission prompt; long sessions and 2,000-file commands in a real tab.
Review round (2026-10-14)
All five review threads are answered in place and resolved. All five were in Tab completion (
completion.js, with one change inline-editor.js), andefdd2a5fixes them:cleanText's list) is no longer offered. Before,rm a+ Tab could type another file's name.;does.NAME=assignment keeps the command position.<or>ends there, unless it is a descriptor.>&,<&,>|,&>and&>>are read as redirections.cat "my fi|"becamecat "my file.txt" "; now the closing quote is consumed.Every new test fails on the code as reviewed.
4a9803fmergesfeat/web-look(#106 and #105's review fixes); the build id is now a hash of everything staged, the shell's bundle included.Verified on
efdd2a5: the unit gate passes, 122 files on Node 24 and on Node 18, withLEVELCODE_REQUIRE_WORKER=1andLEVELCODE_REQUIRE_XTERM=1. In headless Chrome against a release built from it:e2e-shell38/38,e2e30/30,e2e-look26/26,e2e-signin-tab20/20,e2e-byok5/5. The redirection table was checked withbash -xcon bash 3.2 only. The golden suite's bash 5.1 needs Docker, which was unavailable, so the differential corpus was not re-run.Second review (2026-10-14, after
developwas merged in)The two code-quality threads are resolved (
187fd21: an unused constant and a dead assignment). A full review of the PR found the issues below; each was reproduced on the code as it was, and each fix has tests that fail on that code.Data and safety
ba7b046: a case-only rename on a folder that ignores case (mv a.txt A.txton macOS or Windows) produced a change set holding onlydelete a.txt, so the file was deleted. Other ways of reaching one entry under two spellings did the same. The overlay now refuses such a command whole and says how to rename in two steps.356bc47: the protected paths were case-sensitive, so an agent's command could write.GIT/config(the real.git/configon those disks). They are now matched with case and Unicode form folded.6c57cef: making or removing a folder in a protected place is refused too.76a3b92: a command's change could replace a file that had unsaved changes in the editor. The refusal existed but was never wired up.c1e7d46: with two workspace folders of the same name, a change to the second was written into the first. Such changes are now refused, because the review addresses files by folder name.Correctness
82e6cea: in a browser on Windows, every agent command failed, because the shell was handedfsPath(\proj). It now gets the URI path.6820015: a refused commit's "NONE of this command's file changes were applied" line could be cut off by the cap on notes.e37ec59,27b790e: the prompt said "a stopped command changes nothing". A Stop during the review can leave changes in Keep/Undo, and the prompt and the result now say so.1a47179: for output over 256 KiB, the result showed a "start" taken from the middle and under-counted what was omitted.397df3f: the applied count included a folder the review had kept back.720f5a3:skills.jsgets// @ts-check(it checks clean).Verified on
397df3f: the unit gate passes, 124 files, on Node 24 and on Node 18, withLEVELCODE_REQUIRE_WORKER=1andLEVELCODE_REQUIRE_XTERM=1. In headless Chrome against a release built from it:e2e-shell38/38,e2e30/30,e2e-look26/26,e2e-signin-tab20/20,e2e-byok5/5. The repo's mutation table: 368 of 371 caught, 0 survived. The other 3 are stale anchors that already failed before this round. Not run: a real case-insensitive File System Access folder (fakes only), a browser on Windows, and the Linux and bash 5.1 suites (Docker unavailable).Left as they were:
// @ts-checkafter'use strict', where TypeScript ignores it, so they are not type-checked. Moving it surfaces about 170–200 existing diagnostics.fsPath, so they likely fail in a browser on Windows too. That code came with feat(web): LevelCode in your browser — the same workbench and AI extension as a static site, signed in with a LevelCode account #105.Known differences from a real shell (all in
web/shell/engine/NOTES.md§4–§5, and the model is told the main ones)grep -rdoes not enter.git; back-references insed/grep -Efail closed (RE2); there is one file time(
-atime/-ctimeread the modification time);date -dtakes a defined subset of GNU's relative grammar; aredirection writes to the path when it ends, not an open descriptor; resizing a terminal while a multi-line command
is pending can leave a stale copy of the prompt (never lose earlier output in 33,600 measured sessions; the rate and
method are in
src/terminal/NOTES.md).Owner decisions
.github/workflowsand CI files areever allowed.
connect-src 'self'and, for threads, COOP/COEP on the editor origin. Stage 3 (real Node/npm) needsWebContainers or BrowserPod: not open source, needs a StackBlitz agreement. Neither is started.
onetime.Layout of the diff
web/shell/(engine, runtime, terminal;README.mdis the contract,engine/NOTES.mdandsrc/terminal/NOTES.mdgo deeper),
extensions/levelcode-ai(the seam:host.js,agent.js,shellCommit.js),web/ai-extension(manifest scoping, skills, copy),
scripts/build-web.mjs,web/lib/config.mjs,docs/WEB.md,CLAUDE.md, testsand fixtures. The engine is ~1.5 MB raw / ~445 KB gzip, fetched only when the shell is first used.