diff --git a/.eslintrc.cjs b/.eslintrc.cjs index e195d90..e41f76a 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -14,7 +14,7 @@ module.exports = { '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], }, env: { node: true, diff --git a/artipod-layer-plan.md b/artipod-layer-plan.md index b048f33..4f97601 100644 --- a/artipod-layer-plan.md +++ b/artipod-layer-plan.md @@ -43,7 +43,7 @@ Read order: §1 (goal) and §6 (decisions already made) first; skim §2–§3 fo |---|---|---|---| | 0 — repo prep | `phase-0-esm-vitest` | done (owner npm actions pending) | [#33](https://github.com/mieweb/artipod/pull/33), [#34](https://github.com/mieweb/artipod/pull/34) | | 1 — fs injection | `phase-1-podfs-injection` | done | [#35](https://github.com/mieweb/artipod/pull/35) | -| 2 — import sandbox/agent/proc | `phase-2-import-sandbox` | not started | | +| 2 — import sandbox/agent/proc | `phase-2-import-sandbox` | done (shim deletion + ui PR landing pending) | mieweb/artipod#37, [horner/artipod-sync#2](https://github.com/horner/artipod-sync/pull/2), [mieweb/ui#404](https://github.com/mieweb/ui/pull/404) | | 3 — manifest + realizers | `phase-3-manifest-realizers` | not started | | | 4 — OCI store | `phase-4-oci-store` | not started | | | 5 — snapshots + commit | `phase-5-snapshots` | not started | | @@ -288,29 +288,34 @@ The one structural refactor everything else depends on. `ArtiMount` already uses ### Phase 2 — Move sandbox, agent, proc into `@artipod/core` (executes just-bash-plan Phase 6) -> **Branch** `phase-2-import-sandbox` · **Status** _not started_ · includes a consuming PR in `horner/artipod-sync` +> **Branch** `phase-2-import-sandbox` · **Status** _done_ · includes a consuming PR in `horner/artipod-sync` -- [ ] Move `artipod-sync/lib/sandbox/` → `@artipod/core/sandbox` (incl. `zenfs-adapter`, `git-command`, `edit-command`, `notes/storage/module` commands, `storage.ts`, `table.ts`) with their vitest suites. -- [ ] Move `artipod-sync/lib/agent/` → `@artipod/core/agent` (loop, ozwell client, `local/` ONNX worker stack). Replace its `read_file`/`write_file`/`list_files` with bindings to `/tools` (schema collision #1 resolved here). Keep `bash` + truncation. Tests must satisfy Decision #8: scripted fakes only — no live model calls, no model downloads, no default AI endpoint shipped (audit the `local/` suite's mocks on the way in). -- [ ] Move `artipod-sync/lib/proc/` → `@artipod/core/proc`. -- [ ] Add `pod.events` (core) + extract `/host` controllers (`TerminalSession`, `FileBuffer`, `TreeSource`) from the logic currently embedded in `Terminal.tsx` / `Editor.tsx` / `FileTree.tsx`; rewire those components as thin shells. Acceptance detail: tree auto-refreshes after every command, editor detects external changes to its open file, agent tool-call echo arrives via `agent:tool-call` (no `registerWriter`). -- [ ] artipod-sync consumes via workspace/path dep; `lib/sandbox` etc. become re-export shims for one release, then delete. `lib/server/`, routes, components stay in the app. -- [ ] Wire `createSandbox` into the pod: `pod.createSandbox()` mounts the realized fs and registers the pod's custom commands. -- [ ] **Agent confinement stub (default-deny)**: tools/bash confined to the pod; `sudo` is recognized but returns EPERM with "approval flow lands in Phase 6.5" — pinned by tests (docs/security-model.md is normative). -- [ ] `/console` module: `installConsole({ pod, hotkey })` — builtin zero-dep renderer, `` Ctrl+` ``/`Ctrl+~` hotkey, SSR-safe no-op, honors `isPrimaryTab`; consumes only `/host` controllers + `pod.events` (docs/console.md). -- [ ] `ui/` proof: Storybook story where AIChat drives the sandbox via MCPToolCall rendering (the original Phase 6 acceptance). +- [x] Move `artipod-sync/lib/sandbox/` → `@artipod/core/sandbox` (incl. `zenfs-adapter`, `git-command`, `edit-command`, `notes/storage/module` commands, `storage.ts`, `table.ts`) with their vitest suites. Also pulled in `git.ts`/`git-auth.ts` (git-command's engine) — decoupled from the app fs singleton (factory-only; `NEXT_PUBLIC_GIT_CORS_PROXY` guarded for non-Next bundles). +- [x] Move `artipod-sync/lib/agent/` → `@artipod/core/agent` (loop, ozwell client, `local/` ONNX worker stack). Replace its `read_file`/`write_file`/`list_files` with bindings to `/tools` (schema collision #1 resolved here). Keep `bash` + truncation. Tests must satisfy Decision #8: scripted fakes only — audit done: `local/` suite touches only parsers/registry/cache math, worker loads transformers from CDN at runtime (types-only devDep), no live calls, no downloads, no default endpoint shipped. +- [x] Move `artipod-sync/lib/proc/` → `@artipod/core/proc`. +- [x] Add `pod.events` (core) + extract `/host` controllers (`TerminalSession`, `FileBuffer`, `TreeSource`) from the logic currently embedded in `Terminal.tsx` / `Editor.tsx` / `FileTree.tsx`; rewire those components as thin shells. Acceptance detail: tree auto-refreshes after every command, editor detects external changes to its open file, agent tool-call echo arrives via `agent:tool-call` (no `registerWriter`) — all three verified live (worklog). +- [x] artipod-sync consumes via workspace/path dep (`file:../artipod` until the npm publish); `lib/sandbox` etc. are re-export shims for one release, then delete (ask-first). `lib/server/`, routes, components stay in the app. +- [ ] ~~Wire `createSandbox` into the pod: `pod.createSandbox()`~~ — **deferred to Phase 3** (rule-6 deviation): it needs the realized fs, which only exists once manifests + realizers land; a Phase 2 version would hardcode exactly the bespoke init Phase 3 deletes. Tracked as part of the Phase 3 realizer work. +- [x] **Agent confinement stub (default-deny)**: tools/bash confined to the pod; `sudo` is recognized but returns EPERM with "approval flow lands in Phase 6.5" — pinned by tests (`sudo-command.test.ts`: bare/args/pipeline forms + `approval:request` emission; docs/security-model.md is normative). +- [x] `/console` module: `installConsole({ sandbox, events, hotkey })` — builtin zero-dep renderer, `` Ctrl+` ``/`Ctrl+~` hotkey, SSR-safe no-op (pinned by test), read-only mode for secondary tabs; consumes only `/host`-style contracts + `pod.events` (docs/console.md). `pod` param arrives with Phase 3's pod↔sandbox unification. +- [x] `ui/` proof: Storybook story where AIChat drives the sandbox via MCPToolCall rendering (the original Phase 6 acceptance) — mieweb/ui#404 (draft until the package publishes). **Done when:** -- [ ] All moved vitest suites green in this repo (sandbox, agent, proc — same counts as they had in artipod-sync) -- [ ] artipod-sync behavior unchanged: clone → pipeline commands → edit → commit → reload persists (Playwright e2e if present, else the manual script — record which in the worklog) -- [ ] Duplicated tool code deleted from artipod-sync; only re-export shims remain (their deletion is on the ask-first list) -- [ ] Event wiring proven in the app: tree auto-refreshes after every command, editor detects external changes to its open file, agent echo arrives via `agent:tool-call` -- [ ] `ui/` Storybook story runs (AIChat driving the sandbox, MCPToolCall rendering) +- [x] All moved vitest suites green in this repo (sandbox, agent, proc — same counts as they had in artipod-sync) — verified: the 6 moved test files (sandbox, storage, storage-command, zenfs-adapter, git-command, proc, agent, local) all pass; package total 17 files / **306 tests** (up from 197), artipod-sync keeps its 2 server files / 11 tests green +- [x] artipod-sync behavior unchanged: clone → pipeline commands → edit → commit → reload persists — no Playwright e2e exists, so recorded as a scripted browser session (worklog): shell pipeline ✓, git clone via /api/git ✓ (clone-into-cwd semantics unchanged), Monaco edit + save ✓, IndexedDB persistence across reload ✓, sudo → EPERM ✓ +- [x] Duplicated tool code deleted from artipod-sync; only re-export shims remain (their deletion is on the ask-first list) +- [x] Event wiring proven in the app: tree auto-refreshes after every command (zero Refresh clicks), editor detects external changes to its open file (clean-buffer reload verified), agent echo arrives via `agent:tool-call` (scripted fake-LLM run: ⚙ create_file + ⚙ bash echoed in xterm) +- [x] `ui/` Storybook story runs (AIChat driving the sandbox, MCPToolCall rendering) — verified live: pipeline command exit 0 with output block, sudo renders an error tool call; mieweb/ui#404 **Worklog:** -- _(empty)_ +- 2026-08-30 — move executed: lib/{sandbox,proc,agent} + git/git-auth copied verbatim (~2.9k lines, 6 test files / 90 tests), 72 imports got `.js` extensions, `../git` → `./git`. New subpath exports: `./sandbox` `./proc` `./agent` (+ `./agent/local/worker`, `./host`, `./console`). isomorphic-git + diff join the exact-pinned optional peers; @huggingface/transformers is a types-only devDep (worker loads it from CDN). +- 2026-08-30 — isomorphism fallout fixed while consuming: (1) node `path`/`crypto` were still imported by artimount/artipod — replaced with dependency-free posix helpers (`pathUtils.ts`) + WebCrypto ids; root-mount (`'/'`) traversal boundary had an `'//'` bug caught by the new agent binding tests. (2) dockerode's ssh2 native addon broke webpack builds — docker backend now lazy-imports with `webpackIgnore`, root entry re-exports docker **types only** (values live in `@artipod/core/docker`), plus a `browser` field stubbing the docker graph. (3) `initFileSystem` now returns the configured `zfs` — under `file:` installs the package resolves its own @zenfs/core copy, and artipod-sync's old `import('@zenfs/core')` bound a second, empty store (found live: `/repo` ENOENT). +- 2026-08-30 — agent rebinding (collision #1): `createSandboxTools` = core `bash` definition (16 KiB truncation preserved) + `createPodFileTools` over an app-declared mount table (default `'/'`), backed by `sandbox.zfs.promises` as PodFs — shell and tools share one store, pinned by a test. Old write_file/list_files shapes deleted; agent suite updated (8-tool surface). +- 2026-08-30 — app acceptance (horner/artipod-sync#2), scripted browser session on `npm run dev` + Playwright: tree auto-refresh ✓ (StrictMode gotcha: TreeSource's bus subscription must live in a React effect — effect-cleanup replay had stranded the memoized instance's constructor-time subscription), editor external-change reload ✓, agent echo via `agent:tool-call` ✓ using the new `/api/fake-llm` scripted endpoint (Decision #8), sudo EPERM ✓, IndexedDB persistence ✓, git clone through the proxy ✓. `registerWriter` deleted. +- 2026-08-30 — ui story (mieweb/ui#404, draft): `@artipod/core` installed via `github:` protocol (package gained a `prepare` build script; pnpm `onlyBuiltDependencies` allowlists it); story drives the real sandbox through the agent bash tool with MCPToolCall rendering. Gotcha for the record: vite/storybook caches optional-peer-dep shims — after adding isomorphic-git/diff, `node_modules/.cache/storybook` must be cleared. +- 2026-08-30 — package totals: 17 files / **306 tests** green (docker suites included), lint clean, Next production build of artipod-sync green. Shim deletion scheduled one release later (ask-first). ### Phase 3 — Pod manifest + realizers diff --git a/package-lock.json b/package-lock.json index 465e681..0c4fe45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,22 +12,30 @@ "dockerode": "^4.0.9" }, "devDependencies": { + "@huggingface/transformers": "^4.2.0", + "@types/diff": "^7.0.2", "@types/dockerode": "^3.3.47", "@types/node": "^20.10.6", "@typescript-eslint/eslint-plugin": "^6.17.0", "@typescript-eslint/parser": "^6.17.0", "@vitest/coverage-v8": "^3.2.4", "@zenfs/core": "2.4.4", + "@zenfs/dom": "1.2.5", + "diff": "^8.0.2", "eslint": "^8.56.0", + "isomorphic-git": "^1.25.0", + "just-bash": "3.2.0", "typescript": "^5.3.3", "vitest": "^3.2.4" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "@zenfs/core": "2.4.4", "@zenfs/dom": "1.2.5", + "diff": "^8.0.2", + "isomorphic-git": "^1.25.0", "just-bash": "3.2.0" }, "peerDependenciesMeta": { @@ -37,6 +45,12 @@ "@zenfs/dom": { "optional": true }, + "diff": { + "optional": true + }, + "isomorphic-git": { + "optional": true + }, "just-bash": { "optional": true } @@ -112,6 +126,28 @@ "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", "license": "Apache-2.0" }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -690,6 +726,37 @@ "node": ">=6" } }, + "node_modules/@huggingface/jinja": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", + "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/tokenizers": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz", + "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@huggingface/transformers": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz", + "integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.6", + "@huggingface/tokenizers": "^0.1.3", + "onnxruntime-node": "1.24.3", + "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", + "sharp": "^0.34.5" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -752,194 +819,767 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@js-sdsl/ordered-map": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", - "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", - "license": "MIT", + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], "funding": { - "type": "opencollective", - "url": "https://opencollective.com/js-sdsl" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jitl/quickjs-ffi-types": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", + "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jitl/quickjs-wasmfile-debug-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz", + "integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-debug-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz", + "integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz", + "integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz", + "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/@mongodb-js/zstd": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/zstd/-/zstd-7.0.0.tgz", + "integrity": "sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "node-addon-api": "^8.5.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": ">= 20.19.0" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" }, "engines": { "node": ">= 8" @@ -1394,6 +2034,31 @@ "win32" ] }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1412,6 +2077,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/diff": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", + "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/docker-modem": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", @@ -2058,6 +2730,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@zenfs/dom": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@zenfs/dom/-/dom-1.2.5.tgz", + "integrity": "sha512-DdQzmROGvgen3JmBVdovnaIyAexPPlCNJ5Tb0e48N8W9TQJA5IuVsBgohBKGwDiGEDPquEMxzoycFD4RC6kjzA==", + "dev": true, + "license": "LGPL-3.0-or-later", + "engines": { + "node": ">= 22" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/james-pre" + }, + "peerDependencies": { + "@zenfs/core": "^2.3.11", + "kerium": "^1.3.4", + "utilium": "^2.5.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2094,6 +2785,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2135,6 +2836,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2190,6 +2904,29 @@ "dev": true, "license": "MIT" }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2237,6 +2974,14 @@ "readable-stream": "^3.4.0" } }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -2303,6 +3048,56 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2363,6 +3158,13 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/clean-git-ref": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz", + "integrity": "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -2395,6 +3197,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2416,6 +3228,19 @@ "node": ">=10.0.0" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2448,6 +3273,22 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2458,6 +3299,17 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2465,6 +3317,76 @@ "dev": true, "license": "MIT" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff3": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz", + "integrity": "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==", + "dev": true, + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2524,6 +3446,21 @@ "node": ">=6.0.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2546,6 +3483,26 @@ "once": "^1.4.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2553,6 +3510,26 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -2829,6 +3806,17 @@ "node": ">=0.8.x" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2890,6 +3878,47 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", + "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -2913,6 +3942,25 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2958,6 +4006,13 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/flatted": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", @@ -2965,6 +4020,22 @@ "dev": true, "license": "ISC" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3023,6 +4094,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3032,6 +4113,53 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3091,6 +4219,24 @@ "node": "*" } }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -3107,6 +4253,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -3128,6 +4291,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -3135,6 +4311,13 @@ "dev": true, "license": "MIT" }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3145,6 +4328,61 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3227,6 +4465,29 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3279,6 +4540,42 @@ "node": ">=8" } }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3286,6 +4583,74 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic-git": { + "version": "1.41.9", + "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.41.9.tgz", + "integrity": "sha512-WOh4ujm5mznHphzQvwj9bVj+pQpV0oZ8H4lAnh4dSaie+lN/lJ2rb6rNOOcP+2m4z8IOQp186TkEULD28NMBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-lock": "^1.4.1", + "clean-git-ref": "^2.0.1", + "crc-32": "^1.2.0", + "diff3": "0.0.3", + "ignore": "^5.1.4", + "minimisted": "^2.0.0", + "pako": "^1.0.10", + "pify": "^4.0.1", + "readable-stream": "^4.0.0", + "sha.js": "^2.4.12", + "simple-get": "^4.0.1" + }, + "bin": { + "isogit": "cli.cjs" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/isomorphic-git/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/isomorphic-git/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -3341,39 +4706,121 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, + "node_modules/just-bash": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/just-bash/-/just-bash-3.2.0.tgz", + "integrity": "sha512-hRTLLWBXCKuosjaNFJR7uPYBza+T2vjG3NdPBz4wxlctlnxwrbLjtLQf6RtSKmy/jzNJ6/URCtvxrXjy6yFGeQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "diff": "^8.0.2", + "fast-xml-parser": "^5.7.3", + "file-type": "^21.2.0", + "ini": "^6.0.0", + "minimatch": "^10.1.1", + "modern-tar": "^0.7.3", + "papaparse": "^5.5.3", + "quickjs-emscripten": "^0.32.0", + "re2js": "^1.2.1", + "seek-bzip": "^2.0.0", + "smol-toml": "^1.6.0", + "sprintf-js": "^1.1.3", + "sql.js": "^1.13.0", + "turndown": "^7.2.2", + "undici": "^7.25.0", + "yaml": "^2.8.2" + }, + "bin": { + "just-bash": "dist/bin/just-bash.js", + "just-bash-shell": "dist/bin/shell/shell.js" + }, + "engines": { + "node": ">=20.18.1" + }, + "optionalDependencies": { + "@mongodb-js/zstd": "^7.0.0", + "node-liblzma": "^2.0.3" + } + }, + "node_modules/just-bash/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/just-bash/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "balanced-match": "^4.0.2" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": "20 || >=22" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "node_modules/just-bash/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "MIT" + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/kerium": { "version": "1.4.2", @@ -3533,6 +4980,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/memium": { "version": "0.3.11", "resolved": "https://registry.npmjs.org/memium/-/memium-0.3.11.tgz", @@ -3572,6 +5042,19 @@ "node": ">=8.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -3588,6 +5071,26 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimisted": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minimisted/-/minimisted-2.0.1.tgz", + "integrity": "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -3604,6 +5107,16 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/modern-tar": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.7.tgz", + "integrity": "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3636,6 +5149,14 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3643,6 +5164,77 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.96.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", + "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-liblzma": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-liblzma/-/node-liblzma-2.2.0.tgz", + "integrity": "sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g==", + "dev": true, + "hasInstallScript": true, + "license": "LGPL-3.0", + "optional": true, + "dependencies": { + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" + }, + "bin": { + "nxz": "lib/cli/nxz.js" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/oorabona" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3652,6 +5244,53 @@ "wrappy": "1" } }, + "node_modules/onnxruntime-common": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", + "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "dev": true, + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", + "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "global-agent": "^3.0.0", + "onnxruntime-common": "1.24.3" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.26.0-dev.20260416-b7804b056c", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz", + "integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.24.0-dev.20251116-b39e144322", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", + "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", + "dev": true, + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3709,6 +5348,20 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/papaparse": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.7.0.tgz", + "integrity": "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==", + "dev": true, + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3732,6 +5385,22 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -3823,6 +5492,33 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -3852,6 +5548,35 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3906,35 +5631,105 @@ "once": "^1.3.1" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quickjs-emscripten": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz", + "integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", + "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-release-sync": "0.32.0", + "quickjs-emscripten-core": "0.32.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quickjs-emscripten-core": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", + "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "dev": true, "license": "MIT", + "optional": true, "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "node_modules/re2js": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-1.3.3.tgz", + "integrity": "sha512-s/I5zEAo79SUK0Qw4dpZKpiMwbQ6Gz0KU2NRr7eaO4x/p2g7Vvmn3hdeXDg8VsaUjfj/ora+e9oi27LX/C9+mw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], "license": "MIT" }, "node_modules/readable-stream": { @@ -3998,6 +5793,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -4094,6 +5907,20 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/seek-bzip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-2.0.0.tgz", + "integrity": "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^6.0.0" + }, + "bin": { + "seek-bunzip": "bin/seek-bunzip", + "seek-table": "bin/seek-bzip-table" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -4107,6 +5934,126 @@ "node": ">=10" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT" + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "dev": true, + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4137,6 +6084,53 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -4147,6 +6141,19 @@ "node": ">=8" } }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4163,6 +6170,20 @@ "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", "license": "ISC" }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/sql.js": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz", + "integrity": "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==", + "dev": true, + "license": "MIT" + }, "node_modules/ssh2": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", @@ -4292,6 +6313,39 @@ "dev": true, "license": "MIT" }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4432,6 +6486,21 @@ "node": ">=14.0.0" } }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4445,6 +6514,25 @@ "node": ">=8.0" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -4458,6 +6546,42 @@ "typescript": ">=4.2.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/turndown": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", + "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + }, + "engines": { + "node": ">=18", + "npm": ">=9" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -4490,6 +6614,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4504,6 +6643,29 @@ "node": ">=14.17" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -4793,6 +6955,28 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4862,6 +7046,22 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -4871,6 +7071,22 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 972a546..ea985cf 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,30 @@ "types": "./dist/prompts/index.d.ts", "default": "./dist/prompts/index.js" }, + "./sandbox": { + "types": "./dist/sandbox/index.d.ts", + "default": "./dist/sandbox/index.js" + }, + "./proc": { + "types": "./dist/proc/index.d.ts", + "default": "./dist/proc/index.js" + }, + "./agent": { + "types": "./dist/agent/index.d.ts", + "default": "./dist/agent/index.js" + }, + "./agent/local/worker": { + "types": "./dist/agent/local/worker.d.ts", + "default": "./dist/agent/local/worker.js" + }, + "./host": { + "types": "./dist/host/index.d.ts", + "default": "./dist/host/index.js" + }, + "./console": { + "types": "./dist/console/index.d.ts", + "default": "./dist/console/index.js" + }, "./docker": { "types": "./dist/docker/index.d.ts", "default": "./dist/docker/index.js" @@ -27,6 +51,12 @@ "publishConfig": { "access": "public" }, + "browser": { + "./dist/docker/index.js": false, + "./dist/docker/containerUtils.js": false, + "./dist/docker/containerRuntime.js": false, + "dockerode": false + }, "files": [ "dist" ], @@ -47,7 +77,8 @@ "lint:fix": "eslint src --ext .ts --fix", "clean": "rm -rf dist", "prepublishOnly": "npm run clean && npm run build", - "example:basic": "npx tsx examples/basic/basic-pod-usage.ts" + "example:basic": "npx tsx examples/basic/basic-pod-usage.ts", + "prepare": "npm run build" }, "keywords": [ "ai", @@ -62,13 +93,19 @@ "author": "Medical Informatics Engineering, LLC", "license": "MIT", "devDependencies": { + "@huggingface/transformers": "^4.2.0", + "@types/diff": "^7.0.2", "@types/dockerode": "^3.3.47", "@types/node": "^20.10.6", "@typescript-eslint/eslint-plugin": "^6.17.0", "@typescript-eslint/parser": "^6.17.0", "@vitest/coverage-v8": "^3.2.4", "@zenfs/core": "2.4.4", + "@zenfs/dom": "1.2.5", + "diff": "^8.0.2", "eslint": "^8.56.0", + "isomorphic-git": "^1.25.0", + "just-bash": "3.2.0", "typescript": "^5.3.3", "vitest": "^3.2.4" }, @@ -78,6 +115,8 @@ "peerDependencies": { "@zenfs/core": "2.4.4", "@zenfs/dom": "1.2.5", + "diff": "^8.0.2", + "isomorphic-git": "^1.25.0", "just-bash": "3.2.0" }, "peerDependenciesMeta": { @@ -87,6 +126,12 @@ "@zenfs/dom": { "optional": true }, + "diff": { + "optional": true + }, + "isomorphic-git": { + "optional": true + }, "just-bash": { "optional": true } diff --git a/src/agent/agent.test.ts b/src/agent/agent.test.ts new file mode 100644 index 0000000..350839b --- /dev/null +++ b/src/agent/agent.test.ts @@ -0,0 +1,184 @@ +/** + * Agent loop tests with a scripted fake LLM (injected fetchFn) executing real + * bash tool calls against a sandbox, plus truncation behavior. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { configure, InMemory, fs as zfs, umount } from '@zenfs/core'; +import { createSandbox, type Sandbox } from '../sandbox/index.js'; +import { ToolCallingLoop } from './loop.js'; +import { OzwellClient } from './ozwell-client.js'; +import { createSandboxTools, toMcpToolDescriptors, truncateOutput, MAX_TOOL_OUTPUT_BYTES } from './tools.js'; +import type { ChatCompletionResponse, ChatMessage, ToolCall } from './types.js'; + +let sandbox: Sandbox; + +beforeEach(async () => { + try { + umount('/'); + } catch { + // first run + } + await configure({ mounts: { '/': InMemory } }); + await zfs.promises.mkdir('/repo'); + sandbox = createSandbox({ zfs }); +}); + +function scriptedClient(script: Array>): { client: OzwellClient; requests: unknown[] } { + const requests: unknown[] = []; + let call = 0; + const fetchFn: typeof fetch = async (_url, init) => { + requests.push(JSON.parse(String(init?.body))); + const message = script[Math.min(call++, script.length - 1)]; + const response: ChatCompletionResponse = { + id: `fake-${call}`, + object: 'chat.completion', + created: Date.now() / 1000, + model: 'fake', + choices: [ + { + index: 0, + message: { role: 'assistant', content: null, ...message } as ChatMessage, + finish_reason: message.tool_calls ? 'tool_calls' : 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }; + return new Response(JSON.stringify(response), { status: 200 }); + }; + const client = new OzwellClient({ baseUrl: 'http://fake', apiKey: 'k', fetchFn }); + return { client, requests }; +} + +const bashCall = (id: string, command: string): ToolCall => ({ + id, + type: 'function', + function: { name: 'bash', arguments: JSON.stringify({ command }) }, +}); + +describe('ToolCallingLoop with sandbox tools', () => { + it('executes tool calls and feeds results back until final response', async () => { + const { client, requests } = scriptedClient([ + { tool_calls: [bashCall('c1', 'echo hello > greet.txt'), bashCall('c2', 'cat greet.txt')] }, + { content: 'The file says hello.' }, + ]); + const loop = new ToolCallingLoop(client, createSandboxTools(sandbox)); + + const calls: string[] = []; + const result = await loop.run('write then read a file', { + onToolCall: (c) => calls.push(JSON.parse(c.function.arguments).command), + }); + + expect(result.content).toBe('The file says hello.'); + expect(result.iterations).toBe(2); + expect(calls).toEqual(['echo hello > greet.txt', 'cat greet.txt']); + expect(result.usage.total_tokens).toBe(30); + + // Tool results made it back into the conversation with matching ids. + const toolMsgs = result.messages.filter((m) => m.role === 'tool'); + expect(toolMsgs.map((m) => m.tool_call_id)).toEqual(['c1', 'c2']); + expect(JSON.parse(toolMsgs[1].content ?? '')).toMatchObject({ exitCode: 0 }); + expect(JSON.parse(toolMsgs[1].content ?? '').stdout).toBe('hello\n'); + + // The second LLM request contained the first tool result. + expect(JSON.stringify(requests[1])).toContain('"tool_call_id":"c1"'); + + // File really exists in the sandbox. + expect(await zfs.promises.readFile('/repo/greet.txt', 'utf8')).toBe('hello\n'); + }); + + it('reports unknown tools without crashing the loop', async () => { + const { client } = scriptedClient([ + { + tool_calls: [ + { id: 'x', type: 'function', function: { name: 'nope', arguments: '{}' } }, + ], + }, + { content: 'ok' }, + ]); + const loop = new ToolCallingLoop(client, createSandboxTools(sandbox)); + const result = await loop.run('use a bad tool'); + const toolMsg = result.messages.find((m) => m.role === 'tool'); + expect(toolMsg?.content).toMatch(/Unknown tool: nope/); + expect(result.content).toBe('ok'); + }); + + it('stops at maxIterations', async () => { + const { client } = scriptedClient([{ tool_calls: [bashCall('l', 'true')] }]); // never final + const loop = new ToolCallingLoop(client, createSandboxTools(sandbox)); + await expect(loop.run('loop forever', { maxIterations: 3 })).rejects.toThrow(/Maximum iterations \(3\)/); + }); + + it('aborts between steps via AbortSignal', async () => { + const controller = new AbortController(); + const { client } = scriptedClient([{ tool_calls: [bashCall('a', 'true')] }]); + const loop = new ToolCallingLoop(client, createSandboxTools(sandbox)); + controller.abort(); + await expect(loop.run('x', { signal: controller.signal })).rejects.toThrow(/abort/i); + }); + + it('truncates giant tool output to protect the context window', async () => { + const { client } = scriptedClient([ + { tool_calls: [bashCall('big', 'seq 1 20000')] }, + { content: 'done' }, + ]); + const loop = new ToolCallingLoop(client, createSandboxTools(sandbox)); + const result = await loop.run('flood'); + const toolMsg = result.messages.find((m) => m.role === 'tool'); + const parsed = JSON.parse(toolMsg?.content ?? '{}'); + expect(parsed.stdout.length).toBeLessThanOrEqual(MAX_TOOL_OUTPUT_BYTES + 100); + expect(parsed.stdout).toMatch(/characters truncated/); + // head and tail survive + expect(parsed.stdout.startsWith('1\n2\n')).toBe(true); + expect(parsed.stdout.trimEnd().endsWith('20000')).toBe(true); + }); +}); + +describe('tool surface serializers', () => { + it('exposes the same tools as OpenAI definitions and MCP descriptors', () => { + const tools = createSandboxTools(sandbox); + const mcp = toMcpToolDescriptors(tools); + // Collision #1 resolved: VS Code-schema file tools + bash — no more + // write_file/list_files shapes. + expect(mcp.map((t) => t.name).sort()).toEqual([ + 'apply_patch', + 'bash', + 'create_directory', + 'create_file', + 'list_dir', + 'multi_replace_string_in_file', + 'read_file', + 'replace_string_in_file', + ]); + const bash = mcp.find((t) => t.name === 'bash'); + expect(bash?.inputSchema.required).toEqual(['command']); + expect(bash?.inputSchema.type).toBe('object'); + }); + + it('file tools use VS Code schemas over the sandbox store (shared with bash)', async () => { + const tools = createSandboxTools(sandbox); + const created = await tools.get('create_file')!.execute({ filePath: '/repo/note.md', content: 'hi' }); + expect(created.success).toBe(true); + const read = await tools.get('read_file')!.execute({ filePath: '/repo/note.md' }); + expect(read.success).toBe(true); + expect(read.content).toContain('hi'); + const listing = await tools.get('list_dir')!.execute({ path: '/repo' }); + expect(listing.content).toMatch(/note\.md/); + // the shell sees the same store + const r = await sandbox.exec('cat /repo/note.md'); + expect(r.stdout).toBe('hi'); + // and shell writes are visible to the tools + await sandbox.exec('echo shell > /repo/from-shell.txt'); + const read2 = await tools.get('read_file')!.execute({ filePath: '/repo/from-shell.txt' }); + expect(read2.content).toContain('shell'); + }); +}); + +describe('truncateOutput', () => { + it('keeps short output untouched and marks long output', () => { + expect(truncateOutput('short')).toBe('short'); + const long = 'x'.repeat(MAX_TOOL_OUTPUT_BYTES + 1000); + const out = truncateOutput(long); + expect(out.length).toBeLessThan(long.length); + expect(out).toMatch(/\[1000 characters truncated\]/); + }); +}); diff --git a/src/agent/index.ts b/src/agent/index.ts new file mode 100644 index 0000000..2a7eb0e --- /dev/null +++ b/src/agent/index.ts @@ -0,0 +1,45 @@ +/** + * @artipod/core/agent — tool-calling loop, clients, and sandbox tool bindings. + * + * Framework-free (browser + Node). The local ONNX worker is exported as its + * own entry (`@artipod/core/agent/local/worker`) so bundlers can target it + * with `new Worker(new URL(...))`. + */ +export { ToolCallingLoop } from './loop.js'; +export { OzwellClient } from './ozwell-client.js'; +export type { OzwellClientConfig } from './ozwell-client.js'; +export { + createSandboxTools, + toOpenAiToolDefinitions, + toMcpToolDescriptors, + truncateOutput, + MAX_TOOL_OUTPUT_BYTES, +} from './tools.js'; +export type { + ChatCompletionClient, + ChatCompletionOptions, + ChatCompletionRequest, + ChatCompletionResponse, + ChatMessage, + McpToolDescriptor, + MessageRole, + TokenUsage, + ToolCall, + ToolCallingLoopOptions, + ToolCallingLoopResult, + ToolDefinition, + ToolHandler, + ToolResult, +} from './types.js'; +export { LocalModelClient, webGpuAvailable } from './local/client.js'; +export type { LocalModelClientOptions } from './local/client.js'; +export { + CURATED_MODELS, + DEFAULT_LOCAL_MODEL, + listLocalModels, + modelInfo, + type LocalModelInfo, + type OnnxDtype, +} from './local/model-registry.js'; +export { formatBytes, listCachedModels, modelIdFromCacheKey } from './local/model-cache.js'; +export type { CachedModel } from './local/model-cache.js'; diff --git a/src/agent/local/client.ts b/src/agent/local/client.ts new file mode 100644 index 0000000..9f789c9 --- /dev/null +++ b/src/agent/local/client.ts @@ -0,0 +1,117 @@ +/** + * LocalModelClient — ChatCompletionClient over the ONNX worker, so + * ToolCallingLoop works identically with in-browser models. WebGPU only. + */ +import type { + ChatCompletionClient, + ChatCompletionOptions, + ChatCompletionResponse, + ChatMessage, +} from '../types.js'; +import type { ResultEvent, WorkerRequest, WorkerResponse } from './protocol.js'; +import type { OnnxDtype } from './model-registry.js'; + +export interface LocalModelClientOptions { + modelId: string; + dtype?: OnnxDtype; + /** Download / load progress for the UI. */ + onProgress?: (status: string, file: string, progress?: number) => void; +} + +export function webGpuAvailable(): boolean { + return typeof navigator !== 'undefined' && 'gpu' in navigator; +} + +interface Pending { + resolve: (r: ResultEvent) => void; + reject: (e: Error) => void; +} + +export class LocalModelClient implements ChatCompletionClient { + private worker: Worker | null = null; + private nextId = 1; + private readonly pending = new Map(); + + constructor(private readonly opts: LocalModelClientOptions) {} + + private getWorker(): Worker { + if (!this.worker) { + this.worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); + this.worker.onmessage = (event: MessageEvent) => { + const msg = event.data; + if (msg.type === 'progress') { + this.opts.onProgress?.(msg.status, msg.file, msg.progress); + return; + } + const pending = msg.id !== undefined ? this.pending.get(msg.id) : undefined; + if (!pending) return; + this.pending.delete(msg.id as number); + if (msg.type === 'result') pending.resolve(msg); + else pending.reject(new Error(msg.message)); + }; + this.worker.onerror = (e) => { + this.pending.forEach((p) => p.reject(new Error(e.message || 'local model worker crashed'))); + this.pending.clear(); + }; + } + return this.worker; + } + + async createChatCompletion( + messages: ChatMessage[], + options: ChatCompletionOptions = {}, + ): Promise { + if (!webGpuAvailable()) { + throw new Error('WebGPU is not available in this browser — local ONNX models need it.'); + } + + const id = this.nextId++; + const request: WorkerRequest = { + type: 'generate', + id, + modelId: this.opts.modelId, + dtype: this.opts.dtype ?? 'q4', + messages, + tools: options.tools, + maxNewTokens: options.maxTokens ?? 1024, + }; + + const worker = this.getWorker(); + const onAbort = () => worker.postMessage({ type: 'abort', id } satisfies WorkerRequest); + options.signal?.addEventListener('abort', onAbort, { once: true }); + + try { + const result = await new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + worker.postMessage(request); + }); + + const message: ChatMessage = { + role: 'assistant', + content: result.toolCalls.length && !result.content ? null : result.content, + ...(result.toolCalls.length ? { tool_calls: result.toolCalls } : {}), + }; + return { + id: `local-${id}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: this.opts.modelId, + choices: [ + { + index: 0, + message, + finish_reason: result.toolCalls.length ? 'tool_calls' : 'stop', + }, + ], + }; + } finally { + options.signal?.removeEventListener('abort', onAbort); + } + } + + dispose(): void { + this.worker?.terminate(); + this.worker = null; + this.pending.clear(); + } +} diff --git a/src/agent/local/local.test.ts b/src/agent/local/local.test.ts new file mode 100644 index 0000000..362ca41 --- /dev/null +++ b/src/agent/local/local.test.ts @@ -0,0 +1,109 @@ +/** + * Local-model plumbing: tool-call parsing for Qwen/Llama output shapes and + * the registry's curated fallback behavior. + */ +import { describe, expect, it } from 'vitest'; +import { parseGeneration } from './parse-tool-calls.js'; +import { CURATED_MODELS, listLocalModels, modelInfo } from './model-registry.js'; +import { formatBytes, modelIdFromCacheKey } from './model-cache.js'; + +describe('model cache key mapping', () => { + it('maps HF resolve URLs back to repo ids', () => { + expect( + modelIdFromCacheKey('https://huggingface.co/onnx-community/Qwen2.5-0.5B-Instruct/resolve/main/onnx/model_q4.onnx'), + ).toBe('onnx-community/Qwen2.5-0.5B-Instruct'); + expect( + modelIdFromCacheKey('https://huggingface.co/onnx-community/Qwen2.5-0.5B-Instruct/resolve/main/tokenizer.json'), + ).toBe('onnx-community/Qwen2.5-0.5B-Instruct'); + }); + + it('ignores runtime assets and junk', () => { + expect( + modelIdFromCacheKey('https://cdn.jsdelivr.net/npm/onnxruntime-web@1.26.0/dist/ort-wasm.wasm'), + ).toBeNull(); + expect(modelIdFromCacheKey('not a url')).toBeNull(); + expect(modelIdFromCacheKey('https://huggingface.co/onnx-community')).toBeNull(); + }); + + it('formats sizes for the picker', () => { + expect(formatBytes(1_800_000_000)).toBe('1.8 GB'); + expect(formatBytes(786_000_000)).toBe('786 MB'); + expect(formatBytes(7_000)).toBe('7 kB'); + }); +}); + +describe('parseGeneration', () => { + it('extracts Qwen-style blocks', () => { + const out = parseGeneration( + 'Let me check.\n\n{"name": "bash", "arguments": {"command": "ls /repo"}}\n', + ); + expect(out.content).toBe('Let me check.'); + expect(out.toolCalls).toHaveLength(1); + expect(out.toolCalls[0].function.name).toBe('bash'); + expect(JSON.parse(out.toolCalls[0].function.arguments)).toEqual({ command: 'ls /repo' }); + }); + + it('extracts multiple blocks in order', () => { + const out = parseGeneration( + '{"name": "a", "arguments": {}}{"name": "b", "arguments": {"x": 1}}', + ); + expect(out.toolCalls.map((c) => c.function.name)).toEqual(['a', 'b']); + expect(out.content).toBe(''); + }); + + it('parses Llama-style bare JSON with parameters', () => { + const out = parseGeneration('{"name": "read_file", "parameters": {"path": "README.md"}}'); + expect(out.toolCalls).toHaveLength(1); + expect(out.toolCalls[0].function.name).toBe('read_file'); + expect(JSON.parse(out.toolCalls[0].function.arguments)).toEqual({ path: 'README.md' }); + expect(out.content).toBe(''); + }); + + it('leaves plain text and JSON-looking prose untouched', () => { + expect(parseGeneration('The answer is 42.')).toEqual({ content: 'The answer is 42.', toolCalls: [] }); + const prose = parseGeneration('{"not": "a tool call"}'); + expect(prose.toolCalls).toHaveLength(0); + expect(prose.content).toBe('{"not": "a tool call"}'); + }); + + it('keeps malformed tool_call blocks visible as content', () => { + const out = parseGeneration('{oops'); + expect(out.toolCalls).toHaveLength(0); + expect(out.content).toContain(''); + }); + + it('assigns unique ids', () => { + const out = parseGeneration( + '{"name": "a", "arguments": {}}{"name": "a", "arguments": {}}', + ); + expect(out.toolCalls[0].id).not.toBe(out.toolCalls[1].id); + }); +}); + +describe('model registry', () => { + it('falls back to the curated list when the Hub is unreachable', async () => { + const failingFetch = (() => Promise.reject(new Error('offline'))) as unknown as typeof fetch; + expect(await listLocalModels(failingFetch)).toEqual(CURATED_MODELS); + }); + + it('merges instruct-style Hub models after the curated ones', async () => { + const hubFetch = (() => + Promise.resolve( + new Response( + JSON.stringify([ + { id: 'onnx-community/Qwen2.5-1.5B-Instruct' }, // duplicate — skipped + { id: 'onnx-community/SomeChat-2B-Instruct' }, + { id: 'onnx-community/turn-detector-GQA-ONNX' }, // not instruct — skipped + ]), + ), + )) as unknown as typeof fetch; + const models = await listLocalModels(hubFetch); + expect(models.slice(0, CURATED_MODELS.length)).toEqual(CURATED_MODELS); + expect(models.map((m) => m.id)).toContain('onnx-community/SomeChat-2B-Instruct'); + expect(models.map((m) => m.id)).not.toContain('onnx-community/turn-detector-GQA-ONNX'); + }); + + it('modelInfo defaults unknown ids to q4', () => { + expect(modelInfo('onnx-community/Custom', CURATED_MODELS)).toMatchObject({ dtype: 'q4', curated: false }); + }); +}); diff --git a/src/agent/local/model-cache.ts b/src/agent/local/model-cache.ts new file mode 100644 index 0000000..aed1368 --- /dev/null +++ b/src/agent/local/model-cache.ts @@ -0,0 +1,61 @@ +/** + * Which models are already in the OPFS weight cache (artipod-models/). + * Cache files are named encodeURIComponent(), so decoding the + * names and grouping by HF repo id tells us what's downloaded. + */ + +const MODELS_DIR = 'artipod-models'; + +/** huggingface.co///resolve/... → "/"; null for runtime assets. */ +export function modelIdFromCacheKey(decodedUrl: string): string | null { + try { + const url = new URL(decodedUrl); + if (url.hostname !== 'huggingface.co') return null; + const segments = url.pathname.split('/').filter(Boolean); + if (segments.length < 4 || segments[2] !== 'resolve') return null; + return `${segments[0]}/${segments[1]}`; + } catch { + return null; + } +} + +const isWeightsFile = (decodedUrl: string) => /\.onnx(_data.*)?$/.test(decodedUrl); + +export interface CachedModel { + bytes: number; + /** True once an .onnx/.onnx_data file is present (config-only ≠ downloaded). */ + hasWeights: boolean; +} + +export async function listCachedModels(): Promise> { + const cached = new Map(); + if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return cached; + try { + const root = await navigator.storage.getDirectory(); + const dir = await root.getDirectoryHandle(MODELS_DIR); + const entries = (dir as unknown as { entries(): AsyncIterableIterator<[string, FileSystemHandle]> }).entries(); + for (;;) { + const { done, value } = await entries.next(); + if (done) break; + const [name, handle] = value; + if (handle.kind !== 'file') continue; + const decoded = decodeURIComponent(name); + const modelId = modelIdFromCacheKey(decoded); + if (!modelId) continue; + const size = (await (handle as FileSystemFileHandle).getFile()).size; + const entry = cached.get(modelId) ?? { bytes: 0, hasWeights: false }; + entry.bytes += size; + entry.hasWeights = entry.hasWeights || isWeightsFile(decoded); + cached.set(modelId, entry); + } + } catch { + // no cache dir yet + } + return cached; +} + +export function formatBytes(bytes: number): string { + if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`; + if (bytes >= 1e6) return `${Math.round(bytes / 1e6)} MB`; + return `${Math.round(bytes / 1e3)} kB`; +} diff --git a/src/agent/local/model-registry.ts b/src/agent/local/model-registry.ts new file mode 100644 index 0000000..6317838 --- /dev/null +++ b/src/agent/local/model-registry.ts @@ -0,0 +1,77 @@ +/** + * In-browser ONNX model registry: curated tool-capable defaults merged with + * a live query of the Hugging Face Hub (the de-facto ONNX model registry — + * CORS-enabled REST API), so the dropdown stays current without shipping a + * hardcoded list. + */ + +/** Quantizations Transformers.js accepts for ONNX models. */ +export type OnnxDtype = 'auto' | 'q4' | 'q4f16' | 'q8' | 'int8' | 'uint8' | 'fp16' | 'fp32'; + +export interface LocalModelInfo { + /** HF repo id, e.g. onnx-community/Qwen2.5-1.5B-Instruct */ + id: string; + label: string; + /** Approximate q4 download size, shown in the picker. */ + approxSize?: string; + /** ONNX quantization to request. */ + dtype: OnnxDtype; + curated: boolean; +} + +export const CURATED_MODELS: LocalModelInfo[] = [ + { + id: 'onnx-community/Qwen2.5-1.5B-Instruct', + label: 'Qwen2.5 1.5B Instruct (best small tool-caller)', + approxSize: '~1.8 GB', + dtype: 'q4', + curated: true, + }, + { + id: 'onnx-community/Qwen2.5-0.5B-Instruct', + label: 'Qwen2.5 0.5B Instruct (fastest, weakest)', + approxSize: '~0.8 GB', + dtype: 'q4', + curated: true, + }, + { + id: 'onnx-community/Llama-3.2-1B-Instruct-ONNX', + label: 'Llama 3.2 1B Instruct', + approxSize: '~1.7 GB', + dtype: 'q4', + curated: true, + }, +]; + +export const DEFAULT_LOCAL_MODEL = CURATED_MODELS[0].id; + +const HUB_QUERY = + 'https://huggingface.co/api/models?author=onnx-community&pipeline_tag=text-generation&sort=downloads&direction=-1&limit=30'; + +interface HubModel { + id: string; +} + +/** Curated list first, then popular Hub extras; Hub failure degrades to curated. */ +export async function listLocalModels(fetchFn: typeof fetch = fetch): Promise { + const models = [...CURATED_MODELS]; + try { + const res = await fetchFn(HUB_QUERY); + if (res.ok) { + const hub = (await res.json()) as HubModel[]; + const known = new Set(models.map((m) => m.id)); + for (const { id } of hub) { + // only instruct/chat-style repos are useful for tool calling + if (known.has(id) || !/instruct|chat|it-/i.test(id)) continue; + models.push({ id, label: id.replace('onnx-community/', ''), dtype: 'q4', curated: false }); + } + } + } catch { + // offline / rate-limited: curated list is the fallback + } + return models; +} + +export function modelInfo(id: string, models: LocalModelInfo[]): LocalModelInfo { + return models.find((m) => m.id === id) ?? { id, label: id, dtype: 'q4', curated: false }; +} diff --git a/src/agent/local/parse-tool-calls.ts b/src/agent/local/parse-tool-calls.ts new file mode 100644 index 0000000..97f7db9 --- /dev/null +++ b/src/agent/local/parse-tool-calls.ts @@ -0,0 +1,65 @@ +/** + * Parse tool calls out of small-model chat output into OpenAI shape. + * Tolerates the two formats our supported templates emit: + * - Qwen: {"name": "...", "arguments": {...}} + * - Llama: a bare JSON object {"name": "...", "parameters": {...}} + */ +import type { ToolCall } from '../types.js'; + +export interface ParsedGeneration { + content: string; + toolCalls: ToolCall[]; +} + +let counter = 0; +const nextId = () => `local_call_${++counter}_${Date.now().toString(36)}`; + +interface RawCall { + name?: unknown; + arguments?: unknown; + parameters?: unknown; +} + +function toToolCall(raw: RawCall): ToolCall | null { + if (typeof raw?.name !== 'string' || !raw.name) return null; + const args = raw.arguments ?? raw.parameters ?? {}; + return { + id: nextId(), + type: 'function', + function: { + name: raw.name, + arguments: typeof args === 'string' ? args : JSON.stringify(args), + }, + }; +} + +const TOOL_CALL_BLOCK = /\s*([\s\S]*?)\s*<\/tool_call>/g; + +export function parseGeneration(output: string): ParsedGeneration { + const toolCalls: ToolCall[] = []; + + const content = output + .replace(TOOL_CALL_BLOCK, (_match, body: string) => { + try { + const call = toToolCall(JSON.parse(body)); + if (call) toolCalls.push(call); + } catch { + // malformed block: keep it visible as content instead of dropping it + return _match; + } + return ''; + }) + .trim(); + + if (toolCalls.length === 0 && content.startsWith('{') && content.endsWith('}')) { + // Llama-style bare JSON tool call + try { + const call = toToolCall(JSON.parse(content)); + if (call) return { content: '', toolCalls: [call] }; + } catch { + // plain JSON-looking prose: fall through + } + } + + return { content, toolCalls }; +} diff --git a/src/agent/local/protocol.ts b/src/agent/local/protocol.ts new file mode 100644 index 0000000..956a671 --- /dev/null +++ b/src/agent/local/protocol.ts @@ -0,0 +1,45 @@ +/** + * Message protocol between LocalModelClient and the model worker. + */ +import type { ChatMessage, ToolCall, ToolDefinition } from '../types.js'; +import type { OnnxDtype } from './model-registry.js'; + +export interface GenerateRequest { + type: 'generate'; + id: number; + modelId: string; + dtype: OnnxDtype; + messages: ChatMessage[]; + tools?: ToolDefinition[]; + maxNewTokens: number; +} + +export interface AbortRequest { + type: 'abort'; + id: number; +} + +export type WorkerRequest = GenerateRequest | AbortRequest; + +export interface ProgressEvent { + type: 'progress'; + file: string; + /** 0..100 for determinate files. */ + progress?: number; + status: string; +} + +export interface ResultEvent { + type: 'result'; + id: number; + content: string; + toolCalls: ToolCall[]; +} + +export interface ErrorEvent { + type: 'error'; + id?: number; + message: string; +} + +export type WorkerResponse = ProgressEvent | ResultEvent | ErrorEvent; diff --git a/src/agent/local/worker.ts b/src/agent/local/worker.ts new file mode 100644 index 0000000..a3ead78 --- /dev/null +++ b/src/agent/local/worker.ts @@ -0,0 +1,226 @@ +/** + * Web Worker running in-browser ONNX inference (Transformers.js / ONNX + * Runtime Web, WebGPU only — WASM is unusably slow at 1B+ params). + * + * Transformers.js is dynamic-imported from the jsDelivr CDN at runtime: its + * bundled onnxruntime-web .mjs breaks Next 14's SWC when webpack tries to + * bundle it into the worker chunk, and the library fetches its wasm/model + * assets from CDNs anyway. The npm package stays as a devtime type source. + * + * Model weights are cached in OPFS under artipod-models/ via a custom + * Transformers.js cache. That directory is a SIBLING of the sandbox mount + * (artipod-fs/), so cached weights are invisible to agent bash/read_file + * calls and are never swept up by storage migration. + */ +import { parseGeneration } from './parse-tool-calls.js'; +import type { WorkerRequest, WorkerResponse, GenerateRequest } from './protocol.js'; +import type { OnnxDtype } from './model-registry.js'; +import type { ChatMessage } from '../types.js'; + +type Transformers = typeof import('@huggingface/transformers'); +type PreTrainedTokenizer = import('@huggingface/transformers').PreTrainedTokenizer; +type PreTrainedModel = import('@huggingface/transformers').PreTrainedModel; + +const TRANSFORMERS_CDN = + 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0/dist/transformers.min.js'; + +let transformersPromise: Promise | null = null; + +function loadTransformers(): Promise { + if (!transformersPromise) { + transformersPromise = (async () => { + // webpackIgnore: resolved by the browser at runtime, not bundled + const mod = (await import(/* webpackIgnore: true */ TRANSFORMERS_CDN)) as Transformers; + mod.env.useBrowserCache = false; + mod.env.useCustomCache = true; + mod.env.customCache = new OpfsModelCache(); + return mod; + })(); + } + return transformersPromise; +} + +const post = (msg: WorkerResponse) => (self as unknown as { postMessage(m: unknown): void }).postMessage(msg); + +// --------------------------------------------------------------------------- +// OPFS-backed model cache (Transformers.js custom cache interface) +// --------------------------------------------------------------------------- + +const MODELS_DIR = 'artipod-models'; + +class OpfsModelCache { + private dir: Promise | null = null; + + private getDir(): Promise { + if (!this.dir) { + this.dir = navigator.storage + .getDirectory() + .then((root) => root.getDirectoryHandle(MODELS_DIR, { create: true })); + } + return this.dir; + } + + // one flat file per URL; encodeURIComponent keeps it traversal-safe + private fileName(key: string): string { + return encodeURIComponent(key); + } + + async match(key: string): Promise { + try { + const dir = await this.getDir(); + const handle = await dir.getFileHandle(this.fileName(key)); + const file = await handle.getFile(); + return new Response(file); + } catch { + return undefined; + } + } + + async put(key: string, response: Response): Promise { + try { + const dir = await this.getDir(); + const handle = await dir.getFileHandle(this.fileName(key), { create: true }); + const writable = await handle.createWritable(); + await response.body?.pipeTo(writable); + } catch { + // cache write failure is non-fatal; the download still succeeded + } + } +} + +// --------------------------------------------------------------------------- +// Model lifecycle — one loaded model at a time +// --------------------------------------------------------------------------- + +interface Loaded { + modelId: string; + tokenizer: PreTrainedTokenizer; + model: PreTrainedModel; +} + +let loaded: Loaded | null = null; +let loading: Promise | null = null; + +async function ensureModel(modelId: string, dtype: OnnxDtype): Promise { + if (loaded?.modelId === modelId) return loaded; + if (loading) await loading.catch(() => undefined); + if (loaded?.modelId === modelId) return loaded; + + if (!('gpu' in navigator)) { + throw new Error('WebGPU is not available in this browser — local models need it.'); + } + + if (loaded) { + await loaded.model.dispose(); + loaded = null; + } + + const progress_callback = (info: { status: string; file?: string; progress?: number }) => { + post({ + type: 'progress', + status: info.status, + file: info.file ?? modelId, + progress: info.progress, + }); + }; + + loading = (async () => { + const { AutoTokenizer, AutoModelForCausalLM } = await loadTransformers(); + const tokenizer = await AutoTokenizer.from_pretrained(modelId, { progress_callback }); + const model = await AutoModelForCausalLM.from_pretrained(modelId, { + device: 'webgpu', + dtype, + progress_callback, + }); + loaded = { modelId, tokenizer, model }; + return loaded; + })(); + + try { + return await loading; + } finally { + loading = null; + } +} + +// --------------------------------------------------------------------------- +// Generation +// --------------------------------------------------------------------------- + +/** Chat templates want tool-call arguments as objects, OpenAI carries strings. */ +function templateMessages(messages: ChatMessage[]): Record[] { + return messages.map((m) => { + if (m.role === 'assistant' && m.tool_calls?.length) { + return { + role: m.role, + content: m.content ?? '', + tool_calls: m.tool_calls.map((tc) => ({ + type: 'function', + function: { + name: tc.function.name, + arguments: safeParse(tc.function.arguments), + }, + })), + }; + } + return { role: m.role, content: m.content ?? '', ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}) }; + }); +} + +function safeParse(s: string): unknown { + try { + return JSON.parse(s); + } catch { + return s; + } +} + +const stoppers = new Map(); + +async function generate(req: GenerateRequest): Promise { + const { TextStreamer, InterruptableStoppingCriteria } = await loadTransformers(); + const { tokenizer, model } = await ensureModel(req.modelId, req.dtype); + + const prompt = tokenizer.apply_chat_template(templateMessages(req.messages) as never, { + tools: (req.tools ?? null) as never, + add_generation_prompt: true, + tokenize: false, + }) as string; + + const inputs = tokenizer(prompt, { return_tensors: 'pt' } as never); + const stopper = new InterruptableStoppingCriteria(); + stoppers.set(req.id, stopper); + + try { + const output = (await model.generate({ + ...inputs, + max_new_tokens: req.maxNewTokens, + do_sample: false, // greedy: small models emit better-formed tool JSON + stopping_criteria: stopper, + streamer: new TextStreamer(tokenizer, { skip_prompt: true, skip_special_tokens: true }), + } as never)) as { slice(batch: null, seq: [number, null]): unknown }; + + const inputLength = (inputs as { input_ids: { dims: number[] } }).input_ids.dims[1]; + // Tensor.slice(batchDim, seqDim): keep all batches, drop the prompt tokens + const newTokens = output.slice(null, [inputLength, null]); + const text = (tokenizer.batch_decode(newTokens as never, { skip_special_tokens: true }) as string[])[0] ?? ''; + + const { content, toolCalls } = parseGeneration(text); + post({ type: 'result', id: req.id, content, toolCalls }); + } finally { + stoppers.delete(req.id); + } +} + +self.onmessage = async (event: MessageEvent) => { + const msg = event.data; + if (msg.type === 'abort') { + stoppers.get(msg.id)?.interrupt(); + return; + } + try { + await generate(msg); + } catch (e) { + post({ type: 'error', id: msg.id, message: e instanceof Error ? e.message : String(e) }); + } +}; diff --git a/src/agent/loop.ts b/src/agent/loop.ts new file mode 100644 index 0000000..59917ec --- /dev/null +++ b/src/agent/loop.ts @@ -0,0 +1,132 @@ +/** + * ToolCallingLoop — ported from ozwell-artipod, browser-safe, with abort. + * + * 1. Send messages to the LLM + * 2. If it returns tool calls, execute them (against the sandbox) + * 3. Feed tool results back + * 4. Repeat until a final text response (or maxIterations / abort) + */ +import type { + ChatCompletionClient, + ChatMessage, + ToolCall, + ToolCallingLoopOptions, + ToolCallingLoopResult, + ToolDefinition, + ToolHandler, + ToolResult, +} from './types.js'; + +const DEFAULT_MAX_ITERATIONS = 10; + +export class ToolCallingLoop { + private readonly toolDefinitions: ToolDefinition[]; + + constructor( + private readonly client: ChatCompletionClient, + private readonly tools: Map, + ) { + this.toolDefinitions = Array.from(tools.values()).map((t) => t.definition); + } + + async run(userMessage: string, options: ToolCallingLoopOptions = {}): Promise { + const messages: ChatMessage[] = []; + if (options.systemPrompt) { + messages.push({ role: 'system', content: options.systemPrompt }); + } + messages.push({ role: 'user', content: userMessage }); + return this.runWithHistory(messages, options); + } + + /** Run the loop over an existing conversation (multi-turn). Mutates nothing; returns the new message list. */ + async runWithHistory( + history: ChatMessage[], + options: ToolCallingLoopOptions = {}, + ): Promise { + const { maxIterations = DEFAULT_MAX_ITERATIONS, signal, onAssistantMessage, onToolCall, onToolResult } = options; + + const messages = [...history]; + let iterations = 0; + const usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }; + + while (iterations < maxIterations) { + if (signal?.aborted) throw new Error('Agent run aborted'); + iterations++; + + const response = await this.client.createChatCompletion(messages, { + tools: this.toolDefinitions, + toolChoice: 'auto', + signal, + }); + + if (response.usage) { + usage.prompt_tokens += response.usage.prompt_tokens; + usage.completion_tokens += response.usage.completion_tokens; + usage.total_tokens += response.usage.total_tokens; + } + + const choice = response.choices[0]; + if (!choice) throw new Error('No response choice returned from LLM'); + + const assistantMessage = choice.message; + messages.push(assistantMessage); + + const toolCalls = assistantMessage.tool_calls; + if (!toolCalls || toolCalls.length === 0) { + const content = assistantMessage.content ?? ''; + onAssistantMessage?.(content); + return { content, messages, iterations, usage }; + } + + // Content alongside tool calls is still worth surfacing. + if (assistantMessage.content) onAssistantMessage?.(assistantMessage.content); + + for (const toolCall of toolCalls) { + if (signal?.aborted) throw new Error('Agent run aborted'); + onToolCall?.(toolCall); + const result = await this.executeToolCall(toolCall, signal); + onToolResult?.(toolCall, result); + messages.push({ + role: 'tool', + content: result.success ? result.content : `Error: ${result.error}`, + tool_call_id: toolCall.id, + }); + } + } + + throw new Error(`Maximum iterations (${maxIterations}) reached without a final response`); + } + + private async executeToolCall(toolCall: ToolCall, signal?: AbortSignal): Promise { + const toolName = toolCall.function.name; + const handler = this.tools.get(toolName); + if (!handler) { + return { + success: false, + content: '', + error: `Unknown tool: ${toolName}. Available tools: ${Array.from(this.tools.keys()).join(', ')}`, + }; + } + try { + const args = JSON.parse(toolCall.function.arguments || '{}'); + return await handler.execute(args, signal); + } catch (error) { + if (error instanceof SyntaxError) { + return { + success: false, + content: '', + error: `Invalid JSON arguments for tool ${toolName}: ${toolCall.function.arguments}`, + }; + } + return { + success: false, + content: '', + error: error instanceof Error ? error.message : String(error), + }; + } + } + + getToolDefinitions(): ToolDefinition[] { + return this.toolDefinitions; + } +} diff --git a/src/agent/ozwell-client.ts b/src/agent/ozwell-client.ts new file mode 100644 index 0000000..783e67e --- /dev/null +++ b/src/agent/ozwell-client.ts @@ -0,0 +1,91 @@ +/** + * Trimmed OpenAI-compatible client (Ozwell, ozwellai-api reference server, + * OpenAI, …). Plain fetch — works in browser and Node. Streaming can come + * later; the loop only needs non-streaming completions. + */ +import type { + ChatCompletionClient, + ChatCompletionOptions, + ChatCompletionRequest, + ChatCompletionResponse, + ChatMessage, +} from './types.js'; + +export type { ChatCompletionOptions } from './types.js'; + +export interface OzwellClientConfig { + baseUrl: string; + apiKey: string; + defaultModel?: string; + /** Request timeout in ms (default 120000). */ + timeout?: number; + /** Injectable for tests; defaults to global fetch. */ + fetchFn?: typeof fetch; +} + +export class OzwellClient implements ChatCompletionClient { + private readonly config: Required> & { + fetchFn: typeof fetch; + }; + + constructor(config: OzwellClientConfig) { + this.config = { + baseUrl: config.baseUrl.replace(/\/$/, ''), + apiKey: config.apiKey, + defaultModel: config.defaultModel ?? 'gpt-4o-mini', + timeout: config.timeout ?? 120_000, + // Wrap: an unbound window.fetch throws "Illegal invocation" in browsers. + fetchFn: config.fetchFn ?? ((...args: Parameters) => fetch(...args)), + }; + } + + async createChatCompletion( + messages: ChatMessage[], + options: ChatCompletionOptions = {}, + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + const onOuterAbort = () => controller.abort(); + options.signal?.addEventListener('abort', onOuterAbort, { once: true }); + + const request: ChatCompletionRequest = { + model: options.model ?? this.config.defaultModel, + messages, + temperature: options.temperature ?? 0.7, + max_tokens: options.maxTokens ?? 4096, + stream: false, + }; + if (options.tools && options.tools.length > 0) { + request.tools = options.tools; + request.tool_choice = options.toolChoice ?? 'auto'; + } + + try { + const response = await this.config.fetchFn(`${this.config.baseUrl}/v1/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify(request), + signal: controller.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`LLM API error (${response.status}): ${errorText.slice(0, 500)}`); + } + return (await response.json()) as ChatCompletionResponse; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error( + options.signal?.aborted ? 'Request aborted' : `Request timed out after ${this.config.timeout}ms`, + ); + } + throw error; + } finally { + clearTimeout(timeoutId); + options.signal?.removeEventListener('abort', onOuterAbort); + } + } +} diff --git a/src/agent/tools.ts b/src/agent/tools.ts new file mode 100644 index 0000000..aab4b70 --- /dev/null +++ b/src/agent/tools.ts @@ -0,0 +1,102 @@ +/** + * Agent tool bindings — resolves plan §2 collision #1: the agent's file tools + * ARE the package's VS Code-schema tools (read_file, create_file, list_dir, + * create_directory, replace_string_in_file, multi_replace_string_in_file, + * apply_patch) resolved over a mount table. artipod-sync's minimal + * read_file/write_file/list_files shapes are gone. + * + * `bash` keeps artipod-sync's semantics: JSON body with per-stream 16 KiB + * head+tail truncation, executed in the persistent sandbox session. + */ +import { ArtiMount } from '../artimount.js'; +import type { PodFs } from '../podfs.js'; +import type { Sandbox } from '../sandbox/types.js'; +import { bashDefinition } from '../tools/bashTool.js'; +import { PodPathResolver, createPodFileTools, type MountTableEntry } from '../tools/podFileTools.js'; +import { toOpenAiTool } from '../tools/serializers.js'; +import { truncateOutput } from '../tools/truncation.js'; +import type { ToolHandler as CoreToolHandler, ToolResult as CoreToolResult } from '../tools/types.js'; +import type { McpToolDescriptor, ToolDefinition, ToolHandler, ToolResult } from './types.js'; + +export { MAX_TOOL_OUTPUT_BYTES, truncateOutput } from '../tools/truncation.js'; + +const okResult = (content: string): ToolResult => ({ success: true, content }); +const errResult = (error: string): ToolResult => ({ success: false, content: '', error }); + +/** An app-declared mount the file tools resolve against (plan Decision #3). */ +export interface SandboxMountDeclaration { + name: string; + path: string; + readonly?: boolean; +} + +export interface CreateSandboxToolsOptions { + /** Mount table for the file tools. Default: one writable mount at '/'. */ + mounts?: SandboxMountDeclaration[]; +} + +/** Adapt a core (structured-result) handler to the agent's OpenAI wire shape. */ +function adaptCoreHandler(handler: CoreToolHandler): ToolHandler { + return { + definition: toOpenAiTool(handler.definition) as ToolDefinition, + execute: async (args) => { + const result = (await handler.execute(args as never)) as CoreToolResult; + if (!result.success) return errResult(result.error ?? `${handler.name} failed`); + if (typeof result.content === 'string') return okResult(truncateOutput(result.content)); + const { success: _success, ...rest } = result; + return okResult(truncateOutput(JSON.stringify(rest))); + }, + }; +} + +export function createSandboxTools( + sandbox: Sandbox, + options: CreateSandboxToolsOptions = {}, +): Map { + const tools = new Map(); + + tools.set('bash', { + definition: toOpenAiTool(bashDefinition) as ToolDefinition, + execute: async (args, signal) => { + const command = String(args.command ?? ''); + if (!command) return errResult('missing "command"'); + const r = await sandbox.exec(command, { signal }); + return okResult( + JSON.stringify({ + stdout: truncateOutput(r.stdout), + stderr: truncateOutput(r.stderr), + exitCode: r.exitCode, + }), + ); + }, + }); + + // VS Code-schema file tools over the sandbox's own store. ZenFS's + // `fs.promises` is PodFs-shaped, so the mounts share the shell's view. + const podFs = sandbox.zfs.promises as unknown as PodFs; + const declarations = options.mounts ?? [{ name: 'root', path: '/' }]; + const entries: MountTableEntry[] = declarations.map((m) => ({ + path: m.path, + mount: new ArtiMount(m.name, m.path, m.readonly ?? false, podFs), + })); + const resolver = new PodPathResolver(entries); + for (const handler of createPodFileTools(resolver)) { + tools.set(handler.name, adaptCoreHandler(handler)); + } + + return tools; +} + +/** OpenAI function-call JSON shape (what the loop / ozwellChat speak). */ +export function toOpenAiToolDefinitions(tools: Map): ToolDefinition[] { + return Array.from(tools.values()).map((t) => t.definition); +} + +/** MCP-style descriptors (what MCPToolCall-style components render). */ +export function toMcpToolDescriptors(tools: Map): McpToolDescriptor[] { + return Array.from(tools.values()).map(({ definition: { function: fn } }) => ({ + name: fn.name, + description: fn.description, + inputSchema: fn.parameters, + })); +} diff --git a/src/agent/types.ts b/src/agent/types.ts new file mode 100644 index 0000000..8a5f462 --- /dev/null +++ b/src/agent/types.ts @@ -0,0 +1,134 @@ +/** + * OpenAI-compatible tool-calling types, ported from ozwell-artipod + * (transport-clean; browser + server). Framework-free like lib/sandbox/. + */ + +export type MessageRole = 'system' | 'user' | 'assistant' | 'tool'; + +export interface ChatMessage { + role: MessageRole; + content: string | null; + name?: string; + tool_calls?: ToolCall[]; + tool_call_id?: string; +} + +export interface ToolCall { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +} + +export interface ToolParameterProperty { + type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + description: string; + enum?: string[]; + items?: ToolParameterProperty; +} + +/** OpenAI function-call tool definition. */ +export interface ToolDefinition { + type: 'function'; + function: { + name: string; + description: string; + parameters: { + type: 'object'; + properties: Record; + required?: string[]; + }; + }; +} + +/** MCP-style tool descriptor (what MCPToolCall-style UIs render). */ +export interface McpToolDescriptor { + name: string; + description: string; + inputSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; +} + +export interface ToolResult { + success: boolean; + content: string; + error?: string; +} + +export interface ToolHandler { + definition: ToolDefinition; + execute: (args: Record, signal?: AbortSignal) => Promise; +} + +export interface ChatCompletionRequest { + model: string; + messages: ChatMessage[]; + tools?: ToolDefinition[]; + tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } }; + temperature?: number; + max_tokens?: number; + stream?: boolean; +} + +export interface ChatCompletionChoice { + index: number; + message: ChatMessage; + finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' | null; +} + +export interface ChatCompletionResponse { + id: string; + object: 'chat.completion'; + created: number; + model: string; + choices: ChatCompletionChoice[]; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +export interface TokenUsage { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +} + +export interface ChatCompletionOptions { + model?: string; + tools?: ToolDefinition[]; + toolChoice?: 'auto' | 'none'; + temperature?: number; + maxTokens?: number; + signal?: AbortSignal; +} + +/** Anything the loop can talk to: the remote OzwellClient or an in-browser model. */ +export interface ChatCompletionClient { + createChatCompletion( + messages: ChatMessage[], + options?: ChatCompletionOptions, + ): Promise; +} + +export interface ToolCallingLoopOptions { + maxIterations?: number; + systemPrompt?: string; + signal?: AbortSignal; + onAssistantMessage?: (content: string) => void; + onToolCall?: (toolCall: ToolCall) => void; + onToolResult?: (toolCall: ToolCall, result: ToolResult) => void; +} + +export interface ToolCallingLoopResult { + content: string; + messages: ChatMessage[]; + iterations: number; + usage: TokenUsage; +} diff --git a/src/artimount.ts b/src/artimount.ts index d2bfd95..35bb73e 100644 --- a/src/artimount.ts +++ b/src/artimount.ts @@ -1,4 +1,4 @@ -import * as path from 'path'; +import { resolvePosix, dirnamePosix, joinPosix, relativePosix } from './pathUtils.js'; import type { PodFs } from './podfs.js'; import { nodePodFs } from './nodePodFs.js'; @@ -20,7 +20,7 @@ export class ArtiMount { */ constructor(name: string, rootPath: string, readonly: boolean = false, podFs?: PodFs) { this.name = name; - this.rootPath = path.resolve(rootPath); + this.rootPath = resolvePosix(rootPath); this.readonly = readonly; this.fs = podFs ?? nodePodFs(); } @@ -76,11 +76,13 @@ export class ArtiMount { * @param relativePath - Path relative to mount root */ private _resolve(relativePath: string): string { - const resolved = path.resolve(this.rootPath, relativePath); + const resolved = resolvePosix(this.rootPath, relativePath); // Prevent path traversal outside mount (exact-boundary check: '/a' must - // not admit '/ab/...', only '/a' itself or '/a/...') - if (resolved !== this.rootPath && !resolved.startsWith(this.rootPath + path.sep)) { + // not admit '/ab/...', only '/a' itself or '/a/...'). Root mounts ('/') + // admit every absolute path. + const boundary = this.rootPath === '/' ? '/' : this.rootPath + '/'; + if (resolved !== this.rootPath && !resolved.startsWith(boundary)) { throw new Error(`Path ${relativePath} is outside mount root`); } @@ -170,7 +172,7 @@ export class ArtiMount { throw new Error(`Cannot write to read-only mount '${this.name}'`); } const fullPath = this._resolve(relativePath); - const dir = path.dirname(fullPath); + const dir = dirnamePosix(fullPath); // Ensure parent directories exist await this.fs.mkdir(dir, { recursive: true }); @@ -204,7 +206,7 @@ export class ArtiMount { // Return paths relative to root with sizes return files.map(f => ({ - path: path.relative(this.rootPath, f.fullPath), + path: relativePosix(this.rootPath, f.fullPath), size: f.size })); } @@ -222,7 +224,7 @@ export class ArtiMount { // Return paths relative to root with sizes and directory flag return entries.map(e => ({ - path: path.relative(this.rootPath, e.fullPath), + path: relativePosix(this.rootPath, e.fullPath), size: e.size, isDirectory: e.isDirectory })); @@ -241,7 +243,7 @@ export class ArtiMount { const dirEntries = await this.fs.readdir(dir, { withFileTypes: true }); for (const entry of dirEntries) { - const fullPath = path.join(dir, entry.name); + const fullPath = joinPosix(dir, entry.name); if (entry.isDirectory()) { entries.push({ fullPath, size: 0, isDirectory: true }); @@ -273,7 +275,7 @@ export class ArtiMount { const entries = await this.fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { - const fullPath = path.join(dir, entry.name); + const fullPath = joinPosix(dir, entry.name); if (entry.isDirectory()) { await this._listRecursive(fullPath, files); diff --git a/src/artipod.ts b/src/artipod.ts index 6f8d674..855f3ff 100644 --- a/src/artipod.ts +++ b/src/artipod.ts @@ -1,18 +1,25 @@ import { ArtiMount } from './artimount.js'; -import { - ContainerHandle, - CommandResult, - ContainerOptions, - buildContainerImage, - createContainer, - executeCommandInContainer, - stopAndRemoveContainer, -} from './docker/containerUtils.js'; +import type { ContainerHandle, CommandResult, ContainerOptions } from './docker/containerUtils.js'; import { ArtiPodOptions } from './types.js'; import type { PodFs } from './podfs.js'; import { nodePodFs } from './nodePodFs.js'; -import * as crypto from 'crypto'; -import * as path from 'path'; +import { joinPosix } from './pathUtils.js'; +import { PodEvents } from './events.js'; + +// The docker backend (dockerode → ssh2 native addon) must never enter +// browser bundles: loaded on first container use, node-only. webpackIgnore +// keeps webpack from following the dynamic edge (vite/vitest still resolve it). +const dockerBackend = () => + import(/* webpackIgnore: true */ './docker/containerUtils.js') as Promise< + typeof import('./docker/containerUtils.js') + >; + +/** Isomorphic 16-byte hex id (WebCrypto exists in browsers and Node ≥20). */ +function randomHexId(): string { + const buf = new Uint8Array(16); + globalThis.crypto.getRandomValues(buf); + return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join(''); +} interface BuildPromptOptions { maxSize?: number; @@ -36,6 +43,8 @@ export class ArtiPod { private useMainMount: boolean; private initialized: boolean = false; private fs: PodFs; + /** Coherence bus: exec:start/exec:end + coarse fs:changed (plan §3). */ + readonly events = new PodEvents(); /** * Format file size in human-readable format @@ -50,7 +59,7 @@ export class ArtiPod { constructor(options?: ArtiPodOptions) { // Generate or use provided ID - this.id = options?.id || crypto.randomBytes(16).toString('hex'); + this.id = options?.id || randomHexId(); this.workspaceDir = options?.workspaceDir; this.useMainMount = options?.useMainMount ?? true; this.fs = options?.fs ?? nodePodFs(); @@ -102,7 +111,7 @@ export class ArtiPod { // Create and initialize main mount if needed if (this.useMainMount && this.workspaceDir) { - const mainMountPath = path.join(this.workspaceDir, `artipod-${this.id}`); + const mainMountPath = joinPosix(this.workspaceDir, `artipod-${this.id}`); // Create directory (recursive: true means no error if exists) await this.fs.mkdir(mainMountPath, { recursive: true }); @@ -139,7 +148,7 @@ export class ArtiPod { // Delete the directory if (this.workspaceDir) { - const mainMountPath = path.join(this.workspaceDir, `artipod-${this.id}`); + const mainMountPath = joinPosix(this.workspaceDir, `artipod-${this.id}`); await this.fs.rm(mainMountPath, { recursive: true, force: true }); } } @@ -445,13 +454,14 @@ export class ArtiPod { } // Build or reuse image - this.imageName = await buildContainerImage(dockerfilePath); + const docker = await dockerBackend(); + this.imageName = await docker.buildContainerImage(dockerfilePath); // Store timeout for later use this.commandTimeout = options?.commandTimeout || 30000; // Create and start container - this.container = await createContainer(this.imageName, mounts, options); + this.container = await docker.createContainer(this.imageName, mounts, options); return this.container; } @@ -463,7 +473,7 @@ export class ArtiPod { throw new Error('No running container for this pod'); } - await stopAndRemoveContainer(this.container); + await (await dockerBackend()).stopAndRemoveContainer(this.container); this.container = undefined; this.imageName = undefined; } @@ -480,7 +490,13 @@ export class ArtiPod { } const effectiveTimeout = timeout ?? this.commandTimeout; - return await executeCommandInContainer(this.container, command, effectiveTimeout); + this.events.emit('exec:start', { line: command }); + const startedAt = Date.now(); + const result = await (await dockerBackend()).executeCommandInContainer(this.container, command, effectiveTimeout); + this.events.emit('exec:end', { line: command, exitCode: result.exitCode, durationMs: Date.now() - startedAt }); + // Coarse by contract: a container command can touch any mount. + this.events.emit('fs:changed', { origin: 'exec' }); + return result; } /** diff --git a/src/console/console.test.ts b/src/console/console.test.ts new file mode 100644 index 0000000..a362535 --- /dev/null +++ b/src/console/console.test.ts @@ -0,0 +1,31 @@ +/** + * Console module contract: SSR-safe no-op without a DOM (docs/console.md). + * The DOM rendering itself is exercised in browser consumers; here we pin + * that importing and installing in Node never throws and never renders. + */ +import { describe, expect, it } from 'vitest'; +import { configure, InMemory, fs as zfs, umount } from '@zenfs/core'; +import { createSandbox } from '../sandbox/index.js'; +import { installConsole } from './index.js'; + +describe('installConsole (no DOM)', () => { + it('returns a no-op handle in Node/SSR', async () => { + try { + umount('/'); + } catch { + // first run + } + await configure({ mounts: { '/': InMemory } }); + await zfs.promises.mkdir('/repo'); + const sandbox = createSandbox({ zfs }); + + const handle = installConsole({ sandbox }); + expect(handle.isOpen).toBe(false); + // all methods are safe no-ops + handle.toggle(); + handle.show(); + handle.hide(); + handle.dispose(); + expect(handle.isOpen).toBe(false); + }); +}); diff --git a/src/console/index.ts b/src/console/index.ts new file mode 100644 index 0000000..249bc49 --- /dev/null +++ b/src/console/index.ts @@ -0,0 +1,200 @@ +/** + * @artipod/core/console — the Ctrl+~ drop-in overlay console (docs/console.md). + * + * One call gives any web app a Quake-style drop-down artipod shell. Builtin + * zero-dependency renderer (no xterm): a log
 + an  line driven
+ * by the same sandbox/agent surfaces every other consumer uses. SSR-safe:
+ * without a DOM this is a no-op. Consumes only /host-style contracts +
+ * pod.events.
+ */
+import type { Sandbox } from '../sandbox/types.js';
+import type { PodEvents } from '../events.js';
+
+export interface InstallConsoleOptions {
+  sandbox: Sandbox;
+  events?: PodEvents;
+  /** Hotkey: 'Ctrl+`' (default) — also matches Ctrl+~ (shifted backquote). */
+  hotkey?: string;
+  /** Secondary tabs: run nothing, say why. */
+  readOnly?: boolean;
+  /** Overlay height as a CSS size. Default: '45vh'. */
+  height?: string;
+}
+
+export interface ConsoleHandle {
+  toggle(): void;
+  show(): void;
+  hide(): void;
+  dispose(): void;
+  readonly isOpen: boolean;
+}
+
+const NOOP_HANDLE: ConsoleHandle = {
+  toggle() {},
+  show() {},
+  hide() {},
+  dispose() {},
+  isOpen: false,
+};
+
+/** Strip ANSI SGR sequences — the builtin renderer is plain text. */
+// eslint-disable-next-line no-control-regex
+const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '');
+
+export function installConsole(options: InstallConsoleOptions): ConsoleHandle {
+  if (typeof document === 'undefined' || typeof window === 'undefined') {
+    return NOOP_HANDLE; // SSR / Node: no-op by contract
+  }
+
+  const { sandbox, events, readOnly, height = '45vh' } = options;
+
+  const overlay = document.createElement('div');
+  overlay.setAttribute('data-artipod-console', '');
+  overlay.style.cssText = [
+    'position:fixed;top:0;left:0;right:0;z-index:2147483000',
+    `height:${height};display:none;flex-direction:column`,
+    'background:rgba(20,20,20,.96);color:#e6e6e6',
+    'font:13px/1.4 Menlo,Monaco,"Courier New",monospace',
+    'border-bottom:1px solid #444;box-shadow:0 6px 24px rgba(0,0,0,.5)',
+  ].join(';');
+
+  const log = document.createElement('pre');
+  log.style.cssText = 'flex:1;margin:0;padding:8px 10px;overflow:auto;white-space:pre-wrap;word-break:break-word';
+
+  const inputRow = document.createElement('div');
+  inputRow.style.cssText = 'display:flex;border-top:1px solid #333';
+  const promptEl = document.createElement('span');
+  promptEl.style.cssText = 'padding:6px 0 6px 10px;color:#8bd5a0;white-space:nowrap';
+  const input = document.createElement('input');
+  input.type = 'text';
+  input.autocapitalize = 'off';
+  input.autocomplete = 'off';
+  input.spellcheck = false;
+  input.style.cssText =
+    'flex:1;background:transparent;border:0;outline:0;color:inherit;font:inherit;padding:6px 10px';
+  inputRow.append(promptEl, input);
+  overlay.append(log, inputRow);
+  document.body.appendChild(overlay);
+
+  let open = false;
+  let busy = false;
+  let abortController: AbortController | null = null;
+  const history: string[] = [];
+  let historyIndex = 0;
+  const disposers: Array<() => void> = [];
+
+  const append = (text: string) => {
+    log.append(stripAnsi(text));
+    log.scrollTop = log.scrollHeight;
+  };
+  const refreshPrompt = () => {
+    promptEl.textContent = `${sandbox.getCwd()} $`;
+  };
+
+  append('artipod console — type a command, `notes` for help, Ctrl+` to hide\n');
+  refreshPrompt();
+
+  if (events) {
+    disposers.push(
+      events.on('agent:tool-call', (e) => {
+        if (e.phase === 'call') append(`⚙ ${e.name} ${e.arguments}\n`);
+      }),
+      events.on('approval:request', (e) => {
+        append(`⛔ approval requested: ${e.verb}${e.target ? ` ${e.target}` : ''} — denied (Phase 6.5 lands the flow)\n`);
+      }),
+    );
+  }
+
+  const run = async (cmd: string) => {
+    append(`${sandbox.getCwd()} $ ${cmd}\n`);
+    if (!cmd.trim()) return;
+    if (readOnly) {
+      append('read-only session: commands are disabled in this tab\n');
+      return;
+    }
+    history.push(cmd);
+    historyIndex = history.length;
+    busy = true;
+    abortController = new AbortController();
+    try {
+      const r = await sandbox.exec(cmd, { signal: abortController.signal });
+      if (r.stdout) append(r.stdout);
+      if (r.stderr) append(r.stderr);
+    } catch (e) {
+      append(`${String(e)}\n`);
+    } finally {
+      busy = false;
+      abortController = null;
+      refreshPrompt();
+    }
+  };
+
+  const onInputKey = async (e: KeyboardEvent) => {
+    if (e.key === 'Enter' && !busy) {
+      const cmd = input.value;
+      input.value = '';
+      await run(cmd);
+    } else if (e.key === 'c' && e.ctrlKey && busy) {
+      abortController?.abort();
+    } else if (e.key === 'ArrowUp') {
+      e.preventDefault();
+      if (historyIndex > 0) {
+        historyIndex--;
+        input.value = history[historyIndex];
+      }
+    } else if (e.key === 'ArrowDown') {
+      e.preventDefault();
+      if (historyIndex < history.length) {
+        historyIndex++;
+        input.value = historyIndex === history.length ? '' : history[historyIndex];
+      }
+    } else if (e.key === 'Tab') {
+      e.preventDefault();
+      if (!input.value) return;
+      const { candidates, replaceStart } = await sandbox.complete(input.value);
+      if (candidates.length === 1) {
+        const token = input.value.slice(replaceStart);
+        input.value += candidates[0].slice(token.length) + (candidates[0].endsWith('/') ? '' : ' ');
+      } else if (candidates.length > 1) {
+        append(`${candidates.slice(0, 40).join('  ')}${candidates.length > 40 ? '  …' : ''}\n`);
+      }
+    }
+  };
+  input.addEventListener('keydown', onInputKey);
+
+  const show = () => {
+    open = true;
+    overlay.style.display = 'flex';
+    refreshPrompt();
+    input.focus();
+  };
+  const hide = () => {
+    open = false;
+    overlay.style.display = 'none';
+  };
+  const toggle = () => (open ? hide() : show());
+
+  // Ctrl+` and Ctrl+~ (same physical key, shifted) toggle the overlay.
+  const onHotkey = (e: KeyboardEvent) => {
+    if (e.ctrlKey && (e.key === '`' || e.key === '~')) {
+      e.preventDefault();
+      toggle();
+    }
+  };
+  window.addEventListener('keydown', onHotkey);
+  disposers.push(() => window.removeEventListener('keydown', onHotkey));
+  disposers.push(() => input.removeEventListener('keydown', onInputKey));
+
+  return {
+    toggle,
+    show,
+    hide,
+    dispose() {
+      for (const d of disposers.splice(0)) d();
+      overlay.remove();
+    },
+    get isOpen() {
+      return open;
+    },
+  };
+}
diff --git a/src/events.ts b/src/events.ts
new file mode 100644
index 0000000..f74cac8
--- /dev/null
+++ b/src/events.ts
@@ -0,0 +1,98 @@
+/**
+ * pod.events — the coherence bus for everything operating on one pod
+ * (plan §3 "Browser UI surfaces"). Shell, tools, editor, tree and agent all
+ * see the same store; these events keep them in sync without polling.
+ *
+ * fs invalidation is command-boundary coarse BY CONTRACT (a bash line can
+ * touch anything): after every live exec a coarse `fs:changed` fires. Do not
+ * rely on ZenFS `fs.watch`.
+ */
+
+export interface ExecStartEvent {
+  line: string;
+}
+
+export interface ExecEndEvent {
+  line: string;
+  exitCode: number;
+  durationMs: number;
+}
+
+export interface FsChangedEvent {
+  /**
+   * Affected paths when precisely known (tool edits, editor saves);
+   * undefined = coarse "anything may have changed" (after a shell command).
+   */
+  paths?: string[];
+  origin: 'exec' | 'tool' | 'editor' | 'git' | 'agent' | 'external';
+}
+
+export interface EditRequestEvent {
+  path: string;
+}
+
+export interface AgentToolCallEvent {
+  phase: 'call' | 'result';
+  name: string;
+  /** Raw JSON argument string as the model sent it. */
+  arguments: string;
+  id?: string;
+  /** Result phase only. */
+  ok?: boolean;
+  summary?: string;
+}
+
+export interface ApprovalRequestEvent {
+  verb: string;
+  target?: string;
+  justification?: string;
+}
+
+export interface PodEventMap {
+  'exec:start': ExecStartEvent;
+  'exec:end': ExecEndEvent;
+  'fs:changed': FsChangedEvent;
+  'edit:request': EditRequestEvent;
+  'agent:tool-call': AgentToolCallEvent;
+  'approval:request': ApprovalRequestEvent;
+}
+
+export type PodEventName = keyof PodEventMap;
+
+type AnyListener = (payload: never) => void;
+
+export class PodEvents {
+  private listeners = new Map>();
+
+  /** Subscribe; returns the unsubscribe function. */
+  on(event: K, listener: (payload: PodEventMap[K]) => void): () => void {
+    let set = this.listeners.get(event);
+    if (!set) {
+      set = new Set();
+      this.listeners.set(event, set);
+    }
+    set.add(listener as AnyListener);
+    return () => this.off(event, listener);
+  }
+
+  off(event: K, listener: (payload: PodEventMap[K]) => void): void {
+    this.listeners.get(event)?.delete(listener as AnyListener);
+  }
+
+  emit(event: K, payload: PodEventMap[K]): void {
+    const set = this.listeners.get(event);
+    if (!set) return;
+    for (const listener of [...set]) {
+      try {
+        (listener as (p: PodEventMap[K]) => void)(payload);
+      } catch (error) {
+        // A misbehaving listener must never take down the emitter.
+        console.error(`pod.events listener for '${event}' threw:`, error);
+      }
+    }
+  }
+
+  listenerCount(event: PodEventName): number {
+    return this.listeners.get(event)?.size ?? 0;
+  }
+}
diff --git a/src/host/file-buffer.ts b/src/host/file-buffer.ts
new file mode 100644
index 0000000..5318558
--- /dev/null
+++ b/src/host/file-buffer.ts
@@ -0,0 +1,145 @@
+/**
+ * FileBuffer — the headless editor document extracted from artipod-sync's
+ * Editor.tsx (plan §3): open → content, save(), isDirty, external-change
+ * detection over `fs:changed` (reload-if-clean / flag-if-dirty). Editor-
+ * agnostic: Monaco, kerebron RichEditor or CodeMirror all sit on top.
+ */
+import type { ZenFsLike } from '../sandbox/types.js';
+import type { PodEvents } from '../events.js';
+
+export interface FileBufferOptions {
+  zfs: ZenFsLike;
+  path: string;
+  events?: PodEvents;
+  /** Secondary tabs: refuse to save. */
+  readOnly?: boolean;
+}
+
+export type FileBufferListener = (buffer: FileBuffer) => void;
+
+export function languageForPath(path: string): string {
+  if (path.endsWith('.ts') || path.endsWith('.tsx')) return 'typescript';
+  if (path.endsWith('.js') || path.endsWith('.jsx')) return 'javascript';
+  if (path.endsWith('.json')) return 'json';
+  if (path.endsWith('.css')) return 'css';
+  if (path.endsWith('.html')) return 'html';
+  if (path.endsWith('.md')) return 'markdown';
+  return 'plaintext';
+}
+
+export class FileBuffer {
+  private _content = '';
+  private _savedContent = '';
+  private _isDirty = false;
+  private _externallyChanged = false;
+  private listeners = new Set();
+  private disposers: Array<() => void> = [];
+
+  private constructor(private readonly opts: FileBufferOptions) {}
+
+  static async open(opts: FileBufferOptions): Promise {
+    const buffer = new FileBuffer(opts);
+    await buffer.reload();
+    if (opts.events) {
+      buffer.disposers.push(
+        opts.events.on('fs:changed', (e) => {
+          // Editor's own save already synced the buffer.
+          if (e.origin === 'editor' && e.paths?.includes(opts.path)) return;
+          const affectsThisFile = !e.paths || e.paths.includes(opts.path);
+          if (!affectsThisFile) return;
+          void buffer.handleExternalChange();
+        }),
+      );
+    }
+    return buffer;
+  }
+
+  get path(): string {
+    return this.opts.path;
+  }
+
+  get content(): string {
+    return this._content;
+  }
+
+  get isDirty(): boolean {
+    return this._isDirty;
+  }
+
+  /** Set when the file changed underneath a dirty buffer (warn, don't clobber). */
+  get externallyChanged(): boolean {
+    return this._externallyChanged;
+  }
+
+  get language(): string {
+    return languageForPath(this.opts.path);
+  }
+
+  onChange(listener: FileBufferListener): () => void {
+    this.listeners.add(listener);
+    return () => this.listeners.delete(listener);
+  }
+
+  private notify(): void {
+    for (const l of [...this.listeners]) l(this);
+  }
+
+  /** Editor keystroke path: update content, mark dirty. */
+  setContent(text: string): void {
+    this._content = text;
+    this._isDirty = text !== this._savedContent;
+    this.notify();
+  }
+
+  async reload(): Promise {
+    const { zfs, path } = this.opts;
+    try {
+      this._content = (await zfs.promises.readFile(path, 'utf8')) as string;
+    } catch {
+      this._content = ''; // new file
+    }
+    this._savedContent = this._content;
+    this._isDirty = false;
+    this._externallyChanged = false;
+    this.notify();
+  }
+
+  async save(): Promise {
+    if (this.opts.readOnly) {
+      throw new Error('read-only session: saving is disabled in this tab');
+    }
+    const { zfs, path, events } = this.opts;
+    await zfs.promises.writeFile(path, this._content);
+    this._savedContent = this._content;
+    this._isDirty = false;
+    this._externallyChanged = false;
+    events?.emit('fs:changed', { paths: [path], origin: 'editor' });
+    this.notify();
+  }
+
+  /** fs:changed for our path: reload if clean, flag if dirty. */
+  private async handleExternalChange(): Promise {
+    const { zfs, path } = this.opts;
+    let onDisk: string;
+    try {
+      onDisk = (await zfs.promises.readFile(path, 'utf8')) as string;
+    } catch {
+      onDisk = '';
+    }
+    if (onDisk === this._savedContent) return; // nothing actually changed
+    if (this._isDirty) {
+      this._externallyChanged = true;
+      this.notify();
+    } else {
+      this._content = onDisk;
+      this._savedContent = onDisk;
+      this._externallyChanged = false;
+      this.notify();
+    }
+  }
+
+  dispose(): void {
+    for (const d of this.disposers.splice(0)) d();
+    this.listeners.clear();
+  }
+}
diff --git a/src/host/host.test.ts b/src/host/host.test.ts
new file mode 100644
index 0000000..34465e9
--- /dev/null
+++ b/src/host/host.test.ts
@@ -0,0 +1,217 @@
+/**
+ * /host controller tests over a real sandbox (ZenFS InMemory) with a fake
+ * xterm-shaped IO — the Phase 2 acceptance wiring, headless:
+ * tree invalidates after every command, editor detects external changes,
+ * agent echo arrives via agent:tool-call (no registerWriter).
+ */
+import { beforeEach, describe, expect, it } from 'vitest';
+import { configure, InMemory, fs as zfs, umount } from '@zenfs/core';
+import { PodEvents } from '../events.js';
+import { createSandbox, type Sandbox } from '../sandbox/index.js';
+import { FileBuffer } from './file-buffer.js';
+import { TerminalSession, commonPrefix, toCrLf } from './terminal-session.js';
+import { TREE_ROOT_ID, TreeSource } from './tree-source.js';
+
+let sandbox: Sandbox;
+let events: PodEvents;
+
+class FakeIO {
+  out = '';
+  write(text: string): void {
+    this.out += text;
+  }
+}
+
+beforeEach(async () => {
+  try {
+    umount('/');
+  } catch {
+    // first run
+  }
+  await configure({ mounts: { '/': InMemory } });
+  await zfs.promises.mkdir('/repo');
+  events = new PodEvents();
+  sandbox = createSandbox({ zfs, events });
+});
+
+describe('TerminalSession', () => {
+  it('runs a typed command line and writes CRLF output after the prompt', async () => {
+    const io = new FakeIO();
+    const session = new TerminalSession({ sandbox, io, banner: ['hello banner'] });
+    expect(io.out).toContain('hello banner\r\n');
+    expect(io.out).toContain('/repo $ ');
+
+    for (const ch of 'echo hi') await session.handleData(ch);
+    await session.handleData('\r');
+    expect(io.out).toContain('hi\r\n');
+    session.dispose();
+  });
+
+  it('recalls history with arrow keys and completes with Tab', async () => {
+    const io = new FakeIO();
+    const session = new TerminalSession({ sandbox, io });
+    for (const ch of 'echo one') await session.handleData(ch);
+    await session.handleData('\r');
+
+    io.out = '';
+    await session.handleData('\x1b[A'); // up
+    expect(io.out).toContain('echo one');
+
+    // Tab completion: unique command prefix
+    await session.handleData('\x1b[B'); // down → empty line
+    for (const ch of 'ech') await session.handleData(ch);
+    await session.handleData('\t');
+    expect(io.out).toContain('echo ');
+    session.dispose();
+  });
+
+  it('echoes agent tool calls via agent:tool-call (no registerWriter)', () => {
+    const io = new FakeIO();
+    const session = new TerminalSession({ sandbox, io, events });
+    events.emit('agent:tool-call', { phase: 'call', name: 'bash', arguments: '{"command":"ls"}' });
+    expect(io.out).toContain('⚙ bash');
+    expect(io.out).toContain('"command":"ls"');
+    session.dispose();
+    io.out = '';
+    events.emit('agent:tool-call', { phase: 'call', name: 'bash', arguments: '{}' });
+    expect(io.out).toBe(''); // disposed sessions detach their listeners
+  });
+
+  it('refuses commands in read-only sessions', async () => {
+    const io = new FakeIO();
+    const session = new TerminalSession({ sandbox, io, readOnly: true });
+    for (const ch of 'echo nope') await session.handleData(ch);
+    await session.handleData('\r');
+    expect(io.out).toContain('read-only session');
+    expect(io.out).not.toContain('\r\nnope\r\n'); // the command never ran
+    session.dispose();
+  });
+
+  it('helpers: toCrLf and commonPrefix', () => {
+    expect(toCrLf('a\nb\r\nc')).toBe('a\r\nb\r\nc');
+    expect(commonPrefix(['foobar', 'foobaz', 'foo'])).toBe('foo');
+  });
+});
+
+describe('FileBuffer', () => {
+  it('opens, edits, saves, and reports dirty state', async () => {
+    await zfs.promises.writeFile('/repo/a.md', 'one');
+    const buffer = await FileBuffer.open({ zfs, path: '/repo/a.md', events });
+    expect(buffer.content).toBe('one');
+    expect(buffer.isDirty).toBe(false);
+    expect(buffer.language).toBe('markdown');
+
+    buffer.setContent('two');
+    expect(buffer.isDirty).toBe(true);
+    await buffer.save();
+    expect(buffer.isDirty).toBe(false);
+    expect(await zfs.promises.readFile('/repo/a.md', 'utf8')).toBe('two');
+    buffer.dispose();
+  });
+
+  it('detects external changes: reloads when clean', async () => {
+    await zfs.promises.writeFile('/repo/b.txt', 'v1');
+    const buffer = await FileBuffer.open({ zfs, path: '/repo/b.txt', events });
+
+    // a shell command rewrites the file → coarse fs:changed fires
+    await sandbox.exec('echo v2 > /repo/b.txt');
+    await new Promise((r) => setTimeout(r, 10)); // async reload settles
+    expect(buffer.content).toBe('v2\n');
+    expect(buffer.isDirty).toBe(false);
+    buffer.dispose();
+  });
+
+  it('flags external changes without clobbering a dirty buffer', async () => {
+    await zfs.promises.writeFile('/repo/c.txt', 'v1');
+    const buffer = await FileBuffer.open({ zfs, path: '/repo/c.txt', events });
+    buffer.setContent('my unsaved edit');
+
+    await sandbox.exec('echo surprise > /repo/c.txt');
+    await new Promise((r) => setTimeout(r, 10));
+    expect(buffer.content).toBe('my unsaved edit'); // preserved
+    expect(buffer.externallyChanged).toBe(true);
+    buffer.dispose();
+  });
+
+  it('save emits a precise fs:changed the buffer itself ignores', async () => {
+    const paths: (string[] | undefined)[] = [];
+    events.on('fs:changed', (e) => paths.push(e.paths));
+    const buffer = await FileBuffer.open({ zfs, path: '/repo/d.txt', events });
+    buffer.setContent('data');
+    await buffer.save();
+    expect(paths).toContainEqual(['/repo/d.txt']);
+    expect(buffer.isDirty).toBe(false);
+    buffer.dispose();
+  });
+
+  it('refuses to save in read-only sessions', async () => {
+    const buffer = await FileBuffer.open({ zfs, path: '/repo/e.txt', readOnly: true });
+    buffer.setContent('x');
+    await expect(buffer.save()).rejects.toThrow(/read-only/);
+    buffer.dispose();
+  });
+});
+
+describe('TreeSource', () => {
+  it('serves roots from options and lists children sorted', async () => {
+    await zfs.promises.writeFile('/repo/z.txt', 'z');
+    await zfs.promises.mkdir('/repo/sub');
+    const tree = new TreeSource({ zfs, roots: ['/repo'], events });
+
+    const root = await tree.getItem(TREE_ROOT_ID);
+    expect(root.children).toEqual(['/repo']);
+
+    const repo = await tree.getItem('/repo');
+    expect(repo.isFolder).toBe(true);
+    expect(repo.children).toEqual(['/repo/sub', '/repo/z.txt']);
+
+    const file = await tree.getItem('/repo/z.txt');
+    expect(file.isFolder).toBe(false);
+    expect(file.data).toBe('z.txt');
+    tree.dispose();
+  });
+
+  it('auto-invalidates every known id after each shell command', async () => {
+    const tree = new TreeSource({ zfs, roots: ['/repo'], events });
+    await tree.getItem(TREE_ROOT_ID);
+    await tree.getItem('/repo');
+
+    const invalidations: string[][] = [];
+    tree.onDidChange((ids) => invalidations.push(ids));
+
+    await sandbox.exec('touch /repo/new-file.txt');
+    expect(invalidations.length).toBeGreaterThan(0);
+    expect(invalidations[0]).toContain('/repo');
+    expect(invalidations[0]).toContain(TREE_ROOT_ID);
+
+    // the re-fetch sees the new file
+    const repo = await tree.getItem('/repo');
+    expect(repo.children).toContain('/repo/new-file.txt');
+    tree.dispose();
+  });
+});
+
+describe('sandbox events', () => {
+  it('emits exec:start/exec:end and coarse fs:changed per live command', async () => {
+    const seen: string[] = [];
+    events.on('exec:start', (e) => seen.push(`start:${e.line}`));
+    events.on('exec:end', (e) => seen.push(`end:${e.line}:${e.exitCode}`));
+    events.on('fs:changed', (e) => seen.push(`fs:${e.origin}`));
+
+    await sandbox.exec('true');
+    expect(seen).toEqual(['start:true', 'end:true:0', 'fs:exec']);
+
+    // transient execs (tab completion) stay silent
+    seen.length = 0;
+    await sandbox.complete('ech');
+    expect(seen).toEqual([]);
+  });
+
+  it('emits edit:request from the edit command', async () => {
+    const requested: string[] = [];
+    events.on('edit:request', (e) => requested.push(e.path));
+    const r = await sandbox.exec('edit notes.md');
+    expect(r.exitCode).toBe(0);
+    expect(requested).toEqual(['/repo/notes.md']);
+  });
+});
diff --git a/src/host/index.ts b/src/host/index.ts
new file mode 100644
index 0000000..908d0a7
--- /dev/null
+++ b/src/host/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @artipod/core/host — headless UI controllers (plan §3): framework-free
+ * logic for terminals, editors and file trees. Apps keep thin shells
+ * (xterm, Monaco, react-complex-tree); tests use fakes. Import-safe in Node.
+ */
+export { TerminalSession, toCrLf, commonPrefix } from './terminal-session.js';
+export type { TerminalIO, TerminalSessionOptions } from './terminal-session.js';
+export { FileBuffer, languageForPath } from './file-buffer.js';
+export type { FileBufferOptions, FileBufferListener } from './file-buffer.js';
+export { TreeSource, TREE_ROOT_ID } from './tree-source.js';
+export type { TreeItemData, TreeSourceOptions } from './tree-source.js';
+export { PodEvents } from '../events.js';
+export type {
+  PodEventMap,
+  PodEventName,
+  ExecStartEvent,
+  ExecEndEvent,
+  FsChangedEvent,
+  EditRequestEvent,
+  AgentToolCallEvent,
+  ApprovalRequestEvent,
+} from '../events.js';
diff --git a/src/host/terminal-session.ts b/src/host/terminal-session.ts
new file mode 100644
index 0000000..6b1ec9f
--- /dev/null
+++ b/src/host/terminal-session.ts
@@ -0,0 +1,224 @@
+/**
+ * TerminalSession — the headless terminal line discipline extracted from
+ * artipod-sync's Terminal.tsx (plan §3): input buffer, history, common-prefix
+ * tab completion, Ctrl+C abort, prompt-from-cwd, \n→\r\n normalization.
+ *
+ * I/O contract: `handleData(data)` in, `io.write(text)` out — anything
+ * xterm-shaped satisfies it; tests use a fake. No DOM at module top level.
+ */
+import type { Sandbox } from '../sandbox/types.js';
+import type { PodEvents } from '../events.js';
+
+export interface TerminalIO {
+  write(text: string): void;
+}
+
+export interface TerminalSessionOptions {
+  sandbox: Sandbox;
+  io: TerminalIO;
+  /** Agent tool-call echo arrives via events (replaces registerWriter). */
+  events?: PodEvents;
+  /** Lines written once on attach (app-owned banner). */
+  banner?: string[];
+  /** Secondary tabs: refuse to run commands, explain why. */
+  readOnly?: boolean;
+}
+
+const RED = '\x1b[31m';
+const DIM = '\x1b[2m';
+const RESET = '\x1b[0m';
+
+/** xterm wants \r\n; sandbox output uses \n. */
+export const toCrLf = (s: string): string => s.replace(/\r?\n/g, '\r\n');
+
+/** Longest common prefix of a non-empty candidate list. */
+export function commonPrefix(items: string[]): string {
+  let prefix = items[0];
+  for (const item of items.slice(1)) {
+    while (!item.startsWith(prefix)) prefix = prefix.slice(0, -1);
+  }
+  return prefix;
+}
+
+export class TerminalSession {
+  private buffer = '';
+  private history: string[] = [];
+  private historyIndex = 0;
+  private busy = false;
+  private abortController: AbortController | null = null;
+  private completing = false;
+  private disposers: Array<() => void> = [];
+  private disposed = false;
+
+  constructor(private readonly opts: TerminalSessionOptions) {
+    const { io, banner, events } = opts;
+    if (banner?.length) {
+      for (const line of banner) io.write(`${line}\r\n`);
+    }
+    if (events) {
+      this.disposers.push(
+        events.on('agent:tool-call', (e) => {
+          if (e.phase === 'call') {
+            const args = e.arguments.length > 120 ? `${e.arguments.slice(0, 120)}…` : e.arguments;
+            io.write(`\r\n${DIM}⚙ ${e.name} ${args}${RESET}\r\n`);
+          } else if (e.summary) {
+            io.write(`${DIM}${toCrLf(e.summary)}${RESET}`);
+          }
+        }),
+      );
+    }
+    this.prompt();
+  }
+
+  /** The shell prompt string (cwd-derived, like the app's getPrompt). */
+  private promptText(): string {
+    return `${this.opts.sandbox.getCwd()} $ `;
+  }
+
+  prompt(): void {
+    this.opts.io.write(`\r\n${this.promptText()}`);
+  }
+
+  private redrawLine(): void {
+    this.opts.io.write(`${this.promptText()}${this.buffer}`);
+  }
+
+  private eraseBuffer(): void {
+    while (this.buffer.length > 0) {
+      this.opts.io.write('\b \b');
+      this.buffer = this.buffer.slice(0, -1);
+    }
+  }
+
+  /** Feed raw terminal input (keystrokes, paste). */
+  async handleData(data: string): Promise {
+    if (this.disposed) return;
+    const { io, sandbox } = this.opts;
+
+    // Ctrl+C first: must work while a command is running.
+    if (data === '\x03') {
+      if (this.busy) {
+        this.abortController?.abort();
+      } else {
+        io.write('^C');
+        this.buffer = '';
+        this.prompt();
+      }
+      return;
+    }
+    if (this.busy) return; // ignore typing while a command runs
+    const code = data.charCodeAt(0);
+
+    if (data === '\t') {
+      if (this.completing || !this.buffer) return;
+      this.completing = true;
+      try {
+        const { candidates, replaceStart } = await sandbox.complete(this.buffer);
+        if (candidates.length === 0) return;
+        const token = this.buffer.slice(replaceStart);
+        if (candidates.length === 1) {
+          const chosen = candidates[0];
+          const suffix = chosen.endsWith('/') ? '' : ' ';
+          const insert = chosen.slice(token.length) + suffix;
+          this.buffer += insert;
+          io.write(insert);
+        } else {
+          const lcp = commonPrefix(candidates);
+          if (lcp.length > token.length) {
+            const insert = lcp.slice(token.length);
+            this.buffer += insert;
+            io.write(insert);
+          } else {
+            const shown = candidates.slice(0, 60);
+            const more = candidates.length - shown.length;
+            io.write(`\r\n${DIM}${shown.join('  ')}${more > 0 ? `  …+${more}` : ''}${RESET}\r\n`);
+            this.redrawLine();
+          }
+        }
+      } finally {
+        this.completing = false;
+      }
+      return;
+    }
+
+    if (data === '\x1b[A') {
+      // Up arrow
+      if (this.historyIndex > 0) {
+        this.eraseBuffer();
+        this.historyIndex--;
+        const prev = this.history[this.historyIndex];
+        io.write(prev);
+        this.buffer = prev;
+      }
+      return;
+    }
+
+    if (data === '\x1b[B') {
+      // Down arrow
+      if (this.historyIndex < this.history.length) {
+        this.eraseBuffer();
+        this.historyIndex++;
+        if (this.historyIndex === this.history.length) {
+          this.buffer = '';
+        } else {
+          const next = this.history[this.historyIndex];
+          io.write(next);
+          this.buffer = next;
+        }
+      }
+      return;
+    }
+
+    if (code === 13) {
+      // Enter
+      io.write('\r\n');
+      const cmd = this.buffer;
+      this.buffer = '';
+
+      if (cmd.trim()) {
+        if (this.opts.readOnly) {
+          io.write(`${RED}read-only session: commands are disabled in this tab${RESET}\r\n`);
+          this.prompt();
+          return;
+        }
+        this.history.push(cmd);
+        this.historyIndex = this.history.length;
+
+        this.busy = true;
+        this.abortController = new AbortController();
+        try {
+          const result = await sandbox.exec(cmd, { signal: this.abortController.signal });
+          if (result.stdout) io.write(toCrLf(result.stdout));
+          if (result.stderr) io.write(`${RED}${toCrLf(result.stderr)}${RESET}`);
+        } catch (e) {
+          io.write(`${RED}${toCrLf(String(e))}${RESET}\r\n`);
+        } finally {
+          this.busy = false;
+          this.abortController = null;
+        }
+      }
+      this.prompt();
+    } else if (code === 127) {
+      // Backspace
+      if (this.buffer.length > 0) {
+        this.buffer = this.buffer.slice(0, -1);
+        this.opts.io.write('\b \b');
+      }
+    } else if (code < 32) {
+      // Ignore other control characters
+    } else {
+      this.buffer += data;
+      this.opts.io.write(data);
+    }
+  }
+
+  get isBusy(): boolean {
+    return this.busy;
+  }
+
+  dispose(): void {
+    this.disposed = true;
+    this.abortController?.abort();
+    for (const d of this.disposers.splice(0)) d();
+  }
+}
diff --git a/src/host/tree-source.ts b/src/host/tree-source.ts
new file mode 100644
index 0000000..b4bf9c6
--- /dev/null
+++ b/src/host/tree-source.ts
@@ -0,0 +1,89 @@
+/**
+ * TreeSource — the headless file-tree data source extracted from
+ * artipod-sync's FileTree.tsx (plan §3): getItem/children lookups plus
+ * `fs:changed`-driven invalidation, structurally matching react-complex-tree's
+ * TreeDataProvider (incl. its change-listener slot) without importing its
+ * types. Roots are app-declared (the pod's mount table in Phase 3+), not a
+ * hardcoded /repo.
+ */
+import type { ZenFsLike } from '../sandbox/types.js';
+import type { PodEvents } from '../events.js';
+
+export interface TreeItemData {
+  index: string;
+  isFolder: boolean;
+  children: string[];
+  /** Display label (basename). */
+  data: string;
+}
+
+export const TREE_ROOT_ID = 'root';
+
+export interface TreeSourceOptions {
+  zfs: ZenFsLike;
+  /** Absolute paths shown at the top level. Default: ['/repo']. */
+  roots?: string[];
+  events?: PodEvents;
+}
+
+export class TreeSource {
+  private readonly roots: string[];
+  private listeners = new Set<(changedIds: string[]) => void>();
+  /** Every id handed out — the bounded invalidation set for coarse changes. */
+  private knownIds = new Set();
+  private disposers: Array<() => void> = [];
+
+  constructor(private readonly opts: TreeSourceOptions) {
+    this.roots = opts.roots ?? ['/repo'];
+    if (opts.events) {
+      this.disposers.push(
+        opts.events.on('fs:changed', () => this.invalidate()),
+      );
+    }
+  }
+
+  /** Matches TreeDataProvider.getTreeItem structurally. */
+  async getItem(itemId: string): Promise {
+    if (itemId === TREE_ROOT_ID) {
+      this.knownIds.add(TREE_ROOT_ID);
+      return { index: TREE_ROOT_ID, isFolder: true, children: [...this.roots], data: 'Root' };
+    }
+    const path = itemId;
+    this.knownIds.add(path);
+    try {
+      const stat = await this.opts.zfs.promises.stat(path);
+      const isFolder = stat.isDirectory();
+      let children: string[] = [];
+      if (isFolder) {
+        const names = (await this.opts.zfs.promises.readdir(path)) as string[];
+        children = names.map((f) => (path === '/' ? `/${f}` : `${path}/${f}`)).sort();
+      }
+      return { index: itemId, isFolder, children, data: path.split('/').pop() || path };
+    } catch {
+      return { index: itemId, isFolder: false, children: [], data: path.split('/').pop() || path };
+    }
+  }
+
+  async getChildren(itemId: string): Promise {
+    return (await this.getItem(itemId)).children;
+  }
+
+  /** Matches TreeDataProvider's change-listener slot. */
+  onDidChange(listener: (changedIds: string[]) => void): () => void {
+    this.listeners.add(listener);
+    return () => this.listeners.delete(listener);
+  }
+
+  /** Coarse invalidation: every id the UI has asked about gets re-fetched. */
+  invalidate(): void {
+    if (this.listeners.size === 0) return;
+    const ids = [TREE_ROOT_ID, ...this.knownIds];
+    const unique = [...new Set(ids)];
+    for (const l of [...this.listeners]) l(unique);
+  }
+
+  dispose(): void {
+    for (const d of this.disposers.splice(0)) d();
+    this.listeners.clear();
+  }
+}
diff --git a/src/index.ts b/src/index.ts
index 5caedf3..b74e290 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,14 +2,20 @@ export { ArtiPod } from './artipod.js';
 export { ArtiMount } from './artimount.js';
 export type { PodFs, PodDirent, PodStats } from './podfs.js';
 export { nodePodFs } from './nodePodFs.js';
-export { 
-  findAllContainers, 
-  removeContainer,
-  detectRuntime,
-  getCachedRuntimeInfo,
-  clearRuntimeCache,
-  isRuntimeAvailable,
-} from './docker/index.js';
+export { PodEvents } from './events.js';
+export type {
+  PodEventMap,
+  PodEventName,
+  ExecStartEvent,
+  ExecEndEvent,
+  FsChangedEvent,
+  EditRequestEvent,
+  AgentToolCallEvent,
+  ApprovalRequestEvent,
+} from './events.js';
+export { normalizePosix, resolvePosix, joinPosix, dirnamePosix, relativePosix } from './pathUtils.js';
+// Docker runtime VALUES live in '@artipod/core/docker' only — re-exporting
+// them here would drag dockerode (native ssh2) into browser bundles.
 export type {
   ArtiPodConfig,
   ArtiPodOptions,
diff --git a/src/pathUtils.ts b/src/pathUtils.ts
new file mode 100644
index 0000000..5a742da
--- /dev/null
+++ b/src/pathUtils.ts
@@ -0,0 +1,69 @@
+/**
+ * Dependency-free posix path helpers for the isomorphic core.
+ *
+ * Pod paths are posix by contract (container bind mounts, ZenFS and OCI
+ * layers all speak '/'); Node callers on Windows pass forward-slash paths.
+ * Pure string ops — removes the `node:path` import that broke browser
+ * bundles (Next/Vite don't polyfill node builtins).
+ */
+
+/** Collapse '.', '..' and duplicate slashes; result keeps a single leading '/'. */
+export function normalizePosix(p: string): string {
+  const out: string[] = [];
+  for (const part of p.split('/')) {
+    if (!part || part === '.') continue;
+    if (part === '..') {
+      out.pop();
+      continue;
+    }
+    out.push(part);
+  }
+  return '/' + out.join('/');
+}
+
+/**
+ * posix `path.resolve`: right-most absolute segment wins; relative inputs
+ * resolve against `process.cwd()` in Node and throw elsewhere (browsers must
+ * pass absolute pod paths).
+ */
+export function resolvePosix(...segments: string[]): string {
+  let acc = '';
+  for (let i = segments.length - 1; i >= 0; i--) {
+    const seg = segments[i];
+    if (!seg) continue;
+    acc = acc ? `${seg}/${acc}` : seg;
+    if (seg.startsWith('/')) return normalizePosix(acc);
+  }
+  if (typeof process !== 'undefined' && typeof process.cwd === 'function') {
+    return normalizePosix(`${process.cwd().replace(/\\/g, '/')}/${acc}`);
+  }
+  throw new Error(`Cannot resolve relative path '${segments.join(', ')}' without process.cwd(); pass an absolute path`);
+}
+
+/** posix `path.join` for '..'-free segments (mount-internal joins). */
+export function joinPosix(...segments: string[]): string {
+  const parts = segments.filter(Boolean);
+  const joined = parts.join('/');
+  if (!joined) return '.';
+  const abs = joined.startsWith('/');
+  const normalized = normalizePosix(joined).slice(1);
+  return abs ? `/${normalized}` : normalized || '.';
+}
+
+/** posix `path.dirname`. */
+export function dirnamePosix(p: string): string {
+  const n = p.length > 1 ? p.replace(/\/+$/, '') : p;
+  const idx = n.lastIndexOf('/');
+  if (idx < 0) return '.';
+  if (idx === 0) return '/';
+  return n.slice(0, idx);
+}
+
+/** posix `path.relative` for absolute paths. */
+export function relativePosix(from: string, to: string): string {
+  const f = normalizePosix(from).split('/').filter(Boolean);
+  const t = normalizePosix(to).split('/').filter(Boolean);
+  let i = 0;
+  while (i < f.length && i < t.length && f[i] === t[i]) i++;
+  return [...f.slice(i).map(() => '..'), ...t.slice(i)].join('/');
+}
diff --git a/src/proc/README.md b/src/proc/README.md
new file mode 100644
index 0000000..c2924c0
--- /dev/null
+++ b/src/proc/README.md
@@ -0,0 +1,30 @@
+# `lib/proc` — host state as files
+
+`/proc` is a snapshot mount. Before every command the sandbox throws the mount
+away and rebuilds it from the registered **providers**; after the command, files
+under `rw` providers that changed are handed back to their provider.
+
+| File | Role |
+| --- | --- |
+| [registry.ts](registry.ts) | what a provider is, and who is registered/enabled |
+| [snapshot.ts](snapshot.ts) | mounts `/proc`, writes provider trees, records hashes |
+| [reconcile.ts](reconcile.ts) | write-back for `rw` providers |
+| [storage-provider.ts](storage-provider.ts) | the one built-in provider: raw IndexedDB / OPFS |
+
+A provider is shaped like a kernel module on purpose, so `lsmod`, `modinfo` and
+`modprobe` (in [../sandbox/module-command.ts](../sandbox/module-command.ts)) are
+the natural UI:
+
+```ts
+registerProcProvider({
+  name: 'route',
+  description: 'the current hash route',
+  mode: 'ro',
+  root: '',                                  // writes /proc/route.json
+  read: async () => ({ 'route.json': JSON.stringify(route) }),
+});
+```
+
+Enable the framework with `createSandbox({ proc: true })`; nothing else needs
+wiring. artipod-sync ships only `storage` — every other projection belongs to
+the app embedding the sandbox.
diff --git a/src/proc/index.ts b/src/proc/index.ts
new file mode 100644
index 0000000..8c27aa6
--- /dev/null
+++ b/src/proc/index.ts
@@ -0,0 +1,29 @@
+/**
+ * The proc framework: host state projected into `/proc` as files.
+ *
+ * Apps register providers; the sandbox refreshes the snapshot before every
+ * command and reconciles writes back afterwards. See ./README.md.
+ */
+export {
+  clearProcProviders,
+  enabledProviders,
+  getProvider,
+  listProviders,
+  providerRoot,
+  registerProcProvider,
+  setProviderEnabled,
+} from './registry.js';
+export type { ProcModule, ProcProvider, ProcTree } from './registry.js';
+export {
+  hashContent,
+  mkdirp,
+  PROC_MOUNT,
+  procEntries,
+  procPathOf,
+  refreshProc,
+  unmountProc,
+  writeTree,
+} from './snapshot.js';
+export type { ProcEntry } from './snapshot.js';
+export { reconcileProc } from './reconcile.js';
+export { registerBuiltinProviders, storageProvider } from './storage-provider.js';
diff --git a/src/proc/proc.test.ts b/src/proc/proc.test.ts
new file mode 100644
index 0000000..aa4d6f0
--- /dev/null
+++ b/src/proc/proc.test.ts
@@ -0,0 +1,241 @@
+/**
+ * The proc framework: registry, snapshot rebuild, rw write-back — plus the
+ * shell surface (`lsmod`/`modprobe`, `mount`/`umount`) that exposes them.
+ */
+import { beforeEach, describe, expect, it } from 'vitest';
+import { configure, InMemory, fs as zfs, umount } from '@zenfs/core';
+import { createSandbox, type Sandbox } from '../sandbox/index.js';
+import {
+  clearProcProviders,
+  listProviders,
+  registerProcProvider,
+  setProviderEnabled,
+} from './registry.js';
+import { procEntries, refreshProc, unmountProc, writeTree } from './snapshot.js';
+import { reconcileProc } from './reconcile.js';
+
+let sandbox: Sandbox;
+
+beforeEach(async () => {
+  await unmountProc();
+  clearProcProviders();
+  try {
+    umount('/');
+  } catch {
+    // first run
+  }
+  await configure({ mounts: { '/': InMemory } });
+  await zfs.promises.mkdir('/repo');
+  sandbox = createSandbox({ zfs, proc: true, cwd: '/repo' });
+});
+
+const provider = (name: string, tree: Record) => ({
+  name,
+  description: `${name} test provider`,
+  version: '1',
+  mode: 'ro' as const,
+  read: async () => tree,
+});
+
+describe('registry', () => {
+  it('unregisters through the returned function', () => {
+    const off = registerProcProvider(provider('a', {}));
+    expect(listProviders()).toHaveLength(2); // 'a' plus the built-in storage
+    off();
+    expect(listProviders().map((m) => m.provider.name)).toEqual(['storage']);
+  });
+
+  it('refuses a duplicate name and an rw provider with no write()', () => {
+    registerProcProvider(provider('a', {}));
+    expect(() => registerProcProvider(provider('a', {}))).toThrow(/already registered/);
+    expect(() =>
+      registerProcProvider({ name: 'b', mode: 'rw', read: async () => ({}) }),
+    ).toThrow(/no write/);
+  });
+});
+
+describe('snapshot', () => {
+  it('writes each provider tree under /proc/', async () => {
+    registerProcProvider(provider('zustand', { 'form.json': '{"a":1}' }));
+    await refreshProc(zfs);
+    expect(await zfs.promises.readFile('/proc/zustand/form.json', 'utf8')).toBe('{"a":1}');
+  });
+
+  it("writes a root-less provider straight into /proc", async () => {
+    registerProcProvider({ ...provider('route', {}), root: '', read: async () => ({ 'route.json': '"#/"' }) });
+    await refreshProc(zfs);
+    expect(await zfs.promises.readFile('/proc/route.json', 'utf8')).toBe('"#/"');
+  });
+
+  it('rebuilds from scratch, so stale files do not survive', async () => {
+    let tree: Record = { 'a.json': '1', 'b.json': '2' };
+    registerProcProvider({ ...provider('x', {}), read: async () => tree });
+    await refreshProc(zfs);
+    tree = { 'a.json': '3' };
+    await refreshProc(zfs);
+    expect(await zfs.promises.readdir('/proc/x')).toEqual(['a.json']);
+    expect(await zfs.promises.readFile('/proc/x/a.json', 'utf8')).toBe('3');
+  });
+
+  it('reports a throwing provider without losing the others', async () => {
+    registerProcProvider({
+      ...provider('bad', {}),
+      read: async () => {
+        throw new Error('nope');
+      },
+    });
+    registerProcProvider(provider('good', { 'ok.json': '1' }));
+    const errors = await refreshProc(zfs);
+    expect(errors).toEqual(['proc: bad: nope']);
+    expect(await zfs.promises.exists('/proc/good/ok.json')).toBe(true);
+  });
+
+  it('mkdir -p writeTree also works on the persistent tree', async () => {
+    await writeTree(zfs, '/artipods/demo', { 'forms/case.yaml': 'id: case\n' });
+    expect(await zfs.promises.readFile('/artipods/demo/forms/case.yaml', 'utf8')).toBe('id: case\n');
+  });
+});
+
+describe('reconcile', () => {
+  it('hands a changed rw file back to its provider, and ignores untouched ones', async () => {
+    const writes: [string, string][] = [];
+    let stored = 'value: 1\n';
+    registerProcProvider({
+      name: 'cases',
+      mode: 'rw',
+      read: async () => ({ 'responses.yaml': stored }),
+      write: async (rel, content) => {
+        writes.push([rel, new TextDecoder().decode(content)]);
+        stored = new TextDecoder().decode(content);
+      },
+    });
+
+    await refreshProc(zfs);
+    expect(await reconcileProc(zfs)).toEqual([]);
+    expect(writes).toHaveLength(0);
+
+    await zfs.promises.writeFile('/proc/cases/responses.yaml', 'value: 2\n');
+    await reconcileProc(zfs);
+    expect(writes).toEqual([['responses.yaml', 'value: 2\n']]);
+  });
+
+  it('surfaces a provider write error and reverts on the next refresh', async () => {
+    registerProcProvider({
+      name: 'cases',
+      mode: 'rw',
+      read: async () => ({ 'responses.yaml': 'value: 1\n' }),
+      write: async () => {
+        throw new Error('malformed');
+      },
+    });
+    await refreshProc(zfs);
+    await zfs.promises.writeFile('/proc/cases/responses.yaml', 'garbage');
+    expect(await reconcileProc(zfs)).toEqual(['proc: cases: responses.yaml: malformed']);
+    await refreshProc(zfs);
+    expect(await zfs.promises.readFile('/proc/cases/responses.yaml', 'utf8')).toBe('value: 1\n');
+  });
+
+  it('runs around each command, so the shell sees fresh state and edits land', async () => {
+    let stored = 'value: 1\n';
+    registerProcProvider({
+      name: 'cases',
+      mode: 'rw',
+      read: async () => ({ 'responses.yaml': stored }),
+      write: async (_rel, content) => {
+        stored = new TextDecoder().decode(content);
+      },
+    });
+    const write = await sandbox.exec("echo 'value: 2' > /proc/cases/responses.yaml");
+    expect(write.exitCode).toBe(0);
+    expect(stored).toBe('value: 2\n');
+    expect((await sandbox.exec('cat /proc/cases/responses.yaml')).stdout).toBe('value: 2\n');
+  });
+});
+
+describe('module commands', () => {
+  beforeEach(() => {
+    registerProcProvider(provider('zustand', { 'form.json': '{}' }));
+  });
+
+  it('lsmod lists the providers with their file counts', async () => {
+    const r = await sandbox.exec('lsmod');
+    expect(r.exitCode).toBe(0);
+    expect(r.stdout).toMatch(/^Module\s+Mode\s+Files\s+State$/m);
+    expect(r.stdout).toMatch(/^zustand\s+ro\s+1\s+Live$/m);
+  });
+
+  it('modinfo prints the provider metadata', async () => {
+    const r = await sandbox.exec('modinfo zustand');
+    expect(r.stdout).toContain('description: zustand test provider');
+    expect(r.stdout).toContain('file:        /proc/zustand/form.json');
+    expect((await sandbox.exec('modinfo nope')).exitCode).toBe(1);
+  });
+
+  it('modprobe -r hides the provider from /proc, and modprobe brings it back', async () => {
+    expect((await sandbox.exec('ls /proc')).stdout).toContain('zustand');
+    await sandbox.exec('modprobe -r zustand');
+    expect(setProviderEnabled('zustand', false)).toBe(true);
+    expect((await sandbox.exec('ls /proc')).stdout).not.toContain('zustand');
+    await sandbox.exec('modprobe zustand');
+    expect((await sandbox.exec('ls /proc')).stdout).toContain('zustand');
+  });
+});
+
+describe('mount / umount', () => {
+  it('mounts an in-memory filesystem anywhere and shows it in the storage views', async () => {
+    expect((await sandbox.exec('mount -t memory /scratch')).exitCode).toBe(0);
+    await sandbox.exec('echo hi > /scratch/note.txt');
+    expect((await sandbox.exec('cat /scratch/note.txt')).stdout).toBe('hi\n');
+
+    for (const view of ['mount', 'df', 'findmnt', 'lsblk']) {
+      expect((await sandbox.exec(view)).stdout, view).toContain('/scratch');
+    }
+  });
+
+  it('rejects an unknown type and a mount point already in use', async () => {
+    expect((await sandbox.exec('mount -t nfs /scratch')).stderr).toMatch(/unknown filesystem type/);
+    await sandbox.exec('mount -t memory /scratch');
+    expect((await sandbox.exec('mount -t memory /scratch')).stderr).toMatch(/already in use/);
+  });
+
+  it('unmounts a memory device but refuses / and /proc', async () => {
+    await sandbox.exec('mount -t memory /scratch');
+    expect((await sandbox.exec('umount /scratch')).exitCode).toBe(0);
+    expect((await sandbox.exec('umount /scratch')).stderr).toMatch(/not mounted/);
+    expect((await sandbox.exec('umount /')).stderr).toMatch(/cannot unmount/);
+    expect((await sandbox.exec('umount /proc')).stderr).toMatch(/cannot unmount/);
+  });
+});
+
+describe('the snapshot is only refreshed for real commands', () => {
+  it('leaves the entry table alone for transient execs', async () => {
+    registerProcProvider(provider('zustand', { 'form.json': '{}' }));
+    await sandbox.exec('true');
+    const before = [...procEntries().keys()];
+    await sandbox.complete('ls /pr');
+    expect([...procEntries().keys()]).toEqual(before);
+  });
+});
+
+describe('host hooks', () => {
+  it('run around each command and report on stderr', async () => {
+    const calls: string[] = [];
+    const hooked = createSandbox({
+      zfs,
+      cwd: '/repo',
+      hooks: {
+        beforeExec: () => {
+          calls.push('before');
+        },
+        afterExec: () => {
+          calls.push('after');
+          return ['cases: responses.yaml: bad indentation'];
+        },
+      },
+    });
+    const r = await hooked.exec('echo hi');
+    expect(calls).toEqual(['before', 'after']);
+    expect(r.stdout).toBe('hi\n');
+    expect(r.stderr).toContain('cases: responses.yaml: bad indentation');
+  });
+});
diff --git a/src/proc/reconcile.ts b/src/proc/reconcile.ts
new file mode 100644
index 0000000..a06a68e
--- /dev/null
+++ b/src/proc/reconcile.ts
@@ -0,0 +1,71 @@
+/**
+ * Write-back for `rw` proc providers.
+ *
+ * After a command, every file under an `rw` provider whose content hash moved
+ * away from the refresh baseline is handed to `provider.write()`. The provider
+ * decides how — and whether — the write applies; a throw surfaces on stderr and
+ * the file reverts on the next refresh, because the snapshot is rebuilt from
+ * `read()` regardless.
+ */
+import { enabledProviders, providerRoot, type ProcProvider } from './registry.js';
+import { hashContent, PROC_MOUNT, procEntries } from './snapshot.js';
+import type { ZenFsLike } from '../sandbox/types.js';
+
+export async function reconcileProc(zfs: ZenFsLike): Promise {
+  const errors: string[] = [];
+  for (const provider of enabledProviders()) {
+    if (provider.mode !== 'rw' || !provider.write) continue;
+    for (const [path, rel] of await currentFiles(zfs, provider)) {
+      let bytes: Uint8Array;
+      try {
+        bytes = (await zfs.promises.readFile(path)) as Uint8Array;
+      } catch {
+        continue; // removed by the command; the next refresh restores it
+      }
+      if (procEntries().get(path)?.hash === hashContent(bytes)) continue;
+      try {
+        await provider.write(rel, bytes);
+      } catch (e) {
+        errors.push(`proc: ${provider.name}: ${rel}: ${(e as Error).message}`);
+      }
+    }
+  }
+  return errors;
+}
+
+/**
+ * Absolute path → provider-relative path for everything the provider currently
+ * owns. Providers rooted at `/proc` itself cannot be walked (the directory is
+ * shared), so only the files the last refresh wrote are considered.
+ */
+async function currentFiles(
+  zfs: ZenFsLike,
+  provider: ProcProvider,
+): Promise> {
+  const root = providerRoot(provider);
+  const files = new Map();
+  if (!root) {
+    for (const [path, entry] of procEntries()) {
+      if (entry.provider === provider) files.set(path, entry.rel);
+    }
+    return files;
+  }
+
+  const base = `${PROC_MOUNT}/${root}`;
+  const walk = async (dir: string): Promise => {
+    let names: string[];
+    try {
+      names = await zfs.promises.readdir(dir);
+    } catch {
+      return;
+    }
+    for (const name of names) {
+      const path = `${dir}/${name}`;
+      const stat = await zfs.promises.lstat(path);
+      if (stat.isDirectory()) await walk(path);
+      else files.set(path, path.slice(base.length + 1));
+    }
+  };
+  await walk(base);
+  return files;
+}
diff --git a/src/proc/registry.ts b/src/proc/registry.ts
new file mode 100644
index 0000000..9fb0cc7
--- /dev/null
+++ b/src/proc/registry.ts
@@ -0,0 +1,88 @@
+/**
+ * The proc provider registry.
+ *
+ * A *provider* projects host state into the `/proc` tree. It is deliberately
+ * shaped like a kernel module — name, description, version, load state — so
+ * `lsmod` / `modinfo` / `modprobe` fall out naturally.
+ *
+ * App-agnostic: artipod-sync ships only the built-in `storage` provider; every
+ * other projection is registered by whatever app embeds the sandbox.
+ */
+
+/** A provider's projection: path relative to the provider root → file content. */
+export type ProcTree = Record;
+
+export interface ProcProvider {
+  /** Module name — also the default directory under `/proc`. No slashes. */
+  name: string;
+  description?: string;
+  version?: string;
+  /** `rw` providers must supply `write()`; their files are reconciled after each command. */
+  mode: 'ro' | 'rw';
+  /**
+   * Directory under `/proc` that this provider owns, defaulting to `name`.
+   * `''` writes straight into `/proc` (for single-file projections such as
+   * `/proc/route.json`) and gives up new-file detection.
+   */
+  root?: string;
+  /** Called on every snapshot refresh. */
+  read(): Promise;
+  /** `rw` only. A throw surfaces on stderr and the file reverts on next refresh. */
+  write?(relPath: string, content: Uint8Array): Promise;
+}
+
+export interface ProcModule {
+  provider: ProcProvider;
+  enabled: boolean;
+}
+
+const modules = new Map();
+
+/** Registers a provider (enabled). Returns the unregister function. */
+export function registerProcProvider(provider: ProcProvider): () => void {
+  if (provider.name.includes('/') || !provider.name) {
+    throw new Error(`proc provider name must be a single path segment: '${provider.name}'`);
+  }
+  if (modules.has(provider.name)) {
+    throw new Error(`proc provider '${provider.name}' is already registered`);
+  }
+  if (provider.mode === 'rw' && !provider.write) {
+    throw new Error(`proc provider '${provider.name}' is rw but has no write()`);
+  }
+  modules.set(provider.name, { provider, enabled: true });
+  return () => {
+    if (modules.get(provider.name)?.provider === provider) modules.delete(provider.name);
+  };
+}
+
+export function listProviders(): ProcModule[] {
+  return [...modules.values()].sort((a, b) => a.provider.name.localeCompare(b.provider.name));
+}
+
+export function getProvider(name: string): ProcModule | undefined {
+  return modules.get(name);
+}
+
+/** `modprobe` / `modprobe -r`. Returns false when there is no such provider. */
+export function setProviderEnabled(name: string, enabled: boolean): boolean {
+  const module = modules.get(name);
+  if (!module) return false;
+  module.enabled = enabled;
+  return true;
+}
+
+export function enabledProviders(): ProcProvider[] {
+  return listProviders()
+    .filter((m) => m.enabled)
+    .map((m) => m.provider);
+}
+
+/** The `/proc` subdirectory a provider owns, `''` meaning `/proc` itself. */
+export function providerRoot(provider: ProcProvider): string {
+  return provider.root ?? provider.name;
+}
+
+/** Test helper — drops every registration. */
+export function clearProcProviders(): void {
+  modules.clear();
+}
diff --git a/src/proc/snapshot.ts b/src/proc/snapshot.ts
new file mode 100644
index 0000000..6c19b14
--- /dev/null
+++ b/src/proc/snapshot.ts
@@ -0,0 +1,110 @@
+/**
+ * The `/proc` snapshot.
+ *
+ * `/proc` is a plain in-memory ZenFS mount that is thrown away and rebuilt from
+ * the enabled providers before every command — a snapshot, not a live procfs.
+ * Implementing a real ZenFS `FileSystem` would mean 24 abstract methods in
+ * sync+async pairs for no gain: nothing observes the tree between commands.
+ */
+import { enabledProviders, providerRoot, type ProcProvider, type ProcTree } from './registry.js';
+import type { ZenFsLike } from '../sandbox/types.js';
+
+export const PROC_MOUNT = '/proc';
+
+/** One file as it stood at the last refresh — the reconcile baseline. */
+export interface ProcEntry {
+  provider: ProcProvider;
+  /** Path relative to the provider root. */
+  rel: string;
+  hash: string;
+}
+
+const entries = new Map();
+
+/** Absolute path → what the last refresh wrote there. */
+export function procEntries(): ReadonlyMap {
+  return entries;
+}
+
+export function procPathOf(provider: ProcProvider, rel: string): string {
+  const root = providerRoot(provider);
+  return root ? `${PROC_MOUNT}/${root}/${rel}` : `${PROC_MOUNT}/${rel}`;
+}
+
+/**
+ * `mkdir -p` + write for a whole tree. Pure with respect to the mount table, so
+ * apps reuse it to materialize persistent directories too.
+ */
+export async function writeTree(zfs: ZenFsLike, root: string, files: ProcTree): Promise {
+  await mkdirp(zfs, root);
+  for (const [rel, content] of Object.entries(files)) {
+    const path = root === '/' ? `/${rel}` : `${root}/${rel}`;
+    const slash = path.lastIndexOf('/');
+    if (slash > 0) await mkdirp(zfs, path.slice(0, slash));
+    await zfs.promises.writeFile(path, content as Parameters[1]);
+  }
+}
+
+export async function mkdirp(zfs: ZenFsLike, dir: string): Promise {
+  if (dir === '' || dir === '/') return;
+  try {
+    await zfs.promises.mkdir(dir, { recursive: true });
+  } catch (e) {
+    if ((e as { code?: string }).code !== 'EEXIST') throw e;
+  }
+}
+
+/**
+ * Rebuilds `/proc` from the enabled providers. A provider that throws is
+ * skipped with its message returned, rather than taking the whole tree down.
+ */
+export async function refreshProc(zfs: ZenFsLike): Promise {
+  const { mount, mounts, umount, resolveMountConfig, InMemory } = await import('@zenfs/core');
+  if (mounts.has(PROC_MOUNT)) umount(PROC_MOUNT);
+  // ZenFS only exposes a mount point that exists as a directory underneath, and
+  // the mkdir has to happen before the mount or it lands inside it.
+  await mkdirp(zfs, PROC_MOUNT);
+  mount(PROC_MOUNT, await resolveMountConfig({ backend: InMemory }));
+  entries.clear();
+
+  const errors: string[] = [];
+  for (const provider of enabledProviders()) {
+    let tree: ProcTree;
+    try {
+      tree = await provider.read();
+    } catch (e) {
+      errors.push(`proc: ${provider.name}: ${(e as Error).message}`);
+      continue;
+    }
+    const root = providerRoot(provider);
+    await writeTree(zfs, root ? `${PROC_MOUNT}/${root}` : PROC_MOUNT, tree);
+    for (const [rel, content] of Object.entries(tree)) {
+      entries.set(procPathOf(provider, rel), { provider, rel, hash: hashContent(content) });
+    }
+  }
+  return errors;
+}
+
+export async function unmountProc(): Promise {
+  const { mounts, umount } = await import('@zenfs/core');
+  if (mounts.has(PROC_MOUNT)) umount(PROC_MOUNT);
+  entries.clear();
+}
+
+const encoder = new TextEncoder();
+
+export function toBytes(content: string | Uint8Array): Uint8Array {
+  return typeof content === 'string' ? encoder.encode(content) : content;
+}
+
+/** Change detection only — a fast non-cryptographic 64-bit FNV-1a pair. */
+export function hashContent(content: string | Uint8Array): string {
+  const bytes = toBytes(content);
+  let a = 0x811c9dc5;
+  let b = 0x01000193;
+  for (const byte of bytes) {
+    a = Math.imul(a ^ byte, 0x01000193) >>> 0;
+    b = Math.imul(b + byte, 0x85ebca6b) >>> 0;
+  }
+  return `${a.toString(16)}${b.toString(16)}-${bytes.length}`;
+}
diff --git a/src/proc/storage-provider.ts b/src/proc/storage-provider.ts
new file mode 100644
index 0000000..f2764ea
--- /dev/null
+++ b/src/proc/storage-provider.ts
@@ -0,0 +1,125 @@
+/**
+ * The one provider artipod-sync ships itself: a snapshot of the raw browser
+ * storage under `/proc/storage`.
+ *
+ * This is how the *backing devices* are inspected without mounting them into
+ * the very filesystem they back — a snapshot cannot recurse into itself. IDB
+ * database names containing `/` (Yjs rooms are named that way) become
+ * subdirectories, so `ls /proc/storage/idb/case` enumerates case docs.
+ *
+ * Metadata only. Dumping records is a targeted operation, not something every
+ * refresh should pay for.
+ */
+import { getProvider, registerProcProvider, type ProcProvider, type ProcTree } from './registry.js';
+import { getStorageUsage, OPFS_FS_DIR } from '../sandbox/storage.js';
+
+const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
+
+export const storageProvider: ProcProvider = {
+  name: 'storage',
+  description: 'raw browser storage: IndexedDB databases and the OPFS tree',
+  version: '1',
+  mode: 'ro',
+  async read(): Promise {
+    const tree: ProcTree = {};
+    const usage = await getStorageUsage();
+    if (usage) tree['origin.json'] = json(usage);
+    for (const [name, info] of Object.entries(await probeIndexedDb())) {
+      tree[`idb/${name}.json`] = json(info);
+    }
+    tree['opfs.json'] = json(await probeOpfs());
+    return tree;
+  },
+};
+
+/** Idempotent — the sandbox calls it on every `createSandbox({ proc: true })`. */
+export function registerBuiltinProviders(): void {
+  if (!getProvider(storageProvider.name)) registerProcProvider(storageProvider);
+}
+
+interface IdbInfo {
+  database: string;
+  version: number | null;
+  stores: { name: string; records: number | null }[];
+}
+
+async function probeIndexedDb(): Promise> {
+  if (typeof indexedDB === 'undefined' || !indexedDB.databases) return {};
+  const out: Record = {};
+  for (const { name } of await indexedDB.databases()) {
+    if (!name) continue;
+    out[name] = await describeDatabase(name);
+  }
+  return out;
+}
+
+function describeDatabase(name: string): Promise {
+  return new Promise((resolve) => {
+    const request = indexedDB.open(name);
+    request.onerror = () => resolve({ database: name, version: null, stores: [] });
+    request.onsuccess = async () => {
+      const db = request.result;
+      const stores = [...db.objectStoreNames];
+      const counted = await Promise.all(stores.map((store) => countRecords(db, store)));
+      resolve({
+        database: name,
+        version: db.version,
+        stores: stores.map((store, i) => ({ name: store, records: counted[i] })),
+      });
+      db.close();
+    };
+  });
+}
+
+function countRecords(db: IDBDatabase, store: string): Promise {
+  return new Promise((resolve) => {
+    try {
+      const request = db.transaction(store, 'readonly').objectStore(store).count();
+      request.onsuccess = () => resolve(request.result);
+      request.onerror = () => resolve(null);
+    } catch {
+      resolve(null);
+    }
+  });
+}
+
+interface OpfsEntry {
+  path: string;
+  kind: 'file' | 'directory';
+  size: number | null;
+}
+
+async function probeOpfs(): Promise<{ available: boolean; entries: OpfsEntry[] }> {
+  if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) {
+    return { available: false, entries: [] };
+  }
+  try {
+    const root = await navigator.storage.getDirectory();
+    return { available: true, entries: await listTree(root, '') };
+  } catch {
+    return { available: false, entries: [] };
+  }
+}
+
+/** The sandbox subtree is already browsable as `/`; only its shape is listed. */
+async function listTree(dir: FileSystemDirectoryHandle, prefix: string): Promise {
+  const out: OpfsEntry[] = [];
+  const iterator = (
+    dir as unknown as { entries(): AsyncIterableIterator<[string, FileSystemHandle]> }
+  ).entries();
+  for (;;) {
+    const { done, value } = await iterator.next();
+    if (done) break;
+    const [name, handle] = value;
+    const path = `${prefix}${name}`;
+    if (handle.kind === 'file') {
+      out.push({ path, kind: 'file', size: (await (handle as FileSystemFileHandle).getFile()).size });
+      continue;
+    }
+    out.push({ path, kind: 'directory', size: null });
+    if (name !== OPFS_FS_DIR) {
+      out.push(...(await listTree(handle as FileSystemDirectoryHandle, `${path}/`)));
+    }
+  }
+  return out;
+}
diff --git a/src/sandbox/edit-command.ts b/src/sandbox/edit-command.ts
new file mode 100644
index 0000000..6b3d93f
--- /dev/null
+++ b/src/sandbox/edit-command.ts
@@ -0,0 +1,21 @@
+/**
+ * `edit` as a just-bash custom command: resolves the argument against the
+ * shell cwd and hands the path to the host (Monaco in the browser). With a
+ * PodEvents bus attached it emits `edit:request` (generalizes onEdit).
+ */
+import { defineCommand } from 'just-bash/browser';
+import type { PodEvents } from '../events.js';
+
+export const makeEditCommand = (onEdit?: (path: string) => void, events?: PodEvents) =>
+  defineCommand('edit', async (args, ctx) => {
+    if (!args[0]) {
+      return { stdout: '', stderr: 'usage: edit \n', exitCode: 1 };
+    }
+    if (!onEdit && !events) {
+      return { stdout: '', stderr: 'edit: no editor attached in this environment\n', exitCode: 1 };
+    }
+    const path = ctx.fs.resolvePath(ctx.cwd, args[0]);
+    onEdit?.(path);
+    events?.emit('edit:request', { path });
+    return { stdout: `Opening editor for ${path}...\n`, stderr: '', exitCode: 0 };
+  });
diff --git a/src/sandbox/git-auth.ts b/src/sandbox/git-auth.ts
new file mode 100644
index 0000000..8caaf36
--- /dev/null
+++ b/src/sandbox/git-auth.ts
@@ -0,0 +1,83 @@
+/**
+ * Git credentials for push/fetch over HTTPS.
+ *
+ * Tokens live OUTSIDE the sandbox filesystem (agents can read anything in
+ * ZenFS — see plan §5/§8): per-origin, in memory by default, localStorage
+ * only when the user opts in via `git config credential.persist true`.
+ */
+
+const LS_PREFIX = 'artipod-sync-git-token:';
+const LS_PERSIST_FLAG = 'artipod-sync-git-credential-persist';
+
+const memTokens = new Map();
+
+type AuthPrompt = (origin: string) => Promise;
+let promptFn: AuthPrompt | null = null;
+
+/** Host registers how to ask the user for a PAT (e.g. a dialog in the browser). */
+export function setAuthPrompt(fn: AuthPrompt | null): void {
+  promptFn = fn;
+}
+
+function hasLocalStorage(): boolean {
+  return typeof localStorage !== 'undefined';
+}
+
+export function persistenceEnabled(): boolean {
+  return hasLocalStorage() && localStorage.getItem(LS_PERSIST_FLAG) === 'true';
+}
+
+export function setPersistence(enabled: boolean): void {
+  if (!hasLocalStorage()) return;
+  if (enabled) {
+    localStorage.setItem(LS_PERSIST_FLAG, 'true');
+  } else {
+    localStorage.removeItem(LS_PERSIST_FLAG);
+    // drop previously persisted tokens as well
+    for (let i = localStorage.length - 1; i >= 0; i--) {
+      const key = localStorage.key(i);
+      if (key?.startsWith(LS_PREFIX)) localStorage.removeItem(key);
+    }
+  }
+}
+
+export function setToken(origin: string, token: string): void {
+  memTokens.set(origin, token);
+  if (persistenceEnabled()) localStorage.setItem(LS_PREFIX + origin, token);
+}
+
+export function getToken(origin: string): string | null {
+  const mem = memTokens.get(origin);
+  if (mem) return mem;
+  if (hasLocalStorage()) {
+    const stored = localStorage.getItem(LS_PREFIX + origin);
+    if (stored) {
+      memTokens.set(origin, stored);
+      return stored;
+    }
+  }
+  return null;
+}
+
+export function clearToken(origin: string): void {
+  memTokens.delete(origin);
+  if (hasLocalStorage()) localStorage.removeItem(LS_PREFIX + origin);
+}
+
+/** isomorphic-git onAuth callback for a repo URL. */
+export async function onAuthForUrl(url: string): Promise<{ username: string; password: string } | { cancel: true }> {
+  const origin = new URL(url).origin;
+  let token = getToken(origin);
+  if (!token && promptFn) {
+    token = await promptFn(origin);
+    if (token) setToken(origin, token);
+  }
+  if (!token) return { cancel: true };
+  // GitHub/GitLab accept a PAT as the password with any username.
+  return { username: 'x-access-token', password: token };
+}
+
+/** isomorphic-git onAuthFailure: drop the bad token so the next try re-prompts. */
+export function onAuthFailureForUrl(url: string): void {
+  clearToken(new URL(url).origin);
+}
diff --git a/src/sandbox/git-command.test.ts b/src/sandbox/git-command.test.ts
new file mode 100644
index 0000000..4f6dfd1
--- /dev/null
+++ b/src/sandbox/git-command.test.ts
@@ -0,0 +1,150 @@
+/**
+ * git command tests over a fixture repo built with isomorphic-git APIs
+ * (no network) — exercised through the sandbox's `git` custom command.
+ */
+import { beforeEach, describe, expect, it } from 'vitest';
+import { configure, InMemory, fs as zfs, umount } from '@zenfs/core';
+import git from 'isomorphic-git';
+import { createGitOps } from './git.js';
+import { createSandbox, type Sandbox } from './index.js';
+
+const AUTHOR = { name: 'Test', email: 'test@example.com' };
+
+let sandbox: Sandbox;
+
+async function initRepoFixture() {
+  const dir = '/repo';
+  await zfs.promises.mkdir(dir);
+  await git.init({ fs: zfs, dir, defaultBranch: 'main' });
+  await zfs.promises.writeFile(`${dir}/readme.md`, 'v1\n');
+  await git.add({ fs: zfs, dir, filepath: 'readme.md' });
+  await git.commit({ fs: zfs, dir, message: 'initial', author: AUTHOR });
+}
+
+beforeEach(async () => {
+  try {
+    umount('/');
+  } catch {
+    // first run
+  }
+  await configure({ mounts: { '/': InMemory } });
+  await initRepoFixture();
+  sandbox = createSandbox({ zfs });
+});
+
+describe('git custom command (Phase 3 surface)', () => {
+  it('status shows branch and short-status codes', async () => {
+    const clean = await sandbox.exec('git status');
+    expect(clean.stdout).toMatch(/On branch main/);
+    expect(clean.stdout).toMatch(/working tree clean/);
+
+    await zfs.promises.writeFile('/repo/readme.md', 'v2 with more bytes\n');
+    await zfs.promises.writeFile('/repo/new.txt', 'n\n');
+    const dirty = await sandbox.exec('git status');
+    expect(dirty.stdout).toMatch(/ M readme\.md/);
+    expect(dirty.stdout).toMatch(/\?\? new\.txt/);
+  });
+
+  it('add ., commit -m, log --oneline complete the loop', async () => {
+    await zfs.promises.writeFile('/repo/readme.md', 'v2 with more bytes\n');
+    await zfs.promises.writeFile('/repo/new.txt', 'n\n');
+
+    expect((await sandbox.exec('git add .')).exitCode).toBe(0);
+    const staged = await sandbox.exec('git status');
+    expect(staged.stdout).toMatch(/M {2}readme\.md/);
+    expect(staged.stdout).toMatch(/A {2}new\.txt/);
+
+    const commit = await sandbox.exec('git commit -m "second change"');
+    expect(commit.exitCode).toBe(0);
+    expect(commit.stdout).toMatch(/second change/);
+
+    const log = await sandbox.exec('git log --oneline');
+    expect(log.stdout.trim().split('\n')).toHaveLength(2);
+    expect(log.stdout).toMatch(/second change/);
+    expect(log.stdout).toMatch(/initial/);
+  });
+
+  it('commit refuses when nothing is staged', async () => {
+    const r = await sandbox.exec('git commit -m "empty"');
+    expect(r.exitCode).not.toBe(0);
+    expect(r.stderr).toMatch(/nothing to commit/);
+  });
+
+  it('reset unstages and rm removes', async () => {
+    await zfs.promises.writeFile('/repo/readme.md', 'v2 with more bytes\n');
+    await sandbox.exec('git add readme.md');
+    await sandbox.exec('git reset readme.md');
+    const st = await sandbox.exec('git status');
+    expect(st.stdout).toMatch(/ M readme\.md/);
+
+    await sandbox.exec('git rm readme.md');
+    expect(await zfs.promises.exists('/repo/readme.md')).toBe(false);
+    const st2 = await sandbox.exec('git status');
+    expect(st2.stdout).toMatch(/D {2}readme\.md|D. readme\.md/);
+  });
+
+  it('diff variants: file, all, --staged', async () => {
+    await zfs.promises.writeFile('/repo/readme.md', 'v2 with more bytes\n');
+
+    const one = await sandbox.exec('git diff readme.md');
+    expect(one.stdout).toMatch(/-v1/);
+    expect(one.stdout).toMatch(/\+v2 with more bytes/);
+
+    const all = await sandbox.exec('git diff');
+    expect(all.stdout).toMatch(/readme\.md/);
+
+    const emptyStaged = await sandbox.exec('git diff --staged');
+    expect(emptyStaged.stdout.trim()).toBe('');
+
+    await sandbox.exec('git add readme.md');
+    const staged = await sandbox.exec('git diff --staged');
+    expect(staged.stdout).toMatch(/\+v2 with more bytes/);
+  });
+
+  it('branch and checkout -b work', async () => {
+    const main = await sandbox.exec('git branch');
+    expect(main.stdout).toMatch(/\* main/);
+
+    const co = await sandbox.exec('git checkout -b feature');
+    expect(co.exitCode).toBe(0);
+    const branches = await sandbox.exec('git branch');
+    expect(branches.stdout).toMatch(/\* feature/);
+    expect(branches.stdout).toMatch(/ {2}main/);
+
+    await sandbox.exec('git checkout main');
+    expect((await sandbox.exec('git branch')).stdout).toMatch(/\* main/);
+  });
+
+  it('config stores author identity', async () => {
+    await sandbox.exec('git config user.name Alice');
+    await sandbox.exec('git config user.email alice@example.com');
+    expect((await sandbox.exec('git config user.name')).stdout.trim()).toBe('Alice');
+    expect((await sandbox.exec('git config user.email')).stdout.trim()).toBe('alice@example.com');
+
+    await zfs.promises.writeFile('/repo/x.txt', 'x\n');
+    await sandbox.exec('git add x.txt');
+    await sandbox.exec('git commit -m "by alice"');
+    const log = await sandbox.exec('git log -n 1');
+    expect(log.stdout).toMatch(/Alice /);
+  });
+
+  it('rejects non-https clone URLs', async () => {
+    const r = await sandbox.exec('git clone http://example.com/a.git');
+    expect(r.exitCode).not.toBe(0);
+    expect(r.stderr).toMatch(/only https/);
+
+    const r2 = await sandbox.exec("git clone 'javascript:alert(1)'");
+    expect(r2.exitCode).not.toBe(0);
+  });
+});
+
+describe('createGitOps direct API', () => {
+  it('diffAll covers deletions and additions', async () => {
+    const ops = createGitOps(() => zfs);
+    await zfs.promises.rm('/repo/readme.md');
+    await zfs.promises.writeFile('/repo/added.txt', 'add\n');
+    const diff = await ops.diffAll('/repo');
+    expect(diff).toMatch(/-v1/);
+    expect(diff).toMatch(/\+add/);
+  });
+});
diff --git a/src/sandbox/git-command.ts b/src/sandbox/git-command.ts
new file mode 100644
index 0000000..9f3fa91
--- /dev/null
+++ b/src/sandbox/git-command.ts
@@ -0,0 +1,172 @@
+/**
+ * `git` as a just-bash custom command.
+ *
+ * Runs TRUSTED, in-page, delegating to lib/git.ts (isomorphic-git). Its
+ * network goes through the CORS proxy — deliberately outside just-bash's
+ * network firewall (see plan §5 Security Notes). Keep arguments validated.
+ */
+import { defineCommand } from 'just-bash/browser';
+import type { GitOps, GitStatusResult } from './git.js';
+import { getAuthor, setAuthor } from './git.js';
+import { persistenceEnabled, setPersistence } from './git-auth.js';
+
+const ok = (stdout: string) => ({ stdout, stderr: '', exitCode: 0 });
+const err = (stderr: string, exitCode = 1) => ({ stdout: '', stderr, exitCode });
+
+const USAGE =
+  'usage: git  [args]\n';
+
+function renderStatus({ branch, changes }: GitStatusResult): string {
+  const head = `On branch ${branch ?? '(detached)'}\n`;
+  if (changes.length === 0) return head + 'nothing to commit, working tree clean\n';
+  return head + changes.map((c) => `${c.code} ${c.filepath}`).join('\n') + '\n';
+}
+
+function renderLog(
+  commits: { oid: string; message: string; author: string; email: string; timestamp: number }[],
+  oneline: boolean,
+): string {
+  if (oneline) {
+    return commits.map((c) => `${c.oid.slice(0, 7)} ${c.message.split('\n')[0]}`).join('\n') + '\n';
+  }
+  return commits
+    .map((c) => {
+      const date = new Date(c.timestamp * 1000).toISOString();
+      return `commit ${c.oid}\nAuthor: ${c.author} <${c.email}>\nDate:   ${date}\n\n    ${c.message.trim().replace(/\n/g, '\n    ')}\n`;
+    })
+    .join('\n');
+}
+
+/** Only https:// git URLs are permitted (host-hook escape hatch stays narrow). */
+function assertHttpsUrl(url: string | undefined): string {
+  if (!url) throw new Error('missing repository URL');
+  let parsed: URL;
+  try {
+    parsed = new URL(url);
+  } catch {
+    throw new Error(`invalid URL: ${url}`);
+  }
+  if (parsed.protocol !== 'https:') {
+    throw new Error(`only https:// repository URLs are supported (got ${parsed.protocol}//)`);
+  }
+  return url;
+}
+
+function parseFlags(args: string[], flagsWithValue: string[] = []): { flags: Map; positional: string[] } {
+  const flags = new Map();
+  const positional: string[] = [];
+  for (let i = 0; i < args.length; i++) {
+    const a = args[i];
+    if (a.startsWith('-')) {
+      if (flagsWithValue.includes(a)) {
+        flags.set(a, args[++i] ?? '');
+      } else {
+        flags.set(a, '');
+      }
+    } else {
+      positional.push(a);
+    }
+  }
+  return { flags, positional };
+}
+
+async function handleConfig(rest: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
+  const [key, ...valueParts] = rest;
+  const value = valueParts.join(' ');
+  switch (key) {
+    case 'user.name':
+      if (!value) return ok(getAuthor().name + '\n');
+      setAuthor({ name: value });
+      return ok('');
+    case 'user.email':
+      if (!value) return ok(getAuthor().email + '\n');
+      setAuthor({ email: value });
+      return ok('');
+    case 'credential.persist':
+      if (!value) return ok(String(persistenceEnabled()) + '\n');
+      setPersistence(value === 'true');
+      return ok('');
+    default:
+      return err(`git config: supported keys: user.name, user.email, credential.persist\n`);
+  }
+}
+
+export const makeGitCommand = (gitOps: GitOps) =>
+  defineCommand('git', async (args, ctx) => {
+    const [sub, ...rest] = args;
+    const dir = ctx.cwd;
+    try {
+      switch (sub) {
+        case 'clone':
+          await gitOps.clone(assertHttpsUrl(rest[0]), dir);
+          return ok('Cloned.\n');
+        case 'status':
+          return ok(renderStatus(await gitOps.status(dir)));
+        case 'files':
+          return ok((await gitOps.listFiles(dir)).join('\n') + '\n');
+        case 'diff': {
+          const { flags, positional } = parseFlags(rest);
+          if (flags.has('--staged') || flags.has('--cached')) {
+            return ok(await gitOps.diffStaged(dir));
+          }
+          if (positional[0]) return ok(await gitOps.diff(positional[0], dir));
+          return ok(await gitOps.diffAll(dir));
+        }
+        case 'add': {
+          if (!rest[0]) return err('usage: git add |.\n');
+          const staged = await gitOps.add(rest[0], dir);
+          return ok(staged.length ? '' : 'nothing to add\n');
+        }
+        case 'reset':
+          if (!rest[0]) return err('usage: git reset \n');
+          await gitOps.reset(rest[0], dir);
+          return ok('');
+        case 'rm':
+          if (!rest[0]) return err('usage: git rm \n');
+          await gitOps.rm(rest[0], dir);
+          return ok('');
+        case 'commit': {
+          const { flags } = parseFlags(rest, ['-m']);
+          const message = flags.get('-m');
+          if (!message) return err('usage: git commit -m "message"\n');
+          const oid = await gitOps.commit(message, dir);
+          return ok(`[${oid.slice(0, 7)}] ${message}\n`);
+        }
+        case 'log': {
+          const { flags } = parseFlags(rest, ['-n']);
+          const depth = flags.has('-n') ? parseInt(flags.get('-n') ?? '', 10) || 10 : undefined;
+          const commits = await gitOps.log(dir, { depth });
+          return ok(renderLog(commits, flags.has('--oneline')));
+        }
+        case 'branch': {
+          const { flags } = parseFlags(rest);
+          const { current, branches } = await gitOps.branch(dir, { all: flags.has('-a') });
+          return ok(branches.map((b) => (b === current ? `* ${b}` : `  ${b}`)).join('\n') + '\n');
+        }
+        case 'checkout': {
+          const { flags, positional } = parseFlags(rest, ['-b']);
+          const create = flags.get('-b');
+          const ref = create || positional[0];
+          if (!ref) return err('usage: git checkout  | git checkout -b \n');
+          await gitOps.checkout(ref, dir, { create: Boolean(create) });
+          return ok(`Switched to ${create ? 'a new branch ' : ''}'${ref}'\n`);
+        }
+        case 'fetch':
+          await gitOps.fetch(dir);
+          return ok('Fetched.\n');
+        case 'pull':
+          await gitOps.pull(dir);
+          return ok('Already up to date or fast-forwarded.\n');
+        case 'push':
+          return ok((await gitOps.push(dir)) + '\n');
+        case 'config':
+          return handleConfig(rest);
+        case undefined:
+          return err(USAGE);
+        default:
+          return err(`git: '${sub}' is not supported.\n${USAGE}`);
+      }
+    } catch (e) {
+      return err(`git: ${(e as Error).message}\n`);
+    }
+  });
diff --git a/src/sandbox/git.ts b/src/sandbox/git.ts
new file mode 100644
index 0000000..cceb1c8
--- /dev/null
+++ b/src/sandbox/git.ts
@@ -0,0 +1,284 @@
+import git from 'isomorphic-git';
+import http from 'isomorphic-git/http/web';
+import { createTwoFilesPatch } from 'diff';
+import type { ZenFsLike } from './types.js';
+import { onAuthForUrl, onAuthFailureForUrl } from './git-auth.js';
+
+/**
+ * Git operations over a ZenFS-like fs (browser singleton by default; tests
+ * and the server pass their own via createGitOps). Network goes through a
+ * configurable CORS proxy — self-hostable since Phase 5.
+ */
+
+// Browser: a self-hosted git CORS proxy (the public cors.isomorphic-git.org
+// instance stalls indefinitely). Server: none — fetch there is not CORS-bound.
+// NEXT_PUBLIC_GIT_CORS_PROXY kept for artipod-sync compat; guarded so plain
+// browser bundles without a `process` shim don't crash at import.
+const DEFAULT_CORS_PROXY =
+  (typeof process !== 'undefined' ? process.env?.NEXT_PUBLIC_GIT_CORS_PROXY : undefined) ||
+  (typeof window === 'undefined' ? undefined : '/api/git');
+
+let corsProxy: string | undefined = DEFAULT_CORS_PROXY;
+export function setCorsProxy(url: string): void {
+  corsProxy = url;
+}
+export function getCorsProxy(): string | undefined {
+  return corsProxy;
+}
+
+// --- author configuration (localStorage in browser, memory elsewhere) -------
+
+const AUTHOR_KEY = 'artipod-sync-git-author';
+const memAuthor: { name: string; email: string } = {
+  name: 'artipod-sync',
+  email: 'artipod-sync@localhost',
+};
+
+export function getAuthor(): { name: string; email: string } {
+  if (typeof localStorage !== 'undefined') {
+    try {
+      const raw = localStorage.getItem(AUTHOR_KEY);
+      if (raw) return JSON.parse(raw);
+    } catch {
+      // fall through to memory default
+    }
+  }
+  return { ...memAuthor };
+}
+
+export function setAuthor(patch: Partial<{ name: string; email: string }>): void {
+  const next = { ...getAuthor(), ...patch };
+  memAuthor.name = next.name;
+  memAuthor.email = next.email;
+  if (typeof localStorage !== 'undefined') {
+    localStorage.setItem(AUTHOR_KEY, JSON.stringify(next));
+  }
+}
+
+// --- status ------------------------------------------------------------------
+
+export interface StatusEntry {
+  filepath: string;
+  /** Two-letter short-status code (index/workdir), '??' for untracked. */
+  code: string;
+}
+
+export interface GitStatusResult {
+  branch: string | null;
+  changes: StatusEntry[];
+}
+
+/** [head, workdir, stage] → short-status code. */
+const STATUS_CODES: Record = {
+  '020': '??',
+  '022': 'A ',
+  '023': 'AM',
+  '003': 'AD',
+  '121': ' M',
+  '122': 'M ',
+  '123': 'MM',
+  '101': ' D',
+  '100': 'D ',
+  '110': 'D?',
+};
+
+/** All ops take the repo dir; fs comes from the factory's getter. */
+export function createGitOps(getFs: () => ZenFsLike) {
+  const common = (dir: string) => ({ fs: getFs(), dir });
+  const network = (dir: string, url?: string) => ({
+    ...common(dir),
+    http,
+    corsProxy,
+    onAuth: (u: string) => onAuthForUrl(url ?? u),
+    onAuthFailure: (u: string) => {
+      onAuthFailureForUrl(url ?? u);
+    },
+  });
+
+  async function readHeadContent(dir: string, filepath: string): Promise {
+    try {
+      const commitOid = await git.resolveRef({ ...common(dir), ref: 'HEAD' });
+      const { blob } = await git.readBlob({ ...common(dir), oid: commitOid, filepath });
+      return new TextDecoder().decode(blob);
+    } catch {
+      return ''; // new file
+    }
+  }
+
+  async function readWorkContent(dir: string, filepath: string): Promise {
+    try {
+      return (await getFs().promises.readFile(`${dir}/${filepath}`, 'utf8')) as string;
+    } catch {
+      return ''; // deleted file
+    }
+  }
+
+  async function diffOne(dir: string, filepath: string): Promise {
+    const headContent = await readHeadContent(dir, filepath);
+    const workContent = await readWorkContent(dir, filepath);
+    return createTwoFilesPatch(`a/${filepath}`, `b/${filepath}`, headContent, workContent);
+  }
+
+  const ops = {
+    clone: async (url: string, dir: string) => {
+      await git.clone({ ...network(dir, url), url, singleBranch: true, depth: 1 });
+    },
+
+    status: async (dir: string): Promise => {
+      const branch = (await git.currentBranch({ ...common(dir), fullname: false })) ?? null;
+      const matrix = await git.statusMatrix(common(dir));
+      const changes = matrix
+        .filter(([, head, workdir, stage]) => !(head === 1 && workdir === 1 && stage === 1))
+        .map(([filepath, head, workdir, stage]) => ({
+          filepath,
+          code: STATUS_CODES[`${head}${workdir}${stage}`] ?? '??',
+        }));
+      return { branch, changes };
+    },
+
+    listFiles: async (dir: string) => git.listFiles(common(dir)),
+
+    diff: async (filepath: string, dir: string) => diffOne(dir, filepath),
+
+    /** All working-tree changes vs HEAD (git diff without args). */
+    diffAll: async (dir: string): Promise => {
+      const { changes } = await ops.status(dir);
+      const parts: string[] = [];
+      for (const { filepath } of changes) {
+        parts.push(await diffOne(dir, filepath));
+      }
+      return parts.join('');
+    },
+
+    /** Staged changes vs HEAD (git diff --staged). */
+    diffStaged: async (dir: string): Promise => {
+      const decoder = new TextDecoder();
+      // STAGE walker entries expose oid but not content() — read blobs by oid.
+      const blobText = async (oid: string | undefined): Promise => {
+        if (!oid) return '';
+        const { blob } = await git.readBlob({ ...common(dir), oid });
+        return decoder.decode(blob);
+      };
+      const results = await git.walk({
+        ...common(dir),
+        trees: [git.TREE({ ref: 'HEAD' }), git.STAGE()],
+        map: async (filepath, [head, stage]) => {
+          if (filepath === '.') return undefined;
+          if ((await head?.type()) === 'tree' || (await stage?.type()) === 'tree') return undefined;
+          const headOid = await head?.oid();
+          const stageOid = await stage?.oid();
+          if (headOid === stageOid) return undefined;
+          const headText = await blobText(headOid);
+          const stageText = await blobText(stageOid);
+          return createTwoFilesPatch(`a/${filepath}`, `b/${filepath}`, headText, stageText);
+        },
+      });
+      return (results as (string | undefined)[]).filter(Boolean).join('');
+    },
+
+    /** git add |. — stages modifications, additions and deletions. */
+    add: async (path: string, dir: string): Promise => {
+      const staged: string[] = [];
+      if (path === '.' || path === '-A' || path === '--all') {
+        const matrix = await git.statusMatrix(common(dir));
+        for (const [filepath, head, workdir] of matrix) {
+          if (head === 1 && workdir === 0) {
+            await git.remove({ ...common(dir), filepath });
+            staged.push(filepath);
+          } else if (workdir === 2) {
+            await git.add({ ...common(dir), filepath });
+            staged.push(filepath);
+          }
+        }
+      } else {
+        const exists = await getFs()
+          .promises.stat(`${dir}/${path}`)
+          .then(() => true)
+          .catch(() => false);
+        if (exists) {
+          await git.add({ ...common(dir), filepath: path });
+        } else {
+          await git.remove({ ...common(dir), filepath: path }); // stage a deletion
+        }
+        staged.push(path);
+      }
+      return staged;
+    },
+
+    /** git reset  — unstage (restore index entry to HEAD). */
+    reset: async (path: string, dir: string) => {
+      await git.resetIndex({ ...common(dir), filepath: path });
+    },
+
+    /** git rm  — remove from index and working tree. */
+    rm: async (path: string, dir: string) => {
+      await git.remove({ ...common(dir), filepath: path });
+      await getFs()
+        .promises.rm(`${dir}/${path}`)
+        .catch(() => undefined); // already gone is fine
+    },
+
+    commit: async (message: string, dir: string): Promise => {
+      const matrix = await git.statusMatrix(common(dir));
+      const hasStaged = matrix.some(([, head, , stage]) => !(head === 1 && stage === 1) && stage !== 0)
+        || matrix.some(([, head, , stage]) => head === 1 && stage === 0);
+      if (!hasStaged) throw new Error('nothing to commit (use "git add")');
+      return git.commit({ ...common(dir), message, author: getAuthor() });
+    },
+
+    log: async (dir: string, opts: { depth?: number } = {}) => {
+      const commits = await git.log({ ...common(dir), depth: opts.depth });
+      return commits.map((c) => ({
+        oid: c.oid,
+        message: c.commit.message,
+        author: c.commit.author.name,
+        email: c.commit.author.email,
+        timestamp: c.commit.author.timestamp,
+      }));
+    },
+
+    branch: async (dir: string, opts: { all?: boolean } = {}) => {
+      const current = (await git.currentBranch({ ...common(dir), fullname: false })) ?? null;
+      const local = await git.listBranches(common(dir));
+      let remote: string[] = [];
+      if (opts.all) {
+        remote = await git
+          .listBranches({ ...common(dir), remote: 'origin' })
+          .then((refs) => refs.filter((r) => r !== 'HEAD').map((r) => `remotes/origin/${r}`))
+          .catch(() => []);
+      }
+      return { current, branches: [...local, ...remote] };
+    },
+
+    checkout: async (ref: string, dir: string, opts: { create?: boolean } = {}) => {
+      if (opts.create) await git.branch({ ...common(dir), ref });
+      await git.checkout({ ...common(dir), ref });
+    },
+
+    fetch: async (dir: string) => {
+      const res = await git.fetch({ ...network(dir), singleBranch: false });
+      return res.fetchHead;
+    },
+
+    /** Fast-forward-only pull (merge commits are out of scope — documented). */
+    pull: async (dir: string) => {
+      await git.pull({
+        ...network(dir),
+        fastForwardOnly: true,
+        singleBranch: true,
+        author: getAuthor(),
+      });
+    },
+
+    push: async (dir: string): Promise => {
+      const res = await git.push(network(dir));
+      if (res.ok) return 'Push complete.';
+      throw new Error(res.error ?? 'push failed');
+    },
+  };
+  return ops;
+}
+
+export type GitOps = ReturnType;
+// NOTE: the app-level singleton (`gitOps` over a live fs binding) stays in the
+// consuming app — the package only ships the factory.
diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts
new file mode 100644
index 0000000..0749993
--- /dev/null
+++ b/src/sandbox/index.ts
@@ -0,0 +1,192 @@
+/**
+ * createSandbox() — the only public entry of the sandbox core.
+ *
+ * Wraps a just-bash Bash instance over a ZenFsAdapter. just-bash isolates
+ * state per exec() BY DESIGN (each call is "a new shell"); we reconstruct the
+ * session host-side:
+ *   - cwd    → recovered from result.env.PWD, passed as ExecOptions.cwd
+ *   - vars / exports / aliases (BASH_ALIAS_) → full env replay with
+ *     replaceEnv, so `unset` also sticks
+ *   - functions → known v1 limitation (just-bash does not retain function
+ *     source); documented in the terminal help text
+ *
+ * Framework-free: no React, no Next, no window. Works in browser and Node.
+ */
+import { Bash } from 'just-bash/browser';
+import type { BashOptions, CustomCommand } from 'just-bash/browser';
+import { createGitOps } from './git.js';
+import { reconcileProc } from '../proc/reconcile.js';
+import { refreshProc } from '../proc/snapshot.js';
+import { registerBuiltinProviders } from '../proc/storage-provider.js';
+import { makeEditCommand } from './edit-command.js';
+import { makeGitCommand } from './git-command.js';
+import { makeModuleCommands } from './module-command.js';
+import { makeNotesCommand } from './notes-command.js';
+import { makeStorageCommands } from './storage-command.js';
+import { makeSudoCommand } from './sudo-command.js';
+import type { PodEvents } from '../events.js';
+import type { CompletionResult, Sandbox, SandboxExecOptions, ZenFsLike } from './types.js';
+import { ZenFsAdapter } from './zenfs-adapter.js';
+
+export type { CompletionResult, Sandbox, SandboxExecOptions, SandboxExecResult, ZenFsLike } from './types.js';
+export { SHELL_NOTES } from './notes-command.js';
+export { ZenFsAdapter } from './zenfs-adapter.js';
+export { SUDO_DENIED_MESSAGE } from './sudo-command.js';
+// App-facing sandbox infrastructure: storage backends, git ops + auth.
+export * from './storage.js';
+export { createGitOps, getAuthor, setAuthor, setCorsProxy, getCorsProxy } from './git.js';
+export type { GitOps, GitStatusResult, StatusEntry } from './git.js';
+export {
+  setAuthPrompt,
+  persistenceEnabled,
+  setPersistence,
+  setToken,
+  getToken,
+  clearToken,
+  onAuthForUrl,
+  onAuthFailureForUrl,
+} from './git-auth.js';
+
+export interface CreateSandboxOptions {
+  /** The ZenFS node-like fs object backing the sandbox. */
+  zfs: ZenFsLike;
+  /** Host hook for the `edit` command (opens Monaco in the browser). */
+  onEdit?: (path: string) => void;
+  /** Pod event bus: exec:start/exec:end, coarse fs:changed, edit:request, approval:request. */
+  events?: PodEvents;
+  /** Initial working directory. Default: /repo */
+  cwd?: string;
+  /** Additional custom commands (server may add more; agents reuse as-is). */
+  extraCommands?: CustomCommand[];
+  /**
+   * Mount `/proc` and add lsmod/modinfo/modprobe: the snapshot is rebuilt from
+   * the registered providers before every command and reconciled after it.
+   */
+  proc?: boolean;
+  /**
+   * Host work to run around each non-transient command, e.g. materializing
+   * state into the filesystem. Returned messages are appended to stderr, so a
+   * hook reports a problem without taking the command down.
+   */
+  hooks?: {
+    beforeExec?: () => Promise | string[] | void;
+    afterExec?: () => Promise | string[] | void;
+  };
+  /** Tighter limits for server / agent use (defaults are sane for humans). */
+  executionLimits?: BashOptions['executionLimits'];
+  executionLimitProfile?: BashOptions['executionLimitProfile'];
+}
+
+const DEFAULT_CWD = '/repo';
+
+export function createSandbox(opts: CreateSandboxOptions): Sandbox {
+  const adapter = new ZenFsAdapter(opts.zfs);
+  const initialCwd = opts.cwd ?? DEFAULT_CWD;
+  if (opts.proc) registerBuiltinProviders();
+
+  const bash = new Bash({
+    fs: adapter,
+    cwd: initialCwd,
+    env: { HOME: initialCwd, USER: 'user', TERM: 'xterm-256color' },
+    executionLimits: opts.executionLimits,
+    executionLimitProfile: opts.executionLimitProfile,
+    customCommands: [
+      // git shares the sandbox's zfs — shell view and git view stay coherent
+      makeGitCommand(createGitOps(() => opts.zfs)),
+      makeEditCommand(opts.onEdit, opts.events),
+      makeNotesCommand(),
+      makeSudoCommand(opts.events),
+      ...makeStorageCommands(() => opts.zfs),
+      ...(opts.proc ? makeModuleCommands() : []),
+      ...(opts.extraCommands ?? []),
+    ],
+  });
+
+  let cwd = initialCwd;
+  // null until the first exec; afterwards the full env of the last line.
+  let carriedEnv: Record | null = null;
+  // Mirrored into BASH_HISTORY (JSON array) so the `history` builtin works.
+  const history: string[] = [];
+
+  // Interactive-shell semantics: aliases only expand with expand_aliases on,
+  // and shopt state is not carried in result.env — so prepend it every line.
+  const PRELUDE = 'shopt -s expand_aliases\n';
+
+  const runLine = (line: string, execOpts?: SandboxExecOptions) =>
+    bash.exec(PRELUDE + line, {
+      cwd,
+      env: { ...(carriedEnv ?? {}), BASH_HISTORY: JSON.stringify(history) },
+      ...(carriedEnv ? { replaceEnv: true } : {}),
+      ...(execOpts?.signal ? { signal: execOpts.signal } : {}),
+    });
+
+  const sandbox: Sandbox = {
+    async exec(line: string, execOpts?: SandboxExecOptions) {
+      // Transient execs (tab completion) must not disturb the snapshot.
+      const live = !execOpts?.transient;
+      const notices: string[] = [];
+      const startedAt = Date.now();
+      if (live) {
+        opts.events?.emit('exec:start', { line });
+        notices.push(...((await opts.hooks?.beforeExec?.()) ?? []));
+        if (opts.proc) notices.push(...(await refreshProc(opts.zfs)));
+        history.push(line);
+      }
+      const r = await runLine(line, execOpts);
+      if (live) {
+        if (r.env) {
+          const { BASH_HISTORY: _history, ...rest } = r.env;
+          carriedEnv = rest;
+        }
+        if (r.env?.PWD) cwd = r.env.PWD;
+        if (opts.proc) notices.push(...(await reconcileProc(opts.zfs)));
+        notices.push(...((await opts.hooks?.afterExec?.()) ?? []));
+        opts.events?.emit('exec:end', { line, exitCode: r.exitCode, durationMs: Date.now() - startedAt });
+        // Coarse by contract: a shell line can touch anything.
+        opts.events?.emit('fs:changed', { origin: 'exec' });
+      }
+      const stderr = notices.length ? `${r.stderr}${notices.join('\n')}\n` : r.stderr;
+      return { stdout: r.stdout, stderr, exitCode: r.exitCode };
+    },
+
+    getCwd: () => cwd,
+    getEnv: () => carriedEnv ?? {},
+
+    async complete(line: string): Promise {
+      const tokenMatch = line.match(/[^\s]*$/);
+      const token = tokenMatch ? tokenMatch[0] : '';
+      const replaceStart = line.length - token.length;
+      if (!token) return { candidates: [], replaceStart };
+
+      const before = line.slice(0, replaceStart).trimEnd();
+      const isCommandPos =
+        before === '' || /[|;&(]$|\$\($|&&$|\|\|$/.test(before) || before.endsWith('`');
+
+      const q = shQuote(token);
+      const script =
+        isCommandPos && !token.includes('/')
+          ? `compgen -A command ${q}; compgen -A alias ${q}; compgen -d ${q}`
+          : `compgen -f ${q}; compgen -d ${q}`;
+
+      const r = await this.exec(script, { transient: true });
+      const dirCheck = await this.exec(`compgen -d ${q}`, { transient: true });
+      const dirs = new Set(splitLines(dirCheck.stdout));
+
+      const candidates = Array.from(new Set(splitLines(r.stdout)))
+        .sort()
+        .map((c) => (dirs.has(c) ? `${c}/` : c));
+      return { candidates, replaceStart };
+    },
+
+    fs: adapter,
+    zfs: opts.zfs,
+  };
+  return sandbox;
+}
+
+const splitLines = (s: string) => s.split('\n').filter(Boolean);
+
+/** Single-quote a string for safe embedding in a bash line. */
+function shQuote(s: string): string {
+  return `'${s.replace(/'/g, `'\\''`)}'`;
+}
diff --git a/src/sandbox/module-command.ts b/src/sandbox/module-command.ts
new file mode 100644
index 0000000..23957a4
--- /dev/null
+++ b/src/sandbox/module-command.ts
@@ -0,0 +1,104 @@
+/**
+ * `lsmod`, `modinfo`, `modprobe` — the proc providers presented as kernel
+ * modules, because that is exactly what they are: named, versioned projections
+ * that can be loaded and unloaded while the shell runs.
+ *
+ * Registry-scoped: `modprobe` toggles an already-registered provider, it does
+ * not fetch code.
+ */
+import { defineCommand } from 'just-bash/browser';
+import type { ExecResult } from 'just-bash/browser';
+import { listProviders, getProvider, setProviderEnabled } from '../proc/registry.js';
+import { procEntries, procPathOf } from '../proc/snapshot.js';
+import { renderTable } from './table.js';
+
+const USAGE: Record = {
+  lsmod: `usage: lsmod
+
+List the registered /proc providers: name, mode, file count and load state.
+`,
+  modinfo: `usage: modinfo 
+
+Print a provider's description, version, mode and the files it projects.
+`,
+  modprobe: `usage: modprobe [-r] 
+
+Load (-r: unload) a /proc provider. An unloaded provider's files disappear
+from /proc at the next refresh; the provider itself stays registered.
+`,
+};
+
+const help = (name: string, args: string[]): ExecResult | null =>
+  args.includes('--help') || args.includes('-h')
+    ? { stdout: USAGE[name], stderr: '', exitCode: 0 }
+    : null;
+
+const err = (message: string): ExecResult => ({ stdout: '', stderr: `${message}\n`, exitCode: 1 });
+
+/** Files the last snapshot refresh wrote for a provider. */
+function filesOf(name: string): string[] {
+  const paths: string[] = [];
+  for (const [path, entry] of procEntries()) {
+    if (entry.provider.name === name) paths.push(path);
+  }
+  return paths.sort();
+}
+
+const makeLsmodCommand = () =>
+  defineCommand('lsmod', async (args) => {
+    const h = help('lsmod', args);
+    if (h) return h;
+    const rows = listProviders().map(({ provider, enabled }) => [
+      provider.name,
+      provider.mode,
+      String(filesOf(provider.name).length),
+      enabled ? 'Live' : 'Unloaded',
+    ]);
+    return {
+      stdout: renderTable(['Module', 'Mode', 'Files', 'State'], rows, [2]),
+      stderr: '',
+      exitCode: 0,
+    };
+  });
+
+const makeModinfoCommand = () =>
+  defineCommand('modinfo', async (args) => {
+    const h = help('modinfo', args);
+    if (h) return h;
+    const name = args[0];
+    if (!name) return err("modinfo: missing module name (try 'modinfo --help')");
+    const module = getProvider(name);
+    if (!module) return err(`modinfo: ERROR: Module ${name} not found.`);
+
+    const { provider, enabled } = module;
+    const files = filesOf(name);
+    const lines = [
+      `name:        ${provider.name}`,
+      `description: ${provider.description ?? '-'}`,
+      `version:     ${provider.version ?? '-'}`,
+      `mode:        ${provider.mode}`,
+      `state:       ${enabled ? 'Live' : 'Unloaded'}`,
+      `root:        ${procPathOf(provider, '').replace(/\/$/, '') || '/proc'}`,
+      ...files.map((path) => `file:        ${path}`),
+    ];
+    return { stdout: `${lines.join('\n')}\n`, stderr: '', exitCode: 0 };
+  });
+
+const makeModprobeCommand = () =>
+  defineCommand('modprobe', async (args) => {
+    const h = help('modprobe', args);
+    if (h) return h;
+    const remove = args.includes('-r') || args.includes('--remove');
+    const name = args.find((a) => !a.startsWith('-'));
+    if (!name) return err("modprobe: missing module name (try 'modprobe --help')");
+    if (!setProviderEnabled(name, !remove)) {
+      return err(`modprobe: FATAL: Module ${name} not found.`);
+    }
+    return { stdout: '', stderr: '', exitCode: 0 };
+  });
+
+export const makeModuleCommands = () => [
+  makeLsmodCommand(),
+  makeModinfoCommand(),
+  makeModprobeCommand(),
+];
diff --git a/src/sandbox/notes-command.ts b/src/sandbox/notes-command.ts
new file mode 100644
index 0000000..7cf4e0a
--- /dev/null
+++ b/src/sandbox/notes-command.ts
@@ -0,0 +1,51 @@
+/**
+ * `notes` — artipod-sync shell notes. The just-bash builtin `help` (command
+ * list) cannot be shadowed by a custom command, so session semantics live
+ * here and the terminal banner points at it.
+ */
+import { defineCommand } from 'just-bash/browser';
+
+export const SHELL_NOTES = `artipod-sync shell notes
+=========================
+This is just-bash (a bash interpreter in TypeScript) over a persistent
+in-browser filesystem (ZenFS). Files survive reloads.
+
+Session semantics — each line runs in a fresh shell; the host carries state:
+  * cwd, variables, exports and aliases persist across lines
+  * shell FUNCTIONS do NOT persist across lines (define and use them on
+    one line, or in a script file you run with 'bash file.sh')
+  * shopt/set -o changes do not persist across lines
+
+Interactive limits (no TTY): read -p/-s prompts, pagers (less) and editors
+(vim) do not work — use 'edit ' to open Monaco. gzip/zcat need
+node:zlib and are unavailable in the browser; plain 'tar' works.
+
+Extras:
+  edit       open the Monaco editor
+  git         clone/status/diff/files (isomorphic-git; https:// only)
+  df [-hTv] [--scan]
+                   storage usage. Size/Avail exist only on the 'origin' row:
+                   the browser gives ONE quota to the whole origin, shared by
+                   IndexedDB and OPFS, so per-device capacity is '-'. Used is
+                   a browser estimate (Chromium only); --scan walks the tree
+                   for exact bytes.
+  mount, findmnt, lsblk [-f], fdisk -l, diskutil list
+                   the same storage facts in other shapes. The origin quota
+                   is the 'disk'; IndexedDB and OPFS are its partitions.
+                   Each takes --help.
+  mount -t  , umount [-f] 
+                   attach or detach a backing device anywhere in the tree:
+                   -t memory, -t idb [-o store=NAME], -t opfs [-o dir=PATH].
+  lsmod, modinfo , modprobe [-r] 
+                   the /proc providers, which project host state into files.
+                   /proc is a snapshot: it is rebuilt before every command,
+                   and edits to a read-write provider's files are handed back
+                   to it after the command.
+  help             list all available commands
+  history          shell history (mirrors the terminal's arrow-key history)
+  Tab              complete commands, aliases and paths
+  Ctrl+C           cancel the running command
+`;
+
+export const makeNotesCommand = () =>
+  defineCommand('notes', async () => ({ stdout: SHELL_NOTES, stderr: '', exitCode: 0 }));
diff --git a/src/sandbox/sandbox.test.ts b/src/sandbox/sandbox.test.ts
new file mode 100644
index 0000000..2dbfc9b
--- /dev/null
+++ b/src/sandbox/sandbox.test.ts
@@ -0,0 +1,126 @@
+/**
+ * Sandbox session tests: bash semantics over ZenFS plus the host-side session
+ * reconstruction (cwd via result.env.PWD, env replay, BASH_ALIAS_ pinning).
+ */
+import { beforeEach, describe, expect, it } from 'vitest';
+import { configure, InMemory, fs as zfs, umount } from '@zenfs/core';
+import { createSandbox, type Sandbox } from './index.js';
+
+let sandbox: Sandbox;
+
+beforeEach(async () => {
+  try {
+    umount('/');
+  } catch {
+    // first run
+  }
+  await configure({ mounts: { '/': InMemory } });
+  await zfs.promises.mkdir('/repo');
+  sandbox = createSandbox({ zfs });
+});
+
+describe('sandbox bash semantics over ZenFS', () => {
+  it('runs pipelines, globs, vars and loops', async () => {
+    await zfs.promises.writeFile('/repo/a.md', 'one\ntwo\n');
+    await zfs.promises.writeFile('/repo/b.md', 'three\n');
+    await zfs.promises.writeFile('/repo/c.txt', 'x\n');
+
+    expect((await sandbox.exec('ls | wc -l')).stdout.trim()).toBe('3');
+    expect((await sandbox.exec('echo *.md')).stdout.trim()).toBe('a.md b.md');
+
+    const loop = await sandbox.exec('for f in *.md; do wc -l < "$f"; done');
+    expect(loop.stdout.replace(/\s+/g, ' ').trim()).toBe('2 1');
+
+    const redirect = await sandbox.exec('echo hello > out.txt && cat out.txt');
+    expect(redirect.stdout.trim()).toBe('hello');
+    expect(await zfs.promises.readFile('/repo/out.txt', 'utf8')).toBe('hello\n');
+  });
+
+  it('reports sensible errors for missing files', async () => {
+    const r = await sandbox.exec('cat missing.txt');
+    expect(r.exitCode).not.toBe(0);
+    expect(r.stderr).toMatch(/missing\.txt/);
+  });
+});
+
+describe('session state reconstruction across exec calls', () => {
+  it('persists cwd via result.env.PWD', async () => {
+    await zfs.promises.mkdir('/repo/sub');
+    await sandbox.exec('cd sub');
+    expect(sandbox.getCwd()).toBe('/repo/sub');
+    expect((await sandbox.exec('pwd')).stdout.trim()).toBe('/repo/sub');
+    await sandbox.exec('cd ..');
+    expect(sandbox.getCwd()).toBe('/repo');
+  });
+
+  it('persists variables and exports via env replay', async () => {
+    await sandbox.exec('X=5');
+    expect((await sandbox.exec('echo $X')).stdout.trim()).toBe('5');
+
+    await sandbox.exec('export Y=exported');
+    expect((await sandbox.exec('echo $Y')).stdout.trim()).toBe('exported');
+  });
+
+  it('keeps unset variables unset (replaceEnv replay)', async () => {
+    await sandbox.exec('Z=9');
+    await sandbox.exec('unset Z');
+    expect((await sandbox.exec('echo "[$Z]"')).stdout.trim()).toBe('[]');
+  });
+
+  it('pins the BASH_ALIAS_ env carry for aliases', async () => {
+    await sandbox.exec("alias ll='echo aliased'");
+    // implementation detail, not a documented contract — this test is the canary
+    expect(sandbox.getEnv()).toHaveProperty('BASH_ALIAS_ll');
+    expect((await sandbox.exec('ll')).stdout.trim()).toBe('aliased');
+  });
+
+  it('does not absorb state from transient execs (completion helpers)', async () => {
+    await zfs.promises.mkdir('/repo/tmp2');
+    await sandbox.exec('cd tmp2', { transient: true });
+    expect(sandbox.getCwd()).toBe('/repo');
+  });
+
+  it('supports cooperative cancellation via AbortSignal', async () => {
+    const controller = new AbortController();
+    const pending = sandbox.exec('while true; do :; done', { signal: controller.signal });
+    controller.abort();
+    const r = await pending.catch((e: Error) => ({ aborted: true, message: e.message }));
+    // Either shape is fine as long as the loop terminated promptly.
+    expect(r).toBeTruthy();
+  }, 10_000);
+});
+
+describe('human-shell polish (Phase 1.5)', () => {
+  it('mirrors executed lines into the history builtin', async () => {
+    await sandbox.exec('echo one');
+    await sandbox.exec('echo two');
+    const r = await sandbox.exec('history');
+    expect(r.stdout).toMatch(/echo one/);
+    expect(r.stdout).toMatch(/echo two/);
+  });
+
+  it('completes command names and aliases', async () => {
+    const cmds = await sandbox.complete('ech');
+    expect(cmds.candidates).toContain('echo');
+    expect(cmds.replaceStart).toBe(0);
+
+    await sandbox.exec("alias gsx='git status'");
+    const aliases = await sandbox.complete('gs');
+    expect(aliases.candidates).toContain('gsx');
+  });
+
+  it('completes file paths with directory markers', async () => {
+    await zfs.promises.mkdir('/repo/adir');
+    await zfs.promises.writeFile('/repo/afile.txt', 'x');
+    const r = await sandbox.complete('cat a');
+    expect(r.replaceStart).toBe(4);
+    expect(r.candidates).toContain('adir/');
+    expect(r.candidates).toContain('afile.txt');
+  });
+
+  it('completion execs are transient (no history/cwd pollution)', async () => {
+    await sandbox.complete('cat a');
+    const r = await sandbox.exec('history');
+    expect(r.stdout).not.toMatch(/compgen/);
+  });
+});
diff --git a/src/sandbox/storage-command.test.ts b/src/sandbox/storage-command.test.ts
new file mode 100644
index 0000000..8dd25d4
--- /dev/null
+++ b/src/sandbox/storage-command.test.ts
@@ -0,0 +1,144 @@
+/**
+ * `mount` / `df` over ZenFS. Node has no navigator.storage, so these tests
+ * cover the mount enumeration, the table layout and the --scan walk; the
+ * origin quota row only appears in a browser.
+ */
+import { beforeEach, describe, expect, it } from 'vitest';
+import { configure, InMemory, fs as zfs, umount } from '@zenfs/core';
+import { createSandbox, type Sandbox } from './index.js';
+
+let sandbox: Sandbox;
+
+beforeEach(async () => {
+  try {
+    umount('/');
+  } catch {
+    // first run
+  }
+  await configure({ mounts: { '/': InMemory } });
+  await zfs.promises.mkdir('/repo');
+  sandbox = createSandbox({ zfs });
+});
+
+describe('mount', () => {
+  it('lists the ZenFS mount points with their backend', async () => {
+    const r = await sandbox.exec('mount');
+    expect(r.exitCode).toBe(0);
+    expect(r.stdout.trim()).toBe('tmpfs on / type memory (rw)');
+  });
+
+  it('needs -t to mount: a bare source/target pair is not enough', async () => {
+    const r = await sandbox.exec('mount /dev/sda /mnt');
+    expect(r.exitCode).toBe(1);
+    expect(r.stderr).toMatch(/mount -t /);
+  });
+});
+
+describe('df', () => {
+  it('prints a df-shaped table with a row per device', async () => {
+    const r = await sandbox.exec('df');
+    expect(r.exitCode).toBe(0);
+    const [header, first] = r.stdout.trim().split('\n');
+    expect(header.split(/\s+/)).toEqual(['Filesystem', '1K-blocks', 'Used', 'Avail', 'Use%', 'Mounted', 'on']);
+    expect(first).toMatch(/^tmpfs\s/);
+    expect(first).toMatch(/\/$/);
+  });
+
+  it('reports exact apparent bytes with --scan', async () => {
+    await zfs.promises.writeFile('/repo/big.txt', 'x'.repeat(4096));
+    const r = await sandbox.exec('df --scan');
+    expect(r.stdout.split('\n')[1].split(/\s+/)[2]).toBe('4');
+  });
+
+  it('renders human sizes with -h', async () => {
+    await zfs.promises.writeFile('/repo/big.txt', 'x'.repeat(3 * 1024 * 1024));
+    const r = await sandbox.exec('df -h --scan');
+    expect(r.stdout).toMatch(/\s3M\s/);
+  });
+
+  it('ignores compatibility flags in a cluster, as real df does', async () => {
+    const plain = await sandbox.exec('df -h');
+    const noisy = await sandbox.exec('df -vhP');
+    expect(noisy.exitCode).toBe(0);
+    expect(noisy.stdout).toBe(plain.stdout);
+  });
+
+  it('adds a Type column with -T', async () => {
+    const r = await sandbox.exec('df -T');
+    expect(r.stdout.split('\n')[0].split(/\s+/).slice(0, 2)).toEqual(['Filesystem', 'Type']);
+    expect(r.stdout.split('\n')[1]).toMatch(/^tmpfs\s+memory\s/);
+  });
+
+  it('accepts a path and reports only its file system', async () => {
+    const r = await sandbox.exec('df --scan /repo');
+    expect(r.exitCode).toBe(0);
+    expect(r.stdout.trim().split('\n')).toHaveLength(2);
+  });
+
+  it('rejects unknown flags', async () => {
+    const r = await sandbox.exec('df -z');
+    expect(r.exitCode).toBe(2);
+    expect(r.stderr).toMatch(/unrecognized option '-z'/);
+  });
+
+  it('keeps -h for human sizes and --help for help', async () => {
+    const help = await sandbox.exec('df --help');
+    expect(help.exitCode).toBe(0);
+    expect(help.stdout).toMatch(/^usage: df /);
+    expect((await sandbox.exec('df -h')).stdout).toMatch(/^Filesystem/);
+  });
+});
+
+describe('block-device views', () => {
+  it('findmnt lists targets and sources', async () => {
+    const r = await sandbox.exec('findmnt');
+    expect(r.stdout.split('\n')[0].split(/\s+/)).toEqual(['TARGET', 'SOURCE', 'FSTYPE', 'OPTIONS']);
+    expect(r.stdout.split('\n')[1]).toMatch(/^\/\s+tmpfs\s+memory\s+rw$/);
+  });
+
+  it('lsblk shows the origin disk with the backends as partitions', async () => {
+    const r = await sandbox.exec('lsblk');
+    const lines = r.stdout.trim().split('\n');
+    expect(lines[0].split(/\s+/)).toEqual(['NAME', 'SIZE', 'TYPE', 'MOUNTPOINTS']);
+    expect(lines[1]).toMatch(/^origin\s/);
+    expect(lines[2]).toMatch(/^└─mem0\s+-\s+part\s+\/$/);
+  });
+
+  it('lsblk -f swaps in the filesystem columns', async () => {
+    const r = await sandbox.exec('lsblk -f');
+    expect(r.stdout.split('\n')[0].split(/\s+/)).toEqual([
+      'NAME',
+      'FSTYPE',
+      'LABEL',
+      'UUID',
+      'FSUSE%',
+      'MOUNTPOINTS',
+    ]);
+    // ZenFS gives every mount a real UUID.
+    expect(r.stdout).toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-/);
+  });
+
+  it('fdisk -l describes the quota as the disk', async () => {
+    const r = await sandbox.exec('fdisk -l');
+    expect(r.stdout).toMatch(/^Disk \/dev\/origin: /);
+    expect(r.stdout).toMatch(/\/dev\/mem0\s+memory/);
+    expect((await sandbox.exec('fdisk')).exitCode).toBe(1);
+  });
+
+  it('diskutil list prints the partition map', async () => {
+    const r = await sandbox.exec('diskutil list');
+    expect(r.stdout).toMatch(/^\/dev\/origin \(browser, shared quota\):/);
+    expect(r.stdout).toMatch(/1:\s+memory\s+tmpfs/);
+    expect((await sandbox.exec('diskutil info')).exitCode).toBe(1);
+  });
+
+  it('every view answers --help', async () => {
+    for (const name of ['mount', 'umount', 'findmnt', 'lsblk', 'fdisk', 'diskutil']) {
+      const long = await sandbox.exec(`${name} --help`);
+      const short = await sandbox.exec(`${name} -h`);
+      expect(long.exitCode, name).toBe(0);
+      expect(long.stdout, name).toMatch(new RegExp(`^usage: ${name}\\b`));
+      expect(short.stdout, name).toBe(long.stdout);
+    }
+  });
+});
diff --git a/src/sandbox/storage-command.ts b/src/sandbox/storage-command.ts
new file mode 100644
index 0000000..be2e3e8
--- /dev/null
+++ b/src/sandbox/storage-command.ts
@@ -0,0 +1,578 @@
+/**
+ * `mount`, `df`, `findmnt`, `lsblk`, `fdisk -l`, `diskutil list` — read-only
+ * views of the browser storage layer, all rendered from one `probeStorage()`.
+ *
+ * The block-device framing is deliberate and accurate: the ORIGIN is the disk
+ * (one quota shared by every backend) and IndexedDB / OPFS are partitions on
+ * it. So per-device rows report Used only; Size and Avail exist on the origin.
+ * Anything unknown prints `-` rather than a made up number.
+ *
+ * Used bytes come from `navigator.storage.estimate().usageDetails` (Chromium
+ * only) or, with `df --scan`, from walking the mounted tree.
+ */
+import { defineCommand } from 'just-bash/browser';
+import type { ExecResult } from 'just-bash/browser';
+import {
+  backendAt,
+  getStorageUsage,
+  mountBackend,
+  opfsDirBytes,
+  unmountBackend,
+  OPFS_FS_DIR,
+  OPFS_MODELS_DIR,
+  type MountSpec,
+} from './storage.js';
+import { renderTable } from './table.js';
+import type { ZenFsLike } from './types.js';
+
+export interface StorageDevice {
+  /** Block-device-ish name for the lsblk/fdisk views, e.g. `idb0`. */
+  name: string;
+  /** df's "Filesystem" column, e.g. `indexeddb:browser-git-fs`. */
+  source: string;
+  type: 'indexeddb' | 'opfs' | 'memory';
+  label: string;
+  uuid: string | null;
+  /** ZenFS mount point, or null for a device that exists but is not mounted. */
+  mountPoint: string | null;
+  options: string[];
+  /** Apparent bytes, or null when the browser won't tell us. */
+  used: number | null;
+}
+
+export interface StorageOrigin {
+  host: string;
+  usage: number;
+  quota: number;
+  persisted: boolean;
+}
+
+export interface StorageReport {
+  devices: StorageDevice[];
+  origin: StorageOrigin | null;
+}
+
+/**
+ * ZenFS names a StoreFS after its store, so an IndexedDB mount surfaces as its
+ * object-store name; only the in-memory and OPFS backends have fixed names.
+ */
+function deviceType(fsName: string): StorageDevice['type'] {
+  if (fsName === 'webaccessfs') return 'opfs';
+  if (fsName === 'tmpfs') return 'memory';
+  return 'indexeddb';
+}
+
+const NAME_PREFIX: Record = {
+  indexeddb: 'idb',
+  opfs: 'opfs',
+  memory: 'mem',
+};
+
+export async function probeStorage(opts: {
+  scan?: boolean;
+  zfs?: ZenFsLike;
+}): Promise {
+  const { mounts } = await import('@zenfs/core');
+  const usage = await getStorageUsage();
+  const modelBytes = await opfsDirBytes(OPFS_MODELS_DIR);
+
+  const devices: StorageDevice[] = [];
+  mounts.forEach((fs, mountPoint) => {
+    const type = deviceType(fs.name);
+    const label = type === 'opfs' ? OPFS_FS_DIR : fs.name;
+    devices.push({
+      name: '',
+      source: type === 'memory' ? 'tmpfs' : `${type}:${label}`,
+      type,
+      label,
+      uuid: fs.uuid ?? null,
+      mountPoint,
+      options: ['rw', ...(usage?.persisted ? ['persisted'] : [])],
+      used: backendBytes(type, usage?.details, modelBytes),
+    });
+  });
+  devices.sort((a, b) => (a.mountPoint ?? '').localeCompare(b.mountPoint ?? ''));
+
+  // Data left behind by the other backend still counts against the quota.
+  if (!devices.some((d) => d.type === 'opfs') && (await opfsDirBytes(OPFS_FS_DIR)) !== null) {
+    devices.push({
+      name: '',
+      source: `opfs:${OPFS_FS_DIR}`,
+      type: 'opfs',
+      label: OPFS_FS_DIR,
+      uuid: null,
+      mountPoint: null,
+      options: ['unmounted'],
+      used: backendBytes('opfs', usage?.details, modelBytes),
+    });
+  }
+
+  if (modelBytes !== null) {
+    devices.push({
+      name: '',
+      source: `opfs:${OPFS_MODELS_DIR}`,
+      type: 'opfs',
+      label: OPFS_MODELS_DIR,
+      uuid: null,
+      mountPoint: null,
+      options: ['ro', 'host'],
+      used: modelBytes,
+    });
+  }
+
+  const counters: Record = {};
+  for (const device of devices) {
+    const prefix = NAME_PREFIX[device.type];
+    device.name = `${prefix}${counters[prefix] ?? 0}`;
+    counters[prefix] = (counters[prefix] ?? 0) + 1;
+  }
+
+  if (opts.scan && opts.zfs) {
+    const zfs = opts.zfs;
+    const mountPoints = devices.map((d) => d.mountPoint).filter((p): p is string => p !== null);
+    for (const device of devices) {
+      if (device.mountPoint) device.used = await treeBytes(zfs, device.mountPoint, mountPoints);
+    }
+  }
+
+  return {
+    devices,
+    origin: usage
+      ? { host: typeof location === 'undefined' ? 'browser' : location.hostname, ...usage }
+      : null,
+  };
+}
+
+function backendBytes(
+  type: StorageDevice['type'],
+  details: Record | undefined,
+  modelBytes: number | null,
+): number | null {
+  if (type === 'memory') return null;
+  if (type === 'indexeddb') return details?.indexedDB ?? null;
+  const opfs = details?.fileSystem;
+  return opfs === undefined ? null : Math.max(0, opfs - (modelBytes ?? 0));
+}
+
+/** Apparent size of a subtree, skipping paths owned by a nested mount. */
+async function treeBytes(zfs: ZenFsLike, root: string, allMounts: string[]): Promise {
+  const nested = allMounts.filter((m) => m !== root && m.startsWith(root === '/' ? '/' : `${root}/`));
+  let bytes = 0;
+  const walk = async (dir: string): Promise => {
+    for (const name of await zfs.promises.readdir(dir)) {
+      const path = dir === '/' ? `/${name}` : `${dir}/${name}`;
+      if (nested.includes(path)) continue;
+      const st = await zfs.promises.lstat(path);
+      if (st.isDirectory()) await walk(path);
+      else bytes += st.size;
+    }
+  };
+  await walk(root);
+  return bytes;
+}
+
+const mounted = (devices: StorageDevice[]) => devices.filter((d) => d.mountPoint);
+
+const QUOTA_NOTE = `The browser gives ONE storage quota to the whole origin, shared by every
+backend, so per-device Size/Avail is reported as '-' and only the origin
+row carries real capacity. Used is a browser estimate (Chromium only).
+`;
+
+const USAGE: Record = {
+  mount: `usage: mount
+       mount -t  [-o ] 
+
+With no arguments, list the ZenFS mount points in /etc/mtab style.
+
+One VFS is *the* filesystem; IndexedDB and OPFS are interchangeable backing
+devices that can be attached anywhere in it, side by side.
+
+  -t memory              a fresh in-memory filesystem (tmpfs)
+  -t idb [-o store=NAME] an IndexedDB object store (default store: artipodfs)
+  -t opfs [-o dir=PATH]  the OPFS sandbox dir, or a subdirectory of it
+
+The mount point is created if it does not exist.
+`,
+  umount: `usage: umount [-f] 
+
+Detach a filesystem. / and /proc are never unmountable; a device holding
+persistent data (IndexedDB, OPFS) needs -f, since unmounting it hides data
+that is still there.
+`,
+  df: `usage: df [-h] [-k] [-T] [--scan] [path...]
+
+  -h        human-readable sizes
+  -k        1K blocks (default)
+  -T        add a filesystem Type column
+  --scan    walk the tree for exact Used bytes instead of the browser estimate
+  --help    this message
+
+-v -P -l -a --sync --no-sync --local are accepted and ignored, as in real df.
+
+${QUOTA_NOTE}`,
+  findmnt: `usage: findmnt
+
+List the mounted filesystems as TARGET / SOURCE / FSTYPE / OPTIONS.
+`,
+  lsblk: `usage: lsblk [-f]
+
+  -f   show FSTYPE, LABEL, UUID and FSUSE% instead of SIZE and TYPE
+
+The origin quota is the disk; each storage backend is a partition on it.
+${QUOTA_NOTE}`,
+  fdisk: `usage: fdisk -l
+
+Print the origin quota as a disk with each storage backend as a partition.
+Read-only: partitioning is not supported.
+${QUOTA_NOTE}`,
+  diskutil: `usage: diskutil list
+
+Print the origin quota and its storage backends in diskutil's format.
+Read-only: only 'list' is supported.
+${QUOTA_NOTE}`,
+};
+
+/** `--help` everywhere, plus `-h` where df hasn't already claimed it. */
+function helpFor(name: string, args: string[], shortFlag = true): ExecResult | null {
+  const flags = expandFlags(args);
+  if (flags.includes('--help') || (shortFlag && flags.includes('-h'))) {
+    return { stdout: USAGE[name], stderr: '', exitCode: 0 };
+  }
+  return null;
+}
+
+const MOUNT_TYPES: Record = {
+  memory: 'memory',
+  mem: 'memory',
+  tmpfs: 'memory',
+  idb: 'indexeddb',
+  indexeddb: 'indexeddb',
+  opfs: 'opfs',
+};
+
+/** `-o store=x,dir=y` → `{ store: 'x', dir: 'y' }`. */
+function parseMountOptions(raw: string | undefined): Record {
+  const out: Record = {};
+  for (const part of (raw ?? '').split(',').filter(Boolean)) {
+    const eq = part.indexOf('=');
+    out[eq < 0 ? part : part.slice(0, eq)] = eq < 0 ? '' : part.slice(eq + 1);
+  }
+  return out;
+}
+
+/** Mount points the shell cannot function without. */
+const PINNED_MOUNTS = new Set(['/', '/proc']);
+
+const makeMountCommand = () =>
+  defineCommand('mount', async (args, ctx) => {
+    const help = helpFor('mount', args);
+    if (help) return help;
+
+    if (!args.length) {
+      const { devices } = await probeStorage({});
+      const lines = mounted(devices).map(
+        (d) => `${d.source} on ${d.mountPoint} type ${d.type} (${d.options.join(',')})`,
+      );
+      return { stdout: `${lines.join('\n')}\n`, stderr: '', exitCode: 0 };
+    }
+
+    let type: string | undefined;
+    let options: string | undefined;
+    const rest: string[] = [];
+    for (let i = 0; i < args.length; i++) {
+      if (args[i] === '-t') type = args[++i];
+      else if (args[i] === '-o') options = args[++i];
+      else rest.push(args[i]);
+    }
+    if (!type || rest.length !== 1) {
+      return fail('mount', "usage: mount -t  [-o ]  (try 'mount --help')");
+    }
+    const backend = MOUNT_TYPES[type];
+    if (!backend) return fail('mount', `unknown filesystem type '${type}'`);
+
+    const { store, dir } = parseMountOptions(options);
+    const path = ctx.fs.resolvePath(ctx.cwd, rest[0]);
+    try {
+      await mountBackend(path, { type: backend, store: store || undefined, dir: dir || undefined });
+    } catch (e) {
+      return fail('mount', (e as Error).message);
+    }
+    return { stdout: '', stderr: '', exitCode: 0 };
+  });
+
+const makeUmountCommand = () =>
+  defineCommand('umount', async (args, ctx) => {
+    const help = helpFor('umount', args);
+    if (help) return help;
+    const flags = expandFlags(args);
+    const force = flags.includes('-f') || flags.includes('--force');
+    const target = flags.find((a) => !a.startsWith('-'));
+    if (!target) return fail('umount', "missing operand (try 'umount --help')");
+
+    const path = ctx.fs.resolvePath(ctx.cwd, target);
+    if (PINNED_MOUNTS.has(path)) return fail('umount', `${path}: cannot unmount`);
+
+    const backend = await backendAt(path);
+    if (!backend) return fail('umount', `${path}: not mounted`);
+    if (backend !== 'memory' && !force) {
+      return fail('umount', `${path}: holds persistent data on ${backend}; use -f to detach anyway`);
+    }
+    try {
+      await unmountBackend(path);
+    } catch (e) {
+      return fail('umount', (e as Error).message);
+    }
+    return { stdout: '', stderr: '', exitCode: 0 };
+  });
+
+const fail = (command: string, message: string): ExecResult => ({
+  stdout: '',
+  stderr: `${command}: ${message}\n`,
+  exitCode: 1,
+});
+
+/** Accepted and ignored, exactly as real df ignores them. */
+const DF_NOOP_FLAGS = new Set(['-v', '-P', '-l', '-a', '--all', '--sync', '--no-sync', '--local']);
+
+const makeDfCommand = (getZfs: () => ZenFsLike) =>
+  defineCommand('df', async (args, ctx) => {
+    const help = helpFor('df', args, false); // -h is human-readable, as in real df
+    if (help) return help;
+    let human = false;
+    let scan = false;
+    let showType = false;
+    const paths: string[] = [];
+    for (const arg of expandFlags(args)) {
+      if (DF_NOOP_FLAGS.has(arg)) continue;
+      else if (arg === '-h') human = true;
+      else if (arg === '-k') human = false;
+      else if (arg === '-T') showType = true;
+      else if (arg === '--scan') scan = true;
+      else if (arg.startsWith('-'))
+        return {
+          stdout: '',
+          stderr: `df: unrecognized option '${arg}'\nTry 'df --help' for more information.\n`,
+          exitCode: 2,
+        };
+      else paths.push(arg);
+    }
+
+    const report = await probeStorage({ scan, zfs: getZfs() });
+    let devices = report.devices;
+    if (paths.length) {
+      const wanted = new Set(
+        paths.map((p) => mountOf(ctx.fs.resolvePath(ctx.cwd, p), devices)).filter(Boolean),
+      );
+      devices = devices.filter((d) => d.mountPoint && wanted.has(d.mountPoint));
+      if (!devices.length) return { stdout: '', stderr: 'df: no matching file system\n', exitCode: 1 };
+    }
+
+    const withType = (row: string[], type: string) =>
+      showType ? [row[0], type, ...row.slice(1)] : row;
+
+    const rows = devices.map((d) =>
+      withType(
+        [d.source, '-', fmtSize(d.used, human), '-', '-', d.mountPoint ?? `(${d.options[0]})`],
+        d.type,
+      ),
+    );
+    if (!paths.length && report.origin) {
+      const { host, usage, quota } = report.origin;
+      rows.push(
+        withType(
+          [
+            `origin:${host}`,
+            fmtSize(quota, human),
+            fmtSize(usage, human),
+            fmtSize(Math.max(0, quota - usage), human),
+            pct(usage, quota),
+            '-',
+          ],
+          'quota',
+        ),
+      );
+    }
+
+    const header = withType(
+      ['Filesystem', human ? 'Size' : '1K-blocks', 'Used', 'Avail', 'Use%', 'Mounted on'],
+      'Type',
+    );
+    return {
+      stdout: renderTable(header, rows, showType ? [2, 3, 4, 5] : [1, 2, 3, 4]),
+      stderr: '',
+      exitCode: 0,
+    };
+  });
+
+const makeFindmntCommand = () =>
+  defineCommand('findmnt', async (args) => {
+    const help = helpFor('findmnt', args);
+    if (help) return help;
+    const { devices } = await probeStorage({});
+    const rows = mounted(devices).map((d) => [
+      d.mountPoint as string,
+      d.source,
+      d.type,
+      d.options.join(','),
+    ]);
+    return {
+      stdout: renderTable(['TARGET', 'SOURCE', 'FSTYPE', 'OPTIONS'], rows),
+      stderr: '',
+      exitCode: 0,
+    };
+  });
+
+const makeLsblkCommand = () =>
+  defineCommand('lsblk', async (args) => {
+    const help = helpFor('lsblk', args);
+    if (help) return help;
+    let fsMode = false;
+    for (const arg of expandFlags(args)) {
+      if (arg === '-f' || arg === '--fs') fsMode = true;
+      else return { stdout: '', stderr: USAGE.lsblk, exitCode: 2 };
+    }
+
+    const { devices, origin } = await probeStorage({});
+    const quota = origin?.quota;
+    const branch = (i: number) => (i === devices.length - 1 ? '└─' : '├─');
+
+    const header = fsMode
+      ? ['NAME', 'FSTYPE', 'LABEL', 'UUID', 'FSUSE%', 'MOUNTPOINTS']
+      : ['NAME', 'SIZE', 'TYPE', 'MOUNTPOINTS'];
+    const rows = [
+      fsMode
+        ? ['origin', 'quota', origin?.host ?? '-', '-', pct(origin?.usage ?? null, quota), '']
+        : ['origin', fmtSize(quota ?? null, true), 'disk', ''],
+      ...devices.map((d, i) =>
+        fsMode
+          ? [
+              `${branch(i)}${d.name}`,
+              d.type,
+              d.label,
+              d.uuid ?? '-',
+              pct(d.used, quota),
+              d.mountPoint ?? '',
+            ]
+          : [`${branch(i)}${d.name}`, '-', 'part', d.mountPoint ?? ''],
+      ),
+    ];
+    return { stdout: renderTable(header, rows), stderr: '', exitCode: 0 };
+  });
+
+const makeFdiskCommand = () =>
+  defineCommand('fdisk', async (args) => {
+    const help = helpFor('fdisk', args);
+    if (help) return help;
+    if (!args.includes('-l') && !args.includes('--list')) {
+      return {
+        stdout: '',
+        stderr: "fdisk: read-only view; try 'fdisk -l' or 'fdisk --help'\n",
+        exitCode: 1,
+      };
+    }
+    const { devices, origin } = await probeStorage({});
+    const size = origin ? `${fmtSize(origin.quota, true)}iB, ${origin.quota} bytes` : 'size unavailable';
+    const head = [
+      `Disk /dev/origin: ${size}`,
+      `Disk model: browser storage quota (${origin?.host ?? 'no browser'})`,
+      'Units: bytes — a quota is not a block device, so there are no sectors',
+      'Disklabel type: browser-storage (one quota shared by every partition)',
+      '',
+      '',
+    ].join('\n');
+    const rows = devices.map((d) => [
+      `/dev/${d.name}`,
+      d.type,
+      fmtSize(d.used, true),
+      d.mountPoint ?? '-',
+    ]);
+    return {
+      stdout: head + renderTable(['Device', 'Type', 'Used', 'Mountpoint'], rows, [2]),
+      stderr: '',
+      exitCode: 0,
+    };
+  });
+
+const makeDiskutilCommand = () =>
+  defineCommand('diskutil', async (args) => {
+    const help = helpFor('diskutil', args);
+    if (help) return help;
+    if (args[0] === 'help') return { stdout: USAGE.diskutil, stderr: '', exitCode: 0 };
+    if (args[0] !== 'list') {
+      return {
+        stdout: '',
+        stderr: "diskutil: read-only view; only 'diskutil list' is supported\n",
+        exitCode: 1,
+      };
+    }
+    const { devices, origin } = await probeStorage({});
+    const rows = [
+      [
+        '0:',
+        'browser_quota',
+        origin?.host ?? 'no browser',
+        fmtSize(origin?.quota ?? null, true),
+        'origin',
+      ],
+      ...devices.map((d, i) => [`${i + 1}:`, d.type, d.label, fmtSize(d.used, true), d.name]),
+    ];
+    const table = renderTable(['#:', 'TYPE', 'NAME', 'SIZE', 'IDENTIFIER'], rows, [0, 1, 3])
+      .replace(/^/gm, '   ')
+      .replace(/ +$/gm, '');
+    return {
+      stdout: `/dev/origin (browser, shared quota):\n${table}`,
+      stderr: '',
+      exitCode: 0,
+    };
+  });
+
+export const makeStorageCommands = (getZfs: () => ZenFsLike) => [
+  makeMountCommand(),
+  makeUmountCommand(),
+  makeDfCommand(getZfs),
+  makeFindmntCommand(),
+  makeLsblkCommand(),
+  makeFdiskCommand(),
+  makeDiskutilCommand(),
+];
+
+/** Expands clustered short flags so `df -vh` behaves like `df -v -h`. */
+function expandFlags(args: string[]): string[] {
+  const out: string[] = [];
+  for (const arg of args) {
+    if (/^-[^-]{2,}$/.test(arg)) out.push(...arg.slice(1).split('').map((c) => `-${c}`));
+    else out.push(arg);
+  }
+  return out;
+}
+
+/** Longest mount point that is a prefix of `path`. */
+function mountOf(path: string, devices: StorageDevice[]): string {
+  let best = '';
+  for (const d of devices) {
+    const m = d.mountPoint;
+    if (!m) continue;
+    if ((path === m || path.startsWith(m === '/' ? '/' : `${m}/`)) && m.length >= best.length) best = m;
+  }
+  return best;
+}
+
+function pct(used: number | null, quota: number | undefined): string {
+  if (used === null || !quota) return '-';
+  return `${Math.round((used / quota) * 100)}%`;
+}
+
+const UNITS = ['K', 'M', 'G', 'T', 'P'];
+
+function fmtSize(bytes: number | null, human: boolean): string {
+  if (bytes === null) return '-';
+  if (!human) return String(Math.ceil(bytes / 1024));
+  if (bytes === 0) return '0';
+  let value = bytes / 1024;
+  let unit = 0;
+  while (value >= 1024 && unit < UNITS.length - 1) {
+    value /= 1024;
+    unit++;
+  }
+  return `${value < 10 && value % 1 !== 0 ? value.toFixed(1) : Math.round(value)}${UNITS[unit]}`;
+}
diff --git a/src/sandbox/storage.test.ts b/src/sandbox/storage.test.ts
new file mode 100644
index 0000000..8bfcbed
--- /dev/null
+++ b/src/sandbox/storage.test.ts
@@ -0,0 +1,41 @@
+/**
+ * Migration copy/verify logic over live ZenFS mounts (memory→memory in Node;
+ * the same code path serves indexeddb↔opfs in the browser).
+ */
+import { beforeEach, describe, expect, it } from 'vitest';
+import { configure, InMemory, fs as zfs, umount } from '@zenfs/core';
+import { migrateStorage, type MigrationProgress } from './storage.js';
+
+beforeEach(async () => {
+  try {
+    umount('/');
+  } catch {
+    // first run
+  }
+  await configure({ mounts: { '/': InMemory } });
+});
+
+describe('migrateStorage', () => {
+  it('copies all files and directories and reports progress', async () => {
+    await zfs.promises.mkdir('/repo/src/deep', { recursive: true });
+    await zfs.promises.writeFile('/repo/readme.md', 'hello');
+    await zfs.promises.writeFile('/repo/src/a.ts', 'const a = 1;');
+    await zfs.promises.writeFile('/repo/src/deep/b.bin', new Uint8Array([1, 2, 3]));
+
+    const events: MigrationProgress[] = [];
+    const { files, bytes } = await migrateStorage('memory', (p) => events.push(p));
+
+    expect(files).toBe(3);
+    expect(bytes).toBe(5 + 12 + 3);
+    expect(events.length).toBe(3);
+    expect(events[events.length - 1].copied).toBe(3);
+    // migration mount must be gone again
+    expect(await zfs.promises.exists('/__migrate')).toBe(false);
+  });
+
+  it('handles an empty filesystem', async () => {
+    const { files, bytes } = await migrateStorage('memory');
+    expect(files).toBe(0);
+    expect(bytes).toBe(0);
+  });
+});
diff --git a/src/sandbox/storage.ts b/src/sandbox/storage.ts
new file mode 100644
index 0000000..b07031e
--- /dev/null
+++ b/src/sandbox/storage.ts
@@ -0,0 +1,399 @@
+/**
+ * Storage backend selection, detection, migration and the multi-tab guard.
+ *
+ * Browser-only (uses navigator/localStorage inside functions, never at module
+ * top level). ZenFS backends are dynamically imported so nothing heavy or
+ * DOM-bound lands in server bundles.
+ *
+ * Default stays IndexedDB until OPFS e2e is proven (plan §7 risk table);
+ * OPFS is opt-in via the settings UI, persisted in localStorage.
+ */
+
+import type { ZenFsLike } from './types.js';
+
+export type StorageBackend = 'opfs' | 'indexeddb' | 'memory';
+
+type MountConfig =
+  | import('@zenfs/core').MountConfiguration<(typeof import('@zenfs/core'))['InMemory']>
+  | import('@zenfs/core').MountConfiguration<(typeof import('@zenfs/dom'))['IndexedDB']>
+  | import('@zenfs/core').MountConfiguration<(typeof import('@zenfs/dom'))['WebAccess']>;
+
+const PREF_KEY = 'artipod-sync-storage-backend';
+const LOCK_NAME = 'artipod-sync-fs';
+/**
+ * The default IndexedDB object store. One VFS is *the* filesystem — IndexedDB
+ * and OPFS are interchangeable backing devices for it — so the store is named
+ * after the filesystem, not after git, which is only one of its consumers.
+ */
+export const IDB_STORE = 'artipodfs';
+/** Pre-`artipodfs` store name; read once at first boot, then never again. */
+export const LEGACY_IDB_STORE = 'browser-git-fs';
+const MIGRATE_MOUNT = '/__migrate';
+const LEGACY_MOUNT = '/__legacy';
+/** Sandbox subtree inside OPFS — siblings (artipod-models/) stay invisible to agents. */
+export const OPFS_FS_DIR = 'artipod-fs';
+export const OPFS_MODELS_DIR = 'artipod-models';
+
+export function loadBackendPref(): StorageBackend | null {
+  try {
+    const v = localStorage.getItem(PREF_KEY);
+    return v === 'opfs' || v === 'indexeddb' || v === 'memory' ? v : null;
+  } catch {
+    return null;
+  }
+}
+
+export function saveBackendPref(backend: StorageBackend): void {
+  if (typeof localStorage === 'undefined') return; // Node tests
+  localStorage.setItem(PREF_KEY, backend);
+}
+
+/** OPFS availability: API presence + a real handle probe (Safari private mode etc). */
+export async function supportsOpfs(): Promise {
+  try {
+    if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return false;
+    await navigator.storage.getDirectory();
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+async function mountConfigFor(backend: StorageBackend): Promise {
+  const { InMemory } = await import('@zenfs/core');
+  if (backend === 'memory') return { backend: InMemory };
+  const { IndexedDB, WebAccess } = await import('@zenfs/dom');
+  if (backend === 'opfs') {
+    return { backend: WebAccess, handle: await opfsSandboxDir() };
+  }
+  return { backend: IndexedDB, storeName: IDB_STORE };
+}
+
+/** What `mount -t  [-o ...]` resolves to. */
+export interface MountSpec {
+  type: StorageBackend;
+  /** IndexedDB object store. Default: `artipodfs`. */
+  store?: string;
+  /** OPFS subdirectory below the sandbox dir. Default: the sandbox dir itself. */
+  dir?: string;
+}
+
+async function mountConfigForSpec(spec: MountSpec): Promise {
+  const { InMemory } = await import('@zenfs/core');
+  if (spec.type === 'memory') return { backend: InMemory };
+  const { IndexedDB, WebAccess } = await import('@zenfs/dom');
+  if (spec.type === 'opfs') {
+    let handle = await opfsSandboxDir();
+    for (const segment of (spec.dir ?? '').split('/').filter(Boolean)) {
+      handle = await handle.getDirectoryHandle(segment, { create: true });
+    }
+    return { backend: WebAccess, handle };
+  }
+  return { backend: IndexedDB, storeName: spec.store ?? IDB_STORE };
+}
+
+/**
+ * Attaches a backing device at `path`. ZenFS only surfaces a mount point that
+ * already exists as a directory underneath, and the mkdir has to happen before
+ * the mount or it lands inside the new filesystem instead.
+ */
+export async function mountBackend(path: string, spec: MountSpec): Promise {
+  const { fs, mount, mounts, resolveMountConfig } = await import('@zenfs/core');
+  if (!path.startsWith('/')) throw new Error(`mount point must be absolute: ${path}`);
+  if (mounts.has(path)) throw new Error(`mount point is already in use: ${path}`);
+  const resolved = await resolveMountConfig(await mountConfigForSpec(spec));
+  if (path !== '/' && !(await fs.promises.exists(path))) {
+    await fs.promises.mkdir(path, { recursive: true });
+  }
+  mount(path, resolved);
+}
+
+export async function unmountBackend(path: string): Promise {
+  const { mounts, umount } = await import('@zenfs/core');
+  if (!mounts.has(path)) throw new Error(`not mounted: ${path}`);
+  umount(path);
+}
+
+/** The backend kind currently mounted at `path`, or null when nothing is. */
+export async function backendAt(path: string): Promise {
+  const { mounts } = await import('@zenfs/core');
+  const fs = mounts.get(path);
+  if (!fs) return null;
+  if (fs.name === 'webaccessfs') return 'opfs';
+  if (fs.name === 'tmpfs') return 'memory';
+  return 'indexeddb';
+}
+
+/**
+ * The sandbox mounts the artipod-fs/ SUBDIRECTORY, not the OPFS root, so
+ * model weights and other host data can live beside it out of agent reach.
+ * Data written by earlier builds (which mounted the root) is moved in once.
+ */
+async function opfsSandboxDir(): Promise {
+  const root = await navigator.storage.getDirectory();
+  const fsDir = await root.getDirectoryHandle(OPFS_FS_DIR, { create: true });
+  await moveLegacyRootEntries(root, fsDir);
+  return fsDir;
+}
+
+const KEEP_AT_ROOT = new Set([OPFS_FS_DIR, OPFS_MODELS_DIR]);
+
+async function listEntries(dir: FileSystemDirectoryHandle): Promise<[string, FileSystemHandle][]> {
+  const out: [string, FileSystemHandle][] = [];
+  const it = (dir as unknown as { entries(): AsyncIterableIterator<[string, FileSystemHandle]> }).entries();
+  for (;;) {
+    const { done, value } = await it.next();
+    if (done) break;
+    out.push(value);
+  }
+  return out;
+}
+
+async function copyHandle(
+  source: FileSystemHandle,
+  destParent: FileSystemDirectoryHandle,
+  name: string,
+): Promise {
+  if (source.kind === 'file') {
+    const file = await (source as FileSystemFileHandle).getFile();
+    const target = await destParent.getFileHandle(name, { create: true });
+    const writable = await target.createWritable();
+    await writable.write(file);
+    await writable.close();
+    return;
+  }
+  const destDir = await destParent.getDirectoryHandle(name, { create: true });
+  for (const [childName, child] of await listEntries(source as FileSystemDirectoryHandle)) {
+    await copyHandle(child, destDir, childName);
+  }
+}
+
+async function moveLegacyRootEntries(
+  root: FileSystemDirectoryHandle,
+  fsDir: FileSystemDirectoryHandle,
+): Promise {
+  for (const [name, handle] of await listEntries(root)) {
+    if (KEEP_AT_ROOT.has(name)) continue;
+    await copyHandle(handle, fsDir, name);
+    await root.removeEntry(name, { recursive: true });
+  }
+}
+
+export interface InitResult {
+  backend: StorageBackend;
+  /** False when another tab already owns the filesystem. Advisory: writes are not blocked. */
+  isPrimaryTab: boolean;
+  /**
+   * The node-like fs this init configured. Consumers MUST adopt this instance
+   * instead of importing '@zenfs/core' themselves — under file:/link installs
+   * the package resolves its own @zenfs/core copy, and two singletons means
+   * two disjoint filesystems.
+   */
+  zfs: ZenFsLike;
+}
+
+let releaseLock: (() => void) | null = null;
+
+/**
+ * Single-writer guard: two tabs on one store corrupt state (ZenFS instances
+ * don't sync). First tab holds a Web Lock until it closes; later tabs see
+ * isPrimaryTab=false and warn the user — nothing stops them writing.
+ */
+async function acquireSingleWriterLock(): Promise {
+  if (typeof navigator === 'undefined' || !navigator.locks) return true; // older browsers: no guard
+  if (releaseLock) return true;
+  return new Promise((resolve) => {
+    navigator.locks.request(LOCK_NAME, { ifAvailable: true }, (lock) => {
+      if (!lock) {
+        resolve(false);
+        return;
+      }
+      resolve(true);
+      // Hold the lock for the lifetime of the tab.
+      return new Promise((release) => {
+        releaseLock = release;
+      });
+    });
+  });
+}
+
+export async function initFileSystem(pref?: StorageBackend): Promise {
+  const requested = pref ?? loadBackendPref() ?? 'indexeddb';
+  const backend: StorageBackend =
+    requested === 'opfs' && !(await supportsOpfs()) ? 'indexeddb' : requested;
+
+  const isPrimaryTab = await acquireSingleWriterLock();
+
+  const { configure, fs } = await import('@zenfs/core');
+  try {
+    await configure({ mounts: { '/': await mountConfigFor(backend) } });
+  } catch (e) {
+    if (!(e instanceof Error) || !e.message.includes('Mount point is already in use')) {
+      throw e;
+    }
+  }
+  if (backend === 'indexeddb') await adoptLegacyStore();
+  if (!(await fs.promises.exists('/repo'))) {
+    await fs.promises.mkdir('/repo');
+  }
+  return { backend, isPrimaryTab, zfs: fs };
+}
+
+/**
+ * One-time `browser-git-fs` → `artipodfs` adoption. Only runs when the new
+ * store is untouched, so it can never overwrite live data, and the legacy store
+ * is never written to — after this the old name is dead.
+ */
+async function adoptLegacyStore(): Promise {
+  const { fs, mount, mounts, resolveMountConfig, umount } = await import('@zenfs/core');
+  const p = fs.promises;
+  if (mounts.has(LEGACY_MOUNT) || (await p.readdir('/')).length) return;
+
+  const { IndexedDB } = await import('@zenfs/dom');
+  await p.mkdir(LEGACY_MOUNT);
+  mount(LEGACY_MOUNT, await resolveMountConfig({ backend: IndexedDB, storeName: LEGACY_IDB_STORE }));
+  try {
+    if ((await p.readdir(LEGACY_MOUNT)).length) await copyTree(fs, LEGACY_MOUNT, '/');
+  } finally {
+    umount(LEGACY_MOUNT);
+    await p.rmdir(LEGACY_MOUNT).catch(() => undefined);
+  }
+}
+
+export interface MigrationProgress {
+  copied: number;
+  total: number;
+  currentPath: string;
+}
+
+/**
+ * Copy everything from the live backend into `to`, verify file count + bytes,
+ * flip the preference. Caller must reload the page afterwards.
+ */
+export async function migrateStorage(
+  to: StorageBackend,
+  onProgress?: (p: MigrationProgress) => void,
+): Promise<{ files: number; bytes: number }> {
+  const { fs, mount, resolveMountConfig, umount } = await import('@zenfs/core');
+
+  // Attach the target as a nested mount, then walk-copy (skipping the mount itself).
+  mount(MIGRATE_MOUNT, await resolveMountConfig(await mountConfigFor(to)));
+  try {
+    const result = await copyTree(fs, '/', MIGRATE_MOUNT, onProgress);
+    saveBackendPref(to);
+    return result;
+  } finally {
+    umount(MIGRATE_MOUNT);
+  }
+}
+
+type ZenFs = (typeof import('@zenfs/core'))['fs'];
+
+/**
+ * Walk-copy `from` → `to`, skipping nested mount points, then verify every
+ * file arrived at the same size. Shared by the backend migration and the
+ * legacy-store adoption.
+ */
+async function copyTree(
+  fs: ZenFs,
+  from: string,
+  to: string,
+  onProgress?: (p: MigrationProgress) => void,
+): Promise<{ files: number; bytes: number }> {
+  const { mounts } = await import('@zenfs/core');
+  const p = fs.promises;
+  const nested = [...mounts.keys()].filter((m) => m !== from && isBelow(m, from));
+
+  const files: string[] = [];
+  const dirs: string[] = [];
+  const walk = async (dir: string): Promise => {
+    for (const name of await p.readdir(dir)) {
+      const path = dir === '/' ? `/${name}` : `${dir}/${name}`;
+      if (nested.includes(path)) continue;
+      const st = await p.lstat(path);
+      if (st.isDirectory()) {
+        dirs.push(path);
+        await walk(path);
+      } else if (st.isFile()) {
+        files.push(path);
+      }
+      // symlinks: isomorphic-git materializes none by default; skip safely
+    }
+  };
+  await walk(from);
+
+  const target = (path: string) => {
+    const rel = from === '/' ? path : path.slice(from.length);
+    return to === '/' ? rel : `${to}${rel}`;
+  };
+
+  let copied = 0;
+  let bytes = 0;
+  for (const dir of dirs) {
+    await p.mkdir(target(dir), { recursive: true });
+  }
+  for (const file of files) {
+    const data = await p.readFile(file);
+    await p.writeFile(target(file), data);
+    bytes += data.byteLength;
+    copied++;
+    onProgress?.({ copied, total: files.length, currentPath: file });
+  }
+
+  for (const file of files) {
+    const st = await p.stat(target(file));
+    const src = await p.stat(file);
+    if (st.size !== src.size) {
+      throw new Error(`migration verify failed for ${file}: ${st.size} != ${src.size}`);
+    }
+  }
+  return { files: files.length, bytes };
+}
+
+const isBelow = (path: string, root: string) => path.startsWith(root === '/' ? '/' : `${root}/`);
+
+export interface StorageUsage {
+  usage: number;
+  quota: number;
+  persisted: boolean;
+  /** Chromium-only per-backend split (`indexedDB`, `fileSystem`, `caches`, ...). */
+  details: Record;
+}
+
+export async function getStorageUsage(): Promise {
+  if (typeof navigator === 'undefined' || !navigator.storage?.estimate) return null;
+  const estimate = (await navigator.storage.estimate()) as StorageEstimate & {
+    usageDetails?: Record;
+  };
+  const { usage = 0, quota = 0 } = estimate;
+  const persisted = (await navigator.storage.persisted?.()) ?? false;
+  return { usage, quota, persisted, details: estimate.usageDetails ?? {} };
+}
+
+/** Apparent bytes under a top-level OPFS directory, or null when unavailable. */
+export async function opfsDirBytes(name: string): Promise {
+  try {
+    if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return null;
+    const root = await navigator.storage.getDirectory();
+    const dir = await root.getDirectoryHandle(name); // no create: absent means "no such device"
+    return await sumDirBytes(dir);
+  } catch {
+    return null;
+  }
+}
+
+async function sumDirBytes(dir: FileSystemDirectoryHandle): Promise {
+  let bytes = 0;
+  for (const [, handle] of await listEntries(dir)) {
+    bytes +=
+      handle.kind === 'file'
+        ? (await (handle as FileSystemFileHandle).getFile()).size
+        : await sumDirBytes(handle as FileSystemDirectoryHandle);
+  }
+  return bytes;
+}
+
+export async function requestPersistence(): Promise {
+  if (typeof navigator === 'undefined' || !navigator.storage?.persist) return false;
+  return navigator.storage.persist();
+}
diff --git a/src/sandbox/sudo-command.test.ts b/src/sandbox/sudo-command.test.ts
new file mode 100644
index 0000000..2d6c37e
--- /dev/null
+++ b/src/sandbox/sudo-command.test.ts
@@ -0,0 +1,55 @@
+/**
+ * Agent-confinement stub tests (plan Phase 2, Decision #10): sudo is
+ * recognized, always denied with EPERM until Phase 6.5, never prompts, and
+ * surfaces the attempt as approval:request. docs/security-model.md is
+ * normative.
+ */
+import { beforeEach, describe, expect, it } from 'vitest';
+import { configure, InMemory, fs as zfs, umount } from '@zenfs/core';
+import { PodEvents } from '../events.js';
+import { createSandbox, SUDO_DENIED_MESSAGE, type Sandbox } from './index.js';
+
+let sandbox: Sandbox;
+let events: PodEvents;
+
+beforeEach(async () => {
+  try {
+    umount('/');
+  } catch {
+    // first run
+  }
+  await configure({ mounts: { '/': InMemory } });
+  await zfs.promises.mkdir('/repo');
+  events = new PodEvents();
+  sandbox = createSandbox({ zfs, events });
+});
+
+describe('sudo confinement stub', () => {
+  it('denies sudo with EPERM and the Phase 6.5 pointer', async () => {
+    const r = await sandbox.exec('sudo rm -rf /');
+    expect(r.exitCode).toBe(1);
+    expect(r.stderr).toContain('EPERM');
+    expect(r.stderr).toContain('Phase 6.5');
+    expect(r.stdout).toBe('');
+  });
+
+  it('denies bare sudo too — there is no interactive prompt to rubber-stamp', async () => {
+    const r = await sandbox.exec('sudo');
+    expect(r.exitCode).toBe(1);
+    expect(r.stderr).toBe(SUDO_DENIED_MESSAGE);
+  });
+
+  it('emits approval:request so hosts can observe the attempt', async () => {
+    const attempts: Array<{ verb: string; target?: string }> = [];
+    events.on('approval:request', (e) => attempts.push({ verb: e.verb, target: e.target }));
+    await sandbox.exec('sudo apt-get install nmap');
+    expect(attempts).toEqual([{ verb: 'sudo', target: 'apt-get install nmap' }]);
+  });
+
+  it('does not escape via pipelines or command substitution', async () => {
+    const r = await sandbox.exec('echo start && sudo whoami && echo end');
+    expect(r.stdout).toContain('start');
+    expect(r.stdout).not.toContain('end'); // && chain stops at the denial
+    expect(r.stderr).toContain('EPERM');
+  });
+});
diff --git a/src/sandbox/sudo-command.ts b/src/sandbox/sudo-command.ts
new file mode 100644
index 0000000..e662182
--- /dev/null
+++ b/src/sandbox/sudo-command.ts
@@ -0,0 +1,22 @@
+/**
+ * Agent-confinement stub (plan Phase 2; docs/security-model.md is normative):
+ * the pod boundary is the tool layer, and `sudo` is the only escape. Until
+ * the Phase 6.5 approval flow lands, every sudo is default-deny EPERM —
+ * agents can never self-approve. The attempt is surfaced via
+ * `approval:request` so hosts can already observe it.
+ */
+import { defineCommand } from 'just-bash/browser';
+import type { PodEvents } from '../events.js';
+
+export const SUDO_DENIED_MESSAGE =
+  'sudo: EPERM: privileged execution requires human approval — the approval flow lands in Phase 6.5 (docs/security-model.md)\n';
+
+export const makeSudoCommand = (events?: PodEvents) =>
+  defineCommand('sudo', async (args) => {
+    events?.emit('approval:request', {
+      verb: 'sudo',
+      target: args.join(' ') || undefined,
+      justification: 'phase-2 stub: default-deny, no approval flow yet',
+    });
+    return { stdout: '', stderr: SUDO_DENIED_MESSAGE, exitCode: 1 };
+  });
diff --git a/src/sandbox/table.ts b/src/sandbox/table.ts
new file mode 100644
index 0000000..a41c6ac
--- /dev/null
+++ b/src/sandbox/table.ts
@@ -0,0 +1,14 @@
+/** Shared column layout for the shell's table-shaped commands. */
+export function renderTable(
+  header: string[],
+  rows: string[][],
+  rightAligned: number[] = [],
+): string {
+  const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
+  const line = (cells: string[]) =>
+    cells
+      .map((c, i) => (rightAligned.includes(i) ? c.padStart(widths[i]) : c.padEnd(widths[i])))
+      .join(' ')
+      .trimEnd();
+  return `${[line(header), ...rows.map(line)].join('\n')}\n`;
+}
diff --git a/src/sandbox/types.ts b/src/sandbox/types.ts
new file mode 100644
index 0000000..c6a71f7
--- /dev/null
+++ b/src/sandbox/types.ts
@@ -0,0 +1,46 @@
+/**
+ * Shared types for the sandbox core.
+ *
+ * This module (and everything under lib/sandbox/) must stay framework-free:
+ * no React, no Next, no `window` at module top level. Type-only imports of
+ * @zenfs/core are erased at compile time, so this works in browser and Node.
+ */
+
+/** The node-like ZenFS `fs` object (or a bound context with the same shape). */
+export type ZenFsLike = (typeof import('@zenfs/core'))['fs'];
+
+export interface SandboxExecResult {
+  stdout: string;
+  stderr: string;
+  exitCode: number;
+}
+
+export interface SandboxExecOptions {
+  /** Cooperative cancellation (Ctrl+C, agent abort). */
+  signal?: AbortSignal;
+  /**
+   * When true, the result's env/cwd are NOT absorbed into the session and
+   * the line is not recorded in history. Used for hidden helper execs
+   * (e.g. tab-completion via compgen).
+   */
+  transient?: boolean;
+}
+
+export interface CompletionResult {
+  /** Sorted unique candidates; directories carry a trailing `/`. */
+  candidates: string[];
+  /** Index in the input line where the token being completed starts. */
+  replaceStart: number;
+}
+
+export interface Sandbox {
+  exec(line: string, opts?: SandboxExecOptions): Promise;
+  getCwd(): string;
+  /** The carried session environment (vars, exports, BASH_ALIAS_* entries). */
+  getEnv(): Readonly>;
+  /** Tab-completion for an input line (commands, aliases, paths). */
+  complete(line: string): Promise;
+  fs: import('./zenfs-adapter.js').ZenFsAdapter;
+  /** The raw node-like fs backing the sandbox (same store as `fs`). */
+  zfs: ZenFsLike;
+}
diff --git a/src/sandbox/zenfs-adapter.test.ts b/src/sandbox/zenfs-adapter.test.ts
new file mode 100644
index 0000000..024c99a
--- /dev/null
+++ b/src/sandbox/zenfs-adapter.test.ts
@@ -0,0 +1,139 @@
+/**
+ * IFileSystem contract tests for ZenFsAdapter, mirroring
+ * just-bash/src/fs/interface.contract.test.ts plus error-shape checks.
+ * Runs over an in-memory ZenFS in Node — same code path as the browser.
+ */
+import { beforeEach, describe, expect, it } from 'vitest';
+import { configure, InMemory, fs as zfs, umount } from '@zenfs/core';
+import { ZenFsAdapter } from './zenfs-adapter.js';
+
+async function freshAdapter(): Promise {
+  try {
+    umount('/');
+  } catch {
+    // first run: nothing mounted yet
+  }
+  await configure({ mounts: { '/': InMemory } });
+  return new ZenFsAdapter(zfs);
+}
+
+let fs: ZenFsAdapter;
+
+beforeEach(async () => {
+  fs = await freshAdapter();
+});
+
+describe('ZenFsAdapter IFileSystem contract', () => {
+  it('reads, writes, appends, stats, lists, and removes files', async () => {
+    await fs.mkdir('/docs', { recursive: true });
+    await fs.writeFile('/docs/readme.md', 'hello');
+    await fs.appendFile('/docs/readme.md', ' world');
+
+    expect(await fs.readFile('/docs/readme.md')).toBe('hello world');
+    expect(await fs.exists('/docs/readme.md')).toBe(true);
+    expect((await fs.stat('/docs/readme.md')).isFile).toBe(true);
+    expect(await fs.readdir('/docs')).toContain('readme.md');
+
+    await fs.rm('/docs/readme.md');
+    expect(await fs.exists('/docs/readme.md')).toBe(false);
+  });
+
+  it('copies and moves files without changing file contents', async () => {
+    await fs.mkdir('/tmp', { recursive: true });
+    await fs.writeFile('/tmp/source.txt', 'contents');
+    await fs.cp('/tmp/source.txt', '/tmp/copy.txt');
+    await fs.mv('/tmp/copy.txt', '/tmp/moved.txt');
+
+    expect(await fs.readFile('/tmp/source.txt')).toBe('contents');
+    expect(await fs.readFile('/tmp/moved.txt')).toBe('contents');
+    expect(await fs.exists('/tmp/copy.txt')).toBe(false);
+  });
+
+  it('copies directories only when recursive is set', async () => {
+    await fs.mkdir('/dir/sub', { recursive: true });
+    await fs.writeFile('/dir/sub/f.txt', 'x');
+
+    await expect(fs.cp('/dir', '/dir2')).rejects.toThrow();
+    await fs.cp('/dir', '/dir3', { recursive: true });
+    expect(await fs.readFile('/dir3/sub/f.txt')).toBe('x');
+  });
+
+  it('resolves relative paths consistently', () => {
+    expect(fs.resolvePath('/work', 'file.txt')).toBe('/work/file.txt');
+    expect(fs.resolvePath('/work', '../file.txt')).toBe('/file.txt');
+    expect(fs.resolvePath('/work', '/absolute.txt')).toBe('/absolute.txt');
+    expect(fs.resolvePath('/', 'a/./b/../c')).toBe('/a/c');
+    expect(fs.resolvePath('/a', '../../..')).toBe('/');
+  });
+
+  it('creates symlinks and keeps lstat/stat semantics apart', async () => {
+    await fs.writeFile('/target.txt', 'target');
+    await fs.symlink('/target.txt', '/link.txt');
+
+    expect(await fs.readlink('/link.txt')).toBe('/target.txt');
+    expect((await fs.lstat('/link.txt')).isSymbolicLink).toBe(true);
+    expect((await fs.stat('/link.txt')).isFile).toBe(true);
+    expect(await fs.readFile('/link.txt')).toBe('target');
+    expect(await fs.realpath('/link.txt')).toBe('/target.txt');
+  });
+
+  it('creates hard links sharing content', async () => {
+    await fs.writeFile('/orig.txt', 'data');
+    await fs.link('/orig.txt', '/hard.txt');
+    expect(await fs.readFile('/hard.txt')).toBe('data');
+  });
+
+  it('supports binary reads and buffer round-trips', async () => {
+    const bytes = new Uint8Array([0, 1, 2, 250, 255]);
+    await fs.writeFile('/bin.dat', bytes);
+    expect(Array.from(await fs.readFileBuffer('/bin.dat'))).toEqual([0, 1, 2, 250, 255]);
+  });
+
+  it('lists directory entries with file types', async () => {
+    await fs.mkdir('/d/sub', { recursive: true });
+    await fs.writeFile('/d/a.txt', 'A');
+    const entries = await fs.readdirWithFileTypes('/d');
+    const byName = Object.fromEntries(entries.map((e) => [e.name, e]));
+    expect(byName['sub'].isDirectory).toBe(true);
+    expect(byName['a.txt'].isFile).toBe(true);
+  });
+
+  it('chmod and utimes update metadata', async () => {
+    await fs.writeFile('/m.txt', 'm');
+    await fs.chmod('/m.txt', 0o755);
+    expect((await fs.stat('/m.txt')).mode & 0o777).toBe(0o755);
+
+    const when = new Date('2020-01-02T03:04:05Z');
+    await fs.utimes('/m.txt', when, when);
+    expect((await fs.stat('/m.txt')).mtime.getTime()).toBe(when.getTime());
+  });
+
+  it('exposes stable identity for cp/mv cycle detection', async () => {
+    await fs.writeFile('/id.txt', 'x');
+    const s = await fs.stat('/id.txt');
+    expect(s.identity).toBe(`${s.dev}:${s.ino}`);
+  });
+
+  it('rm honors force and recursive flags', async () => {
+    await expect(fs.rm('/nope')).rejects.toThrow();
+    await expect(fs.rm('/nope', { force: true })).resolves.toBeUndefined();
+
+    await fs.mkdir('/full');
+    await fs.writeFile('/full/f.txt', 'f');
+    await fs.rm('/full', { recursive: true });
+    expect(await fs.exists('/full')).toBe(false);
+  });
+
+  it('throws node-shaped errors just-bash can match on', async () => {
+    // ENOENT on missing file — message mentions the code, like node
+    await expect(fs.readFile('/missing.txt')).rejects.toThrow(/ENOENT/);
+    await expect(fs.stat('/missing.txt')).rejects.toThrow(/ENOENT/);
+    // EEXIST on mkdir over existing path
+    await fs.mkdir('/dup');
+    await expect(fs.mkdir('/dup')).rejects.toThrow(/EEXIST/);
+  });
+
+  it('getAllPaths degrades gracefully (documented ls-glob edge case)', () => {
+    expect(fs.getAllPaths()).toEqual([]);
+  });
+});
diff --git a/src/sandbox/zenfs-adapter.ts b/src/sandbox/zenfs-adapter.ts
new file mode 100644
index 0000000..325aed9
--- /dev/null
+++ b/src/sandbox/zenfs-adapter.ts
@@ -0,0 +1,195 @@
+/**
+ * ZenFsAdapter — bridges just-bash's IFileSystem contract onto the ZenFS
+ * node-like `fs.promises` API.
+ *
+ * ZenFS stays the single source of truth: the shell sees files through this
+ * adapter while isomorphic-git / Monaco / the file tree keep using the same
+ * ZenFS instance directly, so all views are always coherent.
+ *
+ * Errors are passed through unchanged: ZenFS throws node-shaped ErrnoErrors
+ * ("ENOENT: No such file or directory, open '/x'"), which is exactly what
+ * just-bash matches on.
+ */
+import type {
+  BufferEncoding as JbBufferEncoding,
+  CpOptions,
+  FsStat,
+  IFileSystem,
+  MkdirOptions,
+  RmOptions,
+} from 'just-bash/browser';
+import type { ZenFsLike } from './types.js';
+
+interface DirentEntry {
+  name: string;
+  isFile: boolean;
+  isDirectory: boolean;
+  isSymbolicLink: boolean;
+}
+
+type ReadOptions = { encoding?: JbBufferEncoding | null } | JbBufferEncoding;
+type WriteOptions = { encoding?: JbBufferEncoding } | JbBufferEncoding;
+
+function normalizeEncoding(options?: ReadOptions | WriteOptions): JbBufferEncoding | undefined {
+  if (!options) return undefined;
+  if (typeof options === 'string') return options;
+  return options.encoding ?? undefined;
+}
+
+/** Pure POSIX path normalization — no fs access (mirrors just-bash path-utils). */
+export function normalizePath(path: string): string {
+  if (!path || path === '/') return '/';
+  let normalized = path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
+  if (!normalized.startsWith('/')) normalized = `/${normalized}`;
+  const parts = normalized.split('/').filter((p) => p && p !== '.');
+  const resolved: string[] = [];
+  for (const part of parts) {
+    if (part === '..') resolved.pop();
+    else resolved.push(part);
+  }
+  return `/${resolved.join('/')}` || '/';
+}
+
+export class ZenFsAdapter implements IFileSystem {
+  constructor(private zfs: ZenFsLike) {}
+
+  async readFile(path: string, options?: ReadOptions): Promise {
+    const enc = normalizeEncoding(options) ?? 'utf8';
+    return (await this.zfs.promises.readFile(path, enc)) as string;
+  }
+
+  async readFileBuffer(path: string): Promise {
+    return new Uint8Array(await this.zfs.promises.readFile(path));
+  }
+
+  async writeFile(path: string, content: string | Uint8Array, options?: WriteOptions): Promise {
+    const enc = normalizeEncoding(options);
+    if (typeof content === 'string') {
+      await this.zfs.promises.writeFile(path, content, enc ?? 'utf8');
+    } else {
+      await this.zfs.promises.writeFile(path, content);
+    }
+  }
+
+  async appendFile(path: string, content: string | Uint8Array, options?: WriteOptions): Promise {
+    const enc = normalizeEncoding(options);
+    if (typeof content === 'string') {
+      await this.zfs.promises.appendFile(path, content, enc ?? 'utf8');
+    } else {
+      await this.zfs.promises.appendFile(path, content);
+    }
+  }
+
+  async exists(path: string): Promise {
+    try {
+      await this.zfs.promises.lstat(path);
+      return true;
+    } catch {
+      return false;
+    }
+  }
+
+  private toFsStat(s: {
+    isFile(): boolean;
+    isDirectory(): boolean;
+    isSymbolicLink(): boolean;
+    mode: number;
+    size: number;
+    mtime: Date;
+    dev: number | bigint;
+    ino: number | bigint;
+  }): FsStat {
+    return {
+      isFile: s.isFile(),
+      isDirectory: s.isDirectory(),
+      isSymbolicLink: s.isSymbolicLink(),
+      mode: s.mode,
+      size: s.size,
+      mtime: s.mtime,
+      dev: s.dev,
+      ino: s.ino,
+      identity: `${s.dev}:${s.ino}`,
+    };
+  }
+
+  async stat(path: string): Promise {
+    return this.toFsStat(await this.zfs.promises.stat(path));
+  }
+
+  async lstat(path: string): Promise {
+    return this.toFsStat(await this.zfs.promises.lstat(path));
+  }
+
+  async mkdir(path: string, options?: MkdirOptions): Promise {
+    await this.zfs.promises.mkdir(path, { recursive: options?.recursive ?? false });
+  }
+
+  async readdir(path: string): Promise {
+    return await this.zfs.promises.readdir(path);
+  }
+
+  async readdirWithFileTypes(path: string): Promise {
+    const dirents = await this.zfs.promises.readdir(path, { withFileTypes: true });
+    return dirents.map((d) => ({
+      name: d.name,
+      isFile: d.isFile(),
+      isDirectory: d.isDirectory(),
+      isSymbolicLink: d.isSymbolicLink(),
+    }));
+  }
+
+  async rm(path: string, options?: RmOptions): Promise {
+    await this.zfs.promises.rm(path, {
+      recursive: options?.recursive ?? false,
+      force: options?.force ?? false,
+    });
+  }
+
+  async cp(src: string, dest: string, options?: CpOptions): Promise {
+    const st = await this.zfs.promises.stat(src);
+    if (st.isDirectory() && !options?.recursive) {
+      throw new Error(`EISDIR: illegal operation on a directory, cp '${src}'`);
+    }
+    await this.zfs.promises.cp(src, dest, { recursive: options?.recursive ?? false });
+  }
+
+  async mv(src: string, dest: string): Promise {
+    await this.zfs.promises.rename(src, dest);
+  }
+
+  resolvePath(base: string, path: string): string {
+    if (path.startsWith('/')) return normalizePath(path);
+    const combined = base === '/' ? `/${path}` : `${base}/${path}`;
+    return normalizePath(combined);
+  }
+
+  // Only used by `ls` for unexpanded glob patterns; glob expansion itself is
+  // readdir-based. Returning [] degrades that one edge case gracefully.
+  getAllPaths(): string[] {
+    return [];
+  }
+
+  async chmod(path: string, mode: number): Promise {
+    await this.zfs.promises.chmod(path, mode);
+  }
+
+  async symlink(target: string, linkPath: string): Promise {
+    await this.zfs.promises.symlink(target, linkPath);
+  }
+
+  async link(existingPath: string, newPath: string): Promise {
+    await this.zfs.promises.link(existingPath, newPath);
+  }
+
+  async readlink(path: string): Promise {
+    return await this.zfs.promises.readlink(path, 'utf8');
+  }
+
+  async realpath(path: string): Promise {
+    return await this.zfs.promises.realpath(path);
+  }
+
+  async utimes(path: string, atime: Date, mtime: Date): Promise {
+    await this.zfs.promises.utimes(path, atime, mtime);
+  }
+}
diff --git a/src/tools/podTools.ts b/src/tools/podTools.ts
index c69f0b3..a4ee684 100644
--- a/src/tools/podTools.ts
+++ b/src/tools/podTools.ts
@@ -6,7 +6,7 @@
  */
 
 import { ArtiPod } from '../artipod.js';
-import { CommandResult } from '../docker/containerUtils.js';
+import type { CommandResult } from '../docker/containerUtils.js';
 import {
   ToolHandler,
   ToolName,
diff --git a/tsconfig.json b/tsconfig.json
index d47a0b4..a97d1d4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,7 +2,7 @@
   "compilerOptions": {
     "target": "ES2022",
     "module": "NodeNext",
-    "lib": ["ES2022"],
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
     "declaration": true,
     "declarationMap": true,
     "sourceMap": true,
diff --git a/vitest.config.ts b/vitest.config.ts
index af68b76..bb4687c 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -4,11 +4,11 @@ export default defineConfig({
   test: {
     globals: true,
     environment: 'node',
-    include: ['src/**/__tests__/**/*.spec.ts'],
+    include: ['src/**/__tests__/**/*.spec.ts', 'src/**/*.test.ts'],
     coverage: {
       provider: 'v8',
       include: ['src/**/*.ts'],
-      exclude: ['src/**/__tests__/**', 'src/**/*.spec.ts'],
+      exclude: ['src/**/__tests__/**', 'src/**/*.spec.ts', 'src/**/*.test.ts'],
       reportsDirectory: 'coverage',
       reporter: ['text', 'lcov', 'html'],
     },