diff --git a/.changeset/ai-autopilot-local-runner.md b/.changeset/ai-autopilot-local-runner.md
new file mode 100644
index 0000000..77972eb
--- /dev/null
+++ b/.changeset/ai-autopilot-local-runner.md
@@ -0,0 +1,5 @@
+---
+"@gemstack/ai-autopilot": minor
+---
+
+Add `LocalRunner`, the first real adapter behind the runner seam. Where `FakeRunner` simulates a workspace in memory, `LocalRunner` boots each workspace as a real temp directory on the host: real files via `node:fs` (path-traversal guarded to the workspace root), real commands via `child_process` (shell, per-command `cwd`/`env`/`timeoutMs`), a localhost `preview`, and a `dispose()` that removes the workspace. It is the reference the sandboxed adapters (WebContainer, Docker, Flue) mirror. It runs commands unsandboxed on the host, so it is documented for trusted/CI use only, not untrusted agent-authored code. Part of the ai-autopilot epic (#97), issue #106.
diff --git a/examples/autopilot-quickstart/README.md b/examples/autopilot-quickstart/README.md
new file mode 100644
index 0000000..0a27288
--- /dev/null
+++ b/examples/autopilot-quickstart/README.md
@@ -0,0 +1,36 @@
+# @gemstack/example-autopilot-quickstart
+
+A runnable, end-to-end quickstart for [`@gemstack/ai-autopilot`](../../packages/ai-autopilot) — the four layers of the epic composed into one "build a feature" flow:
+
+```
+personas → Supervisor → runner (sandbox) → surfaces
+```
+
+A lead planner decomposes the task **"Add a paginated Orders page backed by an orders table"** and routes each subtask to a stack-aware **persona** (`universal-orm-modeler`, `vike-page-builder`, `ui-intent-designer`). The **Supervisor** dispatches them; each persona worker acts inside a **runner** sandbox — writing Vike/ORM files through `runnerTools` — and progress is rendered through the **surfaces** (a terminal sink for live output, plus a background handle exposing events + result).
+
+It runs **offline**: `AiFake` scripts the model, so there's no API key and the output is deterministic.
+
+## Run it
+
+```bash
+pnpm install && pnpm build # from the repo root, to build the packages first
+pnpm --filter @gemstack/example-autopilot-quickstart start
+```
+
+You'll see the live plan, the files written into the sandbox, a build + preview URL, and the synthesized result.
+
+## Test it
+
+```bash
+pnpm --filter @gemstack/example-autopilot-quickstart test
+```
+
+## Going real
+
+The only fakes here are `AiFake` (the model) and `FakeRunner` (the sandbox). To run it for real, drop `AiFake` and give the personas real model strings, and swap `FakeRunner` for a real runner adapter (a `FlueRunner`, WebContainer, or Docker sandbox) — the `Runner` interface is the same, so nothing else in the flow changes.
+
+## What each file shows
+
+- **`src/autopilot.ts`** — the composition: `personaWorkers` + `runnerTools`, `agentPlanner` fed the `personaRoster`, a `Supervisor`, and `launchAutopilot` + `terminalSink` for the surfaces.
+- **`src/main.ts`** — the runnable demo.
+- **`src/autopilot.test.ts`** — asserts the four layers actually compose (plan routes by persona, files land in the sandbox, surfaces capture events).
diff --git a/examples/autopilot-quickstart/package.json b/examples/autopilot-quickstart/package.json
new file mode 100644
index 0000000..bff0539
--- /dev/null
+++ b/examples/autopilot-quickstart/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@gemstack/example-autopilot-quickstart",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Runnable end-to-end quickstart for @gemstack/ai-autopilot: personas + Supervisor + runner + surfaces composed into one build-a-feature flow, offline via AiFake.",
+ "type": "module",
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "test": "tsc -p tsconfig.test.json && cd dist-test && node --test",
+ "clean": "rm -rf dist-test",
+ "start": "tsx src/main.ts"
+ },
+ "dependencies": {
+ "@gemstack/ai-autopilot": "workspace:^",
+ "@gemstack/ai-sdk": "workspace:^",
+ "zod": "^4.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "tsx": "^4.19.0",
+ "typescript": "^5.4.0"
+ }
+}
diff --git a/examples/autopilot-quickstart/src/autopilot.test.ts b/examples/autopilot-quickstart/src/autopilot.test.ts
new file mode 100644
index 0000000..daf7d82
--- /dev/null
+++ b/examples/autopilot-quickstart/src/autopilot.test.ts
@@ -0,0 +1,41 @@
+import { describe, it } from 'node:test'
+import assert from 'node:assert/strict'
+import { runQuickstart, TASK } from './autopilot.js'
+
+describe('autopilot quickstart: the four layers compose end-to-end', () => {
+ it('plans by persona, dispatches, acts in the sandbox, and surfaces progress', async () => {
+ const lines: string[] = []
+ const result = await runQuickstart(line => lines.push(line))
+
+ // Supervisor: three subtasks, each routed to a stack persona, all succeeded.
+ assert.equal(result.run.plan.length, 3)
+ assert.deepEqual(
+ result.run.plan.map(s => s.worker).sort(),
+ ['ui-intent-designer', 'universal-orm-modeler', 'vike-page-builder'],
+ )
+ assert.ok(result.run.results.every(r => r.ok), 'every subtask succeeded')
+
+ // Runner: each persona worker wrote its file into the sandbox via runnerTools.
+ assert.ok('database/schema.ts' in result.files)
+ assert.ok('pages/orders/+Page.jsx' in result.files)
+ assert.match(result.files['database/schema.ts']!, /orders/)
+ // the seed file is still there
+ assert.ok('package.json' in result.files)
+
+ // Runner: the post-build exec ran and a preview URL was exposed.
+ assert.equal(result.build.exitCode, 0)
+ assert.match(result.build.stdout, /built/)
+ assert.match(result.previewUrl, /:5173$/)
+
+ // Surfaces: the terminal sink printed a plan line and per-subtask lines;
+ // the background handle captured the same events.
+ assert.ok(lines.some(l => l.includes('plan:')), 'terminal printed the plan')
+ assert.ok(lines.some(l => l.includes('✓')), 'terminal printed a completed subtask')
+ assert.equal(result.events[0]!.type, 'plan')
+ assert.equal(result.events.at(-1)!.type, 'synthesize')
+ })
+
+ it('exposes the task constant for the runnable demo', () => {
+ assert.match(TASK, /Orders/)
+ })
+})
diff --git a/examples/autopilot-quickstart/src/autopilot.ts b/examples/autopilot-quickstart/src/autopilot.ts
new file mode 100644
index 0000000..b1a4294
--- /dev/null
+++ b/examples/autopilot-quickstart/src/autopilot.ts
@@ -0,0 +1,150 @@
+import { AiFake, agent, type ToolCall } from '@gemstack/ai-sdk'
+import {
+ Supervisor,
+ agentPlanner,
+ stackPersonas,
+ personaRoster,
+ personaInstructions,
+ personaTools,
+ FakeRunner,
+ runnerTools,
+ terminalSink,
+ launchAutopilot,
+ type RunnerSession,
+ type SupervisorEvent,
+ type SupervisorRun,
+} from '@gemstack/ai-autopilot'
+
+/**
+ * The end-to-end shape of the ai-autopilot epic, in one flow:
+ *
+ * personas → Supervisor → runner (sandbox) → surfaces
+ *
+ * A lead planner decomposes a build task and routes each subtask to a
+ * stack-aware **persona**; the **Supervisor** dispatches them; each persona
+ * worker acts inside a **runner** sandbox (writing Vike/ORM files via
+ * `runnerTools`); progress is rendered through the **surfaces** (a terminal
+ * sink plus a background handle with a live stream).
+ *
+ * It runs offline: `AiFake` scripts the model, so there is no API key and the
+ * output is deterministic. Swapping `FakeRunner` for a real runner and dropping
+ * the fake is the only change needed to run it for real.
+ */
+
+/** The feature we ask autopilot to build. */
+export const TASK = 'Add a paginated Orders page backed by an orders table'
+
+/** Each subtask, the persona that should own it, and the file it writes. */
+const WORK = [
+ {
+ worker: 'universal-orm-modeler',
+ description: 'Define the orders schema and a migration',
+ file: 'database/schema.ts',
+ contents: "export const orders = table('orders', { id: id(), total: integer(), createdAt: timestamp() })\n",
+ },
+ {
+ worker: 'vike-page-builder',
+ description: 'Build the /orders page that lists orders, paginated',
+ file: 'pages/orders/+Page.jsx',
+ contents: "export default function Page({ orders }) { return }\n",
+ },
+ {
+ worker: 'ui-intent-designer',
+ description: 'Express the orders list as intent, not hardcoded markup',
+ file: 'pages/orders/+config.js',
+ contents: "export default { meta: { OrderList: { env: { server: true, client: true } } } }\n",
+ },
+] as const
+
+/** Script the fake: step 0 is the planner's JSON; then each worker writes its file. */
+function scriptModel(fake: AiFake): void {
+ const plannerOutput = JSON.stringify(WORK.map(w => ({ description: w.description, worker: w.worker })))
+ const workerSteps = WORK.flatMap((w, i) => {
+ const toolCalls: ToolCall[] = [
+ { id: `write-${i}`, name: 'write_file', arguments: { path: w.file, contents: w.contents } },
+ ]
+ return [{ toolCalls }, { text: `Wrote ${w.file}` }]
+ })
+ // concurrency: 1 makes the provider-call order deterministic:
+ // 0 = planner, then each worker's (tool-call, final-text) pair in plan order.
+ fake.respondWithSequence([{ text: plannerOutput }, ...workerSteps])
+}
+
+/** Build one worker agent per persona, each with hands inside the sandbox. */
+function personaWorkersWithSandbox(session: RunnerSession): Record> {
+ const sandbox = runnerTools(session)
+ return Object.fromEntries(
+ stackPersonas.map(p => [
+ p.name,
+ agent({ instructions: personaInstructions(p), tools: [...personaTools(p), ...sandbox] }),
+ ]),
+ )
+}
+
+export interface QuickstartResult {
+ run: SupervisorRun
+ /** Every event, in order (from the background handle). */
+ events: SupervisorEvent[]
+ /** Files the workers wrote into the sandbox. */
+ files: Record
+ /** Output of the post-build `exec`. */
+ build: { stdout: string; exitCode: number }
+ /** The preview URL the sandbox exposed. */
+ previewUrl: string
+}
+
+/**
+ * Run the whole flow once and return everything the surfaces exposed.
+ *
+ * @param write where terminal-surface lines go (default: no-op; `main.ts` prints).
+ */
+export async function runQuickstart(write: (line: string) => void = () => {}): Promise {
+ const fake = AiFake.fake()
+ scriptModel(fake)
+ try {
+ // Runner: an in-memory sandbox seeded with a minimal Vike project.
+ const runner = new FakeRunner({
+ onExec: cmd =>
+ cmd.includes('build')
+ ? { stdout: 'orders page built', stderr: '', exitCode: 0 }
+ : { stdout: '', stderr: '', exitCode: 0 },
+ })
+ const session = await runner.boot({ files: { 'package.json': '{ "name": "shop" }\n' } })
+
+ // Personas → Supervisor. The planner is told the roster so it routes by role.
+ const planner = agentPlanner(
+ agent(`You are the lead engineer. Decompose the task and route each subtask to a persona.\n\n${personaRoster(stackPersonas)}`),
+ )
+ const start = (onEvent: (e: SupervisorEvent) => void) =>
+ new Supervisor({
+ plan: planner,
+ workers: personaWorkersWithSandbox(session),
+ concurrency: 1,
+ onEvent,
+ }).run(TASK)
+
+ // Surfaces: one run feeds both the terminal (live) and a background handle.
+ const terminal = terminalSink({ write })
+ const handle = launchAutopilot(onEvent =>
+ start(e => {
+ terminal(e)
+ onEvent(e)
+ }),
+ )
+ const run = await handle.result()
+
+ // Runner again: build the app and grab a preview URL.
+ const build = await session.exec('pnpm build')
+ const preview = session.preview ? await session.preview({ port: 5173 }) : { url: '' }
+
+ return {
+ run,
+ events: handle.events(),
+ files: session.snapshot(),
+ build: { stdout: build.stdout, exitCode: build.exitCode },
+ previewUrl: preview.url,
+ }
+ } finally {
+ fake.restore()
+ }
+}
diff --git a/examples/autopilot-quickstart/src/main.ts b/examples/autopilot-quickstart/src/main.ts
new file mode 100644
index 0000000..7c300bc
--- /dev/null
+++ b/examples/autopilot-quickstart/src/main.ts
@@ -0,0 +1,23 @@
+import { runQuickstart, TASK } from './autopilot.js'
+
+/** Run the quickstart and print what each surface exposed. Offline (AiFake). */
+async function main(): Promise {
+ console.log(`Task: ${TASK}\n`)
+ console.log('--- live progress (terminal surface) ---')
+ const result = await runQuickstart(line => process.stdout.write(line + '\n'))
+
+ console.log('\n--- files written into the sandbox (runner) ---')
+ for (const path of Object.keys(result.files).sort()) console.log(` ${path}`)
+
+ console.log('\n--- build + preview (runner) ---')
+ console.log(` build: exit ${result.build.exitCode} — ${result.build.stdout}`)
+ console.log(` preview: ${result.previewUrl}`)
+
+ console.log('\n--- synthesized result (Supervisor) ---')
+ console.log(result.run.text.replace(/^/gm, ' '))
+}
+
+main().catch(err => {
+ console.error(err)
+ process.exitCode = 1
+})
diff --git a/examples/autopilot-quickstart/tsconfig.json b/examples/autopilot-quickstart/tsconfig.json
new file mode 100644
index 0000000..404aab4
--- /dev/null
+++ b/examples/autopilot-quickstart/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "noEmit": true, "rootDir": "src" },
+ "include": ["src"]
+}
diff --git a/examples/autopilot-quickstart/tsconfig.test.json b/examples/autopilot-quickstart/tsconfig.test.json
new file mode 100644
index 0000000..eebda2f
--- /dev/null
+++ b/examples/autopilot-quickstart/tsconfig.test.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "outDir": "dist-test", "rootDir": "src" },
+ "include": ["src"]
+}
diff --git a/packages/ai-autopilot/README.md b/packages/ai-autopilot/README.md
index d95928b..2dbaf3a 100644
--- a/packages/ai-autopilot/README.md
+++ b/packages/ai-autopilot/README.md
@@ -79,9 +79,24 @@ interface: **WebContainer** (instant in-browser Vike preview), a **Docker**
sandbox on our servers, or a **Flue** sandbox (in-memory / edge / container). We
sit on those harnesses rather than competing with them.
-This package ships the interface plus a **`FakeRunner`** (the runner analog of
-`ai-sdk`'s `AiFake`) so autopilot can be driven and tested without any sandbox
-infra. Real adapters land as separate packages.
+This package ships the interface, a **`FakeRunner`** (the runner analog of
+`ai-sdk`'s `AiFake`) so autopilot can be driven and tested without any infra,
+and a **`LocalRunner`** — the first real adapter: each workspace is a real temp
+directory on the host, with real files and real child processes. The sandboxed
+adapters (WebContainer, Docker, Flue) land as separate packages and mirror it.
+
+`LocalRunner` runs commands **unsandboxed on the host**, so reach for it only
+where execution is already trusted (local dev, or a CI job that is itself the
+sandbox) — not to run untrusted, agent-authored code.
+
+```ts
+import { LocalRunner } from '@gemstack/ai-autopilot'
+
+const runner = new LocalRunner()
+const session = await runner.boot({ files: { 'app.js': "console.log('hi')" } })
+await session.exec('node app.js') // → { stdout: 'hi\n', stderr: '', exitCode: 0 }
+await session.dispose() // removes the temp workspace
+```
```ts
import { FakeRunner, runnerTools } from '@gemstack/ai-autopilot'
diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts
index 846c6e9..1951e63 100644
--- a/packages/ai-autopilot/src/index.ts
+++ b/packages/ai-autopilot/src/index.ts
@@ -25,6 +25,7 @@
* `sandbox` so WebContainer / Docker / Flue drop in behind one interface.
*
* - {@link FakeRunner} — in-memory runner for tests
+ * - {@link LocalRunner} — real host workspace (fs + child processes); the first real adapter
* - {@link runnerTools} — expose a booted session to an agent as sandbox tools
*
* Surfaces run the same autopilot in the terminal, an in-page UI, or a
@@ -56,6 +57,8 @@ export {
export {
FakeRunner,
FakeRunnerSession,
+ LocalRunner,
+ LocalRunnerSession,
RunnerError,
runnerTools,
type Runner,
@@ -70,6 +73,7 @@ export {
type FakeRunnerOptions,
type FakeExec,
type RecordedExec,
+ type LocalRunnerOptions,
type RunnerToolsOptions,
} from './runner/index.js'
export {
diff --git a/packages/ai-autopilot/src/runner/index.ts b/packages/ai-autopilot/src/runner/index.ts
index 6a9e758..b3dd1da 100644
--- a/packages/ai-autopilot/src/runner/index.ts
+++ b/packages/ai-autopilot/src/runner/index.ts
@@ -8,6 +8,7 @@
* tests; real adapters land separately.
*
* - {@link FakeRunner} — in-memory runner for tests (the runner analog of `AiFake`)
+ * - {@link LocalRunner} — real host workspace (fs + child processes); the first real adapter
* - {@link runnerTools} — expose a session to an agent as sandbox tools
*/
export type {
@@ -29,4 +30,5 @@ export {
type FakeExec,
type RecordedExec,
} from './fake.js'
+export { LocalRunner, LocalRunnerSession, type LocalRunnerOptions } from './local.js'
export { runnerTools, type RunnerToolsOptions } from './tools.js'
diff --git a/packages/ai-autopilot/src/runner/local.test.ts b/packages/ai-autopilot/src/runner/local.test.ts
new file mode 100644
index 0000000..17c9139
--- /dev/null
+++ b/packages/ai-autopilot/src/runner/local.test.ts
@@ -0,0 +1,149 @@
+import { describe, it } from 'node:test'
+import assert from 'node:assert/strict'
+import { existsSync } from 'node:fs'
+import { LocalRunner } from './local.js'
+import { RunnerError } from './types.js'
+
+describe('LocalRunner.boot', () => {
+ it('seeds a real workspace with files, including nested paths', async () => {
+ const s = await new LocalRunner().boot({ files: { './pages/+Page.jsx': 'PAGE', 'app.ts': 'APP' } })
+ try {
+ assert.equal(await s.fs.read('pages/+Page.jsx'), 'PAGE')
+ assert.equal(await s.fs.read('/app.ts'), 'APP') // leading slash normalized
+ assert.ok(existsSync(s.root))
+ } finally {
+ await s.dispose()
+ }
+ })
+
+ it('gives each session a distinct id and workspace', async () => {
+ const runner = new LocalRunner()
+ const a = await runner.boot()
+ const b = await runner.boot()
+ try {
+ assert.notEqual(a.id, b.id)
+ assert.notEqual(a.root, b.root)
+ } finally {
+ await a.dispose()
+ await b.dispose()
+ }
+ })
+})
+
+describe('LocalRunnerSession.fs', () => {
+ it('writes, reads, checks existence, lists recursively, and removes', async () => {
+ const s = await new LocalRunner().boot()
+ try {
+ assert.equal(await s.fs.exists('a.txt'), false)
+ await s.fs.write('src/a.txt', 'A')
+ await s.fs.write('src/b.txt', 'B')
+ await s.fs.write('root.txt', 'R')
+ assert.equal(await s.fs.exists('src/a.txt'), true)
+ assert.deepEqual(await s.fs.list('src'), ['src/a.txt', 'src/b.txt'])
+ assert.deepEqual(await s.fs.list(), ['root.txt', 'src/a.txt', 'src/b.txt'])
+ await s.fs.remove('src/a.txt')
+ assert.equal(await s.fs.exists('src/a.txt'), false)
+ } finally {
+ await s.dispose()
+ }
+ })
+
+ it('throws reading a missing file', async () => {
+ const s = await new LocalRunner().boot()
+ try {
+ await assert.rejects(() => s.fs.read('nope.txt'), RunnerError)
+ } finally {
+ await s.dispose()
+ }
+ })
+
+ it('refuses paths that escape the workspace', async () => {
+ const s = await new LocalRunner().boot()
+ try {
+ await assert.rejects(() => s.fs.read('../../etc/passwd'), RunnerError)
+ await assert.rejects(() => s.fs.write('../evil.txt', 'x'), RunnerError)
+ } finally {
+ await s.dispose()
+ }
+ })
+})
+
+describe('LocalRunnerSession.exec', () => {
+ it('runs a real command and captures stdout + exit code', async () => {
+ const s = await new LocalRunner().boot({ files: { 'app.js': "process.stdout.write('hi')" } })
+ try {
+ const r = await s.exec('node app.js')
+ assert.equal(r.stdout, 'hi')
+ assert.equal(r.exitCode, 0)
+ } finally {
+ await s.dispose()
+ }
+ })
+
+ it('reports a non-zero exit code', async () => {
+ const s = await new LocalRunner().boot()
+ try {
+ const r = await s.exec('node -e "process.exit(3)"')
+ assert.equal(r.exitCode, 3)
+ } finally {
+ await s.dispose()
+ }
+ })
+
+ it('honors cwd and env overrides', async () => {
+ const s = await new LocalRunner().boot({ files: { 'sub/probe.js': 'process.stdout.write(process.env.FOO || "")' } })
+ try {
+ const r = await s.exec('node probe.js', { cwd: 'sub', env: { FOO: 'bar' } })
+ assert.equal(r.stdout, 'bar')
+ } finally {
+ await s.dispose()
+ }
+ })
+
+ it('kills a command that exceeds its timeout', async () => {
+ const s = await new LocalRunner().boot()
+ try {
+ const r = await s.exec('node -e "setTimeout(()=>{}, 10000)"', { timeoutMs: 200 })
+ assert.equal(r.exitCode, 124)
+ assert.match(r.stderr, /timed out/)
+ } finally {
+ await s.dispose()
+ }
+ })
+})
+
+describe('LocalRunnerSession.preview', () => {
+ it('returns a localhost url on the requested port when supported', async () => {
+ const s = await new LocalRunner({ previewHost: 'http://127.0.0.1' }).boot()
+ try {
+ assert.equal(typeof s.preview, 'function')
+ assert.deepEqual(await s.preview!({ port: 5173 }), { url: 'http://127.0.0.1:5173', port: 5173 })
+ assert.equal((await s.preview!()).port, 3000) // default port
+ } finally {
+ await s.dispose()
+ }
+ })
+
+ it('omits the preview method when previews are disabled', async () => {
+ const s = await new LocalRunner({ preview: false }).boot()
+ try {
+ assert.equal(s.preview, undefined)
+ } finally {
+ await s.dispose()
+ }
+ })
+})
+
+describe('LocalRunnerSession.dispose', () => {
+ it('removes the workspace and blocks further exec/preview (idempotent)', async () => {
+ const s = await new LocalRunner().boot({ files: { 'a.txt': 'A' } })
+ const root = s.root
+ assert.ok(existsSync(root))
+ await s.dispose()
+ assert.equal(s.disposed, true)
+ assert.equal(existsSync(root), false)
+ await s.dispose() // idempotent
+ await assert.rejects(() => s.exec('ls'), RunnerError)
+ await assert.rejects(() => s.preview!(), RunnerError)
+ })
+})
diff --git a/packages/ai-autopilot/src/runner/local.ts b/packages/ai-autopilot/src/runner/local.ts
new file mode 100644
index 0000000..97c222b
--- /dev/null
+++ b/packages/ai-autopilot/src/runner/local.ts
@@ -0,0 +1,214 @@
+import { spawn } from 'node:child_process'
+import { mkdtemp, mkdir, readFile, writeFile, rm, readdir, access } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, dirname, resolve, relative, sep } from 'node:path'
+import type {
+ Runner,
+ RunnerSession,
+ RunnerFs,
+ BootOptions,
+ ExecOptions,
+ ExecResult,
+ Preview,
+ PreviewOptions,
+} from './types.js'
+import { RunnerError } from './types.js'
+
+export interface LocalRunnerOptions {
+ /** Base directory to create workspaces under. Default: the OS temp dir. */
+ root?: string
+ /** Whether booted sessions expose a `preview`. Default `true`. */
+ preview?: boolean
+ /** Origin returned by `preview()`, joined with the port. Default `http://localhost`. */
+ previewHost?: string
+}
+
+/** Normalize a workspace path to a canonical relative form (matches FakeFs). */
+function norm(path: string): string {
+ return path.replace(/^\.?\/+/, '').replace(/\/+$/, '')
+}
+
+/** Resolve `path` inside `root`, rejecting anything that escapes the workspace. */
+function within(root: string, path: string): string {
+ const abs = resolve(root, norm(path))
+ const rel = relative(root, abs)
+ if (rel === '..' || rel.startsWith('..' + sep)) {
+ throw new RunnerError(`path escapes the workspace: ${path}`)
+ }
+ return abs
+}
+
+class LocalFs implements RunnerFs {
+ constructor(private readonly root: string) {}
+
+ async read(path: string): Promise {
+ try {
+ return await readFile(within(this.root, path), 'utf8')
+ } catch (err) {
+ if (err instanceof RunnerError) throw err
+ throw new RunnerError(`no such file: ${path}`)
+ }
+ }
+
+ async write(path: string, contents: string): Promise {
+ const abs = within(this.root, path)
+ await mkdir(dirname(abs), { recursive: true })
+ await writeFile(abs, contents)
+ }
+
+ async remove(path: string): Promise {
+ await rm(within(this.root, path), { recursive: true, force: true })
+ }
+
+ async list(dir?: string): Promise {
+ const base = dir ? within(this.root, dir) : this.root
+ const out: string[] = []
+ const walk = async (abs: string): Promise => {
+ let entries
+ try {
+ entries = await readdir(abs, { withFileTypes: true })
+ } catch {
+ return // missing dir → empty, mirroring the fake's prefix filter
+ }
+ for (const e of entries) {
+ const child = join(abs, e.name)
+ if (e.isDirectory()) await walk(child)
+ else out.push(relative(this.root, child).split(sep).join('/'))
+ }
+ }
+ await walk(base)
+ return out.sort()
+ }
+
+ async exists(path: string): Promise {
+ try {
+ await access(within(this.root, path))
+ return true
+ } catch {
+ return false
+ }
+ }
+}
+
+/**
+ * A {@link RunnerSession} backed by a real directory on the host: a real
+ * filesystem, real child processes, and (optionally) a localhost preview.
+ */
+export class LocalRunnerSession implements RunnerSession {
+ readonly id: string
+ readonly fs: LocalFs
+ /** The absolute workspace directory on disk. */
+ readonly root: string
+ disposed = false
+
+ readonly preview?: (opts?: PreviewOptions) => Promise
+
+ private readonly cwd: string
+ private readonly env: Record
+
+ constructor(
+ id: string,
+ root: string,
+ boot: BootOptions,
+ opts: Required>,
+ ) {
+ this.id = id
+ this.root = root
+ this.fs = new LocalFs(root)
+ this.cwd = boot.cwd ? norm(boot.cwd) : ''
+ this.env = { ...(boot.env ?? {}) }
+ if (opts.preview) {
+ this.preview = async (previewOpts: PreviewOptions = {}): Promise => {
+ if (this.disposed) throw new RunnerError('preview on a disposed session')
+ const port = previewOpts.port ?? 3000
+ return { url: `${opts.previewHost}:${port}`, port }
+ }
+ }
+ }
+
+ async exec(command: string, opts: ExecOptions = {}): Promise {
+ if (this.disposed) throw new RunnerError('exec on a disposed session')
+ const cwd = within(this.root, opts.cwd ?? (this.cwd || '.'))
+ const env = { ...process.env, ...this.env, ...(opts.env ?? {}) }
+ return await new Promise((resolvePromise, reject) => {
+ const child = spawn(command, { cwd, env, shell: true })
+ let stdout = ''
+ let stderr = ''
+ let timedOut = false
+ const timer =
+ opts.timeoutMs != null
+ ? setTimeout(() => {
+ timedOut = true
+ child.kill('SIGKILL')
+ }, opts.timeoutMs)
+ : undefined
+ child.stdout?.on('data', d => (stdout += d))
+ child.stderr?.on('data', d => (stderr += d))
+ child.on('error', err => {
+ if (timer) clearTimeout(timer)
+ reject(new RunnerError(`failed to spawn: ${(err as Error).message}`))
+ })
+ child.on('close', (code, signal) => {
+ if (timer) clearTimeout(timer)
+ if (timedOut) {
+ resolvePromise({
+ stdout,
+ stderr: stderr + `\n[ai-autopilot] command timed out after ${opts.timeoutMs}ms`,
+ exitCode: 124,
+ })
+ return
+ }
+ resolvePromise({ stdout, stderr, exitCode: code ?? (signal ? 137 : 1) })
+ })
+ })
+ }
+
+ async dispose(): Promise {
+ if (this.disposed) return
+ this.disposed = true
+ await rm(this.root, { recursive: true, force: true })
+ }
+}
+
+/**
+ * A {@link Runner} that boots each workspace as a real temp directory on the
+ * host — real files (`node:fs`), real commands (`child_process` with a shell),
+ * and a localhost `preview`. The first *real* adapter behind the runner seam,
+ * and the reference the sandboxed ones (WebContainer, Docker, Flue) mirror.
+ *
+ * It runs commands **unsandboxed on the host**, so use it only where execution
+ * is already trusted — local dev or a CI job that is itself the sandbox — not
+ * to run untrusted, agent-authored code. Reach for a sandboxed runner there.
+ *
+ * ```ts
+ * const runner = new LocalRunner()
+ * const s = await runner.boot({ files: { 'app.js': "console.log('hi')" } })
+ * await s.exec('node app.js') // → { stdout: 'hi\n', stderr: '', exitCode: 0 }
+ * await s.dispose() // removes the temp workspace
+ * ```
+ */
+export class LocalRunner implements Runner {
+ readonly kind = 'local'
+
+ private readonly base: string
+ private readonly opts: Required>
+
+ constructor(options: LocalRunnerOptions = {}) {
+ this.base = options.root ?? tmpdir()
+ this.opts = {
+ preview: options.preview ?? true,
+ previewHost: options.previewHost ?? 'http://localhost',
+ }
+ }
+
+ async boot(opts: BootOptions = {}): Promise {
+ await mkdir(this.base, { recursive: true })
+ const root = await mkdtemp(join(this.base, 'ai-autopilot-'))
+ for (const [path, contents] of Object.entries(opts.files ?? {})) {
+ const abs = within(root, path)
+ await mkdir(dirname(abs), { recursive: true })
+ await writeFile(abs, contents)
+ }
+ return new LocalRunnerSession(root.split(sep).pop()!, root, opts, this.opts)
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 39f1e17..2eb1ca6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -27,6 +27,28 @@ importers:
specifier: ^3.5.29
version: 3.5.39(typescript@5.9.3)
+ examples/autopilot-quickstart:
+ dependencies:
+ '@gemstack/ai-autopilot':
+ specifier: workspace:^
+ version: link:../../packages/ai-autopilot
+ '@gemstack/ai-sdk':
+ specifier: workspace:^
+ version: link:../../packages/ai-sdk
+ zod:
+ specifier: ^4.0.0
+ version: 4.4.3
+ devDependencies:
+ '@types/node':
+ specifier: ^20.0.0
+ version: 20.19.43
+ tsx:
+ specifier: ^4.19.0
+ version: 4.22.4
+ typescript:
+ specifier: ^5.4.0
+ version: 5.9.3
+
examples/connectors-quickstart:
dependencies:
'@gemstack/connectors':