Initial commit: shiro-neko 0.1.0-beta.1

Agentic coding CLI on Bun, Ink, and the AI SDK.

Core: streamText loop with SDK-level tool approval so a denied call provably never executes; endpoint fallback for OpenAI reasoning models; retry with backoff.

Tools: read/write/edit/glob/grep/bash, path-jailed, gitignore-aware, ripgrep with a JS fallback, binary rejection, live bash streaming.

Agents: five variants crossing thinking level with tool restriction; plan and review withhold mutating tools from the model.

Extensibility: frontmatter skills with on-demand bodies, plugin host with blocking hooks, MCP stdio and HTTP, read-only subagents.

State: durable per-project memory, session task lists, session persistence, compaction that repairs provider-item dependencies.

Distribution: five-platform cross-compiled binaries with checksums, install scripts, CI on three operating systems.

404 tests, typecheck clean.
This commit is contained in:
Muhammad Zakir Ramadhan committed 2026-09-02 17:30:18 +07:00
commit 5b8503fcd9
93 files changed
+12775

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
# Agents and thinking
An agent variant sets three things: how much the model deliberates, which tools it is
offered, and a behaviour appendix in the system prompt.
```bash
shiro --agent deep # at launch
shiro --agent plan --think low # variant with an overridden level
```
```
/agent picker
/agent review direct
/think picker
/think max direct
```
## The variants
| Variant | Thinking | Tools | Steps | For |
|---|---|---|---|---|
| `default` | medium | all | 50 | ordinary work |
| `quick` | off | all | 12 | small, well-scoped edits |
| `deep` | max | all | 80 | hard problems, unclear causes |
| `plan` | high | read-only | 50 | investigate and propose |
| `review` | high | read-only | 50 | critique a change |
**`quick`** tells the model not to deliberate, not to write a task list, and not to explore
beyond what the change needs. Good for a rename or a one-line fix where thinking budget is
pure latency.
**`deep`** asks for more than one hypothesis before acting, more reading before concluding,
and findings recorded with `remember` so they survive compaction.
**`plan`** and **`review`** are genuinely read-only. `write_file`, `edit_file`, and `bash`
are withheld from the model, not merely discouraged in prose — a model that cannot see a
tool cannot call it. Their prompts also forbid describing edits as if they had been made.
## Thinking levels
`off`, `low`, `medium`, `high`, `max`. They map to whatever the provider actually supports:
| Level | OpenAI `reasoning_effort` | Anthropic `thinking` |
|---|---|---|
| `off` | `none` | `{ type: "disabled" }` |
| `low` | `low` | `budget_tokens: 6400` |
| `medium` | `medium` | proportional budget |
| `high` | `high` | `budget_tokens: 38400` |
| `max` | `xhigh` | maximum budget |
Verified against both wire formats rather than assumed.
Higher costs more and takes longer. `off` on a hard problem produces confident wrong
answers; `max` on a rename wastes a few cents and several seconds. The variants pick
sensible defaults, so reach for `/think` only when a specific turn needs something else.
## Overriding
`--agent deep --think low` gives you `deep`'s tools, steps, and appendix with a low thinking
budget. The override clones the preset rather than mutating it, so a later `/agent deep` in
the same session still gets `max`.
## Defaults in config
```json
{ "agent": "deep", "thinking": "high" }
```
A flag beats the config file. An unknown name fails at startup with the valid list rather
than silently falling back:
```
$ shiro --agent turbo
shiro: Unknown agent "turbo". Available: default, quick, deep, plan, review
```
## What the variant changes in the prompt
The system prompt describes only the tools actually offered, and the workflow rules adapt.
Under `plan` the model is told it has no tools that change anything and that it cannot run
commands, so it should say what to run rather than claim it passed. Under `default` it is
told which tools need approval and to verify with the project's tests.
A prompt that describes a withheld tool teaches the model to attempt calls that cannot
succeed, which is why the description is generated from the live tool set.
+170
View File
@@ -0,0 +1,170 @@
# Architecture
## The loop
One turn is a `streamText` call whose stream is translated into UI events.
```
user prompt
→ messages.push({ role: 'user', ... })
→ streamText({ model, system, messages, tools, activeTools, reasoning, toolApproval })
→ for each stream part → yield an AgentEvent
→ if any tool needs approval, the stream ends suspended
→ collect decisions from the UI
→ push a tool message with the approval responses
→ loop
→ otherwise done
```
`src/session.ts` is an async generator. The UI consumes events; it never touches the SDK.
That is what lets the same session drive the Ink app, the headless printer, and the tests.
## Why approval goes through the SDK
An obvious design is a promise inside each tool's `execute`, resolved when the user answers.
That was rejected: it makes "denied" a convention the tool must remember to honour, and one
tool forgetting it is a silent security hole.
Instead the SDK's `toolApproval` is used. A denied call **provably never executes** — the SDK
never reaches `execute`. The tool cannot opt out because the tool is not consulted.
```ts
toolApproval: async ({ toolCall }) => {
const blocked = await plugins?.guard({ toolName: toolCall.toolName, input: toolCall.input, cwd });
if (blocked) return { type: 'denied', reason: blocked }; // --yolo cannot reach this
if (yolo) return undefined;
if (!needsApproval(toolCall.toolName)) return undefined;
return 'user-approval';
}
```
Guards are checked first, so `--yolo` skips prompts but not refusals.
One subtlety: when this function denies, the SDK emits `tool-approval-request` with
`isAutomatic: true` and answers it itself. Queueing that would prompt the user for a call
that is already settled, so automatic requests are skipped and denial is surfaced from
`tool-approval-response` instead.
## Where state lives
The system prompt is rebuilt on **every step**, not once per turn:
```ts
prepareStep: ({ messages }) => {
const instructions = this.systemFor(); // task list, memory, skills, agent
if (estimateTokens(messages) <= threshold) return { instructions };
return { instructions, messages: prunePreservingItems({ messages, reasoning: 'all', ... }) };
}
```
That is not an optimisation. A `todo_write` on step one must be visible to step two, and
`system:` on `streamText` is bound once for the whole run. Returning `instructions` from
`prepareStep` is the only place per-step state can enter.
The prompt also describes only the tools actually offered this turn. A prompt that mentions a
withheld tool teaches the model to attempt impossible calls.
## Rendering
Ink re-renders the whole tree on every `setState`. At 50 tokens a second that is 50 full
renders and a visibly flickering terminal.
Two things fix it:
- Finished lines go into `<Static>`, rendered once and never redrawn.
- Token deltas accumulate in a ref and flush on a 60 ms interval, not per token.
Markdown is parsed on every flush. An unclosed fence renders as a code block that grows,
which is what a reader expects while text is still arriving.
## Input
`ink-text-input` was replaced. It discards up and down before its own handler, so history
recall is impossible, and it only ever *shrinks* its internal cursor offset, so an externally
set value leaves the cursor stranded mid-string.
`src/ui/PromptInput.tsx` owns the cursor. That also gives home, end, and ctrl-a/e/k/u/w for
free. It hands up, down, tab, and escape to a parent callback first, so the command menu and
open panels can claim them before the input treats them as editing keys.
## Subagents
`task` runs a nested `streamText` with only `read_file`, `glob`, and `grep`. It returns one
message.
Two consequences follow from the tool set, not from policy:
- It can never need approval, because it has no gated tools.
- The parent's context holds the findings, not the search transcript.
Progress is reported through a callback, wired to a bus the panel subscribes to. Without the
bus the panel would need a reference to the tool, and the tool would need one to React.
## Provider differences
Two are handled explicitly.
**Thinking levels.** `off`/`low`/`medium`/`high`/`max` become `reasoning_effort` on OpenAI and
a `thinking` token budget on Anthropic. The SDK does the mapping; `src/agents.ts` only picks
the level.
**Endpoint fallback.** Newer OpenAI models reject function tools on `/v1/chat/completions`
and require `/v1/responses`. `src/fallback.ts` presents both as one model and switches when
the first rejects the request *shape* — 400, 404, 405, 415, 422, 501 with `isRetryable` false.
Retryable failures are left to the SDK's backoff.
The switch is sticky. Once an endpoint rejects the shape it will reject every later step too,
so re-probing it each turn would waste a round trip per step.
Only `api.openai.com` gets the chain. Third-party endpoints do not implement `/v1/responses`.
## Compaction and its repair
`pruneMessages({ reasoning: 'all' })` strips a reasoning item and keeps the message item from
the same response. The responses API treats the message as that reasoning item's dependent
and returns 400.
The two carry different ids, so they cannot be matched by id. What links them is the assistant
message they arrived in: one message is one response, and its reasoning item covers every
other item in it. `src/prune.ts` drops the dependent parts of any turn whose reasoning was
removed — which costs nothing, since pruning was already discarding those turns.
## Module map
| Module | Responsibility |
|---|---|
| `session.ts` | the loop, approvals, compaction, event stream |
| `tools.ts` | file and shell tools, ripgrep bridge, bash streaming |
| `ignore.ts` | gitignore-aware walker, path jail |
| `prompt.ts` | system prompt assembly from live state |
| `agents.ts` | variants, thinking levels |
| `skills.ts` | discovery, catalogue, `skill` tool |
| `memory.ts` | durable notes, search, model compaction |
| `notebook.ts` | session task list |
| `plugins.ts` | host, hooks, guard chain |
| `subagent.ts` | `task` tool and progress events |
| `ask.ts` | the `ask` tool |
| `mcp.ts` | MCP clients and namespacing |
| `fallback.ts` | endpoint chain |
| `prune.ts` | provider-item repair |
| `markdown.ts` | parser, no dependency |
| `store.ts` | sessions, prompt history |
| `config.ts` | resolution, model construction |
| `providers.ts` | presets, `/models` fetch |
| `pricing.ts` | USD rates |
| `commands.ts` | slash registry, parsing, menu matching |
| `headless.ts` | `-p` mode |
| `cli.tsx` | argv, wiring, lifecycle |
| `ui/*` | Ink components |
Every module is pure of the UI except `ui/`, and `ui/` never touches the SDK. The seam is the
`AgentEvent` stream.
## Testing
404 tests, no mocking framework. `MockLanguageModelV4` from `ai/test` drives the loop;
`ink-testing-library` drives the UI with real keystrokes; MCP is tested against a real stdio
server subprocess; provider wire formats are tested against a local HTTP server.
The pattern throughout is to assert on what actually crossed a boundary — what went on the
wire, what is on screen, what is on disk — rather than on internal calls.
+140
View File
@@ -0,0 +1,140 @@
# Configuration
Settings come from three places. Later wins:
1. `~/.shiro-neko/config.json`
2. environment variables
3. command-line flags
## The config file
Written by `/provider`, editable by hand. Every field is optional.
```json
{
"provider": "openai",
"model": "gpt-5",
"baseURL": "https://api.openai.com/v1",
"apiKey": "sk-...",
"presetId": "openai",
"agent": "default",
"thinking": "medium",
"maxRetries": 3,
"plugins": ["guard", "time"],
"mcpServers": {
"fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] }
}
}
```
| Field | Meaning |
|---|---|
| `provider` | wire protocol: `anthropic` or `openai`. Not the vendor — Groq, OpenRouter, and Ollama all speak `openai` |
| `model` | model id as the endpoint names it |
| `baseURL` | API root. Defaults to the official endpoint for the provider |
| `apiKey` | sent as `Authorization: Bearer` for `openai`, `x-api-key` for `anthropic` |
| `presetId` | which preset `/provider` chose, so it can show what is configured |
| `agent` | default variant: `default`, `quick`, `deep`, `plan`, `review` |
| `thinking` | default level: `off`, `low`, `medium`, `high`, `max` |
| `maxRetries` | retries per model call for transient failures. Default 3 |
| `plugins` | which plugins to enable. Omit for `["guard", "time"]` |
| `mcpServers` | see [MCP](mcp.md) |
## Provider presets
`/provider` offers these. Each sets `baseURL` and the wire protocol for you.
| Preset | Protocol | Endpoint |
|---|---|---|
| Anthropic | `anthropic` | `api.anthropic.com/v1` |
| OpenAI | `openai` | `api.openai.com/v1` |
| OpenRouter | `openai` | `openrouter.ai/api/v1` |
| Groq | `openai` | `api.groq.com/openai/v1` |
| DeepSeek | `openai` | `api.deepseek.com/v1` |
| xAI | `openai` | `api.x.ai/v1` |
| Ollama | `openai` | `localhost:11434/v1` |
| LM Studio | `openai` | `localhost:1234/v1` |
| Custom OpenAI-compatible | `openai` | you supply it |
| Custom Anthropic-compatible | `anthropic` | you supply it |
After the key is entered, `GET /v1/models` is called and the list becomes a picker. If the
endpoint does not implement it, you type the model id instead — the setup still completes.
## Environment variables
| Variable | Effect |
|---|---|
| `SHIRO_PROVIDER` | overrides `provider` |
| `SHIRO_MODEL` | overrides `model` |
| `SHIRO_BASE_URL` | overrides `baseURL` |
| `SHIRO_API_KEY` | overrides `apiKey` |
| `ANTHROPIC_API_KEY` | used when `provider` is `anthropic` and no key is set |
| `OPENAI_API_KEY` | used when `provider` is `openai` and no key is set |
| `SHIRO_HOME` | relocates config, sessions, memory, history, and user skills |
| `SHIRO_INSTALL_DIR` | where `install:local` and the installers put the binary |
| `SHIRO_REPO` | which GitHub repo the installers download from |
| `SHIRO_VERSION` | pins the version the installers fetch |
`SHIRO_HOME` is what the test suite uses to keep a run out of your real config.
## Flags
```
shiro [options]
shiro -p "prompt" headless, prints to stdout
cat file | shiro -p prompt read from stdin
```
| Flag | Effect |
|---|---|
| `-p`, `--print [prompt]` | headless mode. Needs `--yolo` for tool use |
| `--json` | with `-p`, one JSON event per line |
| `-c`, `--continue` | resume the newest session for this directory |
| `-r`, `--resume <id>` | resume by session id or unique prefix |
| `--agent <name>` | `default`, `quick`, `deep`, `plan`, `review` |
| `--think <level>` | `off`, `low`, `medium`, `high`, `max` |
| `--provider <name>` | `anthropic` or `openai` |
| `--model <id>` | model id |
| `--base-url <url>` | API root |
| `--no-mcp` | skip MCP servers |
| `--no-subagent` | omit the `task` tool |
| `--no-instructions` | ignore `AGENTS.md` and friends |
| `--no-skills` | ignore builtin and project skills |
| `--no-plugins` | disable all plugins, including the guard |
| `--no-memory` | do not load or write project memory |
| `--yolo` | skip every approval prompt |
| `-v`, `--version` | version, bun version, platform, source or compiled |
| `-h`, `--help` | usage |
## Where things live
```
~/.shiro-neko/
config.json provider, model, key, defaults
sessions/<uuid>.json transcripts, token counts, cost, task list
memory/<hash>.json durable per-project notes
history/<hash>.json prompt history for up-arrow recall
skills/*.md your own skills
```
Project files:
```
<project>/
AGENTS.md instructions injected into the system prompt
.shiro/skills/*.md project skills, override user and builtin
.shiroignore extra ignore rules on top of .gitignore
```
Memory and history file names are SHA-256 prefixes of the absolute project path, because a
path is not a safe filename.
## OpenAI reasoning models
Newer OpenAI models reject function tools on `/v1/chat/completions` and require
`/v1/responses`. For `api.openai.com` both are chained: a 400, 404, 405, 415, 422, or 501
on the first switches to the second, sticks for the rest of the session, and prints one
notice. Retryable failures — 429 and 5xx — are left to the SDK's backoff instead.
Third-party endpoints get a plain chat-completions model with no fallback probe, since they
do not implement `/v1/responses`.
+158
View File
@@ -0,0 +1,158 @@
# Development
## Setup
```bash
git clone https://github.com/zakirkun/shiro-neko
cd shiro-neko
bun install
```
Bun 1.3.14 or newer. Nothing else is required, though `rg` on PATH makes `grep` about 15x
faster and the fallback path is exercised without it.
## Commands
```bash
bun run shiro # run from source
bun run typecheck # tsc --noEmit
bun test # 404 tests
bun run build # single binary for this platform -> dist/shiro
bun run release # all five platforms -> dist/release + SHA256SUMS
bun run install:local # build, then copy onto PATH
```
`bun run install:local` copies the compiled binary. Do not use `bun link`: it writes a shim
that re-execs `bun`, which fails on any machine where bun was installed without `bun.exe` on
PATH — an npm install of bun, for instance. The compiled binary embeds its own runtime.
`SHIRO_INSTALL_DIR` overrides the target directory.
## Testing
No mocking framework. Everything is driven through a real boundary.
```ts
// The loop: a mock provider, asserting what crossed the wire.
const seen: LanguageModelV4CallOptions[] = [];
const model = new MockLanguageModelV4({
doStream: async (o) => { seen.push(o); return stream(text('ok')); },
});
const session = new Session({ model, askApproval: async () => 'deny', agent: variantByName('plan') });
for await (const _ of session.send('investigate')) void _;
const offered = (seen[0]?.tools ?? []).map((t) => t.name);
expect(offered).not.toContain('write_file');
```
```ts
// The UI: real keystrokes, asserting what is on screen.
const app = render(<App session={session} bridge={bridge} hooks={testHooks()} header="hdr" />);
app.stdin.write('/');
await wait(120);
expect(app.lastFrame()).toContain('/compact');
```
Provider wire formats are tested against a local `Bun.serve`. MCP is tested against a real
stdio subprocess. Tools are tested in a temp directory with `process.chdir`.
`SHIRO_HOME` points config, sessions, memory, and history at a temp directory, so a test run
never touches your real state.
### What to assert
Assert on what crossed a boundary: the request body, the rendered frame, the file on disk.
Not on internal calls.
That is not style. Several real bugs were caught this way and would have passed a
mock-verification test:
- `pruneMessages` leaving a message item without its reasoning item — visible only in the
request body
- `--json` serialising `Error` as `{}` — visible only in the printed output
- Automatic approval requests prompting the user — visible only in the event sequence
## Adding a tool
1. Define it in `src/tools.ts` with a `zod` schema. Descriptions are read by the model, so
write them as guidance, not as documentation.
2. Add it to the `tools` object.
3. If it mutates anything, add it to `MUTATING_TOOLS` so it requires approval.
4. Add a line to `TOOL_DOCS` in `src/prompt.ts` saying *when* to reach for it.
5. Test the behaviour in a temp directory, including the failure path.
Every tool costs roughly 550 characters of schema on every request. Thirteen live tools is
already where selection accuracy starts to matter, so a new tool needs to earn its place —
see [ROADMAP.md](../ROADMAP.md) for what has been declined and why.
## Adding a slash command
`src/commands.ts` is the single source of truth. Add a `CommandSpec` to `COMMANDS`, a case to
`parseCommand`, and a case in `App.tsx`. The menu, `/help`, and the parser all read from that
one array, and a test asserts every entry parses and appears in help — they cannot drift.
## Code conventions
Sample a neighbouring file before inventing a pattern. Broadly:
- No comment that restates the code. Comments explain *why*, and usually only where something
non-obvious was forced by an external constraint.
- No `as any`, no `@ts-ignore`. `tsconfig.json` runs strict with
`noUncheckedIndexedAccess`.
- Validate at trust boundaries — model output, file contents, network responses. Not between
internal functions.
- Duplication over premature abstraction. No interface with one implementation.
- Errors carry what the reader needs to act. `oldString appears 3 times in src/x.ts` beats
`edit failed`.
## Releasing
The version lives in `src/version.ts`, compiled into the binary. `package.json` carries it too
for tooling, and `bun run release` refuses to build if the two disagree, or if a git tag
disagrees with either:
```
$ GITHUB_REF_NAME=v9.9.9 bun run release
tag v9.9.9 does not match src/version.ts (0.1.0-beta.1). Bump the version or retag.
```
A binary reporting the wrong version is worse than a failed release.
To cut one:
```bash
# bump src/version.ts and package.json to the same value
git commit -am "release 0.1.0-beta.2"
git tag v0.1.0-beta.2
git push --follow-tags
```
`.github/workflows/release.yml` then runs typecheck and tests, cross-compiles all five
targets on one Ubuntu runner, asserts the built binary reports the expected version, and
publishes a GitHub release with the binaries and `SHA256SUMS`. A tag containing `-` is
published as a prerelease.
Bun cross-compiles from any host, which is why there is no build matrix. Verified: a working
`darwin-arm64` binary builds on Windows.
Publishing is gated on a `v*` tag, so a manual `workflow_dispatch` run produces artifacts
without releasing.
## CI
`.github/workflows/ci.yml` runs typecheck, tests, and a build on Ubuntu, macOS, and Windows
for every push and PR.
All three are necessary. The tools shell out to `rg`, `git`, and a platform shell, and path
handling differs — a Windows-only break is invisible on Linux until someone hits it.
## Debugging the agent itself
`--no-plugins --no-skills --no-memory --no-instructions --no-subagent --no-mcp` strips it to
the seven core tools, which isolates whether a problem is the loop or something layered on it.
`--json` in headless mode shows the exact event sequence.
For provider issues, a local `Bun.serve` that logs the request body and returns a canned SSE
stream answers "what did we actually send" faster than any amount of reading. Several bugs in
this codebase were found that way.
+128
View File
@@ -0,0 +1,128 @@
# Headless mode
`-p` runs one prompt without the TUI. For scripts, CI, and piping.
```bash
shiro -p "list every route and its handler"
git diff | shiro -p "review this diff" --yolo
shiro -p "fix the failing test" --yolo --agent deep
```
The prompt comes from the argument, or from stdin when the argument is omitted.
## Tool use needs `--yolo`
There is no terminal to approve on, so every gated tool is denied unless `--yolo` is passed:
```
$ shiro -p "add a test for paginate()"
shiro: headless denies write_file, edit_file, bash and mcp tools unless --yolo is passed
[tool] write_file {"path":"test/paginate.test.ts",...}
[denied] write_file (run with --yolo to allow tool use in headless mode)
```
Read-only tools work either way, so `-p` without `--yolo` is a safe way to ask questions
about a codebase from a script.
**`--yolo` does not disable plugin guards.** `rm -rf` is still refused.
## Output
### Text mode (default)
Assistant text to stdout, everything else to stderr. Pipe-friendly:
```bash
shiro -p "one-line summary of src/session.ts" > summary.txt
```
```
$ shiro -p "what does prune.ts do?" 2>/dev/null
src/prune.ts repairs provider-item dependencies after pruneMessages strips reasoning items.
```
### JSON mode
`--json` emits one event per line:
```bash
$ shiro -p "count the tools" --json
{"type":"tool-call","id":"c1","name":"grep","input":{"pattern":"tool\\("}}
{"type":"tool-result","id":"c1","name":"grep","output":"src/tools.ts:26: ..."}
{"type":"text","text":"There are 6 built-in file and shell tools."}
{"type":"done","inputTokens":4210,"outputTokens":88}
```
Event types: `text`, `reasoning`, `tool-call`, `tool-output`, `tool-result`, `tool-error`,
`tool-denied`, `compacted`, `notice`, `error`, `done`.
Errors are flattened to message strings, because `JSON.stringify` turns an `Error` into `{}`
and a JSON stream that reports failures as empty objects is useless for the one case it
matters.
## Exit codes
`0` on success, `1` on a model or stream error. A denied tool is not a failure — the model was
told and can respond to it.
```bash
if shiro -p "does this build?" --yolo; then echo ok; else echo failed; fi
```
## Sessions
Headless runs save like interactive ones, so `-c` picks up where one left off:
```bash
shiro -p "start the refactor" --yolo
shiro -p "now update the tests" --yolo -c
```
## What is withheld
The `ask` tool is not offered at all, rather than being offered and left to hang. The model
is told to decide and state its assumption instead.
Subagent progress events are not emitted; the report still comes back.
## CI recipes
Review a pull request diff:
```yaml
- run: |
git diff origin/main...HEAD > /tmp/diff
shiro -p "Review this diff. Report defects with file and line. Say so if it is clean." \
--agent review < /tmp/diff
```
`--agent review` is read-only, so no `--yolo` is needed and nothing can be modified.
Fail the build on a specific finding:
```yaml
- run: |
shiro -p "Does any handler skip input validation? Answer only YES or NO." --json \
| jq -r 'select(.type=="text") | .text' | grep -qv YES
```
Generate a changelog entry:
```yaml
- run: |
git log --oneline "$(git describe --tags --abbrev=0)"..HEAD \
| shiro -p "Write a changelog entry from these commits. Group by user-facing change." \
>> CHANGELOG.md
```
Pass the key as a secret:
```yaml
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
```
## Cost control
Headless runs are unattended, so a runaway loop costs real money. `--agent quick` caps the
step count at 12. There is no spend ceiling yet — see [ROADMAP.md](../ROADMAP.md).
+92
View File
@@ -0,0 +1,92 @@
# MCP
[Model Context Protocol](https://modelcontextprotocol.io) servers contribute tools. Configure
them in `~/.shiro-neko/config.json` and they appear alongside the builtins.
## Configuration
```json
{
"mcpServers": {
"fs": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
},
"db": {
"command": "python",
"args": ["-m", "my_mcp_server"],
"env": { "DATABASE_URL": "postgres://localhost/dev" },
"cwd": "/home/you/tools"
},
"api": {
"url": "http://localhost:3000/mcp",
"type": "http",
"headers": { "Authorization": "Bearer local-dev-token" }
}
}
}
```
**stdio** servers take `command`, and optionally `args`, `env`, `cwd`. The process is spawned
at startup and closed on exit.
**Remote** servers take `url`, and optionally `type` (`http` or `sse`, default `http`) and
`headers`.
## Naming
Tools arrive as `mcp__<server>__<tool>`. A server named `fs` exposing `read_file` becomes
`mcp__fs__read_file`.
The namespace is not cosmetic. Two servers both exposing `search` would otherwise silently
shadow each other, and the model would call one believing it was the other.
## Approval
**Every MCP tool requires approval on every call.** They are third-party code with unknown
side effects, so they are treated like `bash` rather than like `read_file`. `a` whitelists
one tool for the session.
`--yolo` skips these prompts, as it does for the builtins. Plugin guards still apply.
## Failure handling
A server that fails to start is reported and the session continues:
```
shiro-neko 0.1.0-beta.1 openai/gpt-5 session 0193ab2c
mcp: 4 tools
mcp db failed: spawn python ENOENT
```
Nothing else is lost — the other servers still load, the builtins still work. A missing
Python interpreter should not stop you from editing a file.
`--no-mcp` skips them all.
## Inspecting
`/tools` lists everything offered this turn, MCP tools included. The system prompt describes
them as a group:
```
- mcp__api__query, mcp__fs__read_file: from MCP servers, named mcp__<server>__<tool>.
Each needs approval; read its own description before calling.
```
Their individual descriptions come from the server, so that is what the model reads before
calling one.
## Cost
Each tool adds roughly 550 characters of schema to every request. A server exposing twenty
tools costs about 2,750 tokens per turn, sent whether or not the model uses any of them.
Prefer servers with a focused tool set. If one exposes many tools you never use, it is worth
finding a narrower server or writing one.
## Writing a server
Any MCP-compliant server works. A minimal stdio one needs three methods: `initialize`,
`tools/list`, and `tools/call`. The test suite includes one at
`test/fixtures/mcp-stub.ts` — about 50 lines, and useful as a starting point.
+157
View File
@@ -0,0 +1,157 @@
# Memory and state
Four kinds of state, each with a different lifetime.
| State | Lives in | Survives |
|---|---|---|
| transcript | the message array | until compaction or `/clear` |
| task list | the system prompt, rebuilt each step | pruning and `/compact` |
| project memory | `~/.shiro-neko/memory/<hash>.json` | across sessions, forever |
| session record | `~/.shiro-neko/sessions/<uuid>.json` | until you delete it |
The split exists because compaction is destructive. `pruneMessages` deletes tool results and
`/compact` deletes the whole transcript, so anything recorded only in messages is lost
exactly when a long task needs it most.
## Project memory
Durable notes about the codebase, injected at the start of every session.
### `remember`
```
kind fact | decision | gotcha | command
text one self-contained line
```
- **fact** — how something is. "The API is versioned under `/v2`."
- **decision** — what was chosen and why. "We use snake_case for DB columns; the ORM
expects it."
- **gotcha** — a trap. "The migration must run before the seed or the FK fails."
- **command** — an invocation that works. "Tests run with `bun test`, not `npm test`."
Duplicates are refused. Text is capped at 400 characters, the store at 300 entries.
### `recall`
Every term must appear. A match increments that entry's hit count, which protects it from
compaction later — an entry the agent actually uses is worth keeping verbatim.
### `forget`
Removes by substring, for a note that turned out wrong.
### What the agent sees
```
What you learned about this project in earlier sessions. Trust it, but verify anything
that contradicts what you can see in the code now:
- (command) tests run with bun test, not npm test
- (gotcha) the migration must run before the seed
- (decision) snake_case for DB columns, the ORM expects it
```
Top 20 by hit count, then recency. The "verify anything that contradicts" line matters:
memory goes stale and a confidently wrong note is worse than none.
### Compacting
`/memory` has the model merge entries. Two rules make it safe:
- Entries with at least one recall are kept verbatim and never merged.
- A model returning nothing parseable leaves the store untouched.
Without the second rule a bad response wipes everything the agent has learned.
`/notes` lists the store with hit counts. `--no-memory` disables loading and writing.
## Task list
`todo_write` replaces the whole list each call. Four states:
```
tasks ##########.............. 1/4 1 blocked
[x] read the pagination code
[~] fix the boundary
[ ] add a test
[!] update the docs (no write access to the wiki)
```
`blocked` requires a note saying what is blocking it. The tool warns when more than one task
is `in_progress`, when nothing is `in_progress` while work remains, or when a `blocked` task
has no note.
The list is re-rendered into the system prompt on **every step**, not once per turn — a
`todo_write` on step one has to be visible to step two. It is saved with the session and
restored by `-c` or `/resume`.
`/todos` shows it. The panel above the input shows it live.
## Sessions
Every turn autosaves, debounced 400 ms so a long tool loop does not hit the disk each step.
```json
{
"id": "0193ab2c-…",
"createdAt": "…", "updatedAt": "…",
"cwd": "/home/you/project",
"provider": "openai", "model": "gpt-5",
"title": "why does the pagination test fail?",
"inputTokens": 48210, "outputTokens": 3105,
"costUsd": 0.0913,
"notebook": { "todos": [ … ] },
"messages": [ … ]
}
```
```bash
shiro -c # newest session for this directory
shiro -r 0193ab2c # by id or unique prefix
```
```
/sessions list the last 15
/resume <id>
/save write now instead of waiting for the debounce
```
A corrupt session file is skipped rather than crashing the list.
## Compaction
Two mechanisms.
**Automatic**, at roughly 120k estimated tokens: `pruneMessages` strips reasoning and older
tool calls from what goes on the wire. Local history is untouched, so the transcript on your
screen stays complete. The turn reports it:
```
context compacted: 192 messages pruned to 15 on the wire
```
**Manual**, `/compact`: the model writes a summary — goal, files touched, decisions, commands
and outcomes, what remains — and it replaces the transcript entirely.
### The pruning repair
`pruneMessages({ reasoning: 'all' })` strips a reasoning item and keeps the message item from
the same response. The OpenAI responses API treats the message as a dependent of that
reasoning item and rejects the request:
```
400 Item 'msg_…' of type 'message' was provided without its required 'reasoning' item: 'rs_…'
```
The two carry different ids, so they cannot be matched by id. What links them is the
assistant message they arrived in — one message is one response. `src/prune.ts` drops the
dependent parts of any turn whose reasoning was removed. That costs nothing, because pruning
was already discarding those turns.
## Prompt history
Per-directory, capped at 200, deduplicated against the previous entry. Up and down in the
input walk it; down past the newest restores what you were typing.
Stored at `~/.shiro-neko/history/<hash>.json`, where the hash is a SHA-256 prefix of the
project path.
+120
View File
@@ -0,0 +1,120 @@
# Plugins
A plugin extends the agent in four ways: it can add tools, mark tools auto-approved, block
a tool call before it runs, and append to the system prompt. It can also run something after
each turn.
Plugins are compiled into the binary. Loading them from disk is deliberately not supported
yet — see [ROADMAP.md](../ROADMAP.md).
## Enabling
```json
{ "plugins": ["guard", "time"] }
```
That is also the default when the field is absent. `--no-plugins` disables all of them,
including the guard. `/plugins` lists what is active and reports any name that did not
resolve.
## The interface
```ts
export type Plugin = {
name: string;
description: string;
tools?: ToolSet;
autoApprove?: readonly string[];
beforeToolCall?: (ctx: { toolName: string; input: unknown; cwd: string }) => string | undefined | Promise<string | undefined>;
afterTurn?: () => void | Promise<void>;
appendix?: string;
};
```
`beforeToolCall` returning a string **blocks** the call, and the string is given to the model
as the reason. Returning `undefined` allows it.
Two decisions worth knowing about:
**A throwing hook blocks.** A guard that crashes must fail closed. Treating an exception as
"allow" would mean a bug in a security plugin silently disables it.
**Blocks are checked before approval.** `--yolo` skips prompts; it does not skip guards. A
plugin block is a refusal, not a permission question.
## Builtins
### `guard` (default on)
Refuses irreversible shell commands outright. Approval alone is a weak defence here: a user
holding `a` through a batch of edits will approve one of these without reading it.
| Pattern | Why |
|---|---|
| `rm -rf`, `rm -f` | recursive or forced delete |
| `git reset --hard` | discards uncommitted work |
| `git clean -f` | deletes untracked files |
| `git push --force`, `-f` | rewrites remote history |
| `git branch -D` | deletes a branch without a merge check |
| `DROP TABLE`, `TRUNCATE` | destroys database data |
| `mkfs`, `dd of=/dev/…` | writes to a raw device |
| `chmod 777` | makes files world-writable |
| `shutdown`, `reboot`, `halt` | affects the whole machine |
| `:(){ :\|:& };:` | fork bomb |
| `curl … \| sh`, `wget … \| sh` | pipes a download into a shell |
```
Blocked by the guard plugin: refusing "rm -rf build" (recursive or forced delete).
Ask the user to run it themselves if it is really needed.
```
The model is told to relay the command rather than work around it. `rm build/one-file.js`,
`git push origin feature`, and `git commit` all pass — the patterns target irreversibility,
not the commands themselves.
### `time` (default on)
Adds `current_time`, returning ISO 8601 plus the local string. Auto-approved; it reads
nothing. Useful because models are confidently wrong about the date.
### `bell` (opt in)
Writes `\u0007` to stderr when a turn ends. Off by default — a bell after every turn is
intrusive, but it is genuinely useful when a turn takes minutes.
```json
{ "plugins": ["guard", "time", "bell"] }
```
## Writing one
Plugins live in `src/plugins-builtin.ts` and are registered in `BUILTIN_PLUGINS`.
```ts
export const noSecretsPlugin: Plugin = {
name: 'no-secrets',
description: 'refuses to write files that look like credentials',
appendix:
'The no-secrets plugin refuses writes to .env and credential files. Ask the user to ' +
'add secrets themselves rather than working around it.',
beforeToolCall: ({ toolName, input }) => {
if (toolName !== 'write_file' && toolName !== 'edit_file') return undefined;
const path = String((input as { path?: unknown } | null)?.path ?? '');
if (/(^|\/)\.env|credentials|\.pem$/.test(path)) {
return `refusing to write ${path}; add secrets yourself`;
}
return undefined;
},
};
```
Then add it to `BUILTIN_PLUGINS` and, if it should be on by default, `DEFAULT_ENABLED`.
Write the `appendix` whenever the plugin can block something. Without it the model hits a
refusal it was never told about and tries to route around it.
## Ordering
Plugins run in the order they are enabled. The first `beforeToolCall` to block wins;
later hooks are not consulted. `afterTurn` runs every hook, and one throwing does not stop
the rest.
+123
View File
@@ -0,0 +1,123 @@
# Skills
A skill is a markdown file with instructions for one kind of task. Only its name and
description sit in the system prompt; the body is loaded on demand.
That split matters. Four bundled skills are 4,659 characters of body but 681 characters of
catalogue. Putting every body in the prompt would cost that on every request, for
instructions that are relevant to one turn in twenty.
## Format
```markdown
---
name: deploy
description: Ship a release. Use when asked to deploy, cut a release, or publish a build.
---
# Deploy
1. Confirm the tests pass. Do not deploy on a red suite.
2. Tag with the version from `src/version.ts`, not by hand.
3. Push the tag. CI builds and publishes.
Never deploy from a dirty working tree.
```
`name` and `description` are both required; a file missing either is skipped. The
description is what the model matches against, so write it as a trigger — "use when asked
to X" — not as a summary.
## Where they load from
Three sources, later overriding earlier by name:
1. **builtin** — compiled into the binary
2. **user** — `~/.shiro-neko/skills/*.md`
3. **project** — `.shiro/skills/*.md`
A project skill named `debug` replaces the bundled one entirely. `/skills` shows what
loaded and where each came from.
`--no-skills` skips all of them, builtin included.
## The bundled skills
**`debug`** — reproduce first, form three hypotheses, disprove them cheapest-first, fix the
cause not the symptom, write a test that failed before. After two failed attempts: re-read
the error literally and check whether the code you think is running is the code that is
running.
**`review`** — severity order: incorrect behaviour, missing validation at trust boundaries,
security, resource handling, then clarity. Say plainly when something is fine. Do not invent
findings to look thorough.
**`refactor`** — establish a safety net first, move in small steps with tests green between
each, do not fix bugs while refactoring, do not add abstraction for a single caller.
**`test`** — read two existing test files first and match them, assert on behaviour not
implementation, never weaken an assertion to make a test pass, a flaky test is a shared-state
problem and not something to retry around.
They are string constants in `src/skills-builtin.ts` rather than files, because
`bun build --compile` only embeds modules reachable through imports. A directory of `.md`
files would be missing from the shipped binary.
## How the agent uses one
The catalogue appears in the system prompt:
```
Skills available through the skill tool. Load one when its description matches the task,
before you start working, and follow it as if the user had written it:
- debug: Track down a bug whose cause is not obvious. Use when a test fails for unclear...
- refactor: Restructure code without changing behaviour. Use when asked to refactor...
```
When the model calls `skill({ name: "debug" })` it gets the full body back and is told to
follow it for this task. The call needs no approval — it reads nothing outside the binary.
## Writing a good one
Skills work when they encode what a newcomer to *your* project would get wrong. The bundled
ones are generic on purpose; yours should not be.
Useful:
```markdown
---
name: migration
description: Write or run a database migration. Use when the schema changes.
---
Migrations live in `db/migrations/` and are timestamped, never renumbered.
Run `bun run db:migrate` locally first. Staging runs them automatically on deploy;
production needs `bun run db:migrate --env=prod` by hand, after the deploy is green.
Never edit a migration that has run anywhere. Write a new one.
```
Not useful:
```markdown
---
name: quality
description: Write good code.
---
Follow best practices. Write clean, maintainable code with good naming.
```
The second costs tokens and changes nothing.
## Skill or AGENTS.md?
`AGENTS.md` is always in the prompt. A skill is loaded when its description matches.
Put standing facts in `AGENTS.md`: build commands, layout, conventions that apply to every
change. Put task-specific procedure in a skill: how to deploy, how to add a migration, how
this project debugs its worker queue.
If it applies to every turn, it belongs in `AGENTS.md`. If it applies to one kind of turn,
make it a skill.
+172
View File
@@ -0,0 +1,172 @@
# Tools
## The approval model
Three categories.
**Free.** Read-only, no prompt: `read_file`, `glob`, `grep`, `task`.
**Session tools.** Also free, because they touch the agent's own state rather than your
files: `todo_write`, `remember`, `recall`, `forget`, `skill`, `ask`, and anything a plugin
marks auto-approved.
**Gated.** Every call stops for a decision: `write_file`, `edit_file`, `bash`, and every
`mcp__*` tool.
```
edit_file wants to run
src/users.ts +2 -1
export function paginate(offset: number, total: number) {
- if (offset < total) return next();
+ if (offset <= total) return next();
}
y allow once | a always allow edit_file | n deny
```
`a` whitelists that tool for the rest of the session. `n` tells the model it was denied and
to ask what to do instead. `--yolo` skips all prompts.
**The guard runs before all of this.** It is not an approval — it is a refusal, and `--yolo`
does not reach it. See [plugins](plugins.md).
## File tools
### `read_file`
```
path file path relative to the workspace root
offset first line, 1-based
limit max lines, default 2000
```
Returns contents with 1-based line numbers. Refuses binaries: a NUL byte in the first 8 KB
means the file is not text, and a model that reads a 90 MB executable has burned its whole
context on nothing.
### `write_file`
```
path file path
content full contents
```
New files and full rewrites only. Creates parent directories.
### `edit_file`
```
path file path
oldString exact text to find, whitespace and indentation included
newString replacement
replaceAll replace every occurrence instead of requiring exactly one
```
`oldString` must match byte-for-byte and appear exactly once unless `replaceAll` is set.
An ambiguous match is an error naming the count, which pushes the model to add surrounding
context rather than guessing which occurrence it meant.
### `glob`
```
pattern e.g. "src/**/*.ts"
limit max paths, default 200
includeIgnored also return files git ignores
```
Walks the tree honouring `.gitignore` and `.shiroignore`, skipping `.git` and
`node_modules` unconditionally. Nested ignore files apply only within their own directory,
as git does. Returns posix paths relative to the workspace root.
### `grep`
```
pattern regex source
include glob limiting the search, default "**/*"
ignoreCase case-insensitive
includeIgnored also search files git ignores
```
Shells out to ripgrep when it is on PATH — roughly 15x faster on a real repo — and falls
back to a JavaScript walker otherwise. Output is `path:line: text` either way, so the model
sees one format regardless. Skips binaries. Caps at 200 hits.
### `bash`
```
command shell command
timeout ms, default 120000, max 600000
```
Runs in the workspace root through `bash -lc` or `cmd /c`. Output streams live to the panel
above the input rather than appearing all at once when the command exits — a two-minute test
run is otherwise indistinguishable from a hang. Both pipes are drained concurrently, since a
command that fills one while you block on the other deadlocks.
Returns exit code, stdout, stderr, and a note if a signal killed it.
## Agent tools
### `task`
```
description short label shown to you
prompt self-contained instructions
kind "explore" (default) or "review"
```
Spawns a read-only subagent with `read_file`, `glob`, and `grep` only. It returns one
report, so the parent pays for findings rather than the whole search transcript. It sees
none of the parent conversation, so its prompt has to stand alone.
`explore` finds and reports. `review` critiques code in severity order. Progress streams to
the subagent panel.
### `ask`
```
question one specific question
options choices, recommendation first, each with an optional detail
multiple allow more than one
```
Stops the turn and puts the question on screen. With options it is a picker; without, free
text. `esc` skips, which tells the model to decide and state its assumption.
Withheld entirely in headless mode — a question with no one to answer it would hang.
### `todo_write`
```
todos the complete list: content, status, optional note
```
Statuses: `pending`, `in_progress`, `done`, `blocked`. Send the whole list each time; it
replaces the previous one. Warns when more than one task is `in_progress`, when nothing is
`in_progress` while work remains, or when a `blocked` task has no note.
### `remember`, `recall`, `forget`
Durable per-project notes. See [memory](memory.md).
### `skill`
```
name skill name from the catalogue
```
Loads the body of a skill. See [skills](skills.md).
## Path safety
Every path a tool receives goes through a jail: resolved against the workspace root, then
checked that it did not escape. `../../etc/passwd` and absolute paths outside the root are
both refused before any filesystem call.
The model's output is a trust boundary. It can emit any string, so the check happens on
every call rather than being assumed.
## Output caps
Any single tool result is truncated at 30,000 characters with a note saying how much was
cut. `grep` stops at 200 hits, `glob` at 200 paths, `read_file` at 2000 lines by default.
Without caps one `grep` for `function` can end a session.