Skip to content

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 code

The 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 parts
type 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 TypeShell SyntaxHandler
CallExprcmd arg1 arg2Resolve bin, spawn, wait
CallExpr (assigns only)FOO=barSet env var
CallExpr (assigns + cmd)FOO=bar cmdSet env var for duration of cmd
BinaryCmd Op=Pipea | bCreate pipe, wire fds, spawn both
BinaryCmd Op=AndStmta && bRun a, if exit 0 run b
BinaryCmd Op=OrStmta || bRun a, if exit != 0 run b
Redirect Op=>cmd > fileOpen file, wire as stdout
Redirect Op=>>cmd >> fileOpen file append, wire as stdout
Redirect Op=<cmd < fileOpen file, wire as stdin
Redirect Op=>&2>&1Dup fd
Stmt.Backgroundcmd &Spawn without waiting
Stmt.Negated! cmdInvert exit code

Tier 2: Control Flow

Required for scripts and multi-step operations.

Node TypeShell SyntaxHandler
IfClauseif cmd; then ...; fiRun cond, branch on exit code
WhileClausewhile cmd; do ...; doneLoop while cond exits 0
WhileClause (Until=true)until cmd; do ...; doneLoop while cond exits != 0
ForClause (WordIter)for x in a b c; do ...; doneIterate, set var, run body
CaseClausecase $x in pat) ... ;; esacMatch 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 TypeShell SyntaxWhy deferred
FuncDeclf() { ... }Nice-to-have but bins cover the use case
CStyleLoopfor ((i=0; i<n; i++))Rare in LLM usage
ArithmCmd(( x++ ))Arithmetic can be done in bins
TestClause[[ -f file ]]test builtin covers this
DeclClauselocal, declareOnly matters inside functions
CoprocClausecoproc cmdNiche
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 code

BinaryCmd (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 code

Pipeline 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 0

ForClause (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 code

WhileClause

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 code

CaseClause

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/foo
3. Parameter expansion $VAR, ${VAR:-default}
4. Command substitution $(cmd)
5. Arithmetic expansion $((1+2)) (deferred — Tier 3)
6. Word splitting results split on $IFS
7. Glob expansion *.txt → matching files
8. Quote removal strip syntactic quotes

MVP 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 a quoted flag 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 $VAR gets 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 pipeline
async 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: expandWord returns string[], not string. A single word like $VAR where VAR="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:

SyntaxBehavior
$VARValue 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
$0Shell name
$1..$9Positional 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 use sed instead.

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 piped
3. Capture all output
4. Strip trailing newlines
5. Replace segment text with captured output
6. 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”:

Terminal window
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 *.txt by calling readdir on the relevant directory and filtering with a glob matcher. If no files match, the glob is passed through literally (bash default with nullglob off). 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 skip
Phase 2: parameter skip APPLY APPLY
Phase 3: cmd subst skip APPLY APPLY
Phase 4: word split skip APPLY skip
Phase 5: glob skip APPLY skip
Phase 6: quote removal emit emit emit

The 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 typeAST nodeSegment kind
Single '...'SglQuotedliteral
Double "..." (literal parts)DblQuotedLitliteral
Double "..." (expansion parts)DblQuotedParamExp/CmdSubstquoted-expanded
Unquoted literalLitexpanded
Unquoted expansionParamExp/CmdSubstexpanded
Backslash \xLit (sh-syntax resolves escapes)literal

Testing Strategy

The Segment IR enables a layered testing approach:

Unit tests per phase (pure functions, no kernel needed):

// Tilde
tildeExpand([{ kind: 'expanded', text: '~/foo' }], { env: { HOME: '/home/u' } })
// → [{ kind: 'expanded', text: '/home/u/foo' }]
// Word split respects segment kinds
wordSplit([
{ 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-expanded
globExpand([{ kind: 'literal', text: '*.txt' }], ctx)
// → [{ kind: 'literal', text: '*.txt' }] // passed through, not globbed

Integration 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 IFS

Differential 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 matrix
assertMatchesBash(`echo "hello world"`)
assertMatchesBash(`x="a b"; echo $x`) // word splitting
assertMatchesBash(`x="a b"; echo "$x"`) // quoted — no split
assertMatchesBash(`echo $(echo "a b")`) // cmd subst + split
assertMatchesBash(`echo "$(echo "a b")"`) // cmd subst, quoted — no split
assertMatchesBash(`echo ~`) // tilde
assertMatchesBash(`echo '~'`) // tilde suppressed by quotes
assertMatchesBash(`echo *.md`) // glob
assertMatchesBash(`echo "*.md"`) // glob suppressed by quotes
assertMatchesBash(`x='*.md'; echo $x`) // glob on expanded variable
assertMatchesBash(`x='*.md'; echo "$x"`) // glob suppressed by quoting expansion
assertMatchesBash(`echo ${UNSET:-fallback}`) // parameter default
assertMatchesBash(`IFS=:; x="a:b:c"; echo $x`) // custom IFS splitting

Decision: 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 fd

Here-Documents

Terminal window
cat << 'EOF'
line 1
line 2
EOF

sh-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 the stopped process state.

execute(Stmt { Background: true }):
1. Spawn the command normally
2. Don't wait
3. Store child pid in $!
4. Return 0 immediately

Shell 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 system
let 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 ProcContextproc.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 BinFunction signature. No separate ShellBuiltin type. 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 { ... }
SentinelThrown byCaught by
ExitSentinelexecuteStmts (errexit)executeScript
ReturnSentinelreturn builtininvokeFunction, executeScript
BreakSentinelbreak builtinexecuteWhile, executeFor
ContinueSentinelcontinue builtinexecuteWhile, 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: isControlFlowSignal is the safety net for builtin catch-all blocks. Any place in the interpreter that has a bare catch to absorb unexpected errors must rethrow sentinels. Failing to do so silently breaks loop control or errexit. The guard type ControlFlowSignal makes 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: ShellContext lives in runtime/shell/, NOT kernel/. FunctionDef depends on the parser’s Cmd type. Moving ShellContext to kernel/ would create a kernel → shell layering violation. ProcContext is 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. The Stmt.negated flag gates the errexit check in executeStmts. &&/|| are handled by the BinaryCmd executor which evaluates X/Y in separate executeStmt calls 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 finalCode
for (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

AspectSignal trap (trap 'cmd' TERM)EXIT trap (trap 'cmd' EXIT)
Storageshell.trapHandlersshell.trapHandlers
Kernel registrationproc.on(signal, noOpHandler)None — EXIT is not a kernel signal
ExecutionPending signal queue (future)executeScript finally block
'' actionIgnore: no-op handler registered immediatelyTrap 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 cmd replaces 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:

Terminal window
break 2 # break out of 2 enclosing loops
continue 3 # continue the 3rd enclosing loop

The sentinel carries the level count:

break.ts
throw new BreakSentinel(levels) // levels defaults to 1
// continue.ts
throw 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.env means 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

Terminal window
local x=val # save current $x; set x=val in proc.env
local 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 finally block 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.