handleSubmit's ~200-line if/return chain becomes a declarative
{test, run}[] list plus one dispatch loop. Same matching semantics
(exact vs. loose startsWith), same order (/permissions before /perm,
/model before /mode), same handler bodies — just uniform structure and
a single place the command set is registered. Handlers still close over
component state; extracting them into an independently testable module
is a larger follow-up.
Also drops a stale comment claiming Ollama can't report context length
for ":cloud" models — /api/show now returns it for every cloud model
tested (glm-5.2, qwen3.5, kimi-k2, minimax, gemma4).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
locode
An agentic coding CLI, in the spirit of Claude Code, for models running locally via Ollama or LM Studio. It talks to either backend's OpenAI-compatible /v1/chat/completions endpoint, so any model you can serve from either one works here.
Install
npm install
npm run build
npm link # makes the `locode` command available globally
Quick start
Make sure Ollama (ollama serve, default http://localhost:11434) or LM Studio (with a model loaded, default http://localhost:1234) is running, then:
locode --model qwen3-coder:30b
If you omit --model, locode lists the models available from the backend and lets you pick one with the arrow keys (Enter to confirm). Your saved default (via config or $LOCODE_MODEL) is pre-highlighted.
Pass --model explicitly to skip the picker entirely. Or persist your defaults so you don't need flags every time:
locode config set backend ollama
locode config set model qwen3-coder:30b
locode
List models available from the configured backend:
locode models
Every conversation is auto-saved as you go. Resume later:
locode --continue # resume the most recent conversation
locode --resume # pick from a list of saved conversations
locode --resume <id> # resume a specific one
locode sessions list # see saved conversations (id, model, title) without starting the UI
locode sessions rm <id> # delete a saved conversation
locode can also use tools from external MCP servers:
locode mcp add my-server --command npx --arg -y --arg @some/mcp-server # stdio server
locode mcp add my-remote --url https://example.com/mcp # remote (streamable HTTP) server
locode mcp list # see configured servers (user-level + project .mcp.json)
locode mcp remove <name>
Project-level servers can also be checked into a repo via a .mcp.json file in its root:
{
"mcpServers": {
"my-server": { "command": "npx", "args": ["-y", "@some/mcp-server"] }
}
}
locode connects to every configured server on startup; their tools show up alongside the built-in ones, namespaced as mcp__<server>__<tool>.
locode can also install Claude Code plugins directly — the parts that map onto locode's own architecture, anyway (see below for what doesn't):
locode plugin add ./path/to/plugin # a local plugin directory
locode plugin add https://github.com/someone/some-plugin.git # cloned via git
locode plugin list
locode plugin remove <name>
Hooks (see below) are hand-edited JSON rather than added via a CLI subcommand:
locode hooks path # print the user-level hooks.json path to edit
locode hooks list # see every configured hook (plugin + user + project), merged
How it works
locode is a full-screen terminal app built with Ink (the same React-for-CLI framework Claude Code itself is built with) — it needs a real interactive terminal (piped/redirected input isn't supported). It takes over the terminal's alternate screen buffer (like vim/htop) — your prior scrollback is restored when you exit. The input box is always pinned to the last row of the window; the conversation fills the space above it and old messages scroll off the top as new ones arrive. Press Ctrl+C or type /exit to quit.
-
Ctrl+O: print the full text of the last
/compact(or auto-compact) summary. The collapsed notice you see right after compacting only shows a one-line hint — press Ctrl+O any time afterward to print the whole thing. -
Ctrl+B: while a
bashcommand is running, detaches it into the background and returns control to you immediately — the turn continues with abash_output-checkable job id instead of waiting for the command to finish. A notice appears in the transcript once the backgrounded command actually completes. Onlybashsupports this today. The model can kill a still-running backgrounded job withbash_kill; any jobs still running when locode itself exits are killed too, so they don't outlive the process as orphans. -
Ctrl+F: open and focus a file panel docked to the right of the chat (hidden by default); press again to close it. It has two tabs — Files, the project's collapsible file tree (directories in cyan, same
node_modules/.git/distexclusions as@mentions), and Activity, the filesread_file/write_file/edit_filehave touched so far this session, most recent first, with a status glyph (·read,+written,~edited) and a repeat count. Ctrl+G switches between the two tabs. While the panel is focused,↑/↓move the selection (auto-scrolling to keep it in view),↵/←/→expand or collapse the selected folder, and Esc hands keyboard focus back to the chat input without closing the panel — typing is disabled while the panel has focus, so the same arrow key doesn't simultaneously recall chat history. -
Backends:
--backend ollama(default) or--backend lmstudio, or--base-url <url>for anything else that speaks the same API. -
Tools:
read_file,list_files,grep,definition,references,diagnostics,web_search,web_fetch,git_status,bash_output,todo_write,task_create,task_list,task_get,task_updaterun automatically.write_file,edit_file,multi_edit,bash,bash_kill, andgit_commitshow a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running. -
Permission modes:
default(ask before every mutating tool),plan(research only — every mutating tool is blocked outright, no prompt; the model is expected to describe what it would do in its final answer instead),auto-edit(file edits auto-approved,bash/git_commitstill ask),auto-accept(everything auto-approved — use with care). Cycle withShift+Tabor set directly with/perm <mode>. -
Structured tasks: for multi-step work the model can call
task_create/task_list/task_get/task_updateto track units of work with a dependency graph (blocks/blockedBy), ownership (owner), status, and free-form metadata — created incrementally rather than replaced wholesale. The older flattodo_writelive checklist (☐/◐/☑) remains for simpler cases. -
Project instructions: a
CLAUDE.md(orAGENTS.md) file in the project root is automatically read at session start and folded into the system prompt — put repo-specific conventions there and every session picks them up without being told. -
Images:
read_filereturns image files (png, jpg, jpeg, gif, webp, bmp — up to 5MB) as actual image content instead of trying to decode them as text, so vision-capable models can see them when the model itself calls the tool. To attach a file or image to your own message directly, use/import <path> [caption]. -
@file mentions: type@in the chat input to open a fuzzy file picker (searches the whole project, skippingnode_modules/.git/dist) — keep typing to filter,↑/↓to navigate,Tab(orEnter) to insert the highlighted path. Any@pathleft in your message when you hitEnterfor real is resolved against disk and attached to that message (text inlined, images attached as image content) — a stray@that isn't an actual file (e.g. an email address) is left as plain text. -
Git:
git_statuscovers read-only inspection (status,diff,log,show,branches) and runs automatically.git_commitcoversadd,commit,create_branch,checkout, andpush— each shows the actual diff/status/commits it's about to affect before you confirm (e.g. a commit's preview is the staged diff plus the message, a push's preview is the list of commits it would send). -
Sub-agents: the
agenttool lets the model delegate a self-contained task to a fresh, isolated tool loop (same tools, minusagentitself — no nested sub-agents) and get back only the final answer, keeping the main conversation's context focused. It shows up as a single⏺ Agent(description)/⎿ Sub-agent finished (...)line — the sub-agent's own intermediate steps aren't displayed. Any mutating tool calls it makes still go through the same permission prompts as the main conversation. -
MCP servers: locode connects to any MCP servers configured via
locode mcp addor a project's.mcp.json(stdio and remote/streamable-HTTP transports), and adds their tools to every session, namespaced asmcp__<server>__<tool>. Every MCP tool is treated as mutating (confirmation required on every call) regardless of what it reports — the MCPreadOnlyHintannotation is advisory and could be wrong (or set by a malicious server specifically to skip confirmation), so locode never trusts it. One misconfigured server doesn't block the others — check/mcpfor per-server connection status. -
Claude Code plugins:
locode plugin add <path-or-git-url>installs a Claude Code-compatible plugin — locode reads its.claude-plugin/plugin.json, then loads it all directly: MCP servers (its.mcp.jsonor manifestmcpServers, merged in like any other MCP server), slash commands (commands/*.md— frontmatterdescription/argument-hint, body is a template expanded with$ARGUMENTS/$1..$9and submitted as your message), agents (agents/*.md— the body becomes a sub-agent's system prompt, exposed as a callable tool namedagent__<plugin>__<agent>; atools:frontmatter list restricts what it can use, with Claude Code's built-in tool names — Read, Grep, Edit, etc. — automatically mapped to locode's equivalents), hooks (hooks/hooks.json, see below), and skills (skills/*/SKILL.md, see below). Check/pluginsfor what's loaded. -
Skills: named instructions the model loads on demand rather than a hook or a sub-agent — every installed skill (
skills/<name>/SKILL.md) is exposed through one sharedskilltool, whose own description lists every skill's name and "use this when..." blurb so the model knows when to call it. You can also invoke one directly with/<skill-name> [request], which skips the model's own judgment and submits the skill's instructions (plus your request, if any) as the turn. Check/skillsfor what's installed. -
Hooks: shell commands that fire on session lifecycle events —
SessionStart,UserPromptSubmit,PreToolUse,PostToolUse,PermissionRequest,SubagentStart,SubagentStop,CwdChanged,FileChanged,ConfigChange,Stop,SessionEnd. Configured the same way MCP servers are — plugin-bundled (hooks/hooks.json), user-level (locode hooks path, hand-edited), and project-level (.locode/hooks.json) all merge together, every hook from every source runs. A hook receives a JSON payload on stdin (session_id,cwd,hook_event_name, plus event-specific fields likepromptortool_name/tool_input); exit 0 allows (stdout becomes injected context forSessionStart/UserPromptSubmit), exit 2 blocks (stderr is the reason shown), anything else is a non-blocking warning.PreToolUse,UserPromptSubmit, andPermissionRequestcan block; the rest are fire-and-forget.PreToolUsefires before permission modes apply, so a hook's block can't be bypassed by auto-accept.commandandhttphook types run an external process/request; aprompthook just injects its staticmessageas additional context instead. Command/http hooks can opt into structured JSON output viaoutputSchema: "json".SessionEndfires on every exit path (including Ctrl+C and externalSIGTERM/SIGHUP). Check/hooksfor what's configured. -
Tool-calling mode: on connect, locode probes whether the model reliably uses native OpenAI-style function calling. If not, it switches to a prompt-based fallback mode where the model is instructed to emit tool calls as fenced
```tool_call ```JSON blocks, which locode parses itself. The result is cached per backend+model so future sessions skip the probe. Override with--tool-mode native|fallback|autoor the in-session/modecommand. -
Context tracking & compaction: the status bar shows
ctx NN%— context window usage, from realusage.prompt_tokenswhen the backend reports it (requested viastream_options.include_usage), or a~-prefixed char-based estimate otherwise. The window size itself is auto-detected (Ollama's/api/show, then LM Studio's/api/v0/models) and cached per backend+model; falls back to a configurable default (locode config set contextWindow <n>, or$LOCODE_CONTEXT_WINDOW) if neither responds. At 85% usage, locode automatically asks the model to summarize the conversation and replaces the history with that summary (a notice tells you when this happens) — or trigger it yourself anytime with/compact. -
Code intelligence (LSP):
definition,references, anddiagnosticsuse a real language server (LSP) for go-to-definition, find-all-references, and type/syntax error checks — the same engine an editor's Problems panel uses, more precise thangrep. A server is lazily started per language on first use and reused for the whole session:typescript-language-server(TypeScript/JavaScript),pyright-langserver(Python),gopls(Go),rust-analyzer(Rust), andclangd(C/C++ — one clangd covers both). The relevant server binary must be on your PATH; if it isn't, the tool returns a clear "install X" error. After any edit, locode syncs the file to the live server so a subsequentdiagnosticscall reflects the change (it waits for the server to publish fresh diagnostics rather than reading a stale snapshot). Add or override servers withlocode config set lspServers(see Config).
Note: even models with genuine native tool-calling support occasionally emit a tool call as plain text instead of a real structured call — this is model sampling variance, not a bug. If a turn seems to "describe" a tool call instead of running it, just ask again or try /mode fallback.
Slash commands
/model <name> switch the model used for the current backend
/backend <name> switch backend (ollama | lmstudio), keeps current model
/mode <name> view or force tool-call mode (native | fallback)
/mouse [on|off] toggle mouse tracking (on by default; hold Shift+click/drag for native text selection)
/perm [mode] cycle or set permission mode (default | plan | auto-edit | auto-accept)
/status show current model, backend, tool-call mode, and cwd
/dashboard show session stats: token I/O, elapsed/model time, turns, tool calls
/tools list available tools
/permissions list mutating tools allowed for the rest of this session
/sessions list saved conversations you can resume with --resume
/mcp show connected MCP servers and their tool counts
/plugins show installed Claude Code-compatible plugins (commands, agents, skills, MCP servers)
/hooks show configured hooks per lifecycle event
/skills show installed skills; /<skill-name> [request] invokes one directly
/compact summarize the conversation now to free up context
/export [file] save the conversation as markdown (or /export json [file] for a full JSON dump incl. tool calls/results)
/import <path> [caption] attach a local file or image to your next message
/clear clear conversation history
/help show this help
/exit, /quit exit
Config
Config precedence: CLI flags > env vars (LOCODE_BACKEND, LOCODE_MODEL, LOCODE_BASE_URL, LOCODE_CONTEXT_WINDOW, LOCODE_MAX_ITERATIONS, LOCODE_AUTO_COMPACT_THRESHOLD, LOCODE_REQUEST_TIMEOUT_MS) > persisted config file > defaults.
locode config set backend ollama
locode config set model qwen3-coder:30b
locode config set contextWindow 32768 # fallback size when auto-detection fails
locode config set maxIterations 40 # max tool calls per turn before locode gives up (default 50)
locode config set autoCompactThreshold 0.85 # fraction of context window at which auto-compact triggers
locode config set requestTimeoutMs 300000 # per-request timeout in ms (default 180000); raise this if
# your backend queues requests behind a concurrency limit
# (e.g. Ollama's OLLAMA_NUM_PARALLEL) under multi-session load
locode config set lspServers '{"java":{"command":"jdtls","extensions":[".java"]}}' # add a language server
# (JSON object keyed by language id; built-in ids
# override command/args, new ids add support and
# require extensions). Built-ins: typescript,
# python, go, rust, c (C/C++ share clangd).
locode config get
locode config path
Known limitations
- Requires a real interactive terminal (TTY) — you can't pipe input into it or run it from a non-interactive script.
- Native tool-calling reliability varies by model and is non-deterministic even for capable models (see above).
- No OS-level sandboxing (no container/VM isolation) — mutating tools operate on the real filesystem/shell with the permissions of the user running
locode. Only approve commands you understand. Two lightweight guardrails run unconditionally regardless of permission mode (includingauto-accept), as a safety floor rather than a full sandbox:write_file/edit_file/bash'scwdoverride can't target a path outside the working directory (../traversal, an absolute path elsewhere, or — on Windows — a different drive all refuse), andbashrefuses a short list of unambiguously catastrophic commands (wiping the filesystem root or home directory, a fork bomb, formatting/wiping a whole drive, writing raw data to a block device) before they'd ever run. Neither guard stops a model from doing damage confined to within the project directory, or running something merely inadvisable — seesrc/tools/pathGuard.tsandsrc/tools/bashGuard.ts. - In-app scrollback: mouse wheel scrolls the conversation view when mouse tracking is on (the default). PageUp/PageDown also scroll a page at a time. Hold Shift+click/drag for native terminal text selection and copy (when mouse tracking is on). Scrolling back up unpins the view from the latest message; scrolling back to the bottom (or sending a new message) re-pins it so new messages auto-scroll into view. Toggle mouse tracking with
/mouse on|off. - Windows shell quoting for the
bashtool has only had light testing; behavior may differ from Unix shells for complex quoting. git_commitcovers add/commit/create_branch/checkout/push/reset/stash/merge/rebase/delete_branch. Usebashfor anything beyond that.- MCP tool results support text, image, audio, and resource content blocks. Images are returned in the same shape as
read_fileso vision-capable models can see them; audio and binary resources are summarized. Remote (HTTP) MCP servers support static headers (e.g. a bearer token) but not OAuth flows. - Compaction (
/compactor automatic) replaces history with a model-generated prose summary — it costs one extra model call and loses tool-call/tool-result detail (the model's own account of what happened survives; the raw record doesn't). The auto-compact threshold defaults to 85% and is configurable vialocode config set autoCompactThresholdorLOCODE_AUTO_COMPACT_THRESHOLD. - Plugin support (
locode plugin add) now covers every part of a plugin: MCP servers, slash commands, agents, hooks, and skills. A slash command'sallowed-toolsfrontmatter restricts that one invocation's toolset (same tool-name translation as an agent'stools:— see/plugins); a skill invoked directly via/<skill-name>isn't restricted this way, since skills have noallowed-toolsfield of their own. Duplicate MCP server names across sources are now detected and surfaced in/mcp— project-level wins over user-level wins over plugin-level. Duplicate slash-command and skill names are also surfaced in/pluginsand/skills. - Skills are exposed as one shared
skilltool rather than one tool per skill — if two plugins install a skill with the same name, the first plugin in load order wins and the collision is shown in/skills. Skills can bundle siblingreferences/*.mdfiles that are included when the skill is invoked. - Hooks cover the most useful subset of Claude Code's lifecycle events:
SessionStart,UserPromptSubmit,PreToolUse,PostToolUse,PermissionRequest,SubagentStart,SubagentStop,CwdChanged,FileChanged,ConfigChange,Stop,SessionEnd.CwdChangedis declared in the config format but not yet fired anywhere — a cwd never changes mid-session in locode today, so configuring it is a no-op for now. All three hook types are supported:commandandhttprun an external process/request, andpromptjust injects its staticmessageas additional context (the same way a command/http hook's stdout does) — it has no process to fail, so it can't block an event the way a command hook's exit code 2 can. Command/http hooks can opt into structured JSON output viaoutputSchema: "json".PreToolUse,UserPromptSubmit, andPermissionRequestcan block; the rest are fire-and-forget.SessionEndfires on every exit path (including Ctrl+C and externalSIGTERM/SIGHUP), so it doesn't always have a real session id to report. All hooks for an event run in parallel with no defined ordering, and every configured hook always runs — there's no way to disable one without editing the file it came from.