Skip to content

Commit 0f6a0dc

Browse files
authored
Merge pull request #107 from levelcodeai/feat/web-shell
web: LevelCode Shell — a terminal and run_command in the tab (stage 1: no programs, no network)
2 parents 9e63850 + 397df3f commit 0f6a0dc

177 files changed

Lines changed: 68909 additions & 49 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/test.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,9 @@ jobs:
3131
node-version: "24" # as release.yml
3232
- name: Extension unit tests
3333
run: ./scripts/test-extensions.sh
34+
env:
35+
# The shell's runtime suite runs the REAL engine in a worker thread (stop on a CPU-bound loop, a deadline,
36+
# a kill). Without this a runner that could not start a worker would skip it and still be green.
37+
# (LEVELCODE_REQUIRE_XTERM is not set: the real-xterm halves need the Code-OSS checkout, which this job
38+
# does not have. They run, and say so, wherever the checkout is.)
39+
LEVELCODE_REQUIRE_WORKER: "1"

‎CLAUDE.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,19 @@ the desktop app exactly as it was:
139139
The embedder's command API is the module's `commands` (`api.commands.executeCommand`): `create()` returns a
140140
disposable, so `workbench.commands` is `undefined` — a helper that used it ran inside a `try` and silently did
141141
nothing until a browser test asked for its effect.
142+
- **LevelCode Shell (`web/shell/`, `docs/WEB.md` "LevelCode Shell") is a simulated bash, not a machine.** The
143+
contract is `web/shell/README.md`; where code and it disagree one is a bug. Rules: a command's output reaches a
144+
terminal **only** through `src/terminal/output-filter.js`, and OSC 633 comes **only** from
145+
`shell-integration.js`; a string that is not this extension's own constant (file names, command output, model
146+
text, provider errors) goes through `src/sanitize.js` before it is shown or put in a note; the engine worker
147+
never writes (the host validates and commits a change set, whole-or-nothing); the vendored `just-bash` is
148+
patched only by `engine/vendor/just-bash/<v>/apply-patches.mjs`, anchor-checked; a change to a shared
149+
`extensions/levelcode-ai` file that the desktop also runs is its own commit and leaves
150+
`agentRunCommand.test.js` untouched. `web/test/e2e-shell.mjs` is the browser check; the unit gate runs with
151+
`LEVELCODE_REQUIRE_XTERM=1 LEVELCODE_REQUIRE_WORKER=1` where the Code-OSS checkout is present.
152+
- The browser's `levelcode.*` settings are `application`-scoped except the display preferences in
153+
`WORKSPACE_SETTINGS_OK` (`web/ai-extension/manifest.js`): Workspace Trust is off in a tab, so an unscoped
154+
setting would be read from a folder's `.vscode/settings.json`.
142155
- The session store (`createSecretStorage`) belongs to every tab: no cache, one-key read-modify-write under a
143156
Web Lock. The refresh token is rotated on use; do not reintroduce a start-up snapshot.
144157
- Build: `node scripts/build-web.mjs --account <origin>` → `dist-web/`; serve with `node web/serve.mjs --dist dist-web`.

‎docs/WEB.md‎

Lines changed: 111 additions & 8 deletions
Large diffs are not rendered by default.

‎extensions/levelcode-ai/agent.js‎

Lines changed: 198 additions & 25 deletions
Large diffs are not rendered by default.

‎extensions/levelcode-ai/host.js‎

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,23 @@
2222

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

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

31+
/**
32+
* A shell the browser build can borrow: a LevelCode-owned extension (web/shell) that exports one. It is
33+
* connected lazily (connectShell), once, the first time a run needs to know whether there is one.
34+
* @type {{ info: ShellInfo, spawn: (command: string, opts: SpawnOptions) => ChildLike, forDisplay?: (text: string) => string } | null}
35+
*/
36+
let shell = null;
37+
3038
/** What this host can do. Tools and prompt text that need a capability are withheld without it. */
3139
const caps = Object.freeze({
32-
/** Start programs: run_command, background commands, verify commands. */
33-
shell: !isBrowser,
40+
/** Run command lines: run_command and verify commands. A real shell on the desktop; a registered one in the browser. */
41+
get shell() { return !isBrowser || shell !== null; },
3442
/** Start MCP servers as local processes (stdio). */
3543
mcpStdio: !isBrowser,
3644
/** Search file contents with the ripgrep binary the editor ships. */
@@ -162,6 +170,135 @@ async function withReads(run) {
162170
return result;
163171
}
164172

173+
/* ----- running a command line ---------------------------------------------------------------- */
174+
175+
/** The extension (browser build) that provides the shell, and the export agent.js reads from it. */
176+
const SHELL_EXTENSION_ID = 'levelcode.levelcode-shell';
177+
const SHELL_API_VERSION = 1;
178+
179+
/**
180+
* @typedef {object} ShellInfo What a shell says about itself; the prompt and the tool list are built from it.
181+
* @property {string} label shown on the command's card instead of "zsh"
182+
* @property {boolean} background run_command background:true works (a command that outlives its tool call)
183+
* @property {boolean} programs real programs (node, npm, git, python …) can be started
184+
* @property {boolean} network the network is reachable from a command
185+
* @property {string[]} commands command names, for the prompt
186+
* @property {string[]} [missing] commands the model is likely to try that are NOT here (node, npm, git …)
187+
* @property {{ commandCount?: number, loopIterations?: number, outputBytes?: number, fileReadBytes?: number, runReadBytes?: number }} [limits]
188+
* what stops a command, so the prompt can say so
189+
*
190+
* @typedef {{ name: string, root: string, uri: string }} WorkspaceFolderRef
191+
* @typedef {{ kind: 'write'|'delete'|'mkdir'|'rmdir', path: string, existed?: boolean, before?: Uint8Array|null, after?: Uint8Array, viaDir?: string }} Change
192+
*
193+
* @typedef {object} SpawnOptions
194+
* @property {string} cwd absolute path of a workspace folder, or inside one
195+
* @property {WorkspaceFolderRef[]} [folders] the workspace folders, live and in order (a shell mounts each at its root)
196+
* For a registered shell `cwd` and each `root` are URI paths (`/proj`), never fsPath, which is `\proj` in a
197+
* browser on Windows (agent.js shellSpawnOpts); the desktop's spawn is given the fsPath it always was.
198+
* @property {Record<string,string>} [env] extra environment
199+
* @property {string} [explanation] the model's own label for the command (at most 300 characters), for a tab that mirrors it
200+
* @property {(changes: Change[], control?: { cancelled(): boolean }) => Promise<{ rest: Change[], notes: string[], failed?: number, refused?: boolean }>} [commit]
201+
* Agent runs only. Called once, after the command has run to its END (never after Stop, a timeout or an engine
202+
* limit: those apply nothing) and BEFORE 'close', with the files the command left changed. The caller validates
203+
* the whole set and reviews it, or refuses it whole; it returns what the shell still has to do itself (make and
204+
* remove directories), lines to append to the command's output, and how many changes it could NOT apply (the
205+
* shell then does not report success). Without it the shell writes the changes straight to the workspace (the
206+
* user's own terminal). `control.cancelled()` turns true when the run is stopped (or the shell stops waiting for
207+
* the review): while the set is being checked the caller then applies nothing; once it is applying, what is already
208+
* in the review stays and nothing more is started (a change in flight is not interrupted).
209+
*
210+
* @typedef {object} ChildLike The part of a Node ChildProcess that agent.js's runCommand uses.
211+
* @property {number} [pid] leave undefined: agent.js then kills with child.kill(signal)
212+
* @property {{ on: (ev: 'data', cb: (chunk: string|Uint8Array) => void) => any }} stdout strings (or Buffers)
213+
* @property {{ on: (ev: 'data', cb: (chunk: string|Uint8Array) => void) => any }} stderr
214+
* @property {(ev: 'close'|'error', cb: Function) => any} on 'close'(code) once, after every 'data'
215+
* @property {(signal?: string) => boolean} kill
216+
* 'SIGTERM' (Stop) and 'SIGKILL' (the timeout, or a Stop that was not heard within 1.5 s). A command that is still
217+
* running is stopped and NONE of its file changes are applied — they were only ever staged. One that has ended and
218+
* whose changes are being reviewed (`commit`) is settled at once, and what the review had applied stays in it. The
219+
* result says which. 'close' is emitted once. agent.js reports 130 for a stop and 124 for a timeout whatever the
220+
* code of 'close'.
221+
*/
222+
223+
/**
224+
* Start a command line. The desktop does what runCommand always did — a `sh -c` in its own process group — and
225+
* nothing else; the browser hands the line to the shell that registered itself. With none registered the browser's
226+
* child_process shim throws ENOSYS, as it did before there was a shell (callers withhold the tool first: caps.shell).
227+
* @param {string} command
228+
* @param {SpawnOptions} opts
229+
* @returns {ChildLike}
230+
*/
231+
function spawn(command, opts) {
232+
if (shell !== null) { return shell.spawn(command, opts); }
233+
return nodeCp.spawn(command, { cwd: opts.cwd, shell: true, detached: true });
234+
}
235+
236+
/** The registered shell's description, or null (always null on the desktop: its shell is the user's own). */
237+
function shellInfo() { return shell ? shell.info : null; }
238+
239+
/**
240+
* A model-written string as the user is shown it on an approval card or a command card: invisible and
241+
* direction-changing characters made visible, so what is approved is what runs. The desktop shows text as it
242+
* always has; a shell that brings its own rules (the browser's) supplies them.
243+
* @param {string} text
244+
* @returns {string}
245+
*/
246+
function displaySafe(text) {
247+
const f = shell && shell.forDisplay;
248+
if (typeof f !== 'function') { return text; }
249+
try { const out = f(String(text)); return typeof out === 'string' ? out : text; } catch { return text; }
250+
}
251+
252+
/**
253+
* Register a shell. Called by connectShell; exported so a test (or a later in-process engine) can register one.
254+
* @param {{ info: ShellInfo, spawn: Function, forDisplay?: (text: string) => string }} impl
255+
* @returns {{ dispose(): void }}
256+
*/
257+
function registerShell(impl) {
258+
if (!impl || typeof impl.spawn !== 'function' || !impl.info) { throw new Error('a shell needs info and spawn'); }
259+
shell = impl;
260+
return { dispose() { if (shell === impl) { shell = null; } } };
261+
}
262+
263+
/** @type {Promise<boolean> | null} */
264+
let connecting = null;
265+
/** How long a run waits for the shell extension to activate before it carries on without one. */
266+
const CONNECT_TIMEOUT_MS = 10000;
267+
/**
268+
* Browser only: find the LevelCode Shell extension, activate it (it is lazily activated, so this is where it
269+
* first loads), and register what it exports. Idempotent; resolves true when a shell is now available. Never
270+
* throws and never waits for ever: a missing, broken or slow shell is "no shell" for this run, which is what the
271+
* browser build was before it existed — and the next run asks again (by then it may have finished activating).
272+
* @param {{ timeoutMs?: number }} [opts]
273+
* @returns {Promise<boolean>}
274+
*/
275+
function connectShell(opts) {
276+
if (!isBrowser) { return Promise.resolve(false); }
277+
if (shell !== null) { return Promise.resolve(true); }
278+
if (connecting) { return connecting; }
279+
const timeoutMs = (opts && opts.timeoutMs) || CONNECT_TIMEOUT_MS;
280+
const attempt = (async () => {
281+
let timer;
282+
try {
283+
const ext = vscode.extensions.getExtension(SHELL_EXTENSION_ID);
284+
if (!ext) { return false; }
285+
const api = ext.isActive ? ext.exports : await Promise.race([
286+
ext.activate(),
287+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('the shell extension did not activate in ' + timeoutMs + ' ms')), timeoutMs); }),
288+
]);
289+
if (!api || api.apiVersion !== SHELL_API_VERSION || !api.agent) { return false; }
290+
registerShell(api.agent);
291+
return true;
292+
} catch { return false; }
293+
finally { if (timer) { clearTimeout(timer); } }
294+
})();
295+
// Cleared AFTER it has settled, from outside: an early `return false` above runs the finally block before this
296+
// assignment, so clearing inside it left a settled promise stored and every later run got its stale answer.
297+
connecting = attempt;
298+
attempt.then(() => { if (connecting === attempt) { connecting = null; } });
299+
return attempt;
300+
}
301+
165302
/* ----- checking paths synchronously --------------------------------------------------------- */
166303

167304
/**
@@ -266,6 +403,7 @@ function findFiles(glob, exclude, max) {
266403

267404
module.exports = {
268405
isBrowser, caps,
406+
spawn, shellInfo, displaySafe, registerShell, connectShell,
269407
uriFor, isUserDocument, readBytes, readText, readTextOrNull, stat, exists, isBinary, withReads, withStats, knownFilesView, findFiles, openAuth,
270408
// For tests.
271409
_path: path,

0 commit comments

Comments
 (0)