Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c914e89
web: a folder cannot configure the editor
ndemianc Oct 11, 2026
fa83ac4
agent: a run with skills off sends the host's prompt, not the desktop's
ndemianc Oct 11, 2026
972c354
seam: host.spawn, a live caps.shell, a per-run prompt and tool list, …
ndemianc Oct 11, 2026
fca2978
fix: a long command's failure and exit code are what the model sees
ndemianc Oct 11, 2026
8daa85e
fix: a background command's registry sees every chunk, not only what …
ndemianc Oct 11, 2026
4e3cdce
shell seam: everything reviewed or the whole command refused; the pro…
ndemianc Oct 11, 2026
817f3cb
web: the playbooks say what is true in a tab
ndemianc Oct 11, 2026
73d9da8
web/shell: the engine, the runtime and the terminal (round 1, each in…
ndemianc Oct 11, 2026
d6d6ba0
agent: a model-written command is shown as it will run; the shell get…
ndemianc Oct 11, 2026
a7f393c
web/shell: fix round 1 (engine 18 patches, runtime, terminal links), …
ndemianc Oct 11, 2026
b973e5c
web/shell: fix round 2 for the engine and the runtime (the terminal's…
ndemianc Oct 11, 2026
ed9db75
web/shell: runtime fix round 3 (stop keeps its notes, the check budge…
ndemianc Oct 11, 2026
648a547
web/shell: terminal fix round 2 (links per folder, line log, rows res…
ndemianc Oct 11, 2026
b2343b9
web/shell: engine fix round 3, terminal fix round 3, runtime round 4;…
ndemianc Oct 11, 2026
6afd1c5
web/test: shell suites fail when a test waits for something that neve…
ndemianc Oct 11, 2026
2e1708a
runtime.stat: 'other' for what is neither a file nor a folder (FileTy…
ndemianc Oct 11, 2026
659d0e0
WIP (unverified): engine round 4 and terminal round 4 as the killed o…
ndemianc Oct 11, 2026
01f4255
web/shell terminal: fix round 4 (the ghost bound, floors checked afte…
ndemianc Oct 12, 2026
db945c3
web/shell engine: fix round 4 (tr NUL and escapes, quoted "$@" as a c…
ndemianc Oct 12, 2026
f905d4e
web/shell: docs and README to the final numbers (60 patches, 842 gold…
ndemianc Oct 12, 2026
a8faa41
web/shell engine: the last pass (grep -r enters dot entries, array de…
ndemianc Oct 12, 2026
e93bc09
golden: r6-touch-date-relative no longer depends on the second bounda…
ndemianc Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ jobs:
node-version: "24" # as release.yml
- name: Extension unit tests
run: ./scripts/test-extensions.sh
env:
# The shell's runtime suite runs the REAL engine in a worker thread (stop on a CPU-bound loop, a deadline,
# a kill). Without this a runner that could not start a worker would skip it and still be green.
# (LEVELCODE_REQUIRE_XTERM is not set: the real-xterm halves need the Code-OSS checkout, which this job
# does not have. They run, and say so, wherever the checkout is.)
LEVELCODE_REQUIRE_WORKER: "1"
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,19 @@ the desktop app exactly as it was:
The embedder's command API is the module's `commands` (`api.commands.executeCommand`): `create()` returns a
disposable, so `workbench.commands` is `undefined` — a helper that used it ran inside a `try` and silently did
nothing until a browser test asked for its effect.
- **LevelCode Shell (`web/shell/`, `docs/WEB.md` "LevelCode Shell") is a simulated bash, not a machine.** The
contract is `web/shell/README.md`; where code and it disagree one is a bug. Rules: a command's output reaches a
terminal **only** through `src/terminal/output-filter.js`, and OSC 633 comes **only** from
`shell-integration.js`; a string that is not this extension's own constant (file names, command output, model
text, provider errors) goes through `src/sanitize.js` before it is shown or put in a note; the engine worker
never writes (the host validates and commits a change set, whole-or-nothing); the vendored `just-bash` is
patched only by `engine/vendor/just-bash/<v>/apply-patches.mjs`, anchor-checked; a change to a shared
`extensions/levelcode-ai` file that the desktop also runs is its own commit and leaves
`agentRunCommand.test.js` untouched. `web/test/e2e-shell.mjs` is the browser check; the unit gate runs with
`LEVELCODE_REQUIRE_XTERM=1 LEVELCODE_REQUIRE_WORKER=1` where the Code-OSS checkout is present.
- The browser's `levelcode.*` settings are `application`-scoped except the display preferences in
`WORKSPACE_SETTINGS_OK` (`web/ai-extension/manifest.js`): Workspace Trust is off in a tab, so an unscoped
setting would be read from a folder's `.vscode/settings.json`.
- The session store (`createSecretStorage`) belongs to every tab: no cache, one-key read-modify-write under a
Web Lock. The refresh token is rotated on use; do not reintroduce a start-up snapshot.
- Build: `node scripts/build-web.mjs --account <origin>` → `dist-web/`; serve with `node web/serve.mjs --dist dist-web`.
Expand Down
117 changes: 109 additions & 8 deletions docs/WEB.md

Large diffs are not rendered by default.

186 changes: 158 additions & 28 deletions extensions/levelcode-ai/agent.js

Large diffs are not rendered by default.

137 changes: 135 additions & 2 deletions extensions/levelcode-ai/host.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,23 @@

const vscode = require('vscode');
const nodeFs = require('fs');
const nodeCp = require('child_process'); // the desktop's spawn; in the browser build this is a shim that always throws ENOSYS
const path = require('path');

/** True in the browser build (web/ai-extension/entry.js sets the flag before anything else loads). */
const isBrowser = !!globalThis.__LEVELCODE_BROWSER_HOST__;

/**
* A shell the browser build can borrow: a LevelCode-owned extension (web/shell) that exports one. It is
* connected lazily (connectShell), once, the first time a run needs to know whether there is one.
* @type {{ info: ShellInfo, spawn: (command: string, opts: SpawnOptions) => ChildLike, forDisplay?: (text: string) => string } | null}
*/
let shell = null;

/** What this host can do. Tools and prompt text that need a capability are withheld without it. */
const caps = Object.freeze({
/** Start programs: run_command, background commands, verify commands. */
shell: !isBrowser,
/** Run command lines: run_command and verify commands. A real shell on the desktop; a registered one in the browser. */
get shell() { return !isBrowser || shell !== null; },
/** Start MCP servers as local processes (stdio). */
mcpStdio: !isBrowser,
/** Search file contents with the ripgrep binary the editor ships. */
Expand Down Expand Up @@ -141,6 +149,130 @@ async function withReads(run) {
return result;
}

/* ----- running a command line ---------------------------------------------------------------- */

/** The extension (browser build) that provides the shell, and the export agent.js reads from it. */
const SHELL_EXTENSION_ID = 'levelcode.levelcode-shell';
const SHELL_API_VERSION = 1;

/**
* @typedef {object} ShellInfo What a shell says about itself; the prompt and the tool list are built from it.
* @property {string} label shown on the command's card instead of "zsh"
* @property {boolean} background run_command background:true works (a command that outlives its tool call)
* @property {boolean} programs real programs (node, npm, git, python …) can be started
* @property {boolean} network the network is reachable from a command
* @property {string[]} commands command names, for the prompt
* @property {string[]} [missing] commands the model is likely to try that are NOT here (node, npm, git …)
* @property {{ commandCount?: number, loopIterations?: number, outputBytes?: number, fileReadBytes?: number, runReadBytes?: number }} [limits]
* what stops a command, so the prompt can say so
*
* @typedef {{ name: string, root: string, uri: string }} WorkspaceFolderRef
* @typedef {{ kind: 'write'|'delete'|'mkdir'|'rmdir', path: string, existed?: boolean, before?: Uint8Array|null, after?: Uint8Array, viaDir?: string }} Change
*
* @typedef {object} SpawnOptions
* @property {string} cwd absolute path of a workspace folder, or inside one
* @property {WorkspaceFolderRef[]} [folders] the workspace folders, live and in order (a shell mounts each at its root)
* @property {Record<string,string>} [env] extra environment
* @property {string} [explanation] the model's own label for the command (at most 300 characters), for a tab that mirrors it
* @property {(changes: Change[], control?: { cancelled(): boolean }) => Promise<{ rest: Change[], notes: string[], failed?: number, refused?: boolean }>} [commit]
* Agent runs only. Called once, after the command has run to its END (never after Stop, a timeout or an engine
* limit: those apply nothing) and BEFORE 'close', with the files the command left changed. The caller validates
* the whole set and reviews it, or refuses it whole; it returns what the shell still has to do itself (make and
* remove directories), lines to append to the command's output, and how many changes it could NOT apply (the
* shell then does not report success). Without it the shell writes the changes straight to the workspace (the
* user's own terminal). `control.cancelled()` turns true when the run was stopped while the set was being
* checked: the caller then applies nothing (once the first write has begun nothing interrupts it).
*
* @typedef {object} ChildLike The part of a Node ChildProcess that agent.js's runCommand uses.
* @property {number} [pid] leave undefined: agent.js then kills with child.kill(signal)
* @property {{ on: (ev: 'data', cb: (chunk: string|Uint8Array) => void) => any }} stdout strings (or Buffers)
* @property {{ on: (ev: 'data', cb: (chunk: string|Uint8Array) => void) => any }} stderr
* @property {(ev: 'close'|'error', cb: Function) => any} on 'close'(code) once, after every 'data'
* @property {(signal?: string) => boolean} kill
* 'SIGTERM' (Stop) and 'SIGKILL' (the timeout, or a Stop that was not heard within 1.5 s): the command is stopped
* and NONE of its file changes are applied — they were only ever staged — and the result says so. 'close' is
* emitted once. agent.js reports 130 for a stop and 124 for a timeout whatever the code of 'close'.
*/

/**
* Start a command line. The desktop does what runCommand always did — a `sh -c` in its own process group — and
* nothing else; the browser hands the line to the shell that registered itself. With none registered the browser's
* child_process shim throws ENOSYS, as it did before there was a shell (callers withhold the tool first: caps.shell).
* @param {string} command
* @param {SpawnOptions} opts
* @returns {ChildLike}
*/
function spawn(command, opts) {
if (shell !== null) { return shell.spawn(command, opts); }
return nodeCp.spawn(command, { cwd: opts.cwd, shell: true, detached: true });
}

/** The registered shell's description, or null (always null on the desktop: its shell is the user's own). */
function shellInfo() { return shell ? shell.info : null; }

/**
* A model-written string as the user is shown it on an approval card or a command card: invisible and
* direction-changing characters made visible, so what is approved is what runs. The desktop shows text as it
* always has; a shell that brings its own rules (the browser's) supplies them.
* @param {string} text
* @returns {string}
*/
function displaySafe(text) {
const f = shell && shell.forDisplay;
if (typeof f !== 'function') { return text; }
try { const out = f(String(text)); return typeof out === 'string' ? out : text; } catch { return text; }
}

/**
* Register a shell. Called by connectShell; exported so a test (or a later in-process engine) can register one.
* @param {{ info: ShellInfo, spawn: Function, forDisplay?: (text: string) => string }} impl
* @returns {{ dispose(): void }}
*/
function registerShell(impl) {
if (!impl || typeof impl.spawn !== 'function' || !impl.info) { throw new Error('a shell needs info and spawn'); }
shell = impl;
return { dispose() { if (shell === impl) { shell = null; } } };
}

/** @type {Promise<boolean> | null} */
let connecting = null;
/** How long a run waits for the shell extension to activate before it carries on without one. */
const CONNECT_TIMEOUT_MS = 10000;
/**
* Browser only: find the LevelCode Shell extension, activate it (it is lazily activated, so this is where it
* first loads), and register what it exports. Idempotent; resolves true when a shell is now available. Never
* throws and never waits for ever: a missing, broken or slow shell is "no shell" for this run, which is what the
* browser build was before it existed — and the next run asks again (by then it may have finished activating).
* @param {{ timeoutMs?: number }} [opts]
* @returns {Promise<boolean>}
*/
function connectShell(opts) {
if (!isBrowser) { return Promise.resolve(false); }
if (shell !== null) { return Promise.resolve(true); }
if (connecting) { return connecting; }
const timeoutMs = (opts && opts.timeoutMs) || CONNECT_TIMEOUT_MS;
const attempt = (async () => {
let timer;
try {
const ext = vscode.extensions.getExtension(SHELL_EXTENSION_ID);
if (!ext) { return false; }
const api = ext.isActive ? ext.exports : await Promise.race([
ext.activate(),
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('the shell extension did not activate in ' + timeoutMs + ' ms')), timeoutMs); }),
]);
if (!api || api.apiVersion !== SHELL_API_VERSION || !api.agent) { return false; }
registerShell(api.agent);
return true;
} catch { return false; }
finally { if (timer) { clearTimeout(timer); } }
})();
// Cleared AFTER it has settled, from outside: an early `return false` above runs the finally block before this
// assignment, so clearing inside it left a settled promise stored and every later run got its stale answer.
connecting = attempt;
attempt.then(() => { if (connecting === attempt) { connecting = null; } });
return attempt;
}

/* ----- sending the user to sign in ------------------------------------------------------------- */

/** Registered by the page (web/main.js); it takes the tab to the account site and the sign-in comes back to it. */
Expand Down Expand Up @@ -185,6 +317,7 @@ function findFiles(glob, exclude, max) {

module.exports = {
isBrowser, caps,
spawn, shellInfo, displaySafe, registerShell, connectShell,
uriFor, readBytes, readText, readTextOrNull, stat, exists, isBinary, withReads, findFiles, openAuth,
// For tests.
_path: path,
Expand Down
Loading
Loading