Skip to content

Commit 972c354

Browse files
committed
seam: host.spawn, a live caps.shell, a per-run prompt and tool list, 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).
1 parent fa83ac4 commit 972c354

8 files changed

Lines changed: 746 additions & 37 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 108 additions & 28 deletions
Large diffs are not rendered by default.

‎extensions/levelcode-ai/host.js‎

Lines changed: 100 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 } | 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. */
@@ -141,6 +149,95 @@ async function withReads(run) {
141149
return result;
142150
}
143151

152+
/* ----- running a command line ---------------------------------------------------------------- */
153+
154+
/** The extension (browser build) that provides the shell, and the export agent.js reads from it. */
155+
const SHELL_EXTENSION_ID = 'levelcode.levelcode-shell';
156+
const SHELL_API_VERSION = 1;
157+
158+
/**
159+
* @typedef {object} ShellInfo What a shell says about itself; the prompt and the tool list are built from it.
160+
* @property {string} label shown on the command's card instead of "zsh"
161+
* @property {boolean} background run_command background:true works (a command that outlives its tool call)
162+
* @property {boolean} programs real programs (node, npm, git, python …) can be started
163+
* @property {boolean} network the network is reachable from a command
164+
* @property {string[]} commands command names, for the prompt
165+
* @property {string[]} [missing] commands the model is likely to try that are NOT here (node, npm, git …)
166+
*
167+
* @typedef {{ name: string, root: string, uri: string }} WorkspaceFolderRef
168+
* @typedef {{ kind: 'write'|'delete'|'mkdir'|'rmdir', path: string, existed?: boolean, before?: Uint8Array|null, after?: Uint8Array, viaDir?: string }} Change
169+
*
170+
* @typedef {object} SpawnOptions
171+
* @property {string} cwd absolute path of a workspace folder, or inside one
172+
* @property {WorkspaceFolderRef[]} [folders] the workspace folders, live and in order (a shell mounts each at its root)
173+
* @property {Record<string,string>} [env] extra environment
174+
* @property {(changes: Change[]) => Promise<{ rest: Change[], notes: string[] }>} [commit]
175+
* Agent runs only. Called once, after the command has ended (also after Stop or a timeout) and BEFORE 'close',
176+
* with the files the command left changed. The caller reviews the ones it can and returns the rest, which the
177+
* shell applies itself, plus lines to append to the command's output. Without it the shell writes them straight
178+
* to the workspace (the user's own terminal).
179+
*
180+
* @typedef {object} ChildLike The part of a Node ChildProcess that agent.js's runCommand uses.
181+
* @property {number} [pid] leave undefined: agent.js then kills with child.kill(signal)
182+
* @property {{ on: (ev: 'data', cb: (chunk: string|Uint8Array) => void) => any }} stdout strings (or Buffers)
183+
* @property {{ on: (ev: 'data', cb: (chunk: string|Uint8Array) => void) => any }} stderr
184+
* @property {(ev: 'close'|'error', cb: Function) => any} on 'close'(code) once, after every 'data'
185+
* @property {(signal?: string) => boolean} kill
186+
*/
187+
188+
/**
189+
* Start a command line. The desktop does what runCommand always did — a `sh -c` in its own process group — and
190+
* nothing else; the browser hands the line to the shell that registered itself. With none registered the browser's
191+
* child_process shim throws ENOSYS, as it did before there was a shell (callers withhold the tool first: caps.shell).
192+
* @param {string} command
193+
* @param {SpawnOptions} opts
194+
* @returns {ChildLike}
195+
*/
196+
function spawn(command, opts) {
197+
if (shell !== null) { return shell.spawn(command, opts); }
198+
return nodeCp.spawn(command, { cwd: opts.cwd, shell: true, detached: true });
199+
}
200+
201+
/** The registered shell's description, or null (always null on the desktop: its shell is the user's own). */
202+
function shellInfo() { return shell ? shell.info : null; }
203+
204+
/**
205+
* Register a shell. Called by connectShell; exported so a test (or a later in-process engine) can register one.
206+
* @param {{ info: ShellInfo, spawn: Function }} impl
207+
* @returns {{ dispose(): void }}
208+
*/
209+
function registerShell(impl) {
210+
if (!impl || typeof impl.spawn !== 'function' || !impl.info) { throw new Error('a shell needs info and spawn'); }
211+
shell = impl;
212+
return { dispose() { if (shell === impl) { shell = null; } } };
213+
}
214+
215+
/** @type {Promise<boolean> | null} */
216+
let connecting = null;
217+
/**
218+
* Browser only: find the LevelCode Shell extension, activate it (it is lazily activated, so this is where it
219+
* first loads), and register what it exports. Idempotent; resolves true when a shell is now available. Never
220+
* throws: a missing or broken shell is "no shell", which is what the browser build was before it existed.
221+
* @returns {Promise<boolean>}
222+
*/
223+
function connectShell() {
224+
if (!isBrowser) { return Promise.resolve(false); }
225+
if (shell !== null) { return Promise.resolve(true); }
226+
if (connecting) { return connecting; }
227+
connecting = (async () => {
228+
try {
229+
const ext = vscode.extensions.getExtension(SHELL_EXTENSION_ID);
230+
if (!ext) { return false; }
231+
const api = ext.isActive ? ext.exports : await ext.activate();
232+
if (!api || api.apiVersion !== SHELL_API_VERSION || !api.agent) { return false; }
233+
registerShell(api.agent);
234+
return true;
235+
} catch { return false; }
236+
finally { connecting = null; }
237+
})();
238+
return connecting;
239+
}
240+
144241
/* ----- sending the user to sign in ------------------------------------------------------------- */
145242

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

186283
module.exports = {
187284
isBrowser, caps,
285+
spawn, shellInfo, registerShell, connectShell,
188286
uriFor, readBytes, readText, readTextOrNull, stat, exists, isBinary, withReads, findFiles, openAuth,
189287
// For tests.
190288
_path: path,
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* LevelCode — AI: a shell command's file changes, routed through the SAME review as edit_file,
3+
* write_file and delete_file.
4+
*
5+
* A host whose shell is simulated (LevelCode in the browser) hands back, when a command ends, the files
6+
* it left changed. Each one that can be shown as text goes through ctx.applyEdit / ctx.applyDelete —
7+
* which is what gives a command Keep/Undo, a per-turn checkpoint (recordTouch) and a place in the
8+
* verify step (ctx.touched, ctx.editCount). What cannot be reviewed is applied by the shell itself and
9+
* said so, or refused and said so: the shell is never a way around what edit_file refuses.
10+
*
11+
* Pure and vscode-free, like verify.js and commandSafety.js, so it is unit-tested with plain node
12+
* (test/shellCommit.test.js).
13+
*--------------------------------------------------------------------------------------------*/
14+
// @ts-check
15+
'use strict';
16+
17+
/** The folders agent.js's FILE_EXCLUDES leaves out of list_files and search: written, never reviewed. */
18+
const EXCLUDED = /(^|\/)(node_modules|\.git|out|dist|\.vscode-test)(\/|$)|\.map$/;
19+
/** Larger than this is not shown as a diff card (snapshots are kept in workspaceState). */
20+
const MAX_REVIEW_BYTES = 1024 * 1024;
21+
const utf8 = new TextDecoder('utf-8', { fatal: true });
22+
23+
/** A NUL in the first 8000 bytes is "binary" (host.isBinary); anything else must also decode as UTF-8. */
24+
function isText(bytes) {
25+
if (!bytes) { return false; }
26+
const n = Math.min(bytes.length, 8000);
27+
for (let i = 0; i < n; i++) { if (bytes[i] === 0) { return false; } }
28+
try { utf8.decode(bytes); return true; } catch { return false; }
29+
}
30+
function same(a, b) {
31+
if (a === null || a === undefined) { return b === null || b === undefined; }
32+
if (b === null || b === undefined || a.length !== b.length) { return false; }
33+
for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { return false; } }
34+
return true;
35+
}
36+
37+
/**
38+
* The path a review call takes for an absolute path: relative to its workspace folder, and — with more than
39+
* one folder — prefixed with the folder's name, which is how resolveWorkspacePath finds the folder
40+
* deterministically (an unprefixed path means "the first folder").
41+
* @param {string} abs
42+
* @param {Array<{name:string, root:string}>} folders
43+
* @returns {string|null} null when `abs` is in none of them
44+
*/
45+
function reviewPathFor(abs, folders) {
46+
let best = null;
47+
for (const f of folders) {
48+
const inside = abs === f.root || abs.startsWith(f.root === '/' ? '/' : f.root + '/');
49+
if (inside && (!best || f.root.length > best.root.length)) { best = f; }
50+
}
51+
if (!best) { return null; }
52+
const sub = abs.slice(best.root === '/' ? 1 : best.root.length + 1);
53+
return folders.length > 1 ? best.name + '/' + sub : sub;
54+
}
55+
56+
/**
57+
* @typedef {import('./host').Change} Change
58+
* @param {Change[]} changes
59+
* @param {{ applyEdit?: Function, applyDelete?: Function, touched?: Set<string>, editCount?: number }} ctx
60+
* @param {{ folders: () => Array<{name:string, root:string}>, readNow: (abs: string) => Promise<Uint8Array|null> }} io
61+
* @returns {Promise<{ rest: Change[], applied: Change[], failed: Array<{c: Change, why: string}>, notes: string[] }>}
62+
*/
63+
async function commitShellChanges(changes, ctx, io) {
64+
const folders = io.folders();
65+
/** @type {Change[]} */ const rest = [];
66+
/** @type {Change[]} */ const applied = [];
67+
/** @type {Array<{c: Change, why: string}>} */ const failed = [];
68+
const touched = ctx.touched;
69+
const bump = () => { ctx.editCount = (ctx.editCount || 0) + 1; };
70+
71+
for (const c of changes.filter((x) => x.kind === 'write').sort((a, b) => (a.path < b.path ? -1 : 1))) {
72+
const rel = reviewPathFor(c.path, folders);
73+
if (rel === null) { failed.push({ c, why: 'outside the workspace folders' }); continue; }
74+
if (EXCLUDED.test(c.path)) { rest.push(c); continue; }
75+
const after = c.after || new Uint8Array(0);
76+
const reviewable = isText(after) && after.length <= MAX_REVIEW_BYTES
77+
&& (!c.existed || (isText(c.before) && /** @type {Uint8Array} */ (c.before).length <= MAX_REVIEW_BYTES));
78+
if (!reviewable) {
79+
if (c.existed) { failed.push({ c, why: 'it is binary or too large to review — edit it in the editor' }); } else { rest.push(c); }
80+
continue;
81+
}
82+
// Changed on disk while the command ran (the user saved, another tab wrote)? Do not overwrite it.
83+
const now = await io.readNow(c.path);
84+
if (!same(now, c.existed ? c.before : null)) { failed.push({ c, why: 'it changed while the command ran, so the command\'s result was not applied' }); continue; }
85+
if (typeof ctx.applyEdit !== 'function') { rest.push(c); continue; }
86+
const ok = await ctx.applyEdit({ path: rel, exists: !!c.existed, proposed: utf8.decode(after) });
87+
if (ok) { bump(); if (touched) { touched.add(c.path); } applied.push(c); }
88+
else { failed.push({ c, why: 'the editor could not apply it' }); }
89+
}
90+
for (const c of changes.filter((x) => x.kind === 'delete').sort((a, b) => (a.path < b.path ? -1 : 1))) {
91+
const rel = reviewPathFor(c.path, folders);
92+
if (rel === null) { failed.push({ c, why: 'outside the workspace folders' }); continue; }
93+
if (EXCLUDED.test(c.path)) { rest.push(c); continue; }
94+
if (!isText(c.before) || /** @type {Uint8Array} */ (c.before).length > MAX_REVIEW_BYTES) { failed.push({ c, why: 'it is binary or too large to review — delete it in the editor' }); continue; }
95+
if (typeof ctx.applyDelete !== 'function') { rest.push(c); continue; }
96+
const ok = await ctx.applyDelete({ path: rel });
97+
if (ok) { bump(); if (touched) { touched.delete(c.path); } applied.push(c); }
98+
else { failed.push({ c, why: 'the editor could not delete it' }); }
99+
}
100+
// A directory goes only when every file in it did.
101+
const blocked = new Set(failed.map((f) => f.c.viaDir).filter(Boolean));
102+
for (const c of changes) {
103+
if (c.kind === 'mkdir') { rest.push(c); }
104+
else if (c.kind === 'rmdir' && !blocked.has(c.path)) { rest.push(c); }
105+
}
106+
107+
/** @type {string[]} */ const notes = [];
108+
const sign = (c) => (c.kind === 'delete' ? '-' : c.existed ? '~' : '+');
109+
if (applied.length) {
110+
const list = applied.slice(0, 8).map((c) => sign(c) + (reviewPathFor(c.path, folders) || c.path)).join(' ');
111+
notes.push('[LevelCode Shell: ' + applied.length + ' file' + (applied.length > 1 ? 's' : '') + ' changed, ready for Keep/Undo: ' + list + (applied.length > 8 ? ' …and ' + (applied.length - 8) + ' more' : '') + ']');
112+
}
113+
const direct = rest.filter((c) => c.kind === 'write' || c.kind === 'delete').length;
114+
if (direct) { notes.push('[LevelCode Shell: ' + direct + ' file' + (direct > 1 ? 's' : '') + ' in build output or binary form were written without review]'); }
115+
for (const f of failed.slice(0, 5)) { notes.push('[LevelCode Shell: NOT applied: ' + (reviewPathFor(f.c.path, folders) || f.c.path) + ' — ' + f.why + ']'); }
116+
if (failed.length > 5) { notes.push('[LevelCode Shell: …and ' + (failed.length - 5) + ' more not applied]'); }
117+
return { rest, applied, failed, notes };
118+
}
119+
120+
module.exports = { commitShellChanges, reviewPathFor, isText, EXCLUDED, MAX_REVIEW_BYTES };

‎extensions/levelcode-ai/test/agentNoWorkspace.test.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ test('the context popover is billed for the list that was actually sent', () =>
100100
'no rootless token estimate — the popover reports the full tool cost for a list that was not sent');
101101
assert.match(agent, /const builtinsTokensEst = root \? TOOLS_TOKENS_EST : PORTABLE_TOOLS_TOKENS_EST;/,
102102
'the estimate no longer switches on the root');
103-
assert.match(agent, /const toolsTokensEst = \(mcp\.tools\.length \|\| extras\.length\)[\s\S]{0,120}: builtinsTokensEst;/,
103+
assert.match(agent, /const toolsTokensEst = \(mcp\.tools\.length \|\| extras\.length(?: \|\| reshaped)?\)[\s\S]{0,120}: builtinsTokensEst;/,
104104
'the plain path still falls back to the full-TOOLS constant');
105105

106106
// The two constants must actually differ, or the guard above passes on a list that gates nothing —

0 commit comments

Comments
 (0)