|
22 | 22 |
|
23 | 23 | const vscode = require('vscode'); |
24 | 24 | 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 |
25 | 26 | const path = require('path'); |
26 | 27 |
|
27 | 28 | /** True in the browser build (web/ai-extension/entry.js sets the flag before anything else loads). */ |
28 | 29 | const isBrowser = !!globalThis.__LEVELCODE_BROWSER_HOST__; |
29 | 30 |
|
| 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 | + |
30 | 38 | /** What this host can do. Tools and prompt text that need a capability are withheld without it. */ |
31 | 39 | 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; }, |
34 | 42 | /** Start MCP servers as local processes (stdio). */ |
35 | 43 | mcpStdio: !isBrowser, |
36 | 44 | /** Search file contents with the ripgrep binary the editor ships. */ |
@@ -162,6 +170,135 @@ async function withReads(run) { |
162 | 170 | return result; |
163 | 171 | } |
164 | 172 |
|
| 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 | + |
165 | 302 | /* ----- checking paths synchronously --------------------------------------------------------- */ |
166 | 303 |
|
167 | 304 | /** |
@@ -266,6 +403,7 @@ function findFiles(glob, exclude, max) { |
266 | 403 |
|
267 | 404 | module.exports = { |
268 | 405 | isBrowser, caps, |
| 406 | + spawn, shellInfo, displaySafe, registerShell, connectShell, |
269 | 407 | uriFor, isUserDocument, readBytes, readText, readTextOrNull, stat, exists, isBinary, withReads, withStats, knownFilesView, findFiles, openAuth, |
270 | 408 | // For tests. |
271 | 409 | _path: path, |
|
0 commit comments