- TypeScript 65.2%
- JavaScript 29%
- CSS 4.2%
- HTML 1%
- Mustache 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .zaneku | ||
| docs | ||
| eslint-rules | ||
| plugins | ||
| src | ||
| test | ||
| ui | ||
| .gitignore | ||
| .prettierignore | ||
| esbuild.config.mjs | ||
| eslint.config.js | ||
| package.json | ||
| prettier.config.js | ||
| README.md | ||
| tsconfig.json | ||
Zaneku
A web-based AI harness: plugin-first, provider-agnostic, permission-gated, and recoverable. The core is thin - it owns the agent loop, session/conversation state, the permission gate, and the tool registry/pipeline. Everything behavioral (tools, model providers, persistence, file-state versioning) is a plugin.
Why Zaneku?
"We needed a name. We generated random names until we found one that sounded pronounceable, wasn't obviously taken, and didn't mean anything in particular. Zaneku was the one."
Built with TypeScript + Node. One runtime dependency - mustache (the Web
UI's item/session-list templates are rendered from mustache at runtime); the
backend uses only Node built-ins and a hand-rolled zero-dependency WebSocket
server. typescript, @types/node, @types/mustache, esbuild, eslint,
prettier, and playwright (used only by the headless UI e2e) are dev-only.
Requirements
- Node.js >= 24 (global
fetch,WebSocket, ESM, built-innode:sqlite). - Git on your
PATH- only if you use thegitchange-management plugin (commit-per-turn, file-state undo/redo, fork branches). The core and all tools run fine without it.
Quick Start
npm install # runtime dep (mustache, the UI template renderer) + dev deps
npm run build # esbuild -> dist/
node dist/main.js
dist/ contains main.js (the single-file backend bundle, also the shared module every
plugin imports via the bare @zaneku/core specifier), ui/ (the Web UI -
index.html, app.js (a single-file IIFE bundle of ui/app.ts + the ui/src/*
modules it imports), and css/main.css bundled from ui/css/), and
plugins/<group>/<name>.js (one self-contained bundle per plugin, group layout preserved;
loaded at runtime from dist/plugins/, config pluginsDir, scanned recursively).
On success it prints:
Zaneku ready
UI: http://127.0.0.1:8765/
Plugins: sqlite-store, bash-tool, context-compaction, debug, echo-provider, edit-tool, git, openai-provider, read-tool, reference-plugin, test-provider, write-tool
Tools: bash, edit, read, write
Open http://127.0.0.1:8765/ in a browser to use the chat UI (create a session, send
prompts, approve ask-gated tool calls, /undo, /redo, fork). Stop with Ctrl+C
(SIGINT/SIGTERM) for a graceful shutdown.
Configuration
Settings are read from two JSON files that are deep-merged:
- User-global -
<home>/.config/zaneku/config.json(optional; skipped if absent). - Local project -
<repoRoot>/.zaneku/config.json(optional; skipped if absent).
The local file overwrites values from the global file; for lists, the local
values are appended to (extend) the global ones. Objects (e.g. permissions,
providers) merge recursively. Missing files are silently ignored; at least one
file is not required (built-in defaults apply).
Key fields:
| Field | Meaning | Default |
|---|---|---|
host, port |
Bind address for HTTP + WebSocket UI | 127.0.0.1, 8765 |
defaultModel |
Default model selection for new sessions, as provider/modelname |
- |
providers.openai.baseUrl |
OpenAI-compatible endpoint (LM Studio / vLLM / Ollama / real OpenAI) | http://localhost:1234/v1 |
providers.openai.model |
Model name for the openai provider |
- |
maxStepsPerTurn |
Step (model + tool-call) budget per turn; a turn aborts when exceeded | 50 |
permissions |
Per-tool allow/ask/deny rules (globs), default is ask |
see file |
git.name, git.email |
Identity used by the git plugin's commits | - |
pluginsDir, dbPath, uiDir |
Where the plugin bundles, the store DB file, and the UI assets are found | dist/plugins (scanned recursively; plugin files live in group subdirectories), dist/plugins, dist/ui |
store |
Name of the persistence backend to activate (key into the StoreRegistry). Persistence is a store plugin, not a core dependency - the shipped default is the sqlite store plugin. |
sqlite |
storePlugin |
Compiled module path of the store plugin to load before the regular plugin scan (explicit path, not discovered by the scan). The plugin self-registers under its name. |
<pluginsDir>/storages/sqlite-store.js (i.e. the bundled sqlite plugin) |
debug |
LLM-response debug logging (debug plugin): enabled turns it on at startup (also toggle at runtime with `/debug on |
off); file` overrides the log path. Off by default. |
A commented-out example of the step-budget override (config files are strict JSON - no comments - so this shows the key you would set on its own line):
{
// uncomment and edit to override the per-turn step budget (default 50)
"maxStepsPerTurn": 50
}
Environment overrides: ZANEKU_HOST, ZANEKU_PORT, ZANEKU_DEFAULT_MODEL.
The OpenAI provider also honors OPENAI_BASE_URL, OPENAI_API_KEY, and
ZANEKU_OPENAI_MODEL.
Using a real model (OpenAI-compatible)
In .zaneku/config.json (local or global), point providers.openai.baseUrl at any
OpenAI-compatible server (LM Studio, vLLM, Ollama):
"providers": { "openai": { "baseUrl": "http://localhost:1234/v1", "model": "google/gemma-4-12b-qat" } },
"defaultModel": "openai/google/gemma-4-12b-qat"
Model selection (provider/modelname)
A model is addressed as provider/modelname (split on the first slash). Set it:
- At session start - the UI/API
session:createaccepts a model spec. - At runtime - type
/model openai/google/gemma-4-12b-qatin chat, or/modelalone to see the current selection + available providers. Chat slash commands are registry-driven:/helplists every registered command (core + plugins, e.g./refinfofrom the reference plugin), and unknown slash words fall through to the model. - As a default -
defaultModelin.zaneku/config.json.
Per-turn precedence and the full provider/modelname addressing rules (split
on the first slash, bare-token disambiguation) are documented in
docs/03-05-model-providers.md (Model Addressing). Forks inherit their
source's model. Changing it never touches file state and applies from the next
turn.
Documentation
The full design documentation lives in docs/index.md, which
is a table of contents pointing to one file per topic. Start there:
- Current state - what is actually implemented vs. planned:
docs/state.md - Architecture overview:
docs/02-architecture.md - Agent loop (turns, steps, step budget & continue, model selection):
docs/03-01-agent-loop.md - Session model (forking, undo/redo halves, lifecycle):
docs/03-02-session-model.md - Model providers (
provider/modelnameaddressing, precedence):docs/03-05-model-providers.md - Web UI (module map, data flow, rendering model, WebSocket protocol, server):
docs/03-10-web-ui.md - Plugin API reference:
docs/05-plugin-api.md - Internationalization (i18n) (segments, lazy loading, language detection, plugin contract):
docs/09-i18n.md
Each docs/*.md file states its own status (implemented / planned) where it
matters; state.md is the single source of truth for "is this built?". The
rest of the component docs (hooks, plugins, tools, permissions, change
management, MCP, data model, scope, open questions, e2e test design) are linked
from docs/index.md.
Testing
npm test # = build + unit tests + backend e2e + UI e2e
npm run test:unit # unit only
npm run test:e2e # backend e2e only (live WebSocket protocol)
npm run test:ui # UI e2e only (headless browser)
All three are hermetic: they run on a dedicated deterministic test dummy
provider (plugins/providers/test-provider.ts) in a scratch workdir and need no
network or external model. Real endpoints are validated separately (ad-hoc), not
by the committed tests.
The backend e2e drives the live WebSocket + /api protocol against a spawned
dist/main.js. The UI e2e (test/ui-e2e.test.mjs) is headless: it drives the
actual compiled dist/ui in a browser against the real backend and asserts on
the visible DOM (session creation, turn streaming + the response rendering,
multi-session content, and switching back). It launches the system Chromium via
Playwright (executablePath: /usr/bin/chromium-bin); override with
CHROMIUM_BIN=/path/to/chromium. Requires playwright (dev dep - see
docs/08-end-to-end-tests.md for the full design).
Project layout
src/core/ core: agent loop, sessions, hooks, permissions, tool registry/pipeline, providers, MCP, WS server
src/main.ts bootstrap - wires everything together and starts the server
plugins/ one plugin per concern (TypeScript, compiled to dist/plugins/ at build time),
grouped by base class - the runtime loader discovers them recursively:
plugins/tools/ ToolPlugin: read-tool, edit-tool, write-tool, bash-tool (the standard tools)
plugins/providers/ ProviderPlugin: openai-provider (OpenAI-compatible), echo-provider (reference), test-provider (test dummy)
plugins/storages/ StoreProviderPlugin: sqlite-store (persistence backend)
plugins/hooks/ plain Plugin: reference-plugin (demo hooks/permissions/UI/command), git (file-state over git), context-compaction (context-window management), debug (LLM-response logging to debug.log, off by default)
ui/ Web UI - served by the core. Layered, observer-driven TypeScript:
`app.ts` (entry) + `ui/src/{app,controller,core,io,render,store}/` -
one way: server->WS->controller->store->observers->views;
click->actions->store/send. `types/` are pure .d.ts (protocol +
view models, no runtime bytes). `css/` is modular CSS (main.css
imports the group files incl. variables.css tokens). See
docs/03-10-web-ui.md for module-by-module responsibilities.
test/ test suites: zero-dep unit + backend e2e (live WS proto) + UI e2e
(headless Playwright over the real dist/ - see test/ui-e2e.test.mjs)
docs/ design docs (architecture, agent loop, tools, providers, plugin API, scope, state)
dist/ build output: main.js (single-file backend bundle), ui/ (Web UI assets:
index.html, app.js (single-file IIFE bundle of ui/app.ts + ui/src/*),
css/main.css bundled from ui/css/), plugins/ (one self-contained bundle
per plugin, same group layout)
How it fits together (one turn)
user message -> [turn_start hooks] -> build context (+compaction) -> select provider/model
-> provider.chat() streams normalized events (tokens / thinking / tool_calls / usage)
+- for each tool_call: permission gate (allow/ask/deny) -> tool_before -> execute
-> tool_after (can scrub/replace the result) -> append to context
-> final answer -> [turn_end hooks] -> file-state commit point (git plugin)
-> persist + advance conversation pointer -> stream events to the UI
(when the per-turn step budget trips, the turn is paused and can be resumed
with /continue or the inline "continue" button - no new user message)
Every internal step is a hook point; plugins attach behavior by overriding base-class
methods. The permission gate always runs before any tool executes, and tool_after can
rewrite what the model sees - e.g. scrubbing secrets out of file reads before they reach the LLM.