Skip to content

web: LevelCode Shell — a terminal and run_command in the tab (stage 1: no programs, no network) - #107

Merged
ndemianc merged 38 commits into
developfrom
feat/web-shell
Oct 14, 2026
Merged

ndemianc merged 38 commits into
developfrom
feat/web-shell

Conversation

@ndemianc

@ndemianc ndemianc commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

LevelCode Shell — stage 1 of running things in the tab

A terminal panel and the agent's run_command in the browser edition, with no programs and no network: a bash
interpreter in JavaScript (the vendored, patched just-bash 3.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 test is "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-ai code are separable (host.js seam, agent.js, shellCommit.js),
and the desktop path is byte-for-byte what it was (pinned by shellSeam.test.js; agentRunCommand.test.js untouched).

What a visitor gets

  • LevelCode Shell: New Terminal (palette, Ctrl+`, or the button in an empty Terminal panel): a persistent working
    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), and path:line:col in output is a link to the file at that line.
  • The agent's commands run in it too, are mirrored (read-only, without taking the focus) in a LevelCode Agent
    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)

  1. The engine cannot write. Copy-on-write overlay over a read-only RPC; the host validates and commits a change
    set, everything reviewed or the whole command refused (extensions/levelcode-ai/shellCommit.js for the
    agent, web/shell/src/commit.js for 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, dirty
    buffers, 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.
  2. No secrets, no network in the engine worker; its only file operations are three bounded reads.
  3. Untrusted text (file names, command output, model text, provider errors) reaches a terminal only through an
    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.
  4. Settings provenance: Workspace Trust is off in a tab, so every levelcode.* setting is application-scoped
    except an explicit display allow-list — a folder's .vscode/settings.json can no longer redirect the session token
    or turn Autopilot on (found while reviewing this, measured in the real workbench).

Verification — what was run, and what was not

  • Unit gate (./scripts/test-extensions.sh, 117 files): macOS Node 24 with the real xterm.js 6.1 (headless) as the
    oracle for the line editor and LEVELCODE_REQUIRE_XTERM=1/LEVELCODE_REQUIRE_WORKER=1; macOS Node 18.20.5; and, on an
    earlier 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.
  • Differential against GNU bash 5.1 / coreutils 8.32 (docker recordings): 905 golden cases (837 identical, 68
    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.
  • Mutation tables: 371 one-edit mutants of the runtime, commit and sanitiser, all caught on macOS Node 24 and
    Linux Node 18; engine mutants for every patch and wrapper branch.
  • Independent review, five rounds (three owners + a different verifier per role each round). Every round found
    regressions that every suite passed (cp -r d d2/, xargs over 16k files, a swallowed tee error, a check budget
    scaled without the write budget); each has a red-before-fix test now.
  • Real Chrome (web/test/e2e-shell.mjs, 38 checks, 39 with the extension host on its own origin): lazy
    activation, a terminal from the palette, typing, cd, 127, Ctrl+C on sleep 60 (~0.3 s), a link click opening the
    file 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_command offered without
    background, a file written through the review with Keep/Undo, a .vscode write refused whole, a timeout that
    applies nothing, the mirror tab. e2e.mjs --dist (now asserts run_command is offered), e2e-look.mjs,
    e2e-signin-tab.mjs, e2e-byok.mjs green.
  • Not run: Safari, Firefox, phones; Windows/Linux key bytes and IME; a real OS folder picker through the shell and
    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 in line-editor.js), and efdd2a5 fixes them:

  • A name with a character the editor would drop when typing it (U+200B and the rest of cleanText's list) is no longer offered. Before, rm a + Tab could type another file's name.
  • An unquoted line end ends a command, as ; does.
  • A leading NAME= assignment keeps the command position.
  • A word written against < or > ends there, unless it is a descriptor. >&, <&, >|, &> and &>> are read as redirections.
  • Tab inside a word replaces through the rest of the word. Before, cat "my fi|" became cat "my file.txt" "; now the closing quote is consumed.

Every new test fails on the code as reviewed. 4a9803f merges feat/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, with LEVELCODE_REQUIRE_WORKER=1 and LEVELCODE_REQUIRE_XTERM=1. In headless Chrome against a release built from it: e2e-shell 38/38, e2e 30/30, e2e-look 26/26, e2e-signin-tab 20/20, e2e-byok 5/5. The redirection table was checked with bash -xc on 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 develop was 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.txt on macOS or Windows) produced a change set holding only delete 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/config on 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 handed fsPath (\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.js gets // @ts-check (it checks clean).

Verified on 397df3f: the unit gate passes, 124 files, on Node 24 and on Node 18, with LEVELCODE_REQUIRE_WORKER=1 and LEVELCODE_REQUIRE_XTERM=1. In headless Chrome against a release built from it: e2e-shell 38/38, e2e 30/30, e2e-look 26/26, e2e-signin-tab 20/20, e2e-byok 5/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:

Known differences from a real shell (all in web/shell/engine/NOTES.md §4–§5, and the model is told the main ones)

grep -r does not enter .git; back-references in sed/grep -E fail closed (RE2); there is one file time
(-atime/-ctime read the modification time); date -d takes a defined subset of GNU's relative grammar; a
redirection 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

  • The commit policy's caps (300 files, 1 MiB) and the protected list; whether .github/workflows and CI files are
    ever allowed.
  • Stage 2 (Pyodide as a custom command behind the same seam) needs the runtime's files fetched by a page with
    connect-src 'self' and, for threads, COOP/COEP on the editor origin. Stage 3 (real Node/npm) needs
    WebContainers or BrowserPod: not open source, needs a StackBlitz agreement. Neither is started.
  • The account-site copy ("no terminal") is a separate stacked PR in onetime.

Layout of the diff

web/shell/ (engine, runtime, terminal; README.md is the contract, engine/NOTES.md and src/terminal/NOTES.md
go 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, tests
and fixtures. The engine is ~1.5 MB raw / ~445 KB gzip, fetched only when the shell is first used.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Tab completion can change file operands and corrupt shell syntax, creating correctness and data-loss risks.

5 open findings
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.

Comment thread web/shell/src/terminal/completion.js
Comment thread web/shell/src/terminal/completion.js Outdated
Comment thread web/shell/src/terminal/completion.js Outdated
Comment thread web/shell/src/terminal/completion.js
Comment thread web/shell/src/terminal/completion.js Outdated
# 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.
Base automatically changed from feat/web-look to feat/web-edition October 14, 2026 19:02
Base automatically changed from feat/web-edition to develop October 14, 2026 19:19
Comment thread web/shell/src/terminal/input-parser.js Fixed
Comment thread web/shell/src/terminal/links.js Fixed
… 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.
@ndemianc
ndemianc merged commit 0f6a0dc into develop Oct 14, 2026
2 checks passed
@ndemianc
ndemianc deleted the feat/web-shell branch October 14, 2026 21:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants