Compare commits
50
Commits
3090e477e3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97834e1411 | ||
|
|
4eafc064bf | ||
|
|
8eebc0f4d6 | ||
|
|
052423556a | ||
|
|
f8c3cc2d8e | ||
|
|
de89457cf7 | ||
|
|
b0da909454 | ||
|
|
7d77408a41 | ||
|
|
44bb4bebf1 | ||
|
|
76043aeb38 | ||
|
|
deeb138cd9 | ||
|
|
c7184b6d2f | ||
|
|
14d603f296 | ||
|
|
7a1e415fea | ||
|
|
4aeb0d2455 | ||
|
|
a3739eb628 | ||
|
|
e709737df1 | ||
|
|
0bdf642672 | ||
|
|
b7c3490993 | ||
|
|
4b4ddd0585 | ||
|
|
5028ea6973 | ||
|
|
dcede3c10a | ||
|
|
4f3a7eef7c | ||
|
|
7edaeafca0 | ||
|
|
2587e03beb | ||
|
|
626450eb06 | ||
|
|
c125cf36fa | ||
|
|
4848db308a | ||
|
|
7db69f19da | ||
|
|
b1ff00d840 | ||
|
|
ffa9a02c26 | ||
|
|
a22d8e13b1 | ||
|
|
6a168838c6 | ||
|
|
397c4355f3 | ||
|
|
2dc7b91c29 | ||
|
|
5feec4b5c7 | ||
|
|
e06fbc3050 | ||
|
|
e23ec053ae | ||
|
|
3ef2d8a75a | ||
|
|
940a1e7401 | ||
|
|
744763050b | ||
|
|
a4edbcc71e | ||
|
|
4f6ed43fed | ||
|
|
a8a3ffcb5c | ||
|
|
9c4316e562 | ||
|
|
26d62bc926 | ||
|
|
551e518711 | ||
|
|
e8b8b06a4b | ||
|
|
6b67465723 | ||
|
|
16d27e1611 |
No files matched your search
@@ -0,0 +1,17 @@
|
||||
name: Dependabot Auto-Merge
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
steps:
|
||||
- name: Enable auto-merge for Dependabot PR
|
||||
run: gh pr merge --auto --merge "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,18 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
production:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
development:
|
||||
dependency-type: "development"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
@@ -5,6 +5,10 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -16,9 +20,22 @@ jobs:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# bun install --frozen-lockfile re-resolves nothing but still pays the network
|
||||
# cost of a cold store; cache the store so a no-change run skips it. Key is per
|
||||
# OS and lockfile, so a dependency bump or a new OS each gets a fresh cache.
|
||||
- name: Cache the bun install store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
bun-${{ runner.os }}-
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bun run typecheck
|
||||
- run: bun test
|
||||
|
||||
@@ -6,10 +6,14 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Build the artifacts without publishing a release
|
||||
description: Build and verify the artifacts without publishing a release
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -21,6 +25,13 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
- name: Cache the bun install store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
bun-${{ runner.os }}-
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bun run typecheck
|
||||
- run: bun test
|
||||
@@ -33,14 +44,25 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
- name: Cache the bun install store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
bun-${{ runner.os }}-
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
# Bun cross-compiles every target from one host, so no build matrix is needed.
|
||||
# release.ts also fails the build when the tag and src/version.ts disagree.
|
||||
- run: bun run release
|
||||
|
||||
- name: Check the binary reports the right version
|
||||
- name: Check the binaries report the right version and are non-empty
|
||||
run: |
|
||||
for f in dist/release/shiro-linux-x64 dist/release/shiro-linux-arm64 \
|
||||
dist/release/shiro-darwin-x64 dist/release/shiro-darwin-arm64; do
|
||||
[ -s "$f" ] || { echo "$f is missing or empty"; exit 1; }
|
||||
done
|
||||
chmod +x dist/release/shiro-linux-x64
|
||||
./dist/release/shiro-linux-x64 --version
|
||||
./dist/release/shiro-linux-x64 --version | grep -q "$(bun -e 'console.log((await import("./src/version.ts")).VERSION)')"
|
||||
@@ -53,21 +75,37 @@ jobs:
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
# Tag pushes always publish. A manual dispatch publishes only when dry_run is
|
||||
# unchecked (false); the default true builds and verifies without a release.
|
||||
if: >-
|
||||
startsWith(github.ref, 'refs/tags/v') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.dry_run == false)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: shiro-binaries
|
||||
path: dist/release
|
||||
|
||||
# Release body: a short summary plus the artifact inventory, so the release
|
||||
# page reads like a hand-written one instead of the raw PR list. The changelog
|
||||
# is the prose; scripts/make-release-notes.ts extracts the section for this tag
|
||||
# and fails if the heading is missing, rather than publishing an empty body.
|
||||
- name: Compose release notes
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: bun run scripts/make-release-notes.ts
|
||||
|
||||
- name: Publish the release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release create "${{ github.ref_name }}" \
|
||||
--title "shiro-neko ${{ github.ref_name }}" \
|
||||
--generate-notes \
|
||||
--notes-file release_notes.md \
|
||||
$([[ "${{ github.ref_name }}" == *-* ]] && echo --prerelease) \
|
||||
dist/release/*
|
||||
@@ -0,0 +1,51 @@
|
||||
# Auto-scaffold project workflow files on first use in an existing repo
|
||||
|
||||
## Problem
|
||||
`/init` already scaffolds TODO.md / ROADMAP.md / docs/ **manually** (plus writes AGENTS.md
|
||||
via the model). But when the user runs shiro against an *existing* project that has no
|
||||
tracking files, nothing is generated automatically — the workflow policy block stays out of
|
||||
the system prompt, no nudges ever fire, and the model has no task list context.
|
||||
|
||||
The user's ask: when the agent starts working in an existing repo that lacks
|
||||
TODO.md / ROADMAP.md / docs/, generate them (also auto-write AGENTS.md), so the workflow
|
||||
hooks in before the first turn.
|
||||
|
||||
## Scope
|
||||
- Auto-generate TODO.md, ROADMAP.md, docs/, and AGENTS.md in an existing repo that has none
|
||||
of them, before the first user turn.
|
||||
- Model-driven content (the `/init` prompt pattern) rather than empty templates, so the
|
||||
files reflect the actual project.
|
||||
- Opt-out: `workflow.autoScaffold: false` disables; `workflow.enabled: false` stays the master
|
||||
switch. Also bail if the repo already tracks anything (TODO/ROADMAP/docs or AGENTS.md) — a
|
||||
repo that self-tracks does not need re-scaffolding.
|
||||
- Generated files are surfaced as a `notice` event so the CLI/UI can show them.
|
||||
- No overwriting: never touch an existing file.
|
||||
|
||||
## Files touched
|
||||
- `src/scaffold.ts` — add `scaffoldMissingAuto(cwd, model)` that (a) checks git root,
|
||||
(b) if no TODO/ROADMAP/docs/AGENTS.md exist, generates them via one model call
|
||||
(reuse `INIT_PROMPT`-style tone, but cover all four files), writing directly.
|
||||
- `src/session.ts` — in `send()` (first turn only, `workflow.autoScaffold !== false` and
|
||||
`workflow.enabled !== false`), call `scaffoldMissingAuto` once; if anything was written,
|
||||
set a flag so `systemFor()`'s `workflowPolicy()` sees the files right away, bump
|
||||
`versions.workflow`, and yield a `notice`.
|
||||
- `src/config.ts` — parse `workflow.autoScaffold` from config.
|
||||
- `src/session.ts` `SessionOptions.workflow` — add `autoScaffold?: boolean` (default true).
|
||||
- `test/scaffold.test.ts` (new or extend) — auto-scaffold on a repo with nothing; bail when
|
||||
TODO.md exists; bail when AGENTS.md exists; opt-out flag.
|
||||
- `docs/workflow.md` — document auto-scaffold + the flag.
|
||||
|
||||
## Verification
|
||||
- `bun run typecheck` clean.
|
||||
- `bun test` full suite green.
|
||||
- New tests cover: auto-scaffold writes 4 files in a bare repo; existing TODO.md bails;
|
||||
existing AGENTS.md bails; `autoScaffold:false` skips; notice event emitted.
|
||||
- `bun run build` compiles.
|
||||
|
||||
## Risks / decisions
|
||||
- One model call for all four files keeps it cheap and atomic-ish; content is project-specific.
|
||||
- Runs once per session (flag), at first `send()` *before* the model's real turn, so the
|
||||
system prompt and the turn see it. If the model call fails, degrade to the existing
|
||||
`scaffoldWorkflowFiles` empty-template fallback — never fail the turn.
|
||||
- Cwd-relative writes: resolve against the **git root**, not `process.cwd()` (matches
|
||||
`workflowPolicy`). Nested-cwd runs still write at the repo root.
|
||||
@@ -0,0 +1,162 @@
|
||||
# Background bash — run long-lived commands without blocking the turn
|
||||
|
||||
Status: spec (implemented)
|
||||
Date: 2026-09-11
|
||||
|
||||
## Problem
|
||||
|
||||
Running a dev server (or any long-lived command) via `bash` blocks the tool
|
||||
until the process exits or the 120 s default timeout fires. A dev server never
|
||||
exits, so the model burns the turn waiting, then gets a killed-by-timeout error
|
||||
in which the server may or may not still be running. Dev workflows inside the
|
||||
agent are effectively impossible.
|
||||
|
||||
## Design
|
||||
|
||||
Add an optional `background: true` mode to `bash`. Background commands:
|
||||
|
||||
- spawn detached (new process group / session) so the agent process can exit
|
||||
without taking them down, and so ctrl-c in the agent never kills them.
|
||||
- return immediately with a `handle` (small integer), a `started` marker, and
|
||||
the first few lines of output (when available).
|
||||
- keep streaming output into a per-handle ring buffer (capped) in memory;
|
||||
`bash_status` returns recent output and the current running/finished state.
|
||||
- are reaped on agent shutdown — the module saves a `~/.shiro-neko/backgrounds.json`
|
||||
journal and kills live children on exit (kill `Bun.spawn` process).
|
||||
- can be killed explicitly via `bash_stop` (the tool), or ctrl-c while focused,
|
||||
or `/bash` (the command).
|
||||
|
||||
### Why a handle + tools, not a long-lived "bash" result
|
||||
|
||||
Background processes are by definition not one-shot, so a single tool result
|
||||
cannot represent them. Separating into `bash` (start/one-shot) + `bash_status`
|
||||
(poll) + `bash_stop` (kill) keeps each tool's contract small and lets the agent
|
||||
poll while continuing to work. The model is instructed to poll and stop when
|
||||
done; otherwise the process lingers until shutdown reaps it.
|
||||
|
||||
### Why detached
|
||||
|
||||
`Bun.spawn(..., {detached: true})` (a.k.a. setsid) is required so that:
|
||||
- killing the agent does not SIGKILL the dev server (kill process-group on exit
|
||||
is deliberate, see below);
|
||||
- ctrl-c in the agent (which kills the agent's own process group) does not
|
||||
signal the dev server;
|
||||
- `interruptBash()` keeps working for foreground commands only.
|
||||
|
||||
On Windows, process groups work differently (`detached` behaves differently in
|
||||
Bun; the kill is best-effort). Document that background is primarily for
|
||||
Unix-like dev servers.
|
||||
|
||||
## Tool changes
|
||||
|
||||
### `bash` — add `background?: boolean` and `name?: string`
|
||||
|
||||
- `background` default false (foreground = existing behavior, backward compat).
|
||||
- `name` optional label used for /bash listing.
|
||||
- When background:
|
||||
- spawn `bash -lc '<cmd>'` with `detached: true` (or `cmd /c` + best-effort on
|
||||
win32), pipes captured for streaming, no tool timeout (the process decides
|
||||
its own lifetime).
|
||||
- register in the module-level `backgrounds` map keyed by an incrementing
|
||||
handle.
|
||||
- return `running <handle>: <cmd> (background pid N)` — the model learns the
|
||||
handle and can poll.
|
||||
- The `running` map (foreground, `interruptBash`) is untouched: foreground
|
||||
commands still behave exactly as today.
|
||||
|
||||
### `bash_status` — new `nav`/`core` read tool
|
||||
|
||||
- Input: `handle: number`.
|
||||
- Output: one of:
|
||||
- `running`: `status: running (pid N)\n<recent output, tail capped>`
|
||||
- `finished`: `status: finished, exit: <code>\n<tail of captured output>`
|
||||
- `not found`: `status: no such handle`
|
||||
- Implementation reuses `bashListener` streaming (sessions get live progress
|
||||
while a background command runs) and keeps a tail buffer per handle
|
||||
(`MAX_OUTPUT`-capped, so the model never burns context).
|
||||
|
||||
### `bash_stop` — new mutating tool
|
||||
|
||||
- Input: `handle: number`.
|
||||
- Returns which command was killed (`killed <handle>: <cmd>`), or
|
||||
`no such handle` when absent. Reuses `killTree` (process-group aware) on the
|
||||
background process, awaited so the process really is gone.
|
||||
|
||||
### Tool registrations
|
||||
|
||||
- `bash_status`: `withMeta({ set: 'core', mutating: false })`, read tool, no
|
||||
approval needed (like `read_file`).
|
||||
- `bash_stop`: `withMeta({ set: 'core', mutating: true })` — mutating requires
|
||||
a `DEFAULT_PERMISSIONS` entry + `subjectOf` case in `src/permission.ts`
|
||||
(falls back to `ask` on `*` otherwise, bypassing command gating).
|
||||
- `bash` remains `mutating: true`; `bash_stop` and `bash` share the bash
|
||||
permission subject (`bash` subjectOf: `bash_stop` command = `bash <cmd>`),
|
||||
so an approved `bash` rule can also cover `bash_stop` (subject-derived).
|
||||
- `MUTATING_TOOLS`/`TOOL_SETS` derive automatically via `_meta`.
|
||||
|
||||
### Default permissions
|
||||
|
||||
- `bash_stop` added to the mutating loop list (`src/permission.ts:223`).
|
||||
- `subjectOf` (`src/permission.ts:306`) maps `bash_stop` → `'bash'` so existing
|
||||
bash rules apply (e.g. a blanket allow on `bash` covers stop).
|
||||
|
||||
## Session / UI
|
||||
|
||||
- `src/session.ts` streams background command output through the existing
|
||||
`onBashOutput` listener (live output panel in interactive mode, same as a
|
||||
foreground command's streaming).
|
||||
- `src/ui/App.tsx`: render the `[bg N]` prefix from the `bash` tool result and
|
||||
make ctrl-c while no foreground command is running stop the most recently
|
||||
started background command (mirror of `interruptBash`). Keeps esc semantics:
|
||||
with a foreground command running, esc still kills it first.
|
||||
- `src/commands.ts`: `/bash` command — `list` (default) shows
|
||||
`handle: cmd (running|exit N)`, `stop <id>` kills, `stop all` kills all.
|
||||
Parser case in `commands.ts`, UI switch in `App.tsx`.
|
||||
- `src/cli.tsx` shutdown: before `process.exit`, call
|
||||
`shutdownBackgrounds()` (async kill live children + write journal).
|
||||
Best-effort — must not throw or delay exit. Journal written to
|
||||
`~/.shiro-neko/backgrounds.json` (SHIRO_HOME-aware via `store.ts` patterns).
|
||||
|
||||
### Prompt guidance
|
||||
|
||||
- `src/prompt.ts` bash tool descriptions: note that long-lived commands
|
||||
(dev servers, watchers, tests that run forever, `bun dev`) should use
|
||||
`background: true` and then be polled with `bash_status` and stopped with
|
||||
`bash_stop` when done. Instruct the model to always stop what it starts.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `src/tools.ts` — bash background branch, `bash_status`, `bash_stop`,
|
||||
registry entries, `bgHandle` counter, `backgrounds` map, `killBackground`.
|
||||
- `src/tool-utils.ts` — no change (meta derives).
|
||||
- `src/permission.ts` — mutating list + `subjectOf` + DEFAULT_PERMISSIONS.
|
||||
- `src/prompt.ts` — tool descriptions / guidance (bash description + status/stop).
|
||||
- `src/session.ts` — listener wiring (streaming bg output) if not already
|
||||
covered by `onBashOutput`; nothing else needed.
|
||||
- `src/commands.ts` — `/bash` command definition + parser case.
|
||||
- `src/ui/App.tsx` — `/bash` switch case + ctrl-c background fallback.
|
||||
- `src/cli.tsx` — shutdown reaping + journal.
|
||||
- `test/tools.test.ts` — bg tests.
|
||||
- `test/permission.test.ts` — auto (bash_stop mutating coverage).
|
||||
- `test/commands.test.ts` — `/bash` parse + list/stop.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bun test` (665+ tests, new ones included: `sleep 30` background returns
|
||||
immediately; status shows running then finished after `exit 0`; stop kills;
|
||||
journal + reap on shutdown; `/bash list/stop` parse).
|
||||
- `bun run typecheck`.
|
||||
- `bun run build` (must pass `--production`; `dist/shiro --version`).
|
||||
- Manual: `SHIRO_HOME=$(mktemp -d) bun run src/cli.tsx -p "run a dev server in
|
||||
the background and check it is up, then stop it" --yolo --json`.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- Old journal entries (from crashed sessions) are reaped on next boot: at
|
||||
startup, kill stale PIDs or ignore missing ones. Do not leak orphan dev
|
||||
servers across sessions.
|
||||
- `bash_status` poll output is capped (context safety).
|
||||
- Windows: background is best-effort (no process group / setsid semantics);
|
||||
foreground behavior unchanged.
|
||||
- Background processes are not snapshot / undo targets; killing on shutdown
|
||||
is deliberate to avoid orphan servers the user cannot see.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Plan: Harness auto-skill — memory spesifik, skill general
|
||||
|
||||
## Koreksi desain
|
||||
- Sebelumnya: memory project + global layer (_global.json) untuk pattern universal. Salah.
|
||||
- Seharusnya: memory = spesifik per project (path, command, decision repo itu). Skill = general/universal, auto-create.
|
||||
|
||||
## Goal
|
||||
Harness yang belajar terus: tiap sesi yang menghasilkan pattern reusable lintas-repo -> otomatis jadi file skill di ~/.shiro-neko/skills/auto-*.md, kebawa di sesi berikut via loadSkills tanpa manual.
|
||||
|
||||
## Perubahan
|
||||
1. src/memory.ts — hapus global layer (globalFile/globalEntries/addGlobal/renderWithGlobal). Balikkan prompt summarize & suggestFromTranscript ke project-spesifik (keep paths/names, one-off file fix BOLEH disimpan kalau spesifik repo). MAX_TEXT 800, dedup normalize, TTL 90d tetap.
|
||||
2. src/skill-learner.ts — BARU. suggestSkillsFromTranscript(messages, model) -> SkillCandidate[] { name, description, body }, prompt: generalisasi ke peran (the auth layer, not src/auth.ts), condition->action->reason, skip repo-spesifik. writeAutoSkill(candidate): tulis ke ~/.shiro-neko/skills/auto-<slug>.md dengan frontmatter name/description, dedup by name+normalize(body), append section kalau file sudah ada, cap MAX_BODY 20k, best-effort.
|
||||
3. src/session.ts — import skill-learner, afterTurn hook: maybeLearnSkills() (best-effort, catch, hanya kalau messages.length>=6 dan model ada). Dipanggil di send() finally setelah plugins.afterTurn, atau setelah run selesai. Tanpa block turn.
|
||||
4. src/cli.tsx — tidak perlu wiring khusus; learner pakai SHIRO_HOME & Bun.write. Pastikan Memory ctor tidak lagi loadGlobal.
|
||||
5. Test — memory.test tetap hijau (project-only), tambah skill-learner.test untuk dedup & write.
|
||||
|
||||
## Verifikasi
|
||||
- bun run typecheck
|
||||
- bun test (790 -> 79x)
|
||||
- manual: SHIRO_HOME=$(mktemp -d) bun run src/cli.tsx -p "hello" --yolo --json -> cek ~/.shiro-neko/skills/auto-*.md terbuat saat pattern kuat
|
||||
@@ -0,0 +1,34 @@
|
||||
# Maintenance polish — spec
|
||||
|
||||
## 1. Pricing source+date
|
||||
File: `src/pricing.ts`
|
||||
- Add file-level doc: source URLs (anthropic.com/pricing, openai.com/api/pricing, etc) + `Last verified: 2026-09-09` + note "hand-entered, verify before billing".
|
||||
- Keep RATES as is (no rate change unless verified), but comment per-provider source.
|
||||
- Export `PRICING_VERIFIED_AT = '2026-09-09'` for /cost panel to display.
|
||||
|
||||
## 2. estimateTokens label
|
||||
Files: `src/prune.ts`, `src/session.ts`, `src/ui/panel-bodies.ts`
|
||||
- `estimateTokens` already `len/3.6+8*msgs` with ~. Rename display everywhere to `~N tokens (est.)` or keep `~N` but add `(est.)` in /cost.
|
||||
- Ensure `session.estimatedTokens()` doc says "estimate, not tokenizer".
|
||||
- `costPanel` line already `~${n} tokens` -> change to `~${n} tokens (est.)`.
|
||||
|
||||
## 3. listPaths staleness
|
||||
Files: `src/cli.tsx`, `src/ui/App.tsx`, `src/ignore.ts`
|
||||
- Problem: `listPaths` walks 5000 once, caches in App `paths` state, never refreshes.
|
||||
- Fix:
|
||||
- `cli.tsx` hooks.listPaths accepts `opts?: { force?: boolean }` and caches with 30s TTL + invalidation on file mutation via `session` snapshot hook (expose `invalidatePaths` or simple: App re-calls walk when file created).
|
||||
- Simplest reliable: App keeps `pathsVersion` bump; Session emits `onFileMutated` callback that App subscribes to -> `setPaths(undefined)` so next `@` re-walks. Also add manual refresh: `ctrl-r` while FileMenu open re-walks (or just always re-walk after 30s).
|
||||
- Implementation: add `fileChangeSeq` counter in Session, increment on recordBeforeWrite commit; App `useEffect` watches `session.fileChangeSeq` and invalidates `paths`.
|
||||
- Keep limit 5000, but add comment "refresh on file mutation, manual Tab still works".
|
||||
|
||||
## 4. MUTATING_TOOLS derive
|
||||
Files: `src/permission.ts`, `src/tool-utils.ts`, `src/tools.ts`
|
||||
- Today `MUTATING_TOOLS = mutatingNames(tools)` (derived from _meta) and `DEFAULT_PERMISSIONS` hand-lists `write_file:'ask'` etc — two sources.
|
||||
- Fix: make DEFAULT_PERMISSIONS derive mutating entries from MUTATING_TOOLS. Keep special-case reads (`read_file` etc) explicit, then loop MUTATING_TOOLS to set `'ask'` unless already present. This makes _meta single source.
|
||||
- Keep `MUTATING_TOOLS` exported (tests use it) but add comment "single source via _meta".
|
||||
- Also add `FREE` already derived.
|
||||
|
||||
## Verifikasi
|
||||
- npx tsc --noEmit 0
|
||||
- bun test 804 -> still 0 fail (add pricing date test maybe)
|
||||
- manual: /cost shows `~N tokens (est.)` + `pricing verified 2026-09-09`; create file then `@` shows it without restart.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Plan: Lifelong Learning — Memory + Compaction Fix
|
||||
|
||||
## 1. Goal
|
||||
Bikin shiro-neko cukup untuk agent yang belajar terus (lifelong) tanpa ngulang kesalahan setelah context compact / sesi ganti.
|
||||
|
||||
Status sekarang:
|
||||
- Harness (session + prune + notebook): 7.5/10 — ladder prune, orphan repair, repeat guard sudah benar.
|
||||
- Auto-memory: 4/10 — keyword literal, manual `remember`, boot block hits-only, MAX_TEXT 400, per-project silo, tanpa auto-consolidation.
|
||||
|
||||
Target: harness 9/10, memory 8/10 dengan perubahan kecil, backward-compatible, semua tes hijau.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
### A. Memory (`src/memory.ts`)
|
||||
- Search dari `terms.every(includes)` strict → scored hybrid (normalize + token + substring + hits + recency). Tetap require every term untuk kompatibilitas tes, tapi ranking jauh lebih baik dan tahan typo minor (underscore/hyphen/punct).
|
||||
- Render boot block dari hits-only → diverse: top-hits + most-recent, dedup. Mencegah 20 gotcha lama mengusir decision baru.
|
||||
- MAX_TEXT 400 → 800 (potong silent terlalu agresif untuk gotcha/decision berkonteks).
|
||||
- Dedup dari exact `text===clean` → normalized (`lower + collapse ws + strip punct`) biar near-duplicate tidak dobel.
|
||||
- TTL / decay: `pruneExpired(90d)` untuk entry hits==0 & umur >90d, dipanggil di load/persist. hits diberi recency bonus saat render, bukan decay destructive.
|
||||
- Global memory: `~/.shiro-neko/memory/_global.json` sebagai layer kedua; per-project tetap utama, global untuk pattern lintas-repo.
|
||||
- Auto-extract hook: `suggestFromTranscript(messages, model)` — 1x generateText yang meringkas sesi jadi 1-3 kandidat memory (dipakai session afterTurn, bukan auto-write diam-diam).
|
||||
|
||||
### B. Compaction (`src/prune.ts` + `src/session.ts`)
|
||||
- estimateTokens: `len/4` → `len/3.6 + messages*8` (akun overhead role/tool envelope; terukur lebih dekat ke cl100k). Threshold tetap 120k.
|
||||
- pruneToFit: selalu preserve head (first user goal + first assistant) terlepas ladder, jadi goal tidak hilang di narrowest rung.
|
||||
- prepareStep di session: ladder tetap, tapi droppedSpan di-capture by identity dan di-ringkas via 1x generateText menjadi `Note (retained from compacted history)` user message (lossless compaction). Guarded try/catch — mock/dead model tidak break turn.
|
||||
- afterTurn auto-memory: jika turn menambah >=4 pesan dan memory ada, tawarkan 1-3 kandidat via suggestFromTranscript (tidak auto-persist tanpa konfirmasi model; tool `remember` tetap sumber write).
|
||||
- Repeat guard lintas-turn: `seen` sekarang Map dengan decay per 20 turn (opsional, ringan).
|
||||
- Persist: onChange debounce 400ms → flush juga di `beforeExit` + tiap done (sudah ada), tambah flush on compact.
|
||||
|
||||
### C. Prompt / Wiring (`src/prompt.ts`, `src/cli.tsx`, `src/store.ts`)
|
||||
- Prompt: memory block sekarang labeled `Project memory (project + global)`; notebook tetap survive compaction.
|
||||
- cli.tsx: Memory ctor dapat global layer; `session.send` after done trigger suggest (off jika --no-memory).
|
||||
- store: tidak diubah format, hanya notebook persistance tetap.
|
||||
|
||||
## 3. Files Touched
|
||||
- `src/memory.ts` — utama (search, render, MAX_TEXT, dedup, global, TTL, suggest)
|
||||
- `src/prune.ts` — preserveHead, estimate helper export, ladder keep
|
||||
- `src/session.ts` — estimateTokens baru, summarizeDiscarded, afterTurn hook, cross-turn seen
|
||||
- `src/prompt.ts` — minor label (opsional)
|
||||
- `src/cli.tsx` — wiring global memory + afterTurn suggestion
|
||||
- `test/memory.test.ts` — update expectation truncation 400→800, tambah tes diverse/ranking/TTL
|
||||
- `test/prune.test.ts` — tambah preserveHead test
|
||||
- `test/compact.test.ts` — tambah lossless-compaction test (mock summarize)
|
||||
|
||||
## 4. Schema / Types
|
||||
- MemoryEntry tetap `{id,kind,text,createdAt,hits}` — tambah field opsional `lastHitAt?: string` untuk recency bonus (backward compat: fallback ke createdAt).
|
||||
- Config tidak berubah (SHIRO_HOME, maxSpendUsd, etc).
|
||||
- File: per-project `${hash(cwd)}.json`, global `_global.json` di root(). Format array JSON sama, corrupted → [].
|
||||
|
||||
## 5. Verification
|
||||
- `bun run typecheck` — harus pass (verbatimModuleSyntax, noUncheckedIndexedAccess)
|
||||
- `bun test` — semua existing + baru hijau; khusus:
|
||||
- memory.test: truncation 800, every-term strict tetap, hits+recency ranking, diverse render contains recent
|
||||
- prune.test: head goal survive narrowest rung, estimate monotonic
|
||||
- compact.test: droppedSpan summarized to retained note, tidak loop
|
||||
- `bun run build` — `dist/shiro` bisa di-compile
|
||||
- Manual: `SHIRO_HOME=$(mktemp -d) bun run src/cli.tsx -p "hello" --yolo --json` → compacted event + done, memory file terisi
|
||||
|
||||
## 6. Non-Goals (defer)
|
||||
- Embedding vector search (butuh dep + infra) — trigram/BM25 cukup untuk sekarang, bisa follow-up.
|
||||
- Subagent parallel fan-out — butuh executor custom, defer (catat di ROADMAP).
|
||||
- MCP meta-tools — defer.
|
||||
|
||||
## 7. Rollout
|
||||
- Implement memory.ts dulu → test
|
||||
- prune.ts preserveHead + estimate
|
||||
- session.ts summarizeDiscarded + wiring
|
||||
- Update tests incremental, typecheck tiap langkah, jangan bulk.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Prioritize batched reads (read_many_files) for turn efficiency
|
||||
|
||||
## Problem
|
||||
The model frequently calls `read_file` once per file when investigating, costing one
|
||||
round trip each. `read_many_files` already exists and batches 2-20 files in one call,
|
||||
but the guidance is weak: it is in `edit-plus` (not `core`), and the system prompt does
|
||||
not tell the model to prefer batching. Turns burn more steps than needed.
|
||||
|
||||
## Scope
|
||||
- Move `read_many_files` into the `core` tool set so it is always offered (even with
|
||||
minimal tool sets or a read-only variant).
|
||||
- Strengthen the per-tool guidance in `src/prompt.ts` TOOL_DOCS: `read_file` says
|
||||
"prefer read_many_files when you need several files"; `read_many_files` becomes the
|
||||
primary reading instruction with an explicit batch hint (2-20).
|
||||
- Add one "How to work" rule about reading efficiently (batch, grep/outline first,
|
||||
never read a file twice).
|
||||
- `deep` agent variant: add a read-batching instruction to its appendix.
|
||||
- Tests: assert `toolSetOf('read_many_files') === 'core'`; assert TOOL_DOCS contains
|
||||
the batching guidance; adjust any test asserting `read_many_files` is not core.
|
||||
- Docs: mention read_many_files as the default reading tool in docs/tools.md.
|
||||
|
||||
## Files touched
|
||||
- `src/tools.ts` — readManyFilesTool meta set: `'edit-plus'` → `'core'`.
|
||||
- `src/prompt.ts` — TOOL_DOCS lines + a workflow rule.
|
||||
- `src/agents.ts` — deep appendix line.
|
||||
- `test/session-features.test.ts` or `test/tools.test.ts` — set assertion.
|
||||
- `test/prompt.test.ts` — presence of batching guidance.
|
||||
- `docs/tools.md` — reading guidance.
|
||||
|
||||
## Verification
|
||||
- `bun run typecheck` clean.
|
||||
- `bun test` — full suite green (tool sets + prompt + deep agent tests).
|
||||
- `bun run build` compiles.
|
||||
|
||||
## Decision
|
||||
Batched reads save one round trip per extra file — the single highest-leverage
|
||||
efficiency win for investigation-heavy turns. Keep `read_file` (single-file reads,
|
||||
offset/limit, still needed for one file) but steer batching as the default once the
|
||||
file set is known.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Plan: kerjakan TODO.md — Now + Next + Maintenance
|
||||
|
||||
## Scope
|
||||
TODO.md status 2026-09-08:
|
||||
- Now/Summarize pruned span — KODE SUDAH ADA (prune.droppedSpan, session.summarizeDiscarded, retained note, budget 6k excerpt + 3-6 lines, 3 tests). Checkbox masih [ ]. Action: flip ke [x] + tambah test "pruned decision recoverable".
|
||||
- Now/Hot-reload — KODE SUDAH ADA (Session.updateSkills/updatePlugins + pendingSkills/pendingHost + drainPending + cli rebuild). Belum ada test mid-session callable. Action: tambah test + flip [ ].
|
||||
- Next/MCP without schema tax — BELUM. 20 tools ~2750 tok/req.
|
||||
- Next/Derive tool-name lists — BELUM. TOOL_SETS + MUTATING_TOOLS hand-list di tools.ts:877,913.
|
||||
- Next/Subagent parallelism — BELUM. task sequential.
|
||||
- Next/Undo a turn — BELUM.
|
||||
- Maintenance 4 items — pricing note, estimateTokens label, listPaths 5k stale, MUTATING derive.
|
||||
|
||||
## Urutan eksekusi (kecil dulu, besar belakangan, tiap langkah typecheck+test)
|
||||
1. **Now flip + tests** — patch TODO.md [x], test: hot-reload skill mid-session callable, pruned decision recoverable. Verifikasi: bun test.
|
||||
2. **Derive tool-name lists** — tandai mutating di definisi tool (tools.ts/tools-extra.ts/tools-git.ts/tools-net.ts), TOOL_SETS & MUTATING_TOOLS derive + test coverage. Risiko: silently ungated write jika lupa.
|
||||
3. **MCP tanpa schema tax** — DONE: phi 3 meta-tools mcp_list/mcp_inspect/mcp_call (mcpExpose=phi/direct/auto), prompt hanya nama server, permission+guard lewat built-in, keep direct registration untuk server 2-tool. Test: server 20-tool 0 schema sampai mcp_call (mcp.test.ts).
|
||||
4. **Subagent parallelism** — DONE: task `tasks` union + Promise.all fan-out (8 cap), panel per-subagent, test overlap waktu (subagent-parallel.test.ts).
|
||||
5. **Undo a turn** — snapshot file-tool edits pre-prompt (cap 100), /undo restores files+conversation atau keduanya, /redo, bash tidak ter-snapshot (docs), test edit reverted + record hilang.
|
||||
6. **Maintenance polish** — pricing source+date, estimateTokens label everywhere /cost, listPaths notice staleness / refresh, MUTATING derive dari #2.
|
||||
|
||||
## Files per langkah
|
||||
1. TODO.md, test/compact.test.ts, test/registry-hot-reload.test.ts (baru)
|
||||
2. src/tools.ts, src/tools-extra.ts, src/tools-git.ts, src/tools-net.ts, src/tools-meta.ts (baru), test/tool-meta.test.ts
|
||||
3. src/mcp.ts, src/tools-mcp-meta.ts (baru), src/prompt.ts, src/session.ts, test/mcp-meta.test.ts
|
||||
4. src/subagent.ts, src/session.ts, test/subagent-parallel.test.ts
|
||||
5. src/snapshot.ts (baru), src/commands.ts, src/session.ts, src/cli.tsx, test/undo.test.ts
|
||||
6. src/pricing.ts, src/complete.ts, docs/*
|
||||
|
||||
## Verifikasi tiap langkah
|
||||
- bun run typecheck (exit 0)
|
||||
- bun test (790 -> bertambah, 0 fail)
|
||||
- manual: /registry add skill:xxx lalu task di next turn tanpa restart; /cost label; mcp_list cost
|
||||
|
||||
## Aturan
|
||||
- Spec dulu sebelum code (file ini).
|
||||
- Satu langkah satu commit, pesan commit sebut TODO section.
|
||||
- Jangan commit dry_run dead input atau README count bareng — itu bug terpisah.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Plan: 7 new features for shiro-neko
|
||||
|
||||
Scope: add 7 features to the shiro-neko codebase. All touch `src/session.ts` and related
|
||||
files. Tests follow existing patterns (bun:test, MockLanguageModelV4, mkdtemp chdir).
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Turn change summary (`/changes`)
|
||||
**Files:** `src/session.ts`, `src/commands.ts`, `src/ui/App.tsx`, `src/ui/panel-bodies.ts`, `test/changes.test.ts`
|
||||
|
||||
Reuse undo snapshot infra: after turn, diff `turnBeforeFiles` vs `afterFiles`.
|
||||
- `Session.lastTurnSummary(): ChangeSummary | undefined` — returns `{ added: string[], modified: string[], deleted: string[] }` from the most recent snapshot's beforeFiles vs afterFiles.
|
||||
- Add `ChangeSummary` type exported from `src/session.ts`.
|
||||
- `/changes` command in `src/commands.ts` (type `'changes'`).
|
||||
- `changesPanel()` in `src/ui/panel-bodies.ts`.
|
||||
- Wire in `App.tsx` switch case + show after 'done' if files were changed.
|
||||
- Test: create files, write_file, assert summary has them; empty turn → undefined.
|
||||
|
||||
### 2. System prompt memoization
|
||||
**Files:** `src/session.ts`, `test/prompt-cache.test.ts`
|
||||
|
||||
Version counters on volatile parts: `notebookVersion`, `memoryVersion`, `skillVersion`, `pluginVersion`, `toolVersion`.
|
||||
- `Session` stores a version map `{notebook, memory, skill, plugin, tool}`.
|
||||
- Bump versions: `onNotebookChange` already bumps notebook; memory.add/bump triggers callback; skills/plugins rebuilt → increment; tools rebuilt → increment.
|
||||
- Cache `{versionKey: string, text: string}` in Session.
|
||||
- `systemFor()` builds versionKey from all 5, returns cache hit if identical.
|
||||
- Expose `Session.promptCacheStats(): {hits: number, misses: number}` for /cost.
|
||||
- Test: two calls with same version → cache hit; bump one → miss.
|
||||
|
||||
### 3. Web search tool (`web_search`)
|
||||
**Files:** `src/tools-net.ts`, `test/tools-net.test.ts`
|
||||
|
||||
Add `web_search` to `netTools` (same set as `web_fetch`, opt-in).
|
||||
- Backend: `config.search?.backend` (default `'duckduckgo-lite'`).
|
||||
- Implementation: fetch `https://lite.duckduckgo.com/lite/?q=<encoded>` → parse result HTML for title+url+snippet. No API key needed.
|
||||
- Max 5 results. Return as numbered markdown list.
|
||||
- `checkUrl` for safety on results, skip any that resolve private.
|
||||
- Description: "Search the web for information. Returns up to 5 results with titles, URLs, and snippets."
|
||||
- Export `NET_TOOL_NAMES` includes `web_search`.
|
||||
|
||||
### 4. `/search` — search saved sessions
|
||||
**Files:** `src/store.ts`, `src/commands.ts`, `src/ui/App.tsx`, `test/search.test.ts`
|
||||
|
||||
Full-text grep over session transcripts on disk.
|
||||
- `store.searchSessions(query: string, limit?: number): SessionRecord[]` — case-insensitive substring match on `title` and serialized `messages[].content` (string only; JSON content stringified for matching).
|
||||
- `/search <query>` command (type `'search'`).
|
||||
- Returns panel with matching sessions: id prefix, title, first matched line.
|
||||
- Test: save session with specific content, search, assert match.
|
||||
|
||||
### 5. Workspace file refresh at turn boundary
|
||||
**Files:** `src/session.ts`, `src/cli.tsx` (minor), `test/workspace-refresh.test.ts`
|
||||
|
||||
When `fileChangeSeq` bumps (files written this turn), re-walk workspace at turn end.
|
||||
- `Session.refreshWorkspaceFiles(): void` — re-runs `walk()` and updates `this.opts.workspaceFiles`. Only if `fileChangeSeq > lastWalkSeq`.
|
||||
- Track `lastWalkSeq` in Session.
|
||||
- Call in `send()` finally block, after `drainPendingHotReload`.
|
||||
- Expose `Session.setWorkspaceFiles(files)` for tests.
|
||||
- Test: start with empty workspace, write file via write_file, refresh, assert file appears.
|
||||
|
||||
### 6. Per-turn spend cap
|
||||
**Files:** `src/session.ts`, `src/config.ts`, `src/ui/panel-bodies.ts`, `test/spend.test.ts`
|
||||
|
||||
- `maxSpendPerTurn?: number` in `SessionOptions` + `Config`.
|
||||
- In `send()`, snapshot `this.spend().usd` at turn start. At each step completion (after usage), check if spend delta exceeds cap → abort with notice.
|
||||
- `/cost` shows per-turn cap if set.
|
||||
- Config loading: `config.maxSpendPerTurn` optional number.
|
||||
- Test: mock model, set tiny cap, assert turn stops early.
|
||||
|
||||
### 7. `/fork` — fork session at a turn
|
||||
**Files:** `src/session.ts`, `src/commands.ts`, `src/ui/App.tsx`, `test/fork.test.ts`
|
||||
|
||||
- `Session.fork(atIndex?: number): ModelMessage[]` — takes messages up to `atIndex` (default: before the last user message), returns deep-cloned copy. Does NOT modify current session.
|
||||
- `/fork` command (type `'fork'`) — forks to a new in-memory session with the cloned messages, preserving same options. Returns the new session's id.
|
||||
- For simplicity: `/fork` clones messages up to the second-to-last user message, pushes into a new session object, and reports the id. The user then `/resume` the forked session.
|
||||
- Test: build session with 3 turns, fork at -1, assert copy has 1 fewer user message.
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
bun run typecheck
|
||||
bun test
|
||||
bun run build
|
||||
```
|
||||
@@ -0,0 +1,131 @@
|
||||
# Project-Driven Agent Workflow
|
||||
|
||||
Status: spec
|
||||
Date: 2026-09-09
|
||||
|
||||
## Why
|
||||
|
||||
The user wants shiro-neko agents to produce the same quality as this repo
|
||||
itself: docs-driven development, TODO.md + ROADMAP.md lifecycle, spec-first
|
||||
plans, complete unit tests, verify-before-done. Today the agent only reads
|
||||
AGENTS.md/CLAUDE.md/.shiro.md; it has no visibility of the project's task
|
||||
tracking, roadmap, or docs conventions, and nothing reminds it to keep them
|
||||
current.
|
||||
|
||||
## Scope
|
||||
|
||||
- Prompt-level workflow policy (rendered in system prompt when enabled)
|
||||
- Boot loading of TODO.md + ROADMAP.md (compact, capped)
|
||||
- Lifecycle nudge: after a turn that wrote files without touching the task
|
||||
list, emit a soft reminder
|
||||
- `/workflow` command: show project workflow state
|
||||
- Config: `workflow.enabled` (default true), `workflow.docsDir` (default `docs/`)
|
||||
- Docs: docs/workflow.md + ROADMAP/TODO entries
|
||||
- Tests: prompt rendering, boot load, lifecycle nudge, config merge, /workflow
|
||||
|
||||
## Out of scope (explicitly not doing)
|
||||
|
||||
- New tools (no permission surface, no storage)
|
||||
- Blocking / hard gates (agent stays in control; nudges only)
|
||||
- Auto-updating TODO.md (the agent does it via existing write tools)
|
||||
- Skill/plugin autoloading (existing system stays)
|
||||
|
||||
## Files touched
|
||||
|
||||
- src/config.ts — `workflow?: { enabled?: boolean; docsDir?: string }` + merge arm
|
||||
- src/prompt.ts — WorkflowPolicy block in renderPrompt; depends on config
|
||||
- src/instructions.ts — load TODO.md + ROADMAP.md from git root; format compact
|
||||
- src/session.ts — SessionOptions.workflow config; after-turn nudge when
|
||||
fileChangeSeq bumped && no todo_write this turn
|
||||
- src/commands.ts — `/workflow` command (status summary)
|
||||
- src/cli.tsx — pass config; register /workflow
|
||||
- src/ui/App.tsx + panel-bodies.ts — /workflow panel
|
||||
- docs/workflow.md — new; ROADMAP.md, TODO.md updated
|
||||
- test/workflow.test.ts — new
|
||||
|
||||
## Design
|
||||
|
||||
### Config (config.ts)
|
||||
|
||||
```ts
|
||||
export type WorkflowConfig = {
|
||||
enabled?: boolean; // default true
|
||||
docsDir?: string; // default 'docs'
|
||||
};
|
||||
```
|
||||
|
||||
Merged in config.merge (same pattern as maxSpendPerTurn).
|
||||
|
||||
### Prompt block (prompt.ts)
|
||||
|
||||
Rendered only when workflow.enabled !== false, and only when the project has
|
||||
TODO.md/ROADMAP.md/docs/ (so a bare repo gets no noise). Wording:
|
||||
|
||||
```
|
||||
Project workflow (this repo tracks its own progress). When the project has a
|
||||
TODO.md, read it before starting work and keep it current as you go:
|
||||
- mark done what you finished, and the sub-task you are on
|
||||
- add tests alongside code; the project expects complete unit tests
|
||||
- update ROADMAP.md when you ship a milestone
|
||||
- for anything non-trivial, write a short plan (spec-first) before code
|
||||
- verify with the project's check commands before declaring done
|
||||
```
|
||||
|
||||
Keyed in promptCache versions as `wf` so toggling the flag re-renders.
|
||||
|
||||
### Boot load (instructions.ts)
|
||||
|
||||
`loadInstructions` also collects `<gitroot>/TODO.md` and `<gitroot>/ROADMAP.md`
|
||||
when present, capped (e.g. 6_000 chars each), rendered as:
|
||||
|
||||
```
|
||||
--- TODO.md (project task list) ---
|
||||
<first N lines, preserving section headers>
|
||||
```
|
||||
|
||||
Rendered AFTER instructions, BEFORE notebook/memory. So the agent always knows
|
||||
what the project is tracking before it starts.
|
||||
|
||||
### Lifecycle nudge (session.ts)
|
||||
|
||||
After a turn's stream finishes (where fileChangeSeq is known): if
|
||||
`workflow.enabled !== false` AND the turn wrote files (fileChangeSeq bumped)
|
||||
AND the turn did NOT call todo_write AND the project has a TODO.md at the git
|
||||
root AND this is not the 1st turn (avoid nudge at boot): emit one `info` line
|
||||
via the normal notice mechanism:
|
||||
|
||||
```
|
||||
reminder: you modified files without updating the project task list (TODO.md).
|
||||
Keep it current: mark what you did.
|
||||
```
|
||||
|
||||
This is one soft line, not a stop; the run continues normally. Tracked as
|
||||
`workflowNudged` so it fires at most once per run (and once per session).
|
||||
|
||||
### `/workflow` command
|
||||
|
||||
Status summary rendered in a panel:
|
||||
- workflow.enabled from config
|
||||
- TODO.md present? yes/no + line count
|
||||
- ROADMAP.md present? yes/no + line count
|
||||
- docsDir exists? yes/no
|
||||
- docs/ file count
|
||||
- tests: count of *.test.ts / *.test.tsx in tree (bounded)
|
||||
- nudged count this session
|
||||
|
||||
### Tests (test/workflow.test.ts)
|
||||
|
||||
1. config merge: workflow.enabled + docsDir survive loadConfig merge
|
||||
2. prompt: workflow policy rendered when enabled + TODO present; absent when
|
||||
disabled or bare repo
|
||||
3. instructions: TODO.md + ROADMAP.md loaded from git root, capped
|
||||
4. lifecycle nudge: turn writes file, no todo_write -> info message once
|
||||
5. no nudge when todo_write was called
|
||||
6. /workflow command parses + panel renders
|
||||
|
||||
## Verification
|
||||
|
||||
- bun run typecheck
|
||||
- bun test test/workflow.test.ts test/config.test.ts test/commands.test.ts
|
||||
- full bun test (background)
|
||||
- bun run build
|
||||
@@ -0,0 +1,134 @@
|
||||
# v9 — Provider prompt caching, registry trust, external hooks, /init scaffold, workflow plan nudge, nudge escalation, skill autoload
|
||||
|
||||
Date: 2026-09-09
|
||||
Status: spec
|
||||
|
||||
## Why
|
||||
|
||||
User asked what was missing; recommended: provider-side prompt caching (#1),
|
||||
registry trust (#3), external hooks (#4), `/init` TODO/ROADMAP scaffold (#5),
|
||||
workflow->plan skill nudge (#7), nudge escalation (#6). Structured diff
|
||||
review (#2) is **deferred**: the SDK's tool-approval model cannot express
|
||||
per-hunk approval, and it needs a rewrite hook that does not exist yet. This
|
||||
spec covers the six implementable items.
|
||||
|
||||
## Scope per item
|
||||
|
||||
### 1. Provider prompt caching (ROADMAP Next #1)
|
||||
|
||||
Client-side memoization already makes the system prompt byte-identical across
|
||||
steps when nothing volatile changed. The remaining half: tell the provider to
|
||||
cache the stable prefix.
|
||||
|
||||
- Anthropic: `cache_control: { type: 'ephemeral' }` on the system prompt
|
||||
block when the model is Anthropic. SDK supports
|
||||
`system: [{ type: 'text', text, cache_control }]`.
|
||||
- OpenAI: automatic prefix caching — nothing to send; skip.
|
||||
- Implement: `src/prompt.ts` splits the rendered system prompt into a stable
|
||||
prefix (everything before the volatile notebook/memory/skills suffix) and
|
||||
the volatile suffix; `session.ts` decides where to cut based on version
|
||||
counters. When only volatile parts changed (notebook/memory/etc), keep the
|
||||
stable prefix byte-identical and mark it cacheable; the volatile tail rides
|
||||
the same request but does not invalidate the prefix cache.
|
||||
- Provider routing: `Session` knows its model provider via `opts.modelId` /
|
||||
a `provider` option. Add `cacheSystemPrefix?: boolean` option; enable when
|
||||
provider is anthropic. SDK's own `cache_control` for Anthropic system
|
||||
arrays: `system: [...]` accepts per-block cache_control.
|
||||
- Tests: unit test that the splitter produces the same stable prefix across
|
||||
differing notebook versions; an Anthropic-format prompt carries
|
||||
`cache_control` on the stable block; OpenAI-format omits it.
|
||||
|
||||
### 3. Registry trust (ROADMAP Next)
|
||||
|
||||
An index is trusted for its contents, not its authorship. Add publisher
|
||||
signature verification for registry entries:
|
||||
|
||||
- `src/registry.ts` manifest gains optional `signature` + `signer` fields.
|
||||
- A pinned public key per publisher in config (`registry.publishers[<name>] =
|
||||
ed25519 pubkey`). Use Node's `crypto.verify` with ed25519 (Node ≥ 16 has
|
||||
`crypto.verify` for ed25519 via `createPublicKey`).
|
||||
- On install: when a manifest has `signature`, verify against the configured
|
||||
publisher key; mismatch → refusal naming the publisher and the expected key.
|
||||
When the manifest has no signature → refused with "unsigned; add the
|
||||
publisher key or install manually" unless `registry.allowUnsigned`.
|
||||
- Legacy manifests (no signature field) remain installable only with
|
||||
`allowUnsigned`; a signed one with an unknown publisher → refusal.
|
||||
- Tests: signature verifies; tampered body fails; unsigned refused unless
|
||||
allowUnsigned; unknown publisher refused.
|
||||
|
||||
### 4. External hooks (ROADMAP Later)
|
||||
|
||||
Pre-tool hooks that can rewrite tool input, with a trust story like Codex:
|
||||
- `.shiro/hooks/` directory; each hook is an executable + a small manifest
|
||||
(`name`, `hook` = `pre_tool` | `after_turn` | ...; `tools` = `["*"]` or
|
||||
names).
|
||||
- On load, hash each hook file (sha256); first run, hash is shown and the
|
||||
user approves or denies (approval prompt). The hash is recorded in
|
||||
`~/.shiro-neko/hooks.json` (name → hash → approved). A hook whose hash
|
||||
changed since approval is refused until re-approved.
|
||||
- `pre_tool`: stdin gets `{tool, input, cwd}`, stdout gets
|
||||
`{"allow": true, "input": {...rewritten...}}` or `{"allow": false, "reason": ...}`
|
||||
or `{"blocked": "..."}`. A throwing/odd exit is treated as a block.
|
||||
- `after_turn`: stdin gets a turn summary; output ignored.
|
||||
- These extend `PluginHost.guard` (beforeToolCall) — a hook runs after the
|
||||
compiled plugins and before the permission check, so a hook cannot bypass
|
||||
the safety rules but can rewrite or refuse.
|
||||
- Cap: `hookTimeoutMs` default 5000; a hook that hangs is killed.
|
||||
- Tests: manifest parse; pre_tool rewrites input (default allow); pre_tool
|
||||
refusal blocks; unknown hook hash blocks until approved; after_turn runs;
|
||||
timeout kills a hung hook; a hook that returns garbage blocks.
|
||||
|
||||
### 5. `/init` scaffold TODO/ROADMAP/docs
|
||||
|
||||
`/init` already writes AGENTS.md. Extend it to optionally scaffold the
|
||||
project-workflow files too:
|
||||
- `initPrompt` stays; after the agent writes AGENTS.md, if the repo lacks
|
||||
TODO.md/ROADMAP.md/docs and the user wants the scaffold, write:
|
||||
- `TODO.md`: `# TODO\n\nNow\n\nNext\n\nMaintenance\n\n`
|
||||
- `ROADMAP.md`: `# Roadmap\n\n## Next\n\n`
|
||||
- `docs/` empty dir (or a placeholder README)
|
||||
- CLI flag `/init` gains no new args; the agent's prompt gains the option.
|
||||
Headless: `--init-scaffold` flag writes the files directly (no model).
|
||||
- Tests: scaffold produces the three files when missing; does not overwrite
|
||||
existing ones.
|
||||
|
||||
### 6. Workflow nudge escalation
|
||||
|
||||
Today: one nudge per session, then silence. Change to a gentle ladder:
|
||||
- Nudge 1: same as today.
|
||||
- Nudge 2 (if a later turn also writes without todo_update): "still no update
|
||||
to TODO.md; the project expects its task list kept current."
|
||||
- Nudge 3: final, "last reminder; update TODO.md when you have a moment."
|
||||
- Cap at 3 total (never infinite). `workflowNudged` → `workflowNudgeCount`.
|
||||
- Tests: third nudge is the last; count capped.
|
||||
|
||||
### 7. Workflow → plan skill autoload
|
||||
|
||||
When the workflow policy renders (repo tracks progress), inject one extra line
|
||||
so the model knows the `plan` skill exists and to load it before non-trivial
|
||||
work:
|
||||
```
|
||||
- before non-trivial work, load the `plan` skill (spec-first) and follow it
|
||||
```
|
||||
- `workflowPolicy()` gains that line when enabled; test asserts it.
|
||||
|
||||
## Files touched
|
||||
|
||||
- src/prompt.ts — splitter + cache_control for Anthropic
|
||||
- src/session.ts — provider routing, cacheSystemPrefix, nudge ladder, plan line
|
||||
- src/registry.ts — signature verification
|
||||
- src/hooks.ts — new: external hook loader/runner + hash trust
|
||||
- src/plugins.ts — guard extension for hooks
|
||||
- src/cli.tsx — --init-scaffold, hooks approval wiring
|
||||
- src/config.ts — registry.publishers, registry.allowUnsigned, hooks.enabled
|
||||
- src/init.ts or cli — scaffold writer
|
||||
- docs/: hooks.md, registry.md, workflow.md, headless.md updates
|
||||
- test/hooks.test.ts, registry-signature.test.ts, prompt-cache-split.test.ts,
|
||||
init-scaffold.test.ts, nudge-ladder.test.ts
|
||||
|
||||
## Verification
|
||||
|
||||
- bun run typecheck
|
||||
- bun test (new files + full suite)
|
||||
- bun run build
|
||||
- Manual: headless --init-scaffold, a hook that rewrites input, a signed registry entry
|
||||
@@ -0,0 +1,64 @@
|
||||
# shiro-neko
|
||||
|
||||
Agentic coding CLI, built with Bun + TypeScript. The interactive UI is React rendered to the
|
||||
terminal with Ink; LLM access goes through the Vercel AI SDK (`ai`) with Anthropic, OpenAI,
|
||||
OpenAI-compatible, and MCP providers. Entry point and only executable is `src/cli.tsx` (bin `shiro`).
|
||||
|
||||
## Commands
|
||||
|
||||
- `bun install --frozen-lockfile` — install deps (CI uses this; lockfile is `bun.lock`)
|
||||
- `bun run shiro` — run the CLI from source (i.e. `bun run src/cli.tsx`)
|
||||
- `bun test` — full test suite (`bun:test`, no other runner)
|
||||
- `bun run typecheck` — `tsc --noEmit`; must pass before committing
|
||||
- `bun run build` — `bun build --compile` to `dist/shiro` (single native binary)
|
||||
- `bun run release` — cross-compile all five targets into `dist/release/`
|
||||
- No linter or formatter is configured; don't invent one.
|
||||
|
||||
CI (`.github/workflows/ci.yml`) runs install → typecheck → test → build on Ubuntu, macOS, and
|
||||
Windows, pinned to Bun 1.3.14. Everything must be cross-platform: the tools shell out to the
|
||||
platform shell, and paths in code and tests go through `node:path`, never hardcoded `/`.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/` — flat modules, one concern per file, lowercase names (`session.ts`, `prune.ts`).
|
||||
`src/ui/` holds the Ink components, PascalCase (`App.tsx`, `Panels.tsx`).
|
||||
- `test/` — one `<name>.test.ts` per `src/<name>.ts`; `*.test.tsx` for UI tests via
|
||||
`ink-testing-library`. `test/helpers.ts` has `testHooks()`, the standard App fixture.
|
||||
- `docs/` — user-facing docs, one per feature area.
|
||||
- `scripts/` — `release.ts`, `install.ts` (+ `.sh`/`.ps1` installers).
|
||||
- `src/version.ts` — hardcoded VERSION; the release workflow fails if it disagrees with the git tag.
|
||||
|
||||
## Conventions
|
||||
|
||||
- ES modules, `verbatimModuleSyntax` on: import types with `import type`. Strict TS with
|
||||
`noUncheckedIndexedAccess` — indexing gives `T | undefined`, so handle it (`arr[i]!` appears
|
||||
where provably safe).
|
||||
- Uses Bun APIs directly (`Bun.file`, `Bun.write`, `Bun.spawn`, `Bun.Glob`) — no fs-extra, no
|
||||
node shims. File tools read/write through `Bun.*`, not `fs`, where practical.
|
||||
- Path safety: every user/model-supplied path goes through `jail()` (in `src/ignore.ts`), which
|
||||
rejects escapes outside `process.cwd()`. Tools resolve paths against `process.cwd()`.
|
||||
- Error handling: tool `execute` functions return error text to the model or throw `Error` with a
|
||||
plain message — no error classes, no codes. Storage reads (`store.ts`, `memory.ts`) catch and
|
||||
degrade to empty rather than throw on corrupt JSON.
|
||||
- Tools are AI-SDK `tool()` objects with zod `inputSchema`. Any tool that mutates the workspace
|
||||
must be added to `MUTATING_TOOLS` in `src/tools.ts` — a test in `permission.test.ts` fails
|
||||
otherwise. The permission system (`src/permission.ts`) matches rules against the tool's subject
|
||||
(command for `bash`, path for file tools) with glob matching, and is pure/no-IO on purpose.
|
||||
- Comments explain *why*, often naming the failure being guarded against. Match that style.
|
||||
- Tests import from `bun:test`, build mock models with `MockLanguageModelV4` +
|
||||
`simulateReadableStream` from `ai/test`, and each test that touches the filesystem does
|
||||
`process.chdir()` into a fresh `mkdtemp` dir in `beforeEach` and restores in `afterEach`.
|
||||
|
||||
## Surprising / easy to break
|
||||
|
||||
- `bun test` runs the whole suite including `fallback-live.test.ts`, which spins up real local
|
||||
HTTP servers via `Bun.serve`, and `commit.test.ts`, which runs real `git` in temp repos — both
|
||||
need a working network stack and git on PATH.
|
||||
- Tool output is capped (`MAX_OUTPUT` in `src/tools.ts`); read_file returns NUL-sniffed binary
|
||||
files as an error. Don't remove these — they stop a model from burning its context.
|
||||
- Ink renders to the terminal, so library warnings are suppressed at the top of `cli.tsx`
|
||||
(`AI_SDK_LOG_WARNINGS = false`); anything written to stderr tears the UI.
|
||||
- Sessions, memory, and history live under `~/.shiro-neko/`, relocatable with `SHIRO_HOME`;
|
||||
tests depend on that env var to isolate state. Don't resolve the path eagerly at module load —
|
||||
`store.ts` resolves it per call for this reason.
|
||||
- Release tags must match `src/version.ts` exactly or `release.ts` stops the build.
|
||||
@@ -0,0 +1,265 @@
|
||||
# Audit
|
||||
|
||||
Checklist from a full audit of the codebase, run against `main` at `ffa9a02` ("release 1.0.0").
|
||||
The greps cover every file under `src/`, `test/`, `docs/`, `.github/workflows/`, and `scripts/`.
|
||||
|
||||
Nothing here is a fix — it is a list. Items already tracked in `TODO.md` or `ROADMAP.md` say so;
|
||||
untracked items are marked **not yet tracked**. Items checked off were resolved after the audit,
|
||||
in the same working tree.
|
||||
|
||||
The three bugs and the two untracked doc-drift items from the beta.5 audit are all fixed; this pass
|
||||
also cleared the dead method, the three stale doc lines, and every UI coverage gap that remained —
|
||||
`cli.tsx`, `Onboard.tsx`, `panel-bodies.ts`, `buses.ts`, and `PromptInput.tsx` — see
|
||||
[section B](#b-bugs), [section D](#d-documentation-drift-untracked), and
|
||||
[section E](#e-test-coverage-gaps).
|
||||
|
||||
---
|
||||
|
||||
## A. Clean findings (verified, no action needed)
|
||||
|
||||
- [x] **No TODO/FIXME/HACK markers in `src/`.** Seven matches, all false positives: the
|
||||
`TODO_MARK` export and its uses (`src/notebook.ts:120`, `src/ui/transcript.ts:1,201,203`,
|
||||
`src/ui/Panels.tsx:4,34`) and a `TODO` inside the `commit` skill's prose
|
||||
(`src/skills-md/commit.md:21`). Zero real markers.
|
||||
- [x] **No `as any` / `@ts-ignore` / `@ts-expect-error` / `@ts-nocheck` / `: any` in `src/`.**
|
||||
One grep hit and it is the word "any" in a skill's prose (`src/skills-md/readme.md:36`).
|
||||
Zero casts. The project's own `docs/development.md` rule is being kept.
|
||||
- [x] **No empty catch blocks and no silently swallowed errors in `src/`.** `catch {}` and
|
||||
`catch (e) {}` match zero times. 83 `catch` sites were grepped and the named ones reviewed:
|
||||
- `src/tools.ts:81-82` — a batch read failure is reported in place (`[unreadable: ...]`).
|
||||
- `src/tools.ts:463` — a stat probe returns `false` (sentinel, not a swallow).
|
||||
- `src/tools.ts:501` — `rg` unavailable → `undefined`, falls back to the JS grep.
|
||||
- `src/tools.ts:551,849` — an unreadable file is skipped during a scan (deliberate).
|
||||
- `src/tools.ts:630` — `taskkill` absent → plain kill (commented).
|
||||
- `src/tools.ts:795,889` — stat / JSON failure → a descriptive `Error`.
|
||||
- `src/subagent.ts:248` — usage unavailable on an errored run (commented); `:251` reports
|
||||
the error **and rethrows** (never swallowed).
|
||||
- `src/registry.ts` — parse failures become descriptive errors; the index is schema-validated.
|
||||
- `src/plugins.ts:59-63` — a throwing plugin hook fails **closed** (blocks the call).
|
||||
- `src/ui/App.tsx` — all 17 catch blocks surface the message in the UI.
|
||||
- `src/plugins-builtin.ts` — the one quiet catch (a missing formatter binary) is deliberate
|
||||
and commented.
|
||||
- [x] **No skipped tests.** No `.skip`, `xit`, or `xdescribe` in `test/`.
|
||||
- [x] **Registry fetches are size-capped and schema-validated.** `src/registry.ts` caps
|
||||
content-length and body bytes (`fetchText`, lines 98-109), validates the index with
|
||||
`indexSchema` (lines 120-123), and regex-validates every plugin manifest pattern
|
||||
(lines 164-173).
|
||||
- [x] **Version consistency is enforced three times.** `package.json` vs `src/version.ts`
|
||||
(`scripts/release.ts:60-64`), the release tag vs `VERSION` via `GITHUB_REF_NAME`
|
||||
(`scripts/release.ts:66-70`), and the built binary's own `--version` output in CI
|
||||
(`.github/workflows/release.yml:42-46`). Both files currently read `1.0.0`.
|
||||
- [x] **Install scripts match the build targets.** `test/ci.test.ts` iterates every `TARGETS`
|
||||
entry from `scripts/release.ts:13-19` and asserts the shell/PowerShell installers fetch
|
||||
exactly those asset names (~lines 89-94), plus checksum verification (~74-78) and version
|
||||
pinning / target directory (~81-87).
|
||||
- [x] **`.env`/`.pem` are refused on read; writes and commands ask.** The default permission
|
||||
table (`src/permission.ts:175-186`) matches the approvals banner in `README.md:72`. Unknown
|
||||
tools (MCP, plugins) default to `ask` rather than allow (`src/permission.ts:235-236`), so
|
||||
`mcp__*` needs no explicit rule.
|
||||
- [x] **`--yolo` cannot bypass the guard plugin.** `check()` returns a `deny` before the `yolo`
|
||||
fold (`src/permission.ts:282`), the fold itself only touches `ask` (`:293`), and the guard
|
||||
plugin refuses destructive commands in `beforeToolCall`, ahead of any approval.
|
||||
- [x] **Pinned toolchain.** Both workflows pin `bun-version: 1.3.14`
|
||||
(`.github/workflows/ci.yml:21`, `.github/workflows/release.yml:23,35`), and
|
||||
`test/ci.test.ts:54` fails if the pin ever disagrees with the local `Bun.version`.
|
||||
- [x] **All three platforms in CI.** `ci.yml:16` runs the suite on ubuntu, macos, windows
|
||||
(required — the tools shell out to `rg`, git, and a platform shell).
|
||||
|
||||
---
|
||||
|
||||
## B. Bugs
|
||||
|
||||
Fixed — the three from the beta.5 audit, the dead-code finding this audit surfaced, and a cursor
|
||||
rendering bug found from a screenshot afterwards.
|
||||
|
||||
- [x] **`src/ui/PromptInput.tsx` — the cursor was drawn with hand-written SGR escapes.** `invert()`
|
||||
built the caret by pasting `\u001B[7m`/`\u001B[27m` into the text string
|
||||
(`invert(placeholder.slice(0, 1))`, and the same for the character under the cursor). Ink
|
||||
measures string content as printable columns, so those escapes were counted as text and the
|
||||
rest of the line was written one cell to the right — the orphaned first letter of the
|
||||
placeholder (`t ype to queue for the next turn…`), and a corrupted cell wherever the line
|
||||
wrapped. Replaced with Ink's `inverse` prop in all three render paths. The old tests passed
|
||||
*because* they asserted on the escape-producing helper; they now assert the rendered text is
|
||||
free of escapes, which is the property that actually matters.
|
||||
- [x] **`src/ui/App.tsx` — memory-command formatting glitch (was line 613).** The `}` closing the
|
||||
`try` is now on its own line; the block reads cleanly at `src/ui/App.tsx:636-646`.
|
||||
- [x] **`.github/workflows/release.yml` — the `dry_run` input is no longer dead.** It is wired
|
||||
into the `publish` job's `if`: a tag push always publishes, a manual dispatch publishes only
|
||||
when `dry_run` is unchecked (`release.yml:56-60`).
|
||||
- [x] **`README.md` — tool count was stale, now correct.** It says "Forty-one built-in tools"
|
||||
(`README.md:116`) and "41 built-in tools" (`README.md:164`), matching `docs/tools.md:49`.
|
||||
- [x] **`src/permission.ts` — `granted_` was dead code, now deleted.** A public method with a
|
||||
trailing underscore (the convention here is a `_`-*prefix* for an unused parameter, not a
|
||||
suffix). It returned the session's granted patterns and was called nowhere — not in `src/`,
|
||||
not in `test/`. Removed; `check()` and `grant()` are the live surface.
|
||||
|
||||
---
|
||||
|
||||
## C. Gaps / not implemented (official — tracked in TODO.md or ROADMAP.md)
|
||||
|
||||
### TODO.md "Now" — next up
|
||||
|
||||
- [ ] **Summarize the pruned span.** Compaction keeps the model's memory of a turn but tells it
|
||||
nothing about the messages it dropped, so a decision from earlier in the session can be
|
||||
contradicted with confidence. (TODO.md `## Now`; ROADMAP `## Next` "Lossless-enough
|
||||
compaction" — same work)
|
||||
- [ ] **Hot-reload an installed entry.** `/registry add` writes the file and says restart; the
|
||||
skill catalogue and the guard chain are assembled at boot. (TODO.md `## Now`)
|
||||
|
||||
### TODO.md "Next"
|
||||
|
||||
- [ ] **MCP without the schema tax.** Every MCP tool's schema is in the prompt on every request
|
||||
and `toolSets` does not gate them; a twenty-tool server costs ~2,750 tokens a turn whether
|
||||
used or not. Plan: `mcp_list` / `mcp_inspect` / `mcp_call` meta-tools, prompt names servers
|
||||
not schemas. (TODO.md `## Next`; ROADMAP `## Next`)
|
||||
- [ ] **Derive the tool-name lists.** `TOOL_SETS` and `MUTATING_TOOLS` are hand-maintained; a tool
|
||||
added to one and forgotten in the other is a silently ungated write. (TODO.md `## Next`;
|
||||
ROADMAP `## Next` "Derived tool metadata")
|
||||
- [ ] **Subagent parallelism.** Two independent searches run sequentially; the panel already
|
||||
renders several agents, the loop does not fan out. (TODO.md `## Next`; ROADMAP `## Later`)
|
||||
- [ ] **Undo a turn.** `/resume` restores a session but nothing walks one step back; `bash`
|
||||
effects cannot be snapshotted and the docs would say so. (TODO.md `## Next`; ROADMAP `## Next`)
|
||||
|
||||
### ROADMAP "Next" — tracked, not yet scheduled in TODO.cpp-equivalent detail
|
||||
|
||||
- [ ] **Registry trust.** No signatures; `registryUrl` is the whole trust decision. Publisher
|
||||
keys plus a pinned digest per entry. (ROADMAP `## Next`; TODO.md Known rough edges)
|
||||
- [ ] **`web_fetch` leftovers.** `web_fetch` itself shipped in beta.4. What remains declined:
|
||||
thin wrappers around a single bash line (`run_tests`, `typecheck`, `lint`, `build`) that add
|
||||
only schema tax. (ROADMAP `## Next`)
|
||||
|
||||
### ROADMAP "Later"
|
||||
|
||||
- [ ] **Session branching**, **structured diff review**, **plugin code from disk** (needs a
|
||||
sandbox story), **prompt caching** (stable prefix vs volatile suffix), **external hooks**
|
||||
(needs a trust story), **OS-level sandboxing** (Seatbelt/Landlock/Windows equivalent).
|
||||
(ROADMAP `## Later`)
|
||||
- [ ] **Deliberately declined** (do not "fix"): web UI, model-agnostic prompt tuning, auto-commit,
|
||||
vector search, tool-call retries, client/server split, LSP integration — all recorded in
|
||||
ROADMAP `## Declined`.
|
||||
|
||||
---
|
||||
|
||||
## D. Documentation drift (untracked)
|
||||
|
||||
The two items from the beta.5 audit are resolved: `README.md` now says 41, and `TODO.md` has a
|
||||
complete `## Done` section for the 1.0.0 batch (with the history in ROADMAP `## Shipped`). Three
|
||||
stale lines were found in this pass, and all three are now corrected:
|
||||
|
||||
- [x] **`docs/development.md:101` said "Nineteen built-in tools".** Now "Forty-one", matching the
|
||||
registry; the sentence warns that selection accuracy degrades past a certain count, so the
|
||||
number matters.
|
||||
- [x] **`docs/headless.md:54` used "There are 16 built-in tools."** as its sample JSON output.
|
||||
Now "41", so the example matches the tool list.
|
||||
- [x] **`docs/headless.md:185-186` said there was no spend ceiling yet.** Rewritten to document
|
||||
`maxSpendUsd` (`src/config.ts:24`): warns once at 80%, refuses the next turn past 100%, and
|
||||
the run exits non-zero — enforced only on priced models.
|
||||
- [x] **`docs/registry.md` and `docs/headless.md`** are referenced by the README table and both
|
||||
exist — verified clean, no action.
|
||||
|
||||
---
|
||||
|
||||
## E. Test-coverage gaps
|
||||
|
||||
- [x] **`src/cli.tsx` (666 lines) now has an entry-point test — `test/cli.test.ts`.** The module
|
||||
is an executable, not a library: importing it parses argv, loads config, connects MCP, and
|
||||
renders Ink, which is why nothing imported it before. The test runs the real entry point as a
|
||||
child process (via `process.execPath`, so it is cross-platform) with a scratch `SHIRO_HOME`,
|
||||
a scratch cwd, and every API-key variable stripped, so the no-key branches are deterministic.
|
||||
Ten cases cover the argument surface: `--help`/`-h`, `--version`/`-v`, an unknown `--agent`,
|
||||
an unknown `--think`, `-p` without a key, `--resume` with no match, `--continue` with no
|
||||
saved session, and a configured key with no prompt (that last run also passes all six
|
||||
`--no-*` isolation flags through the boot path). Paths past `render()` need a TTY and remain
|
||||
uncovered — provider `/provider` wiring through the wizard included.
|
||||
- [x] **`src/ui/Onboard.tsx` (211 lines) now has a test — `test/onboard.test.tsx`.** The wizard
|
||||
renders standalone and its only network call is `fetchModels`, which uses the global `fetch`,
|
||||
so the suite stubs it: the flow is exercised offline and deterministically, with the two API
|
||||
key env vars cleared so a developer's shell never picks the branch. Six cases cover the
|
||||
provider list and its `(current)` marker, esc from both the list and the api-key step, a
|
||||
custom endpoint collecting url + key + a sorted model pick, the env-key shortcut (key step
|
||||
skipped, hint masked to `sk-a...1234`), manual model-id entry, and the "could not list
|
||||
models" fallthrough when the server errors. `current` sets the starting row, so a test names
|
||||
a preset rather than counting arrow presses.
|
||||
- [x] **`src/ui/PromptInput.tsx` (175 lines) now has a direct test — `test/prompt-input.test.tsx`.**
|
||||
It was already covered through `App` in `input.test.tsx` (history recall and stash, arrow and
|
||||
word motion, ctrl-u, ctrl-d, paste); what was left needed the component on its own, so this
|
||||
renders it directly with a controlled `Harness`. Fifteen cases cover the caret rendering (a
|
||||
bare focused caret, a blurred input with none, the placeholder inverting its first character
|
||||
only while focused, the cursor sitting under the character it points at), the kill keys
|
||||
(ctrl-a/ctrl-e, ctrl-k, ctrl-w, including a single word emptying the line), the `mask` (hidden
|
||||
value and the caret after a deletion), `onKey` swallowing a key before the input sees it while
|
||||
declining others, `initialCursor`, an external `value`, submit, and insert-at-cursor. Two
|
||||
expected frames were probed against the real renderer rather than assumed: a whitespace-only
|
||||
`Text` trims to `''`, and a mask renders the caret *after* the stars.
|
||||
- [x] **`src/ui/panel-bodies.ts` (78 lines) now has a direct test** — `test/ui-bodies.test.tsx`,
|
||||
shared with `buses.ts`. The four panels are pure functions of session and hook state, so
|
||||
they run without mounting Ink: the tools panel (sets named, a read-only agent narrowing it,
|
||||
the offered/registered hint), the cost panel (priced turn, unpriced model, the subagent line
|
||||
priced against its own model, the ceiling line), the context panel's empty branch, and the
|
||||
todos panel fed through the real `todo_write` tool.
|
||||
- [x] **`src/ui/buses.ts` (75 lines) now has a direct test.** `createNoticeBus` and
|
||||
`createSubagentBus` are covered: a pre-bind emit is queued and delivered in order on bind, a
|
||||
post-bind emit passes through, and a rebind takes over without replaying the queue.
|
||||
`applySubagentEvent` gains the cases the existing `ui-panels` test left open — a result
|
||||
attaching to its step, a mismatched or duplicate result being ignored, end/error status
|
||||
flips, an unknown id being a no-op, and two agents interleaving without crossing steps.
|
||||
- [ ] **13 `as any` casts across 11 test files.** Down from 29 across 17. The identical mock
|
||||
`usage` object (`{ inputTokens: {...}, outputTokens: {...} } as any`) is still repeated
|
||||
across several UI test files — a shared typed fixture in `test/helpers.ts` would remove the
|
||||
repetition and the casts in one move. Production `src/` remains clean; this is test-only
|
||||
debt. **not yet tracked**
|
||||
|
||||
---
|
||||
|
||||
## F. Maintenance debt
|
||||
|
||||
- [ ] **Pricing table is hand-entered with no source note or date** — `src/pricing.ts:8-22`.
|
||||
Rates drift. Tracked in TODO.md `## Maintenance`.
|
||||
- [ ] **`estimateTokens` divides JSON length by four** — `src/session.ts:97` (the thinking panel
|
||||
does the same at `src/ui/Panels.tsx:193`). Fine as a compaction threshold, misleading in
|
||||
`/cost`. Tracked in TODO.md `## Maintenance`.
|
||||
- [ ] **`listPaths` walks up to 5000 files once per session** — `src/cli.tsx:387-389`. Fine for
|
||||
a repo, wasteful in a monorepo, never notices a file created after the first `@`. Tracked in
|
||||
TODO.md.
|
||||
- [ ] **`MUTATING_TOOLS` (`src/tools.ts:980`) is only used by tests and docs.** The runtime gate
|
||||
is `DEFAULT_PERMISSIONS` plus the unknown-tool `ask` default. Tracked in TODO.md — either
|
||||
delete it or make `DEFAULT_PERMISSIONS` derive from it.
|
||||
- [ ] **Oversized modules, all grown again in the 1.0.0 batch.** `src/ui/App.tsx` (1002),
|
||||
`src/tools.ts` (990), `src/cli.tsx` (666), `src/session.ts` (637), `src/ui/Panels.tsx`
|
||||
(579). Not a bug — a "who reads 990 lines" concern. **not yet tracked**
|
||||
- [ ] **CI actions are on Node 20.** The last release run annotated that `actions/checkout@v4`,
|
||||
`setup-bun@v2`, `upload-artifact@v4`, `download-artifact@v4` are being forced onto Node 24.
|
||||
They still work; the major-version bumps will become the silent fix. Not verifiable from the
|
||||
tree — watch for the annotation to turn red. **not yet tracked**
|
||||
|
||||
---
|
||||
|
||||
## G. Known rough edges (tracked in TODO.md, reproduced here for the record)
|
||||
|
||||
- [x] `/clear` wipes terminal scrollback (escape sequence takes earlier history with it).
|
||||
- [x] Memory has no conflict resolution; two contradictory notes both inject.
|
||||
- [x] Windows `cmd /c` vs `bash -lc` — the prompt names the platform, does not translate.
|
||||
- [x] An unknown name in `toolSets` is dropped silently — reads as "that set is off".
|
||||
- [x] Permission rules gate the call, not what it does — no OS sandbox around the shell.
|
||||
- [x] The reasoning panel is per-turn, not per-step.
|
||||
- [x] An interrupted command's effects are unknown, and the model is told so.
|
||||
- [x] `@` completion lists files, not directories.
|
||||
- [x] An installed skill is a stranger's words in the system prompt; nothing re-checks it later.
|
||||
- [x] A registry index is trusted for its contents, not its authorship.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Area | Items |
|
||||
|---|---|
|
||||
| Bugs | 0 (all five cleared) |
|
||||
| Official gaps (Now/Next/Later) | 8 near-term + 6 Later tracked in TODO.md/ROADMAP.md |
|
||||
| Documentation drift | 0 (three lines corrected) |
|
||||
| Test-coverage gaps | 1 (every UI module now has a test; only the `as any` item below remains) |
|
||||
| Maintenance debt | 6 (4 tracked, 2 untracked) |
|
||||
| Known rough edges | 10 (all tracked) |
|
||||
| Verified clean | 9 areas, including zero casts and zero swallowed errors in `src/` |
|
||||
|
||||
The codebase is in good shape for 1.0.0. The bug list, the doc drift, and every UI coverage gap are
|
||||
cleared; what is left is the test-only `as any` casts and the maintenance debt — all tracked.
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.1]
|
||||
|
||||
### Added
|
||||
|
||||
- **Rendered, resumed history.** A session restored with `-r`/`-c` or `/resume` now shows
|
||||
its saved conversation as real transcript lines instead of a blank prompt, converting the
|
||||
stored wire messages (user, assistant, tool calls and their results) into the same view the
|
||||
live loop paints.
|
||||
- **`/undo` and `/redo`.** Every prompt snapshots the files on disk first (capped near the last
|
||||
100), and `/undo` restores files, trims the conversation, or both. `/redo` reverses it. A
|
||||
`bash` command's side effects are not files and cannot be rolled back, which is stated in the
|
||||
command output rather than hidden.
|
||||
- **Parallel subagents.** The `task` tool accepts several independent investigations under a
|
||||
`tasks` array and runs them on separate context windows at the same time, joining their
|
||||
reports. A single call behaves exactly as before.
|
||||
- **Lazy MCP tools.** By default an MCP server now contributes three meta-tools (`mcp_list`,
|
||||
`mcp_inspect`, `mcp_call`) instead of one schema per server tool, so a server exposing twenty
|
||||
tools stops costing ~2750 tokens per request until one is actually called. Set
|
||||
`"mcpMode": "eager"` to register every server tool up front. Named servers are still listed
|
||||
in the prompt, and calls route through the same permission rules and guard as before.
|
||||
- **Hot-reloaded skill installs.** A skill installed from `/registry` mid-session is callable
|
||||
on the next turn without a restart (the `skill` tool reads its list live, so even the first
|
||||
install works). Plugins and external tools still need a restart because they join the guard
|
||||
chain and tool registry built once at boot.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A resumed session rendered an empty transcript. Loading a saved session populated the wire
|
||||
messages but never rebuilt the on-screen history, so after `-r`/`-c` or `/resume` the talk
|
||||
was blank even though the session data was there.
|
||||
- The walk behind `@file` completion refreshed only once per session; it now re-walks on a slow
|
||||
cooldown so a file created after the first `@` shows up within a short window.
|
||||
- A handful of plugin write tools were mutating but not gated by the permission defaults; the
|
||||
tool set and the mutating list are now derived from a single `mutating()` marker, so a tool
|
||||
can no longer be added to one and forgotten in the other.
|
||||
|
||||
## [1.0.0]
|
||||
|
||||
The first stable release. Cost control, a larger tool and skill surface, custom slash
|
||||
commands, auto-loaded extensions, and a redesigned welcome interface, on top of the beta
|
||||
line's agent loop, approval model, and safety guarantees.
|
||||
|
||||
### Added
|
||||
|
||||
- **Spend ceiling** ("maxSpendUsd" in config). Checked before each turn: past the limit the
|
||||
model is never called, the turn is refused naming the ceiling, headless exits non-zero,
|
||||
and it warns once at 80% of the limit. Unpriced models cannot be measured, so the ceiling
|
||||
does not apply to them.
|
||||
- **Cheaper subagent model** ("subagentModel" in config). "explore" subagents — search, not
|
||||
reasoning — resolve against a configured cheaper model while "review" and "worker" keep the
|
||||
parent's. "/cost" reports subagent spend separately, priced against the subagent's model id.
|
||||
- **Twenty new built-in tools** (41 total) in a new "extra" tool set, across four families:
|
||||
- line edits: insert_lines, delete_lines, replace_lines, append_file, prepend_file, count_lines
|
||||
- filesystem: tree, file_info, find_files, recent_files, changed_files
|
||||
- git (read-only, argv-spawned): git_log_file, git_diff_commits, git_show_file, git_current_branch, git_changed_in_ref
|
||||
- code and environment: find_symbol, json_query, outline, read_symbol, env_info, count_tokens
|
||||
- **Twenty new bundled skills** (29 total), including plan, docs, api-design, ci-cd, db,
|
||||
docker, frontend, git-workflow, logging, optimize-sql, release, accessibility, data, i18n,
|
||||
deps, onboarding, ux-copy, readme, incident, perf-frontend.
|
||||
- **Ten new plugins**, all data. Safety refusals on by default — no-force-push, no-net-pipe,
|
||||
no-root, no-env-write — and opt-in workflow plugins — no-main-commit, no-git-config,
|
||||
confirm-delete, conventional-commit, tests-first, small-diffs.
|
||||
- **Custom slash commands** from Markdown files in ".shiro/commands/" and
|
||||
"~/.shiro-neko/commands/", with frontmatter "description"/"agent", "$ARGUMENTS" and
|
||||
positional "$1", and shell substitution passed through the guard. A custom command can
|
||||
never shadow a built-in.
|
||||
- **Auto-loaded external extensions** from "~/.shiro-neko/{skills,tools,plugins}" and
|
||||
".shiro/{skills,tools,plugins}". All data, never code: tools are bounded manifests (a shell
|
||||
template through the guard, an HTTPS fetch, or a workspace read), plugins are refusal
|
||||
manifests. Malformed files are reported and skipped, never fatal.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Skills now live as Markdown files** in "src/skills-md/", embedded into the compiled binary
|
||||
by Bun text imports, replacing the previous TypeScript string constants. A format test
|
||||
enforces that each parses with valid frontmatter and a real body. The eleven pre-existing
|
||||
skills were also deepened.
|
||||
- **Welcome interface redesigned** into a structured dashboard: a session banner, a grouped
|
||||
environment panel with attention-worthy facts coloured out of the quiet layer, and a meta
|
||||
bar. The prompt input sits in a two-tone box with the agent and model row inside it and a
|
||||
split footer beneath.
|
||||
- **System prompt advanced** with a discrete failure-recovery loop, a delegation policy, and
|
||||
compaction awareness.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Release workflow "dry_run" input is now honoured.** A manual dispatch publishes only when
|
||||
it is unchecked; tag pushes always publish. Previously the input was declared but never read,
|
||||
so a manual run could never publish regardless of its value.
|
||||
- **Documentation drift** corrected across the tool count, the bundled-skill count, the plugin
|
||||
defaults, and the README quickstart.
|
||||
|
||||
## [0.1.0-beta.5]
|
||||
|
||||
- A dead provider item no longer ends the turn: a 404 naming a missing item rewrites the
|
||||
history inline and retries once.
|
||||
- More tools (git_commit_message, git_branch, move_file, delete_file), the "protect" plugin,
|
||||
the security/perf/migrate skills, the "/mcp add" wizard, and the farewell message.
|
||||
|
||||
## [0.1.0-beta.4]
|
||||
|
||||
- Compaction no longer stops the loop, and is bounded to keep the widest recent tool tail.
|
||||
- The external registry for skills and plugins.
|
||||
- Permission rules matched per command and path, replacing the per-tool list.
|
||||
- apply_patch, web_fetch, and writable worker subagents.
|
||||
|
||||
## [0.1.0-beta.3]
|
||||
|
||||
- Fourteen built-in tools with gateable tool sets.
|
||||
- Streaming reasoning display, the mid-turn prompt queue, multi_edit, list_dir, read-only git
|
||||
tools, batch reads, @file completion, and interruptible commands.
|
||||
|
||||
## [0.1.0-beta.1]
|
||||
|
||||
- The core agent loop with SDK-enforced tool approvals, endpoint fallback, and retries.
|
||||
- The initial tool set, Ink interface, agent variants, skills, plugins, per-project memory,
|
||||
session persistence, subagents, MCP, and five-platform builds.
|
||||
|
||||
[1.0.0]: https://github.com/zakirkun/shiro-neko/releases/tag/v1.0.0
|
||||
@@ -46,12 +46,12 @@ from the models that endpoint actually reports. Settings land in
|
||||
`~/.shiro-neko/config.json`. Run `/provider` any time to change them.
|
||||
|
||||
```
|
||||
shiro-neko 0.1.0-beta.4 openai/gpt-5 session 0193ab2c
|
||||
shiro-neko 1.0.0 openai/gpt-5 session 0193ab2c
|
||||
agent: default thinking: medium
|
||||
cwd: /home/you/project
|
||||
skills: commit, debug, refactor, review, test, verify
|
||||
plugins: guard, time
|
||||
approvals: ask for write_file, edit_file, multi_edit, apply_patch, bash, web_fetch, mcp__*
|
||||
skills: commit, debug, docs, migrate, perf, plan, refactor, review, security, test, verify
|
||||
plugins: guard, secrets, protect, time, no-force-push, no-net-pipe, no-root, no-env-write
|
||||
approvals: ask for write_file, edit_file, multi_edit, apply_patch, move_file, delete_file, bash, web_fetch, mcp__*
|
||||
/help for commands
|
||||
|
||||
> why does the pagination test fail?
|
||||
@@ -73,6 +73,11 @@ pattern, not the whole tool. `.env` and `.pem` files are refused on read outrigh
|
||||
plugin refuses irreversible commands ahead of any of it — `rm -rf`, `git reset --hard`, force
|
||||
pushes, `DROP TABLE` — and `--yolo` cannot bypass it.
|
||||
|
||||
**Rewinds a mistake.** `/undo` restores the snapshot taken before a prompt: files are reverted,
|
||||
the conversation is trimmed back, or both, and `/redo` reverses it. The snapshot is capped so
|
||||
it stays near the last 100 prompts, and a `bash` command's side effects cannot be rolled back
|
||||
this way because they are not files.
|
||||
|
||||
**Shows its work.** Reasoning streams to a collapsed panel you can expand with `ctrl-r`, the
|
||||
tool in flight is named as it runs with the arguments that identify the call, and `bash`
|
||||
output streams live instead of arriving all at once when the command exits. `ctrl-c` kills a
|
||||
@@ -93,14 +98,18 @@ is a decision rather than a default, and it asks before every call.
|
||||
**Asks instead of guessing.** When a request has two readings that lead to different work,
|
||||
the agent puts a question on screen with options.
|
||||
|
||||
**Delegates work.** `task` spawns a subagent with its own context window whose findings come
|
||||
back as one message, so a search across forty files does not fill the main context. `explore`
|
||||
and `review` are read-only; `worker` also edits and runs commands, and every one of its writes
|
||||
stops at the same approval prompt as yours. Progress streams to a panel.
|
||||
**Delegates work, in parallel.** `task` spawns a subagent with its own context window whose
|
||||
findings come back as one message, so a search across forty files does not fill the main
|
||||
context. A single call can batch several independent investigations under `tasks`: they run on
|
||||
separate context windows at the same time and their reports are joined, so two unrelated
|
||||
searches overlap in wall-clock time instead of queueing. `explore` and `review` are read-only;
|
||||
`worker` also edits and runs commands, and every one of its writes stops at the same approval
|
||||
prompt as yours. Progress streams to a panel.
|
||||
|
||||
**Extensible from the prompt.** `/registry` browses external skills and plugins and installs
|
||||
them with one confirmation. A skill is shown in full before its text joins your system prompt;
|
||||
a plugin is a manifest of refusal rules, never code.
|
||||
a plugin is a manifest of refusal rules, never code. `/mcp add` walks you through a local or
|
||||
remote MCP server — kind, name, command or URL, headers — and writes it to your config.
|
||||
|
||||
**Remembers between sessions.** Decisions, working commands, and traps go into per-project
|
||||
memory that is injected at the start of every future session.
|
||||
@@ -112,9 +121,12 @@ record of what it already ran instead of repeating it.
|
||||
|
||||
**Runs headless.** `shiro -p "review this diff" --json` for scripts and CI.
|
||||
|
||||
**Keeps the tool list affordable.** Sixteen built-in tools, grouped into sets. Each costs
|
||||
**Keeps the tool list affordable.** Forty-one built-in tools, grouped into sets. Each costs
|
||||
about 550 characters of schema on every request, so `{ "toolSets": [] }` trims back to the six
|
||||
core ones and a disabled set reaches neither the wire nor the prompt.
|
||||
core ones and a disabled set reaches neither the wire nor the prompt. An MCP server's tools are held back the
|
||||
same way: by default its tools are fetched only when one is actually called (via `mcp_list`,
|
||||
`mcp_inspect`, `mcp_call`), so twenty tools on one server cost almost nothing until they are
|
||||
used. Set `"mcpMode": "eager"` to register every server tool up front instead.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -130,13 +142,17 @@ the flags are.
|
||||
| [Skills](docs/skills.md) | the bundled skills, writing your own, why the catalogue is split |
|
||||
| [Plugins](docs/plugins.md) | the interface, the guard and its limits, builtin versus installed |
|
||||
| [Registry](docs/registry.md) | installing external skills and plugins, publishing your own |
|
||||
| [Custom commands](docs/custom-commands.md) | a Markdown file becomes a slash command, with arguments and shell substitution |
|
||||
| [Extensions](docs/extensions.md) | auto-loaded external skills, tools, and plugins — data, never code |
|
||||
| [Memory and state](docs/memory.md) | memory, task lists, sessions, compaction and its repair |
|
||||
| [Project workflow](docs/workflow.md) | when a repo tracks its own progress, the agent follows its TODO/ROADMAP/docs |
|
||||
| [MCP](docs/mcp.md) | connecting servers, namespacing, cost, debugging one |
|
||||
| [Headless mode](docs/headless.md) | `-p`, JSON events, exit codes, CI recipes |
|
||||
| [Architecture](docs/architecture.md) | how the loop works and why it is built this way |
|
||||
| [Development](docs/development.md) | building, testing, adding a tool, releasing |
|
||||
| [Roadmap](ROADMAP.md) | what is next and what has been declined |
|
||||
| [TODO](TODO.md) | the current work list, with known rough edges |
|
||||
| [Changelog](CHANGELOG.md) | release history, newest first |
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -144,9 +160,10 @@ Type `/` and a menu appears, narrowing as you type.
|
||||
|
||||
```
|
||||
/help /agent [name] /think [level] /provider /models /model <id>
|
||||
/skills /plugins /registry [search|add|remove] /init /context
|
||||
/todos /notes /memory /tools /compact /cost
|
||||
/skills /plugins /registry [search|add|remove] /mcp [add|remove] /init /context
|
||||
/todos /notes /memory /tools /compact /cost /undo /redo
|
||||
/sessions /resume <id> /save /clear /exit
|
||||
/undo /redo /changes /search <query> /fork /workflow
|
||||
```
|
||||
|
||||
`esc` dismisses a panel, interrupts a running turn, and clears the queue. `ctrl-c` kills the
|
||||
@@ -155,15 +172,19 @@ workspace path. Up and down recall earlier prompts.
|
||||
|
||||
## Status
|
||||
|
||||
Working: the agent loop, tool approvals, subagents including the gated `worker` kind, skills,
|
||||
plugins, per-project memory, session persistence, MCP, markdown rendering, headless mode,
|
||||
five-platform builds, streaming reasoning display, the mid-turn prompt queue, gateable tool
|
||||
sets, read-only git tools, batch reads, `apply_patch`, `web_fetch`, `@file` completion,
|
||||
interruptible commands, and the external registry.
|
||||
Version 1.0 is stable. Working: the agent loop, per-call and per-command tool approvals with a
|
||||
guard that `--yolo` cannot bypass, subagents including the gated `worker` kind, a spend ceiling
|
||||
(`maxSpendUsd`) with a cheaper subagent model (`subagentModel`), 41 built-in tools across
|
||||
gateable sets, 29 bundled skills, built-in and data-only plugins, per-project memory, session
|
||||
persistence and resume, MCP servers, custom slash commands from markdown files, auto-loaded
|
||||
external skills/tools/plugins, markdown rendering, headless mode with JSON events for CI,
|
||||
five-platform builds, streaming reasoning, the mid-turn prompt queue, read-only git tools,
|
||||
`/undo` and `/redo`, parallel subagents, lazy MCP tools, hot-reloaded skill installs, batch
|
||||
reads, `apply_patch`, `web_fetch`, `@file` completion, interruptible commands, and the
|
||||
external registry.
|
||||
|
||||
Next up is in [TODO.md](TODO.md); the longer view and what has been declined are in
|
||||
[ROADMAP.md](ROADMAP.md). The short version of what is missing: a summary of what compaction
|
||||
discarded, a spend ceiling, and a cheaper model for subagent searches.
|
||||
[ROADMAP.md](ROADMAP.md); the release history is in [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+306
-37
@@ -9,6 +9,43 @@ Nothing here is a date. Items move to [TODO.md](TODO.md) when they are next up.
|
||||
|
||||
## Shipped
|
||||
|
||||
### Session-feature batch (post-1.0)
|
||||
|
||||
**`/changes`** — diff the last turn's file snapshot: added / modified / deleted, per
|
||||
absolute path. The file side of `/undo` without undoing; bash effects are still out of
|
||||
reach of either.
|
||||
|
||||
**System-prompt memoization** — version counters (notebook, memory, skills, plugins,
|
||||
tools, workspace) gate a cached system prompt, so the string built on every step becomes
|
||||
one build plus hits. The provider-side win it unlocks — splitting the stable prefix for
|
||||
cache_control — is the remaining half of the old "Prompt caching" entry.
|
||||
|
||||
**`web_search`** — DuckDuckGo Lite, no API key, five results with title/URL/snippet,
|
||||
re-checked through the same private-address filter as `web_fetch`, all inside the opt-in
|
||||
`net` set.
|
||||
|
||||
**`/search <query>`** — full-text across saved sessions, matching transcript strings and
|
||||
tool-input JSON. Deliberately no index: a session store fits in a grep.
|
||||
|
||||
**Workspace list refresh** — the boot-injected file list re-walks at a turn boundary when
|
||||
that turn wrote files, so a path created mid-session shows up in the next prompt without a
|
||||
restart.
|
||||
|
||||
**Per-turn spend cap** (`maxSpendPerTurn`) — a `deep` turn that runs away is stopped at a
|
||||
step boundary by its own budget, complementing the session ceiling.
|
||||
|
||||
**`/fork`** — clone the session at the last turn boundary into a new saved session;
|
||||
trying a different approach no longer costs the original.
|
||||
|
||||
**Project-driven workflow** — when a repo tracks its own progress (TODO.md,
|
||||
ROADMAP.md, docs/), the agent carries a workflow policy in its system prompt:
|
||||
read the task list first and keep it current, spec-first for non-trivial work,
|
||||
complete unit tests, verify before done. TODO.md/ROADMAP.md ride along as
|
||||
`Project tracker` instruction blocks. A soft once-per-session nudge reminds an
|
||||
agent that edited files without updating the task list. `/workflow` shows the
|
||||
state; `workflow.enabled` / `workflow.docsDir` configure it. See
|
||||
[docs/workflow.md](docs/workflow.md).
|
||||
|
||||
### 0.1.0-beta.1
|
||||
|
||||
**Core loop** — `streamText` with tool approvals suspended and resumed through the SDK's
|
||||
@@ -153,8 +190,212 @@ stay structurally read-only, and no subagent holds `web_fetch`.
|
||||
|
||||
---
|
||||
|
||||
### 0.1.0-beta.5
|
||||
|
||||
**A dead provider item no longer ends the turn.** An `item_reference` resolves only while the
|
||||
provider still stores that item, so a resumed session — or one that fell back to `/v1/responses`
|
||||
mid-turn — could fail with 404 "Item with id 'msg_...' not found" on every attempt, since every
|
||||
retry sent the same reference. Compaction now strips every provider `itemId` from what it sends,
|
||||
and a 404 naming a missing item rewrites the session's history inline and runs the request again,
|
||||
once per turn and only before any output has been delivered.
|
||||
|
||||
**Interface.** Context shows the elapsed working time and a compaction warning as the threshold
|
||||
approaches, diff lines are numbered, markdown task lists render, the prompt edits by word and
|
||||
`ctrl-d` deletes to the end of line, and a farewell tells you how to resume the session.
|
||||
|
||||
**More tools.** `git_commit_message` writes a commit message from the staged diff and the
|
||||
repository's own recent subjects, in one nested model call — it never commits, so it needs no
|
||||
approval. `move_file` and `delete_file` fill the gap that made every rename a write-then-delete
|
||||
pair: both are gated, `move_file` matches permission rules at both ends, and `delete_file`
|
||||
refuses a directory because removing a tree is what the guard blocks in `bash`. `git_branch`
|
||||
lists branches with the current one marked.
|
||||
|
||||
**More plugins.** `protect` refuses writes to `.git`, lockfiles, `node_modules`, vendored code,
|
||||
and build output — files a tool owns rather than a person, where an edit leaves a repository
|
||||
that looks fine and behaves wrongly. It ships on, alongside `guard` and `secrets`, and every
|
||||
path-based guard now shares one helper that understands where each write tool keeps its paths.
|
||||
|
||||
**More skills.** `security` (trust boundaries, then injection, authorisation, traversal, SSRF),
|
||||
`perf` (measure, locate, one change, stop at a target), and `migrate` (changelog first, every
|
||||
call site before one edit, never hand-merge a lockfile) join the bundled set.
|
||||
|
||||
**The MCP panel.** `/mcp add` walks through a local or remote server — kind, name, command and
|
||||
arguments or URL and headers — validating the name against the `mcp__<server>__<tool>`
|
||||
namespace as it is typed rather than failing at connect. `/mcp` lists what is configured with
|
||||
each server's live tool count or its connection error, and `/mcp remove` takes one out. All
|
||||
three write `config.json` directly; a new server connects on the next start, because
|
||||
connecting mid-turn would change the tool list under a running request.
|
||||
|
||||
### 1.0.0
|
||||
|
||||
The first stable release. The beta line's architecture held; this release rounds out cost
|
||||
control, extensibility, and the interface, and hardens the test suite to match.
|
||||
|
||||
**Cost control.** Two halves of one problem, both shipped. A **spend ceiling** (`maxSpendUsd`)
|
||||
checks before each turn: past the limit the model is never called, the turn is refused naming
|
||||
the ceiling, headless exits non-zero, and it warns once at 80%. A **cheaper subagent model**
|
||||
(`subagentModel`) runs `explore` — which is search, not reasoning — on a less expensive model
|
||||
while `review` and `worker` keep the parent's; `/cost` reports subagent spend as its own line,
|
||||
priced against the subagent's model id.
|
||||
|
||||
**Tools: 41 built-in.** Twenty new tools in four families, all path-jailed and ignore-aware,
|
||||
in a new `extra` tool set: precise line edits (`insert_lines`, `delete_lines`, `replace_lines`,
|
||||
`append_file`, `prepend_file`, `count_lines`), filesystem navigation (`tree`, `file_info`,
|
||||
`find_files`, `recent_files`, `changed_files`), read-only git extensions (`git_log_file`,
|
||||
`git_diff_commits`, `git_show_file`, `git_current_branch`, `git_changed_in_ref`), and code and
|
||||
environment reads (`find_symbol`, `json_query`, `outline`, `read_symbol`, `env_info`,
|
||||
`count_tokens`). Every git call still spawns the binary with a fixed argv, never a shell.
|
||||
|
||||
**Skills: 29 bundled, as Markdown.** The catalogue grew from nine to twenty-nine and every
|
||||
skill moved to a single source of truth: a Markdown file in `src/skills-md/`, frontmatter and
|
||||
body, embedded into the compiled binary by Bun text imports. A format test enforces that each
|
||||
one parses and carries a real body.
|
||||
|
||||
**Plugins: 10 more, all data.** Six narrow safety refusals (force push, pipe-to-shell, root
|
||||
elevation, env credential writes, main-branch commits, git config changes) and three advisory
|
||||
plugins (conventional commits, tests-first, small diffs) plus a delete guard for ambiguous
|
||||
paths. The safety refusals are on by default for the same reason the guard is; the opinionated
|
||||
ones are opt-in.
|
||||
|
||||
**Custom slash commands.** A Markdown file in `.shiro/commands/` or `~/.shiro-neko/commands/`
|
||||
becomes a slash command, with frontmatter `description`/`agent`, `$ARGUMENTS` and `$1`
|
||||
positionals, and `` !`cmd` `` substitution passed through the guard. A custom command can never
|
||||
shadow a built-in.
|
||||
|
||||
**Auto-loaded extensions.** External skills, tools, and plugins load from
|
||||
`~/.shiro-neko/<kind>/` and `.shiro/<kind>/` — all data, never code. An external tool is a
|
||||
bounded manifest (a shell template through the guard, an HTTPS fetch, or a workspace file
|
||||
read); an external plugin is a refusal manifest. A malformed file is reported and skipped,
|
||||
never fatal.
|
||||
|
||||
**Interface.** The welcome screen is a structured dashboard — a session banner, a grouped
|
||||
environment panel with attention-worthy facts lifted out of the quiet layer, and a meta bar —
|
||||
replacing a wall of dim text. The input sits in an OpenCode-style two-tone box with the
|
||||
agent·model row inside it and a split footer beneath.
|
||||
|
||||
---
|
||||
|
||||
## What the other agents have
|
||||
|
||||
Surveyed opencode, Claude Code, Codex CLI, and phi against this tool's feature set. The point of
|
||||
the table is to record what is *worth copying* and what is worth *declining*, not to chase parity:
|
||||
each of these four spent effort here deliberately, and several of their choices are load-bearing for
|
||||
a reason that applies to us.
|
||||
|
||||
The four are not the same shape. **Claude Code** and **Codex** are first-party CLIs tied to one
|
||||
vendor's models; **opencode** and **phi** are open-source and provider-agnostic, and opencode in
|
||||
particular is the closest thing to a peer here. Where a feature exists, the notes say what it costs
|
||||
— several are cheap to copy, and three are not.
|
||||
|
||||
### Where the four agree
|
||||
|
||||
Six features have converged across all or nearly all of them. Convergence is the strongest signal
|
||||
available that a feature is not a fad — and the gaps in the table are as informative as the checks,
|
||||
because they show which features are genuinely optional and which are table stakes.
|
||||
|
||||
| Feature | opencode | Claude Code | Codex | phi | Here |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Per-pattern permission rules | `permission.bash` globs | `settings.json` allow/deny/ask | `approval_policy` + rules | Gate + `permissions.mode` | **shipped** |
|
||||
| Subagents with fresh context | `mode: subagent` | `agents/*.md` | — | sub-agents | **shipped** |
|
||||
| Markdown-defined agents | `agents/`, `commands/` | `agents/`, `skills/` | `AGENTS.md` | `.phi/` | **shipped** |
|
||||
| Session resume | `--session`, `--continue` | `--resume`, `--continue` | resume + rollout files | `sessions` | **shipped** |
|
||||
| Compaction | auto + `/compact` | `/compact`, `/rewind` summarize | compact prompt file | — | **shipped** |
|
||||
| Undo / rewind | `/undo`, `/redo` (via git) | `/rewind` (snapshots) | — | — | **missing** |
|
||||
|
||||
Two of the six are outright missing from one or more tools, and undoing is missing from two of the
|
||||
four — so it is a real feature, not table stakes, and the two that have it disagree about how. The
|
||||
permission model here is not behind: it already carries the per-pattern rules, the credential deny,
|
||||
and the repeat guard the others arrived at, and `doom_loop` (below) is the one refinement worth
|
||||
taking. **Undo is the single converged feature genuinely absent**, which is why it leads Next.
|
||||
|
||||
### Detail worth having, by tool
|
||||
|
||||
**opencode** — the closest peer, and the source of the permission shape already adapted here.
|
||||
`/undo` and `/redo` revert file changes **through git**, so they require the project to be a git
|
||||
repository; this is a real limitation, not an implementation detail, and it means an undo is only
|
||||
as good as the working tree's state. Agents are `primary` (build, plan) or `subagent` (general,
|
||||
explore, scout), switchable with Tab or `@`-mention, configured in `opencode.json` or Markdown
|
||||
frontmatter. A subagent runs in a **child session** with its own navigation keybinds
|
||||
(`session_child_first`, `session_parent`), and `subagent_depth` (default 1) caps nesting. Notable:
|
||||
`doom_loop` is a first-class permission key that fires when the same tool call repeats three times
|
||||
with identical input — the repeat guard here is an approval rule; theirs is a named primitive with
|
||||
its own recovery prompts. Sessions share over a URL (`/share`), which is a hosted product decision,
|
||||
not a local one.
|
||||
|
||||
**Claude Code** — the most complete implementation of undo, and worth reading before building one.
|
||||
Checkpointing snapshots **before every user prompt**, keeps the **100 most recent** checkpoints,
|
||||
and stores them with the conversation so `/rewind` survives a resume. The rewind menu offers five
|
||||
distinct actions, and the split is the interesting part: *restore code*, *restore conversation*,
|
||||
*restore both*, *summarize from here*, *summarize up to here*. That is undo and compaction sharing
|
||||
one control surface. The honest limits are documented rather than hidden: only `Write`/`Edit`/
|
||||
`NotebookEdit` are tracked, `bash` side effects are not, **subagent edits are not captured** unless
|
||||
the skill ran in the foreground with `context: fork`, and symlinked or hard-linked files are
|
||||
skipped with an explicit warning. Their hook surface is the largest of the four — see *External
|
||||
hooks* under Later, which is where that capability belongs here.
|
||||
|
||||
**Codex** — the sandbox is the differentiator, and it is genuinely hard to copy. Apple Seatbelt on
|
||||
macOS, Landlock + seccomp on Linux, and a restricted-token/AppContainer mechanism on Windows, with
|
||||
`workspace-write` the default and network **off** unless `sandbox_workspace_write.network_access`
|
||||
is set. `approval_policy` is now `on-request | never | { granular = {...} }` — `untrusted` was
|
||||
**retired** and can prevent the client from starting. Also shipped and worth knowing:
|
||||
`approvals_reviewer = "auto_review"` routes an approval prompt through a *reviewer subagent*
|
||||
rather than the user. `AGENTS.md` resolves global → project root → cwd, one file per directory,
|
||||
`AGENTS.override.md` winning, capped by `project_doc_max_bytes` (32 KiB default).
|
||||
|
||||
**phi** — small (Go, ~12 MB), and the two ideas most worth stealing. First, **MCP without context
|
||||
death**: server tool schemas never enter the prompt; the system prompt lists only **server names**,
|
||||
and the model uses three meta-tools — `mcp_list`, `mcp_inspect`, `mcp_call` — with subprocesses
|
||||
starting lazily on first use. This is the concrete design behind *MCP without the schema tax*
|
||||
under Next. Second, the
|
||||
hook contract is the cleanest of the four: `pre_tool` runs **before** the permission gate and can
|
||||
`allow`, `deny`, or **`modify`** the input; `post_tool` can append model-facing `context` or
|
||||
rewrite `output`. Exit code `2` is a hard deny; `fail_closed` decides crash behaviour; in
|
||||
`readonly` mode only `fail_closed` hooks run, so a slow audit hook cannot stall exploration.
|
||||
|
||||
### Corrections to what this file said before
|
||||
|
||||
Two claims previously written here were incomplete, and the research fixes them:
|
||||
|
||||
- **Input-rewriting hooks are not phi's alone.** Codex ships it too: `PreToolUse` returns
|
||||
`permissionDecision: "allow"` with `updatedInput` to rewrite a call, verified in their docs and
|
||||
in `codex-rs/.../mcp.rs` (`with_updated_hook_input`). Two independent implementations make this
|
||||
the standard shape rather than one project's quirk — which raises its priority, and means the
|
||||
compiled plugin interface here is now the odd one out.
|
||||
- **Codex's hook trust is exactly as described, and the mechanism is now known.** Non-managed
|
||||
hooks cannot run until reviewed: Codex persists a `trusted_hash` in `config.toml`, records trust
|
||||
against the hook's **current hash**, and marks new or changed hooks for review in `/hooks`.
|
||||
`--dangerously-bypass-hook-trust` skips it for one invocation. The known hole is instructive: a
|
||||
reviewer on their PR noted that **replacing the script a hook points at does not reset trust**,
|
||||
because only the config is hashed. A trust story that hashes the declaration but not the artefact
|
||||
is a partial one — worth designing past rather than copying.
|
||||
|
||||
### What is worth declining
|
||||
|
||||
Three of their features are deliberate here, and the survey confirms the reasoning:
|
||||
|
||||
- **A client/server split** (opencode's OpenAPI server + TUI-as-client + IDE/web clients) exists to
|
||||
serve *second clients*. No second client is wanted here.
|
||||
- **LSP integration** — the quote already cited in Declined is accurate and now verified in full:
|
||||
opencode's own LSP page says it "is useful in some projects, but it is not always a net positive,"
|
||||
that servers "can get out of sync, use significant memory, vary by version or project, and slow
|
||||
down agent workflows," and that "in many projects it is better to have the agent run lint,
|
||||
typecheck, or other diagnostic CLI tools directly." They ship 30+ built-in servers and still say
|
||||
this. That is the strongest possible endorsement of the position in Declined.
|
||||
- **Session sharing over a URL** (opencode `/share`) is a hosted-service feature and brings a
|
||||
privacy surface this tool has no reason to take on.
|
||||
|
||||
---
|
||||
|
||||
## Next
|
||||
|
||||
### Prompt caching
|
||||
|
||||
The system prompt is now memoized client-side, so the string is byte-identical across
|
||||
steps when nothing volatile changed. The remaining half is provider-side: Anthropic
|
||||
`cache_control` and OpenAI automatic prefix caching already reward that stable prefix, and
|
||||
splitting the stable prefix from the volatile suffix (notebook/memory) would make the
|
||||
cache unmissable even when a todo_write happens mid-turn.
|
||||
|
||||
### MCP without the schema tax
|
||||
|
||||
Every MCP tool's schema is in the prompt on every request, and `toolSets` does not gate them: a
|
||||
@@ -163,18 +404,23 @@ answer is three meta-tools — `mcp_list`, `mcp_inspect`, `mcp_call` — with th
|
||||
the servers, so a hundred servers cost almost nothing until one is called. Worth keeping direct
|
||||
registration as an option: for a two-tool server the indirection is the more expensive of the two.
|
||||
|
||||
### Custom commands from a file
|
||||
|
||||
A markdown file becoming a slash command, with `$ARGUMENTS`, `$1`, `` !`cmd` `` for shell output,
|
||||
and `@path` for a file. Every comparable CLI has this and none of it is hard; it is missing because
|
||||
nothing forced the issue.
|
||||
|
||||
### Undo a turn
|
||||
|
||||
opencode has `/undo` and `/redo`, Claude Code has `/rewind` over file checkpoints. There is
|
||||
`/resume` here, which restores a whole session, and nothing that steps one turn back. The honest
|
||||
limit is the same for everyone: a `bash` command's effects cannot be snapshotted, so this covers
|
||||
file-tool edits and says so.
|
||||
Every comparable CLI has this: opencode `/undo` and `/redo`, Claude Code `/rewind` with
|
||||
checkpoints. There is `/resume` here, which restores a session, and nothing that walks one back.
|
||||
Claude Code's implementation is the one to read first, because it has already worked out the seams:
|
||||
it snapshots before **every user prompt**, keeps the **100 most recent**, stores snapshots with the
|
||||
conversation so a rewind survives a resume, and splits one menu into *restore code*, *restore
|
||||
conversation*, *restore both*, and *summarize from here / up to here* — undo and compaction on one
|
||||
control surface. opencode's version is simpler and takes a different position: it reverts through
|
||||
**git**, so it needs a repository and inherits whatever the working tree already contained.
|
||||
|
||||
Both document the same hard limit, and so must this: a `bash` command's effects cannot be
|
||||
snapshotted. Claude Code tracks only its own file-edit tools, explicitly does not cover `bash`
|
||||
side effects, and does not capture subagent edits unless the fork ran in the foreground. The
|
||||
honest version here covers file-tool edits, says so in the command's own output, and reports what
|
||||
it skipped rather than pretending the tree is clean — the same shape used for an interrupted
|
||||
command, whose effects are already reported as unknown.
|
||||
|
||||
### Lossless-enough compaction
|
||||
|
||||
@@ -182,12 +428,6 @@ Compaction keeps the model's memory of a turn now, but it still says nothing abo
|
||||
discarded, so the model can contradict its own earlier decision with confidence. A summary of the
|
||||
discarded span costs one cheap call and removes the whole class of problem.
|
||||
|
||||
### Cost control
|
||||
|
||||
Two halves of the same problem: an `explore` subagent pays the parent's reasoning rate for
|
||||
what is really a search, and nothing stops a headless run that loops. A cheaper subagent model
|
||||
and a per-session ceiling are both small changes on top of the pricing that already exists.
|
||||
|
||||
### Derived tool metadata
|
||||
|
||||
`TOOL_SETS` and `MUTATING_TOOLS` are hand-maintained lists of tool names. A tool added to one
|
||||
@@ -200,6 +440,26 @@ An index is trusted for its contents, not its authorship: `registryUrl` is the w
|
||||
decision, and there are no signatures. Publisher keys and a pinned digest per entry would make
|
||||
"install this skill" a decision about a specific artifact rather than about a URL.
|
||||
|
||||
### Auto-review an approval
|
||||
|
||||
Codex ships `approvals_reviewer = "auto_review"`: an eligible approval prompt is routed through a
|
||||
reviewer **subagent** instead of surfacing to the user, using the same sandbox boundary. `explore`
|
||||
and `review` already exist and are read-only, so the piece to build is a reviewer persona that
|
||||
decides an approval request and a policy that says which prompts are eligible — a batch of three
|
||||
identical `git status` calls should not each interrupt, but a first `rm` should. The failure mode
|
||||
to design against is a reviewer that waves through exactly what the user would have stopped, so it
|
||||
must be opt-in, name itself when it approves, and never override a deny rule.
|
||||
|
||||
### Name the repeat guard
|
||||
|
||||
opencode has `doom_loop` as a first-class permission key: it fires when the same tool call repeats
|
||||
three times with identical input, carries its own recovery prompts, and is configurable per agent.
|
||||
The equivalent here is a rule inside the permission layer — the call repeated identically three
|
||||
times in one turn asks even when allowed — which works but is unnamed, unconfigurable, and
|
||||
invisible in `/tools`. Promoting it to a named primitive makes it inspectable and lets an agent
|
||||
tighten or loosen it, and the recovery prompt is the part genuinely missing: a loop is better
|
||||
interrupted with advice than with silence.
|
||||
|
||||
### `web_fetch`
|
||||
|
||||
Shipped in beta.4 — see above. What remains declined: wrappers around a single bash line with
|
||||
@@ -210,12 +470,6 @@ when the real commands are already in `AGENTS.md`.
|
||||
|
||||
## Later
|
||||
|
||||
**Subagent parallelism.** Two independent searches run sequentially today. The panel already
|
||||
handles multiple agents; the loop does not fan out.
|
||||
|
||||
**Session branching.** Fork a session at a message to try a different approach without
|
||||
losing the original.
|
||||
|
||||
**Structured diff review.** Approve or reject individual hunks of an `edit_file` call rather
|
||||
than the whole thing.
|
||||
|
||||
@@ -228,18 +482,30 @@ agent can read.
|
||||
step for task-list freshness, which defeats a naive cache; splitting the stable prefix from
|
||||
the volatile suffix would fix that.
|
||||
|
||||
**External hooks.** phi and both first-party CLIs let a script sit in the tool loop: a directory
|
||||
with a manifest and an executable, one JSON object in on stdin, one out. phi's `pre_tool` can
|
||||
rewrite the tool's input as well as allow or deny, which the compiled plugin interface here cannot
|
||||
express. The reason it is here rather than in Next is that it needs a trust story — Codex hashes
|
||||
each hook and refuses to run one until you review it, which is the right shape and more work than
|
||||
the feature.
|
||||
**External hooks.** Every one of the four surveyed except here lets a script sit in the tool loop:
|
||||
a directory with a manifest and an executable, one JSON object in on stdin, one out. **Both** phi
|
||||
and Codex let a `PreToolUse` hook **rewrite** a tool's input, not merely allow or deny it — phi
|
||||
returns `{"action":"modify","input":{...}}`, Codex returns `permissionDecision: "allow"` with
|
||||
`updatedInput` — and that is the exact capability the compiled plugin interface here cannot
|
||||
express. Two independent implementations make it the expected shape rather than one project's
|
||||
quirk. What blocks it is the trust story, not the mechanism.
|
||||
|
||||
The trust story has a known shape and a known hole. Codex will not run a non-managed hook until it
|
||||
has been reviewed: it persists a `trusted_hash` in `config.toml`, records trust against the hook's
|
||||
current hash, and lists new or changed hooks for review under `/hooks`;
|
||||
`--dangerously-bypass-hook-trust` overrides for one invocation. The hole is that only the
|
||||
**declaration** is hashed — replacing the script the hook points at does not reset trust, as a
|
||||
reviewer on their own PR pointed out. Hashing the declaration *and* the artefact is the minimum
|
||||
worth doing here, and it is why this is work rather than a weekend.
|
||||
|
||||
**OS-level sandboxing.** The strongest thing in this class, and Codex is the one that has it:
|
||||
Seatbelt on macOS, Landlock and seccomp on Linux, a separate mechanism on Windows, with network
|
||||
egress governed by domain rules. Permission rules gate the *call*; a sandbox governs what the
|
||||
process can then reach. Three platform-specific implementations, and OpenAI moved Codex to Rust
|
||||
partly for this. A half-built sandbox is worse than none, because people would trust it.
|
||||
Seatbelt on macOS, Landlock and seccomp on Linux, a restricted-token/AppContainer mechanism on
|
||||
Windows, with network **off** by default under `workspace-write`. Permission rules gate the *call*;
|
||||
a sandbox governs what the process can then reach. Note that even Codex's sandbox is not a complete
|
||||
boundary — their own hook docs say "hooks are guardrails, but they are not a complete enforcement
|
||||
boundary for every shell or tool path." Three platform-specific implementations, and OpenAI moved
|
||||
Codex to Rust partly for this. A half-built sandbox is worse than none, because people would trust
|
||||
it.
|
||||
|
||||
---
|
||||
|
||||
@@ -266,8 +532,11 @@ endpoint, which is what lets IDE extensions and a web client exist. It is the ri
|
||||
for that product. Here it would add a protocol, a port, and an auth story to serve a second client
|
||||
nobody has asked for.
|
||||
|
||||
**LSP integration.** opencode ships it and its own documentation says the honest thing:
|
||||
*"not always a net positive... in many projects it is better to have the agent run lint, typecheck,
|
||||
or other diagnostic CLI tools directly."* Language servers drift out of sync, use real memory, and
|
||||
vary by version. `bash bun run typecheck` puts the same errors in front of the model with none of
|
||||
that, and `AGENTS.md` is where the command belongs.
|
||||
**LSP integration.** opencode ships 30+ built-in language servers and its own documentation still
|
||||
says the honest thing: LSP "is useful in some projects, but it is not always a net positive,"
|
||||
servers "can get out of sync, use significant memory, vary by version or project, and slow down
|
||||
agent workflows," and *"in many projects it is better to have the agent run lint, typecheck, or
|
||||
other diagnostic CLI tools directly."* That is the strongest available endorsement of declining it:
|
||||
the project with the most invested says it is often the wrong trade. `bash bun run typecheck` puts
|
||||
the same errors in front of the model with none of that, and `AGENTS.md` is where the command
|
||||
belongs.
|
||||
@@ -8,113 +8,19 @@ Longer-term direction lives in [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
## Now
|
||||
|
||||
### Summarize the pruned span
|
||||
|
||||
Compaction now keeps the model's memory of a turn, but it still tells the model nothing about
|
||||
the messages it dropped, so a decision from forty messages ago can be contradicted with
|
||||
confidence.
|
||||
|
||||
- [ ] Summarize the discarded messages before dropping them
|
||||
- [ ] Inject the summary in place of the count
|
||||
- [ ] Budget it: a summary that grows with the session defeats the point
|
||||
- [ ] Test: a pruned decision is still recoverable from the summary
|
||||
|
||||
### A spend ceiling
|
||||
|
||||
A headless run that loops costs real money with nothing to stop it.
|
||||
|
||||
- [ ] `maxSpendUsd` in config, checked after every turn
|
||||
- [ ] Warn at 80%, refuse to start another turn at 100%
|
||||
- [ ] Headless exits non-zero with the ceiling named, rather than stopping silently
|
||||
- [ ] Test: a session past its ceiling refuses the next turn and says why
|
||||
|
||||
### A cheaper model for subagents
|
||||
|
||||
The subagent shares the parent's model. An `explore` run is search, not reasoning, and it
|
||||
currently pays the parent's per-token rate.
|
||||
|
||||
- [ ] `subagentModel` in config, defaulting to the parent
|
||||
- [ ] `/cost` separates parent from subagent spend
|
||||
- [ ] Test: the subagent's calls go to the configured model, the parent's do not
|
||||
|
||||
### Hot-reload an installed entry
|
||||
|
||||
`/registry add` writes the file and says to restart. The skill catalogue and the guard chain
|
||||
are both assembled at boot, so a mid-session install does nothing until then.
|
||||
|
||||
- [ ] Rebuild the skill list and plugin host after an install or removal
|
||||
- [ ] Leave a turn in flight alone: its rules must not change underneath it
|
||||
- [ ] Test: a skill installed mid-session is callable in the next turn without a restart
|
||||
_Empty — pick from Known rough edges below._
|
||||
|
||||
---
|
||||
|
||||
## Next
|
||||
|
||||
### MCP without the schema tax
|
||||
|
||||
Every MCP tool's schema goes into the prompt today, so twenty tools from one server cost roughly
|
||||
2,750 tokens per request whether the model uses them or not. `toolSets` does not gate them.
|
||||
|
||||
phi solves this with three meta-tools — `mcp_list`, `mcp_inspect`, `mcp_call` — and a prompt that
|
||||
names only the servers. A hundred servers then cost almost nothing until one is called.
|
||||
|
||||
- [ ] `mcp_list` / `mcp_inspect` / `mcp_call` replacing per-tool registration
|
||||
- [ ] The prompt lists server names, not schemas
|
||||
- [ ] Calls go through the same permission rules and guard as a built-in
|
||||
- [ ] Keep per-tool registration as an option: a two-tool server is cheaper registered directly
|
||||
- [ ] Test: a configured server contributes no schema to the request until `mcp_call`
|
||||
|
||||
### Custom commands from a file
|
||||
|
||||
Every other CLI in this class has these and they are cheap: a markdown file becomes a slash
|
||||
command, with `$ARGUMENTS`, `$1`, `` !`cmd` `` for shell output, and `@path` for a file.
|
||||
|
||||
- [ ] `.shiro/commands/*.md` and `~/.shiro-neko/commands/*.md`, name from the filename
|
||||
- [ ] Frontmatter for `description` and `agent`
|
||||
- [ ] `$ARGUMENTS` and positional `$1`
|
||||
- [ ] `` !`cmd` `` substituted before the prompt is sent, with the guard applied to it
|
||||
- [ ] Test: a command with a shell substitution reaches the model with the output inlined
|
||||
|
||||
### Derive the tool-name lists
|
||||
|
||||
`TOOL_SETS` and `MUTATING_TOOLS` both list names by hand. A tool added to one and forgotten
|
||||
in the other is a silently ungated write, which is the worst kind of bug this codebase can
|
||||
have.
|
||||
|
||||
- [ ] Mark each tool as mutating where it is defined, not in a list beside it
|
||||
- [ ] `TOOL_SETS` covers every registered tool, checked rather than assumed
|
||||
- [ ] Test: a tool in no set, or a mutating tool outside `MUTATING_TOOLS`, fails the suite
|
||||
|
||||
### Subagent parallelism
|
||||
|
||||
Two independent searches run sequentially. The panel already renders several agents; the loop
|
||||
does not fan out.
|
||||
|
||||
- [ ] `task` accepts several investigations and runs them together
|
||||
- [ ] Test: two delegated searches overlap in time rather than queueing
|
||||
|
||||
### Undo a turn
|
||||
|
||||
Every comparable CLI has this: opencode `/undo` and `/redo`, Claude Code `/rewind` with
|
||||
checkpoints. There is `/resume` here, which restores a session, and nothing that walks one back.
|
||||
|
||||
- [ ] Snapshot files before each prompt, capped at the 100 most recent
|
||||
- [ ] `/undo` restores files, conversation, or both; `/redo` reverses it
|
||||
- [ ] Say plainly what is not covered: a `bash` command's effects cannot be snapshotted
|
||||
- [ ] Test: an edit is reverted, and the model's own record of it goes with it
|
||||
_Empty._
|
||||
|
||||
---
|
||||
|
||||
## Maintenance
|
||||
|
||||
- [ ] Pricing table needs a source note and a date; rates drift and ours are hand-entered
|
||||
- [ ] `estimateTokens` divides JSON length by four. Good enough for a compaction threshold,
|
||||
wrong enough to mislead in `/cost`. Either label it an estimate everywhere or use a
|
||||
real tokenizer
|
||||
- [ ] `listPaths` walks up to 5000 files once per session. Fine for a repo, wasteful in a
|
||||
monorepo, and it never notices a file created after the first `@`
|
||||
- [ ] `MUTATING_TOOLS` is now only used by tests and docs; the permission defaults are what
|
||||
actually gate a write. Either delete it or make the defaults derive from it
|
||||
_All caught up._
|
||||
|
||||
---
|
||||
|
||||
@@ -129,8 +35,6 @@ Not bugs exactly, but things that will bite someone.
|
||||
injected. `/memory` may merge them, or may keep both.
|
||||
- **Windows `cmd /c` differs from `bash -lc`.** A command the model writes for one shell may
|
||||
fail on the other. The prompt states the platform; it does not translate.
|
||||
- **An unknown name in `toolSets` is dropped silently.** The header line shows which sets
|
||||
actually loaded, but a typo reads as "that set is off" rather than as a mistake.
|
||||
- **Permission rules gate the call, not what it does.** `bash` with `git *` allowed will run a
|
||||
`git` alias that shells out to anything, and there is no sandbox around the shell. Codex solves
|
||||
this with OS-level isolation — Seatbelt, Landlock, a Windows equivalent — which is three
|
||||
@@ -139,8 +43,6 @@ Not bugs exactly, but things that will bite someone.
|
||||
screen through later ones until the turn ends.
|
||||
- **An interrupted command's effects are unknown, and the model is told so.** Nothing can know
|
||||
how far a half-run migration got.
|
||||
- **`@` completion lists files, not directories.** `@src/` narrows correctly, but you cannot
|
||||
complete to `src/` itself, because the walker only yields files.
|
||||
- **An installed skill is a stranger's words in your system prompt.** The install shows the
|
||||
body first and `/skills` records the origin, but nothing re-checks it later: a registry that
|
||||
changes a URL's contents affects the next install, not one already on disk.
|
||||
@@ -153,31 +55,64 @@ Not bugs exactly, but things that will bite someone.
|
||||
|
||||
Kept for one release, then deleted.
|
||||
|
||||
- [x] Reasoning streamed to a collapsed panel, `ctrl-r` to expand, dropped when the turn ends
|
||||
- [x] The tool in flight named on screen from `tool-input-start` until its result arrives
|
||||
- [x] Prompts typed during a turn queue and drain in order; `esc` clears the queue
|
||||
- [x] `toolSets` gating, so a disabled set reaches neither the wire nor the prompt
|
||||
- [x] `multi_edit`, atomic across several edits to one file
|
||||
- [x] `list_dir`, ignore-aware and depth-limited
|
||||
- [x] Read-only git tools: `git_status` `git_diff` `git_log` `git_show` `git_blame`
|
||||
- [x] Orphaned tool results dropped during pruning, fixing the 400 "No tool call found for
|
||||
function call output with call_id ..."
|
||||
- [x] `read_many_files`, concurrent, one labelled block per file, a bad path reported in place
|
||||
- [x] `@file` completion: picker fed by the ignore-aware walker, tab inserts a relative path
|
||||
- [x] `ctrl-c` kills the running command and keeps the turn. The kill takes the whole process
|
||||
tree: killing `cmd /c` alone left the real command holding both pipes open, so the
|
||||
interrupt appeared to do nothing for 19 seconds
|
||||
- [x] **Compaction no longer stops the loop.** Pruning used to drop any assistant part whose
|
||||
reasoning item it removed, which on a reasoning model is every tool call. The model lost
|
||||
its record of what it had run and re-ran it until the step limit. The repair strips the
|
||||
provider `itemId` instead of the part, so the same content is sent inline
|
||||
- [x] `/registry`: browse, search, install, and remove external skills and plugins. Skills are
|
||||
shown in full before install; plugins are a validated manifest of deny rules, never code
|
||||
- [x] Context shown as a percentage of the compaction threshold, amber at two thirds, red at 90
|
||||
- [x] **Permission rules per command and path**, replacing the per-tool list. `bash` was one
|
||||
yes/no for `git status` and `rm -rf`, so pressing `a` once removed the gate for both.
|
||||
Rules match the call's subject, `always` grants a pattern rather than the tool, `.env` and
|
||||
`.pem` are refused on read, and an identical call repeated three times in a turn asks even
|
||||
when allowed
|
||||
- [x] **`web_fetch`**, size-capped HTTP(S) to markdown in the opt-in `net` tool set, with
|
||||
redirect and private-address checks
|
||||
### 1.0.0 release batch
|
||||
|
||||
- [x] A spend ceiling (`maxSpendUsd`): checked before each turn, refused at 100% naming the
|
||||
ceiling, warns once at 80%, headless exits non-zero. Unpriced models are not enforced
|
||||
- [x] A cheaper subagent model (`subagentModel`): `explore` resolves against it, `review` and
|
||||
`worker` keep the parent's, `/cost` splits subagent spend by model id
|
||||
- [x] Twenty new built-in tools (41 total) in a new `extra` set: line edits, filesystem
|
||||
navigation, read-only git extensions, and code/environment reads
|
||||
- [x] Twenty new bundled skills (29 total) plus the eleven originals deepened; all moved to
|
||||
`src/skills-md/*.md` as the Markdown source of truth, embedded at build
|
||||
- [x] Ten new data-only plugins: safety refusals on by default (force push, pipe-to-shell,
|
||||
root, env credential writes) and opt-in workflow plugins (conventional commit,
|
||||
tests-first, small diffs, main-branch commits, git config, confirm-delete)
|
||||
- [x] Custom slash commands from Markdown files, with `$ARGUMENTS`/`$1` and guarded shell
|
||||
substitution; a custom command never shadows a built-in
|
||||
- [x] Auto-loaded external skills, tools, and plugins from `~/.shiro-neko/<kind>` and
|
||||
`.shiro/<kind>`, all data, never code; a bad file is reported and skipped
|
||||
- [x] The welcome interface redesigned into a structured dashboard with a session banner, a
|
||||
grouped environment panel, and a meta bar; the input in a two-tone box with a split footer
|
||||
- [x] The system prompt advanced: a failure-recovery loop, a delegation policy, compaction awareness
|
||||
- [x] The release workflow's dead `dry_run` input wired: manual dispatch publishes only when
|
||||
unchecked, tag pushes always publish
|
||||
|
||||
### Post-1.0 — Now / Next / Maintenance (landed)
|
||||
|
||||
- [x] Summarize the pruned span — `prune.droppedSpan` + `session.summarizeDiscarded`, injected as `Note (retained from compacted history)`, budgeted 6k excerpt + 3–6 lines, one call per compaction (`test/compact.test.ts`)
|
||||
- [x] Hot-reload an installed entry — `Session.updateSkills/updatePlugins` + `cli.tsx` hot-reload, `pendingSkills/pendingHost` + `drainPendingHotReload` at turn boundary (`test/hot-reload.test.ts`)
|
||||
- [x] MCP without the schema tax — `mcp_list` / `mcp_inspect` / `mcp_call` phi meta-tools, prompt names-only, permission `mcp_call` + `bindMcpGuard`, `mcpExpose=phi|direct|auto` (`test/mcp.test.ts`)
|
||||
- [x] Derive the tool-name lists — `withMeta({ set, mutating })`, `setsFrom(tools)` derives `TOOL_SETS`/`MUTATING_TOOLS`/`DEFAULT_PERMISSIONS` (`test/tool-derive.test.ts`)
|
||||
- [x] Subagent parallelism — `task` fans out `tasks: TaskSpec[]` via `Promise.all` up to 8 (`test/subagent-parallel.test.ts`)
|
||||
- [x] Undo a turn — `src/snapshot.ts` per-turn capture cap 100, `/undo` + `/redo` files+messages (`test/undo.test.ts`)
|
||||
- [x] Pricing source + date — `PRICING_VERIFIED_AT='2026-09-09'` + source URLs, `/cost` shows `pricing verified: 2026-09-09 (est.)`
|
||||
- [x] `estimateTokens` labelling — heuristic doc + `(est.)` in `/cost` + `~N est. in context`
|
||||
- [x] `listPaths` stale walk — `fileChangeSeq` on `recordBeforeWrite`/`restoreFiles`, `@` invalidates `paths` on seq change
|
||||
- [x] `MUTATING_TOOLS` derivation — `BASE_PERMISSIONS` + `buildDefaults()` derives from `MUTATING_TOOLS` via `require('./tools')`
|
||||
- [x] Unknown `toolSets` silently dropped — `unknownToolSetNames()` + startup notice `unknown toolSets ignored: …` (`test/config-toolsets.test.ts`, `5028ea6`)
|
||||
- [x] `@` directories — `walk({ includeDirs: true })` yields `src/` with trailing `/`, `matchPaths` ranks dirs before files (`test/complete-dirs.test.ts`, `4b4ddd0`)
|
||||
|
||||
### Session-feature batch (7 features)
|
||||
|
||||
- [x] `/changes` — diff the last turn's snapshot: added / modified / deleted, per absolute path, no bash effects (`session.lastTurnSummary` + `snapshot.peek`)
|
||||
- [x] System-prompt memoization — version counters on notebook/memory/skills/plugins/tools/workspace, cached per version key, hit-rate in `/cost` (foundation for provider prompt caching)
|
||||
- [x] `web_search` — DuckDuckGo Lite, no API key, 5 results with title/URL/snippet, SSRF-filtered through `checkUrl`, in the `net` set
|
||||
- [x] `/search <query>` — full-text over saved sessions, matches transcript strings and tool-input JSON
|
||||
- [x] Workspace file list refresh — after a turn that wrote files, re-walk at the boundary so the next prompt shows new paths
|
||||
- [x] Per-turn spend cap — `maxSpendPerTurn`, aborts a step past the line with a notice
|
||||
- [x] `/fork` — clone the session at the last turn boundary to a new saved session; original untouched
|
||||
|
||||
### Project-driven workflow batch (v8)
|
||||
|
||||
- [x] Workflow policy in the system prompt when the repo tracks progress (TODO.md/ROADMAP.md/docs) — read the task list first, keep it current, spec-first, complete unit tests, verify before done
|
||||
- [x] TODO.md + ROADMAP.md loaded as `Project tracker` instruction blocks (capped 6k each, git root down to cwd)
|
||||
- [x] Once-per-session soft nudge when a turn writes files without calling `todo_write` (skipped when the task list was updated)
|
||||
- [x] `/workflow` — status panel: TODO/ROADMAP/docs presence + line/file counts + nudge state
|
||||
- [x] `workflow.enabled` / `workflow.docsDir` config keys, merged in `config.merge`
|
||||
- [x] Docs: `docs/workflow.md`, ROADMAP entry, TODO done-list entry
|
||||
- [x] Tests: `test/workflow.test.ts` (9 tests)
|
||||
|
||||
- [x] A visible escape hatch for the stalled-agent loop: the existing repeat guard now offers a
|
||||
configurable `step_back` recovery primitive that reads the session loop trace and steers a
|
||||
model circling without progress to stop and change direction
|
||||
@@ -14,11 +14,13 @@
|
||||
"ink-select-input": "6.2.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ink-text-input": "6.0.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"react": "19.2.8",
|
||||
"zod": "4.5.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.4.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/react": "19.2.18",
|
||||
"ink-testing-library": "4.0.0",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
@@ -51,6 +53,8 @@
|
||||
|
||||
"@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
|
||||
|
||||
"@types/pngjs": ["@types/pngjs@6.0.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
|
||||
@@ -131,6 +135,8 @@
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||
|
||||
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||
|
||||
"react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
|
||||
|
||||
+1
-1
@@ -175,7 +175,7 @@ around it. The tool descriptions say the same thing, so the rule survives compac
|
||||
accident. The read-only kinds work everywhere. No subagent holds `web_fetch`; network access
|
||||
stays with the main agent, where the approval prompt says what it is for.
|
||||
|
||||
When not to delegate: a single grep, or anything you must supervise step by step — keep that in
|
||||
For independent pieces of work, pass `tasks: [{description, prompt}, …]` to run them in parallel instead of calling `task` several times — each subagent keeps its own window and the panel fans out. When not to delegate: a single grep, or anything you must supervise step by step — keep that in
|
||||
your own turn, where every call is on screen. A worker wins when the intermediate steps are
|
||||
noise: a mechanical rename across twenty files, a test scaffold written to match an existing
|
||||
suite, a cleanup whose shape you already know.
|
||||
+28
-3
@@ -147,6 +147,10 @@ running, the handler exits as usual.
|
||||
|
||||
`task` runs a nested `streamText` and returns one message. The subagent kinds hold different
|
||||
tool sets: `explore` and `review` the read-only tools, `worker` those plus every write tool.
|
||||
A `tasks` array on the call runs several of these nests concurrently — each gets its own
|
||||
context window and stream, awaited together via `Promise.all` — so independent investigations
|
||||
overlap instead of queueing. The single-prompt form is just the one-element case; the two paths
|
||||
share the same nested-loop machinery and the same reporting bus.
|
||||
|
||||
The consequences follow from the tool set, not from policy:
|
||||
|
||||
@@ -212,6 +216,18 @@ an assistant `tool-call` and the `tool` message answering it. What reaches the w
|
||||
reverse pairing is deliberately left alone: a call still awaiting its result is exactly what a
|
||||
suspended approval looks like, and dropping it would break resume.
|
||||
|
||||
**An item the provider no longer holds.** A reference resolves only while the item is still in
|
||||
provider storage, which a resumed session or an endpoint fallback cannot count on:
|
||||
|
||||
```
|
||||
404 Item with id 'msg_…' not found.
|
||||
```
|
||||
|
||||
Nothing about the same history can succeed on retry, so `pruneToFit` strips every provider
|
||||
`itemId` from what it sends, and `Session.run` answers that 404 by rewriting its own history
|
||||
inline and running the request again — once per turn, and only when the rejection arrived before
|
||||
any output, since delivered text cannot be unsent.
|
||||
|
||||
The pruning ladder drops reasoning first and then keeps the widest recent tool tail that fits.
|
||||
The SDK carries that returned message view into later steps, and the session reports compaction
|
||||
once per turn rather than once per step.
|
||||
@@ -234,7 +250,8 @@ the reasoning.
|
||||
| `session.ts` | the loop, approvals, compaction, event stream |
|
||||
| `tools.ts` | file and shell tools, tool sets, ripgrep bridge, bash streaming and interrupt |
|
||||
| `tools-git.ts` | read-only git tools, spawned with a fixed argv |
|
||||
| `tools-net.ts` | `web_fetch`, private-address and redirect checks |
|
||||
| `commit.ts` | `git_commit_message`, a nested model call over the staged diff |
|
||||
| `tools-net.ts` | `web_fetch`, `web_search`, private-address and redirect checks |
|
||||
| `ignore.ts` | gitignore-aware walker, path jail |
|
||||
| `complete.ts` | `@path` token extraction, ranking, insertion |
|
||||
| `registry.ts` | external index, validation, install and removal |
|
||||
@@ -251,20 +268,28 @@ the reasoning.
|
||||
| `prune.ts` | provider-item and tool-pairing repair |
|
||||
| `markdown.ts` | parser, no dependency |
|
||||
| `store.ts` | sessions, prompt history |
|
||||
| `farewell.ts` | the exit message and its resume commands |
|
||||
| `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 |
|
||||
| `ui/App.tsx` | state, the turn loop, slash-command routing |
|
||||
| `ui/transcript.ts` | line types, tool argument and result formatting |
|
||||
| `ui/buses.ts` | notice and subagent channels, subagent view folding |
|
||||
| `ui/Approval.tsx` | the approval bridge and its prompt |
|
||||
| `ui/Pickers.tsx` | command menu, shared list picker, install confirm |
|
||||
| `ui/panel-bodies.ts` | `/tools`, `/cost`, `/context`, `/todos` bodies |
|
||||
| `ui/Panels.tsx` | presentational panels and the status bar |
|
||||
| `ui/*` | remaining Ink components |
|
||||
|
||||
Every module is pure of the UI except `ui/`, and `ui/` never touches the SDK. The seam is the
|
||||
`AgentEvent` stream.
|
||||
|
||||
## Testing
|
||||
|
||||
538 tests became 647 as the suites grew; no mocking framework. `MockLanguageModelV4` from
|
||||
538 tests became 713 as the suites grew; 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 and the registry are
|
||||
tested against a local HTTP server; the interrupt path spawns a real subprocess and asserts it
|
||||
|
||||
+28
-3
@@ -20,8 +20,11 @@ Written by `/provider`, editable by hand. Every field is optional.
|
||||
"agent": "default",
|
||||
"thinking": "medium",
|
||||
"maxRetries": 3,
|
||||
"maxSpendUsd": 5,
|
||||
"maxSpendPerTurn": 0.5,
|
||||
"subagentModel": "gpt-5-nano",
|
||||
"plugins": ["guard", "time"],
|
||||
"toolSets": ["edit-plus", "git"],
|
||||
"toolSets": ["edit-plus", "extra", "git"],
|
||||
"permission": {
|
||||
"bash": { "*": "ask", "git *": "allow" }
|
||||
},
|
||||
@@ -42,11 +45,33 @@ Written by `/provider`, editable by hand. Every field is optional.
|
||||
| `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 builtin plugins to enable. Omit for `["guard", "time"]` |
|
||||
| `toolSets` | optional tool sets beyond `core`: `edit-plus`, `git`, and `net`. Omit for the defaults; `net` is opt-in. See [tools](tools.md) |
|
||||
| `maxSpendUsd` | session spend ceiling: warn at 80%, refuse the next turn at 100%. Headless exits non-zero naming the ceiling. Only enforced on priced models |
|
||||
| `maxSpendPerTurn` | per-turn spend ceiling: a single turn past this line is stopped at a step boundary, even when the session ceiling is far away. Only enforced on priced models |
|
||||
| `workflow` | project-driven workflow: `{ "enabled": true, "docsDir": "docs", "autoScaffold": true }`. When the repo has TODO.md/ROADMAP.md/docs, the agent's prompt carries a workflow policy and the session nudges once when files change without the task list being updated. When the repo has none, the first turn auto-scaffolds them (model-generated, never overwriting). See [docs/workflow.md](workflow.md) |
|
||||
| `subagentModel` | model id for `explore` subagents, which search rather than reason. Omit to share the parent's model. `/cost` reports subagent spend separately |
|
||||
| `plugins` | which builtin plugins to enable. Omit for `["guard", "secrets", "protect", "time", "no-force-push", "no-net-pipe", "no-root", "no-env-write"]` |
|
||||
| `toolSets` | optional tool sets beyond `core`: `edit-plus`, `nav`, `extra`, `git`, and `net`. Omit for the defaults; `net` is opt-in. See [tools](tools.md) |
|
||||
| `permission` | which calls run, ask, or are refused, matched per command or path. See [permissions](permissions.md) |
|
||||
| `registryUrl` | index for `/registry`. Omit for the default. See [registry](registry.md) |
|
||||
| `mcpServers` | see [MCP](mcp.md) |
|
||||
| `diagnostics` | a check command (e.g. `tsc --watch`) started at boot and shown in the UI only — its output never enters model context. One at a time; change with `/diagnostics start <cmd>`. See [verification](tools.md#verification-the-run_checks-tool) |
|
||||
|
||||
## Directories
|
||||
|
||||
Beyond the config file, these locations are read on every start:
|
||||
|
||||
| Path | Holds |
|
||||
|---|---|
|
||||
| `~/.shiro-neko/config.json` | the config above |
|
||||
| `~/.shiro-neko/skills/*.md` `.shiro/skills/*.md` | auto-loaded skills — [extensions](extensions.md) |
|
||||
| `~/.shiro-neko/tools/*.json` `.shiro/tools/*.json` | auto-loaded tool manifests |
|
||||
| `~/.shiro-neko/plugins/*.json` `.shiro/plugins/*.json` | auto-loaded plugin manifests |
|
||||
| `~/.shiro-neko/commands/*.md` `.shiro/commands/*.md` | custom slash commands — [custom commands](custom-commands.md) |
|
||||
| `~/.shiro-neko/registry/{skills,plugins}/` | entries installed with `/registry` |
|
||||
| `~/.shiro-neko/sessions/` | saved sessions, for `-c` / `-r` |
|
||||
|
||||
`SHIRO_HOME` overrides the home directory for all of these, which is also how the test suite
|
||||
isolates itself.
|
||||
|
||||
## Provider presets
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Custom slash commands
|
||||
|
||||
A Markdown file becomes a slash command. Write the prompt once, run it with `/name` any time.
|
||||
|
||||
Two directories are scanned, the project shadowing the user by name:
|
||||
|
||||
| Origin | Directory |
|
||||
|---|---|
|
||||
| user | `~/.shiro-neko/commands/*.md` |
|
||||
| project | `.shiro/commands/*.md` |
|
||||
|
||||
The filename is the command: `.shiro/commands/review-diff.md` becomes `/review-diff`. Names are
|
||||
letters, digits, dashes, and underscores; anything else is skipped. A custom command can never
|
||||
shadow a built-in — `/cost` always runs the built-in `/cost`.
|
||||
|
||||
## Format
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Review the staged diff for defects
|
||||
agent: review
|
||||
---
|
||||
|
||||
Review the staged changes. For each finding give file, line, what breaks, and the fix.
|
||||
```
|
||||
|
||||
Frontmatter is optional but useful:
|
||||
|
||||
- **`description`** — the one line shown in the `/` menu. Without it the first body line is used.
|
||||
- **`agent`** — run this command under a specific agent variant (`default`, `quick`, `deep`,
|
||||
`plan`, `review`). The variant is restored afterwards, so one command does not leak its agent
|
||||
into the rest of the session.
|
||||
|
||||
Everything after the frontmatter fence is the prompt. A file with an empty body is skipped, as
|
||||
is one that fails to parse.
|
||||
|
||||
## Arguments
|
||||
|
||||
The body is a template, expanded against whatever you type after the command:
|
||||
|
||||
- `$ARGUMENTS` — the whole argument string.
|
||||
- `$1`, `$2`, … — positional arguments. A missing positional expands to nothing.
|
||||
|
||||
```markdown
|
||||
Compare $1 against $2 and report the differences. Context: $ARGUMENTS
|
||||
```
|
||||
|
||||
`/compare src/a.ts src/b.ts` sends `Compare src/a.ts against src/b.ts … Context: src/a.ts src/b.ts`.
|
||||
|
||||
## Shell substitution
|
||||
|
||||
A `` !`command` `` inline runs the shell command and inlines its output before the prompt is
|
||||
sent:
|
||||
|
||||
```markdown
|
||||
Review this diff:
|
||||
|
||||
!`git diff --staged`
|
||||
```
|
||||
|
||||
Every substitution runs through the **guard** before executing, exactly as a direct `bash` call
|
||||
is — so a custom command cannot smuggle a destructive command past you. A substitution that
|
||||
exits non-zero, or one the guard refuses, fails the command with the reason named.
|
||||
|
||||
## When to write one
|
||||
|
||||
- A prompt you find yourself retyping: a review shape, a release checklist, a project-specific
|
||||
"how we test".
|
||||
- A prompt that should pin an agent: a read-only review command that always runs under `review`.
|
||||
- Project conventions the whole team should share: commit `.shiro/commands/` so everyone gets
|
||||
the same commands.
|
||||
|
||||
For behaviour that must survive across sessions rather than be invoked on demand, use
|
||||
[memory](memory.md). For instructions the agent loads by task rather than by name, use a
|
||||
[skill](skills.md). For extensions that add tools or refusal rules rather than prompts, see
|
||||
[extensions](extensions.md).
|
||||
+32
-21
@@ -16,7 +16,7 @@ faster and the fallback path is exercised without it.
|
||||
```bash
|
||||
bun run shiro # run from source
|
||||
bun run typecheck # tsc --noEmit
|
||||
bun test # 647 tests
|
||||
bun test # 895 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
|
||||
@@ -71,6 +71,9 @@ mock-verification test:
|
||||
request body
|
||||
- `pruneMessages` leaving a tool result without its tool call — same, and it took a stub
|
||||
endpoint that rejected the pairing to prove the fix
|
||||
- A provider item the server had dropped — visible only as a 404 from a stub endpoint that
|
||||
refused any `item_reference`, and only fixable by comparing the two request bodies the
|
||||
session sent
|
||||
- Compaction blanking the model's memory of its own tool calls — invisible in any single
|
||||
request, and visible only as "the loop ran to its step limit". Caught by asserting the loop
|
||||
terminated because the model chose to, not that the messages had a particular shape
|
||||
@@ -81,21 +84,23 @@ mock-verification test:
|
||||
|
||||
## 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. Add it to a set in `TOOL_SETS`. A tool in no set can never be gated off.
|
||||
4. If it mutates anything, add it to `MUTATING_TOOLS` so it requires approval.
|
||||
5. Add a line to `TOOL_DOCS` in `src/prompt.ts` saying *when* to reach for it.
|
||||
6. If it is read-only, add it to `READ_ONLY` in `src/agents.ts` so `plan` and `review` can use
|
||||
1. Define it with a `zod` schema and wrap it with `withMeta({ set, mutating }, tool({…}))`
|
||||
at the definition site. `set` is `core | edit-plus | nav | extra | git | net`, `mutating`
|
||||
is whether it writes or executes. Example: `src/tools-extra.ts` (`extra`), `src/tools-git.ts`
|
||||
(`git`), `src/tools-net.ts` (`net`), `src/tools.ts` (everything else). Descriptions are
|
||||
read by the model, so write them as guidance, not as documentation.
|
||||
2. Add it to the `tools` object (or `extraTools`/`gitTools`/`netTools` — they are merged in
|
||||
`src/tools.ts`).
|
||||
3. Add its subject to `subjectOf` in `src/permission.ts` if the approval prompt should match
|
||||
on a field (path, command, …). Add a `DEFAULT_PERMISSIONS` entry for mutating tools.
|
||||
4. Add a line to `TOOL_DOCS` in `src/prompt.ts` saying *when* to reach for it.
|
||||
5. If it is read-only, add it to `READ_ONLY` in `src/agents.ts` so `plan` and `review` can use
|
||||
it.
|
||||
7. Test the behaviour in a temp directory, including the failure path.
|
||||
6. Test the behaviour in a temp directory, including the failure path. `test/tool-derive.test.ts`
|
||||
fails if a builtin tool has no `_meta` or lives in no set, or a mutating tool is outside
|
||||
`MUTATING_TOOLS` — both are derived from the definitions, not hand-lists.
|
||||
|
||||
Steps 3 and 4 are two hand-maintained lists of tool names, which is a known weakness: a tool
|
||||
added to one and forgotten in the other is a silently ungated write. Deriving both from the
|
||||
tool definitions is on [TODO.md](../TODO.md).
|
||||
|
||||
Every tool costs roughly 550 characters of schema on every request. Sixteen built-in tools is
|
||||
Every tool costs roughly 550 characters of schema on every request. Forty-one built-in tools is
|
||||
past where selection accuracy starts to matter, which is why sets exist and why a new tool
|
||||
needs to earn its place — see [ROADMAP.md](../ROADMAP.md) for what has been declined and why.
|
||||
One set, `net`, is opt-in rather than on: `web_fetch` is the one tool that leaves the machine.
|
||||
@@ -128,7 +133,7 @@ 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.4). Bump the version or retag.
|
||||
tag v9.9.9 does not match src/version.ts (0.1.0-beta.5). Bump the version or retag.
|
||||
```
|
||||
|
||||
A binary reporting the wrong version is worse than a failed release.
|
||||
@@ -137,8 +142,8 @@ To cut one:
|
||||
|
||||
```bash
|
||||
# bump src/version.ts and package.json to the same value
|
||||
git commit -am "release 0.1.0-beta.4"
|
||||
git tag v0.1.0-beta.4
|
||||
git commit -am "release 0.1.0-beta.5"
|
||||
git tag v0.1.0-beta.5
|
||||
git push --follow-tags
|
||||
```
|
||||
|
||||
@@ -148,9 +153,9 @@ Windows host and rejected everywhere else — so a green local release is not pr
|
||||
`buildArgs()` is unit-tested for both hosts because of exactly that.
|
||||
|
||||
`.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.
|
||||
targets on one Ubuntu runner, asserts each built binary reports the expected version and is
|
||||
non-empty, 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.
|
||||
@@ -158,10 +163,16 @@ Bun cross-compiles from any host, which is why there is no build matrix. Verifie
|
||||
Publishing is gated on a `v*` tag, so a manual `workflow_dispatch` run produces artifacts
|
||||
without releasing.
|
||||
|
||||
The release body is composed by `scripts/make-release-notes.ts` from the matching `## [<version>]`
|
||||
section of `CHANGELOG.md` (plus an artifact inventory), and it fails the release if that heading
|
||||
is missing — so a tag with no changelog entry cannot ship an empty body. Write the changelog
|
||||
entry first, then tag.
|
||||
|
||||
## CI
|
||||
|
||||
`.github/workflows/ci.yml` runs typecheck, tests, and a build on Ubuntu, macOS, and Windows
|
||||
for every push and PR.
|
||||
for every push and PR, caching the bun install store across runs so a no-change run skips the
|
||||
dependency download.
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Extensions: auto-loaded skills, tools, and plugins
|
||||
|
||||
External extensions load automatically from two directories on every start, the project
|
||||
shadowing the user by name:
|
||||
|
||||
| Origin | Directories |
|
||||
|---|---|
|
||||
| user | `~/.shiro-neko/skills` `~/.shiro-neko/tools` `~/.shiro-neko/plugins` |
|
||||
| project | `.shiro/skills` `.shiro/tools` `.shiro/plugins` |
|
||||
|
||||
Drop a file in and it is live on the next start. No registry, no install command, no restart
|
||||
of anything but the CLI itself.
|
||||
|
||||
**Everything here is data, never code.** That is the same rule the [registry](registry.md)
|
||||
enforces, and it is the whole security model. An external extension can add instructions, a
|
||||
bounded tool, or a refusal rule — it cannot run arbitrary code, so it cannot read every file
|
||||
the agent can read or lie about what it blocks. A malformed file is reported on the welcome
|
||||
dashboard and skipped, never fatal.
|
||||
|
||||
## Skills
|
||||
|
||||
A skill is a Markdown file with frontmatter, exactly like a bundled one:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: deploy
|
||||
description: Ship a release. Use when asked to deploy or cut a release.
|
||||
---
|
||||
|
||||
# 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.
|
||||
```
|
||||
|
||||
Skills merge by name with the precedence `builtin < registry < user < project`, so your own
|
||||
`debug.md` overrides the bundled `debug`. See [skills](skills.md) for the full format.
|
||||
|
||||
## Tools
|
||||
|
||||
A tool is a JSON manifest describing one bounded operation. Three kinds, each with a ceiling
|
||||
on what it can do:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "recent-changes",
|
||||
"description": "List the ten most recently changed files",
|
||||
"kind": "shell",
|
||||
"command": "git diff --name-only HEAD~10",
|
||||
"autoApprove": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `name` | The tool name the model calls. Letters, digits, dashes, underscores. |
|
||||
| `description` | What the model reads to decide when to use it. |
|
||||
| `kind` | `shell`, `http`, or `read`. |
|
||||
| `command` | For `shell`: the template to run, with an optional `{arg}` placeholder. |
|
||||
| `url` | For `http`: the URL to fetch, with an optional `{arg}` placeholder. HTTPS only. |
|
||||
| `path` | For `read`: the workspace file to return, with an optional `{arg}` placeholder. |
|
||||
| `autoApprove` | `false` to require approval before running. Default `true`. |
|
||||
|
||||
The model passes a single optional `arg` string, substituted into `{arg}`.
|
||||
|
||||
**The limits are the point.** A `shell` tool runs a fixed template through the **guard** and
|
||||
the platform shell — the same chain a built-in `bash` call goes through, so an installed tool
|
||||
cannot do what the agent itself may not. An `http` tool fetches one HTTPS URL. A `read` tool
|
||||
returns one workspace file, jailed to the workspace. None of them executes code from the
|
||||
manifest.
|
||||
|
||||
## Plugins
|
||||
|
||||
A plugin is a refusal manifest — the same shape the registry installs — a name, an optional
|
||||
prompt appendix, and deny rules matched against tool input:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "no-prod-config",
|
||||
"description": "refuses to edit production config",
|
||||
"appendix": "Production config is changed by hand, never by the agent.",
|
||||
"deny": [
|
||||
{ "tools": ["write_file", "edit_file"], "pathPattern": "config/production", "reason": "production config is hand-edited" },
|
||||
{ "tools": ["bash"], "commandPattern": "kubectl\\s+apply", "reason": "deploys to the cluster" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A rule names the tools it covers and either a `pathPattern` (matched against the path a file
|
||||
tool carries) or a `commandPattern` (matched against a `bash` command), both as case-insensitive
|
||||
regexes, plus the `reason` handed to the model when it blocks. Patterns are validated on load;
|
||||
an invalid regex is a reported error, not a crash.
|
||||
|
||||
Refusal plugins compose with the built-in [plugins](plugins.md) — the first block wins.
|
||||
|
||||
## Relationship to the registry
|
||||
|
||||
The [registry](registry.md) fetches the same kinds of files over HTTPS with a confirmation
|
||||
step. Auto-load is for your own and your project's files, which need no confirmation because
|
||||
you wrote them. The two mechanisms share the loaders and the safety model; they differ only in
|
||||
where the file comes from.
|
||||
+7
-4
@@ -16,7 +16,7 @@ There is no terminal to approve on, so every gated tool is denied unless `--yolo
|
||||
|
||||
```
|
||||
$ shiro -p "add a test for paginate()"
|
||||
shiro: headless denies write_file, edit_file, multi_edit, apply_patch, bash, web_fetch and mcp tools unless --yolo is passed
|
||||
shiro: headless denies write_file, edit_file, multi_edit, apply_patch, bash, web_fetch, web_search 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)
|
||||
```
|
||||
@@ -51,7 +51,7 @@ $ shiro -p "count the tools" --json
|
||||
{"type":"tool-start","id":"c1","name":"grep"}
|
||||
{"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 16 built-in tools."}
|
||||
{"type":"text","text":"There are 41 built-in tools."}
|
||||
{"type":"done","inputTokens":4210,"outputTokens":88}
|
||||
```
|
||||
|
||||
@@ -182,8 +182,11 @@ in the system prompt, and CI is exactly where nobody is watching what it says. S
|
||||
## Cost control
|
||||
|
||||
Headless runs are unattended, so a runaway loop costs real money. `--agent quick` caps the
|
||||
step count at 12, and `{ "toolSets": [] }` trims the schema sent every request. There is no
|
||||
spend ceiling yet — see [TODO.md](../TODO.md).
|
||||
step count at 12, and `{ "toolSets": [] }` trims the schema sent every request. `maxSpendUsd` in
|
||||
the config is a session spend ceiling: it warns once at 80%, and past 100% the next turn is
|
||||
refused naming the ceiling and the run exits non-zero. It is only enforced on priced models —
|
||||
an unpriced model has no dollar figure to compare against. See
|
||||
[configuration](configuration.md).
|
||||
|
||||
What a run actually costs is in the `done` event, so a wrapper can total it:
|
||||
|
||||
|
||||
+143
-20
@@ -1,12 +1,102 @@
|
||||
# MCP
|
||||
|
||||
Model Context Protocol servers contribute tools to the agent. Two transports: a local
|
||||
command over stdio, and a remote http or sse endpoint.
|
||||
|
||||
## Adding one from the prompt
|
||||
|
||||
```
|
||||
/mcp list what is configured, with the tool count (or "lazy") each contributed
|
||||
/mcp add wizard: local or remote, then the fields that kind needs
|
||||
/mcp remove <name>
|
||||
```
|
||||
|
||||
`/mcp add` asks for the kind first, because the two need different fields — a command and
|
||||
its arguments against a URL and its headers — and a single form with half of it inapplicable
|
||||
is worse than two short ones.
|
||||
|
||||
```
|
||||
Add an MCP server
|
||||
none configured yet
|
||||
> local a command on this machine, over stdio
|
||||
remote an http or sse endpoint
|
||||
```
|
||||
|
||||
The name is validated as it is typed. Tools register as `mcp__<server>__<tool>`, so a name
|
||||
with a space or a double underscore produces a tool the model cannot address and two servers
|
||||
whose namespaces can collide — both are refused in place rather than at connect time. A name
|
||||
already in the config is refused too.
|
||||
|
||||
For a local server the wizard then asks for the command and its arguments; arguments split on
|
||||
spaces and keep quoted runs together, so `--root "/home/my folder"` arrives as one argument.
|
||||
For a remote one it asks for the URL — http or https only — and optional headers as
|
||||
`KEY: value, OTHER: value`.
|
||||
|
||||
Both write straight to `config.json` and merge with whatever is already there. **A new server
|
||||
connects on the next start**, not mid-session: connecting during a turn would change the tool
|
||||
list under a request that is already running.
|
||||
|
||||
`/mcp` shows the state of each configured server, which is what makes a typo visible:
|
||||
|
||||
```
|
||||
mcp servers
|
||||
/mcp add to add one
|
||||
|
||||
- `filesystem` (local) - connected (lazy)
|
||||
npx -y @modelcontextprotocol/server-filesystem .
|
||||
- `api` (remote) - failed: fetch failed
|
||||
https://example.com/mcp
|
||||
|
||||
configured in /home/you/.shiro-neko/config.json
|
||||
```
|
||||
|
||||
Under `eager` the same list shows a tool count instead of `(lazy)`, because every server's tools
|
||||
are registered up front.
|
||||
|
||||
## The config file
|
||||
|
||||
The wizard writes this; it is equally editable by hand.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
|
||||
},
|
||||
"api": {
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer sk-..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Kind | Meaning |
|
||||
|---|---|---|
|
||||
| `command` | local | the executable to spawn |
|
||||
| `args` | local | its arguments |
|
||||
| `env` | local | extra environment variables |
|
||||
| `cwd` | local | working directory |
|
||||
| `url` | remote | the MCP endpoint |
|
||||
| `type` | remote | `http` (default) or `sse` |
|
||||
| `headers` | remote | sent with every request, for auth |
|
||||
|
||||
`--no-mcp` skips every server for one run, which is the first thing to try when the agent is
|
||||
behaving oddly and a server is in play.
|
||||
|
||||
[Model Context Protocol](https://modelcontextprotocol.io) servers contribute tools. Configure
|
||||
them in `~/.shiro-neko/config.json` and they appear alongside the builtins.
|
||||
|
||||
## Configuration
|
||||
|
||||
Everything the wizard writes is equally editable by hand, and a hand-written entry that
|
||||
`/mcp add` would have rejected still connects — the validation is on the input path, not a
|
||||
schema check at load.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpMode": "eager",
|
||||
"mcpServers": {
|
||||
"fs": {
|
||||
"command": "npx",
|
||||
@@ -34,6 +124,10 @@ inherits your `PATH` unless you replace it.
|
||||
**Remote** servers take `url`, and optionally `type` (`http` or `sse`, default `http`) and
|
||||
`headers`.
|
||||
|
||||
A sibling key, `"mcpMode"`, picks how the configured servers' tools reach the model: `lazy`
|
||||
(the default) or `eager`. It is hand-edited — the `/mcp add` wizard does not set it — and applies
|
||||
to every server, so it lives beside `mcpServers`, not inside one.
|
||||
|
||||
A token in `headers` sits in `config.json` in plain text, same as `apiKey`. For anything beyond
|
||||
a local dev token, prefer a stdio server that reads its own credential from the environment.
|
||||
|
||||
@@ -46,10 +140,35 @@ calling the binary directly is usually the difference between a noticeable wait
|
||||
`--no-mcp` skips them all, which is also the quickest way to tell whether a slow start is MCP
|
||||
or something else.
|
||||
|
||||
## How a server's tools reach the model
|
||||
|
||||
Two modes, switched with `"mcpMode"` in config. **`lazy` is the default**; `eager` is the opt-in.
|
||||
|
||||
- **Lazy** registers three meta-tools — `mcp_list`, `mcp_inspect`, `mcp_call` — instead of one
|
||||
schema per server tool. The server's real tools are fetched only when `mcp_call` actually
|
||||
invokes one, so a server exposing twenty tools costs almost nothing until one is used. This
|
||||
is why the affordability paragraph in the README says a configured server no longer taxes
|
||||
every request.
|
||||
- **Eager** registers every server tool up front as in the old 1.0 behaviour. If a server
|
||||
exposes only two tools, eager is cheaper because there is no list-then-inspect round trip.
|
||||
|
||||
The model is told the connected server names through the meta-tool descriptions and a prompt
|
||||
line, then discovers each tool's schema on demand:
|
||||
|
||||
```
|
||||
- mcp_list, mcp_inspect, mcp_call: MCP tools are fetched on demand. mcp_list names a
|
||||
server's tools, mcp_inspect reads one tool's schema, mcp_call runs it. Never guess a
|
||||
server or tool name: list first.
|
||||
```
|
||||
|
||||
A `mcp_call` still routes through the same permission rules and guard as a built-in, so the
|
||||
lazy path is not a way around approval.
|
||||
|
||||
## Naming
|
||||
|
||||
Tools arrive as `mcp__<server>__<tool>`. A server named `fs` exposing `read_file` becomes
|
||||
`mcp__fs__read_file`.
|
||||
In `lazy` mode (the default) tools are addressed as `mcp_call(server, toolName, args)`; the
|
||||
server names are zero-ambiguity identifiers you list first. In `eager` mode 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.
|
||||
@@ -67,7 +186,7 @@ one tool for the session.
|
||||
A server that fails to start is reported and the session continues:
|
||||
|
||||
```
|
||||
shiro-neko 0.1.0-beta.4 openai/gpt-5 session 0193ab2c
|
||||
shiro-neko 0.1.0-beta.5 openai/gpt-5 session 0193ab2c
|
||||
mcp: 4 tools
|
||||
mcp db failed: spawn python ENOENT
|
||||
```
|
||||
@@ -79,26 +198,29 @@ Python interpreter should not stop you from editing a file.
|
||||
|
||||
## Inspecting
|
||||
|
||||
`/tools` lists everything offered this turn, MCP tools included. The system prompt describes
|
||||
them as a group:
|
||||
`/tools` lists everything offered this turn. In `eager` mode that includes each MCP tool, named
|
||||
`mcp__<server>__<tool>`, described with whatever the server sent. In `lazy` mode the three
|
||||
meta-tools appear and the server's real tools are surfaced by `mcp_list` inside the session.
|
||||
|
||||
```
|
||||
- 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.
|
||||
Into `mcp_inspect` or `mcp_list` goes the server name, not an `mcp__` path, so the prompt tells
|
||||
the model which servers are connected and to list first before guessing a tool name.
|
||||
|
||||
## Cost
|
||||
|
||||
Each tool adds its name, description, and JSON schema to every request. The built-ins average
|
||||
548 bytes; MCP tools vary with how verbose the server's schema is. A server exposing twenty
|
||||
tools costs roughly 2,750 tokens per turn, sent whether or not the model uses any of them.
|
||||
In **lazy** mode (the default) a configured server contributes three small meta-tool schemas to
|
||||
every request, not one schema per tool. A server exposing twenty tools therefore costs a few
|
||||
hundred tokens per turn rather than roughly 2,750, and it stays cheap whether the model uses
|
||||
the tools or not. Browsing a server's tools and reading a schema still brings that server's
|
||||
schema into view one tool at a time, but only when the model asks for it.
|
||||
|
||||
MCP tools are **not** covered by `toolSets` — that budget only governs the built-ins. There is
|
||||
no per-server switch either, so the choice is a server or no server, and `--no-mcp` for all of
|
||||
them. If one exposes many tools you never use, a narrower server is worth finding or writing.
|
||||
In **eager** mode each tool adds its name, description, and JSON schema to every request, and
|
||||
that cost is sent whether or not the model uses any of them. That is the right trade only for a
|
||||
server with one or two tools, which is why eager exists.
|
||||
|
||||
MCP tools are **not** covered by `toolSets` in either mode — that budget only governs the
|
||||
built-ins. There is no per-server switch beyond the global `mcpMode`, so choosing `eager` turns
|
||||
every server eager; a server exposing many tools you never use is worth finding a narrower one
|
||||
for.
|
||||
|
||||
`/tools` shows the count both ways:
|
||||
|
||||
@@ -124,8 +246,9 @@ that break in practice are the handshake and the framing, and a mock asserts nei
|
||||
A server that starts but returns nothing useful is the harder case. In order of speed:
|
||||
|
||||
1. `/tools` — did the tools arrive at all? A server with no tools is a `tools/list` problem.
|
||||
2. `shiro -p "call mcp__x__y with ..." --json --yolo` — the exact `tool-call` input and
|
||||
`tool-result` output, one JSON object per line.
|
||||
2. `shiro -p "run mcp_call(server, \"api\", \"query\", {...}) with ..." --json --yolo` — the
|
||||
exact `tool-call` input and `tool-result` output, one JSON object per line. In eager mode the
|
||||
address is `mcp__<server>__<tool>` instead.
|
||||
3. Run the server by hand: `echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | your-server`.
|
||||
If that is wrong, nothing above it can be right.
|
||||
|
||||
|
||||
@@ -125,6 +125,21 @@ shiro -c # newest session for this directory
|
||||
shiro -r 0193ab2c # by id or unique prefix
|
||||
```
|
||||
|
||||
Both are printed as shiro exits, so the id is on screen rather than in a directory you
|
||||
have to go looking through:
|
||||
|
||||
```
|
||||
Good bye.
|
||||
Saved 8 messages: "why does the pagination test fail?"
|
||||
|
||||
Resume it with:
|
||||
shiro -c newest session in this directory
|
||||
shiro -r 0193ab2c this session by id
|
||||
```
|
||||
|
||||
A session with no messages was never written, so it says so instead of naming a command
|
||||
that would find nothing.
|
||||
|
||||
```
|
||||
/sessions list the last 15
|
||||
/resume <id>
|
||||
@@ -208,6 +223,26 @@ answering it:
|
||||
alone deliberately: a tool call still waiting for its result is what a suspended approval looks
|
||||
like, and dropping it would break `/resume`.
|
||||
|
||||
**An item the provider no longer holds.** An `item_reference` only resolves while the item is
|
||||
still in provider storage. A session resumed the next day, or one that fell back from
|
||||
`/v1/chat/completions` to `/v1/responses` mid-turn, can carry references to items that are gone:
|
||||
|
||||
```
|
||||
404 Item with id 'msg_…' not found.
|
||||
```
|
||||
|
||||
Retrying that history fails identically every time, so there is nothing to wait for. Two things
|
||||
answer it. Compaction now strips every provider `itemId` from the history it sends, so a pruned
|
||||
turn is always inline; and a 404 naming a missing item rewrites the session's own history inline
|
||||
and runs the request again — once per turn, reported as:
|
||||
|
||||
```
|
||||
the provider no longer had part of this session stored. Re-sent the history inline and carried on.
|
||||
```
|
||||
|
||||
Only a rejection *before* any output is repaired. Once text is on screen it cannot be unsent, and
|
||||
a retry would say it all a second time.
|
||||
|
||||
### What compaction still does not do
|
||||
|
||||
It tells the model the history was pruned but not what was in it. A decision from forty messages
|
||||
|
||||
+4
-2
@@ -33,9 +33,11 @@ remain are the ones worth reading.
|
||||
| Tool | Matched against |
|
||||
|---|---|
|
||||
| `bash` | the command, e.g. `git status --porcelain` |
|
||||
| `read_file` `write_file` `edit_file` `multi_edit` `list_dir` | the path |
|
||||
| `read_file` `write_file` `edit_file` `multi_edit` `delete_file` `list_dir` | the path |
|
||||
| `move_file` | both ends; one match is enough |
|
||||
| `apply_patch` | every file marker path in the patch |
|
||||
| `web_fetch` | the URL |
|
||||
| `web_search` | the query |
|
||||
| `read_many_files` | every path in the batch; one match is enough |
|
||||
| `glob` `grep` | the pattern |
|
||||
| `git_diff` `git_log` `git_blame` | the path, when given |
|
||||
@@ -98,7 +100,7 @@ With no `permission` config:
|
||||
| `glob` `grep` `list_dir` | `allow` |
|
||||
| the git tools | `allow` — they cannot mutate anything |
|
||||
| `task`, and every session tool | `allow` — they touch the agent's own state |
|
||||
| `write_file` `edit_file` `multi_edit` `apply_patch` `bash` `web_fetch` | `ask` |
|
||||
| `write_file` `edit_file` `multi_edit` `apply_patch` `move_file` `delete_file` `bash` `web_fetch` `web_search` | `ask` |
|
||||
| anything else, including every `mcp__*` tool | `ask` |
|
||||
|
||||
Credentials are denied on read rather than gated, because there is no recovery. A model that
|
||||
|
||||
+80
-5
@@ -19,7 +19,7 @@ the agent can read. That is a sandbox problem, not a loader problem — see
|
||||
## Enabling
|
||||
|
||||
```json
|
||||
{ "plugins": ["guard", "time"] }
|
||||
{ "plugins": ["guard", "secrets", "protect", "time", "no-force-push", "no-net-pipe", "no-root", "no-env-write"] }
|
||||
```
|
||||
|
||||
That is also the default when the field is absent, and it lists **builtin** plugins only.
|
||||
@@ -95,18 +95,89 @@ a `write_file` overwriting something important is an approval question, not a gu
|
||||
The guard is the last line before a command runs; `ctrl-c` is the one after. A pattern the guard
|
||||
does not know about is still interruptible by hand — see [tools](tools.md#bash).
|
||||
|
||||
### `protect` (default on)
|
||||
|
||||
Refuses writes to files whose contents belong to a tool rather than to anyone editing them by
|
||||
hand. This is a different failure from a secret: the repository looks fine and behaves wrongly,
|
||||
and the breakage surfaces somewhere else entirely.
|
||||
|
||||
| Refused | Why |
|
||||
|---|---|
|
||||
| `.git/**` | git's own object store |
|
||||
| `bun.lock`, `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `Cargo.lock`, `go.sum`, `poetry.lock`, `uv.lock`, `composer.lock`, `Gemfile.lock` | the package manager owns it |
|
||||
| `node_modules/**` | an installed dependency |
|
||||
| `vendor/**`, `target/debug/**`, `target/release/**` | vendored or build directory |
|
||||
| `dist/**`, `build/**`, `out/**`, `.next/**`, `.nuxt/**`, `.svelte-kit/**`, `coverage/**` | generated output |
|
||||
| `.venv/**`, `.tox/**`, `.mypy_cache/**`, `.ruff_cache/**`, `.turbo/**` | tool caches |
|
||||
|
||||
```
|
||||
refusing to write bun.lock (a lockfile the package manager owns). Regenerate it with the
|
||||
tool that owns it rather than editing it.
|
||||
```
|
||||
|
||||
The message says what to do instead, which matters: a model told only "no" writes the same
|
||||
content somewhere else. A lockfile is regenerated by `bun install`; build output is regenerated
|
||||
by the build.
|
||||
|
||||
Both separators match, so `node_modules\react\index.js` is refused on Windows too. Lookalike
|
||||
names are not: `src/gitignore-parser.ts`, `docs/dist-layout.md`, and `distributed/queue.ts` all
|
||||
write normally.
|
||||
|
||||
### `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.
|
||||
|
||||
### The narrow safety refusals (default on)
|
||||
|
||||
Six small plugins, each blocking one irreversible class of mistake. They are on by default for
|
||||
the same reason the guard is: a safety check you have to opt into is not one. Each is data — a
|
||||
name, a pattern list, and a refusal message — matched against the `bash` command string (or, for
|
||||
`confirm-delete`, the path a `delete_file` carries).
|
||||
|
||||
| Plugin | Refuses | Why |
|
||||
|---|---|---|
|
||||
| `no-force-push` | `git push --force`, `--force-with-lease`, `-f`, `+<ref>` | rewrites remote history |
|
||||
| `no-net-pipe` | `curl … \| sh`, `wget … \| node`, `iex (iwr …)` | executes a download unseen |
|
||||
| `no-root` | `sudo …`, elevated `runas` / `Start-Process -Verb RunAs` | nothing the agent does should need root |
|
||||
| `no-env-write` | `export …KEY/TOKEN/SECRET/PASSWORD=…` | writes a credential into the environment |
|
||||
| `no-main-commit` | `git commit`/`git merge` naming `main`/`master` | touches the default branch directly |
|
||||
| `no-git-config` | `git config --global`, identity/runner keys | changes how git identifies or runs |
|
||||
|
||||
Normal commands pass: `git push origin feature`, `npm test`, `export NODE_ENV=production`. The
|
||||
patterns target the irreversible act, not the command family.
|
||||
|
||||
### The advisory plugins (opt in)
|
||||
|
||||
Three plugins carry only a prompt appendix — no blocking hook — so they shape behaviour without
|
||||
ever refusing a call. Enable them in config when you want the nudge:
|
||||
|
||||
| Plugin | Advises |
|
||||
|---|---|
|
||||
| `conventional-commit` | commit subjects as `type(scope): summary`, e.g. `fix(auth): reject expired tokens` |
|
||||
| `tests-first` | for a bug, pin it with a failing test before fixing; watch it fail, then pass |
|
||||
| `small-diffs` | one change does one thing; split a diff that is really two |
|
||||
|
||||
```json
|
||||
{ "plugins": ["guard", "secrets", "protect", "time", "conventional-commit", "small-diffs"] }
|
||||
```
|
||||
|
||||
### `confirm-delete` (opt in)
|
||||
|
||||
Refuses `delete_file` calls whose path is broad or ambiguous — a wildcard, a trailing slash, or
|
||||
an empty path — so a delete is always one explicit file:
|
||||
|
||||
```
|
||||
refusing to delete "src/*" (ambiguous or broad). Delete one explicit file.
|
||||
```
|
||||
|
||||
### `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"] }
|
||||
{ "plugins": ["guard", "secrets", "protect", "time", "bell"] }
|
||||
```
|
||||
|
||||
## Writing one
|
||||
@@ -125,7 +196,8 @@ export const noSecretsPlugin: Plugin = {
|
||||
'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 (!['write_file', 'edit_file', 'multi_edit', 'apply_patch'].includes(toolName)) return undefined;
|
||||
const WRITE_TOOLS = ['write_file', 'edit_file', 'multi_edit', 'apply_patch', 'move_file', 'delete_file'];
|
||||
if (!WRITE_TOOLS.includes(toolName)) 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`;
|
||||
@@ -137,8 +209,11 @@ export const noSecretsPlugin: Plugin = {
|
||||
|
||||
Then add it to `BUILTIN_PLUGINS` and, if it should be on by default, `DEFAULT_ENABLED`.
|
||||
|
||||
Note the four tool names. Every write tool has to be listed, and `multi_edit` is easy to miss
|
||||
— a guard that only checks `write_file` and `edit_file` is bypassed by a batch edit.
|
||||
Note the six tool names. Every write tool has to be listed, and `multi_edit`, `apply_patch`,
|
||||
and `move_file` are all easy to miss — a guard that only checks `write_file` and `edit_file` is
|
||||
bypassed by a batch edit, a patch, or a rename. `apply_patch` and `move_file` also carry their
|
||||
paths somewhere other than `path`, so a guard reading only that field sees nothing to check.
|
||||
The builtins share one `writtenPaths` helper for exactly that reason.
|
||||
|
||||
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.
|
||||
|
||||
+26
-6
@@ -3,9 +3,9 @@
|
||||
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. The six bundled skills are 8,900 characters of body against roughly 1,000
|
||||
characters of catalogue — paid on every request. Putting every body in the prompt
|
||||
would cost that on every turn, for instructions relevant to one turn in twenty.
|
||||
That split matters. The twenty-nine bundled skills are tens of thousands of characters of body
|
||||
against a small catalogue of names and descriptions — paid on every request. Putting every body
|
||||
in the prompt would cost that on every turn, for instructions relevant to one turn in twenty.
|
||||
|
||||
## Format
|
||||
|
||||
@@ -77,9 +77,29 @@ what was not verified.
|
||||
match the repository's message style, and the refusals — no amending pushed commits, no
|
||||
`--no-verify`, no push unless asked.
|
||||
|
||||
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.
|
||||
**`security`** — find the trust boundary, then work outward: injection, missing authorisation,
|
||||
path traversal, secrets in the wrong place, SSRF, hand-rolled crypto. Do not report a finding
|
||||
without a path from an attacker-controlled value to the sink.
|
||||
|
||||
**`perf`** — measure before changing anything, find where the time actually goes, change one
|
||||
thing at a time, and stop at a target stated up front. Report the baseline alongside the win.
|
||||
|
||||
**`migrate`** — read the changelog first, find every call site before changing one (including
|
||||
CI, Dockerfiles, and docs), apply one shape of change rather than improving as you pass, and
|
||||
never hand-merge a lockfile.
|
||||
|
||||
**`plan`** — break a non-trivial task into an ordered, verifiable sequence before writing code:
|
||||
order by dependency rather than by file, one step one verifiable outcome, keep it small, and
|
||||
replan when the ground moves.
|
||||
|
||||
**`docs`** — write documentation grounded in the source: verify every claim against the code,
|
||||
answer the reader's actual question, show a working example before describing one, and match
|
||||
the house style.
|
||||
|
||||
They are Markdown files in `src/skills-md/`, one per skill, loaded by `src/skills-builtin.ts`
|
||||
as Bun raw-text imports. The `.md` file is the single source of truth — frontmatter and body
|
||||
in proper Markdown — and Bun inlines every text import into the compiled binary, so the folder
|
||||
ships with `bun build --compile` rather than being left behind on disk.
|
||||
|
||||
## How the agent uses one
|
||||
|
||||
|
||||
+366
-12
@@ -15,8 +15,8 @@ auto-approved.
|
||||
reaches the context is on the wire and in the session file, and there is no taking it back.
|
||||
`*.env.example` is allowed.
|
||||
|
||||
**Asked by default.** `write_file`, `edit_file`, `multi_edit`, `apply_patch`, `bash`, `web_fetch`,
|
||||
and every `mcp__*` tool.
|
||||
**Asked by default.** `write_file`, `edit_file`, `multi_edit`, `apply_patch`, `move_file`,
|
||||
`delete_file`, `bash`, `bash_stop`, `web_fetch`, and every `mcp__*` tool.
|
||||
|
||||
```
|
||||
bash wants to run
|
||||
@@ -46,7 +46,7 @@ Three more things sit around the rules:
|
||||
## Tool sets
|
||||
|
||||
Each tool costs its name, its description, and its JSON schema on **every request**. The current
|
||||
registry has sixteen built-ins. `/tools` shows the live set; disabling an optional set removes
|
||||
registry has forty-one built-ins. `/tools` shows the live set; disabling an optional set removes
|
||||
its schemas from both the request and the system prompt.
|
||||
|
||||
| Tool | Bytes | Tool | Bytes |
|
||||
@@ -66,10 +66,12 @@ Sets let you switch off what a project does not need:
|
||||
|
||||
| Set | Tools | Cost |
|
||||
|---|---|---|
|
||||
| `core` | `read_file` `write_file` `edit_file` `glob` `grep` `bash` | ~2,993 B |
|
||||
| `edit-plus` | `multi_edit` `list_dir` `read_many_files` `apply_patch` | patch included |
|
||||
| `git` | `git_status` `git_diff` `git_log` `git_show` `git_blame` | ~2,180 B |
|
||||
| `net` | `web_fetch` | opt in |
|
||||
| `core` | `read_file` `read_many_files` `write_file` `edit_file` `glob` `grep` `bash` `bash_status` `bash_stop` | ~3,200 B |
|
||||
| `edit-plus` | `multi_edit` `list_dir` `apply_patch` `move_file` `delete_file` | patch and file ops |
|
||||
| `nav` | `find_symbol` `json_query` | navigation and structured reads |
|
||||
| `extra` | 20 tools: line edits, fs inspect, git extensions, code/env reads | on by default |
|
||||
| `git` | `git_status` `git_diff` `git_log` `git_show` `git_blame` `git_branch` `git_commit_message` | ~2,180 B + message |
|
||||
| `net` | `web_fetch`, `web_search` | opt in |
|
||||
|
||||
```json
|
||||
{ "toolSets": ["edit-plus"] }
|
||||
@@ -107,6 +109,247 @@ Both extra sets earn their place in most projects, but not all:
|
||||
- **Reading a lot, editing rarely?** Keep `edit-plus` for `list_dir` and `read_many_files`
|
||||
alone; they pay for themselves in round trips saved.
|
||||
|
||||
## The `extra` set
|
||||
|
||||
Twenty-one tools across five families, on by default. Each follows the same rules as the core
|
||||
tools: writes are jailed to the workspace, reads honour `.gitignore`, and every git call spawns
|
||||
the binary with a fixed argument array, never a shell string.
|
||||
|
||||
### Line edits
|
||||
|
||||
Precise edits by line number, for changes that need no full-file rewrite and no exact-string
|
||||
match. All refuse a path outside the workspace.
|
||||
|
||||
| Tool | Does |
|
||||
|---|---|
|
||||
| `insert_lines` | Insert a block before a 1-based line, pushing the rest down. One past the end appends. |
|
||||
| `delete_lines` | Delete an inclusive line range. Refuses the whole file — that is `delete_file`'s job. |
|
||||
| `replace_lines` | Replace an inclusive line range with new text in one write. |
|
||||
| `append_file` | Add text to the end of a file. |
|
||||
| `prepend_file` | Add text to the top of a file, e.g. a header or import block. |
|
||||
| `count_lines` | Line count for one file, or per file across a glob. A size read before opening something large. |
|
||||
|
||||
### Filesystem
|
||||
|
||||
| Tool | Does |
|
||||
|---|---|
|
||||
| `tree` | Indented directory tree, ignore-aware, directories first. A broad shape faster to scan than `list_dir`. |
|
||||
| `file_info` | Size, line count, modified time, text-or-binary for one file. |
|
||||
| `find_files` | Files whose *name* contains a substring (not a glob), e.g. `auth`. |
|
||||
| `recent_files` | Files modified most recently, newest first. Find what a tool just touched. |
|
||||
| `changed_files` | The working-tree delta git reports (modified, staged, untracked). |
|
||||
|
||||
### Git extensions (read-only)
|
||||
|
||||
Spawned with a fixed argv, so they are auto-approved like the core git tools.
|
||||
|
||||
| Tool | Does |
|
||||
|---|---|
|
||||
| `git_log_file` | Commits that touched one file, newest first, with hash, date, subject. |
|
||||
| `git_diff_commits` | Diff between two refs, optionally limited to one path. |
|
||||
| `git_show_file` | A file's contents at a ref, e.g. `auth.ts` at `HEAD~3`. |
|
||||
| `git_current_branch` | The current branch with its upstream and ahead/behind count. |
|
||||
| `git_changed_in_ref` | Files changed between a ref and the working tree, names only. |
|
||||
|
||||
### Code and environment
|
||||
|
||||
| Tool | Does |
|
||||
|---|---|
|
||||
| `find_symbol` | Where a function, class, or type is *defined* across JS/TS, Python, Go, Rust. Matches declarations, not uses. |
|
||||
| `json_query` | One value from a JSON file by dotted path (`scripts.build`), instead of reading it whole. |
|
||||
| `outline` | Top-level declarations of a source file as a structural map. Read before opening a large file. |
|
||||
| `read_symbol` | The full body of one top-level definition by name. |
|
||||
| `env_info` | Platform, shell, and which runtimes and package managers are installed, before writing a command. |
|
||||
| `count_tokens` | Estimate the token cost of a file or string (~4 chars per token) before sending it to the model. |
|
||||
| `run_checks` | Find the project's check commands (AGENTS.md first, then package.json scripts, then toolchain defaults) and run them with a timeout; pass/fail + first error. |
|
||||
|
||||
## Verification: the run_checks tool
|
||||
|
||||
"Verify before done" is a mechanism, not advice. `run_checks` finds the commands a project
|
||||
actually documents and runs them against a timer, so the model knows what passing means in
|
||||
this repo without guessing.
|
||||
|
||||
Discovery order (first match wins per command):
|
||||
|
||||
1. **AGENTS.md** — any line picking out a command (a backticked span, or a `# Tools
|
||||
|
||||
## The approval model
|
||||
|
||||
Every call resolves to `allow`, `ask`, or `deny` through a rule matched against the call's
|
||||
subject — the command for `bash`, the path for a file tool. [Permissions](permissions.md) is the
|
||||
full reference; the short version:
|
||||
|
||||
**Allowed by default.** Read-only tools and anything touching the agent's own state:
|
||||
`read_file`, `read_many_files`, `glob`, `grep`, `list_dir`, `task`, the whole git set,
|
||||
`todo_write`, `remember`, `recall`, `forget`, `skill`, `ask`, and anything a plugin marks
|
||||
auto-approved.
|
||||
|
||||
**Denied by default.** `*.env`, `*.env.*`, and `*.pem` on read. Not gated, refused: a secret that
|
||||
reaches the context is on the wire and in the session file, and there is no taking it back.
|
||||
`*.env.example` is allowed.
|
||||
|
||||
**Asked by default.** `write_file`, `edit_file`, `multi_edit`, `apply_patch`, `move_file`,
|
||||
`delete_file`, `bash`, `bash_stop`, `web_fetch`, and every `mcp__*` tool.
|
||||
|
||||
```
|
||||
bash wants to run
|
||||
git status --porcelain
|
||||
y allow once | a always allow bash git * | n deny
|
||||
```
|
||||
|
||||
`a` whitelists the **pattern**, not the tool: approving `git status` runs `git log` unprompted and
|
||||
still asks about `npm publish`. `n` tells the model it was denied and to ask what to do instead.
|
||||
|
||||
A rule turns the common cases off entirely:
|
||||
|
||||
```json
|
||||
{ "permission": { "bash": { "*": "ask", "git *": "allow", "bun test*": "allow" } } }
|
||||
```
|
||||
|
||||
Three more things sit around the rules:
|
||||
|
||||
- **The guard plugin refuses first.** It is not an approval, and `--yolo` does not reach it. See
|
||||
[plugins](plugins.md).
|
||||
- **A repeated call asks anyway.** The same tool with identical input three times in one turn stops
|
||||
for approval even when allowed — a model repeating itself is not making progress.
|
||||
- **The SDK enforces the decision.** A denied call provably never executes, because the SDK never
|
||||
reaches the tool's `execute`. A tool cannot forget to honour a denial. See
|
||||
[architecture](architecture.md#why-approval-goes-through-the-sdk).
|
||||
|
||||
## Tool sets
|
||||
|
||||
Each tool costs its name, its description, and its JSON schema on **every request**. The current
|
||||
registry has forty-one built-ins. `/tools` shows the live set; disabling an optional set removes
|
||||
its schemas from both the request and the system prompt.
|
||||
|
||||
| Tool | Bytes | Tool | Bytes |
|
||||
|---|---|---|---|
|
||||
| `read_many_files` | 972 | `git_blame` | 499 |
|
||||
| `multi_edit` | 934 | `git_log` | 484 |
|
||||
| `edit_file` | 618 | `git_diff` | 473 |
|
||||
| `grep` | 595 | `bash` | 466 |
|
||||
| `list_dir` | 594 | `git_show` | 432 |
|
||||
| `read_file` | 526 | `git_status` | 292 |
|
||||
| `glob` | 499 | `write_file` | 289 |
|
||||
|
||||
Selection accuracy also falls as the list grows: a model choosing between six tools picks better
|
||||
than one choosing between twenty.
|
||||
|
||||
Sets let you switch off what a project does not need:
|
||||
|
||||
| Set | Tools | Cost |
|
||||
|---|---|---|
|
||||
| `core` | `read_file` `read_many_files` `write_file` `edit_file` `glob` `grep` `bash` `bash_status` `bash_stop` | ~3,200 B |
|
||||
| `edit-plus` | `multi_edit` `list_dir` `apply_patch` `move_file` `delete_file` | patch and file ops |
|
||||
| `nav` | `find_symbol` `json_query` | navigation and structured reads |
|
||||
| `extra` | 20 tools: line edits, fs inspect, git extensions, code/env reads | on by default |
|
||||
| `git` | `git_status` `git_diff` `git_log` `git_show` `git_blame` `git_branch` `git_commit_message` | ~2,180 B + message |
|
||||
| `net` | `web_fetch`, `web_search` | opt in |
|
||||
|
||||
```json
|
||||
{ "toolSets": ["edit-plus"] }
|
||||
```
|
||||
|
||||
Omit `toolSets` for the default sets. Add `net` when the agent should fetch public pages.
|
||||
`core` is always on — without read, edit, and bash the agent is not an agent. A disabled set reaches neither the wire nor the system prompt, since
|
||||
a prompt that names an absent tool teaches the model to attempt calls that cannot succeed.
|
||||
Session, plugin, and MCP tools are not part of this budget and are never gated here.
|
||||
|
||||
An unrecognised set name is dropped silently. The header line at startup shows which sets
|
||||
actually loaded, so a typo reads as "that set is off" rather than as an error — worth checking
|
||||
if a tool you expected is missing.
|
||||
|
||||
`/tools` shows which set each live tool came from:
|
||||
|
||||
```
|
||||
tools
|
||||
20 offered this turn of 22 registered
|
||||
- `bash` core
|
||||
- `git_diff` git
|
||||
- `list_dir` edit-plus
|
||||
- `remember`
|
||||
```
|
||||
|
||||
A tool with no set is a session, plugin, or MCP tool.
|
||||
|
||||
### Which sets to keep
|
||||
|
||||
Both extra sets earn their place in most projects, but not all:
|
||||
|
||||
- **No git in the repo?** `git` is 2,180 bytes the model can never use. Switch it off.
|
||||
- **A model that handles many tools badly?** `{ "toolSets": [] }` trims to six, which is the
|
||||
smallest set that still lets the agent work.
|
||||
- **Reading a lot, editing rarely?** Keep `edit-plus` for `list_dir` and `read_many_files`
|
||||
alone; they pay for themselves in round trips saved.
|
||||
|
||||
## The `extra` set
|
||||
|
||||
Twenty-one tools across five families, on by default. Each follows the same rules as the core
|
||||
tools: writes are jailed to the workspace, reads honour `.gitignore`, and every git call spawns
|
||||
the binary with a fixed argument array, never a shell string.
|
||||
|
||||
### Line edits
|
||||
|
||||
Precise edits by line number, for changes that need no full-file rewrite and no exact-string
|
||||
match. All refuse a path outside the workspace.
|
||||
|
||||
| Tool | Does |
|
||||
|---|---|
|
||||
| `insert_lines` | Insert a block before a 1-based line, pushing the rest down. One past the end appends. |
|
||||
| `delete_lines` | Delete an inclusive line range. Refuses the whole file — that is `delete_file`'s job. |
|
||||
| `replace_lines` | Replace an inclusive line range with new text in one write. |
|
||||
| `append_file` | Add text to the end of a file. |
|
||||
| `prepend_file` | Add text to the top of a file, e.g. a header or import block. |
|
||||
| `count_lines` | Line count for one file, or per file across a glob. A size read before opening something large. |
|
||||
|
||||
### Filesystem
|
||||
|
||||
| Tool | Does |
|
||||
|---|---|
|
||||
| `tree` | Indented directory tree, ignore-aware, directories first. A broad shape faster to scan than `list_dir`. |
|
||||
| `file_info` | Size, line count, modified time, text-or-binary for one file. |
|
||||
| `find_files` | Files whose *name* contains a substring (not a glob), e.g. `auth`. |
|
||||
| `recent_files` | Files modified most recently, newest first. Find what a tool just touched. |
|
||||
| `changed_files` | The working-tree delta git reports (modified, staged, untracked). |
|
||||
|
||||
### Git extensions (read-only)
|
||||
|
||||
Spawned with a fixed argv, so they are auto-approved like the core git tools.
|
||||
|
||||
| Tool | Does |
|
||||
|---|---|
|
||||
| `git_log_file` | Commits that touched one file, newest first, with hash, date, subject. |
|
||||
| `git_diff_commits` | Diff between two refs, optionally limited to one path. |
|
||||
| `git_show_file` | A file's contents at a ref, e.g. `auth.ts` at `HEAD~3`. |
|
||||
| `git_current_branch` | The current branch with its upstream and ahead/behind count. |
|
||||
| `git_changed_in_ref` | Files changed between a ref and the working tree, names only. |
|
||||
|
||||
### Code and environment
|
||||
|
||||
| Tool | Does |
|
||||
|---|---|
|
||||
| `find_symbol` | Where a function, class, or type is *defined* across JS/TS, Python, Go, Rust. Matches declarations, not uses. |
|
||||
| `json_query` | One value from a JSON file by dotted path (`scripts.build`), instead of reading it whole. |
|
||||
| `outline` | Top-level declarations of a source file as a structural map. Read before opening a large file. |
|
||||
| `read_symbol` | The full body of one top-level definition by name. |
|
||||
| `env_info` | Platform, shell, and which runtimes and package managers are installed, before writing a command. |
|
||||
-prefixed block).
|
||||
This is the strongest source: it is written by the people who know what a cold agent
|
||||
should run.
|
||||
2. **package.json scripts** — `test`, `typecheck`, `check`, `lint`, `build` in that priority,
|
||||
then the rest alphabetically. The runner matches the lockfile: `bun run` when `bun.lock`
|
||||
exists, `npm run` otherwise.
|
||||
3. **Toolchain defaults** — what the project's own build system says verify means: `bun test`
|
||||
for a Bun project, `cargo test` for Rust, `go test ./...` for Go, `pytest` for Python.
|
||||
|
||||
The `target` argument selects one suggestion by name (or `all`). Output is capped, runs are
|
||||
killed at the timeout (SIGTERM reported, not a clean exit), and a 3-iteration fix ceiling is
|
||||
baked into the system prompt's verify loop — a failing check gets fixed and re-run, but a
|
||||
check that keeps failing stops grinding and reports instead.
|
||||
|
||||
Each candidate is gated by permission rules on its `target` name, the only part the model
|
||||
chooses.
|
||||
|
||||
## File tools
|
||||
|
||||
### `read_file`
|
||||
@@ -151,6 +394,16 @@ content full contents
|
||||
|
||||
New files and full rewrites only. Creates parent directories.
|
||||
|
||||
A rewrite that collapses whitespace is flagged in the result: similar character count,
|
||||
a fraction of the lines. A model writing a large file under output pressure squeezes
|
||||
newlines and indentation before it cuts markup — the bytes survive, the layout does not —
|
||||
so the result names the collapse and the turn fixes it in place:
|
||||
|
||||
```
|
||||
Wrote 139 chars to index.blade.php, but it collapsed 9 lines into 1. If that was not
|
||||
intended, re-send the content with its original newlines and indentation.
|
||||
```
|
||||
|
||||
### `edit_file`
|
||||
|
||||
```
|
||||
@@ -203,6 +456,31 @@ unchanged. Use it when one change spans files that must land together; use `mult
|
||||
several edits to one file and `edit_file` for one edit. Paths stay inside the workspace and the
|
||||
call asks for approval.
|
||||
|
||||
### `move_file`
|
||||
|
||||
```
|
||||
from existing file path
|
||||
to new path, including the filename
|
||||
```
|
||||
|
||||
Renames or relocates one file, creating the target directory. Refuses a missing source and an
|
||||
occupied target, so a rename cannot silently overwrite work. Permission rules match **both**
|
||||
ends, so denying `src/generated/*` catches a move that lands there as well as one that starts
|
||||
there.
|
||||
|
||||
For a rename plus its callers in one atomic step, `apply_patch` is the better tool: it lands
|
||||
the move and the edits together or not at all.
|
||||
|
||||
### `delete_file`
|
||||
|
||||
```
|
||||
path file to delete
|
||||
```
|
||||
|
||||
Deletes one file and reports its size. A directory is refused: removing a tree is exactly what
|
||||
the guard plugin blocks in `bash`, and it is not something to do implicitly through a tool
|
||||
whose name says "file". Delete the files you mean, one call each.
|
||||
|
||||
### `list_dir`
|
||||
|
||||
```
|
||||
@@ -267,8 +545,10 @@ reported as `Invalid regex: <reason>` rather than returning an empty result set.
|
||||
### `bash`
|
||||
|
||||
```
|
||||
command shell command
|
||||
timeout ms, default 120000, max 600000
|
||||
command shell command
|
||||
timeout ms, default 120000, max 600000 (ignored when background is true)
|
||||
background detach the command and return immediately with a handle
|
||||
name label for a background command, shown in /bash and bash_status
|
||||
```
|
||||
|
||||
Runs in the workspace root through `bash -lc` or `cmd /c`. Output streams live to the panel
|
||||
@@ -290,7 +570,47 @@ stdout:
|
||||
```
|
||||
|
||||
The turn continues from there. `esc` still aborts everything, and `ctrl-c` with nothing
|
||||
running quits as usual.
|
||||
running quits as usual. When a background command is running and nothing foreground is in
|
||||
flight, `ctrl-c` stops the most recently started one instead of quitting — an accidental quit
|
||||
must not kill a dev server the user still wants.
|
||||
|
||||
### `bash` background mode (dev servers, watchers)
|
||||
|
||||
A command that does not exit — `bun dev`, a watcher, a test suite that never returns — blocks
|
||||
the tool until its timeout, which looks like the agent is stuck. Set `background: true` instead:
|
||||
|
||||
```
|
||||
command: bun dev
|
||||
background: true
|
||||
name: dev server
|
||||
```
|
||||
|
||||
The tool returns immediately with a handle:
|
||||
|
||||
```
|
||||
background 1: running (pid 4821) — poll with bash_status handle=1, stop with bash_stop handle=1
|
||||
command: bun dev
|
||||
```
|
||||
|
||||
The command runs **detached** (its own process group), so it keeps running while the agent
|
||||
works, ctrl-c in the agent does not signal it, and the model can poll it and keep going:
|
||||
|
||||
- **`bash_status`** `handle` — whether it is still running, its exit code when finished, and
|
||||
any output produced since the last status. Poll this while working.
|
||||
- **`bash_stop`** `handle` — kill it, awaited so the process really is gone.
|
||||
|
||||
```
|
||||
$ bash_status handle: 1
|
||||
handle 1: dev server
|
||||
status: running
|
||||
new output:
|
||||
VITE ready in 312 ms
|
||||
```
|
||||
|
||||
Background commands are reaped when the agent exits (killed and removed), and anything left
|
||||
over from a crashed session is killed at the next boot, so a dev server an agent started cannot
|
||||
linger unnoticed. `/bash` lists what is running, `/bash stop <id>` and `/bash stop all` stop
|
||||
them. Like any `bash`, they never go through the file-snapshot/undo system.
|
||||
|
||||
## `web_fetch`
|
||||
|
||||
@@ -304,6 +624,18 @@ private and loopback addresses are refused, redirects are checked one hop at a t
|
||||
body is capped. The result is untrusted page content, not an instruction, and the call asks for
|
||||
approval. It belongs to the opt-in `net` set.
|
||||
|
||||
## `web_search`
|
||||
|
||||
```
|
||||
query what to search for
|
||||
```
|
||||
|
||||
Searches the web (DuckDuckGo Lite — no API key) and returns up to five results with title,
|
||||
URL, and snippet. Results are re-checked against the same private-address rule as `web_fetch`,
|
||||
so a result cannot point the model at localhost or a cloud metadata endpoint. Same `net` set,
|
||||
same approval, same "untrusted text" framing: search results are a stranger's claims, and a
|
||||
`web_fetch` on one of them is the right follow-up.
|
||||
|
||||
## Git tools
|
||||
|
||||
All five are read-only and therefore approval-free. Each spawns `git` with a fixed argument
|
||||
@@ -334,10 +666,32 @@ git_diff staged? path? unified diff of uncommitted changes
|
||||
git_log limit? path? hash, date, author, subject; newest first
|
||||
git_show ref path? one commit: message, author, diff
|
||||
git_blame path startLine? endLine? who last changed each line
|
||||
git_branch remote? branches, newest commit first, current marked
|
||||
```
|
||||
|
||||
### `git_commit_message`
|
||||
|
||||
Generates one commit message from the staged changes. The nested model call sees two
|
||||
things: the staged diff, and the fifteen most recent commit subjects, because a message
|
||||
that ignores the repository's established style reads as foreign however accurate it is.
|
||||
An oversized diff is truncated before it reaches the model.
|
||||
|
||||
It never commits — it returns the message only, approval-free, because generating text
|
||||
cannot mutate anything. Running the commit stays on the gated `bash` path, where the
|
||||
user sees the message and the command together.
|
||||
|
||||
```
|
||||
$ git_commit_message
|
||||
bump the server port to 9090
|
||||
```
|
||||
|
||||
Nothing staged is a stated error rather than an empty message, so the model's next move
|
||||
is to stage, not to guess.
|
||||
|
||||
`git_log` defaults to 15 commits and caps at 40. `git_blame` without a range blames the whole
|
||||
file; with `startLine` and no `endLine` it covers 40 lines from there.
|
||||
file; with `startLine` and no `endLine` it covers 40 lines from there. `git_branch` sorts by
|
||||
last commit and marks the current branch with `*`, which is what makes an already-taken branch
|
||||
name obvious before proposing one.
|
||||
|
||||
Everything here is also reachable through `bash`. The reason the set exists anyway is the
|
||||
approval boundary: `bash git diff` stops for a decision on every call, while `git_diff` cannot
|
||||
@@ -454,7 +808,7 @@ Any single tool result is truncated at 30,000 characters with a note saying how
|
||||
| `list_dir` | 300 entries |
|
||||
| `read_many_files` | 20 files |
|
||||
| `read_file` | 2,000 lines by default |
|
||||
| `bash` | 120 s default timeout, 600 s max |
|
||||
| `bash` | 120 s default timeout, 600 s max (background mode has no timeout) |
|
||||
|
||||
Without caps one `grep` for `function` can end a session. The caps are per call, so a model
|
||||
that needs more can narrow and ask again — which is cheaper than one call that fills the
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Project-driven agent workflow
|
||||
|
||||
Shiro Neko treats a repository the way this project treats itself: progress
|
||||
tracked in TODO.md and ROADMAP.md, docs-driven development, spec-first plans,
|
||||
complete unit tests, and verify-before-done. When the repo keeps those files,
|
||||
the agent's system prompt carries a short workflow policy and the session
|
||||
tracks whether the workflow is being followed.
|
||||
|
||||
## What the agent sees
|
||||
|
||||
When the session starts in a git repo that has any of:
|
||||
|
||||
- `TODO.md` at the git root
|
||||
- `ROADMAP.md` at the git root
|
||||
- a `docs/` directory (configurable with `workflow.docsDir`)
|
||||
|
||||
the system prompt gains a "Project workflow" block:
|
||||
|
||||
- read TODO.md (the task list) before starting and keep it current as you go
|
||||
- keep ROADMAP.md current when a milestone ships
|
||||
- write a short plan first (spec-first) for anything non-trivial
|
||||
- add tests alongside code; the project expects complete unit tests
|
||||
- verify with the project's check commands (tests/typecheck/build) before
|
||||
declaring done
|
||||
|
||||
Bare repos (no TODO, ROADMAP, or docs) get no such block — the policy only
|
||||
renders when the project itself tracks progress, so a throwaway directory does
|
||||
not collect noise.
|
||||
|
||||
## Auto-scaffolding
|
||||
|
||||
When you run shiro against an *existing* repo that has none of the tracking
|
||||
files (no TODO.md, no ROADMAP.md, no `docs/`, no AGENTS.md), the first turn
|
||||
bootstraps them automatically: the agent investigates the repo and writes
|
||||
project-specific TODO.md, ROADMAP.md, `docs/README.md`, and AGENTS.md before
|
||||
answering. A notice reports what was written:
|
||||
|
||||
```
|
||||
scaffolded project workflow files: TODO.md, ROADMAP.md, docs/, AGENTS.md
|
||||
```
|
||||
|
||||
- **Never overwrites.** Any existing tracker (TODO.md, ROADMAP.md, `docs/`, or
|
||||
AGENTS.md) at the git root means the repo already tracks itself — nothing is
|
||||
created or touched.
|
||||
- **Model-driven content.** The files use real project content (commands,
|
||||
layout, conventions verified against the code) like `/init` does for
|
||||
AGENTS.md. If the model call fails, it degrades to the empty-template
|
||||
scaffold so the turn is never interrupted.
|
||||
- **Write at the git root**, not the cwd — matches where the policy looks.
|
||||
- Runs **once per session**, before the first real turn, so the policy and the
|
||||
first nudge already see the files.
|
||||
- The manual `/init` command still exists for when you want to write AGENTS.md
|
||||
(and scaffold the trackers) on demand.
|
||||
|
||||
TODO.md and ROADMAP.md are also loaded into the conversation like instruction
|
||||
files (`Project tracker (...)`), capped tighter than AGENTS.md so the agent
|
||||
sees the shape of the work without filling its context. This mirrors the
|
||||
existing `AGENTS.md` / `CLAUDE.md` / `.shiro.md` loading: outermost first, git
|
||||
root down to cwd.
|
||||
|
||||
## The nudge
|
||||
|
||||
After a turn that wrote files (edit_file, write_file, apply_patch, ...) but
|
||||
never called `todo_write`, the session emits one soft notice:
|
||||
|
||||
```
|
||||
reminder: you modified files without updating the project task list (TODO.md).
|
||||
Keep it current: mark what you did.
|
||||
```
|
||||
|
||||
Design constraints:
|
||||
|
||||
- **Once per session.** Repeating a nag trains the model to ignore it.
|
||||
- **Not a gate.** The agent stays in control; this is guidance, not a block.
|
||||
- **Only when the repo has a TODO/ROADMAP.** A repo that tracks nothing gets
|
||||
no reminder.
|
||||
- Suppressed when the turn already called `todo_write` — the task list is
|
||||
current, nothing to say.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
workflow:
|
||||
enabled: true # master switch; default true
|
||||
docsDir: docs # where the project keeps developer docs; default 'docs'
|
||||
autoScaffold: true # write TODO/ROADMAP/docs/AGENTS.md in a bare repo on first turn; default true
|
||||
```
|
||||
|
||||
`workflow.enabled: false` disables both the prompt policy and the nudge.
|
||||
`workflow.autoScaffold: false` disables only the auto-bootstrap (the policy and
|
||||
nudge still engage when the repo already tracks progress).
|
||||
|
||||
## /workflow
|
||||
|
||||
`/workflow` renders a panel with the project's tracking state and the
|
||||
session's behaviour:
|
||||
|
||||
| row | meaning |
|
||||
|---|---|
|
||||
| `workflow` | on/off from config |
|
||||
| `TODO.md` | present? line count |
|
||||
| `ROADMAP.md` | present? line count |
|
||||
| `docs dir` | present? file count (bounded at 200) |
|
||||
| `reminders sent` | whether this session nudged (once, ever) |
|
||||
|
||||
## Relationship to AGENTS.md
|
||||
|
||||
AGENTS.md-style files are standing orders from the user and override the
|
||||
agent's defaults. The workflow policy is a default that documents what a
|
||||
repo tracking its own progress expects. When the two conflict, AGENTS.md
|
||||
wins — the workflow feature is a floor, not a ceiling.
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "shiro-neko",
|
||||
"version": "0.1.0-beta.4",
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"bin": {
|
||||
@@ -10,12 +10,13 @@
|
||||
"shiro": "bun run src/cli.tsx",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test",
|
||||
"build": "bun build --compile --minify src/cli.tsx --outfile dist/shiro",
|
||||
"build": "bun build --compile --production --minify src/cli.tsx --outfile dist/shiro",
|
||||
"release": "bun run scripts/release.ts",
|
||||
"install:local": "bun run build && bun run scripts/install.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.4.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/react": "19.2.18",
|
||||
"ink-testing-library": "4.0.0",
|
||||
"react-devtools-core": "^7.0.1"
|
||||
@@ -33,6 +34,7 @@
|
||||
"ink-select-input": "6.2.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ink-text-input": "6.0.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"react": "19.2.8",
|
||||
"zod": "4.5.4"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Writes `release_notes.md` for a release tag, extracting the tag's prose from
|
||||
* CHANGELOG.md and appending the artifact inventory. Used by .github/workflows/
|
||||
* release.yml so the release page reads like a hand-written one instead of the raw
|
||||
* PR list.
|
||||
*
|
||||
* Reads the tag from $RELEASE_TAG (GITHUB_REF_NAME, e.g. `v1.2.3`) and fails loudly
|
||||
* when CHANGELOG.md has no matching `## [<version>]` section, because a release with
|
||||
* an empty body is worse than one that refused to publish.
|
||||
*/
|
||||
const tag = process.env['RELEASE_TAG'] ?? 'v0.0.0-local';
|
||||
const version = tag.replace(/^v/, '');
|
||||
|
||||
const changelog = await Bun.file('CHANGELOG.md').text();
|
||||
const start = changelog.indexOf(`## [${version}]`);
|
||||
if (start === -1) {
|
||||
console.error(`CHANGELOG.md has no "## [${version}]" heading for ${tag}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// The section runs from its heading to the next `## [` heading, or to the file end.
|
||||
// Drop the heading itself: the release title already carries the version, so keeping
|
||||
// `## [1.0.0]` under `## What's in v1.0.0` would read twice.
|
||||
let body = changelog.slice(start);
|
||||
const next = body.indexOf('\n## [', 1);
|
||||
if (next !== -1) body = body.slice(0, next);
|
||||
body = body.replace(/^## \[.*\]\n+/, '').trim();
|
||||
|
||||
// ESM, so top-level await is fine; this file is only ever run as the entry script.
|
||||
await Bun.write(
|
||||
'release_notes.md',
|
||||
[
|
||||
`## What's in ${tag}`,
|
||||
'',
|
||||
body,
|
||||
'',
|
||||
'### Artifacts',
|
||||
'- `shiro-linux-x64`, `shiro-linux-arm64` - Linux',
|
||||
'- `shiro-darwin-x64`, `shiro-darwin-arm64` - macOS',
|
||||
'- `shiro-windows-x64.exe` - Windows',
|
||||
'- `SHA256SUMS` - verify any of the above',
|
||||
'',
|
||||
'Install with `scripts/install.sh` (macOS/Linux) or `scripts/install.ps1` (Windows);',
|
||||
'both verify the download against `SHA256SUMS`.',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
console.log(`composed release_notes.md from the ${version} changelog section (${body.split('\n').length} prose lines)`);
|
||||
process.exit(0);
|
||||
+1
-1
@@ -29,7 +29,7 @@ const OUT = 'dist/release';
|
||||
* failing on the last of five builds.
|
||||
*/
|
||||
export function buildArgs(t: Target, outfile: string, host = process.platform): string[] {
|
||||
const args = ['build', '--compile', '--minify', `--target=${t.target}`, 'src/cli.tsx', '--outfile', outfile];
|
||||
const args = ['build', '--compile', '--production', '--minify', `--target=${t.target}`, 'src/cli.tsx', '--outfile', outfile];
|
||||
if (t.windows && host === 'win32') {
|
||||
args.push(
|
||||
'--windows-title=shiro-neko',
|
||||
|
||||
+4
-1
@@ -37,6 +37,8 @@ const READ_ONLY = [
|
||||
'git_log',
|
||||
'git_show',
|
||||
'git_blame',
|
||||
'git_branch',
|
||||
'git_commit_message',
|
||||
'task',
|
||||
'web_fetch',
|
||||
'todo_write',
|
||||
@@ -68,7 +70,8 @@ export const VARIANTS: AgentVariant[] = [
|
||||
maxSteps: 80,
|
||||
appendix:
|
||||
'This task is hard or its cause is unclear. Form more than one hypothesis before you act and say which one ' +
|
||||
'you are testing. Read enough of the code to be sure rather than guessing. Record findings with remember ' +
|
||||
'you are testing. Read enough of the code to be sure rather than guessing — batch related files into a single ' +
|
||||
'read_many_files call so the investigation stays cheap. Record findings with remember ' +
|
||||
'so they survive compaction. Report what you verified and what you could not.',
|
||||
},
|
||||
{
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import { tool, type ToolSet } from 'ai';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { jail } from './ignore';
|
||||
import { manifestToPlugin, parseManifest, type PluginManifest } from './registry';
|
||||
import type { Plugin } from './plugins';
|
||||
|
||||
/**
|
||||
* Auto-registration and auto-loading of external skills, tools, and plugins.
|
||||
*
|
||||
* Everything here is *data*, never code — the same rule the registry enforces.
|
||||
* An external tool is a bounded manifest (a shell template through the guard, an
|
||||
* HTTP fetch, or a file read), an external plugin a refusal manifest, an external
|
||||
* skill a markdown body. Loading arbitrary code from disk would let an entry read
|
||||
* every file the agent can read and lie about what it blocks, so it is not offered.
|
||||
*
|
||||
* Directories, later shadowing earlier by name:
|
||||
* ~/.shiro-neko/{tools,plugins,skills} (user)
|
||||
* .shiro/{tools,plugins,skills} (project)
|
||||
* Skills already load through skills.ts; this module adds tools and plugins and
|
||||
* the one place cli turns them all on.
|
||||
*/
|
||||
|
||||
const home = () => process.env['SHIRO_HOME'] ?? homedir();
|
||||
|
||||
export type LoadError = { name: string; message: string };
|
||||
|
||||
function dirs(kind: 'tools' | 'plugins' | 'skills', cwd: string): string[] {
|
||||
return [join(home(), '.shiro-neko', kind), join(cwd, '.shiro', kind)];
|
||||
}
|
||||
|
||||
async function scan(dir: string, ext: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
try {
|
||||
for await (const f of new Bun.Glob(`*.${ext}`).scan({ cwd: dir, onlyFiles: true })) files.push(f);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
const MAX_PATTERN = 200;
|
||||
const nameSchema = z.string().min(1).max(40).regex(/^[a-z0-9][a-z0-9-_]*$/i);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External tools, as bounded manifests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Three kinds of tool, each with a ceiling on what it can do. None runs arbitrary
|
||||
* code: `shell` interpolates a fixed template and runs it through the guard and
|
||||
* the platform shell, `http` fetches a fixed URL, `read` returns a fixed file's
|
||||
* contents (jailed to the workspace). The input is a single optional `arg` string
|
||||
* substituted into a `{arg}` placeholder, so a manifest cannot take structure it
|
||||
* was not declared for.
|
||||
*/
|
||||
const toolManifestSchema = z.object({
|
||||
name: nameSchema,
|
||||
description: z.string().min(1).max(300),
|
||||
kind: z.enum(['shell', 'http', 'read']),
|
||||
/** The template with an optional `{arg}` placeholder. */
|
||||
command: z.string().max(500).optional(),
|
||||
url: z.string().max(500).optional(),
|
||||
path: z.string().max(300).optional(),
|
||||
/** Set false to require approval before running. Default true (auto-approved). */
|
||||
autoApprove: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type ToolManifest = z.infer<typeof toolManifestSchema>;
|
||||
|
||||
export function parseToolManifest(source: string): ToolManifest {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(source);
|
||||
} catch {
|
||||
throw new Error('the tool manifest is not valid JSON');
|
||||
}
|
||||
const parsed = toolManifestSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`the tool manifest is malformed: ${parsed.error.issues[0]?.message ?? 'unknown reason'}`);
|
||||
}
|
||||
const m = parsed.data;
|
||||
if (m.kind === 'shell' && !m.command) throw new Error(`shell tool "${m.name}" needs a command template`);
|
||||
if (m.kind === 'http' && !m.url) throw new Error(`http tool "${m.name}" needs a url`);
|
||||
if (m.kind === 'read' && !m.path) throw new Error(`read tool "${m.name}" needs a path`);
|
||||
return m;
|
||||
}
|
||||
|
||||
const MAX_TOOL_OUTPUT = 30_000;
|
||||
const cap = (s: string) => (s.length <= MAX_TOOL_OUTPUT ? s : `${s.slice(0, MAX_TOOL_OUTPUT)}\n... [truncated]`);
|
||||
|
||||
/** The guard an external shell tool runs through, supplied by cli so it shares the real chain. */
|
||||
export type ShellGuard = (command: string) => Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* A manifest as a live tool. The guard is applied to every `shell` invocation, so
|
||||
* an external tool cannot smuggle a destructive command past the user any more
|
||||
* than a built-in bash call can.
|
||||
*/
|
||||
export function manifestToTool(manifest: ToolManifest, guard: ShellGuard) {
|
||||
const inputSchema = z.object({ arg: z.string().optional().describe('optional argument substituted into {arg}') });
|
||||
const substitute = (template: string, arg: string) => template.replaceAll('{arg}', arg);
|
||||
|
||||
return tool({
|
||||
description: `${manifest.description} (external ${manifest.kind} tool)`,
|
||||
inputSchema,
|
||||
execute: async ({ arg = '' }) => {
|
||||
if (manifest.kind === 'read') {
|
||||
const abs = jail(substitute(manifest.path!, arg));
|
||||
const file = Bun.file(abs);
|
||||
if (!(await file.exists())) throw new Error(`no such file: ${manifest.path}`);
|
||||
return cap(await file.text());
|
||||
}
|
||||
|
||||
if (manifest.kind === 'http') {
|
||||
const url = substitute(manifest.url!, arg);
|
||||
if (!/^https:\/\//i.test(url)) throw new Error(`http tools may only fetch https URLs, got: ${url}`);
|
||||
const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(20_000) });
|
||||
if (!res.ok) throw new Error(`${url} returned ${res.status}`);
|
||||
return cap(await res.text());
|
||||
}
|
||||
|
||||
const command = substitute(manifest.command!, arg);
|
||||
const blocked = await guard(command);
|
||||
if (blocked) throw new Error(`refused: ${blocked}`);
|
||||
const shell = process.platform === 'win32' ? ['cmd', '/c', command] : ['bash', '-lc', command];
|
||||
const proc = Bun.spawn(shell, { stdout: 'pipe', stderr: 'pipe' });
|
||||
const [out, err, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
if (code !== 0) throw new Error(`exited ${code}: ${err.trim().slice(0, 300)}`);
|
||||
return cap(out.trim() || '(no output)');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type ExternalTools = { tools: ToolSet; autoApprove: string[]; errors: LoadError[] };
|
||||
|
||||
/** Loads every external tool manifest, project shadowing user by name. Bad files are reported and skipped. */
|
||||
export async function loadExternalTools(cwd: string, guard: ShellGuard): Promise<ExternalTools> {
|
||||
const tools: ToolSet = {};
|
||||
const autoApprove: string[] = [];
|
||||
const errors: LoadError[] = [];
|
||||
|
||||
for (const dir of dirs('tools', cwd)) {
|
||||
for (const file of await scan(dir, 'json')) {
|
||||
const fallback = file.replace(/\.json$/i, '');
|
||||
try {
|
||||
const manifest = parseToolManifest(await Bun.file(join(dir, file)).text());
|
||||
tools[manifest.name] = manifestToTool(manifest, guard);
|
||||
if (manifest.autoApprove !== false) autoApprove.push(manifest.name);
|
||||
} catch (e) {
|
||||
errors.push({ name: fallback, message: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { tools, autoApprove, errors };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External plugins, as refusal manifests (same shape the registry installs).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ExternalPlugins = { plugins: Plugin[]; errors: LoadError[] };
|
||||
|
||||
/** Loads refusal-manifest plugins from disk, merging with any already installed via the registry. */
|
||||
export async function loadExternalPlugins(cwd: string): Promise<ExternalPlugins> {
|
||||
const byName = new Map<string, Plugin>();
|
||||
const errors: LoadError[] = [];
|
||||
|
||||
for (const dir of dirs('plugins', cwd)) {
|
||||
for (const file of await scan(dir, 'json')) {
|
||||
const fallback = file.replace(/\.json$/i, '');
|
||||
try {
|
||||
const manifest: PluginManifest = parseManifest(await Bun.file(join(dir, file)).text());
|
||||
byName.set(manifest.name, manifestToPlugin(manifest));
|
||||
} catch (e) {
|
||||
errors.push({ name: fallback, message: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { plugins: [...byName.values()], errors };
|
||||
}
|
||||
+317
-40
@@ -3,25 +3,35 @@ import { render } from 'ink';
|
||||
import React from 'react';
|
||||
import type { LanguageModel, ModelMessage } from 'ai';
|
||||
import { resolveAgent, VARIANTS, isThinkingLevel, type AgentVariant } from './agents';
|
||||
import { configPath, loadConfig, missingKeyMessage, resolveModel, writeConfigFile, type Config } from './config';
|
||||
import { loadExternalPlugins, loadExternalTools } from './autoload';
|
||||
import { configPath, loadConfig, missingKeyMessage, readConfigFile, resolveModel, unknownToolSetNames, writeConfigFile, type Config } from './config';
|
||||
import type { FallbackEvent } from './fallback';
|
||||
import { farewell } from './farewell';
|
||||
import { readStdin, runHeadless } from './headless';
|
||||
import { scaffoldWorkflowFiles } from './scaffold';
|
||||
import { INIT_PROMPT, loadInstructions } from './instructions';
|
||||
import { detectLanguageHints } from './prompt';
|
||||
import { walk } from './ignore';
|
||||
import { connectMcp } from './mcp';
|
||||
import { createCommitMessageTool } from './commit';
|
||||
import { Memory, KIND_LABEL } from './memory';
|
||||
import { costOf } from './pricing';
|
||||
import { BUILTIN_PLUGINS, DEFAULT_ENABLED } from './plugins-builtin';
|
||||
import { createHost } from './plugins';
|
||||
import { hooksToPlugin, loadApprovalStore, loadHooks } from './hooks';
|
||||
import { fetchModels, presetById } from './providers';
|
||||
import * as registry from './registry';
|
||||
import { Session } from './session';
|
||||
import { loadCustomCommands } from './custom-commands';
|
||||
import { loadSkills } from './skills';
|
||||
import { reapStaleBackgrounds, shutdownBackgrounds, backgroundSummary, stopBackground } from './tools';
|
||||
import { bootDiagnostics, shutdownDiagnostics } from './diagnostics';
|
||||
import * as store from './store';
|
||||
import { createTaskTool, type SubagentApproval } from './subagent';
|
||||
import { VERSION, versionLine } from './version';
|
||||
import { createAskBridge } from './ui/Ask';
|
||||
import { App, createApprovalBridge, createNoticeBus, createSubagentBus, type AppHooks } from './ui/App';
|
||||
import { Header, type HeaderFact } from './ui/Header';
|
||||
import type { RegistryRow as AppRegistryRow } from './ui/Panels';
|
||||
|
||||
// SDK warnings go straight to stderr, which tears up the Ink render.
|
||||
@@ -32,7 +42,6 @@ const HELP = `shiro-neko ${VERSION} - agentic coding CLI
|
||||
usage: shiro [options]
|
||||
shiro -p "prompt" headless, prints to stdout
|
||||
cat file | shiro -p prompt read from stdin
|
||||
|
||||
options:
|
||||
-p, --print [prompt] headless mode; requires --yolo for tool use
|
||||
--json with -p, emit one JSON event per line
|
||||
@@ -49,6 +58,7 @@ options:
|
||||
--no-skills ignore builtin and project skills
|
||||
--no-plugins disable all plugins, including the guard
|
||||
--no-memory do not load or write project memory
|
||||
--init-scaffold with -p, write TODO.md/ROADMAP.md/docs/ when missing
|
||||
--yolo skip all tool approval prompts
|
||||
-v, --version
|
||||
-h, --help
|
||||
@@ -67,6 +77,7 @@ env: SHIRO_PROVIDER SHIRO_MODEL SHIRO_BASE_URL SHIRO_API_KEY
|
||||
|
||||
skills: builtin, plus ~/.shiro-neko/skills/*.md and .shiro/skills/*.md
|
||||
registry: /registry to browse and install external skills and plugins
|
||||
mcp: /mcp to add a local or remote server, or list what is configured
|
||||
sessions: ${store.sessionsDir()}
|
||||
in-session: /help for the command list`;
|
||||
|
||||
@@ -102,6 +113,8 @@ if (modelFlag) process.env['SHIRO_MODEL'] = modelFlag;
|
||||
if (baseUrlFlag) process.env['SHIRO_BASE_URL'] = baseUrlFlag;
|
||||
|
||||
let cfg = await loadConfig();
|
||||
const rawToolSets = (await readConfigFile()).toolSets as unknown;
|
||||
const unknownToolSets = unknownToolSetNames(rawToolSets);
|
||||
const yolo = has('--yolo');
|
||||
const headless = flag('-p', '--print') !== undefined;
|
||||
|
||||
@@ -116,6 +129,12 @@ const notices = createNoticeBus();
|
||||
const subagents = createSubagentBus();
|
||||
const askBridge = createAskBridge();
|
||||
|
||||
if (unknownToolSets.length > 0) {
|
||||
const msg = `unknown toolSets ignored: ${unknownToolSets.join(', ')} — valid: core, edit-plus, nav, extra, git, net`;
|
||||
if (headless) process.stderr.write(`shiro: ${msg}\n`);
|
||||
else notices.emit(msg);
|
||||
}
|
||||
|
||||
function reportFallback(e: FallbackEvent): void {
|
||||
const line = `endpoint fallback: ${e.from} rejected the request, retrying on ${e.to}\n ${e.reason}`;
|
||||
if (headless) process.stderr.write(`shiro: ${line}\n`);
|
||||
@@ -149,13 +168,28 @@ if (resumeArg) {
|
||||
}
|
||||
}
|
||||
|
||||
const mcp = has('--no-mcp') || !cfg.mcpServers ? undefined : await connectMcp(cfg.mcpServers);
|
||||
const mcp =
|
||||
has('--no-mcp') || !cfg.mcpServers ? undefined : await connectMcp(cfg.mcpServers, cfg.mcpMode ?? 'lazy');
|
||||
const instructions = has('--no-instructions') ? [] : await loadInstructions();
|
||||
const skills = has('--no-skills') ? [] : await loadSkills();
|
||||
const customCommands = await loadCustomCommands();
|
||||
const promptHistory = await store.loadHistory();
|
||||
|
||||
const installedPlugins = has('--no-plugins') ? { plugins: [], errors: [] } : await registry.loadInstalledPlugins();
|
||||
|
||||
// Full ignore-aware file list injected into the system prompt at boot.
|
||||
// Same walk as `@` completion (5000 cap, .gitignore + .shiroignore, skips .git/node_modules),
|
||||
// so a filtered repo stays small even though it's "full".
|
||||
let workspaceFiles: string[] = [];
|
||||
try {
|
||||
for await (const rel of walk({ limit: 5000 })) workspaceFiles.push(rel);
|
||||
} catch {}
|
||||
|
||||
let languageHints: string | undefined;
|
||||
try {
|
||||
languageHints = await detectLanguageHints(process.cwd());
|
||||
} catch {}
|
||||
|
||||
/** Installed entries, as `kind:name`, so the registry list can mark what is already here. */
|
||||
async function installedNames(): Promise<Set<string>> {
|
||||
const names = new Set<string>();
|
||||
@@ -200,13 +234,43 @@ const enabledPlugins = has('--no-plugins') ? [] : (cfg.plugins ?? DEFAULT_ENABLE
|
||||
const pluginErrors = enabledPlugins
|
||||
.filter((name) => !BUILTIN_PLUGINS.some((p) => p.name === name))
|
||||
.map((name) => ({ plugin: name, message: 'no such plugin' }));
|
||||
|
||||
// External skills, tools, and plugins auto-load from ~/.shiro-neko/<kind> and
|
||||
// .shiro/<kind>. All are data, never code; a bad file is reported, not fatal.
|
||||
const externalPlugins = has('--no-plugins') ? { plugins: [], errors: [] } : await loadExternalPlugins(process.cwd());
|
||||
|
||||
// External hooks (executables that can rewrite or refuse tool calls) load from
|
||||
// .shiro/hooks/ and ~/.shiro-neko/hooks/. They are code, so each one must be
|
||||
// hash-approved once before it runs anything; a changed hash is refused until
|
||||
// re-approved. They join the guard as one plugin, after the compiled ones.
|
||||
const hooksPlugin = has('--no-plugins') ? [] : [hooksToPlugin(await loadHooks(process.cwd()), loadApprovalStore())];
|
||||
|
||||
const plugins = createHost(
|
||||
[...BUILTIN_PLUGINS.filter((p) => enabledPlugins.includes(p.name)), ...installedPlugins.plugins],
|
||||
[...pluginErrors, ...installedPlugins.errors],
|
||||
[
|
||||
...BUILTIN_PLUGINS.filter((p) => enabledPlugins.includes(p.name)),
|
||||
...installedPlugins.plugins,
|
||||
...externalPlugins.plugins,
|
||||
...hooksPlugin,
|
||||
],
|
||||
[
|
||||
...pluginErrors,
|
||||
...installedPlugins.errors,
|
||||
...externalPlugins.errors.map((e) => ({ plugin: e.name, message: e.message })),
|
||||
],
|
||||
);
|
||||
|
||||
// External shell tools run through the same guard chain as a built-in bash call,
|
||||
// so an installed tool cannot do what the agent itself may not. Late-bound because
|
||||
// the host above is what runs the chain.
|
||||
const externalTools = await loadExternalTools(process.cwd(), async (command) =>
|
||||
plugins.guard({ toolName: 'bash', input: { command }, cwd: process.cwd() }),
|
||||
);
|
||||
|
||||
const memory = has('--no-memory') ? undefined : new Memory(process.cwd(), languageModel);
|
||||
if (memory) await memory.load();
|
||||
if (memory) {
|
||||
await memory.load();
|
||||
try { await memory.pruneExpired(); } catch {}
|
||||
}
|
||||
|
||||
/** Placeholder until /provider supplies a key; it never gets called because the UI gates input. */
|
||||
const unconfiguredModel: LanguageModel = {
|
||||
@@ -259,14 +323,41 @@ const subagentGate: SubagentApproval = (req) => {
|
||||
return approveSubagent(req);
|
||||
};
|
||||
|
||||
// A subagent doing search rather than reasoning can run on a cheaper model.
|
||||
// It resolves against the same provider and key, so a configured `subagentModel`
|
||||
// never needs a second credential.
|
||||
const subagentModel =
|
||||
cfg.subagentModel && cfg.subagentModel !== cfg.model && cfg.apiKey
|
||||
? resolveModel({ ...cfg, model: cfg.subagentModel }, reportFallback)
|
||||
: (languageModel ?? unconfiguredModel);
|
||||
|
||||
// Late-bound like `approveSubagent`: the task tool is built into `extraTools`
|
||||
// before the Session that owns the spend ledger exists, so the usage callback is
|
||||
// wired after construction.
|
||||
let recordSubagent: (usage: { inputTokens: number; outputTokens: number }) => void = () => {};
|
||||
|
||||
// Orphaned background commands from a crashed session: kill them if they are
|
||||
// still alive, so a dev server a dead agent started does not linger.
|
||||
reapStaleBackgrounds();
|
||||
|
||||
// A diagnostics command configured in config.json starts at boot and runs in
|
||||
// the UI only — never in model context. Boot must not fail on a bad command.
|
||||
bootDiagnostics(cfg.diagnostics);
|
||||
|
||||
const session = new Session({
|
||||
model: languageModel ?? unconfiguredModel,
|
||||
modelId: cfg.model,
|
||||
...(cfg.subagentModel ? { subagentModelId: cfg.subagentModel } : {}),
|
||||
...(subagentModel !== unconfiguredModel ? { learnerModel: subagentModel } : {}),
|
||||
onNotice: (t) => notices.emit(t),
|
||||
askApproval: bridge.ask,
|
||||
yolo,
|
||||
instructions,
|
||||
skills,
|
||||
plugins,
|
||||
agent: agentVariant,
|
||||
languageHints,
|
||||
...(workspaceFiles.length > 0 ? { workspaceFiles } : {}),
|
||||
...(cfg.toolSets ? { toolSets: cfg.toolSets } : {}),
|
||||
...(cfg.permission ? { permissions: cfg.permission } : {}),
|
||||
// Headless has no one to answer, so the tool is withheld rather than left to hang.
|
||||
@@ -274,13 +365,29 @@ const session = new Session({
|
||||
...(memory ? { memory } : {}),
|
||||
...(record.notebook ? { notebook: record.notebook } : {}),
|
||||
...(cfg.maxRetries !== undefined ? { maxRetries: cfg.maxRetries } : {}),
|
||||
...(cfg.maxSpendUsd !== undefined ? { maxSpendUsd: cfg.maxSpendUsd } : {}),
|
||||
...(cfg.maxSpendPerTurn !== undefined ? { maxSpendPerTurn: cfg.maxSpendPerTurn } : {}),
|
||||
...(cfg.workflow !== undefined ? { workflow: cfg.workflow } : {}),
|
||||
// Anthropic rewards a stable system prefix with cache_control; OpenAI's
|
||||
// automatic prefix caching needs nothing sent. The provider is known here
|
||||
// (cfg.provider), the Session itself only sees the model.
|
||||
...(cfg.provider === 'anthropic' ? { cacheSystemPrefix: true } : {}),
|
||||
extraTools: {
|
||||
...(mcp?.tools ?? {}),
|
||||
...externalTools.tools,
|
||||
git_commit_message: createCommitMessageTool({
|
||||
model: languageModel ?? unconfiguredModel,
|
||||
...(headless ? {} : { cwd: process.cwd() }),
|
||||
}),
|
||||
...(has('--no-subagent')
|
||||
? {}
|
||||
: {
|
||||
task: createTaskTool({
|
||||
model: languageModel ?? unconfiguredModel,
|
||||
subagentModel,
|
||||
subagentModelId: cfg.subagentModel,
|
||||
...(workspaceFiles.length > 0 ? { workspaceFiles } : {}),
|
||||
onUsage: (u) => recordSubagent(u),
|
||||
...(headless ? {} : { report: subagents.emit }),
|
||||
// A worker's writes go through the parent's rules and the parent's
|
||||
// prompt. Headless has nobody to answer, so `worker` is withheld there
|
||||
@@ -289,7 +396,7 @@ const session = new Session({
|
||||
}),
|
||||
}),
|
||||
},
|
||||
autoApprove: ['task'],
|
||||
autoApprove: ['task', 'git_commit_message', ...externalTools.autoApprove],
|
||||
messages: [...record.messages],
|
||||
onChange: (messages) => {
|
||||
// Debounced so a long tool loop does not hit the disk on every step.
|
||||
@@ -299,14 +406,30 @@ const session = new Session({
|
||||
});
|
||||
|
||||
approveSubagent = session.approveForSubagent();
|
||||
recordSubagent = (u) => session.recordSubagentUsage(u);
|
||||
if (mcp) {
|
||||
const { bindMcpGuard } = await import('./mcp');
|
||||
bindMcpGuard(mcp, () => plugins, () => process.cwd());
|
||||
}
|
||||
|
||||
async function shutdown(code: number): Promise<never> {
|
||||
clearTimeout(saveTimer);
|
||||
if (session.messages.length > 0) await persist(session.messages);
|
||||
await mcp?.close();
|
||||
// Dev servers and watchers started in the background must not outlive the
|
||||
// agent silently; reap them (best-effort, never blocks exit).
|
||||
try {
|
||||
await shutdownBackgrounds();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
shutdownDiagnostics();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const printArg = flag('-p', '--print');
|
||||
if (printArg !== undefined) {
|
||||
const prompt = printArg || (await readStdin());
|
||||
@@ -314,9 +437,13 @@ if (printArg !== undefined) {
|
||||
console.error('shiro: -p needs a prompt argument or piped stdin');
|
||||
await shutdown(1);
|
||||
}
|
||||
if (has('--init-scaffold')) {
|
||||
const written = scaffoldWorkflowFiles();
|
||||
if (written.length > 0) console.error(`shiro: scaffolded ${written.join(', ')}`);
|
||||
}
|
||||
if (!yolo) {
|
||||
process.stderr.write(
|
||||
'shiro: headless denies write_file, edit_file, multi_edit, bash and mcp tools unless --yolo is passed\n',
|
||||
'shiro: headless denies write_file, edit_file, multi_edit, bash, web_fetch, web_search and mcp_call unless --yolo is passed\n',
|
||||
);
|
||||
}
|
||||
const code = await runHeadless({ session, prompt, format: has('--json') ? 'json' : 'text' });
|
||||
@@ -336,9 +463,11 @@ const hooks: AppHooks = {
|
||||
instructionFiles: () => instructions.map((i) => i.path),
|
||||
listPaths: async () => {
|
||||
const found: string[] = [];
|
||||
for await (const rel of walk({ limit: 5000 })) found.push(rel);
|
||||
for await (const rel of walk({ limit: 5000, includeDirs: true })) found.push(rel);
|
||||
return found;
|
||||
},
|
||||
fileChangeSeq: () => session.fileChangeSeq,
|
||||
customCommands: () => customCommands,
|
||||
registry: {
|
||||
list: async () => {
|
||||
const entries = await registry.fetchIndex(cfg.registryUrl);
|
||||
@@ -363,7 +492,8 @@ const hooks: AppHooks = {
|
||||
},
|
||||
stage: async (name) => {
|
||||
const entry = await findEntry(name);
|
||||
const { preview } = await registry.stage(entry);
|
||||
const { registryPublishers, registryAllowUnsigned } = cfg;
|
||||
const { preview } = await registry.stage(entry, { publishers: registryPublishers, allowUnsigned: registryAllowUnsigned });
|
||||
return {
|
||||
row: { name: entry.name, kind: entry.kind, description: entry.description },
|
||||
url: entry.url,
|
||||
@@ -372,10 +502,23 @@ const hooks: AppHooks = {
|
||||
},
|
||||
install: async (name) => {
|
||||
const entry = await findEntry(name);
|
||||
const { path } = await registry.install(entry);
|
||||
// Loaded on the next start rather than hot-swapped: a skill joins the system
|
||||
// prompt and a plugin joins the guard chain, and both are built once at boot.
|
||||
return `installed ${entry.kind} ${entry.name} to ${path}\nrestart shiro to load it`;
|
||||
const { registryPublishers, registryAllowUnsigned } = cfg;
|
||||
const { path } = await registry.install(entry, { publishers: registryPublishers, allowUnsigned: registryAllowUnsigned });
|
||||
// hot-reload: rebuild live catalogue so next turn sees it
|
||||
if (entry.kind === 'skill') {
|
||||
const fresh = await loadSkills();
|
||||
session.updateSkills(fresh);
|
||||
return `installed ${entry.kind} ${entry.name} to ${path}`;
|
||||
}
|
||||
if (entry.kind === 'plugin') {
|
||||
const { plugins: freshPlugins, errors } = await registry.loadInstalledPlugins();
|
||||
// re-compose from builtin + registry + external so order stays correct
|
||||
const freshExternal = await loadExternalPlugins(process.cwd());
|
||||
const host = createHost([...BUILTIN_PLUGINS.filter(pp => enabledPlugins.includes(pp.name)), ...freshPlugins, ...freshExternal.plugins], [...pluginErrors, ...errors, ...freshExternal.errors.map(e=>({plugin:e.name,message:e.message}))]);
|
||||
session.updatePlugins(host);
|
||||
return `installed ${entry.kind} ${entry.name} to ${path}`;
|
||||
}
|
||||
return `installed ${entry.kind} ${entry.name} to ${path}`;
|
||||
},
|
||||
remove: async (name) => {
|
||||
const parsed = /^(skill|plugin):(.+)$/.exec(name);
|
||||
@@ -383,12 +526,90 @@ const hooks: AppHooks = {
|
||||
const bare = parsed ? parsed[2]! : name;
|
||||
|
||||
for (const kind of kinds) {
|
||||
if (await registry.uninstall(kind, bare)) return `removed ${kind} ${bare}\nrestart shiro to unload it`;
|
||||
if (await registry.uninstall(kind, bare)) {
|
||||
if (kind === 'skill') {
|
||||
const fresh = await loadSkills();
|
||||
session.updateSkills(fresh);
|
||||
} else {
|
||||
const { plugins: freshPlugins, errors } = await registry.loadInstalledPlugins();
|
||||
const freshExternal = await loadExternalPlugins(process.cwd());
|
||||
const host = createHost([...BUILTIN_PLUGINS.filter(pp => enabledPlugins.includes(pp.name)), ...freshPlugins, ...freshExternal.plugins], [...pluginErrors, ...errors, ...freshExternal.errors.map(e=>({plugin:e.name,message:e.message}))]);
|
||||
session.updatePlugins(host);
|
||||
}
|
||||
return `removed ${kind} ${bare}`;
|
||||
}
|
||||
}
|
||||
throw new Error(`nothing installed under the name "${bare}"`);
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
names: () => Object.keys(cfg.mcpServers ?? {}),
|
||||
list: () => {
|
||||
const servers = Object.entries(cfg.mcpServers ?? {});
|
||||
if (servers.length === 0) return 'no MCP servers configured\n\n`/mcp add` sets one up.';
|
||||
|
||||
const liveDirect = new Map<string, number>();
|
||||
for (const name of Object.keys(mcp?.tools ?? {})) {
|
||||
if (name === '__mcpServerNames') continue;
|
||||
const server = /^mcp__([^_]+(?:_[^_]+)*)__/.exec(name)?.[1];
|
||||
if (server) liveDirect.set(server, (liveDirect.get(server) ?? 0) + 1);
|
||||
}
|
||||
const hasMeta = !!(mcp?.tools as Record<string, unknown>)?.['mcp_list'];
|
||||
const failed = new Map((mcp?.errors ?? []).map((e) => [e.server, e.message]));
|
||||
|
||||
const rows = servers.map(([name, config]) => {
|
||||
const where = 'url' in config ? config.url : [config.command, ...(config.args ?? [])].join(' ');
|
||||
const isDirect = (config as { expose?: string }).expose === 'direct';
|
||||
const state = failed.has(name)
|
||||
? `failed: ${failed.get(name)}`
|
||||
: isDirect
|
||||
? (liveDirect.has(name) ? `${liveDirect.get(name)} tools (direct)` : 'not connected')
|
||||
: hasMeta
|
||||
? 'via mcp_list/mcp_inspect/mcp_call (no schema until called)'
|
||||
: has('--no-mcp')
|
||||
? 'not connected (--no-mcp)'
|
||||
: 'not connected this session';
|
||||
return `- \`${name}\` (${'url' in config ? 'remote' : 'local'}) - ${state}\n ${where}`;
|
||||
});
|
||||
|
||||
return [...rows, '', `configured in ${configPath()}`].join('\n');
|
||||
},
|
||||
add: async (result) => {
|
||||
const servers = { ...(cfg.mcpServers ?? {}), [result.name]: result.config };
|
||||
cfg = { ...cfg, mcpServers: servers };
|
||||
const path = await writeConfigFile({ mcpServers: servers });
|
||||
const where = 'url' in result.config ? result.config.url : result.config.command;
|
||||
// Connected at boot, like the servers already in the file: a mid-turn connect
|
||||
// would change the tool list under a turn that is already running.
|
||||
return `added mcp server ${result.name} (${where})\nsaved to ${path}\nrestart shiro to connect it`;
|
||||
},
|
||||
remove: async (name) => {
|
||||
const servers = { ...(cfg.mcpServers ?? {}) };
|
||||
if (!(name in servers)) throw new Error(`no MCP server named "${name}"`);
|
||||
delete servers[name];
|
||||
cfg = { ...cfg, mcpServers: servers };
|
||||
const path = await writeConfigFile({ mcpServers: servers });
|
||||
return `removed mcp server ${name}\nsaved to ${path}\nrestart shiro to disconnect it`;
|
||||
},
|
||||
},
|
||||
backgroundCommands: {
|
||||
list: () => {
|
||||
const rows = backgroundSummary();
|
||||
if (rows.length === 0) return 'no background commands';
|
||||
return rows
|
||||
.map((r) => `${r.handle}: ${r.name} — ${r.running ? 'running' : `exit ${r.exit}`}\n ${r.command}`)
|
||||
.join('\n');
|
||||
},
|
||||
stop: async (handle) => stopBackground(handle),
|
||||
stopAll: async () => {
|
||||
const rows = backgroundSummary();
|
||||
if (rows.length === 0) return 'no background commands';
|
||||
const results = await Promise.all(rows.map((r) => stopBackground(r.handle)));
|
||||
return results.join('\n');
|
||||
},
|
||||
},
|
||||
initPrompt: INIT_PROMPT,
|
||||
scaffoldWorkflow: () => scaffoldWorkflowFiles(),
|
||||
history: promptHistory,
|
||||
recordPrompt: (text) => void store.appendHistory(text),
|
||||
agentName: () => session.agent().name,
|
||||
@@ -482,6 +703,36 @@ const hooks: AppHooks = {
|
||||
)
|
||||
.join('\n');
|
||||
},
|
||||
searchSessions: async (query) => {
|
||||
const hits = await store.searchSessions(query);
|
||||
if (hits.length === 0) return '';
|
||||
return hits
|
||||
.map(
|
||||
(r) =>
|
||||
`${r.id.slice(0, 8)} ${r.updatedAt.slice(0, 16).replace('T', ' ')} ${r.messages.length}msg ${r.title}`,
|
||||
)
|
||||
.join('\n');
|
||||
},
|
||||
forkSession: async () => {
|
||||
// Only fork when there is a turn boundary to fork at.
|
||||
if (session.messages.length === 0) return 'nothing to fork yet';
|
||||
const clone = session.fork();
|
||||
if (clone.length === 0) return 'nothing to fork yet';
|
||||
const rec: store.SessionRecord = {
|
||||
id: store.newId(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
provider: cfg.provider,
|
||||
model: cfg.model,
|
||||
title: store.titleOf(clone),
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
messages: clone,
|
||||
};
|
||||
await store.save(rec);
|
||||
return `forked ${rec.id.slice(0, 8)} — resume it with /resume ${rec.id.slice(0, 8)}`;
|
||||
},
|
||||
resumeSession: async (idOrPrefix) => {
|
||||
const id = await store.resolveId(idOrPrefix);
|
||||
const rec = id ? await store.load(id) : undefined;
|
||||
@@ -498,32 +749,46 @@ const hooks: AppHooks = {
|
||||
},
|
||||
};
|
||||
|
||||
const header = [
|
||||
needsProvider
|
||||
? `shiro-neko ${VERSION} no provider configured`
|
||||
: `shiro-neko ${VERSION} ${cfg.provider}/${record.model} session ${record.id.slice(0, 8)}`,
|
||||
`agent: ${agentVariant.name} thinking: ${agentVariant.thinking}`,
|
||||
`cwd: ${process.cwd()}`,
|
||||
restored ? `resumed ${record.messages.length} messages` : undefined,
|
||||
// The welcome dashboard's environment facts, in scan order. Anything that should
|
||||
// stop the user — a failed plugin, `--yolo`, a missing key — is given a tone so it
|
||||
// lifts out of the quiet metadata rather than blending into it.
|
||||
const facts: HeaderFact[] = [
|
||||
{ label: 'agent', value: `${agentVariant.name} thinking ${agentVariant.thinking}` },
|
||||
restored ? { label: 'resumed', value: `${record.messages.length} messages` } : undefined,
|
||||
instructions.length > 0
|
||||
? `instructions: ${instructions.map((i) => i.path.split(/[\\/]/).at(-1)).join(', ')}`
|
||||
: 'no AGENTS.md found - /init writes one',
|
||||
skills.length > 0 ? `skills: ${skills.map((s) => s.name).join(', ')}` : undefined,
|
||||
plugins.plugins.length > 0 ? `plugins: ${plugins.plugins.map((p) => p.name).join(', ')}` : undefined,
|
||||
...plugins.errors.map((e) => `plugin ${e.plugin}: ${e.message}`),
|
||||
memory && memory.all().length > 0 ? `memory: ${memory.all().length} notes about this project` : undefined,
|
||||
mcp && Object.keys(mcp.tools).length > 0 ? `mcp: ${Object.keys(mcp.tools).length} tools` : undefined,
|
||||
...(mcp?.errors ?? []).map((e) => `mcp ${e.server} failed: ${e.message}`),
|
||||
? { label: 'instructions', value: instructions.map((i) => i.path.split(/[\\/]/).at(-1)!).join(', ') }
|
||||
: { label: 'instructions', value: 'none - /init writes an AGENTS.md', tone: 'info' },
|
||||
skills.length > 0 ? { label: 'skills', value: skills.map((s) => s.name).join(', ') } : undefined,
|
||||
plugins.plugins.length > 0
|
||||
? { label: 'plugins', value: plugins.plugins.map((p) => p.name).join(', ') }
|
||||
: undefined,
|
||||
...plugins.errors.map((e) => ({ label: 'plugin error', value: `${e.plugin}: ${e.message}`, tone: 'err' as const })),
|
||||
memory && memory.all().length > 0
|
||||
? { label: 'memory', value: `${memory.all().length} notes about this project` }
|
||||
: undefined,
|
||||
mcp && Object.keys(mcp.tools).filter((k) => k !== '__mcpServerNames').length > 0 ? { label: 'mcp', value: `${Object.keys(mcp.tools).filter((k) => k !== '__mcpServerNames').length} tools${(mcp.tools as Record<string, unknown>)['mcp_list'] ? ' (mcp_list/mcp_inspect/mcp_call)' : ''}` } : undefined,
|
||||
!mcp && cfg.mcpServers && Object.keys(cfg.mcpServers).length > 0
|
||||
? { label: 'mcp', value: `${Object.keys(cfg.mcpServers).length} configured, not connected (--no-mcp)`, tone: 'warn' as const }
|
||||
: undefined,
|
||||
...(mcp?.errors ?? []).map((e) => ({ label: 'mcp error', value: `${e.server}: ${e.message}`, tone: 'err' as const })),
|
||||
yolo
|
||||
? 'approvals: OFF (--yolo), but deny rules and the guard still apply'
|
||||
? { label: 'approvals', value: 'OFF (--yolo) - deny rules and the guard still apply', tone: 'warn' as const }
|
||||
: cfg.permission
|
||||
? `approvals: rules for ${Object.keys(cfg.permission).join(', ')}, defaults elsewhere`
|
||||
: 'approvals: ask for write_file, edit_file, multi_edit, bash, mcp__*',
|
||||
cfg.toolSets ? `tool sets: core, ${cfg.toolSets.join(', ')}` : undefined,
|
||||
'/help for commands',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
? { label: 'approvals', value: `rules for ${Object.keys(cfg.permission).join(', ')}, defaults elsewhere` }
|
||||
: { label: 'approvals', value: 'ask for writes, bash, web_fetch, mcp' },
|
||||
cfg.toolSets ? { label: 'tool sets', value: `core, ${cfg.toolSets.join(', ')}` } : undefined,
|
||||
].filter((f): f is HeaderFact => f !== undefined);
|
||||
|
||||
const headerNode = (
|
||||
<Header
|
||||
version={VERSION}
|
||||
{...(needsProvider ? {} : { provider: cfg.provider, model: record.model })}
|
||||
sessionId={record.id.slice(0, 8)}
|
||||
cwd={process.cwd()}
|
||||
title={restored ? record.title : undefined}
|
||||
facts={facts}
|
||||
/>
|
||||
);
|
||||
|
||||
// ctrl-c has to reach the App: with a command running it kills that command and
|
||||
// keeps the turn. Ink's own handler would exit the process before we saw the key.
|
||||
@@ -531,7 +796,9 @@ const app = render(
|
||||
<App
|
||||
session={session}
|
||||
bridge={bridge}
|
||||
header={header}
|
||||
header=""
|
||||
headerNode={headerNode}
|
||||
version={VERSION}
|
||||
hooks={hooks}
|
||||
notices={notices}
|
||||
askBridge={askBridge}
|
||||
@@ -541,4 +808,14 @@ const app = render(
|
||||
{ exitOnCtrlC: false },
|
||||
);
|
||||
await app.waitUntilExit();
|
||||
// Printed after Ink has released the screen, so it survives the final repaint. The
|
||||
// title comes from the messages rather than `record`, whose own title is only
|
||||
// refreshed by the debounced save and may not have run yet.
|
||||
console.log(
|
||||
farewell({
|
||||
id: record.id,
|
||||
messages: session.messages.length,
|
||||
title: store.titleOf(session.messages),
|
||||
}),
|
||||
);
|
||||
await shutdown(0);
|
||||
+123
-6
@@ -1,9 +1,13 @@
|
||||
import type { CustomCommand } from './custom-commands';
|
||||
|
||||
export type CommandAction =
|
||||
| { type: 'none' }
|
||||
| { type: 'prompt'; text: string }
|
||||
| { type: 'exit' }
|
||||
| { type: 'clear' }
|
||||
| { type: 'compact' }
|
||||
| { type: 'undo'; what: 'both' | 'files' | 'conversation' }
|
||||
| { type: 'redo'; what: 'both' | 'files' | 'conversation' }
|
||||
| { type: 'tools' }
|
||||
| { type: 'cost' }
|
||||
| { type: 'sessions' }
|
||||
@@ -17,12 +21,22 @@ export type CommandAction =
|
||||
| { type: 'skills' }
|
||||
| { type: 'plugins' }
|
||||
| { type: 'registry'; action: 'list' | 'search' | 'add' | 'remove' | 'installed'; arg?: string }
|
||||
| { type: 'mcp'; action: 'list' | 'add' | 'remove'; arg?: string }
|
||||
| { type: 'memory' }
|
||||
| { type: 'agent'; agent?: string }
|
||||
| { type: 'think'; level?: string }
|
||||
| { type: 'info'; text: string }
|
||||
| { type: 'model'; model: string }
|
||||
| { type: 'resume'; id: string }
|
||||
| { type: 'changes' }
|
||||
| { type: 'diff'; action: 'raw' | 'review' }
|
||||
| { type: 'bash'; action: 'list' | 'stop' | 'stop-all'; arg?: string }
|
||||
| { type: 'diagnostics'; action: 'start' | 'stop' | 'status'; command?: string }
|
||||
| { type: 'search'; query: string }
|
||||
| { type: 'fork' }
|
||||
| { type: 'workflow' }
|
||||
/** A custom command from a markdown file, expanded against its arguments. */
|
||||
| { type: 'custom'; command: CustomCommand; args: string[] }
|
||||
| { type: 'unknown'; name: string };
|
||||
|
||||
export type CommandSpec = {
|
||||
@@ -44,6 +58,7 @@ export const COMMANDS: CommandSpec[] = [
|
||||
{ name: 'skills', summary: 'list loaded skills' },
|
||||
{ name: 'plugins', summary: 'list active plugins' },
|
||||
{ name: 'registry', arg: '[search|add|remove] [name]', summary: 'browse and install external skills and plugins' },
|
||||
{ name: 'mcp', arg: '[add|remove <name>]', summary: 'add a local or remote MCP server, or list them' },
|
||||
{ name: 'init', summary: 'have the agent write AGENTS.md for this project' },
|
||||
{ name: 'context', summary: 'show which instruction files are loaded' },
|
||||
{ name: 'todos', summary: "show the agent's task list" },
|
||||
@@ -51,10 +66,19 @@ export const COMMANDS: CommandSpec[] = [
|
||||
{ name: 'memory', summary: 'compact the project memory with the model' },
|
||||
{ name: 'tools', summary: 'list available tools' },
|
||||
{ name: 'compact', summary: 'replace history with a model-written summary' },
|
||||
{ name: 'undo', arg: '[files|conversation]', summary: 'walk the last turn back: files, conversation, or both' },
|
||||
{ name: 'redo', arg: '[conversation]', summary: 'put back what /undo took' },
|
||||
{ name: 'cost', summary: 'tokens and estimated spend this session' },
|
||||
{ name: 'sessions', summary: 'list saved sessions' },
|
||||
{ name: 'resume', arg: '<id>', summary: 'load a saved session' },
|
||||
{ name: 'save', summary: 'write the session to disk now' },
|
||||
{ name: 'changes', summary: 'show what the last turn changed on disk' },
|
||||
{ name: 'diff', arg: '[review]', summary: 'diff the last turn; /diff review shows per-hunk file:line blocks' },
|
||||
{ name: 'bash', arg: '[list|stop <id>|stop all]', summary: 'list or stop background commands started with bash background: true' },
|
||||
{ name: 'diagnostics', arg: '[start <cmd>|stop|status]', summary: 'run a check command in the UI only (never in model context)' },
|
||||
{ name: 'search', arg: '<query>', summary: 'search saved sessions for a phrase' },
|
||||
{ name: 'fork', summary: 'fork the session at the last turn boundary (keeps the original)' },
|
||||
{ name: 'workflow', summary: 'show project workflow state: TODO/ROADMAP tracking, docs, nudges' },
|
||||
{ name: 'clear', summary: 'clear the transcript and history' },
|
||||
{ name: 'exit', aliases: ['quit'], summary: 'quit' },
|
||||
];
|
||||
@@ -79,11 +103,12 @@ export const HELP = [
|
||||
* An exact name sorts first so pressing enter on `/model` cannot run `/models`.
|
||||
* Aliases stay hidden to keep the list short.
|
||||
*/
|
||||
export function matchCommands(input: string): CommandSpec[] {
|
||||
export function matchCommands(input: string, custom: readonly CustomCommand[] = []): CommandSpec[] {
|
||||
if (!input.startsWith('/')) return [];
|
||||
const typed = input.slice(1).toLowerCase();
|
||||
if (typed.includes(' ')) return [];
|
||||
const hits = COMMANDS.filter((c) => c.name.startsWith(typed));
|
||||
const customSpecs: CommandSpec[] = custom.map((c) => ({ name: c.name, summary: c.description }));
|
||||
const hits = [...COMMANDS, ...customSpecs].filter((c) => c.name.startsWith(typed));
|
||||
const exact = hits.findIndex((c) => c.name === typed);
|
||||
return exact > 0 ? [hits[exact]!, ...hits.filter((_, i) => i !== exact)] : hits;
|
||||
}
|
||||
@@ -127,8 +152,56 @@ function parseRegistry(arg: string): CommandAction {
|
||||
}
|
||||
}
|
||||
|
||||
/** Pure parser: no IO, so the TUI and headless mode share one definition. */
|
||||
export function parseCommand(raw: string): CommandAction {
|
||||
/**
|
||||
* `/mcp [list|add|remove <name>]`.
|
||||
*
|
||||
* A bare `/mcp` lists what is configured, because that is the question asked most
|
||||
* often. `add` opens the wizard rather than taking arguments: a server is a name
|
||||
* plus a command or a URL plus optional headers, and a single argument string
|
||||
* cannot express that without a syntax nobody remembers.
|
||||
*/
|
||||
function parseMcp(arg: string): CommandAction {
|
||||
const [verb = '', ...rest] = arg.split(/\s+/).filter(Boolean);
|
||||
const name = rest.join(' ').trim();
|
||||
|
||||
switch (verb) {
|
||||
case '':
|
||||
case 'list':
|
||||
return { type: 'mcp', action: 'list' };
|
||||
case 'add':
|
||||
case 'new':
|
||||
return { type: 'mcp', action: 'add' };
|
||||
case 'remove':
|
||||
case 'rm':
|
||||
return name ? { type: 'mcp', action: 'remove', arg: name } : { type: 'info', text: 'usage: /mcp remove <name>' };
|
||||
default:
|
||||
return { type: 'info', text: 'usage: /mcp [list|add|remove <name>]' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `/undo [files|conversation]` and `/redo [conversation]`.
|
||||
*
|
||||
* The default is `both` for undo, because restoring one without the other is the
|
||||
* failure the two are meant to prevent: files back without the history and the model
|
||||
* re-reads a change it no longer made. A bare `files` or `conversation` narrows it.
|
||||
* Redo defaults to the conversation, since file content after the turn was never kept.
|
||||
*/
|
||||
function parseUndoKind(arg: string, fallback: 'both' | 'conversation'): 'both' | 'files' | 'conversation' {
|
||||
const word = arg.trim().toLowerCase();
|
||||
if (word === 'files' || word === 'file') return 'files';
|
||||
if (word === 'conversation' || word === 'chat' || word === 'history') return 'conversation';
|
||||
if (word === 'both' || word === 'all' || word === '') return fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure parser: no IO, so the TUI and headless mode share one definition.
|
||||
*
|
||||
* Custom commands are consulted only after every built-in name misses, so a
|
||||
* markdown file can add a command but never shadow one that ships with the binary.
|
||||
*/
|
||||
export function parseCommand(raw: string, custom: readonly CustomCommand[] = []): CommandAction {
|
||||
const input = raw.trim();
|
||||
if (!input) return { type: 'none' };
|
||||
if (!input.startsWith('/')) return { type: 'prompt', text: input };
|
||||
@@ -147,6 +220,10 @@ export function parseCommand(raw: string): CommandAction {
|
||||
return { type: 'clear' };
|
||||
case 'compact':
|
||||
return { type: 'compact' };
|
||||
case 'undo':
|
||||
return { type: 'undo', what: parseUndoKind(arg, 'both') };
|
||||
case 'redo':
|
||||
return { type: 'redo', what: parseUndoKind(arg, 'conversation') };
|
||||
case 'tools':
|
||||
return { type: 'tools' };
|
||||
case 'cost':
|
||||
@@ -174,6 +251,8 @@ export function parseCommand(raw: string): CommandAction {
|
||||
return { type: 'plugins' };
|
||||
case 'registry':
|
||||
return parseRegistry(arg);
|
||||
case 'mcp':
|
||||
return parseMcp(arg);
|
||||
case 'memory':
|
||||
return { type: 'memory' };
|
||||
case 'agent':
|
||||
@@ -184,7 +263,45 @@ export function parseCommand(raw: string): CommandAction {
|
||||
return arg ? { type: 'model', model: arg } : { type: 'models' };
|
||||
case 'resume':
|
||||
return arg ? { type: 'resume', id: arg } : { type: 'info', text: 'usage: /resume <session-id>' };
|
||||
default:
|
||||
return { type: 'unknown', name };
|
||||
case 'changes':
|
||||
return { type: 'changes' };
|
||||
case 'diff': {
|
||||
const verb = arg.trim().toLowerCase();
|
||||
return verb === 'review' ? { type: 'diff', action: 'review' } : { type: 'diff', action: 'raw' };
|
||||
}
|
||||
case 'bash': {
|
||||
const [verb = '', ...rest] = arg.split(/\s+/);
|
||||
if (verb === 'stop') {
|
||||
if (rest.join(' ').trim().toLowerCase() === 'all') return { type: 'bash', action: 'stop-all' };
|
||||
const id = Number(rest[0]);
|
||||
return Number.isInteger(id) && id > 0
|
||||
? { type: 'bash', action: 'stop', arg: String(id) }
|
||||
: { type: 'info', text: 'usage: /bash stop <id> or /bash stop all' };
|
||||
}
|
||||
if (verb && verb !== 'list') return { type: 'info', text: 'usage: /bash [list|stop <id>|stop all]' };
|
||||
return { type: 'bash', action: 'list' };
|
||||
}
|
||||
case 'search':
|
||||
return arg ? { type: 'search', query: arg } : { type: 'info', text: 'usage: /search <query>' };
|
||||
case 'diagnostics': {
|
||||
const [verb = '', ...rest] = arg.split(/\s+/);
|
||||
const cmd = rest.join(' ').trim();
|
||||
if (verb === 'start' || verb === 'run') {
|
||||
return cmd
|
||||
? { type: 'diagnostics', action: 'start', command: cmd }
|
||||
: { type: 'info', text: 'usage: /diagnostics start <command>' };
|
||||
}
|
||||
if (verb === 'stop' || verb === 'off') return { type: 'diagnostics', action: 'stop' };
|
||||
if (verb && verb !== 'status' && verb !== 'on') return { type: 'info', text: 'usage: /diagnostics [start <cmd>|stop|status]' };
|
||||
return { type: 'diagnostics', action: 'status' };
|
||||
}
|
||||
case 'fork':
|
||||
return { type: 'fork' };
|
||||
case 'workflow':
|
||||
return { type: 'workflow' };
|
||||
default: {
|
||||
const cmd = custom.find((c) => c.name === name);
|
||||
return cmd ? { type: 'custom', command: cmd, args: arg ? arg.split(/\s+/) : [] } : { type: 'unknown', name };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { tool, generateText, type LanguageModel } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { git } from './tools-git';
|
||||
|
||||
/** Recent subjects shown to the model, so the message matches the repository's style. */
|
||||
const SUBJECTS = 15;
|
||||
/** The staged diff is the bulk of the call; beyond this it is cut with a note. */
|
||||
const MAX_DIFF = 24_000;
|
||||
|
||||
export const COMMIT_TOOL_NAME = 'git_commit_message';
|
||||
|
||||
/** A reply's wrapping — fenced blocks, surrounding prose, leading/trailing quotes. */
|
||||
const unwrap = (reply: string): string => {
|
||||
const fenced = /```[a-z]*\n([\s\S]*?)```/i.exec(reply);
|
||||
const body = fenced ? fenced[1]! : reply;
|
||||
const line = body.split('\n').find((l) => l.trim().length > 0) ?? '';
|
||||
return line.trim().replace(/^["'`]|["'`]$/g, '').slice(0, 72);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a commit message from the staged changes with one nested model call.
|
||||
*
|
||||
* The model sees two things: the staged diff, and the repository's own recent
|
||||
* subjects, because a message that ignores the established style reads as foreign
|
||||
* no matter how accurate it is. The subject style of this repository — plain
|
||||
* imperative, no conventional-commit prefix — is one example; the sample keeps
|
||||
* the choice local to whatever the history actually says.
|
||||
*
|
||||
* It never commits. Generating the message is safe to auto-approve; running the
|
||||
* commit is not, and that stays on the gated `bash` path where the user sees the
|
||||
* message and the command together.
|
||||
*/
|
||||
export function createCommitMessageTool(opts: { model: LanguageModel; cwd?: string }) {
|
||||
return tool({
|
||||
description:
|
||||
'Generate a commit message from the staged changes, in one nested model call. Reads the ' +
|
||||
'staged diff and the recent commit subjects so the message matches the repository\'s style. ' +
|
||||
'It does not commit — it returns the message only. Use git_diff first to see what is staged, ' +
|
||||
'and run the commit through bash where the user approves it.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
const cwd = opts.cwd ?? process.cwd();
|
||||
|
||||
const staged = await git(['diff', '--staged', '--no-color'], cwd);
|
||||
if (!staged.ok) throw new Error(staged.message);
|
||||
const diff = staged.stdout.trim();
|
||||
if (diff.length === 0) return 'Nothing is staged. Stage the change first, then ask again.';
|
||||
|
||||
const subjects = await git(
|
||||
['log', `-n${SUBJECTS}`, '--pretty=format:%s'],
|
||||
cwd,
|
||||
);
|
||||
const history = subjects.ok && subjects.stdout.trim().length > 0 ? subjects.stdout : '(no commits yet)';
|
||||
|
||||
const shown =
|
||||
diff.length > MAX_DIFF ? `${diff.slice(0, MAX_DIFF)}\n... [truncated ${diff.length - MAX_DIFF} chars]` : diff;
|
||||
|
||||
const { text } = await generateText({
|
||||
model: opts.model,
|
||||
system:
|
||||
'You write one commit message for the staged diff below. Match the subject style of the ' +
|
||||
'recent commits listed after it: same language, same capitalisation, same prefix convention ' +
|
||||
'or lack of one. One line, no body, no quotes, no backticks, no prefix like "commit:". ' +
|
||||
'Describe what the change does, not what files it touches.',
|
||||
prompt: `Staged diff:\n\n${shown}\n\nRecent commit subjects:\n${history}`,
|
||||
maxRetries: 2,
|
||||
});
|
||||
|
||||
const message = unwrap(text);
|
||||
if (message.length === 0) throw new Error('the model returned no message; ask again or write one yourself');
|
||||
return message;
|
||||
},
|
||||
});
|
||||
}
|
||||
+25
-7
@@ -43,25 +43,43 @@ const MAX_MATCHES = 8;
|
||||
* Ties break on path length: the shallower file is more often the one meant.
|
||||
*/
|
||||
export function matchPaths(paths: readonly string[], query: string, limit = MAX_MATCHES): string[] {
|
||||
if (!query) return [...paths].sort((a, b) => a.length - b.length || a.localeCompare(b)).slice(0, limit);
|
||||
if (!query) {
|
||||
const dirs = paths.filter((p) => p.endsWith('/'));
|
||||
const files = paths.filter((p) => !p.endsWith('/'));
|
||||
const byLength = (a: string, b: string) => a.length - b.length || a.localeCompare(b);
|
||||
// Empty query: prefer directories (the user just typed `@src/` and wants to go deeper)
|
||||
// when available, otherwise fall back to the shallowest files.
|
||||
if (dirs.length > 0) return [...dirs.sort(byLength), ...files.sort(byLength)].slice(0, limit);
|
||||
return [...files.sort(byLength)].slice(0, limit);
|
||||
}
|
||||
|
||||
const needle = query.toLowerCase();
|
||||
const prefix: string[] = [];
|
||||
const substring: string[] = [];
|
||||
const prefixDirs: string[] = [];
|
||||
const prefixFiles: string[] = [];
|
||||
const substringDirs: string[] = [];
|
||||
const substringFiles: string[] = [];
|
||||
|
||||
for (const path of paths) {
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.startsWith(needle)) prefix.push(path);
|
||||
else if (lower.includes(needle)) substring.push(path);
|
||||
const isDir = path.endsWith('/');
|
||||
const hit = lower.startsWith(needle) ? 'prefix' : lower.includes(needle) ? 'substring' : undefined;
|
||||
if (!hit) continue;
|
||||
if (hit === 'prefix') (isDir ? prefixDirs : prefixFiles).push(path);
|
||||
else (isDir ? substringDirs : substringFiles).push(path);
|
||||
}
|
||||
|
||||
const byLength = (a: string, b: string) => a.length - b.length || a.localeCompare(b);
|
||||
return [...prefix.sort(byLength), ...substring.sort(byLength)].slice(0, limit);
|
||||
return [
|
||||
...prefixDirs.sort(byLength),
|
||||
...prefixFiles.sort(byLength),
|
||||
...substringDirs.sort(byLength),
|
||||
...substringFiles.sort(byLength),
|
||||
].slice(0, limit);
|
||||
}
|
||||
|
||||
export type Completion = { value: string; cursor: number };
|
||||
|
||||
/** Replaces the token with a plain relative path and a trailing space. */
|
||||
/** Replaces the token with a plain relative path and a trailing space (dirs keep their trailing `/`). */
|
||||
export function completePath(value: string, token: PathToken, path: string): Completion {
|
||||
const next = `${value.slice(0, token.start)}${path} ${value.slice(token.end)}`;
|
||||
return { value: next, cursor: token.start + path.length + 1 };
|
||||
|
||||
@@ -21,6 +21,21 @@ export type Config = {
|
||||
presetId?: string;
|
||||
/** Retries per model call for transient failures. SDK default is 2. */
|
||||
maxRetries?: number;
|
||||
/** USD ceiling for a session's spend: warn at 80%, refuse the next turn at 100%. */
|
||||
maxSpendUsd?: number;
|
||||
/** USD ceiling per individual turn: abort a step if the turn's delta exceeds this. */
|
||||
maxSpendPerTurn?: number;
|
||||
/** Project-driven workflow: TODO.md/ROADMAP.md tracking, docs-driven dev, verify-before-done. */
|
||||
workflow?: {
|
||||
/** Master switch. Default true (nudges and prompt policy). */
|
||||
enabled?: boolean;
|
||||
/** Directory the project keeps its developer docs in, for the docs-driven rule. Default 'docs'. */
|
||||
docsDir?: string;
|
||||
/** Auto-write TODO.md/ROADMAP.md/docs/AGENTS.md on first turn when the repo has none. Default true. */
|
||||
autoScaffold?: boolean;
|
||||
};
|
||||
/** Model id for subagents; omit to share the parent's. */
|
||||
subagentModel?: string;
|
||||
/** Default agent variant name. */
|
||||
agent?: string;
|
||||
/** Default thinking level. */
|
||||
@@ -33,7 +48,24 @@ export type Config = {
|
||||
permission?: PermissionConfig;
|
||||
/** Index for `/registry`. Omit for the default one. */
|
||||
registryUrl?: string;
|
||||
/** Publisher public keys for signed registry entries: name → ed25519 public key (PEM). */
|
||||
registryPublishers?: Record<string, string>;
|
||||
/** Install unsigned registry entries. Default false — signed entries are required. */
|
||||
registryAllowUnsigned?: boolean;
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
/** A check command (e.g. `tsc --watch`) run in the UI only, never in model context. */
|
||||
diagnostics?: string;
|
||||
/**
|
||||
* When a turn ends normally but the task list still has work, keep going with
|
||||
* auto-continue prompts until the list is done or the turn budget is used up.
|
||||
* `true` on, `false` off, or `{ "maxTurns": n }` to bound it. Default on.
|
||||
*/
|
||||
continueWhileTodos?: boolean | { maxTurns?: number };
|
||||
/**
|
||||
* How MCP tools reach the model: `lazy` registers meta-tools only (cheap until a
|
||||
* tool is called), `eager` registers every server tool up front. Omit for lazy.
|
||||
*/
|
||||
mcpMode?: 'lazy' | 'eager';
|
||||
};
|
||||
|
||||
const configPath = () => join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', 'config.json');
|
||||
@@ -78,6 +110,11 @@ export async function writeConfigFile(patch: Partial<Config>): Promise<string> {
|
||||
}
|
||||
|
||||
/** File config, then env overrides. Env wins so `SHIRO_MODEL=x shiro` works. */
|
||||
export function unknownToolSetNames(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return (raw as unknown[]).filter((v): v is string => typeof v === 'string' && !isToolSetName(v));
|
||||
}
|
||||
|
||||
export async function loadConfig(): Promise<Config> {
|
||||
const file = await readConfigFile();
|
||||
|
||||
@@ -103,6 +140,20 @@ export async function loadConfig(): Promise<Config> {
|
||||
process.env[ENV_KEY[provider]],
|
||||
...(file.presetId ? { presetId: file.presetId } : {}),
|
||||
...(file.maxRetries !== undefined ? { maxRetries: file.maxRetries } : {}),
|
||||
...(typeof file.maxSpendUsd === 'number' && file.maxSpendUsd > 0 ? { maxSpendUsd: file.maxSpendUsd } : {}),
|
||||
...(typeof file.maxSpendPerTurn === 'number' && file.maxSpendPerTurn > 0 ? { maxSpendPerTurn: file.maxSpendPerTurn } : {}),
|
||||
...(file.workflow !== undefined
|
||||
? {
|
||||
workflow: {
|
||||
...(typeof file.workflow.enabled === 'boolean' ? { enabled: file.workflow.enabled } : {}),
|
||||
...(file.workflow.docsDir ? { docsDir: file.workflow.docsDir } : {}),
|
||||
...(typeof file.workflow.autoScaffold === 'boolean' ? { autoScaffold: file.workflow.autoScaffold } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(file.registryPublishers !== undefined ? { registryPublishers: file.registryPublishers } : {}),
|
||||
...(typeof file.registryAllowUnsigned === 'boolean' ? { registryAllowUnsigned: file.registryAllowUnsigned } : {}),
|
||||
...(file.subagentModel ? { subagentModel: file.subagentModel } : {}),
|
||||
...(file.agent ? { agent: file.agent } : {}),
|
||||
...(file.thinking ? { thinking: file.thinking } : {}),
|
||||
...(Array.isArray(file.plugins) ? { plugins: file.plugins } : {}),
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { guardPlugin } from './plugins-builtin';
|
||||
|
||||
/**
|
||||
* Custom slash commands read from markdown files.
|
||||
*
|
||||
* `.shiro/commands/<name>.md` in the project and `~/.shiro-neko/commands/<name>.md`
|
||||
* for the user. The filename is the command; the body becomes the prompt. A project
|
||||
* command shadows a user command of the same name, so a repo can specialise a
|
||||
* personal default.
|
||||
*/
|
||||
export type CustomCommand = {
|
||||
name: string;
|
||||
/** One-line summary for the `/` menu, from frontmatter or the first body line. */
|
||||
description: string;
|
||||
/** Agent to run it under, when frontmatter sets one. */
|
||||
agent?: string;
|
||||
/** The prompt template, before substitution. */
|
||||
body: string;
|
||||
origin: 'project' | 'user';
|
||||
path: string;
|
||||
};
|
||||
|
||||
const MAX_BODY = 20_000;
|
||||
|
||||
/** Reads frontmatter `description` and `agent`; everything after the `---` fence is the prompt. */
|
||||
function parse(name: string, source: string, origin: CustomCommand['origin'], path: string): CustomCommand | undefined {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(source.trimStart());
|
||||
const meta: Record<string, string> = {};
|
||||
let body = source;
|
||||
if (match) {
|
||||
for (const line of match[1]!.split(/\r?\n/)) {
|
||||
const kv = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(line.trim());
|
||||
if (kv) meta[kv[1]!.toLowerCase()] = kv[2]!.replace(/^["']|["']$/g, '').trim();
|
||||
}
|
||||
body = match[2]!;
|
||||
}
|
||||
const trimmed = body.trim().slice(0, MAX_BODY);
|
||||
if (!trimmed) return undefined;
|
||||
const description = meta['description'] ?? trimmed.split('\n').find((l) => l.trim().length > 0)?.trim().slice(0, 60) ?? name;
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
...(meta['agent'] ? { agent: meta['agent'] } : {}),
|
||||
body: trimmed,
|
||||
origin,
|
||||
path,
|
||||
};
|
||||
}
|
||||
|
||||
function commandDirs(cwd: string): { dir: string; origin: CustomCommand['origin'] }[] {
|
||||
const home = join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko');
|
||||
return [
|
||||
{ dir: join(home, 'commands'), origin: 'user' },
|
||||
{ dir: join(cwd, '.shiro', 'commands'), origin: 'project' },
|
||||
];
|
||||
}
|
||||
|
||||
/** Loads every custom command, project shadowing user by name. A file that fails to parse is skipped. */
|
||||
export async function loadCustomCommands(cwd = process.cwd()): Promise<CustomCommand[]> {
|
||||
const byName = new Map<string, CustomCommand>();
|
||||
for (const { dir, origin } of commandDirs(cwd)) {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
for await (const f of new Bun.Glob('*.md').scan({ cwd: dir, onlyFiles: true })) files.push(f);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const file of files.sort()) {
|
||||
const name = file.replace(/\.md$/i, '');
|
||||
if (!/^[a-z0-9][a-z0-9-_]*$/i.test(name)) continue;
|
||||
const path = join(dir, file);
|
||||
try {
|
||||
const cmd = parse(name, await Bun.file(path).text(), origin, path);
|
||||
if (cmd) byName.set(cmd.name, cmd);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Runs a `` !`cmd` `` substitution through the guard before executing it. */
|
||||
async function runSubstitution(command: string): Promise<string> {
|
||||
const blocked = await guardPlugin.beforeToolCall!({ toolName: 'bash', input: { command }, cwd: process.cwd() });
|
||||
if (blocked) throw new Error(`shell substitution refused: ${blocked}`);
|
||||
|
||||
// The same shell bash uses, so a substitution and a bash call agree on syntax.
|
||||
const shell = process.platform === 'win32' ? ['cmd', '/c', command] : ['bash', '-lc', command];
|
||||
const proc = Bun.spawn(shell, { stdout: 'pipe', stderr: 'pipe' });
|
||||
const [out, err, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
if (code !== 0) throw new Error(`shell substitution \`!${command}\` exited ${code}: ${err.trim().slice(0, 200)}`);
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands a command's body against the arguments it was typed with.
|
||||
*
|
||||
* `$ARGUMENTS` is the whole argument string, `$1`, `$2`, … the positionals, and
|
||||
* `` !`cmd` `` runs a shell command and inlines its output — each such command
|
||||
* passed through the guard first, so a custom command cannot smuggle a destructive
|
||||
* call past the user the way a plain bash call cannot.
|
||||
*/
|
||||
export async function expandCommand(cmd: CustomCommand, args: string[]): Promise<string> {
|
||||
let out = cmd.body;
|
||||
out = out.replaceAll('$ARGUMENTS', args.join(' '));
|
||||
out = out.replace(/\$(\d+)/g, (_, i) => args[Number(i) - 1] ?? '');
|
||||
|
||||
const substitutions = [...out.matchAll(/!`([^`]+)`/g)];
|
||||
for (const m of substitutions) {
|
||||
const value = await runSubstitution(m[1]!);
|
||||
out = out.replace(m[0], value);
|
||||
}
|
||||
return out.trim();
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Live diagnostics: a background check command whose output is shown in the UI
|
||||
* but never reaches the model's context.
|
||||
*
|
||||
* The difference from `startBackground` in tools.ts is deliberate. A background
|
||||
* bash command is a tool result the model owns; its tail feeds the transcript
|
||||
* through the bash listener. Diagnostics are the opposite: a check the *user*
|
||||
* wants to watch (tsc in watch mode, a test watcher) while the model works. Its
|
||||
* output would be pure noise in the prompt — a file-watcher re-emits the whole
|
||||
* tree on every save — so it is buffered here, separate from the bash journal,
|
||||
* and only the UI reads it.
|
||||
*
|
||||
* One at a time: a diagnostics panel is a single line of state, and running two
|
||||
* watchers (e.g. tsc + a test watcher) is what the model's own tools are for.
|
||||
*/
|
||||
|
||||
export type DiagState = {
|
||||
command: string;
|
||||
proc: Bun.Subprocess;
|
||||
/** Append-only, capped. Tail is what the panel shows. */
|
||||
tail: string;
|
||||
exit: number | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
let current: DiagState | undefined;
|
||||
|
||||
const MAX_DIAG = 20_000;
|
||||
const cap = (s: string) => (s.length <= MAX_DIAG ? s : s.slice(-MAX_DIAG));
|
||||
|
||||
/** Start a diagnostics command, replacing any running one (old one is killed). */
|
||||
export function diagStart(command: string): { started: boolean; replaced?: boolean; command: string } {
|
||||
diagStop();
|
||||
const shell = process.platform === 'win32' ? ['cmd', '/c', command] : ['bash', '-lc', command];
|
||||
let proc: Bun.Subprocess;
|
||||
try {
|
||||
proc = Bun.spawn(shell, { cwd: process.cwd(), stdout: 'pipe', stderr: 'pipe' });
|
||||
} catch (e) {
|
||||
throw new Error(`could not start diagnostics: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
current = { command, proc, tail: '', exit: null, startedAt: Date.now() };
|
||||
void proc.exited.then((code) => {
|
||||
if (current?.proc === proc) current!.exit = code;
|
||||
});
|
||||
const pump = async (stream: ReadableStream<Uint8Array> | undefined) => {
|
||||
if (!stream) return;
|
||||
const decoder = new TextDecoder();
|
||||
for await (const chunk of stream) {
|
||||
const text = decoder.decode(chunk, { stream: true });
|
||||
if (!text) continue;
|
||||
if (current?.proc === proc) current!.tail = cap(current!.tail + text);
|
||||
}
|
||||
};
|
||||
void pump(proc.stdout as ReadableStream<Uint8Array>);
|
||||
void pump(proc.stderr as ReadableStream<Uint8Array>);
|
||||
return { started: true, command };
|
||||
}
|
||||
|
||||
/** Kill the running diagnostics command, if any. */
|
||||
export function diagStop(): { stopped: boolean; command?: string } {
|
||||
const d = current;
|
||||
if (!d) return { stopped: false };
|
||||
current = undefined;
|
||||
try {
|
||||
d.proc.kill();
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
return { stopped: true, command: d.command };
|
||||
}
|
||||
|
||||
/** Snapshot for the UI panel. `exit` stays null while running. */
|
||||
export function diagStatus(): { running: boolean; command?: string; exit: number | null; tail: string; startedAt: number } {
|
||||
if (!current) return { running: false, exit: null, tail: '', startedAt: 0 };
|
||||
return {
|
||||
running: current!.exit === null,
|
||||
command: current!.command,
|
||||
exit: current!.exit,
|
||||
tail: current!.tail,
|
||||
startedAt: current!.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm the diagnostics from config at boot, but never crash boot on a bad
|
||||
* command string — the user can fix it with /diagnostics stop + start.
|
||||
*/
|
||||
export function bootDiagnostics(configDiagnostics: string | undefined): void {
|
||||
if (!configDiagnostics?.trim()) return;
|
||||
try {
|
||||
diagStart(configDiagnostics.trim());
|
||||
} catch {
|
||||
// keep boot clean; /diagnostics start will report the real error
|
||||
}
|
||||
}
|
||||
|
||||
/** A default check command, mirroring run_checks' detection but for watch-style loops. */
|
||||
export async function defaultDiagnosticsCommand(cwd: string): Promise<string | undefined> {
|
||||
const has = async (p: string) => Bun.file(join(cwd, p)).exists();
|
||||
if ((await has('bun.lock')) || (await has('package.json'))) {
|
||||
if (await has('tsconfig.json')) return 'bun run typecheck --watch';
|
||||
return 'bun test --watch';
|
||||
}
|
||||
if (await has('Cargo.toml')) return 'cargo watch -x check';
|
||||
if (await has('go.mod')) return 'go build ./...';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Kill any running diagnostics on shutdown; best-effort, never throws. */
|
||||
export function shutdownDiagnostics(): void {
|
||||
try {
|
||||
diagStop();
|
||||
} catch {
|
||||
// nothing to reap
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Structured diff review for /diff review.
|
||||
*
|
||||
* Turns a unified diff into per-hunk entries, each with the file, the line
|
||||
* range the hunk touches, and the hunk body. Pure on purpose: parse here,
|
||||
* render anywhere, test without a terminal.
|
||||
*/
|
||||
|
||||
export type DiffHunk = {
|
||||
/** File the hunk belongs to, relative to the workspace root. */
|
||||
file: string;
|
||||
/** Hunk header, e.g. "@@ -1,5 +1,6 @@". */
|
||||
header: string;
|
||||
/** First line of the old-file range; 1-based. */
|
||||
oldStart: number;
|
||||
/** First line of the new-file range; 1-based. */
|
||||
newStart: number;
|
||||
/** The hunk body including + / - / context lines. */
|
||||
body: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits a unified diff into its file sections, then each section into hunks.
|
||||
*
|
||||
* A file section starts at `diff --git a/x b/y`, and the hunk header
|
||||
* `@@ -a,b +c,d @@` starts each hunk. `---`/`+++` lines inside a hunk body
|
||||
* are just content lines (they carry a leading space or +/-), so they are
|
||||
* never mistaken for a new section.
|
||||
*/
|
||||
export function parseDiffHunks(diff: string): DiffHunk[] {
|
||||
const lines = diff.replace(/\r\n/g, '\n').split('\n');
|
||||
const hunks: DiffHunk[] = [];
|
||||
let currentFile = '';
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
|
||||
const fileHeader = /^diff --git a\/(.*) b\/(.*)$/.exec(line);
|
||||
if (fileHeader) {
|
||||
currentFile = fileHeader[2]!;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hunkHeader = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/.exec(line);
|
||||
if (hunkHeader) {
|
||||
const body: string[] = [];
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const next = lines[j]!;
|
||||
if (/^diff --git /.test(next) || /^@@ /.test(next)) break;
|
||||
body.push(next);
|
||||
}
|
||||
hunks.push({
|
||||
file: currentFile,
|
||||
header: line,
|
||||
oldStart: Number(hunkHeader[1]),
|
||||
newStart: Number(hunkHeader[2]),
|
||||
body: body.join('\n'),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a hunk for human review, with the file:line anchor the reader needs
|
||||
* to find it in their editor.
|
||||
*/
|
||||
export function renderHunk(h: DiffHunk): string {
|
||||
return [`${h.file}:${h.newStart} ${h.header}`, h.body].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The /diff review output: every hunk of the last turn's changes, one block
|
||||
* per hunk, each headed by its file:line anchor and hunk header.
|
||||
*/
|
||||
export function renderDiffReview(diff: string): string {
|
||||
const hunks = parseDiffHunks(diff);
|
||||
if (hunks.length === 0) return 'no hunks to review';
|
||||
return ['diff review:', ...hunks.map(renderHunk)].join('\n\n');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/** What the session was, for the line printed as shiro exits. */
|
||||
export type Farewell = {
|
||||
id: string;
|
||||
messages: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
/** Long enough to be unique in practice, short enough to retype from the screen. */
|
||||
const PREFIX = 8;
|
||||
|
||||
const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 3)}...` : s);
|
||||
|
||||
/**
|
||||
* The exit message: good bye, and how to pick this session up again.
|
||||
*
|
||||
* A session's id is a UUIDv7 nobody retypes, so the resume line shows the prefix
|
||||
* `resolveId` accepts alongside `-c`, which needs no id at all. An empty session was
|
||||
* never persisted, so it gets no resume command — pointing someone at `-c` that finds
|
||||
* nothing is worse than saying nothing.
|
||||
*/
|
||||
export function farewell({ id, messages, title }: Farewell): string {
|
||||
if (messages === 0) return 'Good bye. Nothing to save from this session.';
|
||||
|
||||
const count = `${messages} message${messages === 1 ? '' : 's'}`;
|
||||
const named = title && title !== 'untitled' ? `: "${clip(title, 52)}"` : '';
|
||||
|
||||
return [
|
||||
'Good bye.',
|
||||
`Saved ${count}${named}`,
|
||||
'',
|
||||
'Resume it with:',
|
||||
` shiro -c newest session in this directory`,
|
||||
` shiro -r ${id.slice(0, PREFIX)} this session by id`,
|
||||
].join('\n');
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, statSync, readdirSync } from 'node:fs';
|
||||
import { z } from 'zod';
|
||||
import type { Plugin, ToolCallContext } from './plugins';
|
||||
|
||||
/**
|
||||
* External hooks: executables that sit in the tool loop.
|
||||
*
|
||||
* A `.shiro/hooks/<name>/` directory (or a single `.shiro/hooks/<name>` file
|
||||
* next to a `manifest.json`) declares an executable that receives one JSON
|
||||
* object on stdin and writes one on stdout.
|
||||
*
|
||||
* Two kinds:
|
||||
* - `pre_tool`: can ALLOW with a rewritten input, or BLOCK with a reason.
|
||||
* - `after_turn`: a notification; output is ignored.
|
||||
*
|
||||
* Trust: code from disk is code, so the first time a hook is seen its sha256
|
||||
* is shown and the user approves or denies it. The approval is recorded in
|
||||
* `~/.shiro-neko/hooks.json` keyed by hash. A hook whose hash changed since
|
||||
* approval is refused until re-approved. A hook that was never approved is
|
||||
* refused.
|
||||
*
|
||||
* A hook can never bypass the compiled guard's deny rules — it runs after
|
||||
* them — but it can rewrite input, so approval is a real decision, not
|
||||
* ceremony.
|
||||
*/
|
||||
|
||||
export type HookKind = 'pre_tool' | 'after_turn';
|
||||
|
||||
export type HookManifest = {
|
||||
name: string;
|
||||
hook: HookKind;
|
||||
/** Tool names this affects; omit or ["*"] for all. */
|
||||
tools?: string[];
|
||||
/** Seconds before the hook is killed. Default 5. */
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
const manifestSchema = z.object({
|
||||
name: z.string().min(1).max(40).regex(/^[a-z0-9][a-z0-9-]*$/),
|
||||
hook: z.enum(['pre_tool', 'after_turn']),
|
||||
tools: z.array(z.string().min(1).max(60)).max(100).optional(),
|
||||
timeout: z.number().int().min(1).max(60).optional(),
|
||||
});
|
||||
|
||||
export function parseHookManifest(source: string): HookManifest {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(source);
|
||||
} catch {
|
||||
throw new Error('the hook manifest is not valid JSON');
|
||||
}
|
||||
const parsed = manifestSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`the hook manifest is malformed: ${parsed.error.issues[0]?.message ?? 'unknown reason'}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
const HASH_STORE = () => join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', 'hooks.json');
|
||||
|
||||
type HashRecord = { hash: string; approvedAt: string };
|
||||
|
||||
export type HookApprovalStore = {
|
||||
/** Returns true when this exact hash was previously approved. */
|
||||
isApproved: (hash: string) => boolean;
|
||||
/** Records an approval; never throws (a broken store degrades to refused). */
|
||||
approve: (hash: string) => void;
|
||||
};
|
||||
|
||||
export function loadApprovalStore(): HookApprovalStore {
|
||||
let records: Record<string, HashRecord> = {};
|
||||
const path = HASH_STORE();
|
||||
try {
|
||||
records = JSON.parse(readFileSync(path, 'utf8')) as Record<string, HashRecord>;
|
||||
} catch {
|
||||
records = {};
|
||||
}
|
||||
return {
|
||||
isApproved: (hash) => records[hash]?.hash === hash,
|
||||
approve: (hash) => {
|
||||
records[hash] = { hash, approvedAt: new Date().toISOString() };
|
||||
try {
|
||||
const dir = join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko');
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(records, null, 2));
|
||||
} catch {
|
||||
// a read-only home keeps refusing; the hook simply stays unapproved
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type LoadedHook = {
|
||||
manifest: HookManifest;
|
||||
/** Absolute path to the executable. */
|
||||
path: string;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
/** sha256 of a file's bytes — the trust anchor for "this exact code ran". */
|
||||
export function hashFile(path: string): string {
|
||||
const bytes = readFileSync(path);
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers hooks from `.shiro/hooks/` (project) and `~/.shiro-neko/hooks/`
|
||||
* (user). Layout: either `<name>/manifest.json` with an executable `run`
|
||||
* beside it, or `<name>.json` + `<name>` executable.
|
||||
*/
|
||||
export async function loadHooks(cwd: string): Promise<LoadedHook[]> {
|
||||
const found: LoadedHook[] = [];
|
||||
const seen = new Set<string>();
|
||||
const candidates: string[] = [];
|
||||
for (const dir of [join(cwd, '.shiro', 'hooks'), join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', 'hooks')]) {
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (seen.has(e)) continue;
|
||||
seen.add(e);
|
||||
candidates.push(join(dir, e));
|
||||
}
|
||||
}
|
||||
for (const full of candidates) {
|
||||
try {
|
||||
const base = full;
|
||||
const isDir = existsSync(base) && statSync(base).isDirectory();
|
||||
let manifestPath: string;
|
||||
let runPath: string;
|
||||
if (isDir) {
|
||||
manifestPath = join(base, 'manifest.json');
|
||||
runPath = join(base, 'run');
|
||||
} else {
|
||||
// `<name>.json` manifest next to `<name>` executable
|
||||
if (!full.endsWith('.json')) continue;
|
||||
manifestPath = full;
|
||||
runPath = full.replace(/\.json$/, '');
|
||||
}
|
||||
const manifest = parseHookManifest(readFileSync(manifestPath, 'utf8'));
|
||||
if (!existsSync(runPath)) throw new Error(`hook executable missing: ${runPath}`);
|
||||
try {
|
||||
chmodSync(runPath, 0o755);
|
||||
} catch {
|
||||
// a non-posix filesystem may not support chmod; the spawn will tell us
|
||||
}
|
||||
found.push({ manifest, path: runPath, hash: hashFile(runPath) });
|
||||
} catch (e) {
|
||||
// a broken hook is reported and skipped, never fatal (same as plugins)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export type PreToolOutcome =
|
||||
| { allow: true; input?: unknown }
|
||||
| { allow: false; reason: string };
|
||||
|
||||
/**
|
||||
* Runs a pre_tool hook: feeds {tool, input, cwd} on stdin, expects
|
||||
* {"allow":true,"input"?} or {"allow":false,"reason"} on stdout.
|
||||
* Anything else — a crash, a timeout, garbage — blocks the call.
|
||||
*/
|
||||
export async function runPreTool(hook: LoadedHook, ctx: ToolCallContext, timeoutMs: number): Promise<PreToolOutcome> {
|
||||
const input = JSON.stringify({ tool: ctx.toolName, input: ctx.input ?? null, cwd: ctx.cwd });
|
||||
const result = await spawnHook(hook, input, timeoutMs);
|
||||
if (!result.ok) return { allow: false, reason: result.error };
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout) as unknown;
|
||||
if (parsed !== null && typeof parsed === 'object') {
|
||||
const o = parsed as Record<string, unknown>;
|
||||
if (o['allow'] === true) {
|
||||
return { allow: true, ...(o['input'] !== undefined ? { input: o['input'] } : {}) };
|
||||
}
|
||||
if (o['allow'] === false) {
|
||||
return { allow: false, reason: typeof o['reason'] === 'string' ? o['reason'] : 'hook refused the call' };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to block
|
||||
}
|
||||
return { allow: false, reason: `hook "${hook.manifest.name}" returned invalid output; call blocked` };
|
||||
}
|
||||
|
||||
/** Runs an after_turn hook; output is ignored, failures are swallowed. */
|
||||
export async function runAfterTurn(hook: LoadedHook, summary: unknown, timeoutMs: number): Promise<void> {
|
||||
await spawnHook(hook, JSON.stringify({ summary }), timeoutMs);
|
||||
}
|
||||
|
||||
type SpawnResult = { ok: true; stdout: string } | { ok: false; error: string };
|
||||
|
||||
async function spawnHook(hook: LoadedHook, input: string, timeoutMs: number): Promise<SpawnResult> {
|
||||
try {
|
||||
const proc = Bun.spawn([hook.path], {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
// FileSink: write the whole payload then close, exactly once.
|
||||
proc.stdin.write(input);
|
||||
proc.stdin.end();
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}, timeoutMs);
|
||||
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
clearTimeout(timer);
|
||||
const exit = await proc.exited;
|
||||
if (exit !== 0) {
|
||||
return { ok: false, error: `hook "${hook.manifest.name}" exited ${exit}: ${stderr.trim().slice(0, 300) || 'no stderr'}` };
|
||||
}
|
||||
return { ok: true, stdout };
|
||||
} catch (e) {
|
||||
return { ok: false, error: `hook "${hook.manifest.name}" failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Plugin from the loaded hooks, so the existing guard runs them in
|
||||
* order after the compiled plugins and before the permission check.
|
||||
*/
|
||||
export function hooksToPlugin(hooks: LoadedHook[], store: HookApprovalStore, opts?: { timeoutMs?: number }): Plugin {
|
||||
const timeoutMs = opts?.timeoutMs ?? 5_000;
|
||||
const byName = new Map<string, LoadedHook>();
|
||||
for (const h of hooks) byName.set(h.manifest.name, h);
|
||||
const preTools = hooks.filter((h) => h.manifest.hook === 'pre_tool');
|
||||
const afterTurns = hooks.filter((h) => h.manifest.hook === 'after_turn');
|
||||
|
||||
return {
|
||||
name: 'external-hooks',
|
||||
description: 'runs approved executables in the tool loop',
|
||||
beforeToolCall: async (ctx: ToolCallContext): Promise<string | undefined> => {
|
||||
for (const hook of preTools) {
|
||||
const tools = hook.manifest.tools;
|
||||
if (tools && !tools.includes('*') && !tools.includes(ctx.toolName)) continue;
|
||||
if (!store.isApproved(hook.hash)) {
|
||||
return `hook "${hook.manifest.name}" (sha256 ${hook.hash.slice(0, 12)}…) has not been approved. Approve it in ~/.shiro-neko/hooks.json after reviewing the code, or remove it from .shiro/hooks/.`;
|
||||
}
|
||||
const outcome = await runPreTool(hook, ctx, timeoutMs);
|
||||
if (!outcome.allow) return outcome.reason;
|
||||
if (outcome.input !== undefined) {
|
||||
// Rewriting input inside a guard is not possible with the current
|
||||
// PluginHost shape (beforeToolCall returns a block reason or nothing).
|
||||
// We surface the rewrite as a notice instead: the hook allowed the
|
||||
// call, and the model sees the suggestion in the transcript.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
afterTurn: async () => {
|
||||
for (const hook of afterTurns) {
|
||||
try {
|
||||
await runAfterTurn(hook, { done: true }, timeoutMs);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Loaded 50 of 180 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user