Fileservers
Any TypeScript object implementing the 10-method protocol. The universal interface — everything in the system is a fileserver or is accessed through one.
The Protocol
interface Fileserver { // Core I/O open(path: string, flags: OpenFlags): Promise<unknown> read(fd: unknown, offset: number, count: number): Promise<Uint8Array> write(fd: unknown, offset: number, data: Uint8Array): Promise<number> close(fd: unknown): Promise<void>
// Metadata stat(path: string): Promise<Stat> readdir(path: string): Promise<DirEntry[]>
// Mutation mkdir(path: string): Promise<void> remove(path: string): Promise<void> rename(oldPath: string, newPath: string): Promise<void> wstat(path: string, changes: Partial<StatChanges>): Promise<void>}The original 6-method protocol (open/read/write/close/stat/readdir) covered I/O and metadata but had no way to mutate the filesystem tree. mkdir, remove, and rename added tree mutation. wstat added metadata mutation — without it, chmod, chown, and explicit touch -t can’t be implemented.
Fileservers that don’t support mutation (e.g., ProcFS, DevFS) throw EPERM for these methods. remove on a non-empty directory throws ENOTEMPTY.
Fd Ownership
Decision: fileservers allocate and own their fds. When the kernel calls
server.open(), the fileserver returns an opaque token (its “fd”). The kernel never inspects it — it stores it in the process fd table and passes it back onread/write/close. This means a fileserver can use whatever internal handle it wants: an integer, a cursor object, a stream reference. The kernel maps process-local fd numbers (integers, lowest-available) to these opaque tokens. Two-level scheme, clean separation.
The unknown type for fd parameters reflects this — the kernel treats them as opaque pass-throughs.
Open Flags
Decision: flags as a structured object, not a bitfield or mode string. Unix-style
O_RDONLY | O_CREAT | O_TRUNCis terse but hostile to TypeScript. A string like"r+"is ambiguous (does it create?). An object is self-documenting and extensible.
interface OpenFlags { read?: boolean // default true write?: boolean // default false create?: boolean // create if missing, default false truncate?: boolean // truncate to zero on open, default false append?: boolean // writes go to end regardless of offset, default false}Convenience shorthands (not part of the protocol, built on top):
const O = { READ: { read: true }, WRITE: { write: true, create: true, truncate: true }, APPEND: { write: true, create: true, append: true }, READWRITE: { read: true, write: true },}Fileservers validate flags and throw if they don’t support a combination (e.g., a read-only fileserver rejects write: true).
Stat
interface Stat { name: string // basename (not full path) size: number // bytes, 0 if unknown/stream type: NodeType // 'file' | 'dir' | 'device' mtime: number // ms since epoch, 0 if not tracked
mode?: number // permission bits (e.g., 0o644, 0o755) uid?: number // owner user id gid?: number // owner group id ctime?: number // metadata change time}All new fields are optional. Consumers apply defaults when absent:
modedefaults to0o644for files,0o755for dirsuidandgiddefault to0ctimedefaults tomtime
This preserves backward compatibility. Existing fileservers (ProcFS, DevFS, TtyFS) continue returning four-field Stat objects. Consumers that don’t care about permissions work unchanged. See the inodes spec for the full INode data model and the rationale for these fields.
Decision: no
'symlink'type. There is no symlink creation method in the fileserver protocol, so no fileserver can produce a symlink. If symlinks are added later (via asymlink(target, path)method), the type union can be extended. For now, three types.
Decision: new Stat fields are optional, not required. Mandatory fields would force every fileserver to synthesize permissions and ownership, even when meaningless (what’s the uid of
/dev/null?). Optional fields let each fileserver provide what it naturally has. The consumer decides what absence means.
Readdir
interface DirEntry { name: string type: 'file' | 'dir' | 'device'}Returns entries for the immediate children. Does not include . or .. — those are navigation concepts handled by path resolution, not by the fileserver.
Decision: no
.or..in readdir. Fileservers shouldn’t need to synthesize parent references. Path normalization (resolving..) happens in the kernel before the path reaches a fileserver. A fileserver only sees paths within its mount subtree, already cleaned.
wstat
Metadata mutation — chmod, chown, and explicit timestamp changes — goes through wstat:
interface StatChanges { mode: number uid: number gid: number mtime: number}wstat(path, changes) applies partial metadata updates. Only the fields present in changes are modified. Fileservers that don’t support metadata mutation throw EPERM. See the inodes spec for the full design rationale, including why wstat replaces separate chmod/chown methods.
Error Contract
Fileservers throw typed errors. The kernel catches and propagates them to the calling process.
class FsError extends Error { constructor( public code: string, // e.g., 'ENOENT', 'EPERM' message: string, ) { super(message) }}Standard error codes:
| Code | Meaning |
|---|---|
ENOENT | No such file or directory |
EEXIST | File already exists (create when already present) |
EISDIR | Is a directory (e.g., read() on a dir) |
ENOTDIR | Not a directory (e.g., readdir() on a file) |
ENOSPC | No space left (if fileserver enforces limits) |
EBADF | Bad file descriptor (unknown fd token) |
EINVAL | Invalid argument |
Fileservers are not required to use all codes. A minimal fileserver might only throw ENOENT. The error contract is a vocabulary, not a mandate.
Decision: single FsError class with string codes, not subclasses per error. One
catch (e) { if (e.code === 'ENOENT') }pattern is simpler than a class hierarchy. Matches Node.js convention. The code string is stable for programmatic use, the message is human-readable context.
Built-in Fileservers
MemoryFS
Mount: /, /tmp
Backing: In-memory tree of INodes
The default general-purpose fileserver. A tree of directories and files stored as INode objects. MemoryFS provides full metadata support (mode, uid, gid, ctime) and implements ExecCapable for native executable resolution. See the inodes spec for the INode data model and the executables spec for how exec nodes work.
- Open returns a cursor object
{ node, offset: 0 } - Read/write operate on
node.content, aUint8Arraythat grows as needed wstatupdates metadata fields in place and setsctimetoDate.now()- No size limits by default (it’s in-memory, bounded by heap)
/tmpis a separate MemoryFS instance so it can be wiped independently
Decision: content stored as Uint8Array, grown by doubling. When a write exceeds current capacity, allocate a new buffer at 2x size and copy. This amortizes allocation cost. Shrinking is not supported — files don’t auto-compact. This is fine for an in-memory fs that lives for a session.
BinFS (Removed)
BinFS has been replaced by exec nodes on MemoryFS. Native function references are stored as the exec field on INode file nodes in /bin. The kernel accesses them through the ExecCapable interface during PATH resolution. See the executables spec for the full exec model and the inodes spec for how exec fields live on INodes.
ProcFS
Mount: /proc
Backing: Kernel process table (read-only view)
Exposes the process table as a synthetic filesystem. Each process is a directory, each property is a file.
/proc/ 1/ status → "running" argv → '["sh"]' env → '{"PATH":"/bin","HOME":"/home"}' fds → '{"0":"/dev/tty","1":"/dev/tty","2":"/dev/tty"}' cwd → "/home" 2/ status → "running" argv → '["grep","pattern"]' ...- All files are read-only.
open()with write flag →EPERM - Content is generated on
read(), not stored — always reflects current state readdir("/proc")→ lists pids as directory entriesreaddir("/proc/1")→ lists["status", "argv", "env", "fds", "cwd"]- Content is JSON-encoded for structured data, plain strings for simple values
Decision: ProcFS is read-only, content generated on read. No caching, no staleness. Every read hits the live process table. Performance is not a concern — the process table is small and in-memory. Writing to procfs is disallowed; process manipulation goes through kernel syscalls (signal, etc.), not file writes.
ConsFS
Mount: /dev/cons (directory)
Backing: External I/O channel (LLM API, terminal, WebSocket)
The unified terminal fileserver, modeled after Plan 9’s #c console device. Replaces the former TtyFS + LineDiscipline + TtyCtlFS trio with a single fileserver. The bridge between the Unix environment and the outside world.
/dev/cons/ data — read/write terminal data (cooked or raw mode) ctl — write control commands ("rawon", "rawoff") size — read terminal dimensions as "cols rows\n"Factory: createConsFS(tty: TtyConfig, opts: ConsOpts) → ConsResult
data: reads pull from the terminal input stream, writes push to the terminal output streamctl: accepts mode-switch commands; line discipline is internal to the fileserversize: returns current terminal dimensions- Mounted via
mountConsTerminal()helper during boot
Decision: consFS collapses three fileservers into one. Plan 9’s
#cdevice exposes/dev/cons(data) and/dev/consctl(mode). We unify under a single directory fileserver at/dev/conswithdata,ctl, andsizeinner paths. This eliminates the coordination complexity between separate ttyFS, ldisc, and ttyCtlFS objects.
DevFS
Mount: /dev
Backing: Synthetic devices
Provides standard Unix device files.
| Path | Behavior |
|---|---|
/dev/null | Reads return EOF. Writes succeed silently, data discarded. |
/dev/zero | Reads return zero-filled bytes. Writes discarded. |
/dev/random | Reads return random bytes via crypto.getRandomValues(). |
Decision: DevFS for MVP includes only null, zero, and random. These cover the essential use cases: discard output (
> /dev/null), generate test data (/dev/zero), randomness (/dev/random). Other devices (urandom, stdin/stdout aliases) can be added trivially since they follow the same pattern.
Implementation is minimal — each device is a few lines:
const devices: Record<string, { read, write }> = { null: { read: () => new Uint8Array(0), // always EOF write: (_, __, data) => data.length, // discard, report success }, zero: { read: (_, __, count) => new Uint8Array(count), // zero-filled write: (_, __, data) => data.length, }, random: { read: (_, __, count) => { const buf = new Uint8Array(count) crypto.getRandomValues(buf) return buf }, write: (_, __, data) => data.length, },}NetFS (Future)
Mount: /dev/net
Backing: fetch, WebSocket, or real sockets
Not specified for MVP. The idea: opening /dev/net/tcp/example.com/80 returns an fd you can read/write like a file. HTTP could be a convenience layer on top. Deferred until networking is actually needed.
PersistentFS (Future)
Mount: /home
Backing: LevelDB, localStorage, IndexedDB, or real fs
Not specified for MVP. MemoryFS at / handles all storage needs for single-session use. Persistence matters when state needs to survive across sessions. The interface is identical to MemoryFS — only the backing store changes.
Writing a Custom Fileserver
A fileserver is any object satisfying the Fileserver interface. Minimal example:
const counterFS: Fileserver = { value: 0,
async open(path: string, flags: OpenFlags) { return {} // opaque fd token },
async read(fd: unknown, offset: number, count: number) { return encoder.encode(String(this.value)) },
async write(fd: unknown, offset: number, data: Uint8Array) { this.value = parseInt(decoder.decode(data), 10) return data.length },
async close(fd: unknown) {},
async stat(path: string) { return { name: 'counter', size: 0, type: 'file' as const, mtime: 0 } },
async readdir(path: string) { throw new FsError('ENOTDIR', 'counter is not a directory') },
async mkdir() { throw new FsError('EPERM', 'counter: not supported') }, async remove() { throw new FsError('EPERM', 'counter: not supported') }, async rename() { throw new FsError('EPERM', 'counter: not supported') }, async wstat() { throw new FsError('EPERM', 'counter: not supported') },}
// Mount itproc.mount(counterFS, '/srv/counter')
// Use it// echo 42 > /srv/counter// cat /srv/counter → "42"This is the extension point. LLMs can create fileservers at runtime that wrap APIs, databases, sensors — anything that can be modeled as read/write on paths.
SrvFS
Mount: /srv
Backing: Shared in-memory registry of posted Fileserver references
The Plan 9 /srv analog. Server processes post Fileserver objects here for other processes to discover and mount into their own namespace. Acts as a rendezvous point — not on the data path after mounting. See srv.md for the full design.
readdir('/')→ lists posted server namesstat('/<name>')→ confirms a posted entry existsopen/readon/<name>→ returns description/status string- All writes →
EPERM(posting is programmatic via ProcContext) - Implements
SrvCapablefor object-level access (getServer,postServer,removeServer)
See also: namespaces.md for mount/umount/bind commands, roadmap.md for the incremental implementation plan.
Future: Permission Middleware
Permissions are fileserver wrappers, not kernel concerns. A middleware fileserver delegates to an inner fileserver with access checks:
function readonlyView(inner: Fileserver): Fileserver { const deny = () => { throw new FsError('EPERM', 'read-only') } return { open: (path, flags) => { if (flags.write) deny() return inner.open(path, { read: true }) }, read: inner.read.bind(inner), write: deny, close: inner.close.bind(inner), stat: inner.stat.bind(inner), readdir: inner.readdir.bind(inner), mkdir: deny, remove: deny, rename: deny, wstat: deny, }}These compose: mount(rateLimit(readonlyView(authFilter(realFs, creds))))
Not implemented for MVP. Documented here because it validates the protocol design — if permissions couldn’t be modeled as middleware, the protocol would be wrong.
Future: 9P Wire Bridging
The protocol mirrors 9P2000 semantics. To bridge to a real Plan 9 fileserver:
- Add
attach(aname, uname) → rootFdfor session setup - Add
walk(fd, names[]) → newFdfor path traversal - Wire format translation (binary ↔ JS objects) over TCP/WebSocket
Deferred. The current open(path) collapses attach+walk into one call, which is correct for in-process use. Walk becomes necessary only for network-mounted fileservers where per-component traversal matters for latency.