Shell
Userspace process. Parses input via sh-syntax (WASM port of mvdan/sh), walks the AST, dispatches to kernel. No special kernel privileges — it’s a bin like any other.
Parse → Walk → Execute
input string → sh-syntax parse() → File { Stmts[] } → walk each Stmt → dispatch based on Cmd.Type → return exit codeThe shell is an async recursive interpreter. Each AST node type has a handler that returns a Promise<number> (exit code).
sh-syntax AST
sh-syntax produces a JSON AST where each node has a Type string discriminator. The AST mirrors the Go mvdan.cc/sh/v3/syntax types exactly.
Hierarchy
File └─ Stmt[] ├─ Cmd (the command node — many possible types) ├─ Redirs[] (redirections on this statement) ├─ Background (bool — trailing &) └─ Negated (bool — leading !)Our Type Definitions
sh-syntax’s TypeScript types are minimal — Cmd is typed as Node | null. We define our own discriminated union for the command types we handle:
type Cmd = | CallExpr | BinaryCmd | IfClause | WhileClause | ForClause | CaseClause | Block | Subshell | FuncDecl | DeclClause | ArithmCmd | TestClause
interface CallExpr { Type: 'CallExpr'; Assigns: Assign[]; Args: Word[] }interface BinaryCmd { Type: 'BinaryCmd'; Op: string; X: Stmt; Y: Stmt }interface IfClause { Type: 'IfClause'; Cond: Stmt[]; Then: Stmt[]; Else: IfClause | null }interface WhileClause { Type: 'WhileClause'; Cond: Stmt[]; Do: Stmt[]; Until: boolean }interface ForClause { Type: 'ForClause'; Loop: WordIter | CStyleLoop; Do: Stmt[]; Select: boolean }interface CaseClause { Type: 'CaseClause'; Word: Word; Items: CaseItem[] }interface Block { Type: 'Block'; Stmts: Stmt[] }interface Subshell { Type: 'Subshell'; Stmts: Stmt[] }interface FuncDecl { Type: 'FuncDecl'; Name: Lit; Body: Stmt }interface DeclClause { Type: 'DeclClause'; Variant: Lit; Args: Assign[] }interface ArithmCmd { Type: 'ArithmCmd'; X: ArithmExpr }interface TestClause { Type: 'TestClause'; X: TestExpr }
// Word partstype WordPart = | Lit // literal text | SglQuoted // 'text' | DblQuoted // "text with $expansion" | ParamExp // $var, ${var:-default} | CmdSubst // $(cmd) | ArithmExp // $((expr))
interface Word { Parts: WordPart[] }interface Lit { Type: 'Lit'; Value: string }interface SglQuoted { Type: 'SglQuoted'; Value: string }interface DblQuoted { Type: 'DblQuoted'; Parts: WordPart[] }interface ParamExp { Type: 'ParamExp'; Param: Lit; Short: boolean; Exp?: Expansion; /* ... */ }interface CmdSubst { Type: 'CmdSubst'; Stmts: Stmt[] }interface ArithmExp { Type: 'ArithmExp'; X: ArithmExpr }MVP AST Node Coverage
Tier 1: Must Have
These are required for basic shell functionality. Without any of these, the shell can’t do useful work.
| Node Type | Shell Syntax | Handler |
|---|---|---|
CallExpr | cmd arg1 arg2 | Resolve bin, spawn, wait |
CallExpr (assigns only) | FOO=bar | Set env var |
CallExpr (assigns + cmd) | FOO=bar cmd | Set env var for duration of cmd |
BinaryCmd Op=Pipe | a | b | Create pipe, wire fds, spawn both |
BinaryCmd Op=AndStmt | a && b | Run a, if exit 0 run b |
BinaryCmd Op=OrStmt | a || b | Run a, if exit != 0 run b |
Redirect Op=> | cmd > file | Open file, wire as stdout |
Redirect Op=>> | cmd >> file | Open file append, wire as stdout |
Redirect Op=< | cmd < file | Open file, wire as stdin |
Redirect Op=>& | 2>&1 | Dup fd |
Stmt.Background | cmd & | Spawn without waiting |
Stmt.Negated | ! cmd | Invert exit code |
Tier 2: Control Flow
Required for scripts and multi-step operations.
| Node Type | Shell Syntax | Handler |
|---|---|---|
IfClause | if cmd; then ...; fi | Run cond, branch on exit code |
WhileClause | while cmd; do ...; done | Loop while cond exits 0 |
WhileClause (Until=true) | until cmd; do ...; done | Loop while cond exits != 0 |
ForClause (WordIter) | for x in a b c; do ...; done | Iterate, set var, run body |
CaseClause | case $x in pat) ... ;; esac | Match patterns, run branch |
Block | { cmd1; cmd2; } | Run in current shell context |
Subshell | (cmd1; cmd2) | Spawn child shell, run, collect exit |
Tier 3: Deferred
Not needed for MVP. Can be added incrementally.
| Node Type | Shell Syntax | Why deferred |
|---|---|---|
FuncDecl | f() { ... } | Nice-to-have but bins cover the use case |
CStyleLoop | for ((i=0; i<n; i++)) | Rare in LLM usage |
ArithmCmd | (( x++ )) | Arithmetic can be done in bins |
TestClause | [[ -f file ]] | test builtin covers this |
DeclClause | local, declare | Only matters inside functions |
CoprocClause | coproc cmd | Niche |
Redirect Op=<<< | cmd <<< "string" | Here-strings; low priority |
ProcSubst | <(cmd) | Process substitution; complex |
Decision: three tiers of AST support. Tier 1 (basic commands, pipes, redirects, logical operators) and Tier 2 (control flow) ship in MVP. Tier 3 is deferred. This covers the vast majority of what LLMs actually type into a shell. The tier boundary is “does an LLM need this to accomplish real tasks?” — pipes and if/for/while: yes. C-style for loops and coprocs: no.
AST Node → Execution
CallExpr (Simple Command)
The workhorse. Handles commands, assignments, and assignment+command combos.
execute(CallExpr): 1. If Assigns present and Args empty: → bare assignment: FOO=bar → expand value, set in shell env → return 0
2. If Assigns present and Args present: → prefixed assignment: FOO=bar cmd → expand value, add to child env (not shell env) → continue to step 3
3. Expand all Args (word expansion — see below) → args[0] is the command name → args[1..] are arguments
4. Check builtins: cd, export, exit, etc. → if builtin, execute in shell context, return exit code
5. Resolve bin via $PATH (see bins spec) → if not found, stderr "command not found", return 127 → if not executable, return 126
6. Apply Stmt.Redirs (see redirects below)
7. spawn(bin, args, { stdin, stdout, stderr, env })
8. If Stmt.Background → don't wait, return 0 Else → wait(), return exit codeBinaryCmd (Pipe / Logical)
execute(BinaryCmd): if Op === 'Pipe' or 'PipeAll': → flatten pipeline: collect all stages from nested BinaryCmds → create N-1 pipes → wire fds as described in pipes spec → spawn all stages → close shell's pipe copies → wait for all → return last stage's exit code
if Op === 'AndStmt': code = execute(X) if code === 0 → return execute(Y) else → return code
if Op === 'OrStmt': code = execute(X) if code !== 0 → return execute(Y) else → return codePipeline Flattening
sh-syntax nests pipelines as right-recursive BinaryCmd trees:
a | b | c → BinaryCmd(Pipe, a, BinaryCmd(Pipe, b, c))We flatten this to [a, b, c] before wiring pipes:
function flattenPipeline(cmd: BinaryCmd): Stmt[] { const stages: Stmt[] = [cmd.X] let right: Stmt | BinaryCmd = cmd.Y while (right.Cmd?.Type === 'BinaryCmd' && right.Cmd.Op === 'Pipe') { stages.push(right.Cmd.X) right = right.Cmd.Y } stages.push(right) return stages}IfClause
execute(IfClause): 1. Run Cond stmts sequentially 2. If last Cond exit code === 0 → run Then stmts, return last exit code 3. Else if Else is an IfClause → recurse (handles elif chain) 4. Else if Else is null → return 0ForClause (WordIter)
execute(ForClause { Loop: WordIter }): 1. Expand Loop.Iter words 2. For each word: a. Set Loop.Name = word in shell env b. Run Do stmts sequentially c. If any stmt is break → stop loop d. If any stmt is continue → skip to next iteration 3. Return last exit codeWhileClause
execute(WhileClause): loop: 1. Run Cond stmts 2. If Until: invert condition 3. If last exit code !== 0 → break 4. Run Do stmts 5. Handle break/continue return last exit codeCaseClause
execute(CaseClause): 1. Expand Word 2. For each CaseItem: a. For each pattern in Item.Patterns: - Expand pattern - Glob-match against expanded Word - If match → run Item.Stmts, handle ;; / ;& / ;;& 3. Return last exit code (or 0 if no match)Block
execute(Block): Run Stmts sequentially in current shell context. Return last exit code.Subshell
execute(Subshell): 1. Spawn a child shell process with: - Inherited namespace (shallow copy) - Inherited env (shallow copy) - Inherited fds 2. Execute Stmts in child context 3. Return child's exit code Changes to env/cwd in the subshell don't affect the parent.Word Expansion
Words must be expanded before use. Expansion happens in this order (matching POSIX):
1. Brace expansion {a,b,c} → a b c (deferred — Tier 3)2. Tilde expansion ~/foo → /home/foo3. Parameter expansion $VAR, ${VAR:-default}4. Command substitution $(cmd)5. Arithmetic expansion $((1+2)) (deferred — Tier 3)6. Word splitting results split on $IFS7. Glob expansion *.txt → matching files8. Quote removal strip syntactic quotesMVP Word Expansion
Decision: support tilde, parameter, command substitution, word splitting, glob, and quote removal. Defer brace and arithmetic expansion. These six cover real usage. Brace expansion is convenience (
file{1,2,3}→file1 file2 file3) and arithmetic expansion ($((x+1))) is rare in LLM usage where bins handle computation.
The Segment IR
Decision: word expansion uses a tagged-segment intermediate representation between phases. The classic shell implementation bug is losing track of quoting context as data flows through expansion phases. A
"$VAR"should expand $VAR but NOT word-split the result. In a naive implementation this requires threading aquotedflag through every phase. The Segment IR makes quoting an intrinsic property of the data itself, so each phase can be a pure function that doesn’t need to know about the phases before or after it.
/** * A segment is a piece of text with a tag indicating how downstream * phases should treat it. The tag is set at creation time (when the * AST node is first converted to segments) and never changes. */type Segment = | { kind: 'literal'; text: string } // from single quotes or escapes | { kind: 'expanded'; text: string } // from unquoted $VAR, $(cmd), tilde | { kind: 'quoted-expanded'; text: string } // from $VAR or $(cmd) inside "..."
/** * How each phase treats each segment kind: * * literal expanded quoted-expanded * tilde expand skip apply skip * param expand skip apply apply * cmd substitution skip apply apply * word splitting skip apply skip * glob expansion skip apply skip * quote removal emit emit emit */The key properties:
literal— produced by single-quoted strings ('...'), backslash escapes, and hard-coded text. Never touched by any phase. Passes through to the final output as-is. This is how'*.txt'avoids globbing.expanded— produced by unquoted expansions. Subject to all downstream phases. This is how unquoted$VARgets word-split and globbed.quoted-expanded— produced by expansions inside double quotes. Subject to parameter expansion and command substitution, but NOT word splitting or globbing. This is how"$VAR"expands without splitting.
AST → Segments (Initial Conversion)
The first step of word expansion converts sh-syntax AST nodes into segments. This is where quoting context is captured:
function astToSegments(parts: WordPart[]): Segment[] { const result: Segment[] = [] for (const part of parts) { switch (part.Type) { case 'Lit': result.push({ kind: 'expanded', text: part.Value }) break case 'SglQuoted': result.push({ kind: 'literal', text: part.Value }) break case 'DblQuoted': // Recurse into DblQuoted parts, but tag results as 'quoted-expanded' for (const inner of part.Parts) { if (inner.Type === 'Lit') { result.push({ kind: 'literal', text: inner.Value }) } else { // ParamExp, CmdSubst inside "..." → quoted-expanded result.push({ kind: 'quoted-expanded', text: '' }) // placeholder, filled by expansion } } break case 'ParamExp': result.push({ kind: 'expanded', text: '' }) // filled by param expansion phase break case 'CmdSubst': result.push({ kind: 'expanded', text: '' }) // filled by cmd substitution phase break } } return result}Decision: the AST → Segment conversion captures quoting context once, at the boundary. No phase after this point needs to inspect the AST or track whether it’s “inside double quotes.” The segment kind carries that information. This is what makes the phases independent.
Expansion Pipeline
Each phase is a function from segments to segments. The pipeline is their composition:
type ExpansionPhase = (segments: Segment[], ctx: ExpansionCtx) => Segment[] | Promise<Segment[]>
interface ExpansionCtx { env: Record<string, string> // shell environment lastExit: number // $? shellPid: number // $$ lastBgPid: number | null // $! positional: string[] // $1..$9, $@, $* ifs: string // $IFS (default: ' \t\n') // Async capabilities (only command substitution and glob need these) spawn: (bin: string, argv: string[]) => Promise<string> // for $(cmd) readdir: (path: string) => Promise<string[]> // for glob}
// The full pipelineasync function expandWord(parts: WordPart[], ctx: ExpansionCtx): Promise<string[]> { let segments = astToSegments(parts) segments = tildeExpand(segments, ctx) segments = parameterExpand(segments, ctx) segments = await commandSubstitute(segments, ctx) // async: spawns subshells segments = wordSplit(segments, ctx) segments = await globExpand(segments, ctx) // async: calls readdir return quoteRemove(segments) // strips kinds, returns string[]}Decision:
expandWordreturnsstring[], notstring. A single word like$VARwhereVAR="a b"can expand to multiple strings after word splitting. The caller (command execution) concatenates adjacent non-split results and passes the final list as argv. This matches POSIX behavior.
Phase Specifications
Phase 1: Tilde Expansion
function tildeExpand(segments: Segment[], ctx: ExpansionCtx): Segment[] { return segments.map(seg => { if (seg.kind !== 'expanded') return seg if (seg.text === '~') return { ...seg, text: ctx.env.HOME ?? '' } if (seg.text.startsWith('~/')) return { ...seg, text: (ctx.env.HOME ?? '') + seg.text.slice(1) } return seg })}Only applies to expanded segments. A literal '~' (single-quoted) is untouched.
Phase 2: Parameter Expansion
Replaces ParamExp placeholders in expanded and quoted-expanded segments:
| Syntax | Behavior |
|---|---|
$VAR | Value of VAR, empty string if unset |
${VAR} | Same, explicit boundary |
${VAR:-default} | Value of VAR, or “default” if unset/empty |
${VAR:=default} | Same, but also assigns the default |
${VAR:+alternate} | ”alternate” if VAR is set and non-empty, else empty |
${VAR:?error} | Value of VAR, or print error and exit if unset |
${#VAR} | Length of value |
$? | Last exit code |
$$ | Current shell pid |
$! | Pid of last background process |
$# | Number of positional params |
$@ / $* | All positional params |
$0 | Shell name |
$1..$9 | Positional params |
Decision: support all common parameter expansions above. Defer
${var%pat},${var#pat},${var/pat/rep}(pattern manipulation) to post-MVP. The above list covers variable lookup, defaults, and special variables. Pattern manipulation within parameter expansion is a power feature that LLMs rarely use — they’d usesedinstead.
Skips literal segments. Applies to both expanded and quoted-expanded — the kind tag is preserved on the output, so the downstream difference in splitting/globbing behavior is maintained.
Phase 3: Command Substitution
$(cmd) or `cmd`
1. Parse inner command(s)2. Spawn a subshell with stdout piped3. Capture all output4. Strip trailing newlines5. Replace segment text with captured output6. Preserve segment kind (expanded or quoted-expanded)Async — requires spawning a child process. Skips literal segments.
Command substitution is the main mechanism for “using output as an argument”:
files=$(ls /tmp)echo "count: $(wc -l < data.txt)"Phase 4: Word Splitting
function wordSplit(segments: Segment[], ctx: ExpansionCtx): Segment[] { const ifs = ctx.ifs const result: Segment[] = [] for (const seg of segments) { // Only split 'expanded' segments — literal and quoted-expanded pass through if (seg.kind !== 'expanded') { result.push(seg) continue } const parts = splitOnIFS(seg.text, ifs) for (const part of parts) { result.push({ kind: 'expanded', text: part }) } } return result}This is the phase where the Segment IR pays for itself. The classic bug — "$VAR" getting split — is impossible because quoted-expanded segments are skipped by the kind !== 'expanded' check. No quoting flag, no context tracking, just data.
Phase 5: Glob Expansion
async function globExpand(segments: Segment[], ctx: ExpansionCtx): Promise<Segment[]> { const result: Segment[] = [] for (const seg of segments) { // Only glob 'expanded' segments if (seg.kind !== 'expanded' || !containsGlobChars(seg.text)) { result.push(seg) continue } const matches = await glob(seg.text, ctx.readdir) if (matches.length === 0) { result.push(seg) // no match → pass through literally } else { for (const match of matches.sort()) { result.push({ kind: 'literal', text: match }) // glob results are literal } } } return result}Decision: glob expansion is done by the shell via fileserver readdir + pattern matching. The shell expands
*.txtby callingreaddiron the relevant directory and filtering with a glob matcher. If no files match, the glob is passed through literally (bash default withnullgloboff). We implement a simple glob matcher supporting*,?, and[charset]— no extended globs (**,?(pat)) in MVP.
Note: glob results are tagged literal — they’re concrete filenames, not subject to further expansion.
Phase 6: Quote Removal
function quoteRemove(segments: Segment[]): string[] { // Concatenate adjacent segments into words. // Word boundaries were introduced by word splitting (phase 4). // All remaining segments merge into a single string per word. if (segments.length === 0) return [] return segments.reduce<string[]>((words, seg) => { // Each segment contributes its text. Word boundaries are // represented by separate entries in the segments array // (created by word splitting). words[words.length - 1] += seg.text return words }, [''])}Strips the segment kind tags and returns plain strings. This is the final output of word expansion.
Segment IR Interaction Matrix
The complete matrix of which phases apply to which segment kinds:
literal expanded quoted-expanded ─────── ──────── ───────────────Phase 1: tilde skip APPLY skipPhase 2: parameter skip APPLY APPLYPhase 3: cmd subst skip APPLY APPLYPhase 4: word split skip APPLY skipPhase 5: glob skip APPLY skipPhase 6: quote removal emit emit emitThe pattern is clear: literal is always inert, expanded is always active, and quoted-expanded is active for value-producing phases (parameter, command substitution) but inert for restructuring phases (splitting, globbing). This is exactly the POSIX quoting contract, encoded as data rather than control flow.
Quoting Rules (Reference)
sh-syntax handles quote parsing — we see SglQuoted, DblQuoted, and Lit nodes. The Segment IR captures their semantics:
| Quote type | AST node | Segment kind |
|---|---|---|
Single '...' | SglQuoted | literal |
Double "..." (literal parts) | DblQuoted → Lit | literal |
Double "..." (expansion parts) | DblQuoted → ParamExp/CmdSubst | quoted-expanded |
| Unquoted literal | Lit | expanded |
| Unquoted expansion | ParamExp/CmdSubst | expanded |
Backslash \x | Lit (sh-syntax resolves escapes) | literal |
Testing Strategy
The Segment IR enables a layered testing approach:
Unit tests per phase (pure functions, no kernel needed):
// TildetildeExpand([{ kind: 'expanded', text: '~/foo' }], { env: { HOME: '/home/u' } }) // → [{ kind: 'expanded', text: '/home/u/foo' }]
// Word split respects segment kindswordSplit([ { kind: 'expanded', text: 'a b' }, { kind: 'quoted-expanded', text: 'c d' },], { ifs: ' \t\n' }) // → [{ kind: 'expanded', text: 'a' }, // { kind: 'expanded', text: 'b' }, // { kind: 'quoted-expanded', text: 'c d' }] // ^ quoted-expanded was NOT split
// Glob skips non-expandedglobExpand([{ kind: 'literal', text: '*.txt' }], ctx) // → [{ kind: 'literal', text: '*.txt' }] // passed through, not globbedIntegration tests (full pipeline, still no kernel):
expandWord(parse('"$VAR"').Parts, { env: { VAR: 'a b' } }) // → ['a b'] — one string, not split
expandWord(parse('$VAR').Parts, { env: { VAR: 'a b' } }) // → ['a', 'b'] — two strings, split on IFSDifferential tests (against bash):
async function assertMatchesBash(input: string) { const bashResult = await runBash(input) const jsResult = await jsUnixShell.eval(input) expect(jsResult.stdout).toBe(bashResult.stdout) expect(jsResult.exitCode).toBe(bashResult.exitCode)}
// Curated corpus covering the quoting × expansion interaction matrixassertMatchesBash(`echo "hello world"`)assertMatchesBash(`x="a b"; echo $x`) // word splittingassertMatchesBash(`x="a b"; echo "$x"`) // quoted — no splitassertMatchesBash(`echo $(echo "a b")`) // cmd subst + splitassertMatchesBash(`echo "$(echo "a b")"`) // cmd subst, quoted — no splitassertMatchesBash(`echo ~`) // tildeassertMatchesBash(`echo '~'`) // tilde suppressed by quotesassertMatchesBash(`echo *.md`) // globassertMatchesBash(`echo "*.md"`) // glob suppressed by quotesassertMatchesBash(`x='*.md'; echo $x`) // glob on expanded variableassertMatchesBash(`x='*.md'; echo "$x"`) // glob suppressed by quoting expansionassertMatchesBash(`echo ${UNSET:-fallback}`) // parameter defaultassertMatchesBash(`IFS=:; x="a:b:c"; echo $x`) // custom IFS splittingDecision: three-layer testing strategy — phase unit tests, pipeline integration tests, and differential bash tests. Phase tests verify each expansion in isolation (pure functions, fast, no dependencies). Pipeline tests verify the phases compose correctly through the Segment IR. Differential tests verify the whole system matches real bash behavior. The phase tests catch implementation bugs. The differential tests catch spec bugs (places where our interpretation of POSIX diverges from bash).
Redirects
Redirections are on the Stmt, not the Cmd. They’re applied after word expansion but before execution.
applyRedirects(stmt: Stmt, fds: FdSet): for each redir in stmt.Redirs: switch redir.Op: '>': fd = open(word, { write: true, create: true, truncate: true }) replace fds[redir.N ?? 1] with fd
'>>': fd = open(word, { write: true, create: true, append: true }) replace fds[redir.N ?? 1] with fd
'<': fd = open(word, { read: true }) replace fds[redir.N ?? 0] with fd
'>&': kernel.dup(pid, word, redir.N ?? 1) // 2>&1
'<&': kernel.dup(pid, word, redir.N ?? 0)
'<<': fd = createHeredocFd(redir.Hdoc) // here-document replace fds[redir.N ?? 0] with fdHere-Documents
cat << 'EOF'line 1line 2EOFsh-syntax parses the heredoc content into Redir.Hdoc (a Word). If the delimiter is quoted ('EOF'), no expansion. If unquoted (EOF), parameter and command expansion apply within the body.
The shell creates a pipe, writes the expanded heredoc content to the write end, closes it, and passes the read end as stdin.
Decision: support here-documents in MVP. They’re heavily used for multi-line input — LLMs use them constantly for writing file content (
cat > file << 'EOF'). The implementation is straightforward: expand content, stuff into pipe, wire as stdin.
Job Control
Decision: background with
&is supported; full job control (fg, bg, jobs, Ctrl+Z) is not.cmd &spawns without waiting and stores the pid in$!. That’s it. No job table, no foreground/background switching, no SIGTSTP handling. LLMs don’t need interactive job control — they either run commands sequentially or fire-and-forget with&. Full job control can be added later alongside thestoppedprocess state.
execute(Stmt { Background: true }): 1. Spawn the command normally 2. Don't wait 3. Store child pid in $! 4. Return 0 immediatelyShell State
The shell is a bin. Its internal state lives in its closure — not exported as a public contract.
// Internal to the shell bin — not part of the type systemlet lastExit: number = 0 // $?let lastBg: number | null // $!const funcs = new Map<string, Stmt>() // shell functions (Tier 3)The shell’s env and cwd are on its ProcContext — proc.env and accessed via proc.chdir(). These are the same fields child processes inherit.
Builtins
Builtins are BinFunctions that the shell calls with its own ProcContext instead of spawning a child:
const builtins: Record<string, BinFunction> = { cd: async (proc) => { proc.chdir(proc.argv[1] ?? proc.env.HOME ?? '/'); return 0 }, pwd: async (proc) => { await proc.stdout.write(proc.env.PWD + '\n'); return 0 }, export: async (proc) => { /* parse KEY=VAL, set proc.env */ return 0 }, exit: async (proc) => { proc.exit(Number(proc.argv[1]) || 0) }, true: async () => 0, false: async () => 1,}source is NOT a builtin — it’s a shell-internal special form (like if/for) because it needs the interpreter to execute parsed commands in the current context.
Decision: builtins share the
BinFunctionsignature. No separateShellBuiltintype. The only difference between a builtin and a regular bin is execution path (in-process vs spawned). Same contract, less conceptual overhead.
Prompt
Decision: prompt is the TtyFS’s concern, not the shell’s. The shell reads from stdin (fd 0). If stdin is a tty, the shell writes a prompt string to stdout before each read. The prompt is
$PS1(default:$). There is no line editing, history, or tab completion in MVP — those are TtyFS features that can be added without changing the shell.
shell main loop: while true: if stdin is tty: await stdout.write(env.PS1 ?? '$ ') line = await stdin.readLine() if line is EOF → break ast = await parse(line) for stmt of ast.Stmts: lastExit = await execute(stmt)Error Recovery
If parse() throws a syntax error:
1. Write error message to stderr: "sh: syntax error: ..."2. Set $? = 2 (misuse of command)3. Continue to next prompt (don't exit the shell)The shell never crashes on bad input. It reports the error and continues. This is essential for LLM interaction — a typo shouldn’t kill the session.
Execution Flow Summary
input line │ parse (sh-syntax) │ File.Stmts │ ┌────────┼────────┐ │ │ │ Stmt Stmt Stmt (sequential) │ ┌─────┴─────┐ │ │ Cmd Redirs │ │ ┌────────┴────────┐ └── apply fd rewiring │ │ CallExpr BinaryCmd │ │ expand words ┌───┴───┐ │ │ │ builtin? Pipe? &&/||? │ │ │ or spawn wire fds conditional │ spawn execution │ wait │ wait → exit code → $?Control-Flow Sentinels
The interpreter uses thrown objects — not return values — to implement loop control, function return, and errexit abort. These are defined in src/runtime/shell/signals.ts.
The Four Sentinels
// Plain classes — do NOT extend Error.// Reason 1: extending Error triggers V8 stack trace capture (perf cost in loops).// Reason 2: bins that catch `instanceof Error` would accidentally swallow them.
export class ExitSentinel { constructor(public readonly code: ExitCode) {} }export class ReturnSentinel { constructor(public readonly code: ExitCode) {} }export class BreakSentinel { constructor(public readonly levels: number = 1) {} }export class ContinueSentinel { constructor(public readonly levels: number = 1) {} }
export type ControlFlowSignal = ExitSentinel | ReturnSentinel | BreakSentinel | ContinueSentinel
export function isControlFlowSignal(e: unknown): e is ControlFlowSignal { ... }| Sentinel | Thrown by | Caught by |
|---|---|---|
ExitSentinel | executeStmts (errexit) | executeScript |
ReturnSentinel | return builtin | invokeFunction, executeScript |
BreakSentinel | break builtin | executeWhile, executeFor |
ContinueSentinel | continue builtin | executeWhile, executeFor |
Propagation Contract
Each sentinel is caught at exactly the right boundary and re-thrown (with level decremented for break/continue) if it needs to travel further. The rule for every catch site is:
} catch (err: unknown) { if (err instanceof ExitError) throw err // kernel exit — always rethrow if (isControlFlowSignal(err)) throw err // shell control flow — always rethrow return exitCode(1) // unexpected error — absorb}Decision:
isControlFlowSignalis the safety net for builtin catch-all blocks. Any place in the interpreter that has a barecatchto absorb unexpected errors must rethrow sentinels. Failing to do so silently breaks loop control or errexit. The guard typeControlFlowSignalmakes this checkable.
Relationship to ExecSentinel
ExecSentinel (in kernel/errors.ts) is NOT part of this module. It crosses the kernel boundary (exec() replaces the process image) and has a different contract: it must propagate unconditionally all the way out of the bin’s stack. Shell control-flow sentinels are shell-layer concerns. Kernel-layer concerns use a separate class in a separate module.
ShellContext
ShellContext (in src/runtime/shell/shell-context.ts) is the interpreter-scoped state container introduced to make trap, set, local, and function dispatch possible without casting through ProcContext.
Decision:
ShellContextlives inruntime/shell/, NOTkernel/.FunctionDefdepends on the parser’sCmdtype. MovingShellContexttokernel/would create a kernel → shell layering violation.ProcContextis the kernel contract; shell-internal state is a shell concern.
Type
export interface ShellContext { shellOpts: ShellOpts // set -e / -u / -x / -o pipefail trapHandlers: Map<TrapSignal, string> // trap body strings; '' = ignore trapFiring: Set<TrapSignal> // re-entrancy guard functions: Map<string, FunctionDef> // shell function definitions localFrameStack: Array<Map<string, string | undefined>> // local variable save frames}ShellBuiltin vs BinFunction
Builtins that need interpreter-internal state receive both proc and shell:
export type ShellBuiltin = (proc: ProcContext, shell: ShellContext) => Promise<ExitCode>Builtins that don’t need shell state remain BinFunction. The interpreter dispatches to a separate shellBuiltins map (checked first) before the main bin registry. Current shell builtins: set, trap, local.
forkShellContext and POSIX §2.14.3
Subshells receive a forked context. forkShellContext applies POSIX §2.14.3 rules exactly:
export function forkShellContext(parent: ShellContext): ShellContext { const trapHandlers = new Map<TrapSignal, string>() // Only '' (ignored) entries survive fork — all other handlers reset to default for (const [sig, action] of parent.trapHandlers) { if (action === '') trapHandlers.set(sig, '') } return { shellOpts: { ...parent.shellOpts }, // copied — subshell inherits options trapHandlers, // only ignored entries survive trapFiring: new Set(), // no in-progress traps inherited functions: new Map(parent.functions), // shallow copy — mutations don't affect parent localFrameStack: [], // fresh — process boundary = isolation boundary }}Functions share the same ShellContext reference — not a fork. invokeFunction passes shell directly to the child interpreter. This is what allows registerLocal calls inside the function body to write to the frame that invokeFunction’s finally block pushed. A fork here would silently break local.
Concurrency Safety
All function invocations within a single process are sequential (awaited). The localFrameStack is safe because JS is single-threaded and every function call is fully awaited before the next begins. Pipeline stages are separate processes with separate ShellContext instances — there is no shared mutable state across pipeline stages.
set -e / errexit / pipefail
Shell execution options are collected in ShellOpts (defined in src/kernel/types.ts, referenced by ShellContext):
export interface ShellOpts { xtrace: boolean // set -x: print expanded commands to stderr errexit: boolean // set -e: exit on non-zero (with POSIX exemptions) pipefail: boolean // set -o pipefail: pipeline exit = rightmost non-zero stage nounset: boolean // set -u: error on unset variable reference (enforcement pending)}set is a ShellBuiltin that writes directly to shell.shellOpts. Compound forms (-euo pipefail) are parsed by iterating flag characters and consuming the option name for -o.
errexit: ExitSentinel in executeStmts
async function executeStmts(stmts: Stmt[]): Promise<ExitCode> { let code: ExitCode = exitCode(0) for (const stmt of stmts) { code = await executeStmt(stmt) if (ctx.shellOpts.errexit && code !== 0 && !stmt.negated) { throw new ExitSentinel(code) } } return code}ExitSentinel propagates up through the call stack until caught by executeScript, which terminates the script with the non-zero code.
POSIX Exemptions: Condition Contexts
if/elif/while/until conditions must NOT trigger errexit — testing a failing command is the whole point. These contexts suppress ExitSentinel by catching and absorbing it:
async function executeIf(cmd): Promise<ExitCode> { let condCode: ExitCode try { condCode = await executeStmts(cmd.cond) } catch (e: unknown) { if (e instanceof ExitSentinel) condCode = e.code // absorb — condition result only else throw e } // ...}The same pattern applies to while/until condition evaluation. This is what makes if failing_cmd; then work under set -e without aborting the script.
Decision:
&&/||left-hand side and!-negated commands are also POSIX exemptions. TheStmt.negatedflag gates the errexit check inexecuteStmts.&&/||are handled by theBinaryCmdexecutor which evaluates X/Y in separateexecuteStmtcalls without the errexit loop.
pipefail: Rightmost Non-Zero
Without pipefail, a pipeline’s exit code is the last stage’s exit code. With pipefail, it is the rightmost non-zero exit code in the pipeline:
const results = await Promise.all(children)const finalCode = results[results.length - 1]if (!ctx.shellOpts.pipefail) return finalCodefor (let i = results.length - 1; i >= 0; i--) { if (results[i] !== 0) return results[i]}return exitCode(0)xtrace
When shellOpts.xtrace is true, the interpreter writes each expanded command to stderr before executing it:
if (ctx.shellOpts.xtrace) { await effectiveIO.stderr.write(`+ ${allArgs.join(' ')}\n`)}This fires after word expansion and after set itself — so set -x does not trace its own invocation.
trap
trap is a ShellBuiltin in src/runtime/shell/builtins/trap.ts that writes handler bodies into shell.trapHandlers.
TrapSignal
export type TrapSignal = Signal | 'EXIT'EXIT is a pseudo-signal — not a kernel signal. It fires from the interpreter’s executeScript finally block, not from the kernel’s signal delivery machinery. The TrapSignal key type means that adding a new signal to the Signal union will cause a compile-time error at any exhaustive switch on TrapSignal that forgets to handle it.
Signal Traps vs EXIT Trap
| Aspect | Signal trap (trap 'cmd' TERM) | EXIT trap (trap 'cmd' EXIT) |
|---|---|---|
| Storage | shell.trapHandlers | shell.trapHandlers |
| Kernel registration | proc.on(signal, noOpHandler) | None — EXIT is not a kernel signal |
| Execution | Pending signal queue (future) | executeScript finally block |
'' action | Ignore: no-op handler registered immediately | Trap is set (listed), body is empty string |
The '' action distinction is load-bearing. trap '' SIGPIPE is the standard pattern to make writes to a closed pipe non-fatal. trap '' EXIT sets the trap (so it appears in trap -p output) but runs an empty body.
EXIT Trap Wiring
executeScript uses a boolean flag to communicate across the try/finally boundary:
async function executeScript(script: Script): Promise<ExitCode> { let result: ExitCode = exitCode(0) let execReplaced = false try { result = await executeStmts(script.stmts) } catch (err: unknown) { if (err instanceof ExecSentinel) { execReplaced = true; throw err } // ... handle other sentinels ... } finally { if (!execReplaced) await runTrap('EXIT', result) } return result}Decision: ExecSentinel must suppress the EXIT trap. When
exec cmdreplaces the process image, the current script is not “exiting” — it is being replaced. Running the EXIT trap body in this scenario would produce surprising output and is explicitly excluded by POSIX.
Re-Entrancy Guard
runTrap prevents recursive trap execution via shell.trapFiring:
async function runTrap(sig: TrapSignal, triggerExitCode?: ExitCode): Promise<void> { if (ctx.trapFiring.has(sig)) return // already executing — skip const action = ctx.trapHandlers.get(sig) if (action === undefined) return // no handler if (action === '' && sig !== 'EXIT') return // signal ignore: nothing to run ctx.trapFiring.add(sig) try { if (triggerExitCode !== undefined) lastExit = triggerExitCode // $? during EXIT await executeScript(parseScript(action)) } finally { ctx.trapFiring.delete(sig) }}$? Contract During EXIT Handler
The triggering exit code is available as $? during the EXIT trap body. The trap handler’s own exit code is discarded — the script’s final exit code is the code that triggered the exit, not the trap body’s exit code. This matches POSIX and bash behavior.
break / continue
break and continue are BinFunctions (not ShellBuiltins — they don’t need ShellContext). They work by throwing sentinels rather than returning.
Multi-Level Control
Both builtins accept an optional level argument:
break 2 # break out of 2 enclosing loopscontinue 3 # continue the 3rd enclosing loopThe sentinel carries the level count:
throw new BreakSentinel(levels) // levels defaults to 1
// continue.tsthrow new ContinueSentinel(levels)Loop Boundary: Decrement and Rethrow
executeWhile and executeFor catch sentinels in the body only — never in the condition. They decrement the level and either handle locally or rethrow:
try { result = await executeStmts(cmd.body)} catch (e: unknown) { if (e instanceof BreakSentinel) { if (e.levels <= 1 || loopDepth <= 1) return result // handled here throw new BreakSentinel(e.levels - 1) // rethrow with decremented level } if (e instanceof ContinueSentinel) { if (e.levels <= 1 || loopDepth <= 1) continue // continue the JS loop throw new ContinueSentinel(e.levels - 1) } throw e}The condition block does not have a similar catch — break inside a condition is already meaningless and propagates upward as an unhandled sentinel.
Functions Are NOT Loop Boundaries
POSIX: “The break utility shall exit from the nearest enclosing for, while, or until loop.” A function call is not a loop. invokeFunction does NOT catch BreakSentinel or ContinueSentinel:
async function invokeFunction(...): Promise<ExitCode> { // ... try { return await fnInterp.executeStmts(fnBody.body) } catch (err: unknown) { if (err instanceof ReturnSentinel) return err.code // function return — handled here throw err // break/continue — propagate through } finally { // restore local vars }}This is the POSIX-correct behavior — bash, dash, and ksh all propagate break/continue through function calls. A break inside a function exits the loop that called the function, not the function itself.
Top-Level Escape
Sentinels that escape executeScript with no enclosing loop mean break/continue was called outside any loop. executeScript diagnoses this:
} catch (err: unknown) { if (err instanceof BreakSentinel) { await effectiveIO.stderr.write('sh: break: only meaningful in a loop\n') result = exitCode(1) return result } // same for ContinueSentinel}Subshell isolation is free — pipeline stages and (...) subshells are separate processes. A break inside a subshell cannot escape to the parent shell’s loops.
local
local is a ShellBuiltin in src/runtime/shell/builtins/local.ts. It provides function-scoped variable shadowing by writing save points into the current frame of shell.localFrameStack.
Frame Stack Architecture
shell.localFrameStack: [ Map { 'x' => '0', 'y' => undefined }, // frame for outerFn() — 'y' was unset Map { 'x' => '1' }, // frame for innerFn() <- top]Each entry in a frame is a save point: the key is the variable name, the value is the pre-function value (undefined means the variable was unset before the function ran).
proc.env remains the single source of truth during execution. Variable expansion ($VAR) reads from proc.env directly — no scope chain traversal. local works by mutating proc.env and registering a restore point.
Decision: save/restore on flat env, not a scope chain. A scope chain would require flattening before spawning child processes (child env inheritance), export-flag tracking, and changes to expansion. Save/restore on a flat
proc.envmeans child processes spawned inside a function see the function’s local values, which is correct POSIX behavior.
registerLocal: First-Write-Wins
export function registerLocal(shell, name, env): boolean { const frame = shell.localFrameStack[shell.localFrameStack.length - 1] if (frame === undefined) return false // no active frame -> caller emits error if (!frame.has(name)) { frame.set(name, env[name]) // save the pre-function value once } return true}First-write-wins: a second local x within the same function does not overwrite the original save point. This prevents a function from losing its restore value by declaring the same variable twice.
local Semantics
local x=val # save current $x; set x=val in proc.envlocal x # save current $x; UNSET x in proc.env (Reflect.deleteProperty)local x without a value unsets the variable in the current scope. This matches bash/dash behavior: inside the function, $x will be empty/unset; on return, the pre-function value is restored.
local outside a function (empty localFrameStack) emits an error and returns exitCode(1).
invokeFunction: Push/Pop/Restore
async function invokeFunction(...): Promise<ExitCode> { // ... positional param setup ... const localFrame = new Map<string, string | undefined>() ctx.localFrameStack.push(localFrame)
try { const fnInterp = createInterpreter(proc, redirectedIO, ctx) return await fnInterp.executeStmts(fnBody.body) } catch (err: unknown) { if (err instanceof ReturnSentinel) return err.code throw err } finally { ctx.localFrameStack.pop() // pop BEFORE iterating — critical for nested functions for (const [key, savedValue] of localFrame) { if (savedValue !== undefined) { proc.env[key] = savedValue } else { Reflect.deleteProperty(proc.env, key) // was unset before function — restore to unset } } }}Decision: pop the frame before iterating restore. In nested function calls, each function’s
finallyblock must restore using its own frame snapshot, not the current top of the stack. Popping first ensures that if a restore itself triggers any code, the stack is already in the correct state.
Why localFrameStack Lives in ShellContext
The frame stack must be shared between invokeFunction (which pushes/pops and restores in finally) and the child interpreter (which runs the function body and calls registerLocal). If the stack were per-interpreter-instance rather than per-ShellContext, registerLocal inside the function body would write to a different stack than the one invokeFunction pops. Placing it on ShellContext — which is passed directly (not forked) to function invocations — ensures they share the same reference.