diff --git a/dev/bootloader.js b/dev/bootloader.js
index 37a9332..92bea5d 100644
--- a/dev/bootloader.js
+++ b/dev/bootloader.js
@@ -81,31 +81,24 @@ if (!globalThis["ServiceWorkerGlobalScope"]) {
     await setupServiceWorker();
 
     globalThis.initfs = {};
-    const load = async (path) => {
-      const basename = (path) => path.replace(/\\/g,'/').split('/').pop();
-      if (globalThis.initdata) {
-        // use embedded data if present
-        path = `./~init/${basename(path)}`;
+    const load = async (name, file) => {
+      // Determine if file contains a path to fetch or embedded file contents to load.
+      if(file.type === "text/plain") {
+        globalThis.initfs[name] = { mtimeMs: file.mtimeMs, blob: await (await fetch(`./sys/dev/${file.data}`)).blob() };
+      } else {
+        globalThis.initfs[name] = { mtimeMs: file.mtimeMs, blob: await (await fetch(`./~init/${name}`)).blob() };
       }
-      globalThis.initfs[basename(path)] = await (await fetch(path)).blob();
+    };
+
+    // allow loading concurrently
+    let loads = [];
+    for(const property in globalThis.initdata) {
+      loads.push(load(property, globalThis.initdata[property]));
     }
-    // TODO: define these in one place. duplicated in initdata.go
-    await Promise.all([
-      load("./sys/dev/kernel/web/lib/duplex.js"),
-      load("./sys/dev/kernel/web/lib/worker.js"),
-      load("./sys/dev/kernel/web/lib/syscall.js"),
-      load("./sys/dev/kernel/web/lib/task.js"),
-      load("./sys/dev/kernel/web/lib/wasm.js"),
-      load("./sys/dev/kernel/web/lib/host.js"),
-      load("./sys/dev/internal/indexedfs/indexedfs.js"), // maybe load from kernel?
-      load("./sys/dev/local/bin/kernel"),
-      load("./sys/dev/local/bin/shell"),
-      load("./sys/dev/local/bin/build"),
-      load("./sys/dev/local/bin/micro"),
-    ]);
-    
-    globalThis.duplex = await import(URL.createObjectURL(initfs["duplex.js"]));
-    globalThis.task = await import(URL.createObjectURL(initfs["task.js"]));
+    await Promise.all(loads);
+
+    globalThis.duplex = await import(URL.createObjectURL(initfs["duplex.js"].blob));
+    globalThis.task = await import(URL.createObjectURL(initfs["task.js"].blob));
 
     globalThis.sys = new task.Task(initfs);
     
@@ -114,7 +107,7 @@ if (!globalThis["ServiceWorkerGlobalScope"]) {
     await sys.exec("kernel");
 
     // load host API
-    await import(URL.createObjectURL(initfs["host.js"]));
+    await import(URL.createObjectURL(initfs["host.js"].blob));
 
   })();
 }
diff --git a/dev/bundle.go b/dev/bundle.go
index d449154..31fe2a6 100644
--- a/dev/bundle.go
+++ b/dev/bundle.go
@@ -14,7 +14,7 @@ func main() {
 	fatal(err)
 	defer f.Close()
 
-	PackFilesTo(f)
+	PackFilesTo(f, PackFileData)
 
 	src, err := os.ReadFile("./dev/bootloader.js")
 	fatal(err)
diff --git a/dev/initdata.go b/dev/initdata.go
index 486aac9..2399b3f 100644
--- a/dev/initdata.go
+++ b/dev/initdata.go
@@ -7,42 +7,75 @@ import (
 	"io"
 	"log"
 	"os"
-	"path/filepath"
 	"strings"
 	"text/template"
 )
 
-func PackFilesTo(w io.Writer) {
-	var files []File
-	for _, path := range []string{
-		"./kernel/web/lib/duplex.js",
-		"./kernel/web/lib/worker.js",
-		"./kernel/web/lib/syscall.js",
-		"./kernel/web/lib/task.js",
-		"./kernel/web/lib/wasm.js",
-		"./kernel/web/lib/host.js",
-		"./internal/indexedfs/indexedfs.js",
-		"./local/bin/kernel",
-		"./local/bin/shell",
-		"./local/bin/build",
-		"./local/bin/micro",
-	} {
-		typ := "application/octet-stream"
-		if strings.HasSuffix(path, ".js") {
-			typ = "application/javascript"
+// Files must be local to the Wanix project root.
+var files = []File{
+	{Name: "duplex.js", Path: "./kernel/web/lib/duplex.js"},
+	{Name: "worker.js", Path: "./kernel/web/lib/worker.js"},
+	{Name: "syscall.js", Path: "./kernel/web/lib/syscall.js"},
+	{Name: "task.js", Path: "./kernel/web/lib/task.js"},
+	{Name: "wasm.js", Path: "./kernel/web/lib/wasm.js"},
+	{Name: "host.js", Path: "./kernel/web/lib/host.js"},
+	{Name: "indexedfs.js", Path: "./internal/indexedfs/indexedfs.js"},
+	{Name: "kernel", Path: "./local/bin/kernel"},
+	{Name: "build", Path: "./local/bin/build"},
+	{Name: "macro", Path: "./local/bin/micro"},
+
+	// Shell source files
+	{Name: "shell/main.go", Path: "shell/main.go"},
+	{Name: "shell/copy.go", Path: "shell/copy.go"},
+	{Name: "shell/download.go", Path: "shell/download.go"},
+	{Name: "shell/main.go", Path: "shell/main.go"},
+	{Name: "shell/open.go", Path: "shell/open.go"},
+	{Name: "shell/preprocessor.go", Path: "shell/preprocessor.go"},
+	{Name: "shell/smallcmds.go", Path: "shell/smallcmds.go"},
+	{Name: "shell/tree.go", Path: "shell/tree.go"},
+	{Name: "shell/util.go", Path: "shell/util.go"},
+	{Name: "shell/watch.go", Path: "shell/watch.go"},
+}
+
+type PackMode int
+
+const (
+	PackFileData PackMode = iota
+	PackFilePaths
+)
+
+func PackFilesTo(w io.Writer, mode PackMode) {
+	switch mode {
+	case PackFileData:
+		for i := range files {
+			if strings.HasSuffix(files[i].Path, ".js") {
+				files[i].Type = "application/javascript"
+			} else {
+				files[i].Type = "application/octet-stream"
+			}
+
+			fi, err := os.Stat(files[i].Path)
+			fatal(err)
+			files[i].Mtime = fi.ModTime().UnixMilli()
+
+			data, err := os.ReadFile(files[i].Path)
+			fatal(err)
+			var gzipBuffer bytes.Buffer
+			gzipWriter := gzip.NewWriter(&gzipBuffer)
+			_, err = gzipWriter.Write(data)
+			fatal(err)
+			fatal(gzipWriter.Close())
+			files[i].Data = base64.StdEncoding.EncodeToString(gzipBuffer.Bytes())
+		}
+
+	case PackFilePaths:
+		for i := range files {
+			files[i].Type = "text/plain"
+			files[i].Data = files[i].Path
+			fi, err := os.Stat(files[i].Path)
+			fatal(err)
+			files[i].Mtime = fi.ModTime().UnixMilli()
 		}
-		data, err := os.ReadFile(path)
-		fatal(err)
-		var gzipBuffer bytes.Buffer
-		gzipWriter := gzip.NewWriter(&gzipBuffer)
-		_, err = gzipWriter.Write(data)
-		fatal(err)
-		fatal(gzipWriter.Close())
-		files = append(files, File{
-			Name: filepath.Base(path),
-			Type: typ,
-			Data: base64.StdEncoding.EncodeToString(gzipBuffer.Bytes()),
-		})
 	}
 
 	t := template.Must(template.New("initdata.tmpl").ParseFiles("./dev/initdata.tmpl"))
@@ -52,9 +85,11 @@ func PackFilesTo(w io.Writer) {
 }
 
 type File struct {
-	Name string
-	Type string
-	Data string
+	Name  string
+	Path  string
+	Type  string
+	Data  string
+	Mtime int64
 }
 
 func fatal(err error) {
diff --git a/dev/initdata.tmpl b/dev/initdata.tmpl
index a38408a..9143c70 100644
--- a/dev/initdata.tmpl
+++ b/dev/initdata.tmpl
@@ -1,5 +1,5 @@
 globalThis.initdata = {
 {{range .}}
-  "{{.Name}}": {type: "{{.Type}}", data: "{{.Data}}"},
+  "{{.Name}}": {type: "{{.Type}}", mtimeMs: {{.Mtime}}, data: "{{.Data}}"},
 {{end}}
 }
diff --git a/dev/server.go b/dev/server.go
index 2827731..582eb6c 100644
--- a/dev/server.go
+++ b/dev/server.go
@@ -38,17 +38,21 @@ func main() {
 	mux.Handle(fmt.Sprintf("%s/wanix-bootloader.js", basePath), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.Header().Set("content-type", "application/javascript")
 
-		if os.Getenv("PROD") != "1" {
-			http.ServeFile(w, r, "./dev/bootloader.js")
-			return
+		var packMode PackMode
+		if os.Getenv("PROD") == "1" {
+			log.Printf("Packing self-contained bootloader...\n")
+			packMode = PackFileData
+		} else {
+			packMode = PackFilePaths
 		}
 
-		// emulate a build
-		PackFilesTo(w)
+		// TODO: Does this need to pack on every request for the bootloader?
+		// I don't think you want to be changing PROD at runtime, so we can
+		// probably cache this.
+		PackFilesTo(w, packMode)
 		f, err := os.ReadFile("./dev/bootloader.js")
 		fatal(err)
 		w.Write(f)
-
 	}))
 	mux.Handle(fmt.Sprintf("%s/~init/", basePath), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		http.Error(w, "Not found", http.StatusNotFound)
diff --git a/internal/app/terminal/index.html b/internal/app/terminal/index.html
index 1069af8..ffbcc4e 100644
--- a/internal/app/terminal/index.html
+++ b/internal/app/terminal/index.html
@@ -61,7 +61,7 @@
     const enc = new TextEncoder();
     const dec = new TextDecoder();
 
-    const resp = await sys.call("tty.open", ["shell", [], { TERM: "xterm-256color" }]);
+    const resp = await sys.call("tty.open", ["sys/cmd/shell", [], { TERM: "xterm-256color" }]);
     const pid = resp.value;
     const ch = resp.channel;
     //parent.wanix.termCh = ch;
diff --git a/internal/indexedfs/indexedfs.go b/internal/indexedfs/indexedfs.go
index d29f1e9..a68901d 100644
--- a/internal/indexedfs/indexedfs.go
+++ b/internal/indexedfs/indexedfs.go
@@ -54,7 +54,7 @@ type FS struct {
 
 func New() (*FS, error) {
 	if helper.IsUndefined() {
-		blob := js.Global().Get("initfs").Get("indexedfs.js")
+		blob := js.Global().Get("initfs").Get("indexedfs.js").Get("blob")
 		url := js.Global().Get("URL").Call("createObjectURL", blob)
 		helper = jsutil.Await(js.Global().Call("import", url))
 	}
diff --git a/kernel/fs/fs.go b/kernel/fs/fs.go
index 78ecf7e..6890cfa 100644
--- a/kernel/fs/fs.go
+++ b/kernel/fs/fs.go
@@ -32,7 +32,9 @@ func log(args ...any) {
 }
 
 type Service struct {
-	fsys *watchfs.FS
+	fsys fs.MutableFS
+	// Wraps fsys, so it's actually the same filesystem.
+	watcher *watchfs.FS
 
 	fds    map[int]*fd
 	nextFd int
@@ -53,21 +55,30 @@ func (s *Service) Initialize() {
 	if err != nil {
 		panic(err)
 	}
-	mntfs := mountablefs.New(ifs)
-	s.fsys = watchfs.New(mntfs)
+	s.fsys = mountablefs.New(ifs)
+	s.watcher = watchfs.New(s.fsys)
 
 	// ensure basic system tree exists
-	fs.MkdirAll(s.fsys.FS, "app", 0755)
-	fs.MkdirAll(s.fsys.FS, "cmd", 0755)
-	fs.MkdirAll(s.fsys.FS, "sys/app", 0755)
-	fs.MkdirAll(s.fsys.FS, "sys/bin", 0755)
-	fs.MkdirAll(s.fsys.FS, "sys/cmd", 0755)
-	fs.MkdirAll(s.fsys.FS, "sys/dev", 0755)
-	fs.MkdirAll(s.fsys.FS, "sys/tmp", 0755)
+	fs.MkdirAll(s.fsys, "app", 0755)
+	fs.MkdirAll(s.fsys, "cmd", 0755)
+	fs.MkdirAll(s.fsys, "sys/app", 0755)
+	fs.MkdirAll(s.fsys, "sys/bin", 0755)
+	fs.MkdirAll(s.fsys, "sys/cmd", 0755)
+	fs.MkdirAll(s.fsys, "sys/dev", 0755)
+	fs.MkdirAll(s.fsys, "sys/tmp", 0755)
 
 	// copy builtin exe's into filesystem
-	s.copyInitFileIntoFS("sys/cmd/build.wasm", "build")
-	s.copyInitFileIntoFS("cmd/micro.wasm", "micro")
+	s.copyFileFromInitFS("sys/cmd/build.wasm", "build")
+	s.copyFileFromInitFS("cmd/micro.wasm", "micro")
+
+	// copy shell source into filesystem
+	fs.MkdirAll(s.fsys, "sys/cmd/shell", 0755)
+	shellFiles := getPrefixedInitFiles("shell/")
+	for _, path := range shellFiles {
+		if err = s.copyFileFromInitFS(filepath.Join("sys/cmd", path), path); err != nil {
+			panic(err)
+		}
+	}
 
 	devURL := fmt.Sprintf("%ssys/dev", js.Global().Get("hostURL").String())
 	resp, err := http.DefaultClient.Get(devURL)
@@ -75,19 +86,39 @@ func (s *Service) Initialize() {
 		panic(err)
 	}
 	if resp.StatusCode == 200 {
-		if err := s.fsys.FS.(*mountablefs.FS).Mount(httpfs.New(devURL), "/sys/dev"); err != nil {
+		if err := s.fsys.(*mountablefs.FS).Mount(httpfs.New(devURL), "/sys/dev"); err != nil {
 			panic(err)
 		}
 	}
 
-	if err := s.fsys.FS.(*mountablefs.FS).Mount(memfs.New(), "/sys/tmp"); err != nil {
+	if err := s.fsys.(*mountablefs.FS).Mount(memfs.New(), "/sys/tmp"); err != nil {
 		panic(err)
 	}
 }
 
-func (s *Service) copyInitFileIntoFS(dst, src string) error {
+func getPrefixedInitFiles(prefix string) []string {
+	names := js.Global().Get("Object").Call("getOwnPropertyNames", js.Global().Get("initfs"))
+	length := names.Length()
+
+	var result []string
+	for i := 0; i < length; i += 1 {
+		name := names.Index(i).String()
+		if strings.HasPrefix(name, prefix) {
+			result = append(result, name)
+		}
+	}
+
+	return result
+}
+
+func (s *Service) copyFileFromInitFS(dst, src string) error {
+	initFile := js.Global().Get("initfs").Get(src)
+	if initFile.IsUndefined() {
+		return nil
+	}
+
 	var exists bool
-	fi, err := fs.Stat(s.fsys.FS, dst)
+	fi, err := fs.Stat(s.fsys, dst)
 	if err == nil {
 		exists = true
 	} else if os.IsNotExist(err) {
@@ -96,12 +127,8 @@ func (s *Service) copyInitFileIntoFS(dst, src string) error {
 		return err
 	}
 
-	blob := js.Global().Get("initfs").Get(src)
-	if blob.IsUndefined() {
-		return nil
-	}
-
-	if !exists || int64(blob.Get("size").Int()) != fi.Size() {
+	if !exists || time.UnixMilli(int64(initFile.Get("mtimeMs").Float())).After(fi.ModTime()) {
+		blob := initFile.Get("blob")
 		buffer, err := jsutil.AwaitErr(blob.Call("arrayBuffer"))
 		if err != nil {
 			return err
@@ -110,7 +137,7 @@ func (s *Service) copyInitFileIntoFS(dst, src string) error {
 		// TODO: creating the file and applying the blob directly in indexedfs would be faster.
 		data := make([]byte, blob.Get("size").Int())
 		js.CopyBytesToGo(data, js.Global().Get("Uint8Array").New(buffer))
-		err = fs.WriteFile(s.fsys.FS, dst, data, 0644)
+		err = fs.WriteFile(s.fsys, dst, data, 0644)
 		if err != nil {
 			return err
 		}
@@ -197,7 +224,7 @@ func (s *Service) open(this js.Value, args []js.Value) any {
 	go func() {
 		log("open", path, s.nextFd, strings.Join(flags, ","), fmt.Sprintf("%o\n", mode))
 
-		f, err := s.fsys.FS.(*mountablefs.FS).OpenFile(path, flag, fs.FileMode(mode))
+		f, err := s.fsys.OpenFile(path, flag, fs.FileMode(mode))
 		if err != nil {
 			if f != nil {
 				log("opened")
@@ -346,7 +373,7 @@ func (s *Service) readdir(this js.Value, args []js.Value) any {
 	go func() {
 		log("readdir", path)
 
-		fi, err := fs.ReadDir(s.fsys.FS, path)
+		fi, err := fs.ReadDir(s.fsys, path)
 		if err != nil {
 			cb.Invoke(jsutil.ToJSError(err))
 			return
@@ -385,7 +412,7 @@ func newStatEmpty() map[string]any {
 }
 
 func (s *Service) _stat(path string, cb js.Value) {
-	fi, err := s.fsys.FS.(*mountablefs.FS).Stat(path)
+	fi, err := s.fsys.Stat(path)
 	if err != nil {
 		cb.Invoke(jsutil.ToJSError(err))
 		return
@@ -504,7 +531,7 @@ func (s *Service) chown(this js.Value, args []js.Value) any {
 	go func() {
 		log("chown", path)
 
-		if err := s.fsys.FS.(*mountablefs.FS).Chown(path, uid, gid); err != nil {
+		if err := s.fsys.Chown(path, uid, gid); err != nil {
 			cb.Invoke(jsutil.ToJSError(err))
 			return
 		}
@@ -533,7 +560,7 @@ func (s *Service) fchown(this js.Value, args []js.Value) any {
 			return
 		}
 
-		if err := s.fsys.FS.(*mountablefs.FS).Chown(f.Path, uid, gid); err != nil {
+		if err := s.fsys.Chown(f.Path, uid, gid); err != nil {
 			cb.Invoke(jsutil.ToJSError(err))
 			return
 		}
@@ -554,7 +581,7 @@ func (s *Service) lchown(this js.Value, args []js.Value) any {
 	go func() {
 		log("lchown", path)
 
-		if err := s.fsys.FS.(*mountablefs.FS).Chown(path, uid, gid); err != nil {
+		if err := s.fsys.Chown(path, uid, gid); err != nil {
 			cb.Invoke(jsutil.ToJSError(err))
 			return
 		}
@@ -574,7 +601,7 @@ func (s *Service) chmod(this js.Value, args []js.Value) any {
 	go func() {
 		log("chmod", path)
 
-		if err := s.fsys.FS.(*mountablefs.FS).Chmod(path, fs.FileMode(mode)); err != nil {
+		if err := s.fsys.Chmod(path, fs.FileMode(mode)); err != nil {
 			cb.Invoke(jsutil.ToJSError(err))
 			return
 		}
@@ -602,7 +629,7 @@ func (s *Service) fchmod(this js.Value, args []js.Value) any {
 			return
 		}
 
-		if err := s.fsys.FS.(*mountablefs.FS).Chmod(f.Path, fs.FileMode(mode)); err != nil {
+		if err := s.fsys.Chmod(f.Path, fs.FileMode(mode)); err != nil {
 			cb.Invoke(jsutil.ToJSError(err))
 			return
 		}
@@ -622,7 +649,7 @@ func (s *Service) mkdir(this js.Value, args []js.Value) any {
 	go func() {
 		log("mkdir", path)
 
-		if err := s.fsys.FS.(*mountablefs.FS).MkdirAll(path, os.FileMode(perm)); err != nil {
+		if err := s.fsys.MkdirAll(path, os.FileMode(perm)); err != nil {
 			cb.Invoke(jsutil.ToJSError(err))
 			return
 		}
@@ -641,7 +668,7 @@ func (s *Service) rename(this js.Value, args []js.Value) any {
 	go func() {
 		log("rename", from, to)
 
-		if err := s.fsys.FS.(*mountablefs.FS).Rename(from, to); err != nil {
+		if err := s.fsys.Rename(from, to); err != nil {
 			cb.Invoke(jsutil.ToJSError(err))
 			return
 		}
@@ -661,7 +688,7 @@ func (s *Service) rmdir(this js.Value, args []js.Value) any {
 		log("rmdir", path)
 
 		// TODO: should only remove if dir is empty i think?
-		if err := s.fsys.FS.(*mountablefs.FS).RemoveAll(path); err != nil {
+		if err := s.fsys.RemoveAll(path); err != nil {
 			cb.Invoke(jsutil.ToJSError(err))
 			return
 		}
@@ -681,7 +708,7 @@ func (s *Service) unlink(this js.Value, args []js.Value) any {
 		log("unlink", path)
 
 		// GOOS=js calls unlink for os.RemoveAll so we use RemoveAll here
-		if err := s.fsys.FS.(*mountablefs.FS).RemoveAll(path); err != nil {
+		if err := s.fsys.RemoveAll(path); err != nil {
 			cb.Invoke(jsutil.ToJSError(err))
 			return
 		}
@@ -733,7 +760,7 @@ func (s *Service) utimes(this js.Value, args []js.Value) any {
 	go func() {
 		log("utimes", path)
 
-		if err := s.fsys.FS.(*mountablefs.FS).Chtimes(path, atime, mtime); err != nil {
+		if err := s.fsys.Chtimes(path, atime, mtime); err != nil {
 			cb.Invoke(jsutil.ToJSError(err))
 			return
 		}
@@ -762,7 +789,7 @@ func (s *Service) watchRPC(this js.Value, args []js.Value) any {
 
 		log("watch", path, recursive, eventMask, params.Index(3))
 
-		w, err := s.fsys.Watch(path, &watchfs.Config{
+		w, err := s.watcher.Watch(path, &watchfs.Config{
 			Recursive: recursive,
 			EventMask: eventMask,
 			Ignores:   ignores,
diff --git a/kernel/main.go b/kernel/main.go
index 4fb1e2d..7d41d3d 100644
--- a/kernel/main.go
+++ b/kernel/main.go
@@ -34,7 +34,7 @@ type Kernel struct {
 
 func (k *Kernel) Run(ctx context.Context) error {
 	// import syscall.js
-	blob := js.Global().Get("initfs").Get("syscall.js")
+	blob := js.Global().Get("initfs").Get("syscall.js").Get("blob")
 	url := js.Global().Get("URL").Call("createObjectURL", blob)
 	jsutil.Await(js.Global().Call("import", url))
 
diff --git a/kernel/proc/proc.go b/kernel/proc/proc.go
index 30018d0..83307e2 100644
--- a/kernel/proc/proc.go
+++ b/kernel/proc/proc.go
@@ -3,9 +3,13 @@ package proc
 import (
 	"fmt"
 	"io"
+	"os"
+	"path/filepath"
+	"strings"
 	"sync"
 	"syscall/js"
 
+	"tractor.dev/toolkit-go/engine/fs"
 	"tractor.dev/wanix/internal/jsutil"
 )
 
@@ -32,6 +36,25 @@ func (s *Service) Get(pid int) (*Process, error) {
 func (s *Service) Spawn(path string, args []string, env map[string]string, dir string) (*Process, error) {
 	// TODO: check path exists, execute bit
 
+	// can't use jsutil.WanixSyscall inside the kernel
+	stat, err := jsutil.AwaitErr(js.Global().Get("api").Get("fs").Call("stat", path))
+	if err != nil {
+		return nil, err
+	}
+
+	if stat.Get("isDirectory").Bool() {
+		matches, _ := fs.Glob(os.DirFS(unixToFsPath(path)), "*.go")
+		if matches != nil && len(matches) > 0 {
+			path, err = s.buildCmdSource(path, dir)
+			if err != nil {
+				return nil, err
+			}
+			if path == "" {
+				return nil, os.ErrInvalid
+			}
+		}
+	}
+
 	if env == nil {
 		// TODO: set from os.Environ()
 	}
@@ -53,7 +76,7 @@ func (s *Service) Spawn(path string, args []string, env map[string]string, dir s
 	s.mu.Unlock()
 
 	p.Task = js.Global().Get("task").Get("Task").New(js.Global().Get("initfs"), p.ID)
-	_, err := jsutil.AwaitErr(p.Task.Call("exec", p.Path, jsutil.ToJSArray(p.Args), map[string]any{
+	_, err = jsutil.AwaitErr(p.Task.Call("exec", p.Path, jsutil.ToJSArray(p.Args), map[string]any{
 		"env": jsutil.ToJSMap(p.Env),
 		"dir": p.Dir,
 	}))
@@ -103,3 +126,73 @@ func (p *Process) Wait() (int, error) {
 	v, err := jsutil.AwaitErr(p.Task.Call("wait"))
 	return v.Int(), err
 }
+
+func unixToFsPath(path string) string {
+	return filepath.Clean(strings.TrimLeft(path, "/"))
+}
+
+// returns an empty wasmPath on error or non-zero exit code
+func (s *Service) buildCmdSource(path, workingDir string) (wasmPath string, err error) {
+	wasmPath = filepath.Join("/sys/bin", filepath.Base(path)+".wasm")
+
+	dfs := os.DirFS("/")
+	var wasmExists bool
+	wasmStat, err := fs.Stat(dfs, unixToFsPath(wasmPath))
+	if err == nil {
+		wasmExists = true
+	} else if os.IsNotExist(err) {
+		wasmExists = false
+	} else {
+		return "", err
+	}
+
+	var shouldBuild bool
+	if !wasmExists {
+		shouldBuild = true
+	} else {
+		wasmMtime := wasmStat.ModTime()
+		err = fs.WalkDir(dfs, unixToFsPath(path), func(walkPath string, d fs.DirEntry, walkErr error) error {
+			if walkErr != nil {
+				return walkErr
+			}
+
+			fi, err := d.Info()
+			if err != nil {
+				return err
+			}
+
+			if fi.ModTime().After(wasmMtime) {
+				shouldBuild = true
+				return fs.SkipAll
+			}
+
+			return nil
+		})
+		if err != nil {
+			return "", err
+		}
+	}
+
+	if shouldBuild {
+		p, err := s.Spawn(
+			"/sys/cmd/build.wasm",
+			[]string{"-output", wasmPath, path},
+			map[string]string{},
+			workingDir,
+		)
+		if err != nil {
+			return "", err
+		}
+
+		// TODO: https://github.com/tractordev/wanix/issues/69
+		// go io.Copy(os.Stdout, p.Stdout())
+		// go io.Copy(os.Stderr, p.Stderr())
+
+		exitCode, err := p.Wait()
+		if exitCode != 0 {
+			return "", err
+		}
+	}
+
+	return wasmPath, nil
+}
diff --git a/kernel/web/lib/task.js b/kernel/web/lib/task.js
index 91902c0..709e6d8 100644
--- a/kernel/web/lib/task.js
+++ b/kernel/web/lib/task.js
@@ -11,8 +11,8 @@ export class Task {
     const name = `${path.split('/').pop()}.${this.pid}`;
 
     const blob = new Blob([
-      this.initfs["worker.js"], 
-      this.initfs["wasm.js"],
+      this.initfs["worker.js"].blob, 
+      this.initfs["wasm.js"].blob,
       `\n//# sourceURL=${name}\n` // names the worker in logs
     ], { type: 'application/javascript' });
     
@@ -34,7 +34,7 @@ export class Task {
       })
     });
 
-    const duplex = await import(URL.createObjectURL(this.initfs["duplex.js"]));
+    const duplex = await import(URL.createObjectURL(this.initfs["duplex.js"].blob));
     this.pipe = duplex.open(new duplex.WorkerConn(this.worker), new duplex.CBORCodec());
     this.pipe.respond();
 
diff --git a/kernel/web/lib/wasm.js b/kernel/web/lib/wasm.js
index f92f4a8..02f64a2 100644
--- a/kernel/web/lib/wasm.js
+++ b/kernel/web/lib/wasm.js
@@ -101,10 +101,11 @@
 			},
 			// writeSync used by runtime.wasmWrite
 			writeSync(fd, buf) {
+				if(!buf) return 0;
+
 				outputBuf += decoder.decode(buf);
 				const nl = outputBuf.lastIndexOf("\n");
 				if (nl != -1) {
-					console.log(outputBuf.substring(0, nl));
 					outputBuf = outputBuf.substring(nl + 1);
 				}
 				return buf.length;
diff --git a/kernel/web/lib/worker.js b/kernel/web/lib/worker.js
index 0f9299e..45a4814 100644
--- a/kernel/web/lib/worker.js
+++ b/kernel/web/lib/worker.js
@@ -12,8 +12,8 @@ addEventListener("message", async (e) => {
     globalThis.process.ppid = e.data.init.ppid;
     globalThis.process.dir = e.data.init.dir;
 
-    globalThis.duplex = await import(URL.createObjectURL(initfs["duplex.js"]));
-    globalThis.task = await import(URL.createObjectURL(initfs["task.js"])); // only for kernel
+    globalThis.duplex = await import(URL.createObjectURL(initfs["duplex.js"].blob));
+    globalThis.task = await import(URL.createObjectURL(initfs["task.js"].blob)); // only for kernel
     
     globalThis.sys = duplex.open(new duplex.WorkerConn(globalThis), new duplex.CBORCodec());
     
@@ -26,7 +26,7 @@ addEventListener("message", async (e) => {
       }
       let mod;
       if (initfs[params[0]]) {
-        mod = await blobToArrayBuffer(initfs[params[0]]);
+        mod = await blobToArrayBuffer(initfs[params[0]].blob);
       } else {
         mod = await globalThis.fs.readFile(params[0]);
       }
diff --git a/shell/main.go b/shell/main.go
index bd6f064..7bb6d89 100644
--- a/shell/main.go
+++ b/shell/main.go
@@ -249,18 +249,13 @@ func findCommand(name string, args []string) (*exec.Cmd, error) {
 
 	if scriptPath != "" {
 		shellArgs := append([]string{scriptPath}, args...)
-		// TODO: shell is currently only available in the initfs,
-		// but the process worker is able to exec it from there anyway.
-		// We should really mount the shell exe in /sys/bin though.
-		return exec.Command("shell", shellArgs...), nil
+		return exec.Command("/sys/bin/shell.wasm", shellArgs...), nil
 	}
 
-	var err error
 	if buildPath != "" {
-		wasmPath, err = buildCmdSource(buildPath)
-		if err != nil {
-			return nil, err
-		}
+		// kernel/proc will automatically build and execute the program if you
+		// pass it the path to it's source code.
+		return exec.Command(buildPath, args...), nil
 	}
 
 	if wasmPath != "" {
diff --git a/shell/util.go b/shell/util.go
index ef49910..5deccd3 100644
--- a/shell/util.go
+++ b/shell/util.go
@@ -8,35 +8,8 @@ import (
 	"path/filepath"
 	"strings"
 	"sync"
-
-	"tractor.dev/toolkit-go/engine/fs/fsutil"
-	"tractor.dev/wanix/kernel/proc/exec"
 )
 
-// returns an empty wasmPath on error or non-zero exit code
-func buildCmdSource(path string) (wasmPath string, err error) {
-	wasmPath = filepath.Join("/sys/bin", filepath.Base(path)+".wasm")
-
-	// TODO: could also just change the search order in shell.go:findCommand()
-	wasmExists, err := fsutil.Exists(os.DirFS("/"), unixToFsPath(wasmPath))
-	if err != nil {
-		return "", err
-	}
-
-	if !wasmExists {
-		cmd := exec.Command("build", "-output", wasmPath, path)
-		cmd.Stdout = os.Stdout
-		cmd.Stderr = os.Stderr
-
-		exitCode, err := cmd.Run()
-		if exitCode != 0 {
-			return "", err
-		}
-	}
-
-	return wasmPath, nil
-}
-
 var WASM_MAGIC = []byte{0, 'a', 's', 'm'}
 
 func isWasmFile(name string) bool {