A custom coding agent built on top of @mariozechner/pi-coding-agent. Curates a fixed set of pi extensions, owns its own customization layer, and ships a single binary you control.
pi is great but the extension ecosystem has two pain points:
- Updates break customizations. Every
pi updaterolls extensions forward and can clobber any tweaks you made to make them work together. - Settings are scattered. Per-extension JSON,
~/.pi/agent/settings.json, project.pi/settings.json, env vars, CLI flags. No single source of truth.
qs solves both by being a thin Bun project that:
- Pins every extension as a normal
package.jsondependency. Updates are deliberatebun update <pkg>, never automatic. - Wraps each extension factory in a
customize()seam so your tweaks live in your config file, not in vendored source. They survive upstream updates. - Loads extensions as inline factories through
pi-coding-agent's SDK, with no filesystem discovery at all (no jiti recompile, no walks). All extensions are imported statically and bundled by Bun at build time. - Replaces
pi-fffentirely with a qs-owned override that uses@ff-labs/fff-nodedirectly with a sharedFileFinderper indexing root (kills the per-call respawn that was the actual perf bug). - Adds
find_files_allviafd --no-ignore --hiddenfor the "I want files insidenode_modules/.git" case that fff's binary refuses to do (gitignore is hardcoded into the Rust core; not a config option). - Provides one typed config file at
~/.config/qs/config.tsand a runtime patches dir at~/.config/qs/patches/for AI-written overrides. - Vendors your custom footer into
extensions/footer.tsso the bottom bar matches your existing pi setup.
| Extension | Source | Status |
|---|---|---|
pi-subagents |
npm | ✅ |
pi-subagents/notify |
npm | ✅ |
pi-web-access |
npm | ✅ |
@burneikis/pi-vim |
npm | |
pi-rewind |
npm | ✅ |
@aliou/pi-processes |
npm | |
pi-btw |
npm | |
pi-ask-user |
npm | |
@samfp/pi-memory |
npm | better-sqlite3 (Bun-incompatible) AND second-order import broken |
pi-free (11 providers) |
git: github:apmantza/pi-free | ✅ |
pi-collapse-tools |
npm | |
qs/extensions/fff.ts |
qs-owned | ✅ replaces upstream pi-fff |
qs/extensions/footer.ts |
qs-owned | ✅ vendored from ~/.pi/agent/extensions/footer.ts |
pi-coding-agent itself is a dependency, not vendored. You get core engine improvements automatically; only the extension ecosystem is locked.
~/qs/
package.json # all deps pinned, bun-only, no npm
tsconfig.json
bin/qs # bun shim entry point
src/
main.ts # boot: createAgentSessionRuntime + InteractiveMode
config.ts # typed QsConfig schema + loader
extensions.ts # registry, applies tool middleware + customize seam
tool-middleware.ts # Proxy wrapper that intercepts pi.registerTool
paths.ts # XDG-style ~/.config/qs, ~/.local/share/qs, ~/.cache/qs
patches.ts # discovers ~/.config/qs/patches/*.ts at runtime
qs-builtin.ts # /qs-doctor /qs-version /qs-paths /qs-init /qs-patches
types/extension-modules.d.ts # ambient declarations for npm .ts subpaths
extensions/ # ALL qs-owned extensions live here
_upstream.ts # @ts-nocheck shim, static imports of all npm extensions
fff.ts # qs's fff override (replaces pi-fff)
footer.ts # custom bottom bar
qs.config.example.ts # copy to ~/.config/qs/config.ts
dist/ # bun --compile output + runtime assets
~/.config/qs/
config.ts # your customizations (auto-loaded if present)
patches/ # *.ts files auto-loaded as inline extensions
~/.local/share/qs/
sessions/ # session history
auth.json # credentials
models.json # model registry
~/.cache/qs/
fff/ # fff frecency + history DBs
Why not vendor every extension's source? ~180 files. Updates would become manual diffs. Worst of all worlds.
Why not just use upstream pi with a config layer? That's what broke before. Updates rolled extensions forward and your tweaks died.
Why static imports in extensions/_upstream.ts instead of dynamic import()? Bun's --compile only bundles statically-resolvable imports. Earlier iterations used dynamic-string import(specifier) to dodge TypeScript's third-party type checking, but those weren't bundled into the binary, so the compiled qs failed to find extensions at runtime. Static imports + a // @ts-nocheck shim file + a filtered tsc script is the fix. Third-party .ts source still floods tsc with errors (each upstream extension was published against a different version of pi's internal types), but those errors are filtered out and don't affect runtime.
Why is fff hardcoded to respect .gitignore? Because @ff-labs/fff-node@0.5.2's InitOptions has no flag to disable it. The fff Rust binary enforces gitignore filtering. The README on dmtrKovalenko/fff.nvim confirms this. Two tools handle the split: find_files (fff, ranked, gitignored) and find_files_all (fd --no-ignore --hidden, slower on huge repos but actually sees everything).
Why ~/.local/share/qs/ instead of ~/.pi/? XDG Base Directory spec. Cleaner separation from pi's data so they don't share or fight over session files, auth, or settings.
Why a patches dir? When an upstream extension breaks under a new pi version, you (or AI) drop a .ts file into ~/.config/qs/patches/. It's loaded as an inline extension factory after the registry, so it can register tools with the same names (later registration wins) and override behavior without touching vendored source. No fork, no rebuild, no bun update collision. The "AI repatches it" use case from the spec.
cd ~/qs
bun install
bun run build # compiles dist/qs + copies runtime assets
./dist/qs # launchOr skip the binary and use the dev shim:
cd ~/qs
bun dev # bun --watch run src/main.ts~/qs/dist/qs # launch
> /qs-init # writes a starter ~/.config/qs/config.tsThen edit ~/.config/qs/config.ts. Anything you set there merges over DEFAULT_CONFIG from src/config.ts. The customize field gives you a typed (api: ExtensionAPI) => void callback per extension key, called after the upstream factory loads, so you can register additional tools, wrap commands, etc.
extensions: {
memory: false, // skip @samfp/pi-memory entirely
}tools: {
disable: ["bash", "subagent_v2"],
}tools: {
middleware: {
bash: async (ctx, next) => {
console.log("about to run bash:", ctx.params);
const result = await next();
console.log("bash done");
return result;
},
},
}The middleware proxy is wired through wrapApiWithPolicy in src/tool-middleware.ts. It intercepts pi.registerTool calls during factory execution and wraps the execute field. It applies to extension-registered tools — built-in bash/edit/write (passed via tools array to pi's runtime) are not yet middleware-wrapped (iteration 3 work).
mkdir -p ~/.config/qs/patches
cat > ~/.config/qs/patches/01-fix-broken-tool.ts <<'EOF'
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "broken_tool_name", // same name as upstream registers — overrides it
label: "fixed",
description: "qs replacement for a broken upstream tool",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "ok" }],
details: undefined,
}),
});
}
EOFRestart qs. /qs-patches will list it. /qs-doctor will show every loaded factory and tool name so you know what to patch.
| Command | Purpose |
|---|---|
/qs-doctor |
Full diagnostic: extensions loaded, tool inventory, paths, errors |
/qs-version |
qs version |
/qs-paths |
config, data, cache locations |
/qs-init |
scaffold a starter ~/.config/qs/config.ts |
/qs-patches |
list active runtime patches and patch errors |
-
Second-order module resolution failures. Several extensions fail to load at runtime with errors like:
pi-btw: Cannot find module '@mariozechner/pi-coding-agent' from '/Users/nicojaffer/qs/node_modules/pi-btw/extensions/btw.ts' pi-ask-user: same pi-collapse-tools: same @aliou/pi-processes: Cannot find '@mariozechner/pi-ai' @samfp/pi-memory: Cannot find '@sinclair/typebox'This happens because the static-import bundle in
extensions/_upstream.tsreaches the entry file, but those entriesimportpeer dependencies via bare specifiers that Bun resolves from the importing file's location (insidenode_modules/<pkg>/). The peer deps ARE installed at~/qs/node_modules/, but Bun's bundler isn't following them transitively for some reason — possibly because they're peerDependencies declared by the extension package without listing@mariozechner/pi-coding-agentin its owndependencies.Fix path for next iteration: add the peer deps explicitly to qs's
package.jsonso they hoist (@mariozechner/pi-ai,@sinclair/typeboxare already there but maybe not at top level — verify withls node_modules/@mariozechner/). Or use Bun's--externalto tell the bundler to keep them as runtime imports resolved from qs's ownnode_modules. Orbun patcheach broken extension to make their imports relative. -
@samfp/pi-memoryusesbetter-sqlite3which is not Bun-compatible. Bun runtime errors out onimport Database from "better-sqlite3". Fix:bun patch @samfp/pi-memoryto swapbetter-sqlite3→bun:sqlite(the API is mostly compatible — same prepared statements, same.run()/.all()/.get()). -
tsc --noEmitis noisy with third-party errors. Thetypecheckscript inpackage.jsonfilters them out via grep, but the IDE will still flag them. They don't affect runtime. The right long-term fix is project references or moving_upstream.tsto its own sub-tsconfig withnoCheck. -
Compiled binary needs runtime assets next to it.
bun run buildalready handles this via thecopy-assetsscript — pi's theme JSONs, photon WASM, export-html templates, and a stubpackage.jsonget copied todist/. If you rundist/qsand it ENOENTs, you forgot to runcopy-assets.
The Ralph loop is set up to keep iterating on this. Each iteration should ship one focused improvement.
Open work in priority order:
-
Fix the second-order resolution failures. This is the biggest functional gap — 5 extensions are dead at runtime. Investigation: run
bun pm ls @mariozechner/pi-coding-agentto see if it's hoisted; if not, force-hoist viapackage.jsonresolutions or workspaces. Trybun build --external @mariozechner/pi-coding-agent --external @mariozechner/pi-ai --external @sinclair/typeboxand verify that helps. If yes, bake those into the build script. -
bun patch @samfp/pi-memoryto make memory work on Bun. The patch is small: one import line. Dobun patch @samfp/pi-memory, editnode_modules/@samfp/pi-memory/src/store.tsto swapimport Database from "better-sqlite3"→import { Database } from "bun:sqlite", thenbun patch --commit @samfp/pi-memory. Bun will re-apply it on every install. Verifymemory_remember/memory_searchtools work end-to-end. -
Built-in tool middleware. The current middleware system only wraps extension-registered tools (via
wrapApiWithPolicy). Built-inbashTool/editTool/writeToolare passed directly to pi's runtime via thetoolsarray inmain.tsand are not wrapped. To fix: write awrapBuiltinTool(tool, middleware)that returns a newToolwhoseexecutecalls the middleware, and apply it inselectBuiltinTools(already insrc/dedup.tsthough now unused — wire it back). -
Hot reload. A
/qs-reloadcommand that re-runsloadConfig(), rebuilds the factories, and swaps them in without restart. Pi's runtime supports refresh viaruntime.refreshTools()— investigate whether full extension reload is possible or if it needs a session restart. -
Per-project config.
.qs/config.tsin cwd merges over~/.config/qs/config.ts. Useful for project-specific extension toggles (e.g., enable rust-analyzer wrapper only in Rust projects). -
Drop the binary, use a Bun shim. 69 MB compiled binary is overkill. Replace with
bin/qsshim that doescd $QS_ROOT && exec bun run src/main.ts "$@". Faster builds, no asset copy, same startup time. Or keep both and let users pick. -
Auto-discovery of
extensions/*.ts. Currently_upstream.tsandextensions.tshave a hardcoded REGISTRY. Useimport.meta.glob("./extensions/*.ts")(Bun supports it) so dropping a new file inextensions/auto-registers it. Lower the friction for "code my own agent" to one file create. -
Lockfile-aware updates.
bun update --interactiveper extension, with aqs-updatecommand that lists which extensions have available upgrades. Right now updates are ad-hoc. -
Skill / prompt template / theme passthroughs. qs only wires
extensionsfrom pi's resource loader. Skills, prompt templates, and themes are still loaded from pi's default discovery (~/.pi/agent/...). Mirror them into~/.config/qs/{skills,prompts,themes}/so qs is fully self-contained.
cd ~/qs
bun dev # watch mode dev launch
bun run build # compile + copy assets
bun typecheck # filtered tsc (qs-only errors)
./dist/qs # run compiled binary
bun pm ls --depth 1 # see installed deps
bun update <pkg> # bump one extension
bun patch <pkg> # start patching an upstream pkg
bun patch --commit <pkg> # save the patchextensions/is for all qs-owned extension source (custom + the static-import shim for upstream). One place. No moresrc/overrides/.src/is for qs runtime infrastructure only — config, paths, the wrapping system, the entry point, slash commands.- Bun-only. Never
npm install, nevernpm run. Thepackage.jsonscripts use bun. - No comments unless they encode a non-obvious why. No banners, no headers, no attribution.
- XDG paths.
~/.config/qs,~/.local/share/qs,~/.cache/qs. Never write to~/.pi.