Compare commits

...
50 Commits
Author SHA1 Message Date
asepharyana 97834e1411 Merge remote-tracking branch 'refs/remotes/upstream/main'
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s
# Conflicts:
#	ROADMAP.md
#	TODO.md
#	src/cli.tsx
#	src/config.ts
#	src/mcp.ts
#	src/permission.ts
#	src/prompt.ts
#	src/session.ts
#	src/snapshot.ts
#	src/subagent.ts
#	src/tools-extra.ts
#	src/tools.ts
#	src/ui/App.tsx
#	test/mcp.test.ts
#	test/prune.test.ts
#	test/session.test.ts
#	test/tools.test.ts
2026-09-21 20:43:35 +07:00
Muhammad Zakir Ramadhan 4eafc064bf fix release.yml: install bun in the publish job before composing notes
The publish job runs make-release-notes.ts with bun but never installed it,
so on a bare runner it failed with 'bun: command not found' and no release was
ever published after the binaries were built.
2026-09-17 19:33:29 +07:00
Muhammad Zakir Ramadhan 8eebc0f4d6 release 1.0.1: fix resume showing a blank transcript
A resumed session loaded its wire messages but never rebuilt the on-screen
history, so -r/-c and /resume rendered a blank transcript. Convert the stored
messages back into transcript lines and seed the panel with them at mount and
after /resume.
2026-09-17 19:26:42 +07:00
Muhammad Zakir Ramadhan 052423556a fix release.yml: pin bun-version literally so the workflow pin test passes
The release workflow hoisted bun-version into an env var, but test/cli.test.ts asserts
both workflows carry the literal 'bun-version: 1.3.14' pin. Revert the indirection - the
pin is the point.
2026-09-17 18:15:02 +07:00
Muhammad Zakir Ramadhan f8c3cc2d8e Ship the batch: undo, parallel subagents, lazy MCP, hot-reloaded skills; docs and CI/CD
Loop and ergonomics batch across Now/Next and Maintenance:
- /undo and /redo via pre-prompt file snapshots (snapshot.ts)
- task takes a tasks[] array and runs investigations concurrently (subagent.ts)
- lazy MCP tools: mcp_list/mcp_inspect/mcp_call meta-tools, eager opt-in (mcp.ts, config.ts)
- skill tool reads its list live so a mid-session install is callable next turn (skills.ts)
- tool-name lists (tool-kinds.ts) derived from a mutating() marker; gates previously ungated writes
- prune/session recovery path summarized, and step-back doom-loop primitive (step-back.ts)
- @file completion re-walks on a slow cooldown; estimateTokens and pricing labeled as estimates

Docs: README, CHANGELOG, docs/{mcp,architecture,development} updated to match.
CI/CD: bun install-store cache and concurrency gates on both workflows; release.yml now
composes file-based release notes via scripts/make-release-notes.ts and verifies every binary.
2026-09-17 17:49:19 +07:00
asepharyana de89457cf7 feat: add diagnostics tool for live command output monitoring
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s
- Implemented diagnostics functionality to start, stop, and monitor background check commands.
- Created a DiagnosticsPanel for real-time output display in the UI.
- Added support for auto-detecting default diagnostics commands based on project configuration.
- Introduced run_checks tool to execute project verification commands and report results.
- Enhanced tools-extra with functions to parse AGENTS.md and package.json for check commands.
- Added diff review functionality to visualize changes made in the last turn.
- Implemented tests for diagnostics and run_checks functionalities to ensure reliability.
2026-09-11 22:09:19 +07:00
asepharyana b0da909454 feat: prioritize batched reads for turn efficiency
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s
Move read_many_files to core tool set so it is always offered. Strengthen
system-prompt guidance: read_file now redirects to read_many_files for
multiple files, read_many_files is framed as the primary reading tool with
an explicit batch range (2-20). Add a 'How to work' rule on read
efficiency, and a read-batching instruction in the deep agent variant.
861 tests pass; build clean.
2026-09-11 19:06:04 +07:00
asepharyana 7d77408a41 feat: implement auto-scaffolding for project workflow files in bare repos
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s
2026-09-11 18:52:50 +07:00
asepharyana 44bb4bebf1 feat: enhance nudge logic to suppress notifications during todo_write 2026-09-11 18:52:50 +07:00
asepharyana 76043aeb38 Implement background command support for bash tool
- Added `background` option to `bash` tool to allow long-lived commands to run without blocking the turn.
- Introduced `bash_status` tool to check the status and output of background commands.
- Added `bash_stop` tool to explicitly stop running background commands.
- Updated command parsing to handle `/bash` commands for listing, stopping individual, and stopping all background commands.
- Enhanced session management to reap stale background commands on agent shutdown.
- Updated UI to reflect background command status and allow user interruption via ctrl-c.
- Added tests for background command functionality and command parsing.
- Documented new features and usage in background-bash.md.
2026-09-11 18:52:50 +07:00
asepharyana deeb138cd9 feat: add dependabot-auto-merge.yml
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s
2026-09-10 03:00:58 +07:00
asepharyana c7184b6d2f feat: add dependabot.yml 2026-09-10 03:00:57 +07:00
asepharyana 14d603f296 build: add production flag to build and release scripts
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s
2026-09-09 20:53:30 +07:00
asepharyana 7a1e415fea feat: implement provider-side prompt caching and workflow scaffolding
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s
- Added support for caching the stable prefix of the system prompt for Anthropic models.
- Introduced a new option `cacheSystemPrefix` in SessionOptions to enable caching.
- Implemented a nudge escalation system that reminds users to update the TODO.md file, capped at three nudges.
- Created a new `scaffoldWorkflowFiles` function to generate TODO.md, ROADMAP.md, and docs/ directory when they are missing.
- Updated the App component to scaffold workflow files during initialization.
- Added hooks functionality to allow external scripts to modify tool input and manage approvals.
- Implemented tests for the new hooks functionality, ensuring proper approval and execution flow.
2026-09-09 20:11:23 +07:00
asepharyana 4aeb0d2455 workflow: polish — /context groups trackers, /workflow in README, memoize git root
- contextPanel splits 'instructions' from 'trackers' (TODO.md/ROADMAP.md), so
  /context no longer presents project trackers as standing orders, and its
  empty-state mentions both instruction files and trackers
- README: docs table gets Project workflow, command list gets the new commands
- session: gitRoot memoized (one sync fs walk per session, not per prompt
  build); workflowPolicy computed once per systemFor
- test: context panel grouping covered

10 workflow tests, 830+ suite green
2026-09-09 19:12:29 +07:00
asepharyana a3739eb628 workflow: project-driven agent workflow (docs-first, TODO/ROADMAP tracking, verify-before-done)
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s
- system prompt gains a Project workflow block when the repo tracks its own
  progress (TODO.md/ROADMAP.md/docs): read the task list first and keep it
  current, spec-first for non-trivial work, complete unit tests, verify before
  declaring done
- TODO.md + ROADMAP.md loaded as 'Project tracker' instruction blocks, capped
  tighter than AGENTS.md so the agent sees the shape without drowning in it
- one soft nudge per session when a turn writes files without touching the
  task list; suppressed when todo_write was called; never a gate
- /workflow command + panel showing TODO/ROADMAP/docs presence, line/file
  counts, nudge state
- config: workflow.enabled (default true), workflow.docsDir (default docs)
- prompt-cache version key extended with wf: so toggling the workflow busts
  the cached system prompt
- docs: docs/workflow.md, configuration table row, ROADMAP + TODO entries
- tests: test/workflow.test.ts (9 tests)

830 tests pass, typecheck clean, build green, live headless demo verified
2026-09-09 19:00:22 +07:00
asepharyana e709737df1 features: /changes, /search, /fork, web_search, prompt memoization, per-turn spend cap, workspace refresh
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s
- /changes diffs the last turn's file snapshot (added/modified/deleted), reusing undo infra via SnapshotStack.peek()
- system prompt memoized behind version counters (notebook/memory/skills/plugins/tools/workspace); hit-rate in /cost, foundation for provider caching
- web_search: DuckDuckGo Lite, keyless, 5 results, SSRF-filtered, in the net set with ask permission
- /search <query>: full-text grep over saved sessions incl. tool-input JSON
- workspace file list re-walks at a turn boundary after writes
- maxSpendPerTurn: per-turn cap stops a runaway step with a notice
- /fork: branch the session at the last turn boundary, original untouched

821 tests pass, typecheck clean, build green
2026-09-09 18:06:31 +07:00
asepharyana 0bdf642672 prompt: inject full ignore-aware file list at boot (gitignore-respected, capped 5000)
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s
walk() at boot -> Session.workspaceFiles -> systemPrompt Environment
section + subagent system prompts. read_many_files still on-demand,
but the agent now sees the tree without a tool call.
2026-09-09 14:19:29 +07:00
asepharyana b7c3490993 TODO: archive landed items, empty Now/Next/Maintenance
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s
2026-09-09 13:56:26 +07:00
asepharyana 4b4ddd0585 @: directories complete, not just files
walk({includeDirs}) yields src/ and src/ui/ with trailing slash, only
on the @ path. matchPaths ranks dirs before files for the same prefix
so @src/ surfaces the directory itself. Insertion keeps the slash.
2026-09-09 13:55:02 +07:00
asepharyana 5028ea6973 config: warn on unknown toolSets typo instead of dropping silently
unknownToolSetNames() + startup notice (stderr headless, NoticeBus
interactive) flags gti vs git typos at boot, not as that set off.
2026-09-09 13:28:24 +07:00
asepharyana dcede3c10a Maintenance: pricing date, token est. label, listPaths refresh, MUTATING derive 2026-09-09 12:43:03 +07:00
asepharyana 4f3a7eef7c TODO Next: undo a turn — /undo /redo + per-turn snapshots
File tools record before-write state via snapshot hook; Session
captures beforeFiles at turn start and afterFiles at turn end (cap
100 via SnapshotStack), and undo/redo restore files + messages
together. Bash not snapshotted (docs + notice).
2026-09-09 11:49:33 +07:00
asepharyana 7edaeafca0 TODO Next: subagent parallelism (task tasks batch)
task now accepts { description, prompt, kind } or
{ tasks: TaskSpec[], kind? } (up to 8) and fans out with
Promise.all. Each subagent keeps its own context window and
the progress panel receives start/step/result/end per id, so
independent searches overlap in wall time instead of queueing.

- src/subagent.ts: runOne extracted, createTaskTool uses union
  schema (single | batch), batch validates worker channel once
  then Promise.all, results joined as headings.
- docs/agents.md: delegation section notes tasks batch.
- 800 pass (added subagent-parallel.test.ts).
2026-09-09 10:51:01 +07:00
asepharyana 2587e03beb TODO Next: MCP without the schema tax (phi meta-tools)
- mcpExpose=phi|direct|auto on RawConfig + MCPConfig; phi default keeps
  direct as a measured option for small servers (2-tool cheaper direct)
- src/mcp.ts: buildMcpMetaTools + phi branch in connectMcp: lazy
  list/inspect/call behind 3 fixed schemas instead of N per-tool schemas;
  direct branch kept; bindMcpGuard wires permission+PluginHost guard for
  MCP calls through the same gate as built-ins
- prompt mcpServers names-only under phi; under direct schemas travel
  as before — 20-tool server ~2750 tok -> ~phi (names) until mcp_call
- session: isDirect mcpServerNames non-enumerable marker, activeTools
  hides mcp_call from pre-wired rules when inside mcp_call, /tools
  shows mcp_call and Enabled/Withheld reflects the 3 meta-names under phi
- permission: mcp_* as read (free), mcp_call mutating keyed by
  server.tool, same top-level suppression + ask-to-approve as other
  mutating tools
- cli: bindMcpGuard + /mcp list shows exposure + mcp_* listed;
  headless denies line mentions mcp_list
- commit.ts: git_commit_message kept in git set via toolSetOf/
  disabledToolNames overlay

Tests: 798 pass, 0 fail; tsc exit 0; mcp.test.ts covers phi stays
empty until mcp_call and direct still namespaced.
2026-09-09 10:33:18 +07:00
asepharyana 626450eb06 TODO Next: derive TOOL_SETS and MUTATING_TOOLS from tool definitions
Wrap every builtin tool with withMeta({set, mutating}) at its definition
site (src/tool-utils.ts). TOOL_SETS and MUTATING_TOOLS are now derived
via setsFrom/mutatingNames rather than hand-lists, so a new write cannot
be added ungated by forgetting a parallel list. Permission defaults now
cover the 5 extra mutating line-edit tools. Test suite covers coverage,
derived-equality, and mutating consistency (test/tool-derive.test.ts).
2026-09-09 00:29:23 +07:00
asepharyana c125cf36fa TODO Now: summarize pruned span + hot-reload installed entries
Summarize pruned span: droppedSpan + summarizeDiscarded (6k excerpt,
3-6 lines, one call per compaction) with retained note already landed;
flip TODO Now checkboxes to [x].

Hot-reload: Session.updateSkills/updatePlugins with pendingSkills/
pendingHost deferred during a turn, drainPendingHotReload at turn
boundary, live skill tool closure, cli.tsx rebuilds host from
builtin+registry+external on /registry add/remove. Guard now uses
live pluginHost. Test mid-session skill callability and plugin
enforcement without restart.

Spec: .hermes/plans/todo-now-next.md
2026-09-08 23:54:37 +07:00
asepharyana 4848db308a harness: auto-skill general + memory spesifik + lossless compaction
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s
Memory balik spesifik per-project (keep paths, THIS repo), general jadi
skill auto-create ke ~/.shiro-neko/skills/auto-*.md. Compaction lossless
dengan head preserved + droppedSpan normalized + retained note. Learner
throttled tiap 3 turn / delta 8, pakai subagentModel (learnerModel) dan
ceiling guard, hash dedup, JSON fallback, TTL tiap 6 turn.
2026-09-08 22:52:31 +07:00
asepharyana 7db69f19da Enhance memory management and compaction features
- Implement global memory layer for cross-project patterns.
- Improve memory entry scoring with recency and tokenization.
- Adjust MAX_TEXT limit from 400 to 800 for better context retention.
- Add TTL pruning for stale memory entries.
- Capture and summarize dropped content during compaction.
- Update tests to reflect changes in memory behavior and compaction logic.
2026-09-08 21:27:47 +07:00
asepharyana b1ff00d840 Merge upstream zakirkun/shiro-neko (v1.0.0: cost control, 41 tools, 29 skills, custom commands, auto-load)
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s
Keeps local claude-code preset (readClaudeCodeSettings from ~/.claude/settings.json) merged with upstream Pickers refactor.
Resolved Onboard.tsx: both readClaudeCodeSettings + Frame/Row imports.
2026-09-08 20:11:55 +07:00
Muhammad Zakir Ramadhan ffa9a02c26 release 1.0.0: cost control, 41 tools, 29 skills, custom commands, auto-load 2026-09-07 19:59:58 +07:00
Muhammad Zakir RamadhanandSisyphus a22d8e13b1 release 0.1.0-beta.5
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:42:16 +07:00
Muhammad Zakir RamadhanandSisyphus 6a168838c6 File the new work under a 0.1.0-beta.5 release note
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:39:55 +07:00
Muhammad Zakir RamadhanandSisyphus 397c4355f3 Document the new tools, plugins, skills, MCP panel, and item repair
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:32:32 +07:00
Muhammad Zakir RamadhanandSisyphus 2dc7b91c29 Wire the new tools, the mcp hooks, and the farewell into the app
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:32:14 +07:00
Muhammad Zakir RamadhanandSisyphus 5feec4b5c7 Print resume instructions on the way out
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:31:56 +07:00
Muhammad Zakir RamadhanandSisyphus e06fbc3050 Add the /mcp wizard for local and remote servers
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:31:38 +07:00
Muhammad Zakir RamadhanandSisyphus e23ec053ae Show elapsed working time, a compaction warning, and dismiss hints
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:31:19 +07:00
Muhammad Zakir RamadhanandSisyphus 3ef2d8a75a Number diff lines, render task lists, and add word navigation
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:31:00 +07:00
Muhammad Zakir RamadhanandSisyphus 940a1e7401 Draw Onboard with the shared frame
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:30:38 +07:00
Muhammad Zakir RamadhanandSisyphus 744763050b Split the transcript, buses, panels, approval, and pickers out of App
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:30:19 +07:00
Muhammad Zakir RamadhanandSisyphus a4edbcc71e Retry a dead provider item inline instead of ending the turn
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:30:00 +07:00
Muhammad Zakir RamadhanandSisyphus 4f6ed43fed Send a compacted history inline, with no provider item references
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:29:40 +07:00
Muhammad Zakir RamadhanandSisyphus a8a3ffcb5c Describe the new tools in the system prompt
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:29:20 +07:00
Muhammad Zakir RamadhanandSisyphus 9c4316e562 Bundle the security, perf, and migrate skills
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:29:01 +07:00
Muhammad Zakir RamadhanandSisyphus 26d62bc926 Add the protect plugin, refusing writes to tool-owned files
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:28:39 +07:00
Muhammad Zakir RamadhanandSisyphus 551e518711 Gate the new tools, matching a move at both ends
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:28:18 +07:00
Muhammad Zakir RamadhanandSisyphus e8b8b06a4b Add git_commit_message, written from the staged diff
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:28:00 +07:00
Muhammad Zakir RamadhanandSisyphus 6b67465723 Add git_branch and export the git runner
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:27:43 +07:00
Muhammad Zakir RamadhanandSisyphus 16d27e1611 Add move_file and delete_file, and flag a collapsed rewrite
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-05 17:26:49 +07:00
180 changed files with 17709 additions and 1750 deletions

No files matched your search

+17
View File
@@ -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 }}
+18
View File
@@ -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"
+17
View File
@@ -5,6 +5,10 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
check: check:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
@@ -16,9 +20,22 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest] os: [ubuntu-latest, macos-latest, windows-latest]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2 - uses: oven-sh/setup-bun@v2
with: with:
bun-version: 1.3.14 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 install --frozen-lockfile
- run: bun run typecheck - run: bun run typecheck
- run: bun test - run: bun test
+42 -4
View File
@@ -6,10 +6,14 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
dry_run: dry_run:
description: Build the artifacts without publishing a release description: Build and verify the artifacts without publishing a release
type: boolean type: boolean
default: true default: true
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions: permissions:
contents: write contents: write
@@ -21,6 +25,13 @@ jobs:
- uses: oven-sh/setup-bun@v2 - uses: oven-sh/setup-bun@v2
with: with:
bun-version: 1.3.14 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 install --frozen-lockfile
- run: bun run typecheck - run: bun run typecheck
- run: bun test - run: bun test
@@ -33,14 +44,25 @@ jobs:
- uses: oven-sh/setup-bun@v2 - uses: oven-sh/setup-bun@v2
with: with:
bun-version: 1.3.14 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 install --frozen-lockfile
# Bun cross-compiles every target from one host, so no build matrix is needed. # 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. # release.ts also fails the build when the tag and src/version.ts disagree.
- run: bun run release - 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: | 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 chmod +x dist/release/shiro-linux-x64
./dist/release/shiro-linux-x64 --version ./dist/release/shiro-linux-x64 --version
./dist/release/shiro-linux-x64 --version | grep -q "$(bun -e 'console.log((await import("./src/version.ts")).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: publish:
needs: build 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 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
name: shiro-binaries name: shiro-binaries
path: dist/release 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 - name: Publish the release
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
gh release create "${{ github.ref_name }}" \ gh release create "${{ github.ref_name }}" \
--title "shiro-neko ${{ github.ref_name }}" \ --title "shiro-neko ${{ github.ref_name }}" \
--generate-notes \ --notes-file release_notes.md \
$([[ "${{ github.ref_name }}" == *-* ]] && echo --prerelease) \ $([[ "${{ github.ref_name }}" == *-* ]] && echo --prerelease) \
dist/release/* dist/release/*
+51
View File
@@ -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.
+162
View File
@@ -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.
+20
View File
@@ -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
+34
View File
@@ -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.
+39
View File
@@ -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.
+37
View File
@@ -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.
+83
View File
@@ -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
```
+131
View File
@@ -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
+134
View File
@@ -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
+64
View File
@@ -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.
+265
View File
@@ -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
View File
@@ -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
+41 -20
View File
@@ -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/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 agent: default thinking: medium
cwd: /home/you/project cwd: /home/you/project
skills: commit, debug, refactor, review, test, verify skills: commit, debug, docs, migrate, perf, plan, refactor, review, security, test, verify
plugins: guard, time 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, bash, web_fetch, mcp__* approvals: ask for write_file, edit_file, multi_edit, apply_patch, move_file, delete_file, bash, web_fetch, mcp__*
/help for commands /help for commands
> why does the pagination test fail? > 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 plugin refuses irreversible commands ahead of any of it — `rm -rf`, `git reset --hard`, force
pushes, `DROP TABLE` — and `--yolo` cannot bypass it. 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 **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` 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 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, **Asks instead of guessing.** When a request has two readings that lead to different work,
the agent puts a question on screen with options. the agent puts a question on screen with options.
**Delegates work.** `task` spawns a subagent with its own context window whose findings come **Delegates work, in parallel.** `task` spawns a subagent with its own context window whose
back as one message, so a search across forty files does not fill the main context. `explore` findings come back as one message, so a search across forty files does not fill the main
and `review` are read-only; `worker` also edits and runs commands, and every one of its writes context. A single call can batch several independent investigations under `tasks`: they run on
stops at the same approval prompt as yours. Progress streams to a panel. 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 **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; 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 **Remembers between sessions.** Decisions, working commands, and traps go into per-project
memory that is injected at the start of every future session. 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. **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 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 ## Documentation
@@ -130,13 +142,17 @@ the flags are.
| [Skills](docs/skills.md) | the bundled skills, writing your own, why the catalogue is split | | [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 | | [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 | | [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 | | [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 | | [MCP](docs/mcp.md) | connecting servers, namespacing, cost, debugging one |
| [Headless mode](docs/headless.md) | `-p`, JSON events, exit codes, CI recipes | | [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 | | [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 | | [Development](docs/development.md) | building, testing, adding a tool, releasing |
| [Roadmap](ROADMAP.md) | what is next and what has been declined | | [Roadmap](ROADMAP.md) | what is next and what has been declined |
| [TODO](TODO.md) | the current work list, with known rough edges | | [TODO](TODO.md) | the current work list, with known rough edges |
| [Changelog](CHANGELOG.md) | release history, newest first |
## Commands ## Commands
@@ -144,9 +160,10 @@ Type `/` and a menu appears, narrowing as you type.
``` ```
/help /agent [name] /think [level] /provider /models /model <id> /help /agent [name] /think [level] /provider /models /model <id>
/skills /plugins /registry [search|add|remove] /init /context /skills /plugins /registry [search|add|remove] /mcp [add|remove] /init /context
/todos /notes /memory /tools /compact /cost /todos /notes /memory /tools /compact /cost /undo /redo
/sessions /resume <id> /save /clear /exit /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 `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 ## Status
Working: the agent loop, tool approvals, subagents including the gated `worker` kind, skills, Version 1.0 is stable. Working: the agent loop, per-call and per-command tool approvals with a
plugins, per-project memory, session persistence, MCP, markdown rendering, headless mode, guard that `--yolo` cannot bypass, subagents including the gated `worker` kind, a spend ceiling
five-platform builds, streaming reasoning display, the mid-turn prompt queue, gateable tool (`maxSpendUsd`) with a cheaper subagent model (`subagentModel`), 41 built-in tools across
sets, read-only git tools, batch reads, `apply_patch`, `web_fetch`, `@file` completion, gateable sets, 29 bundled skills, built-in and data-only plugins, per-project memory, session
interruptible commands, and the external registry. 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 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 [ROADMAP.md](ROADMAP.md); the release history is in [CHANGELOG.md](CHANGELOG.md).
discarded, a spend ceiling, and a cheaper model for subagent searches.
## License ## License
+306 -37
View File
@@ -9,6 +9,43 @@ Nothing here is a date. Items move to [TODO.md](TODO.md) when they are next up.
## Shipped ## 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 ### 0.1.0-beta.1
**Core loop** — `streamText` with tool approvals suspended and resumed through the SDK's **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 ## 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 ### MCP without the schema tax
Every MCP tool's schema is in the prompt on every request, and `toolSets` does not gate them: a 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 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. 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 ### Undo a turn
opencode has `/undo` and `/redo`, Claude Code has `/rewind` over file checkpoints. There is Every comparable CLI has this: opencode `/undo` and `/redo`, Claude Code `/rewind` with
`/resume` here, which restores a whole session, and nothing that steps one turn back. The honest checkpoints. There is `/resume` here, which restores a session, and nothing that walks one back.
limit is the same for everyone: a `bash` command's effects cannot be snapshotted, so this covers Claude Code's implementation is the one to read first, because it has already worked out the seams:
file-tool edits and says so. 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 ### 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, 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. 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 ### Derived tool metadata
`TOOL_SETS` and `MUTATING_TOOLS` are hand-maintained lists of tool names. A tool added to one `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 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. "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` ### `web_fetch`
Shipped in beta.4 — see above. What remains declined: wrappers around a single bash line with 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 ## 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 **Structured diff review.** Approve or reject individual hunks of an `edit_file` call rather
than the whole thing. 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 step for task-list freshness, which defeats a naive cache; splitting the stable prefix from
the volatile suffix would fix that. the volatile suffix would fix that.
**External hooks.** phi and both first-party CLIs let a script sit in the tool loop: a directory **External hooks.** Every one of the four surveyed except here lets a script sit in the tool loop:
with a manifest and an executable, one JSON object in on stdin, one out. phi's `pre_tool` can a directory with a manifest and an executable, one JSON object in on stdin, one out. **Both** phi
rewrite the tool's input as well as allow or deny, which the compiled plugin interface here cannot and Codex let a `PreToolUse` hook **rewrite** a tool's input, not merely allow or deny it — phi
express. The reason it is here rather than in Next is that it needs a trust story — Codex hashes returns `{"action":"modify","input":{...}}`, Codex returns `permissionDecision: "allow"` with
each hook and refuses to run one until you review it, which is the right shape and more work than `updatedInput` — and that is the exact capability the compiled plugin interface here cannot
the feature. 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: **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 Seatbelt on macOS, Landlock and seccomp on Linux, a restricted-token/AppContainer mechanism on
egress governed by domain rules. Permission rules gate the *call*; a sandbox governs what the Windows, with network **off** by default under `workspace-write`. Permission rules gate the *call*;
process can then reach. Three platform-specific implementations, and OpenAI moved Codex to Rust a sandbox governs what the process can then reach. Note that even Codex's sandbox is not a complete
partly for this. A half-built sandbox is worse than none, because people would trust it. 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 for that product. Here it would add a protocol, a port, and an auth story to serve a second client
nobody has asked for. nobody has asked for.
**LSP integration.** opencode ships it and its own documentation says the honest thing: **LSP integration.** opencode ships 30+ built-in language servers and its own documentation still
*"not always a net positive... in many projects it is better to have the agent run lint, typecheck, says the honest thing: LSP "is useful in some projects, but it is not always a net positive,"
or other diagnostic CLI tools directly."* Language servers drift out of sync, use real memory, and servers "can get out of sync, use significant memory, vary by version or project, and slow down
vary by version. `bash bun run typecheck` puts the same errors in front of the model with none of agent workflows," and *"in many projects it is better to have the agent run lint, typecheck, or
that, and `AGENTS.md` is where the command belongs. 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.
+64 -129
View File
@@ -8,113 +8,19 @@ Longer-term direction lives in [ROADMAP.md](ROADMAP.md).
## Now ## Now
### Summarize the pruned span _Empty — pick from Known rough edges below._
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
--- ---
## Next ## Next
### MCP without the schema tax _Empty._
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
--- ---
## Maintenance ## Maintenance
- [ ] Pricing table needs a source note and a date; rates drift and ours are hand-entered _All caught up._
- [ ] `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
--- ---
@@ -129,8 +35,6 @@ Not bugs exactly, but things that will bite someone.
injected. `/memory` may merge them, or may keep both. 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 - **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. 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 - **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 `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 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. screen through later ones until the turn ends.
- **An interrupted command's effects are unknown, and the model is told so.** Nothing can know - **An interrupted command's effects are unknown, and the model is told so.** Nothing can know
how far a half-run migration got. 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 - **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 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. 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. Kept for one release, then deleted.
- [x] Reasoning streamed to a collapsed panel, `ctrl-r` to expand, dropped when the turn ends ### 1.0.0 release batch
- [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] A spend ceiling (`maxSpendUsd`): checked before each turn, refused at 100% naming the
- [x] `toolSets` gating, so a disabled set reaches neither the wire nor the prompt ceiling, warns once at 80%, headless exits non-zero. Unpriced models are not enforced
- [x] `multi_edit`, atomic across several edits to one file - [x] A cheaper subagent model (`subagentModel`): `explore` resolves against it, `review` and
- [x] `list_dir`, ignore-aware and depth-limited `worker` keep the parent's, `/cost` splits subagent spend by model id
- [x] Read-only git tools: `git_status` `git_diff` `git_log` `git_show` `git_blame` - [x] Twenty new built-in tools (41 total) in a new `extra` set: line edits, filesystem
- [x] Orphaned tool results dropped during pruning, fixing the 400 "No tool call found for navigation, read-only git extensions, and code/environment reads
function call output with call_id ..." - [x] Twenty new bundled skills (29 total) plus the eleven originals deepened; all moved to
- [x] `read_many_files`, concurrent, one labelled block per file, a bad path reported in place `src/skills-md/*.md` as the Markdown source of truth, embedded at build
- [x] `@file` completion: picker fed by the ignore-aware walker, tab inserts a relative path - [x] Ten new data-only plugins: safety refusals on by default (force push, pipe-to-shell,
- [x] `ctrl-c` kills the running command and keeps the turn. The kill takes the whole process root, env credential writes) and opt-in workflow plugins (conventional commit,
tree: killing `cmd /c` alone left the real command holding both pipes open, so the tests-first, small diffs, main-branch commits, git config, confirm-delete)
interrupt appeared to do nothing for 19 seconds - [x] Custom slash commands from Markdown files, with `$ARGUMENTS`/`$1` and guarded shell
- [x] **Compaction no longer stops the loop.** Pruning used to drop any assistant part whose substitution; a custom command never shadows a built-in
reasoning item it removed, which on a reasoning model is every tool call. The model lost - [x] Auto-loaded external skills, tools, and plugins from `~/.shiro-neko/<kind>` and
its record of what it had run and re-ran it until the step limit. The repair strips the `.shiro/<kind>`, all data, never code; a bad file is reported and skipped
provider `itemId` instead of the part, so the same content is sent inline - [x] The welcome interface redesigned into a structured dashboard with a session banner, a
- [x] `/registry`: browse, search, install, and remove external skills and plugins. Skills are grouped environment panel, and a meta bar; the input in a two-tone box with a split footer
shown in full before install; plugins are a validated manifest of deny rules, never code - [x] The system prompt advanced: a failure-recovery loop, a delegation policy, compaction awareness
- [x] Context shown as a percentage of the compaction threshold, amber at two thirds, red at 90 - [x] The release workflow's dead `dry_run` input wired: manual dispatch publishes only when
- [x] **Permission rules per command and path**, replacing the per-tool list. `bash` was one unchecked, tag pushes always publish
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 ### Post-1.0 — Now / Next / Maintenance (landed)
`.pem` are refused on read, and an identical call repeated three times in a turn asks even
when allowed - [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] **`web_fetch`**, size-capped HTTP(S) to markdown in the opt-in `net` tool set, with - [x] Hot-reload an installed entry — `Session.updateSkills/updatePlugins` + `cli.tsx` hot-reload, `pendingSkills/pendingHost` + `drainPendingHotReload` at turn boundary (`test/hot-reload.test.ts`)
redirect and private-address checks - [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
+6
View File
@@ -14,11 +14,13 @@
"ink-select-input": "6.2.0", "ink-select-input": "6.2.0",
"ink-spinner": "^5.0.0", "ink-spinner": "^5.0.0",
"ink-text-input": "6.0.0", "ink-text-input": "6.0.0",
"pngjs": "^7.0.0",
"react": "19.2.8", "react": "19.2.8",
"zod": "4.5.4", "zod": "4.5.4",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.4.0", "@types/bun": "^1.4.0",
"@types/pngjs": "^6.0.5",
"@types/react": "19.2.18", "@types/react": "19.2.18",
"ink-testing-library": "4.0.0", "ink-testing-library": "4.0.0",
"react-devtools-core": "^7.0.1", "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/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=="], "@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=="], "@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=="], "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": ["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=="], "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
+1 -1
View File
@@ -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 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. 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 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 noise: a mechanical rename across twenty files, a test scaffold written to match an existing
suite, a cleanup whose shape you already know. suite, a cleanup whose shape you already know.
+28 -3
View File
@@ -147,6 +147,10 @@ running, the handler exits as usual.
`task` runs a nested `streamText` and returns one message. The subagent kinds hold different `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. 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: 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 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. 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 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 The SDK carries that returned message view into later steps, and the session reports compaction
once per turn rather than once per step. once per turn rather than once per step.
@@ -234,7 +250,8 @@ the reasoning.
| `session.ts` | the loop, approvals, compaction, event stream | | `session.ts` | the loop, approvals, compaction, event stream |
| `tools.ts` | file and shell tools, tool sets, ripgrep bridge, bash streaming and interrupt | | `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-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 | | `ignore.ts` | gitignore-aware walker, path jail |
| `complete.ts` | `@path` token extraction, ranking, insertion | | `complete.ts` | `@path` token extraction, ranking, insertion |
| `registry.ts` | external index, validation, install and removal | | `registry.ts` | external index, validation, install and removal |
@@ -251,20 +268,28 @@ the reasoning.
| `prune.ts` | provider-item and tool-pairing repair | | `prune.ts` | provider-item and tool-pairing repair |
| `markdown.ts` | parser, no dependency | | `markdown.ts` | parser, no dependency |
| `store.ts` | sessions, prompt history | | `store.ts` | sessions, prompt history |
| `farewell.ts` | the exit message and its resume commands |
| `config.ts` | resolution, model construction | | `config.ts` | resolution, model construction |
| `providers.ts` | presets, `/models` fetch | | `providers.ts` | presets, `/models` fetch |
| `pricing.ts` | USD rates | | `pricing.ts` | USD rates |
| `commands.ts` | slash registry, parsing, menu matching | | `commands.ts` | slash registry, parsing, menu matching |
| `headless.ts` | `-p` mode | | `headless.ts` | `-p` mode |
| `cli.tsx` | argv, wiring, lifecycle | | `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 Every module is pure of the UI except `ui/`, and `ui/` never touches the SDK. The seam is the
`AgentEvent` stream. `AgentEvent` stream.
## Testing ## 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 `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 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 tested against a local HTTP server; the interrupt path spawns a real subprocess and asserts it
+28 -3
View File
@@ -20,8 +20,11 @@ Written by `/provider`, editable by hand. Every field is optional.
"agent": "default", "agent": "default",
"thinking": "medium", "thinking": "medium",
"maxRetries": 3, "maxRetries": 3,
"maxSpendUsd": 5,
"maxSpendPerTurn": 0.5,
"subagentModel": "gpt-5-nano",
"plugins": ["guard", "time"], "plugins": ["guard", "time"],
"toolSets": ["edit-plus", "git"], "toolSets": ["edit-plus", "extra", "git"],
"permission": { "permission": {
"bash": { "*": "ask", "git *": "allow" } "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` | | `agent` | default variant: `default`, `quick`, `deep`, `plan`, `review` |
| `thinking` | default level: `off`, `low`, `medium`, `high`, `max` | | `thinking` | default level: `off`, `low`, `medium`, `high`, `max` |
| `maxRetries` | retries per model call for transient failures. Default 3 | | `maxRetries` | retries per model call for transient failures. Default 3 |
| `plugins` | which builtin plugins to enable. Omit for `["guard", "time"]` | | `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 |
| `toolSets` | optional tool sets beyond `core`: `edit-plus`, `git`, and `net`. Omit for the defaults; `net` is opt-in. See [tools](tools.md) | | `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) | | `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) | | `registryUrl` | index for `/registry`. Omit for the default. See [registry](registry.md) |
| `mcpServers` | see [MCP](mcp.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 ## Provider presets
+76
View File
@@ -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
View File
@@ -16,7 +16,7 @@ faster and the fallback path is exercised without it.
```bash ```bash
bun run shiro # run from source bun run shiro # run from source
bun run typecheck # tsc --noEmit 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 build # single binary for this platform -> dist/shiro
bun run release # all five platforms -> dist/release + SHA256SUMS bun run release # all five platforms -> dist/release + SHA256SUMS
bun run install:local # build, then copy onto PATH bun run install:local # build, then copy onto PATH
@@ -71,6 +71,9 @@ mock-verification test:
request body request body
- `pruneMessages` leaving a tool result without its tool call — same, and it took a stub - `pruneMessages` leaving a tool result without its tool call — same, and it took a stub
endpoint that rejected the pairing to prove the fix 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 - 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 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 terminated because the model chose to, not that the messages had a particular shape
@@ -81,21 +84,23 @@ mock-verification test:
## Adding a tool ## Adding a tool
1. Define it in `src/tools.ts` with a `zod` schema. Descriptions are read by the model, so 1. Define it with a `zod` schema and wrap it with `withMeta({ set, mutating }, tool({…}))`
write them as guidance, not as documentation. at the definition site. `set` is `core | edit-plus | nav | extra | git | net`, `mutating`
2. Add it to the `tools` object. is whether it writes or executes. Example: `src/tools-extra.ts` (`extra`), `src/tools-git.ts`
3. Add it to a set in `TOOL_SETS`. A tool in no set can never be gated off. (`git`), `src/tools-net.ts` (`net`), `src/tools.ts` (everything else). Descriptions are
4. If it mutates anything, add it to `MUTATING_TOOLS` so it requires approval. read by the model, so write them as guidance, not as documentation.
5. Add a line to `TOOL_DOCS` in `src/prompt.ts` saying *when* to reach for it. 2. Add it to the `tools` object (or `extraTools`/`gitTools`/`netTools` — they are merged in
6. If it is read-only, add it to `READ_ONLY` in `src/agents.ts` so `plan` and `review` can use `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. 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 Every tool costs roughly 550 characters of schema on every request. Forty-one built-in tools is
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
past where selection accuracy starts to matter, which is why sets exist and why a new tool 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. 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. 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 $ 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. A binary reporting the wrong version is worse than a failed release.
@@ -137,8 +142,8 @@ To cut one:
```bash ```bash
# bump src/version.ts and package.json to the same value # bump src/version.ts and package.json to the same value
git commit -am "release 0.1.0-beta.4" git commit -am "release 0.1.0-beta.5"
git tag v0.1.0-beta.4 git tag v0.1.0-beta.5
git push --follow-tags 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. `buildArgs()` is unit-tested for both hosts because of exactly that.
`.github/workflows/release.yml` then runs typecheck and tests, cross-compiles all five `.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 targets on one Ubuntu runner, asserts each built binary reports the expected version and is
publishes a GitHub release with the binaries and `SHA256SUMS`. A tag containing `-` is non-empty, and publishes a GitHub release with the binaries and `SHA256SUMS`. A tag containing
published as a prerelease. `-` is published as a prerelease.
Bun cross-compiles from any host, which is why there is no build matrix. Verified: a working Bun cross-compiles from any host, which is why there is no build matrix. Verified: a working
`darwin-arm64` binary builds on Windows. `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 Publishing is gated on a `v*` tag, so a manual `workflow_dispatch` run produces artifacts
without releasing. 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 ## CI
`.github/workflows/ci.yml` runs typecheck, tests, and a build on Ubuntu, macOS, and Windows `.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 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. handling differs — a Windows-only break is invisible on Linux until someone hits it.
+101
View File
@@ -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
View File
@@ -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 -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",...} [tool] write_file {"path":"test/paginate.test.ts",...}
[denied] write_file (run with --yolo to allow tool use in headless mode) [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-start","id":"c1","name":"grep"}
{"type":"tool-call","id":"c1","name":"grep","input":{"pattern":"tool\\("}} {"type":"tool-call","id":"c1","name":"grep","input":{"pattern":"tool\\("}}
{"type":"tool-result","id":"c1","name":"grep","output":"src/tools.ts:26: ..."} {"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} {"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 ## Cost control
Headless runs are unattended, so a runaway loop costs real money. `--agent quick` caps the 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 step count at 12, and `{ "toolSets": [] }` trims the schema sent every request. `maxSpendUsd` in
spend ceiling yet — see [TODO.md](../TODO.md). 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: What a run actually costs is in the `done` event, so a wrapper can total it:
+143 -20
View File
@@ -1,12 +1,102 @@
# MCP # 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 [Model Context Protocol](https://modelcontextprotocol.io) servers contribute tools. Configure
them in `~/.shiro-neko/config.json` and they appear alongside the builtins. them in `~/.shiro-neko/config.json` and they appear alongside the builtins.
## Configuration ## 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 ```json
{ {
"mcpMode": "eager",
"mcpServers": { "mcpServers": {
"fs": { "fs": {
"command": "npx", "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 **Remote** servers take `url`, and optionally `type` (`http` or `sse`, default `http`) and
`headers`. `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 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. 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 `--no-mcp` skips them all, which is also the quickest way to tell whether a slow start is MCP
or something else. 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 ## Naming
Tools arrive as `mcp__<server>__<tool>`. A server named `fs` exposing `read_file` becomes In `lazy` mode (the default) tools are addressed as `mcp_call(server, toolName, args)`; the
`mcp__fs__read_file`. 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 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. 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: 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: 4 tools
mcp db failed: spawn python ENOENT mcp db failed: spawn python ENOENT
``` ```
@@ -79,26 +198,29 @@ Python interpreter should not stop you from editing a file.
## Inspecting ## Inspecting
`/tools` lists everything offered this turn, MCP tools included. The system prompt describes `/tools` lists everything offered this turn. In `eager` mode that includes each MCP tool, named
them as a group: `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.
``` Into `mcp_inspect` or `mcp_list` goes the server name, not an `mcp__` path, so the prompt tells
- mcp__api__query, mcp__fs__read_file: from MCP servers, named mcp__<server>__<tool>. the model which servers are connected and to list first before guessing a tool name.
Each needs approval; read its own description before calling.
```
Their individual descriptions come from the server, so that is what the model reads before
calling one.
## Cost ## Cost
Each tool adds its name, description, and JSON schema to every request. The built-ins average In **lazy** mode (the default) a configured server contributes three small meta-tool schemas to
548 bytes; MCP tools vary with how verbose the server's schema is. A server exposing twenty every request, not one schema per tool. A server exposing twenty tools therefore costs a few
tools costs roughly 2,750 tokens per turn, sent whether or not the model uses any of them. 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 In **eager** mode each tool adds its name, description, and JSON schema to every request, and
no per-server switch either, so the choice is a server or no server, and `--no-mcp` for all of that cost is sent whether or not the model uses any of them. That is the right trade only for a
them. If one exposes many tools you never use, a narrower server is worth finding or writing. 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: `/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: 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. 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 2. `shiro -p "run mcp_call(server, \"api\", \"query\", {...}) with ..." --json --yolo` — the
`tool-result` output, one JSON object per line. 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`. 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. If that is wrong, nothing above it can be right.
+35
View File
@@ -125,6 +125,21 @@ shiro -c # newest session for this directory
shiro -r 0193ab2c # by id or unique prefix 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 /sessions list the last 15
/resume <id> /resume <id>
@@ -208,6 +223,26 @@ answering it:
alone deliberately: a tool call still waiting for its result is what a suspended approval looks alone deliberately: a tool call still waiting for its result is what a suspended approval looks
like, and dropping it would break `/resume`. 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 ### 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 It tells the model the history was pruned but not what was in it. A decision from forty messages
+4 -2
View File
@@ -33,9 +33,11 @@ remain are the ones worth reading.
| Tool | Matched against | | Tool | Matched against |
|---|---| |---|---|
| `bash` | the command, e.g. `git status --porcelain` | | `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 | | `apply_patch` | every file marker path in the patch |
| `web_fetch` | the URL | | `web_fetch` | the URL |
| `web_search` | the query |
| `read_many_files` | every path in the batch; one match is enough | | `read_many_files` | every path in the batch; one match is enough |
| `glob` `grep` | the pattern | | `glob` `grep` | the pattern |
| `git_diff` `git_log` `git_blame` | the path, when given | | `git_diff` `git_log` `git_blame` | the path, when given |
@@ -98,7 +100,7 @@ With no `permission` config:
| `glob` `grep` `list_dir` | `allow` | | `glob` `grep` `list_dir` | `allow` |
| the git tools | `allow` — they cannot mutate anything | | the git tools | `allow` — they cannot mutate anything |
| `task`, and every session tool | `allow` — they touch the agent's own state | | `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` | | anything else, including every `mcp__*` tool | `ask` |
Credentials are denied on read rather than gated, because there is no recovery. A model that Credentials are denied on read rather than gated, because there is no recovery. A model that
+80 -5
View File
@@ -19,7 +19,7 @@ the agent can read. That is a sandbox problem, not a loader problem — see
## Enabling ## Enabling
```json ```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. 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 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). 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) ### `time` (default on)
Adds `current_time`, returning ISO 8601 plus the local string. Auto-approved; it reads Adds `current_time`, returning ISO 8601 plus the local string. Auto-approved; it reads
nothing. Useful because models are confidently wrong about the date. 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) ### `bell` (opt in)
Writes `\u0007` to stderr when a turn ends. Off by default — a bell after every turn is 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. intrusive, but it is genuinely useful when a turn takes minutes.
```json ```json
{ "plugins": ["guard", "time", "bell"] } { "plugins": ["guard", "secrets", "protect", "time", "bell"] }
``` ```
## Writing one ## 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 ' + 'The no-secrets plugin refuses writes to .env and credential files. Ask the user to ' +
'add secrets themselves rather than working around it.', 'add secrets themselves rather than working around it.',
beforeToolCall: ({ toolName, input }) => { 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 ?? ''); const path = String((input as { path?: unknown } | null)?.path ?? '');
if (/(^|\/)\.env|credentials|\.pem$/.test(path)) { if (/(^|\/)\.env|credentials|\.pem$/.test(path)) {
return `refusing to write ${path}; add secrets yourself`; 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`. 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 Note the six tool names. Every write tool has to be listed, and `multi_edit`, `apply_patch`,
— a guard that only checks `write_file` and `edit_file` is bypassed by a batch edit. 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 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. refusal it was never told about and tries to route around it.
+26 -6
View File
@@ -3,9 +3,9 @@
A skill is a markdown file with instructions for one kind of task. Only its name and 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. 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 That split matters. The twenty-nine bundled skills are tens of thousands of characters of body
characters of catalogue — paid on every request. Putting every body in the prompt against a small catalogue of names and descriptions — paid on every request. Putting every body
would cost that on every turn, for instructions relevant to one turn in twenty. in the prompt would cost that on every turn, for instructions relevant to one turn in twenty.
## Format ## Format
@@ -77,9 +77,29 @@ what was not verified.
match the repository's message style, and the refusals — no amending pushed commits, no match the repository's message style, and the refusals — no amending pushed commits, no
`--no-verify`, no push unless asked. `--no-verify`, no push unless asked.
They are string constants in `src/skills-builtin.ts` rather than files, because **`security`** — find the trust boundary, then work outward: injection, missing authorisation,
`bun build --compile` only embeds modules reachable through imports. A directory of `.md` path traversal, secrets in the wrong place, SSRF, hand-rolled crypto. Do not report a finding
files would be missing from the shipped binary. 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 ## How the agent uses one
+366 -12
View File
@@ -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. reaches the context is on the wire and in the session file, and there is no taking it back.
`*.env.example` is allowed. `*.env.example` is allowed.
**Asked by default.** `write_file`, `edit_file`, `multi_edit`, `apply_patch`, `bash`, `web_fetch`, **Asked by default.** `write_file`, `edit_file`, `multi_edit`, `apply_patch`, `move_file`,
and every `mcp__*` tool. `delete_file`, `bash`, `bash_stop`, `web_fetch`, and every `mcp__*` tool.
``` ```
bash wants to run bash wants to run
@@ -46,7 +46,7 @@ Three more things sit around the rules:
## Tool sets ## Tool sets
Each tool costs its name, its description, and its JSON schema on **every request**. The current 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. its schemas from both the request and the system prompt.
| Tool | Bytes | Tool | Bytes | | Tool | Bytes | Tool | Bytes |
@@ -66,10 +66,12 @@ Sets let you switch off what a project does not need:
| Set | Tools | Cost | | Set | Tools | Cost |
|---|---|---| |---|---|---|
| `core` | `read_file` `write_file` `edit_file` `glob` `grep` `bash` | ~2,993 B | | `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` `read_many_files` `apply_patch` | patch included | | `edit-plus` | `multi_edit` `list_dir` `apply_patch` `move_file` `delete_file` | patch and file ops |
| `git` | `git_status` `git_diff` `git_log` `git_show` `git_blame` | ~2,180 B | | `nav` | `find_symbol` `json_query` | navigation and structured reads |
| `net` | `web_fetch` | opt in | | `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 ```json
{ "toolSets": ["edit-plus"] } { "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` - **Reading a lot, editing rarely?** Keep `edit-plus` for `list_dir` and `read_many_files`
alone; they pay for themselves in round trips saved. 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 ## File tools
### `read_file` ### `read_file`
@@ -151,6 +394,16 @@ content full contents
New files and full rewrites only. Creates parent directories. 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` ### `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 several edits to one file and `edit_file` for one edit. Paths stay inside the workspace and the
call asks for approval. 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` ### `list_dir`
``` ```
@@ -267,8 +545,10 @@ reported as `Invalid regex: <reason>` rather than returning an empty result set.
### `bash` ### `bash`
``` ```
command shell command command shell command
timeout ms, default 120000, max 600000 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 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 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` ## `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 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. 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 ## Git tools
All five are read-only and therefore approval-free. Each spawns `git` with a fixed argument 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_log limit? path? hash, date, author, subject; newest first
git_show ref path? one commit: message, author, diff git_show ref path? one commit: message, author, diff
git_blame path startLine? endLine? who last changed each line 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 `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 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 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 | | `list_dir` | 300 entries |
| `read_many_files` | 20 files | | `read_many_files` | 20 files |
| `read_file` | 2,000 lines by default | | `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 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 that needs more can narrow and ask again — which is cheaper than one call that fills the
+111
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "shiro-neko", "name": "shiro-neko",
"version": "0.1.0-beta.4", "version": "1.0.1",
"type": "module", "type": "module",
"private": true, "private": true,
"bin": { "bin": {
@@ -10,12 +10,13 @@
"shiro": "bun run src/cli.tsx", "shiro": "bun run src/cli.tsx",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "bun test", "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", "release": "bun run scripts/release.ts",
"install:local": "bun run build && bun run scripts/install.ts" "install:local": "bun run build && bun run scripts/install.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.4.0", "@types/bun": "^1.4.0",
"@types/pngjs": "^6.0.5",
"@types/react": "19.2.18", "@types/react": "19.2.18",
"ink-testing-library": "4.0.0", "ink-testing-library": "4.0.0",
"react-devtools-core": "^7.0.1" "react-devtools-core": "^7.0.1"
@@ -33,6 +34,7 @@
"ink-select-input": "6.2.0", "ink-select-input": "6.2.0",
"ink-spinner": "^5.0.0", "ink-spinner": "^5.0.0",
"ink-text-input": "6.0.0", "ink-text-input": "6.0.0",
"pngjs": "^7.0.0",
"react": "19.2.8", "react": "19.2.8",
"zod": "4.5.4" "zod": "4.5.4"
} }
+50
View File
@@ -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
View File
@@ -29,7 +29,7 @@ const OUT = 'dist/release';
* failing on the last of five builds. * failing on the last of five builds.
*/ */
export function buildArgs(t: Target, outfile: string, host = process.platform): string[] { 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') { if (t.windows && host === 'win32') {
args.push( args.push(
'--windows-title=shiro-neko', '--windows-title=shiro-neko',
+4 -1
View File
@@ -37,6 +37,8 @@ const READ_ONLY = [
'git_log', 'git_log',
'git_show', 'git_show',
'git_blame', 'git_blame',
'git_branch',
'git_commit_message',
'task', 'task',
'web_fetch', 'web_fetch',
'todo_write', 'todo_write',
@@ -68,7 +70,8 @@ export const VARIANTS: AgentVariant[] = [
maxSteps: 80, maxSteps: 80,
appendix: appendix:
'This task is hard or its cause is unclear. Form more than one hypothesis before you act and say which one ' + '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.', 'so they survive compaction. Report what you verified and what you could not.',
}, },
{ {
+186
View File
@@ -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
View File
@@ -3,25 +3,35 @@ import { render } from 'ink';
import React from 'react'; import React from 'react';
import type { LanguageModel, ModelMessage } from 'ai'; import type { LanguageModel, ModelMessage } from 'ai';
import { resolveAgent, VARIANTS, isThinkingLevel, type AgentVariant } from './agents'; 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 type { FallbackEvent } from './fallback';
import { farewell } from './farewell';
import { readStdin, runHeadless } from './headless'; import { readStdin, runHeadless } from './headless';
import { scaffoldWorkflowFiles } from './scaffold';
import { INIT_PROMPT, loadInstructions } from './instructions'; import { INIT_PROMPT, loadInstructions } from './instructions';
import { detectLanguageHints } from './prompt';
import { walk } from './ignore'; import { walk } from './ignore';
import { connectMcp } from './mcp'; import { connectMcp } from './mcp';
import { createCommitMessageTool } from './commit';
import { Memory, KIND_LABEL } from './memory'; import { Memory, KIND_LABEL } from './memory';
import { costOf } from './pricing'; import { costOf } from './pricing';
import { BUILTIN_PLUGINS, DEFAULT_ENABLED } from './plugins-builtin'; import { BUILTIN_PLUGINS, DEFAULT_ENABLED } from './plugins-builtin';
import { createHost } from './plugins'; import { createHost } from './plugins';
import { hooksToPlugin, loadApprovalStore, loadHooks } from './hooks';
import { fetchModels, presetById } from './providers'; import { fetchModels, presetById } from './providers';
import * as registry from './registry'; import * as registry from './registry';
import { Session } from './session'; import { Session } from './session';
import { loadCustomCommands } from './custom-commands';
import { loadSkills } from './skills'; import { loadSkills } from './skills';
import { reapStaleBackgrounds, shutdownBackgrounds, backgroundSummary, stopBackground } from './tools';
import { bootDiagnostics, shutdownDiagnostics } from './diagnostics';
import * as store from './store'; import * as store from './store';
import { createTaskTool, type SubagentApproval } from './subagent'; import { createTaskTool, type SubagentApproval } from './subagent';
import { VERSION, versionLine } from './version'; import { VERSION, versionLine } from './version';
import { createAskBridge } from './ui/Ask'; import { createAskBridge } from './ui/Ask';
import { App, createApprovalBridge, createNoticeBus, createSubagentBus, type AppHooks } from './ui/App'; 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'; import type { RegistryRow as AppRegistryRow } from './ui/Panels';
// SDK warnings go straight to stderr, which tears up the Ink render. // 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] usage: shiro [options]
shiro -p "prompt" headless, prints to stdout shiro -p "prompt" headless, prints to stdout
cat file | shiro -p prompt read from stdin cat file | shiro -p prompt read from stdin
options: options:
-p, --print [prompt] headless mode; requires --yolo for tool use -p, --print [prompt] headless mode; requires --yolo for tool use
--json with -p, emit one JSON event per line --json with -p, emit one JSON event per line
@@ -49,6 +58,7 @@ options:
--no-skills ignore builtin and project skills --no-skills ignore builtin and project skills
--no-plugins disable all plugins, including the guard --no-plugins disable all plugins, including the guard
--no-memory do not load or write project memory --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 --yolo skip all tool approval prompts
-v, --version -v, --version
-h, --help -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 skills: builtin, plus ~/.shiro-neko/skills/*.md and .shiro/skills/*.md
registry: /registry to browse and install external skills and plugins 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()} sessions: ${store.sessionsDir()}
in-session: /help for the command list`; 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; if (baseUrlFlag) process.env['SHIRO_BASE_URL'] = baseUrlFlag;
let cfg = await loadConfig(); let cfg = await loadConfig();
const rawToolSets = (await readConfigFile()).toolSets as unknown;
const unknownToolSets = unknownToolSetNames(rawToolSets);
const yolo = has('--yolo'); const yolo = has('--yolo');
const headless = flag('-p', '--print') !== undefined; const headless = flag('-p', '--print') !== undefined;
@@ -116,6 +129,12 @@ const notices = createNoticeBus();
const subagents = createSubagentBus(); const subagents = createSubagentBus();
const askBridge = createAskBridge(); 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 { function reportFallback(e: FallbackEvent): void {
const line = `endpoint fallback: ${e.from} rejected the request, retrying on ${e.to}\n ${e.reason}`; const line = `endpoint fallback: ${e.from} rejected the request, retrying on ${e.to}\n ${e.reason}`;
if (headless) process.stderr.write(`shiro: ${line}\n`); 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 instructions = has('--no-instructions') ? [] : await loadInstructions();
const skills = has('--no-skills') ? [] : await loadSkills(); const skills = has('--no-skills') ? [] : await loadSkills();
const customCommands = await loadCustomCommands();
const promptHistory = await store.loadHistory(); const promptHistory = await store.loadHistory();
const installedPlugins = has('--no-plugins') ? { plugins: [], errors: [] } : await registry.loadInstalledPlugins(); 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. */ /** Installed entries, as `kind:name`, so the registry list can mark what is already here. */
async function installedNames(): Promise<Set<string>> { async function installedNames(): Promise<Set<string>> {
const names = new Set<string>(); const names = new Set<string>();
@@ -200,13 +234,43 @@ const enabledPlugins = has('--no-plugins') ? [] : (cfg.plugins ?? DEFAULT_ENABLE
const pluginErrors = enabledPlugins const pluginErrors = enabledPlugins
.filter((name) => !BUILTIN_PLUGINS.some((p) => p.name === name)) .filter((name) => !BUILTIN_PLUGINS.some((p) => p.name === name))
.map((name) => ({ plugin: name, message: 'no such plugin' })); .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( 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); 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. */ /** Placeholder until /provider supplies a key; it never gets called because the UI gates input. */
const unconfiguredModel: LanguageModel = { const unconfiguredModel: LanguageModel = {
@@ -259,14 +323,41 @@ const subagentGate: SubagentApproval = (req) => {
return approveSubagent(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({ const session = new Session({
model: languageModel ?? unconfiguredModel, model: languageModel ?? unconfiguredModel,
modelId: cfg.model,
...(cfg.subagentModel ? { subagentModelId: cfg.subagentModel } : {}),
...(subagentModel !== unconfiguredModel ? { learnerModel: subagentModel } : {}),
onNotice: (t) => notices.emit(t),
askApproval: bridge.ask, askApproval: bridge.ask,
yolo, yolo,
instructions, instructions,
skills, skills,
plugins, plugins,
agent: agentVariant, agent: agentVariant,
languageHints,
...(workspaceFiles.length > 0 ? { workspaceFiles } : {}),
...(cfg.toolSets ? { toolSets: cfg.toolSets } : {}), ...(cfg.toolSets ? { toolSets: cfg.toolSets } : {}),
...(cfg.permission ? { permissions: cfg.permission } : {}), ...(cfg.permission ? { permissions: cfg.permission } : {}),
// Headless has no one to answer, so the tool is withheld rather than left to hang. // 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 } : {}), ...(memory ? { memory } : {}),
...(record.notebook ? { notebook: record.notebook } : {}), ...(record.notebook ? { notebook: record.notebook } : {}),
...(cfg.maxRetries !== undefined ? { maxRetries: cfg.maxRetries } : {}), ...(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: { extraTools: {
...(mcp?.tools ?? {}), ...(mcp?.tools ?? {}),
...externalTools.tools,
git_commit_message: createCommitMessageTool({
model: languageModel ?? unconfiguredModel,
...(headless ? {} : { cwd: process.cwd() }),
}),
...(has('--no-subagent') ...(has('--no-subagent')
? {} ? {}
: { : {
task: createTaskTool({ task: createTaskTool({
model: languageModel ?? unconfiguredModel, model: languageModel ?? unconfiguredModel,
subagentModel,
subagentModelId: cfg.subagentModel,
...(workspaceFiles.length > 0 ? { workspaceFiles } : {}),
onUsage: (u) => recordSubagent(u),
...(headless ? {} : { report: subagents.emit }), ...(headless ? {} : { report: subagents.emit }),
// A worker's writes go through the parent's rules and the parent's // 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 // 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], messages: [...record.messages],
onChange: (messages) => { onChange: (messages) => {
// Debounced so a long tool loop does not hit the disk on every step. // 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(); 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> { async function shutdown(code: number): Promise<never> {
clearTimeout(saveTimer); clearTimeout(saveTimer);
if (session.messages.length > 0) await persist(session.messages); if (session.messages.length > 0) await persist(session.messages);
await mcp?.close(); 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); process.exit(code);
} }
const printArg = flag('-p', '--print'); const printArg = flag('-p', '--print');
if (printArg !== undefined) { if (printArg !== undefined) {
const prompt = printArg || (await readStdin()); const prompt = printArg || (await readStdin());
@@ -314,9 +437,13 @@ if (printArg !== undefined) {
console.error('shiro: -p needs a prompt argument or piped stdin'); console.error('shiro: -p needs a prompt argument or piped stdin');
await shutdown(1); await shutdown(1);
} }
if (has('--init-scaffold')) {
const written = scaffoldWorkflowFiles();
if (written.length > 0) console.error(`shiro: scaffolded ${written.join(', ')}`);
}
if (!yolo) { if (!yolo) {
process.stderr.write( 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' }); const code = await runHeadless({ session, prompt, format: has('--json') ? 'json' : 'text' });
@@ -336,9 +463,11 @@ const hooks: AppHooks = {
instructionFiles: () => instructions.map((i) => i.path), instructionFiles: () => instructions.map((i) => i.path),
listPaths: async () => { listPaths: async () => {
const found: string[] = []; 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; return found;
}, },
fileChangeSeq: () => session.fileChangeSeq,
customCommands: () => customCommands,
registry: { registry: {
list: async () => { list: async () => {
const entries = await registry.fetchIndex(cfg.registryUrl); const entries = await registry.fetchIndex(cfg.registryUrl);
@@ -363,7 +492,8 @@ const hooks: AppHooks = {
}, },
stage: async (name) => { stage: async (name) => {
const entry = await findEntry(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 { return {
row: { name: entry.name, kind: entry.kind, description: entry.description }, row: { name: entry.name, kind: entry.kind, description: entry.description },
url: entry.url, url: entry.url,
@@ -372,10 +502,23 @@ const hooks: AppHooks = {
}, },
install: async (name) => { install: async (name) => {
const entry = await findEntry(name); const entry = await findEntry(name);
const { path } = await registry.install(entry); const { registryPublishers, registryAllowUnsigned } = cfg;
// Loaded on the next start rather than hot-swapped: a skill joins the system const { path } = await registry.install(entry, { publishers: registryPublishers, allowUnsigned: registryAllowUnsigned });
// prompt and a plugin joins the guard chain, and both are built once at boot. // hot-reload: rebuild live catalogue so next turn sees it
return `installed ${entry.kind} ${entry.name} to ${path}\nrestart shiro to load 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) => { remove: async (name) => {
const parsed = /^(skill|plugin):(.+)$/.exec(name); const parsed = /^(skill|plugin):(.+)$/.exec(name);
@@ -383,12 +526,90 @@ const hooks: AppHooks = {
const bare = parsed ? parsed[2]! : name; const bare = parsed ? parsed[2]! : name;
for (const kind of kinds) { 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}"`); 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, initPrompt: INIT_PROMPT,
scaffoldWorkflow: () => scaffoldWorkflowFiles(),
history: promptHistory, history: promptHistory,
recordPrompt: (text) => void store.appendHistory(text), recordPrompt: (text) => void store.appendHistory(text),
agentName: () => session.agent().name, agentName: () => session.agent().name,
@@ -482,6 +703,36 @@ const hooks: AppHooks = {
) )
.join('\n'); .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) => { resumeSession: async (idOrPrefix) => {
const id = await store.resolveId(idOrPrefix); const id = await store.resolveId(idOrPrefix);
const rec = id ? await store.load(id) : undefined; const rec = id ? await store.load(id) : undefined;
@@ -498,32 +749,46 @@ const hooks: AppHooks = {
}, },
}; };
const header = [ // The welcome dashboard's environment facts, in scan order. Anything that should
needsProvider // stop the user — a failed plugin, `--yolo`, a missing key — is given a tone so it
? `shiro-neko ${VERSION} no provider configured` // lifts out of the quiet metadata rather than blending into it.
: `shiro-neko ${VERSION} ${cfg.provider}/${record.model} session ${record.id.slice(0, 8)}`, const facts: HeaderFact[] = [
`agent: ${agentVariant.name} thinking: ${agentVariant.thinking}`, { label: 'agent', value: `${agentVariant.name} thinking ${agentVariant.thinking}` },
`cwd: ${process.cwd()}`, restored ? { label: 'resumed', value: `${record.messages.length} messages` } : undefined,
restored ? `resumed ${record.messages.length} messages` : undefined,
instructions.length > 0 instructions.length > 0
? `instructions: ${instructions.map((i) => i.path.split(/[\\/]/).at(-1)).join(', ')}` ? { label: 'instructions', value: instructions.map((i) => i.path.split(/[\\/]/).at(-1)!).join(', ') }
: 'no AGENTS.md found - /init writes one', : { label: 'instructions', value: 'none - /init writes an AGENTS.md', tone: 'info' },
skills.length > 0 ? `skills: ${skills.map((s) => s.name).join(', ')}` : undefined, skills.length > 0 ? { label: 'skills', value: skills.map((s) => s.name).join(', ') } : undefined,
plugins.plugins.length > 0 ? `plugins: ${plugins.plugins.map((p) => p.name).join(', ')}` : undefined, plugins.plugins.length > 0
...plugins.errors.map((e) => `plugin ${e.plugin}: ${e.message}`), ? { label: 'plugins', value: plugins.plugins.map((p) => p.name).join(', ') }
memory && memory.all().length > 0 ? `memory: ${memory.all().length} notes about this project` : undefined, : undefined,
mcp && Object.keys(mcp.tools).length > 0 ? `mcp: ${Object.keys(mcp.tools).length} tools` : undefined, ...plugins.errors.map((e) => ({ label: 'plugin error', value: `${e.plugin}: ${e.message}`, tone: 'err' as const })),
...(mcp?.errors ?? []).map((e) => `mcp ${e.server} failed: ${e.message}`), 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 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 : cfg.permission
? `approvals: rules for ${Object.keys(cfg.permission).join(', ')}, defaults elsewhere` ? { label: 'approvals', value: `rules for ${Object.keys(cfg.permission).join(', ')}, defaults elsewhere` }
: 'approvals: ask for write_file, edit_file, multi_edit, bash, mcp__*', : { label: 'approvals', value: 'ask for writes, bash, web_fetch, mcp' },
cfg.toolSets ? `tool sets: core, ${cfg.toolSets.join(', ')}` : undefined, cfg.toolSets ? { label: 'tool sets', value: `core, ${cfg.toolSets.join(', ')}` } : undefined,
'/help for commands', ].filter((f): f is HeaderFact => f !== undefined);
]
.filter(Boolean) const headerNode = (
.join('\n'); <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 // 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. // keeps the turn. Ink's own handler would exit the process before we saw the key.
@@ -531,7 +796,9 @@ const app = render(
<App <App
session={session} session={session}
bridge={bridge} bridge={bridge}
header={header} header=""
headerNode={headerNode}
version={VERSION}
hooks={hooks} hooks={hooks}
notices={notices} notices={notices}
askBridge={askBridge} askBridge={askBridge}
@@ -541,4 +808,14 @@ const app = render(
{ exitOnCtrlC: false }, { exitOnCtrlC: false },
); );
await app.waitUntilExit(); 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); await shutdown(0);
+123 -6
View File
@@ -1,9 +1,13 @@
import type { CustomCommand } from './custom-commands';
export type CommandAction = export type CommandAction =
| { type: 'none' } | { type: 'none' }
| { type: 'prompt'; text: string } | { type: 'prompt'; text: string }
| { type: 'exit' } | { type: 'exit' }
| { type: 'clear' } | { type: 'clear' }
| { type: 'compact' } | { type: 'compact' }
| { type: 'undo'; what: 'both' | 'files' | 'conversation' }
| { type: 'redo'; what: 'both' | 'files' | 'conversation' }
| { type: 'tools' } | { type: 'tools' }
| { type: 'cost' } | { type: 'cost' }
| { type: 'sessions' } | { type: 'sessions' }
@@ -17,12 +21,22 @@ export type CommandAction =
| { type: 'skills' } | { type: 'skills' }
| { type: 'plugins' } | { type: 'plugins' }
| { type: 'registry'; action: 'list' | 'search' | 'add' | 'remove' | 'installed'; arg?: string } | { type: 'registry'; action: 'list' | 'search' | 'add' | 'remove' | 'installed'; arg?: string }
| { type: 'mcp'; action: 'list' | 'add' | 'remove'; arg?: string }
| { type: 'memory' } | { type: 'memory' }
| { type: 'agent'; agent?: string } | { type: 'agent'; agent?: string }
| { type: 'think'; level?: string } | { type: 'think'; level?: string }
| { type: 'info'; text: string } | { type: 'info'; text: string }
| { type: 'model'; model: string } | { type: 'model'; model: string }
| { type: 'resume'; id: 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 }; | { type: 'unknown'; name: string };
export type CommandSpec = { export type CommandSpec = {
@@ -44,6 +58,7 @@ export const COMMANDS: CommandSpec[] = [
{ name: 'skills', summary: 'list loaded skills' }, { name: 'skills', summary: 'list loaded skills' },
{ name: 'plugins', summary: 'list active plugins' }, { name: 'plugins', summary: 'list active plugins' },
{ name: 'registry', arg: '[search|add|remove] [name]', summary: 'browse and install external skills and 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: 'init', summary: 'have the agent write AGENTS.md for this project' },
{ name: 'context', summary: 'show which instruction files are loaded' }, { name: 'context', summary: 'show which instruction files are loaded' },
{ name: 'todos', summary: "show the agent's task list" }, { 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: 'memory', summary: 'compact the project memory with the model' },
{ name: 'tools', summary: 'list available tools' }, { name: 'tools', summary: 'list available tools' },
{ name: 'compact', summary: 'replace history with a model-written summary' }, { 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: 'cost', summary: 'tokens and estimated spend this session' },
{ name: 'sessions', summary: 'list saved sessions' }, { name: 'sessions', summary: 'list saved sessions' },
{ name: 'resume', arg: '<id>', summary: 'load a saved session' }, { name: 'resume', arg: '<id>', summary: 'load a saved session' },
{ name: 'save', summary: 'write the session to disk now' }, { 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: 'clear', summary: 'clear the transcript and history' },
{ name: 'exit', aliases: ['quit'], summary: 'quit' }, { 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`. * An exact name sorts first so pressing enter on `/model` cannot run `/models`.
* Aliases stay hidden to keep the list short. * 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 []; if (!input.startsWith('/')) return [];
const typed = input.slice(1).toLowerCase(); const typed = input.slice(1).toLowerCase();
if (typed.includes(' ')) return []; 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); const exact = hits.findIndex((c) => c.name === typed);
return exact > 0 ? [hits[exact]!, ...hits.filter((_, i) => i !== exact)] : hits; 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(); const input = raw.trim();
if (!input) return { type: 'none' }; if (!input) return { type: 'none' };
if (!input.startsWith('/')) return { type: 'prompt', text: input }; if (!input.startsWith('/')) return { type: 'prompt', text: input };
@@ -147,6 +220,10 @@ export function parseCommand(raw: string): CommandAction {
return { type: 'clear' }; return { type: 'clear' };
case 'compact': case 'compact':
return { type: 'compact' }; return { type: 'compact' };
case 'undo':
return { type: 'undo', what: parseUndoKind(arg, 'both') };
case 'redo':
return { type: 'redo', what: parseUndoKind(arg, 'conversation') };
case 'tools': case 'tools':
return { type: 'tools' }; return { type: 'tools' };
case 'cost': case 'cost':
@@ -174,6 +251,8 @@ export function parseCommand(raw: string): CommandAction {
return { type: 'plugins' }; return { type: 'plugins' };
case 'registry': case 'registry':
return parseRegistry(arg); return parseRegistry(arg);
case 'mcp':
return parseMcp(arg);
case 'memory': case 'memory':
return { type: 'memory' }; return { type: 'memory' };
case 'agent': case 'agent':
@@ -184,7 +263,45 @@ export function parseCommand(raw: string): CommandAction {
return arg ? { type: 'model', model: arg } : { type: 'models' }; return arg ? { type: 'model', model: arg } : { type: 'models' };
case 'resume': case 'resume':
return arg ? { type: 'resume', id: arg } : { type: 'info', text: 'usage: /resume <session-id>' }; return arg ? { type: 'resume', id: arg } : { type: 'info', text: 'usage: /resume <session-id>' };
default: case 'changes':
return { type: 'unknown', name }; 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 };
}
} }
} }
+74
View File
@@ -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
View File
@@ -43,25 +43,43 @@ const MAX_MATCHES = 8;
* Ties break on path length: the shallower file is more often the one meant. * 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[] { 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 needle = query.toLowerCase();
const prefix: string[] = []; const prefixDirs: string[] = [];
const substring: string[] = []; const prefixFiles: string[] = [];
const substringDirs: string[] = [];
const substringFiles: string[] = [];
for (const path of paths) { for (const path of paths) {
const lower = path.toLowerCase(); const lower = path.toLowerCase();
if (lower.startsWith(needle)) prefix.push(path); const isDir = path.endsWith('/');
else if (lower.includes(needle)) substring.push(path); 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); 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 }; 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 { export function completePath(value: string, token: PathToken, path: string): Completion {
const next = `${value.slice(0, token.start)}${path} ${value.slice(token.end)}`; const next = `${value.slice(0, token.start)}${path} ${value.slice(token.end)}`;
return { value: next, cursor: token.start + path.length + 1 }; return { value: next, cursor: token.start + path.length + 1 };
+51
View File
@@ -21,6 +21,21 @@ export type Config = {
presetId?: string; presetId?: string;
/** Retries per model call for transient failures. SDK default is 2. */ /** Retries per model call for transient failures. SDK default is 2. */
maxRetries?: number; 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. */ /** Default agent variant name. */
agent?: string; agent?: string;
/** Default thinking level. */ /** Default thinking level. */
@@ -33,7 +48,24 @@ export type Config = {
permission?: PermissionConfig; permission?: PermissionConfig;
/** Index for `/registry`. Omit for the default one. */ /** Index for `/registry`. Omit for the default one. */
registryUrl?: string; 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>; 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'); 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. */ /** 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> { export async function loadConfig(): Promise<Config> {
const file = await readConfigFile(); const file = await readConfigFile();
@@ -103,6 +140,20 @@ export async function loadConfig(): Promise<Config> {
process.env[ENV_KEY[provider]], process.env[ENV_KEY[provider]],
...(file.presetId ? { presetId: file.presetId } : {}), ...(file.presetId ? { presetId: file.presetId } : {}),
...(file.maxRetries !== undefined ? { maxRetries: file.maxRetries } : {}), ...(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.agent ? { agent: file.agent } : {}),
...(file.thinking ? { thinking: file.thinking } : {}), ...(file.thinking ? { thinking: file.thinking } : {}),
...(Array.isArray(file.plugins) ? { plugins: file.plugins } : {}), ...(Array.isArray(file.plugins) ? { plugins: file.plugins } : {}),
+121
View File
@@ -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();
}
+118
View File
@@ -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
}
}
+81
View File
@@ -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');
}
+35
View File
@@ -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
View File
@@ -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