Initial commit: shiro-neko 0.1.0-beta.1

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

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

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

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

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

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

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

404 tests, typecheck clean.
This commit is contained in:
Muhammad Zakir Ramadhan
2026-09-02 17:30:18 +07:00
commit 5b8503fcd9
93 changed files with 12775 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
export type ThinkingLevel = 'off' | 'low' | 'medium' | 'high' | 'max';
/** Maps our vocabulary to the SDK's, which each provider then maps to its own knob. */
const SDK_REASONING: Record<ThinkingLevel, 'none' | 'low' | 'medium' | 'high' | 'xhigh'> = {
off: 'none',
low: 'low',
medium: 'medium',
high: 'high',
max: 'xhigh',
};
export const THINKING_LEVELS: ThinkingLevel[] = ['off', 'low', 'medium', 'high', 'max'];
export const isThinkingLevel = (v: string): v is ThinkingLevel => (THINKING_LEVELS as string[]).includes(v);
export const sdkReasoning = (level: ThinkingLevel) => SDK_REASONING[level];
export type AgentVariant = {
name: string;
summary: string;
thinking: ThinkingLevel;
/** Appended to the system prompt to shape behaviour. */
appendix: string;
/** When set, only these tools are offered. Omit to offer everything. */
allowTools?: readonly string[];
maxSteps?: number;
};
const READ_ONLY = [
'read_file',
'glob',
'grep',
'list_dir',
'task',
'todo_write',
'remember',
'recall',
'skill',
] as const;
export const VARIANTS: AgentVariant[] = [
{
name: 'default',
summary: 'balanced: full tools, medium thinking',
thinking: 'medium',
appendix: '',
},
{
name: 'quick',
summary: 'small edits: no thinking budget, act immediately',
thinking: 'off',
maxSteps: 12,
appendix:
'This is a small, well-scoped task. Do not deliberate: locate the code, make the change, verify it. ' +
'Do not write a task list. Do not explore beyond what the change requires.',
},
{
name: 'deep',
summary: 'hard problems: maximum thinking, more steps',
thinking: 'max',
maxSteps: 80,
appendix:
'This task is hard or its cause is unclear. Form more than one hypothesis before you act and say which one ' +
'you are testing. Read enough of the code to be sure rather than guessing. Record findings with remember ' +
'so they survive compaction. Report what you verified and what you could not.',
},
{
name: 'plan',
summary: 'read-only: investigate and propose, never edit',
thinking: 'high',
allowTools: READ_ONLY,
appendix:
'You are in planning mode and have no tools that change anything. Investigate, then produce a plan: ' +
'the files to touch, the change in each, the order, and how to verify. Flag anything ambiguous instead of ' +
'assuming. Do not describe edits as if you had made them.',
},
{
name: 'review',
summary: 'read-only: critique a change, find defects',
thinking: 'high',
allowTools: READ_ONLY,
appendix:
'You are reviewing code, not writing it. Look for defects in this order: incorrect behaviour, missing error ' +
'handling at trust boundaries, security issues, then clarity. For each finding give file, line, why it is ' +
'wrong, and the fix. Say plainly when something is fine. Do not invent problems to fill a report.',
},
];
export const DEFAULT_VARIANT = VARIANTS[0]!;
export const variantByName = (name: string) => VARIANTS.find((v) => v.name === name);
/** Variant with an explicit thinking override applied, for `--agent deep --think low`. */
export function resolveAgent(name: string | undefined, thinking: string | undefined): AgentVariant {
const base = name ? variantByName(name) : DEFAULT_VARIANT;
if (!base) throw new Error(`Unknown agent "${name}". Available: ${VARIANTS.map((v) => v.name).join(', ')}`);
if (thinking === undefined) return base;
if (!isThinkingLevel(thinking)) {
throw new Error(`Unknown thinking level "${thinking}". Available: ${THINKING_LEVELS.join(', ')}`);
}
return { ...base, thinking };
}
export function renderAgent(variant: AgentVariant): string {
return variant.appendix ? `\n${variant.appendix}` : '';
}
+56
View File
@@ -0,0 +1,56 @@
import { tool } from 'ai';
import { z } from 'zod';
export type AskRequest = {
question: string;
options?: { label: string; detail?: string }[];
multiple: boolean;
};
/** Set by the UI. Absent means nothing can answer, so asking is an error. */
export type AskFn = (req: AskRequest) => Promise<string[] | undefined>;
const MAX_OPTIONS = 8;
/**
* Lets the model stop and ask rather than guess.
*
* Without this a model facing two materially different readings of a request picks
* one and writes code for it. The cost of a wrong guess is a whole wasted turn plus
* the user's correction, so one question is almost always cheaper.
*/
export function createAskTool(ask: AskFn | undefined) {
return tool({
description:
'Ask the user a question and wait for the answer. Use it when the request has two or more readings that ' +
'lead to materially different work, when a required detail is missing, or to confirm an approach before a ' +
'large change. Offer concrete options when you can; omit them for an open question. ' +
'Do not use it for things you can determine by reading the code, and do not ask twice about the same thing.',
inputSchema: z.object({
question: z.string().describe('One specific question. State what you already know, then what you need.'),
options: z
.array(
z.object({
label: z.string().describe('Short choice, a few words'),
detail: z.string().optional().describe('What choosing this implies, including any tradeoff'),
}),
)
.max(MAX_OPTIONS)
.optional()
.describe('Concrete choices. Put your recommendation first. Omit for an open question.'),
multiple: z.boolean().optional().describe('Allow more than one option to be chosen'),
}),
execute: async ({ question, options, multiple }) => {
if (!ask) {
throw new Error(
'No one is available to answer: this session is running headless. Decide yourself and state the assumption.',
);
}
const answers = await ask({ question, ...(options ? { options } : {}), multiple: multiple ?? false });
if (!answers || answers.length === 0) return 'The user dismissed the question without answering. Proceed with your best judgement and say what you assumed.';
return `The user answered: ${answers.join(', ')}`;
},
});
}
export const ASK_TOOL_NAME = 'ask';
+414
View File
@@ -0,0 +1,414 @@
#!/usr/bin/env bun
import { render } from 'ink';
import React from 'react';
import type { LanguageModel, ModelMessage } from 'ai';
import { resolveAgent, VARIANTS, isThinkingLevel, type AgentVariant } from './agents';
import { configPath, loadConfig, missingKeyMessage, resolveModel, writeConfigFile, type Config } from './config';
import type { FallbackEvent } from './fallback';
import { readStdin, runHeadless } from './headless';
import { INIT_PROMPT, loadInstructions } from './instructions';
import { connectMcp } from './mcp';
import { Memory, KIND_LABEL } from './memory';
import { costOf } from './pricing';
import { BUILTIN_PLUGINS, DEFAULT_ENABLED } from './plugins-builtin';
import { createHost } from './plugins';
import { fetchModels, presetById } from './providers';
import { Session } from './session';
import { loadSkills } from './skills';
import * as store from './store';
import { createTaskTool } from './subagent';
import { VERSION, versionLine } from './version';
import { createAskBridge } from './ui/Ask';
import { App, createApprovalBridge, createNoticeBus, createSubagentBus, type AppHooks } from './ui/App';
// SDK warnings go straight to stderr, which tears up the Ink render.
(globalThis as { AI_SDK_LOG_WARNINGS?: boolean }).AI_SDK_LOG_WARNINGS = false;
const HELP = `shiro-neko ${VERSION} - agentic coding CLI
usage: shiro [options]
shiro -p "prompt" headless, prints to stdout
cat file | shiro -p prompt read from stdin
options:
-p, --print [prompt] headless mode; requires --yolo for tool use
--json with -p, emit one JSON event per line
-c, --continue resume the newest session for this directory
-r, --resume <id> resume a session by id or id prefix
--agent <name> ${VARIANTS.map((v) => v.name).join(' | ')}
--think <level> off | low | medium | high | max
--provider <anthropic|openai> wire protocol to use (default anthropic)
--model <id> model id
--base-url <url> OpenAI/Anthropic-compatible endpoint
--no-mcp skip MCP servers from the config file
--no-subagent omit the task tool
--no-instructions ignore AGENTS.md / CLAUDE.md
--no-skills ignore builtin and project skills
--no-plugins disable all plugins, including the guard
--no-memory do not load or write project memory
--yolo skip all tool approval prompts
-v, --version
-h, --help
first run: start shiro with no key and it opens provider setup, or use /provider anytime.
config: ${configPath()}
{ "provider": "openai", "model": "gpt-5", "apiKey": "...",
"agent": "default", "thinking": "medium", "plugins": ["guard", "time"],
"mcpServers": { "fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] } } }
env: SHIRO_PROVIDER SHIRO_MODEL SHIRO_BASE_URL SHIRO_API_KEY
ANTHROPIC_API_KEY OPENAI_API_KEY
skills: builtin, plus ~/.shiro-neko/skills/*.md and .shiro/skills/*.md
sessions: ${store.sessionsDir()}
in-session: /help for the command list`;
const argv = process.argv.slice(2);
function flag(...names: string[]): string | undefined {
for (const n of names) {
const i = argv.indexOf(n);
if (i === -1) continue;
const next = argv[i + 1];
return next && !next.startsWith('-') ? next : '';
}
return undefined;
}
const has = (...names: string[]) => names.some((n) => argv.includes(n));
if (has('-h', '--help')) {
console.log(HELP);
process.exit(0);
}
if (has('-v', '--version')) {
console.log(versionLine());
process.exit(0);
}
const providerFlag = flag('--provider');
const modelFlag = flag('--model');
const baseUrlFlag = flag('--base-url');
if (providerFlag) process.env['SHIRO_PROVIDER'] = providerFlag;
if (modelFlag) process.env['SHIRO_MODEL'] = modelFlag;
if (baseUrlFlag) process.env['SHIRO_BASE_URL'] = baseUrlFlag;
let cfg = await loadConfig();
const yolo = has('--yolo');
const headless = flag('-p', '--print') !== undefined;
// A missing key is fatal for a pipe, but interactively it just means "not set up yet".
if (!cfg.apiKey && headless) {
console.error(`shiro: ${missingKeyMessage(cfg.provider)}`);
process.exit(1);
}
const needsProvider = !cfg.apiKey;
const notices = createNoticeBus();
const subagents = createSubagentBus();
const askBridge = createAskBridge();
function reportFallback(e: FallbackEvent): void {
const line = `endpoint fallback: ${e.from} rejected the request, retrying on ${e.to}\n ${e.reason}`;
if (headless) process.stderr.write(`shiro: ${line}\n`);
else notices.emit(line);
}
let languageModel: LanguageModel | undefined;
if (cfg.apiKey) {
try {
languageModel = resolveModel(cfg, reportFallback);
} catch (e) {
console.error(`shiro: ${(e as Error).message}`);
process.exit(1);
}
}
let restored: store.SessionRecord | undefined;
const resumeArg = flag('-r', '--resume');
if (resumeArg) {
const id = await store.resolveId(resumeArg);
restored = id ? await store.load(id) : undefined;
if (!restored) {
console.error(`shiro: no session matching "${resumeArg}"`);
process.exit(1);
}
} else if (has('-c', '--continue')) {
restored = await store.latest(process.cwd());
if (!restored) {
console.error('shiro: no saved session for this directory');
process.exit(1);
}
}
const mcp = has('--no-mcp') || !cfg.mcpServers ? undefined : await connectMcp(cfg.mcpServers);
const instructions = has('--no-instructions') ? [] : await loadInstructions();
const skills = has('--no-skills') ? [] : await loadSkills();
const promptHistory = await store.loadHistory();
let agentVariant: AgentVariant;
try {
agentVariant = resolveAgent(flag('--agent') || cfg.agent, flag('--think') || cfg.thinking);
} catch (e) {
console.error(`shiro: ${(e as Error).message}`);
process.exit(1);
}
const enabledPlugins = has('--no-plugins') ? [] : (cfg.plugins ?? DEFAULT_ENABLED);
const pluginErrors = enabledPlugins
.filter((name) => !BUILTIN_PLUGINS.some((p) => p.name === name))
.map((name) => ({ plugin: name, message: 'no such plugin' }));
const plugins = createHost(
BUILTIN_PLUGINS.filter((p) => enabledPlugins.includes(p.name)),
pluginErrors,
);
const memory = has('--no-memory') ? undefined : new Memory(process.cwd(), languageModel);
if (memory) await memory.load();
/** Placeholder until /provider supplies a key; it never gets called because the UI gates input. */
const unconfiguredModel: LanguageModel = {
specificationVersion: 'v4',
provider: 'unconfigured',
modelId: 'unconfigured',
supportedUrls: {},
doGenerate: () => Promise.reject(new Error('no provider configured - run /provider')),
doStream: () => Promise.reject(new Error('no provider configured - run /provider')),
};
const record: store.SessionRecord = restored ?? {
id: store.newId(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
cwd: process.cwd(),
provider: cfg.provider,
model: cfg.model,
title: 'untitled',
inputTokens: 0,
outputTokens: 0,
messages: [],
};
let saveTimer: ReturnType<typeof setTimeout> | undefined;
async function persist(messages: ModelMessage[]): Promise<void> {
record.messages = messages;
record.title = store.titleOf(messages);
record.inputTokens = session.inputTokens;
record.outputTokens = session.outputTokens;
record.notebook = session.notebook.state();
const cost = costOf(record.model, session.inputTokens, session.outputTokens);
if (cost !== undefined) record.costUsd = cost;
await store.save(record);
}
const bridge = createApprovalBridge();
const session = new Session({
model: languageModel ?? unconfiguredModel,
askApproval: bridge.ask,
yolo,
instructions,
skills,
plugins,
agent: agentVariant,
// Headless has no one to answer, so the tool is withheld rather than left to hang.
...(headless ? {} : { ask: askBridge.ask }),
...(memory ? { memory } : {}),
...(record.notebook ? { notebook: record.notebook } : {}),
...(cfg.maxRetries !== undefined ? { maxRetries: cfg.maxRetries } : {}),
extraTools: {
...(mcp?.tools ?? {}),
...(has('--no-subagent')
? {}
: {
task: createTaskTool({
model: languageModel ?? unconfiguredModel,
...(headless ? {} : { report: subagents.emit }),
}),
}),
},
autoApprove: ['task'],
messages: [...record.messages],
onChange: (messages) => {
// Debounced so a long tool loop does not hit the disk on every step.
clearTimeout(saveTimer);
saveTimer = setTimeout(() => void persist(messages), 400);
},
});
async function shutdown(code: number): Promise<never> {
clearTimeout(saveTimer);
if (session.messages.length > 0) await persist(session.messages);
await mcp?.close();
process.exit(code);
}
const printArg = flag('-p', '--print');
if (printArg !== undefined) {
const prompt = printArg || (await readStdin());
if (!prompt) {
console.error('shiro: -p needs a prompt argument or piped stdin');
await shutdown(1);
}
if (!yolo) {
process.stderr.write('shiro: headless denies write_file, edit_file, bash and mcp tools unless --yolo is passed\n');
}
const code = await runHeadless({ session, prompt, format: has('--json') ? 'json' : 'text' });
await shutdown(code);
}
function applyConfig(next: Config): void {
cfg = next;
record.provider = next.provider;
record.model = next.model;
session.setModel(resolveModel(next, reportFallback));
}
const hooks: AppHooks = {
sessionId: record.id,
config: () => cfg,
instructionFiles: () => instructions.map((i) => i.path),
initPrompt: INIT_PROMPT,
history: promptHistory,
recordPrompt: (text) => void store.appendHistory(text),
agentName: () => session.agent().name,
thinkingLevel: () => session.agent().thinking,
switchModel: (id) => {
applyConfig({ ...cfg, model: id });
return `model is now ${id}`;
},
switchAgent: (name) => {
const next = resolveAgent(name, session.agent().thinking);
session.setAgent(next);
const scope = next.allowTools ? ` (read-only: ${next.allowTools.length} tools)` : '';
return `agent is now ${next.name}, thinking ${next.thinking}${scope}`;
},
switchThinking: (level) => {
if (!isThinkingLevel(level)) throw new Error(`Unknown thinking level "${level}"`);
session.setAgent({ ...session.agent(), thinking: level });
return `thinking is now ${level}`;
},
listSkills: () => {
if (skills.length === 0) return 'no skills loaded';
return skills.map((s) => `${s.name.padEnd(10)} ${s.origin.padEnd(8)} ${s.description}`).join('\n');
},
listPlugins: () => {
const active = plugins.plugins.map((p) => `${p.name.padEnd(8)} ${p.description}`);
const failed = plugins.errors.map((e) => `${e.plugin.padEnd(8)} ${e.message}`);
if (active.length === 0 && failed.length === 0) return 'no plugins active';
return [...active, ...failed].join('\n');
},
listMemory: async () => {
if (!memory) return 'memory is disabled (--no-memory)';
const all = await memory.load();
if (all.length === 0) return 'nothing remembered about this project yet';
return all
.slice()
.reverse()
.map((e) => `(${KIND_LABEL[e.kind]}) ${e.text}${e.hits > 0 ? ` [recalled ${e.hits}x]` : ''}`)
.join('\n');
},
summarizeMemory: async () => {
if (!memory) return 'memory is disabled (--no-memory)';
const { before, after } = await memory.summarize();
return before === after
? `memory left as is: ${before} entries, too few unused ones to merge`
: `memory compacted: ${before} entries into ${after}`;
},
applyProvider: async (result) => {
const next: Config = {
...cfg,
provider: result.provider,
model: result.model,
baseURL: result.baseURL,
apiKey: result.apiKey,
presetId: result.presetId,
};
applyConfig(next);
const path = await writeConfigFile({
provider: next.provider,
model: next.model,
baseURL: next.baseURL,
apiKey: next.apiKey,
presetId: next.presetId,
});
const label = presetById(result.presetId)?.label ?? result.presetId;
return `${label} configured with ${result.model}\nsaved to ${path}`;
},
listModels: async () => {
if (!cfg.apiKey) return { models: [], warning: 'no API key set - run /provider' };
const preset = presetById(cfg.presetId ?? cfg.provider);
const { models, warning } = await fetchModels(
{
kind: cfg.provider,
baseURL: cfg.baseURL ?? '',
...(preset?.fallbackModels ? { fallbackModels: preset.fallbackModels } : {}),
},
cfg.apiKey,
);
return warning ? { models, warning } : { models };
},
listSessions: async () => {
const all = await store.list(15);
if (all.length === 0) return 'no saved sessions';
return all
.map(
(r) =>
`${r.id.slice(0, 8)} ${r.updatedAt.slice(0, 16).replace('T', ' ')} ${r.messages.length}msg ${r.title}`,
)
.join('\n');
},
resumeSession: async (idOrPrefix) => {
const id = await store.resolveId(idOrPrefix);
const rec = id ? await store.load(id) : undefined;
if (!rec) throw new Error(`no session matching "${idOrPrefix}"`);
session.replace(rec.messages);
record.id = rec.id;
record.title = rec.title;
hooks.sessionId = rec.id;
return `resumed ${rec.id.slice(0, 8)} (${rec.messages.length} messages): ${rec.title}`;
},
saveSession: async () => {
await persist(session.messages);
return `saved ${record.id}`;
},
};
const header = [
needsProvider
? `shiro-neko ${VERSION} no provider configured`
: `shiro-neko ${VERSION} ${cfg.provider}/${record.model} session ${record.id.slice(0, 8)}`,
`agent: ${agentVariant.name} thinking: ${agentVariant.thinking}`,
`cwd: ${process.cwd()}`,
restored ? `resumed ${record.messages.length} messages` : undefined,
instructions.length > 0
? `instructions: ${instructions.map((i) => i.path.split(/[\\/]/).at(-1)).join(', ')}`
: 'no AGENTS.md found - /init writes one',
skills.length > 0 ? `skills: ${skills.map((s) => s.name).join(', ')}` : undefined,
plugins.plugins.length > 0 ? `plugins: ${plugins.plugins.map((p) => p.name).join(', ')}` : undefined,
...plugins.errors.map((e) => `plugin ${e.plugin}: ${e.message}`),
memory && memory.all().length > 0 ? `memory: ${memory.all().length} notes about this project` : undefined,
mcp && Object.keys(mcp.tools).length > 0 ? `mcp: ${Object.keys(mcp.tools).length} tools` : undefined,
...(mcp?.errors ?? []).map((e) => `mcp ${e.server} failed: ${e.message}`),
yolo ? 'approvals: OFF (--yolo)' : 'approvals: on for write_file, edit_file, bash, mcp__*',
'/help for commands',
]
.filter(Boolean)
.join('\n');
const app = render(
<App
session={session}
bridge={bridge}
header={header}
hooks={hooks}
notices={notices}
askBridge={askBridge}
subagents={subagents}
needsProvider={needsProvider}
/>,
);
await app.waitUntilExit();
await shutdown(0);
+145
View File
@@ -0,0 +1,145 @@
export type CommandAction =
| { type: 'none' }
| { type: 'prompt'; text: string }
| { type: 'exit' }
| { type: 'clear' }
| { type: 'compact' }
| { type: 'tools' }
| { type: 'cost' }
| { type: 'sessions' }
| { type: 'save' }
| { type: 'provider' }
| { type: 'models' }
| { type: 'init' }
| { type: 'context' }
| { type: 'todos' }
| { type: 'notes' }
| { type: 'skills' }
| { type: 'plugins' }
| { type: 'memory' }
| { type: 'agent'; agent?: string }
| { type: 'think'; level?: string }
| { type: 'info'; text: string }
| { type: 'model'; model: string }
| { type: 'resume'; id: string }
| { type: 'unknown'; name: string };
export type CommandSpec = {
name: string;
/** Extra names that resolve to the same command, hidden from the menu. */
aliases?: string[];
arg?: string;
summary: string;
};
/** Single source of truth for the menu, `/help`, and the parser. */
export const COMMANDS: CommandSpec[] = [
{ name: 'help', aliases: ['?'], summary: 'list these commands' },
{ name: 'agent', arg: '[name]', summary: 'switch agent: default, quick, deep, plan, review' },
{ name: 'think', arg: '[level]', summary: 'thinking level: off, low, medium, high, max' },
{ name: 'provider', aliases: ['login'], summary: 'set up a provider: pick, paste API key, choose model' },
{ name: 'models', summary: 'pick a model from the current provider' },
{ name: 'model', arg: '<id>', summary: 'switch model by name' },
{ name: 'skills', summary: 'list loaded skills' },
{ name: 'plugins', summary: 'list active plugins' },
{ name: 'init', summary: 'have the agent write AGENTS.md for this project' },
{ name: 'context', summary: 'show which instruction files are loaded' },
{ name: 'todos', summary: "show the agent's task list" },
{ name: 'notes', summary: 'show what the agent remembers about this project' },
{ name: 'memory', summary: 'compact the project memory with the model' },
{ name: 'tools', summary: 'list available tools' },
{ name: 'compact', summary: 'replace history with a model-written summary' },
{ name: 'cost', summary: 'tokens and estimated spend this session' },
{ name: 'sessions', summary: 'list saved sessions' },
{ name: 'resume', arg: '<id>', summary: 'load a saved session' },
{ name: 'save', summary: 'write the session to disk now' },
{ name: 'clear', summary: 'clear the transcript and history' },
{ name: 'exit', aliases: ['quit'], summary: 'quit' },
];
const usage = (c: CommandSpec) => `/${c.name}${c.arg ? ` ${c.arg}` : ''}`;
export const HELP = [
...COMMANDS.map((c) => `${usage(c).padEnd(18)} ${c.summary}`),
'',
'esc interrupt the running turn',
'tab complete the highlighted command',
'up / down recall earlier prompts',
].join('\n');
/**
* Commands whose name starts with the typed prefix, for the `/` menu.
* An exact name sorts first so pressing enter on `/model` cannot run `/models`.
* Aliases stay hidden to keep the list short.
*/
export function matchCommands(input: string): CommandSpec[] {
if (!input.startsWith('/')) return [];
const typed = input.slice(1).toLowerCase();
if (typed.includes(' ')) return [];
const hits = COMMANDS.filter((c) => c.name.startsWith(typed));
const exact = hits.findIndex((c) => c.name === typed);
return exact > 0 ? [hits[exact]!, ...hits.filter((_, i) => i !== exact)] : hits;
}
/** True while the input is a bare command name being typed, so the menu should show. */
export const isMenuOpen = (input: string) => input.startsWith('/') && !input.includes(' ');
/** Pure parser: no IO, so the TUI and headless mode share one definition. */
export function parseCommand(raw: string): CommandAction {
const input = raw.trim();
if (!input) return { type: 'none' };
if (!input.startsWith('/')) return { type: 'prompt', text: input };
const [name = '', ...rest] = input.slice(1).split(/\s+/);
const arg = rest.join(' ').trim();
switch (name) {
case 'help':
case '?':
return { type: 'info', text: HELP };
case 'exit':
case 'quit':
return { type: 'exit' };
case 'clear':
return { type: 'clear' };
case 'compact':
return { type: 'compact' };
case 'tools':
return { type: 'tools' };
case 'cost':
return { type: 'cost' };
case 'sessions':
return { type: 'sessions' };
case 'save':
return { type: 'save' };
case 'provider':
case 'login':
return { type: 'provider' };
case 'models':
return { type: 'models' };
case 'init':
return { type: 'init' };
case 'context':
return { type: 'context' };
case 'todos':
return { type: 'todos' };
case 'notes':
return { type: 'notes' };
case 'skills':
return { type: 'skills' };
case 'plugins':
return { type: 'plugins' };
case 'memory':
return { type: 'memory' };
case 'agent':
return arg ? { type: 'agent', agent: arg } : { type: 'agent' };
case 'think':
return arg ? { type: 'think', level: arg } : { type: 'think' };
case 'model':
return arg ? { type: 'model', model: arg } : { type: 'models' };
case 'resume':
return arg ? { type: 'resume', id: arg } : { type: 'info', text: 'usage: /resume <session-id>' };
default:
return { type: 'unknown', name };
}
}
+123
View File
@@ -0,0 +1,123 @@
import { createAnthropic } from '@ai-sdk/anthropic';
import { createOpenAI } from '@ai-sdk/openai';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import type { LanguageModel } from 'ai';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { withFallback, type FallbackEvent } from './fallback';
import type { McpServerConfig } from './mcp';
export type ProviderName = 'anthropic' | 'openai';
export type Config = {
provider: ProviderName;
model: string;
baseURL?: string;
apiKey?: string;
/** Preset id from providers.ts, kept so /provider can show what is configured. */
presetId?: string;
/** Retries per model call for transient failures. SDK default is 2. */
maxRetries?: number;
/** Default agent variant name. */
agent?: string;
/** Default thinking level. */
thinking?: string;
/** Plugin names to enable; omit for the default set. */
plugins?: string[];
mcpServers?: Record<string, McpServerConfig>;
};
const configPath = () => join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', 'config.json');
const DEFAULT_MODEL: Record<ProviderName, string> = {
anthropic: 'claude-sonnet-4-5',
openai: 'gpt-5',
};
const DEFAULT_BASE_URL: Record<ProviderName, string> = {
anthropic: 'https://api.anthropic.com/v1',
openai: 'https://api.openai.com/v1',
};
/** Env key checked per provider when no explicit apiKey is configured. */
const ENV_KEY: Record<ProviderName, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
};
function isProvider(v: unknown): v is ProviderName {
return v === 'anthropic' || v === 'openai';
}
/** Raw file contents, without env overlay. Used when rewriting the file. */
export async function readConfigFile(): Promise<Partial<Config>> {
const f = Bun.file(configPath());
if (!(await f.exists())) return {};
try {
const parsed: unknown = await f.json();
return parsed && typeof parsed === 'object' ? (parsed as Partial<Config>) : {};
} catch {
throw new Error(`${configPath()} is not valid JSON`);
}
}
/** Merges patch into the config file, preserving unrelated keys such as mcpServers. */
export async function writeConfigFile(patch: Partial<Config>): Promise<string> {
const merged = { ...(await readConfigFile()), ...patch };
await Bun.write(configPath(), `${JSON.stringify(merged, null, 2)}\n`);
return configPath();
}
/** File config, then env overrides. Env wins so `SHIRO_MODEL=x shiro` works. */
export async function loadConfig(): Promise<Config> {
const file = await readConfigFile();
const envProvider = process.env['SHIRO_PROVIDER'];
const provider = isProvider(envProvider) ? envProvider : isProvider(file.provider) ? file.provider : 'anthropic';
return {
provider,
model: process.env['SHIRO_MODEL'] ?? file.model ?? DEFAULT_MODEL[provider],
baseURL: process.env['SHIRO_BASE_URL'] ?? file.baseURL ?? DEFAULT_BASE_URL[provider],
apiKey: process.env['SHIRO_API_KEY'] ?? file.apiKey ?? process.env[ENV_KEY[provider]],
...(file.presetId ? { presetId: file.presetId } : {}),
...(file.maxRetries !== undefined ? { maxRetries: file.maxRetries } : {}),
...(file.agent ? { agent: file.agent } : {}),
...(file.thinking ? { thinking: file.thinking } : {}),
...(Array.isArray(file.plugins) ? { plugins: file.plugins } : {}),
...(file.mcpServers ? { mcpServers: file.mcpServers } : {}),
};
}
export function missingKeyMessage(provider: ProviderName): string {
return `No API key for provider "${provider}". Run shiro and use /provider to set one, or set ${ENV_KEY[provider]} / SHIRO_API_KEY, or add "apiKey" to ${configPath()}`;
}
const isOfficialOpenAI = (baseURL: string | undefined) =>
!baseURL || /^https:\/\/api\.openai\.com(\/|$)/.test(baseURL);
/**
* Newer OpenAI reasoning models refuse function tools on /v1/chat/completions and
* demand /v1/responses. Rather than guess per model id, build both and let
* withFallback switch when the endpoint rejects the request shape.
*/
export function resolveModel(cfg: Config, onFallback?: (e: FallbackEvent) => void): LanguageModel {
if (!cfg.apiKey) throw new Error(missingKeyMessage(cfg.provider));
if (cfg.provider === 'anthropic') {
return createAnthropic({ apiKey: cfg.apiKey, baseURL: cfg.baseURL })(cfg.model);
}
const chat = createOpenAICompatible({
name: 'openai',
apiKey: cfg.apiKey,
baseURL: cfg.baseURL ?? DEFAULT_BASE_URL.openai,
})(cfg.model);
if (!isOfficialOpenAI(cfg.baseURL)) return chat;
const openai = createOpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseURL });
return withFallback([chat, openai.responses(cfg.model)], onFallback);
}
export { configPath, ENV_KEY };
+83
View File
@@ -0,0 +1,83 @@
import { APICallError } from 'ai';
import type { LanguageModelV4 } from '@ai-sdk/provider';
export type FallbackEvent = {
from: string;
to: string;
reason: string;
};
/** Marks a model built by withFallback, so callers can assert the chain is active. */
export const FALLBACK_CHAIN = Symbol.for('shiro.fallbackChain');
export const fallbackChainOf = (model: unknown): string[] | undefined =>
(model as Record<symbol, string[] | undefined>)[FALLBACK_CHAIN];
/**
* Status codes that mean "this endpoint cannot serve this request", as opposed to
* "try again later". Only these justify switching to a different API shape;
* 401/403/429/5xx are either permanent or the SDK's own retry territory.
*/
const SHAPE_MISMATCH = new Set([400, 404, 405, 415, 422, 501]);
function shouldFallback(error: unknown): string | undefined {
if (!APICallError.isInstance(error)) return undefined;
if (error.isRetryable) return undefined;
if (error.statusCode === undefined || !SHAPE_MISMATCH.has(error.statusCode)) return undefined;
return `${error.statusCode}: ${error.message}`;
}
const label = (m: LanguageModelV4) => `${m.provider}/${m.modelId}`;
/**
* Presents several models as one, walking the list when an endpoint rejects the
* request shape. Built for OpenAI models that only accept function tools on
* /v1/responses, not /v1/chat/completions.
*
* ponytail: only a rejected doStream/doGenerate triggers the switch, not an error
* emitted mid-stream, since tokens already delivered to the UI cannot be unsent.
* Revisit if a provider starts returning 400s inside the stream body.
*/
export function withFallback(models: LanguageModelV4[], onFallback?: (e: FallbackEvent) => void): LanguageModelV4 {
const [primary] = models;
if (!primary) throw new Error('withFallback needs at least one model');
if (models.length === 1) return primary;
// Sticky: once an endpoint rejects the request shape it will reject every later
// step too, so start from the one that worked instead of re-probing each time.
let start = 0;
const reported = new Set<string>();
async function attempt<T>(op: (model: LanguageModelV4) => PromiseLike<T>): Promise<T> {
let lastError: unknown;
for (let i = start; i < models.length; i++) {
const model = models[i]!;
try {
return await op(model);
} catch (error) {
const reason = shouldFallback(error);
const next = models[i + 1];
if (!reason || !next) throw error;
lastError = error;
start = i + 1;
const key = `${label(model)}->${label(next)}`;
if (!reported.has(key)) {
reported.add(key);
onFallback?.({ from: label(model), to: label(next), reason });
}
}
}
throw lastError;
}
const wrapped: LanguageModelV4 = {
specificationVersion: 'v4',
provider: primary.provider,
modelId: primary.modelId,
supportedUrls: primary.supportedUrls,
doGenerate: (options) => attempt((m) => m.doGenerate(options)),
doStream: (options) => attempt((m) => m.doStream(options)),
};
Object.defineProperty(wrapped, FALLBACK_CHAIN, { value: models.map(label), enumerable: false });
return wrapped;
}
+78
View File
@@ -0,0 +1,78 @@
import type { AgentEvent, Session } from './session';
export type HeadlessOptions = {
session: Session;
prompt: string;
/** 'text' streams assistant text only; 'json' emits one event object per line. */
format?: 'text' | 'json';
out?: (chunk: string) => void;
};
const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
/**
* JSON.stringify turns an Error into `{}`, which would make --json useless for
* diagnosing a failure, so error payloads are flattened to a message string.
*/
function serialize(ev: AgentEvent): string {
if (ev.type === 'error') return JSON.stringify({ type: 'error', error: message(ev.error) });
if (ev.type === 'tool-error') {
return JSON.stringify({ type: 'tool-error', id: ev.id, name: ev.name, error: message(ev.error) });
}
return JSON.stringify(ev);
}
/**
* Non-interactive run for pipes and CI. There is no terminal to prompt on, so the
* Session must already be constructed with yolo or every mutating call gets denied.
* Returns a process exit code.
*/
export async function runHeadless({ session, prompt, format = 'text', out }: HeadlessOptions): Promise<number> {
const write = out ?? ((s: string) => process.stdout.write(s));
let failed = false;
for await (const ev of session.send(prompt)) {
if (format === 'json') {
write(`${serialize(ev)}\n`);
if (ev.type === 'error') failed = true;
continue;
}
switch (ev.type) {
case 'text':
write(ev.text);
break;
case 'tool-call':
process.stderr.write(`[tool] ${ev.name} ${JSON.stringify(ev.input)}\n`);
break;
case 'tool-denied':
process.stderr.write(`[denied] ${ev.name} (run with --yolo to allow tool use in headless mode)\n`);
break;
case 'tool-error':
process.stderr.write(`[tool-error] ${ev.name}: ${message(ev.error)}\n`);
break;
case 'notice':
process.stderr.write(`[notice] ${ev.text}\n`);
break;
case 'compacted':
process.stderr.write(`[compacted] ${ev.before} messages pruned to ${ev.after}\n`);
break;
case 'error':
process.stderr.write(`[error] ${message(ev.error)}\n`);
failed = true;
break;
case 'done':
write('\n');
break;
default:
break;
}
}
return failed ? 1 : 0;
}
export async function readStdin(): Promise<string> {
if (process.stdin.isTTY) return '';
return (await Bun.stdin.text()).trim();
}
+149
View File
@@ -0,0 +1,149 @@
import { isAbsolute, join, relative, resolve } from 'node:path';
import { readdir as readdirFs } from 'node:fs/promises';
const ALWAYS_SKIP = ['.git', 'node_modules'];
type Rule = {
/** Directory the rule was declared in, relative and posix-separated. */
base: string;
negated: boolean;
dirOnly: boolean;
re: RegExp;
};
const posix = (p: string) => p.replaceAll('\\', '/');
/**
* Translates one gitignore pattern into a regex over posix-relative paths.
* Supports `!` negation, trailing `/`, leading `/` anchoring, `*`, `?`, and `**`.
*/
function compile(pattern: string, base: string): Rule | undefined {
let body = pattern.trim();
if (!body || body.startsWith('#')) return undefined;
const negated = body.startsWith('!');
if (negated) body = body.slice(1);
const dirOnly = body.endsWith('/');
if (dirOnly) body = body.slice(0, -1);
const anchored = body.startsWith('/') || body.slice(0, -1).includes('/');
if (body.startsWith('/')) body = body.slice(1);
if (!body) return undefined;
let re = '';
for (let i = 0; i < body.length; i++) {
const ch = body[i]!;
if (ch === '*') {
if (body[i + 1] === '*') {
// `**/` spans any number of directories, bare `**` spans anything.
if (body[i + 2] === '/') {
re += '(?:.*/)?';
i += 2;
} else {
re += '.*';
i += 1;
}
} else {
re += '[^/]*';
}
} else if (ch === '?') re += '[^/]';
else if ('.+^${}()|[]\\'.includes(ch)) re += `\\${ch}`;
else re += ch;
}
// An unanchored pattern matches at any depth; both forms also match everything
// beneath a matched directory.
const prefix = anchored ? '' : '(?:.*/)?';
return { base, negated, dirOnly, re: new RegExp(`^${prefix}${re}(?:/.*)?$`) };
}
async function rulesIn(root: string, dir: string): Promise<Rule[]> {
const base = posix(relative(root, dir));
const out: Rule[] = [];
for (const name of ['.gitignore', '.shiroignore']) {
const file = Bun.file(join(dir, name));
if (!(await file.exists())) continue;
for (const line of (await file.text()).split('\n')) {
const rule = compile(line, base);
if (rule) out.push(rule);
}
}
return out;
}
function ignored(relPath: string, isDir: boolean, rules: Rule[]): boolean {
let hit = false;
for (const rule of rules) {
if (rule.dirOnly && !isDir) continue;
const scoped = rule.base ? (relPath.startsWith(`${rule.base}/`) ? relPath.slice(rule.base.length + 1) : undefined) : relPath;
if (scoped === undefined) continue;
// Later rules win, which is how git resolves a negation after an ignore.
if (rule.re.test(scoped)) hit = !rule.negated;
}
return hit;
}
export type WalkOptions = {
root?: string;
/** Include files git would ignore. */
noIgnore?: boolean;
limit?: number;
};
/**
* Yields workspace-relative posix paths, skipping .git, node_modules, and anything
* .gitignore or .shiroignore excludes. Nested ignore files are honoured, so a
* `dist/` rule in a subpackage only applies inside it.
*/
export async function* walk(options: WalkOptions = {}): AsyncGenerator<string> {
const root = resolve(options.root ?? process.cwd());
const limit = options.limit ?? Infinity;
let yielded = 0;
const queue: { dir: string; rules: Rule[] }[] = [
{ dir: root, rules: options.noIgnore ? [] : await rulesIn(root, root) },
];
while (queue.length > 0) {
const { dir, rules } = queue.shift()!;
let entries: Entry[];
try {
entries = (await readdirFs(dir, { withFileTypes: true })).map((d) => ({
name: d.name,
isDirectory: d.isDirectory(),
}));
} catch {
continue;
}
for (const entry of entries) {
if (ALWAYS_SKIP.includes(entry.name)) continue;
const full = join(dir, entry.name);
const rel = posix(relative(root, full));
if (!options.noIgnore && ignored(rel, entry.isDirectory, rules)) continue;
if (entry.isDirectory) {
const nested = options.noIgnore ? rules : [...rules, ...(await rulesIn(root, full))];
queue.push({ dir: full, rules: nested });
} else {
yield rel;
if (++yielded >= limit) return;
}
}
}
}
type Entry = { name: string; isDirectory: boolean };
/** Resolves a model-supplied path inside the workspace, rejecting escapes. */
export function jail(p: string, root = process.cwd()): string {
const abs = isAbsolute(p) ? resolve(p) : resolve(root, p);
const rel = relative(resolve(root), abs);
if (rel.startsWith('..') || isAbsolute(rel)) {
throw new Error(`Path escapes workspace: ${p}`);
}
return abs;
}
export { posix };
+71
View File
@@ -0,0 +1,71 @@
import { dirname, join, resolve } from 'node:path';
const NAMES = ['AGENTS.md', 'CLAUDE.md', '.shiro.md'];
/** Cap per file so one huge doc cannot crowd out the conversation. */
const MAX_CHARS = 12_000;
export type Instructions = { path: string; text: string }[];
/**
* Collects project instruction files from the git root down to cwd, outermost
* first so a nested file's rules read as refinements of the ones above it.
* Stops at the git root, or the filesystem root when there is no repo.
*/
export async function loadInstructions(cwd = process.cwd()): Promise<Instructions> {
const dirs: string[] = [];
let dir = resolve(cwd);
while (true) {
dirs.unshift(dir);
if (await Bun.file(join(dir, '.git', 'HEAD')).exists()) break;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
const found: Instructions = [];
const seen = new Set<string>();
for (const d of dirs) {
for (const name of NAMES) {
const path = join(d, name);
if (seen.has(path)) continue;
const file = Bun.file(path);
if (!(await file.exists())) continue;
seen.add(path);
const text = (await file.text()).trim();
if (text) found.push({ path, text: text.slice(0, MAX_CHARS) });
}
}
return found;
}
export function formatInstructions(instructions: Instructions, cwd = process.cwd()): string {
if (instructions.length === 0) return '';
const blocks = instructions.map(({ path, text }) => {
const label = path.startsWith(cwd) ? path.slice(cwd.length + 1) || path : path;
return `--- ${label} ---\n${text}`;
});
return [
'',
'Project instructions (from the files below). Treat these as standing orders from the user;',
'they override your defaults but never your safety rules.',
'',
...blocks,
].join('\n');
}
export const INSTRUCTION_NAMES = NAMES;
export const INIT_PROMPT = `Write an AGENTS.md at the workspace root that will orient a coding agent joining this project cold.
Investigate first: read the manifest, the config files, the entry points, and a couple of representative
source files. Run the test and build commands if that is the only way to learn how they are invoked.
Then write AGENTS.md covering only what you actually verified:
- what this project is, in two or three sentences
- the exact commands for install, build, test, typecheck, lint
- the layout: which directory holds what
- conventions a newcomer would otherwise get wrong: naming, error handling, module boundaries, test style
- anything surprising or easy to break
Keep it under 100 lines. No filler sections, no "best practices" boilerplate, nothing you did not confirm
by reading the code. If a section would be guesswork, leave it out.`;
+147
View File
@@ -0,0 +1,147 @@
export type Span = { text: string; bold?: boolean; italic?: boolean; code?: boolean; strike?: boolean; link?: boolean };
export type Block =
| { kind: 'heading'; level: number; spans: Span[] }
| { kind: 'paragraph'; spans: Span[] }
| { kind: 'bullet'; indent: number; marker: string; spans: Span[] }
| { kind: 'quote'; spans: Span[] }
| { kind: 'code'; language: string; lines: string[] }
| { kind: 'rule' }
| { kind: 'blank' };
const INLINE =
/(`+)([\s\S]*?)\1|\*\*([\s\S]+?)\*\*|__([\s\S]+?)__|~~([\s\S]+?)~~|(?<![A-Za-z0-9_])_([^\s_][\s\S]*?)_(?![A-Za-z0-9_])|\*([^\s*][\s\S]*?)\*|\[([^\]]+)\]\(([^)]+)\)/;
/**
* Inline markdown to styled spans.
*
* Code spans are matched first and their contents are never re-scanned, so
* `` `**not bold**` `` stays literal — the mistake a naive replace-based
* renderer makes on every code sample an agent prints. Underscore emphasis is
* also required to sit at a word boundary, so `snake_case_name` survives.
*/
export function parseInline(input: string): Span[] {
const spans: Span[] = [];
let rest = input;
while (rest.length > 0) {
const m = INLINE.exec(rest);
if (!m || m.index === undefined) {
spans.push({ text: rest });
break;
}
if (m.index > 0) spans.push({ text: rest.slice(0, m.index) });
if (m[2] !== undefined) spans.push({ text: m[2].trim(), code: true });
else if (m[3] !== undefined) spans.push(...parseInline(m[3]).map((s) => ({ ...s, bold: true })));
else if (m[4] !== undefined) spans.push(...parseInline(m[4]).map((s) => ({ ...s, bold: true })));
else if (m[5] !== undefined) spans.push(...parseInline(m[5]).map((s) => ({ ...s, strike: true })));
else if (m[6] !== undefined) spans.push(...parseInline(m[6]).map((s) => ({ ...s, italic: true })));
else if (m[7] !== undefined) spans.push(...parseInline(m[7]).map((s) => ({ ...s, italic: true })));
else if (m[8] !== undefined) spans.push({ text: m[8], link: true });
rest = rest.slice(m.index + m[0].length);
}
return spans.filter((s) => s.text.length > 0);
}
const FENCE = /^\s*(```+|~~~+)\s*([\w+-]*)\s*$/;
const HEADING = /^(#{1,6})\s+(.*)$/;
const BULLET = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
const QUOTE = /^\s*>\s?(.*)$/;
const RULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
/**
* Line-based markdown parser covering what an agent actually emits: headings,
* fences, lists, quotes, rules, and inline styling. Not CommonMark — no nested
* blocks, tables, or reference links, none of which appear in agent replies.
*/
export function parseMarkdown(input: string): Block[] {
const blocks: Block[] = [];
const lines = input.replace(/\r\n/g, '\n').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const fence = FENCE.exec(line);
if (fence) {
const closer = fence[1]!;
const body: string[] = [];
i++;
while (i < lines.length && !new RegExp(`^\\s*${closer[0]}{${closer.length},}\\s*$`).test(lines[i]!)) {
body.push(lines[i]!);
i++;
}
blocks.push({ kind: 'code', language: fence[2] ?? '', lines: body });
continue;
}
if (line.trim().length === 0) {
if (blocks.at(-1)?.kind !== 'blank') blocks.push({ kind: 'blank' });
continue;
}
if (RULE.test(line)) {
blocks.push({ kind: 'rule' });
continue;
}
const heading = HEADING.exec(line);
if (heading) {
blocks.push({ kind: 'heading', level: heading[1]!.length, spans: parseInline(heading[2]!) });
continue;
}
const bullet = BULLET.exec(line);
if (bullet) {
blocks.push({
kind: 'bullet',
indent: Math.floor(bullet[1]!.length / 2),
marker: /\d/.test(bullet[2]!) ? bullet[2]! : '-',
spans: parseInline(bullet[3]!),
});
continue;
}
const quote = QUOTE.exec(line);
if (quote) {
blocks.push({ kind: 'quote', spans: parseInline(quote[1]!) });
continue;
}
// Consecutive plain lines join into one paragraph so wrapping is the terminal's job.
const previous = blocks.at(-1);
if (previous?.kind === 'paragraph') {
previous.spans.push({ text: ' ' }, ...parseInline(line.trim()));
} else {
blocks.push({ kind: 'paragraph', spans: parseInline(line.trim()) });
}
}
while (blocks.at(-1)?.kind === 'blank') blocks.pop();
return blocks;
}
/** Plain text with the markup removed, for widths and non-styled surfaces. */
export function toPlainText(blocks: Block[]): string {
return blocks
.map((b) => {
switch (b.kind) {
case 'code':
return b.lines.join('\n');
case 'rule':
return '---';
case 'blank':
return '';
case 'bullet':
return `${' '.repeat(b.indent)}${b.marker} ${b.spans.map((s) => s.text).join('')}`;
case 'heading':
return `${'#'.repeat(b.level)} ${b.spans.map((s) => s.text).join('')}`;
default:
return b.spans.map((s) => s.text).join('');
}
})
.join('\n');
}
+57
View File
@@ -0,0 +1,57 @@
import { createMCPClient, type MCPClient } from '@ai-sdk/mcp';
import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio';
import type { ToolSet } from 'ai';
export type McpServerConfig =
| { command: string; args?: string[]; env?: Record<string, string>; cwd?: string }
| { url: string; type?: 'http' | 'sse'; headers?: Record<string, string> };
export type McpHandle = {
tools: ToolSet;
errors: { server: string; message: string }[];
close: () => Promise<void>;
};
const isRemote = (c: McpServerConfig): c is Extract<McpServerConfig, { url: string }> => 'url' in c;
/**
* Connects every configured server and namespaces its tools as `mcp__<server>__<tool>`
* so two servers exposing `search` cannot silently shadow each other.
* A server that fails to start is reported, never fatal.
*/
export async function connectMcp(servers: Record<string, McpServerConfig>): Promise<McpHandle> {
const clients: MCPClient[] = [];
const tools: ToolSet = {};
const errors: McpHandle['errors'] = [];
await Promise.all(
Object.entries(servers).map(async ([name, cfg]) => {
try {
const client = await createMCPClient({
transport: isRemote(cfg)
? { type: cfg.type ?? 'http', url: cfg.url, ...(cfg.headers ? { headers: cfg.headers } : {}) }
: new Experimental_StdioMCPTransport({
command: cfg.command,
...(cfg.args ? { args: cfg.args } : {}),
...(cfg.env ? { env: cfg.env } : {}),
...(cfg.cwd ? { cwd: cfg.cwd } : {}),
}),
});
clients.push(client);
for (const [toolName, tool] of Object.entries(await client.tools())) {
tools[`mcp__${name}__${toolName}`] = tool;
}
} catch (e) {
errors.push({ server: name, message: e instanceof Error ? e.message : String(e) });
}
}),
);
return {
tools,
errors,
close: async () => {
await Promise.all(clients.map((c) => c.close().catch(() => {})));
},
};
}
+253
View File
@@ -0,0 +1,253 @@
import { tool, type LanguageModel } from 'ai';
import { generateText } from 'ai';
import { createHash } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { z } from 'zod';
export type MemoryKind = 'fact' | 'decision' | 'gotcha' | 'command';
export type MemoryEntry = {
id: string;
kind: MemoryKind;
text: string;
createdAt: string;
/** Bumped on each recall so summarisation can keep what gets used. */
hits: number;
};
const MAX_ENTRIES = 300;
const MAX_TEXT = 400;
const BOOT_ENTRIES = 20;
const SEARCH_HITS = 15;
/** Summarise once the store passes this, so the boot block stays small. */
const SUMMARISE_AT = 60;
const root = () => join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', 'memory');
/** One file per project directory; the path is hashed because it is not filename-safe. */
const fileFor = (cwd: string) => join(root(), `${createHash('sha256').update(cwd).digest('hex').slice(0, 16)}.json`);
const KIND_LABEL: Record<MemoryKind, string> = {
fact: 'fact',
decision: 'decision',
gotcha: 'gotcha',
command: 'command',
};
/**
* Durable per-project memory, separate from the session transcript.
*
* The transcript is destroyed by compaction and discarded when a session ends.
* Anything worth knowing on the next run has to live here instead.
*/
export class Memory {
private entries: MemoryEntry[] = [];
private loaded = false;
constructor(
private readonly cwd = process.cwd(),
private readonly model?: LanguageModel,
) {}
async load(): Promise<MemoryEntry[]> {
if (this.loaded) return this.entries;
this.loaded = true;
const f = Bun.file(fileFor(this.cwd));
if (await f.exists()) {
try {
const parsed: unknown = await f.json();
if (Array.isArray(parsed)) this.entries = parsed.filter(isEntry);
} catch {
this.entries = [];
}
}
return this.entries;
}
all(): MemoryEntry[] {
return [...this.entries];
}
private async persist(): Promise<void> {
this.entries = this.entries.slice(-MAX_ENTRIES);
await Bun.write(fileFor(this.cwd), JSON.stringify(this.entries, null, 2));
}
async add(kind: MemoryKind, text: string): Promise<MemoryEntry | undefined> {
await this.load();
const clean = text.trim().slice(0, MAX_TEXT);
if (!clean) throw new Error('memory text is empty');
if (this.entries.some((e) => e.text === clean)) return undefined;
const entry: MemoryEntry = {
id: Bun.randomUUIDv7(),
kind,
text: clean,
createdAt: new Date().toISOString(),
hits: 0,
};
this.entries.push(entry);
await this.persist();
return entry;
}
async forget(idOrPrefix: string): Promise<number> {
await this.load();
const before = this.entries.length;
this.entries = this.entries.filter((e) => !e.id.startsWith(idOrPrefix));
if (this.entries.length !== before) await this.persist();
return before - this.entries.length;
}
async clear(): Promise<void> {
await this.load();
this.entries = [];
await this.persist();
}
/** Every term must appear. Matching entries get a hit, which protects them from summarisation. */
async search(query: string): Promise<MemoryEntry[]> {
await this.load();
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) throw new Error('query is empty');
const found = this.entries.filter((e) => {
const lower = e.text.toLowerCase();
return terms.every((t) => lower.includes(t));
});
for (const e of found) e.hits += 1;
if (found.length > 0) await this.persist();
return found.slice(-SEARCH_HITS).reverse();
}
/** The block injected at boot: most-used first, then most recent. */
render(limit = BOOT_ENTRIES): string {
if (this.entries.length === 0) return '';
const ranked = [...this.entries]
.sort((a, b) => b.hits - a.hits || b.createdAt.localeCompare(a.createdAt))
.slice(0, limit);
return [
'',
'What you learned about this project in earlier sessions. Trust it, but verify anything',
'that contradicts what you can see in the code now:',
...ranked.map((e) => `- (${KIND_LABEL[e.kind]}) ${e.text}`),
].join('\n');
}
needsSummary(): boolean {
return this.entries.length >= SUMMARISE_AT;
}
/**
* Collapses the store into fewer, denser entries using the model. Unused entries
* are the ones that get merged away; anything recalled at least once is kept verbatim.
*/
async summarize(): Promise<{ before: number; after: number }> {
await this.load();
const before = this.entries.length;
if (!this.model) throw new Error('no model available to summarize memory');
if (before === 0) return { before, after: 0 };
const used = this.entries.filter((e) => e.hits > 0);
const unused = this.entries.filter((e) => e.hits === 0);
if (unused.length < 2) return { before, after: before };
const { text } = await generateText({
model: this.model,
system:
'You are compacting an agent\'s notes about one codebase. Merge duplicates and near-duplicates, ' +
'drop anything that is no longer useful or was only true of one past task, and keep the rest verbatim ' +
'where you can. Output one note per line, each prefixed with its kind in brackets: ' +
'[fact], [decision], [gotcha], or [command]. No preamble, no numbering, no blank lines.',
prompt: unused.map((e) => `[${e.kind}] ${e.text}`).join('\n'),
maxRetries: 2,
});
const merged = text
.split('\n')
.map((line) => /^\s*\[(fact|decision|gotcha|command)\]\s*(.+?)\s*$/i.exec(line))
.filter((m): m is RegExpExecArray => m !== null)
.map((m) => ({
id: Bun.randomUUIDv7(),
kind: m[1]!.toLowerCase() as MemoryKind,
text: m[2]!.slice(0, MAX_TEXT),
createdAt: new Date().toISOString(),
hits: 0,
}));
// A model that returned nothing parseable must not wipe the store.
if (merged.length === 0) return { before, after: before };
this.entries = [...used, ...merged];
await this.persist();
return { before, after: this.entries.length };
}
tools() {
return {
remember: tool({
description:
'Record something about this project that will still be true next session: a decision and its reason, ' +
'a command that works, a constraint, a trap you hit. Persisted across sessions and shown to you at start. ' +
'Do not use it for narration or for anything specific to the current task only.',
inputSchema: z.object({
kind: z
.enum(['fact', 'decision', 'gotcha', 'command'])
.describe('fact: how it is. decision: what was chosen and why. gotcha: a trap. command: an invocation that works'),
text: z.string().describe('One self-contained line, understandable with no other context'),
}),
execute: async ({ kind, text }) => {
const entry = await this.add(kind, text);
if (!entry) return `Already recorded: ${text.trim()}`;
return `Remembered as ${entry.kind} (${this.entries.length} stored): ${entry.text}`;
},
}),
recall: tool({
description:
'Search what you recorded about this project in earlier sessions. Use it before investigating anything ' +
'that might already be known, and when the user refers to past work.',
inputSchema: z.object({
query: z.string().describe('Words that would appear in the note'),
}),
execute: async ({ query }) => {
const found = await this.search(query);
if (found.length === 0) return `Nothing recorded about "${query}".`;
return found.map((e) => `(${e.kind}) ${e.text}`).join('\n');
},
}),
forget: tool({
description:
'Remove a memory that turned out to be wrong or is now obsolete. Search with recall first to get its text.',
inputSchema: z.object({
text: z.string().describe('Exact text of the memory to remove, or a distinctive part of it'),
}),
execute: async ({ text }) => {
await this.load();
const needle = text.trim().toLowerCase();
const before = this.entries.length;
this.entries = this.entries.filter((e) => !e.text.toLowerCase().includes(needle));
const removed = before - this.entries.length;
if (removed > 0) await this.persist();
return removed > 0 ? `Forgot ${removed} memor${removed === 1 ? 'y' : 'ies'}.` : `No memory matches "${text}".`;
},
}),
};
}
}
export { fileFor as memoryFileFor, root as memoryDir, KIND_LABEL };
function isEntry(value: unknown): value is MemoryEntry {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
return (
typeof v['id'] === 'string' &&
typeof v['text'] === 'string' &&
typeof v['createdAt'] === 'string' &&
typeof v['hits'] === 'number' &&
['fact', 'decision', 'gotcha', 'command'].includes(String(v['kind']))
);
}
+120
View File
@@ -0,0 +1,120 @@
import { tool } from 'ai';
import { z } from 'zod';
export type TodoStatus = 'pending' | 'in_progress' | 'done' | 'blocked';
export type Todo = { content: string; status: TodoStatus; note?: string };
export type NotebookState = { todos: Todo[] };
const MARK: Record<TodoStatus, string> = {
pending: '[ ]',
in_progress: '[~]',
done: '[x]',
blocked: '[!]',
};
const STATUSES = ['pending', 'in_progress', 'done', 'blocked'] as const;
const renderTodo = (t: Todo) => `${MARK[t.status]} ${t.content}${t.note?.trim() ? ` (${t.note.trim()})` : ''}`;
function isTodo(value: unknown): value is Todo {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
return typeof v['content'] === 'string' && (STATUSES as readonly string[]).includes(String(v['status']));
}
/**
* The task list for the current session.
*
* Both `pruneMessages` and `/compact` destroy tool results and older turns, so a plan
* recorded only in the transcript is lost exactly when a long task needs it. This is
* re-rendered into the system prompt on every step instead, so it survives both.
* Anything that should outlive the session belongs in Memory, not here.
*/
export class Notebook {
private todos: Todo[] = [];
constructor(private readonly onChange?: (state: NotebookState) => void) {}
state(): NotebookState {
return { todos: this.todos.map((t) => ({ ...t })) };
}
restore(state: Partial<NotebookState> | undefined): void {
if (Array.isArray(state?.todos)) this.todos = state.todos.filter(isTodo);
}
clear(): void {
this.todos = [];
this.onChange?.(this.state());
}
progress(): { done: number; total: number; blocked: number; current?: Todo } {
const current = this.todos.find((t) => t.status === 'in_progress');
return {
done: this.todos.filter((t) => t.status === 'done').length,
total: this.todos.length,
blocked: this.todos.filter((t) => t.status === 'blocked').length,
...(current ? { current } : {}),
};
}
render(): string {
if (this.todos.length === 0) return '';
const { done, total, blocked } = this.progress();
const header = `\nYour task list (${done}/${total} done${blocked > 0 ? `, ${blocked} blocked` : ''}). Keep it current with todo_write:`;
return `${header}\n${this.todos.map(renderTodo).join('\n')}`;
}
tools() {
return {
todo_write: tool({
description:
'Record or update your task list for a multi-step job. Send the whole list every time; it replaces the ' +
'previous one. Exactly one task should be in_progress. Mark a task done the moment it is finished, not in ' +
'a batch at the end. Use blocked with a note when something outside your control stops you. The list is ' +
'shown to the user and survives context compaction, so it is where your plan lives. ' +
'Skip it entirely for single-step work.',
inputSchema: z.object({
todos: z
.array(
z.object({
content: z
.string()
.describe('One concrete action with a verifiable outcome, e.g. "add limit/offset to listUsers()"'),
status: z.enum(STATUSES),
note: z
.string()
.optional()
.describe('Required for blocked: what is blocking it. Otherwise a short finding worth keeping.'),
}),
)
.describe('The complete list, in the order you will do them'),
}),
execute: async ({ todos }) => {
this.todos = todos;
this.onChange?.(this.state());
const active = todos.filter((t) => t.status === 'in_progress');
const blocked = todos.filter((t) => t.status === 'blocked');
const { done, total } = this.progress();
const warnings: string[] = [];
if (active.length > 1) warnings.push(`${active.length} tasks are in_progress; keep it to one.`);
if (active.length === 0 && done < total && blocked.length < total - done) {
warnings.push('nothing is in_progress; mark what you are working on.');
}
for (const t of blocked) {
if (!t.note?.trim()) warnings.push(`"${t.content}" is blocked with no note saying why.`);
}
const lines = [`Task list updated: ${done}/${total} done.`, ...todos.map(renderTodo)];
if (warnings.length > 0) lines.push(`Warning: ${warnings.join(' ')}`);
return lines.join('\n');
},
}),
};
}
}
export { MARK as TODO_MARK, STATUSES };
+74
View File
@@ -0,0 +1,74 @@
import { tool } from 'ai';
import { z } from 'zod';
import type { Plugin } from './plugins';
/**
* Commands that destroy work irreversibly. Approval alone is a weak defence here:
* a user holding `a` for a batch of edits will approve one of these without reading it,
* so they are refused outright and the user has to run them by hand.
*/
const DESTRUCTIVE: { re: RegExp; why: string }[] = [
{ re: /\brm\s+(-[a-zA-Z]*\s+)*-[a-zA-Z]*[rf]/, why: 'recursive or forced delete' },
{ re: /\bgit\s+reset\s+--hard\b/, why: 'discards uncommitted work' },
{ re: /\bgit\s+clean\s+-[a-zA-Z]*f/, why: 'deletes untracked files' },
{ re: /\bgit\s+push\b.*(--force\b|--force-with-lease\b|\s-f\b)/, why: 'rewrites remote history' },
{ re: /\bgit\s+branch\s+-D\b/, why: 'deletes a branch without a merge check' },
{ re: /\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA)\b/i, why: 'destroys database data' },
{ re: /\bmkfs(\.\w+)?\b|\bdd\s+[^|]*of=\/dev\//, why: 'writes to a raw device' },
{ re: />\s*\/dev\/(sd|nvme|disk)/, why: 'writes to a raw device' },
{ re: /\bchmod\s+(-[a-zA-Z]*\s+)*777\b/, why: 'makes files world-writable' },
{ re: /\b(shutdown|reboot|halt)\b/, why: 'affects the whole machine' },
{ re: /:\(\)\s*\{.*\}\s*;\s*:/, why: 'fork bomb' },
{ re: /\bcurl\b[^|]*\|\s*(ba|z|k)?sh\b|\bwget\b[^|]*\|\s*(ba|z|k)?sh\b/, why: 'pipes a download straight into a shell' },
];
export const guardPlugin: Plugin = {
name: 'guard',
description: 'refuses irreversible shell commands outright',
appendix:
'The guard plugin refuses irreversible shell commands (recursive deletes, hard resets, force pushes, ' +
'DROP TABLE, piping downloads into a shell). If one is refused, do not work around it: tell the user ' +
'what needs running and let them do it themselves.',
beforeToolCall: ({ toolName, input }) => {
if (toolName !== 'bash') return undefined;
const command = String((input as { command?: unknown } | null)?.command ?? '');
if (!command) return undefined;
for (const { re, why } of DESTRUCTIVE) {
if (re.test(command)) {
return `refusing "${command.slice(0, 120)}" (${why}). Ask the user to run it themselves if it is really needed.`;
}
}
return undefined;
},
};
export const bellPlugin: Plugin = {
name: 'bell',
description: 'rings the terminal bell when a turn ends',
afterTurn: () => {
process.stderr.write('\u0007');
},
};
export const timePlugin: Plugin = {
name: 'time',
description: 'adds a current_time tool',
autoApprove: ['current_time'],
tools: {
current_time: tool({
description: 'Current date and time in ISO 8601, with the local timezone. Use it when the date matters.',
inputSchema: z.object({}),
execute: async () => {
const now = new Date();
return `${now.toISOString()} (local: ${now.toString()})`;
},
}),
},
};
export const BUILTIN_PLUGINS: Plugin[] = [guardPlugin, bellPlugin, timePlugin];
/** Enabled unless the config turns them off. bell is opt-in; a bell per turn is intrusive. */
export const DEFAULT_ENABLED = ['guard', 'time'];
export { DESTRUCTIVE };
+77
View File
@@ -0,0 +1,77 @@
import type { ToolSet } from 'ai';
export type ToolCallContext = {
toolName: string;
input: unknown;
cwd: string;
};
/** Returning a string blocks the call; the string is handed to the model as the reason. */
export type BeforeToolCall = (ctx: ToolCallContext) => string | undefined | Promise<string | undefined>;
export type Plugin = {
name: string;
description: string;
/** Extra tools contributed by this plugin. */
tools?: ToolSet;
/** Tool names that should never prompt for approval. */
autoApprove?: readonly string[];
beforeToolCall?: BeforeToolCall;
afterTurn?: () => void | Promise<void>;
/** Text appended to the system prompt. */
appendix?: string;
};
export type PluginHost = {
plugins: Plugin[];
tools: ToolSet;
autoApprove: string[];
appendix: string;
/** Runs every beforeToolCall hook; the first block wins. */
guard: BeforeToolCall;
afterTurn: () => Promise<void>;
errors: { plugin: string; message: string }[];
};
export function createHost(plugins: Plugin[], errors: PluginHost['errors'] = []): PluginHost {
const tools: ToolSet = {};
const autoApprove: string[] = [];
const appendices: string[] = [];
for (const p of plugins) {
for (const [name, t] of Object.entries(p.tools ?? {})) tools[name] = t;
autoApprove.push(...(p.autoApprove ?? []));
if (p.appendix) appendices.push(p.appendix);
}
return {
plugins,
tools,
autoApprove,
appendix: appendices.length > 0 ? `\n${appendices.join('\n')}` : '',
errors,
guard: async (ctx) => {
for (const p of plugins) {
if (!p.beforeToolCall) continue;
try {
const blocked = await p.beforeToolCall(ctx);
if (blocked) return `Blocked by the ${p.name} plugin: ${blocked}`;
} catch (e) {
// A broken hook must not take the agent down, but it must not silently
// allow the call either: treat a throwing guard as a block.
return `The ${p.name} plugin failed while checking this call: ${e instanceof Error ? e.message : String(e)}`;
}
}
return undefined;
},
afterTurn: async () => {
for (const p of plugins) {
try {
await p.afterTurn?.();
} catch {
continue;
}
}
},
};
}
+52
View File
@@ -0,0 +1,52 @@
export type Rate = { inputPerMTok: number; outputPerMTok: number };
/**
* USD per million tokens. Prefix match on the model id, longest first, so
* `claude-sonnet-4-5-20250929` resolves via `claude-sonnet-4-5`. Published rates
* drift, so this is a best-effort estimate rather than a billing source.
*/
const RATES: Record<string, Rate> = {
'claude-opus-4': { inputPerMTok: 15, outputPerMTok: 75 },
'claude-sonnet-4': { inputPerMTok: 3, outputPerMTok: 15 },
'claude-haiku-4': { inputPerMTok: 1, outputPerMTok: 5 },
'claude-3-5-haiku': { inputPerMTok: 0.8, outputPerMTok: 4 },
'gpt-5-mini': { inputPerMTok: 0.25, outputPerMTok: 2 },
'gpt-5-nano': { inputPerMTok: 0.05, outputPerMTok: 0.4 },
'gpt-5': { inputPerMTok: 1.25, outputPerMTok: 10 },
'gpt-4o-mini': { inputPerMTok: 0.15, outputPerMTok: 0.6 },
'gpt-4o': { inputPerMTok: 2.5, outputPerMTok: 10 },
'o4-mini': { inputPerMTok: 1.1, outputPerMTok: 4.4 },
'deepseek-chat': { inputPerMTok: 0.27, outputPerMTok: 1.1 },
'deepseek-reasoner': { inputPerMTok: 0.55, outputPerMTok: 2.19 },
'grok-4': { inputPerMTok: 3, outputPerMTok: 15 },
};
/** Strips a provider prefix such as `anthropic/` that OpenRouter-style ids carry. */
const bare = (modelId: string) => modelId.toLowerCase().split('/').at(-1) ?? modelId.toLowerCase();
export function rateFor(modelId: string): Rate | undefined {
const id = bare(modelId);
const key = Object.keys(RATES)
.filter((k) => id.startsWith(k))
.sort((a, b) => b.length - a.length)[0];
return key ? RATES[key] : undefined;
}
export function costOf(modelId: string, inputTokens: number, outputTokens: number): number | undefined {
const rate = rateFor(modelId);
if (!rate) return undefined;
return (inputTokens / 1_000_000) * rate.inputPerMTok + (outputTokens / 1_000_000) * rate.outputPerMTok;
}
export function formatUsd(amount: number): string {
if (amount === 0) return '$0.00';
if (amount < 0.01) return `$${amount.toFixed(4)}`;
return `$${amount.toFixed(2)}`;
}
/** One-line token and cost summary, omitting the cost when the model is unpriced. */
export function usageLine(modelId: string, inputTokens: number, outputTokens: number): string {
const tokens = `${inputTokens} in / ${outputTokens} out tokens`;
const cost = costOf(modelId, inputTokens, outputTokens);
return cost === undefined ? `${tokens} (${modelId} is unpriced)` : `${tokens} - ${formatUsd(cost)}`;
}
+141
View File
@@ -0,0 +1,141 @@
import { formatInstructions, type Instructions } from './instructions';
export type PromptParts = {
cwd: string;
instructions?: Instructions;
/** Session task list from the Notebook. */
notebook?: string;
/** Durable project memory. */
memory?: string;
/** Skill catalogue: names and descriptions only. */
skills?: string;
/** Behaviour appendix from the selected agent variant. */
agent?: string;
/** Appendices contributed by plugins. */
plugins?: string;
/** Tool names actually offered this turn, so the prompt cannot describe a tool that is absent. */
availableTools?: readonly string[];
/** True when the ask tool has somewhere to send a question. */
canAsk?: boolean;
};
type ToolDoc = { name: string; line: string };
/**
* Guidance per tool, beyond the schema description the model already receives.
*
* The schema says what a tool takes; this says when to reach for it and what goes
* wrong. Only tools actually offered are described, because a prompt that mentions
* a withheld tool teaches the model to attempt calls that cannot succeed.
*/
const TOOL_DOCS: ToolDoc[] = [
{ name: 'read_file', line: 'read before you edit. Never describe code you have not opened.' },
{
name: 'glob',
line: 'find files by pattern. Skips binaries and .gitignore; pass includeIgnored to look anyway.',
},
{
name: 'grep',
line: 'search contents. Prefer it over reading many files; scope with include to keep results small.',
},
{
name: 'edit_file',
line: 'oldString must match byte-for-byte including indentation, and be unique. Include surrounding lines to disambiguate. Prefer several small edits over one large rewrite.',
},
{ name: 'write_file', line: 'new files and full rewrites only. Reach for edit_file on anything that exists.' },
{
name: 'bash',
line: 'builds, tests, git, package managers. Output streams live. Long-running commands are fine; interactive ones are not.',
},
{
name: 'task',
line: 'delegate a read-only search to a subagent. Its prompt must be self-contained; it sees none of this conversation. Worth it when a search would span many files, wasteful for a single grep.',
},
{
name: 'ask',
line: 'stop and ask the user. Cheaper than a wrong guess when a request has two readings that lead to different work.',
},
{
name: 'todo_write',
line: 'your plan for a multi-step job. Send the whole list each time. One task in_progress. Mark done immediately, not in a batch.',
},
{ name: 'remember', line: 'record something still true next session: a decision, a working command, a trap.' },
{ name: 'recall', line: 'search what you recorded before. Try it before investigating something possibly known.' },
{ name: 'forget', line: 'remove a memory that turned out wrong.' },
{ name: 'skill', line: 'load detailed instructions for a kind of task. Call it before starting, not after.' },
{ name: 'current_time', line: 'the current date and time, when it matters.' },
];
function renderTools(available: readonly string[]): string {
const known = TOOL_DOCS.filter((d) => available.includes(d.name));
const extra = available.filter((name) => !TOOL_DOCS.some((d) => d.name === name)).sort();
const lines = known.map((d) => `- ${d.name}: ${d.line}`);
const mcp = extra.filter((n) => n.startsWith('mcp__'));
const other = extra.filter((n) => !n.startsWith('mcp__'));
if (mcp.length > 0) {
lines.push(
`- ${mcp.join(', ')}: from MCP servers, named mcp__<server>__<tool>. Each needs approval; read its own description before calling.`,
);
}
for (const name of other) lines.push(`- ${name}: see its own description.`);
return lines.join('\n');
}
export function systemPrompt(parts: PromptParts): string {
const {
cwd,
instructions = [],
notebook = '',
memory = '',
skills = '',
agent = '',
plugins = '',
availableTools,
canAsk = false,
} = parts;
const toolNames = availableTools ?? TOOL_DOCS.map((d) => d.name);
const canEdit = toolNames.includes('edit_file') || toolNames.includes('write_file');
const canRun = toolNames.includes('bash');
const workflow = [
'- Read before you write. Ground every claim about the code in something you actually opened.',
'- Make the smallest change that solves the task. A bugfix diff contains only the bug.',
'- Match the existing style, libraries, and conventions. Sample a neighbouring file before inventing a pattern.',
canEdit
? '- write_file, edit_file, and bash need the user to approve each call. If one is denied, stop and ask what to do instead of working around it.'
: '- You have no tools that change anything this turn. Investigate and report; do not describe edits as if you had made them.',
canRun
? "- After changing code, verify it: run the project's build or tests. \"Should work\" is not verification."
: '- You cannot run commands this turn, so say what should be run to verify rather than claiming it passes.',
'- When something fails twice, stop and re-read the error literally. Check that the code you think is running is the code that is running.',
canAsk
? '- Ask rather than guess when two readings of the request lead to different work. Decide small things yourself and say what you assumed.'
: '- No one can answer a question this run. Decide yourself and state the assumption plainly.',
].join('\n');
return `You are Shiro Neko, a coding agent working in the user's terminal.
Environment
- Workspace root: ${cwd}
- Platform: ${process.platform}
- Paths are resolved inside the workspace. Anything outside it is refused.
Tools available to you now
${renderTools(toolNames)}
How to work
${workflow}
How to reply
- Lead with the outcome. The user wants to know what happened, not what you are about to do.
- No preamble, no restating the task, no summary of your own summary.
- Markdown is rendered: use fenced code blocks for code, backticks for identifiers and paths.
- Report failures with their actual output. Never imply a command passed when you did not run it.
${formatInstructions(instructions, cwd)}${memory}${skills}${agent}${plugins}${notebook}`;
}
export { TOOL_DOCS, renderTools };
+141
View File
@@ -0,0 +1,141 @@
import type { ProviderName } from './config';
export type ProviderPreset = {
id: string;
label: string;
/** Which wire protocol to speak. */
kind: ProviderName;
baseURL: string;
/** Env var checked before asking for a key. */
envKey?: string;
/** Servers that ignore auth, e.g. a local Ollama. */
keyless?: boolean;
keyHint?: string;
fallbackModels?: string[];
};
export const PRESETS: ProviderPreset[] = [
{
id: 'anthropic',
label: 'Anthropic',
kind: 'anthropic',
baseURL: 'https://api.anthropic.com/v1',
envKey: 'ANTHROPIC_API_KEY',
keyHint: 'sk-ant-...',
fallbackModels: ['claude-sonnet-4-5', 'claude-opus-4-1', 'claude-haiku-4-5'],
},
{
id: 'openai',
label: 'OpenAI',
kind: 'openai',
baseURL: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
keyHint: 'sk-...',
fallbackModels: ['gpt-5', 'gpt-5-mini', 'o4-mini'],
},
{
id: 'openrouter',
label: 'OpenRouter (many models, one key)',
kind: 'openai',
baseURL: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
keyHint: 'sk-or-...',
},
{
id: 'groq',
label: 'Groq',
kind: 'openai',
baseURL: 'https://api.groq.com/openai/v1',
envKey: 'GROQ_API_KEY',
keyHint: 'gsk_...',
},
{
id: 'deepseek',
label: 'DeepSeek',
kind: 'openai',
baseURL: 'https://api.deepseek.com/v1',
envKey: 'DEEPSEEK_API_KEY',
keyHint: 'sk-...',
},
{
id: 'xai',
label: 'xAI (Grok)',
kind: 'openai',
baseURL: 'https://api.x.ai/v1',
envKey: 'XAI_API_KEY',
keyHint: 'xai-...',
},
{
id: 'ollama',
label: 'Ollama (local)',
kind: 'openai',
baseURL: 'http://localhost:11434/v1',
keyless: true,
},
{
id: 'lmstudio',
label: 'LM Studio (local)',
kind: 'openai',
baseURL: 'http://localhost:1234/v1',
keyless: true,
},
{
id: 'custom-openai',
label: 'Custom OpenAI-compatible endpoint',
kind: 'openai',
baseURL: '',
},
{
id: 'custom-anthropic',
label: 'Custom Anthropic-compatible endpoint',
kind: 'anthropic',
baseURL: '',
},
];
export const presetById = (id: string) => PRESETS.find((p) => p.id === id);
export type ModelListResult = { models: string[]; source: 'api' | 'fallback'; warning?: string };
type ModelsResponse = { data?: unknown };
/**
* Both OpenAI- and Anthropic-compatible servers expose `GET /v1/models` with a
* `data[].id` shape, only the auth header differs. A server that does not
* implement it is not fatal: the caller can still type a model id by hand.
*/
export async function fetchModels(
preset: Pick<ProviderPreset, 'kind' | 'baseURL' | 'fallbackModels'>,
apiKey: string,
timeoutMs = 15_000,
): Promise<ModelListResult> {
const url = `${preset.baseURL.replace(/\/+$/, '')}/models`;
const headers: Record<string, string> =
preset.kind === 'anthropic'
? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }
: { authorization: `Bearer ${apiKey}` };
const fallback = (warning: string): ModelListResult => ({
models: preset.fallbackModels ?? [],
source: 'fallback',
warning,
});
try {
const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) {
const body = (await res.text()).slice(0, 200);
return fallback(`${url} returned ${res.status}. ${body}`.trim());
}
const json = (await res.json()) as ModelsResponse;
const models = Array.isArray(json.data)
? json.data
.map((m) => (m && typeof m === 'object' ? (m as { id?: unknown }).id : undefined))
.filter((id): id is string => typeof id === 'string')
: [];
if (models.length === 0) return fallback(`${url} listed no models.`);
return { models: models.sort(), source: 'api' };
} catch (e) {
return fallback(e instanceof Error ? e.message : String(e));
}
}
+86
View File
@@ -0,0 +1,86 @@
import { pruneMessages, type ModelMessage } from 'ai';
type Part = { type: string; providerOptions?: Record<string, Record<string, unknown>> };
/** Parts the OpenAI responses API refuses to accept without their reasoning item. */
const DEPENDENT = new Set(['text', 'tool-call']);
function itemId(part: Part): string | undefined {
for (const options of Object.values(part.providerOptions ?? {})) {
const id = options['itemId'];
if (typeof id === 'string') return id;
}
return undefined;
}
const partsOf = (message: ModelMessage): Part[] =>
message.role === 'assistant' && Array.isArray(message.content) ? (message.content as Part[]) : [];
/**
* Drops assistant parts left orphaned by reasoning removal.
*
* The OpenAI responses API treats a `message` item as a dependent of the `reasoning`
* item from the same response: send the message without its reasoning and the request
* is rejected with 400 "was provided without its required 'reasoning' item".
* `pruneMessages({ reasoning: 'all' })` strips the reasoning and keeps the message,
* producing exactly that request.
*
* The two carry different item ids, so they cannot be matched by id. What links them
* is the assistant message they arrived in: one message is one response, and its
* reasoning item covers every other item in it.
*
* Reasoning only disappears from turns pruning is already discarding, so dropping the
* orphaned text costs nothing pruning was not already spending.
*/
export function dropOrphanedItems(before: ModelMessage[], after: ModelMessage[]): ModelMessage[] {
const survivingReasoning = new Set<string>();
for (const message of after) {
for (const part of partsOf(message)) {
if (part.type !== 'reasoning') continue;
const id = itemId(part);
if (id) survivingReasoning.add(id);
}
}
const orphaned = new Set<string>();
for (const message of before) {
const parts = partsOf(message);
const reasoning = parts.filter((p) => p.type === 'reasoning').map(itemId);
if (reasoning.length === 0) continue;
if (reasoning.some((id) => id !== undefined && survivingReasoning.has(id))) continue;
for (const part of parts) {
if (!DEPENDENT.has(part.type)) continue;
const id = itemId(part);
if (id) orphaned.add(id);
}
}
if (orphaned.size === 0) return after;
const cleaned: ModelMessage[] = [];
for (const message of after) {
const parts = partsOf(message);
if (parts.length === 0) {
cleaned.push(message);
continue;
}
const kept = parts.filter((part) => {
const id = itemId(part);
return id === undefined || !orphaned.has(id);
});
if (kept.length > 0) cleaned.push({ ...message, content: kept } as ModelMessage);
}
return cleaned;
}
export type PruneOptions = Parameters<typeof pruneMessages>[0];
/** pruneMessages, then repair the provider-item dependencies it breaks. */
export function prunePreservingItems(options: PruneOptions): ModelMessage[] {
const pruned = pruneMessages(options);
return dropOrphanedItems(options.messages, pruned);
}
+381
View File
@@ -0,0 +1,381 @@
import {
isStepCount,
generateText,
streamText,
type LanguageModel,
type ModelMessage,
type ToolApprovalResponse,
type ToolSet,
} from 'ai';
import { DEFAULT_VARIANT, sdkReasoning, renderAgent, type AgentVariant } from './agents';
import { createAskTool, type AskFn } from './ask';
import type { Instructions } from './instructions';
import type { Memory } from './memory';
import { Notebook, type NotebookState } from './notebook';
import type { PluginHost } from './plugins';
import { systemPrompt } from './prompt';
import { prunePreservingItems } from './prune';
import { createSkillTool, renderSkills, type Skill } from './skills';
import { MUTATING_TOOLS, onBashOutput, tools as builtinTools } from './tools';
export type ApprovalRequest = {
approvalId: string;
toolName: string;
input: unknown;
};
/** 'once' runs this call only; 'always' whitelists the tool for the rest of the session. */
export type ApprovalDecision = 'once' | 'always' | 'deny';
export type AgentEvent =
| { type: 'text'; text: string }
| { type: 'reasoning'; text: string }
| { type: 'tool-call'; id: string; name: string; input: unknown }
| { type: 'tool-output'; id: string; chunk: string }
| { type: 'tool-result'; id: string; name: string; output: unknown }
| { type: 'tool-error'; id: string; name: string; error: unknown }
| { type: 'tool-denied'; name: string }
| { type: 'compacted'; before: number; after: number }
| { type: 'notice'; text: string }
| { type: 'error'; error: unknown }
| { type: 'done'; inputTokens?: number; outputTokens?: number };
export type SessionOptions = {
model: LanguageModel;
askApproval: (req: ApprovalRequest) => Promise<ApprovalDecision>;
yolo?: boolean;
cwd?: string;
maxSteps?: number;
/** MCP and subagent tools merged on top of the built-ins. */
extraTools?: ToolSet;
/** Tool names that never prompt, e.g. the read-only subagent tool. */
autoApprove?: readonly string[];
/** Prune the history once the estimated token count crosses this. */
compactThreshold?: number;
/** Retries per model call for transient failures. */
maxRetries?: number;
/** AGENTS.md-style files appended to the system prompt. */
instructions?: Instructions;
/** Task list restored from a resumed session. */
notebook?: NotebookState;
/** Thinking level, tool restrictions, and behaviour appendix. */
agent?: AgentVariant;
skills?: Skill[];
memory?: Memory;
plugins?: PluginHost;
/** Where an `ask` tool call goes. Omit in headless runs. */
ask?: AskFn;
messages?: ModelMessage[];
onChange?: (messages: ModelMessage[]) => void;
/** Live stdout/stderr from bash, for a UI that wants progress. */
onToolOutput?: (id: string, chunk: string) => void;
onNotebookChange?: (state: NotebookState) => void;
};
const estimateTokens = (messages: ModelMessage[]) => Math.round(JSON.stringify(messages).length / 4);
export class Session {
readonly messages: ModelMessage[];
readonly tools: ToolSet;
readonly notebook: Notebook;
inputTokens = 0;
outputTokens = 0;
private model: LanguageModel;
private variant: AgentVariant;
private readonly alwaysAllow = new Set<string>();
private controller: AbortController | undefined;
constructor(private readonly opts: SessionOptions) {
this.messages = opts.messages ?? [];
this.notebook = new Notebook(opts.onNotebookChange);
this.notebook.restore(opts.notebook);
this.model = opts.model;
this.variant = opts.agent ?? DEFAULT_VARIANT;
const sessionTools = {
...this.notebook.tools(),
...(opts.memory ? opts.memory.tools() : {}),
...(opts.skills && opts.skills.length > 0 ? { skill: createSkillTool(opts.skills) } : {}),
...(opts.ask ? { ask: createAskTool(opts.ask) } : {}),
};
this.tools = { ...builtinTools, ...sessionTools, ...(opts.plugins?.tools ?? {}), ...(opts.extraTools ?? {}) };
for (const name of [
...(opts.autoApprove ?? []),
...(opts.plugins?.autoApprove ?? []),
...Object.keys(sessionTools),
]) {
this.alwaysAllow.add(name);
}
}
setModel(model: LanguageModel): void {
this.model = model;
}
setAgent(variant: AgentVariant): void {
this.variant = variant;
}
agent(): AgentVariant {
return this.variant;
}
/** Tool names offered this turn; a read-only variant hides the rest. */
activeTools(): string[] {
const all = Object.keys(this.tools);
if (!this.variant.allowTools) return all;
return all.filter((name) => this.variant.allowTools!.includes(name));
}
reset(): void {
this.messages.length = 0;
this.inputTokens = 0;
this.outputTokens = 0;
this.notebook.clear();
this.opts.onChange?.(this.messages);
}
replace(messages: ModelMessage[]): void {
this.messages.length = 0;
this.messages.push(...messages);
this.opts.onChange?.(this.messages);
}
abort(): void {
this.controller?.abort();
}
estimatedTokens(): number {
return estimateTokens(this.messages);
}
private systemFor(): string {
return systemPrompt({
cwd: this.opts.cwd ?? process.cwd(),
instructions: this.opts.instructions ?? [],
notebook: this.notebook.render(),
memory: this.opts.memory?.render() ?? '',
skills: renderSkills(this.opts.skills ?? []),
agent: renderAgent(this.variant),
plugins: this.opts.plugins?.appendix ?? '',
availableTools: this.activeTools(),
canAsk: this.opts.ask !== undefined && this.activeTools().includes('ask'),
});
}
/** Tools that mutate the workspace, plus every externally provided MCP tool. */
private needsApproval(name: string): boolean {
return (MUTATING_TOOLS as readonly string[]).includes(name) || name.startsWith('mcp__');
}
/**
* Approval decisions, evaluated per call by the SDK.
*
* A plugin guard denies outright and is checked before anything else, so `--yolo`
* cannot bypass it. Only after the guard passes does yolo or the mutating-tool
* rule decide whether the user is asked.
*/
private toolApproval(notices: string[]) {
return async ({ toolCall }: { toolCall: { toolName: string; input: unknown } }) => {
const blocked = await this.opts.plugins?.guard({
toolName: toolCall.toolName,
input: toolCall.input,
cwd: this.opts.cwd ?? process.cwd(),
});
if (blocked) {
notices.push(blocked);
return { type: 'denied' as const, reason: blocked };
}
if (this.opts.yolo) return undefined;
if (!this.needsApproval(toolCall.toolName)) return undefined;
if (this.alwaysAllow.has(toolCall.toolName)) return undefined;
return 'user-approval' as const;
};
}
/** Replaces the history with a model-written summary. Backs the /compact command. */
async summarize(): Promise<{ before: number; after: number }> {
const before = this.messages.length;
if (before === 0) return { before, after: 0 };
const { text } = await generateText({
model: this.model,
system:
'Summarize this coding session for use as the sole context of a fresh session. ' +
'Keep: the user goal, files touched with paths, decisions made, commands run and their outcome, ' +
'and what remains to be done. Drop pleasantries and full file contents. Write it as notes, not prose.',
messages: this.messages,
maxRetries: this.opts.maxRetries ?? 3,
});
this.messages.length = 0;
this.messages.push({ role: 'user', content: `Summary of the session so far:\n\n${text}` });
this.opts.onChange?.(this.messages);
return { before, after: this.messages.length };
}
async *send(userText: string): AsyncGenerator<AgentEvent> {
this.messages.push({ role: 'user', content: userText });
this.opts.onChange?.(this.messages);
this.controller = new AbortController();
const signal = this.controller.signal;
const threshold = this.opts.compactThreshold ?? 120_000;
const outputs: Extract<AgentEvent, { type: 'tool-output' }>[] = [];
onBashOutput(({ toolCallId, chunk }) => {
outputs.push({ type: 'tool-output', id: toolCallId, chunk });
this.opts.onToolOutput?.(toolCallId, chunk);
});
try {
yield* this.run(signal, threshold, outputs);
} finally {
onBashOutput(undefined);
await this.opts.plugins?.afterTurn();
}
}
private async *run(
signal: AbortSignal,
threshold: number,
outputs: Extract<AgentEvent, { type: 'tool-output' }>[],
): AsyncGenerator<AgentEvent> {
// Each iteration is one model run. A run ends either finished, or suspended
// on tool approvals, in which case we collect decisions and run again.
while (true) {
const pending: ApprovalRequest[] = [];
const compactions: Extract<AgentEvent, { type: 'compacted' }>[] = [];
const guardNotices: string[] = [];
let sawError = false;
const result = streamText({
model: this.model,
system: this.systemFor(),
messages: this.messages,
tools: this.tools,
activeTools: this.activeTools(),
reasoning: sdkReasoning(this.variant.thinking),
toolApproval: this.toolApproval(guardNotices),
stopWhen: isStepCount(this.variant.maxSteps ?? this.opts.maxSteps ?? 50),
maxRetries: this.opts.maxRetries ?? 3,
abortSignal: signal,
prepareStep: ({ messages }) => {
// Rebuilt every step: a todo_write earlier in this same run must be
// visible to the steps that follow it, not only to the next turn.
const instructions = this.systemFor();
if (estimateTokens(messages) <= threshold) return { instructions };
const pruned = prunePreservingItems({
messages,
reasoning: 'all',
toolCalls: 'before-last-3-messages',
emptyMessages: 'remove',
});
// prepareStep cannot yield, so queue the notice and drain it in the loop.
compactions.push({ type: 'compacted', before: messages.length, after: pruned.length });
return { instructions, messages: pruned };
},
});
// Every promise-shaped accessor settles independently of the stream. Any one
// left without a rejection sink surfaces as an unhandled rejection on abort
// or API failure, which scribbles over the Ink render.
const sink = () => {};
void result.responseMessages.then(undefined, sink);
void result.usage.then(undefined, sink);
void result.steps.then(undefined, sink);
void result.finalStep.then(undefined, sink);
void result.text.then(undefined, sink);
void result.finishReason.then(undefined, sink);
try {
for await (const part of result.stream) {
while (compactions.length > 0) yield compactions.shift()!;
while (outputs.length > 0) yield outputs.shift()!;
while (guardNotices.length > 0) yield { type: 'notice', text: guardNotices.shift()! };
switch (part.type) {
case 'text-delta':
yield { type: 'text', text: part.text };
break;
case 'reasoning-delta':
yield { type: 'reasoning', text: part.text };
break;
case 'tool-call':
yield { type: 'tool-call', id: part.toolCallId, name: part.toolName, input: part.input };
break;
case 'tool-result':
yield { type: 'tool-result', id: part.toolCallId, name: part.toolName, output: part.output };
break;
case 'tool-error':
yield { type: 'tool-error', id: part.toolCallId, name: part.toolName, error: part.error };
break;
case 'tool-approval-request':
// A guard denial is answered by the SDK itself and arrives flagged
// automatic; queueing it would prompt the user for a settled call.
if (part.isAutomatic) break;
pending.push({
approvalId: part.approvalId,
toolName: part.toolCall.toolName,
input: part.toolCall.input,
});
break;
case 'tool-approval-response':
if (!part.approved) yield { type: 'tool-denied', name: part.toolCall.toolName };
break;
case 'tool-output-denied':
yield { type: 'tool-denied', name: part.toolName };
break;
case 'abort':
yield { type: 'done' };
return;
case 'error':
sawError = true;
yield { type: 'error', error: part.error };
break;
default:
break;
}
}
} catch (error) {
if (signal.aborted) {
yield { type: 'done' };
return;
}
yield { type: 'error', error };
return;
}
// A stream that ended in an error has no response messages or usage to
// await; touching them would throw NoOutputGeneratedError.
if (sawError) return;
while (compactions.length > 0) yield compactions.shift()!;
while (outputs.length > 0) yield outputs.shift()!;
while (guardNotices.length > 0) yield { type: 'notice', text: guardNotices.shift()! };
this.messages.push(...(await result.responseMessages));
this.opts.onChange?.(this.messages);
if (pending.length === 0) {
const usage = await result.usage;
this.inputTokens += usage.inputTokens ?? 0;
this.outputTokens += usage.outputTokens ?? 0;
yield { type: 'done', inputTokens: usage.inputTokens, outputTokens: usage.outputTokens };
return;
}
const responses: ToolApprovalResponse[] = [];
for (const req of pending) {
const decision = this.alwaysAllow.has(req.toolName) ? 'always' : await this.opts.askApproval(req);
if (decision === 'always') this.alwaysAllow.add(req.toolName);
responses.push({
type: 'tool-approval-response',
approvalId: req.approvalId,
approved: decision !== 'deny',
...(decision === 'deny' ? { reason: 'User denied this tool call.' } : {}),
});
}
this.messages.push({ role: 'tool', content: responses });
}
}
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Skills bundled with the binary.
*
* These are string constants rather than files on disk because `bun build --compile`
* only embeds modules reachable through imports; a directory of .md files would be
* missing from the shipped binary.
*/
export const BUILTIN_SKILLS: { name: string; source: string }[] = [
{
name: 'debug',
source: `---
name: debug
description: Track down a bug whose cause is not obvious. Use when a test fails for unclear reasons, behaviour differs between environments, or an earlier fix did not hold.
---
# Debugging
Do not guess. A guess that happens to work leaves the real cause in place.
## Reproduce first
Find the smallest command that shows the failure and record it with \`remember\`. If you
cannot reproduce it, say so and ask what the user did differently — do not proceed on a
hypothesis you cannot test.
## Three hypotheses, then evidence
Write down at least three causes that would produce this exact symptom. Rank them by how
cheap they are to disprove, then disprove them in that order. State which one you are
testing before you test it.
Evidence means observed output: a log line, a failing assertion, a value printed at the
point of failure. "It should be X" is not evidence.
## Bisect when the space is large
- Recent regression: check what changed last.
- Unclear layer: assert the value at each boundary until one is wrong.
- Intermittent: run it in a loop and capture the failing case, do not reason about it abstractly.
## Fix the cause
Once you know the cause, fix that and nothing else. Do not tidy surrounding code in the
same change — a bugfix diff should contain only the bug.
Write a test that fails before the fix and passes after. If you cannot express the bug as
a test, say why.
## After two failed attempts
Stop. Re-read the error text literally, character by character. Check your assumption
about which code is actually running: the wrong file, a stale build, a shadowed import,
or a cached dependency accounts for most "impossible" bugs.
`,
},
{
name: 'review',
source: `---
name: review
description: Review a diff or a file for defects. Use when asked to review, critique, or check code before it ships.
---
# Code review
Severity order. Do not lead with style.
1. **Incorrect behaviour** — wrong result, wrong edge case, wrong state after failure.
2. **Missing validation at trust boundaries** — user input, network responses, file contents,
anything crossing a process line. Internal calls need no defensive checks.
3. **Security** — injection, path traversal, secrets in logs or errors, missing authz.
4. **Resource handling** — unclosed handles, unbounded growth, unawaited promises.
5. **Clarity** — only when it will cause a future defect.
## For each finding
State file and line, what breaks, and the change. Show the fix as code when it is short.
Skip anything a formatter would fix. Skip preference. If a choice is defensible, leave it.
## Say when it is fine
A review that invents problems to look thorough is worse than a short one. If the change
is correct, say so and stop.
## Verify, do not assume
Read the surrounding code before calling something a bug. A "missing" null check often
exists one level up. Run the tests if that is what settles it.
`,
},
{
name: 'refactor',
source: `---
name: refactor
description: Restructure code without changing behaviour. Use when asked to refactor, clean up, extract, or reorganise.
---
# Refactoring
Behaviour must not change. That is the whole constraint.
## Establish the safety net first
Run the existing tests and record that they pass. If the code has no tests, write one that
pins current behaviour — including the ugly parts — before touching anything. Refactoring
untested code is rewriting it.
## Then move in small steps
One transformation at a time, tests green between each. Rename, then extract, then move —
not all three in one edit. A large refactor that fails leaves you unable to tell which step
broke it.
## What not to do
- Do not fix bugs while refactoring. Note them, finish, fix separately.
- Do not add abstraction for a single caller. Duplication beats a premature interface.
- Do not widen the scope. The request was this code, not its neighbours.
- Do not change public API unless asked; if it must change, say so first.
## Done means
Tests pass, behaviour is identical, and the diff is smaller than the reader feared.
`,
},
{
name: 'test',
source: `---
name: test
description: Write or repair tests. Use when adding coverage, fixing a flaky test, or asked how something should be tested.
---
# Testing
A test earns its place by failing when the code is wrong.
## Match the project
Read two existing test files first. Use their runner, their assertion style, their file
layout, their naming. A test that looks foreign is a test nobody maintains.
## Test behaviour, not implementation
Assert on what a caller observes. A test that reaches into private state breaks on every
refactor and catches nothing.
Cover: the normal case, the boundaries, and the failure. Failure cases catch more real
defects than happy paths.
## Never do this
- Do not assert what the code currently returns without knowing it is correct — that pins
the bug.
- Do not weaken an assertion to make a test pass. If it fails, either the code or the
expectation is wrong; find out which.
- Do not delete a failing test. It is telling you something.
## Flaky tests
A test that passes alone and fails in a suite is a shared-state problem: a global, a
temp directory, a port, an unawaited promise, or ordering. Find which, do not add a retry.
## Verify
Run the test and watch it fail before the fix, pass after. A test you never saw fail is
not known to work.
`,
},
];
+111
View File
@@ -0,0 +1,111 @@
import { tool } from 'ai';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { z } from 'zod';
import { BUILTIN_SKILLS } from './skills-builtin';
export type SkillOrigin = 'builtin' | 'user' | 'project';
export type Skill = {
name: string;
description: string;
origin: SkillOrigin;
path?: string;
body: string;
};
const MAX_BODY = 20_000;
/**
* Minimal YAML frontmatter reader: `name` and `description` only.
* A real YAML parser would be a dependency for two string fields.
*/
export function parseSkill(source: string, origin: SkillOrigin, path?: string): Skill | undefined {
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(source.trimStart());
if (!match) return undefined;
const meta: Record<string, string> = {};
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();
}
const name = meta['name'];
const description = meta['description'];
if (!name || !description) return undefined;
return { name, description, origin, ...(path ? { path } : {}), body: match[2]!.trim().slice(0, MAX_BODY) };
}
const skillDirs = (cwd: string) => [
{ dir: join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', 'skills'), origin: 'user' as const },
{ dir: join(cwd, '.shiro', 'skills'), origin: 'project' as const },
];
/**
* Builtin, then user, then project. Later wins, so a project can override a
* bundled skill by using the same name.
*/
export async function loadSkills(cwd = process.cwd()): Promise<Skill[]> {
const byName = new Map<string, Skill>();
for (const { name, source } of BUILTIN_SKILLS) {
const skill = parseSkill(source, 'builtin');
if (skill) byName.set(skill.name, skill);
else byName.delete(name);
}
for (const { dir, origin } of skillDirs(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 path = join(dir, file);
try {
const skill = parseSkill(await Bun.file(path).text(), origin, path);
if (skill) byName.set(skill.name, skill);
} catch {
continue;
}
}
}
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Catalogue for the system prompt: names and one-line descriptions only.
* Bodies stay out of context until the model asks, which is the point.
*/
export function renderSkills(skills: Skill[]): string {
if (skills.length === 0) return '';
const lines = skills.map((s) => `- ${s.name}: ${s.description}`);
return [
'',
'Skills available through the skill tool. Load one when its description matches the task,',
'before you start working, and follow it as if the user had written it:',
...lines,
].join('\n');
}
export function createSkillTool(skills: Skill[]) {
const names = skills.map((s) => s.name);
return tool({
description:
'Load a skill: detailed instructions for one kind of task. Call it as soon as a skill description matches ' +
`what you are about to do, then follow what it says. Available: ${names.join(', ') || 'none'}.`,
inputSchema: z.object({
name: z.string().describe('Skill name from the list in your instructions'),
}),
execute: async ({ name }) => {
const skill = skills.find((s) => s.name === name.trim().toLowerCase());
if (!skill) throw new Error(`No skill named "${name}". Available: ${names.join(', ') || 'none'}`);
return `Skill "${skill.name}" (${skill.origin}). Follow these instructions for this task.\n\n${skill.body}`;
},
});
}
export { skillDirs };
+109
View File
@@ -0,0 +1,109 @@
import type { ModelMessage } from 'ai';
import { createHash } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { NotebookState } from './notebook';
export type SessionRecord = {
id: string;
createdAt: string;
updatedAt: string;
cwd: string;
provider: string;
model: string;
title: string;
inputTokens: number;
outputTokens: number;
/** Estimated USD, absent when the model has no known rate. */
costUsd?: number;
/** Task list and notes, so a resumed session keeps its plan. */
notebook?: NotebookState;
messages: ModelMessage[];
};
/** Resolved per call so tests can point SHIRO_HOME at a temp directory. */
const root = () => join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko');
const dir = () => join(root(), 'sessions');
const file = (id: string) => join(dir(), `${id}.json`);
export function newId(): string {
return Bun.randomUUIDv7();
}
export async function save(rec: SessionRecord): Promise<void> {
await Bun.write(file(rec.id), JSON.stringify({ ...rec, updatedAt: new Date().toISOString() }, null, 2));
}
export async function load(id: string): Promise<SessionRecord | undefined> {
const f = Bun.file(file(id));
if (!(await f.exists())) return undefined;
try {
return (await f.json()) as SessionRecord;
} catch {
return undefined;
}
}
export async function list(limit = 20): Promise<SessionRecord[]> {
const found: SessionRecord[] = [];
// Bun.Glob throws ENOENT on a directory that does not exist yet, which is the
// normal state on a fresh install.
try {
for await (const name of new Bun.Glob('*.json').scan({ cwd: dir(), onlyFiles: true })) {
const rec = await load(name.replace(/\.json$/, ''));
if (rec) found.push(rec);
}
} catch {
return [];
}
return found.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit);
}
export async function latest(cwd?: string): Promise<SessionRecord | undefined> {
const all = await list(100);
return cwd ? all.find((r) => r.cwd === cwd) : all[0];
}
/** Resolves a full id or a unique prefix, so users can type the first few chars. */
export async function resolveId(prefix: string): Promise<string | undefined> {
if (await Bun.file(file(prefix)).exists()) return prefix;
const matches = (await list(100)).filter((r) => r.id.startsWith(prefix));
return matches.length === 1 ? matches[0]!.id : undefined;
}
export function titleOf(messages: ModelMessage[]): string {
const first = messages.find((m) => m.role === 'user');
const text = typeof first?.content === 'string' ? first.content : '';
return text.length > 60 ? `${text.slice(0, 60)}...` : text || 'untitled';
}
const MAX_HISTORY = 200;
/** Per-directory file, hashed because a path is not a safe filename. */
const historyFile = (cwd: string) =>
join(root(), 'history', `${createHash('sha256').update(cwd).digest('hex').slice(0, 16)}.json`);
export async function loadHistory(cwd = process.cwd()): Promise<string[]> {
const f = Bun.file(historyFile(cwd));
if (!(await f.exists())) return [];
try {
const parsed: unknown = await f.json();
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
/** Appends unless it repeats the previous entry, keeping the newest MAX_HISTORY. */
export async function appendHistory(prompt: string, cwd = process.cwd()): Promise<string[]> {
const text = prompt.trim();
if (!text) return loadHistory(cwd);
const existing = await loadHistory(cwd);
if (existing.at(-1) === text) return existing;
const next = [...existing, text].slice(-MAX_HISTORY);
await Bun.write(historyFile(cwd), JSON.stringify(next, null, 2));
return next;
}
export { dir as sessionsDir, root as shiroHome };
+127
View File
@@ -0,0 +1,127 @@
import { isStepCount, streamText, tool, type LanguageModel, type ToolSet } from 'ai';
import { z } from 'zod';
import { globTool, grepTool, readFileTool } from './tools';
export type SubagentKind = 'explore' | 'review';
export type SubagentEvent =
| { type: 'start'; id: string; kind: SubagentKind; description: string }
| { type: 'step'; id: string; tool: string; summary: string }
| { type: 'end'; id: string; ok: boolean; steps: number }
| { type: 'error'; id: string; message: string };
export type SubagentReporter = (event: SubagentEvent) => void;
const READ_ONLY: ToolSet = { read_file: readFileTool, glob: globTool, grep: grepTool };
const PROMPTS: Record<SubagentKind, (cwd: string) => string> = {
explore: (cwd) => `You are a research subagent inside a coding agent.
Workspace root: ${cwd}
Tools: read_file, glob, grep. You cannot write files, run commands, or ask questions.
Find what was asked and report once. Rules:
- Give file paths with line numbers, plus a short quote where the quote is the answer.
- Report what you actually read. If you could not determine something, say so; do not fill the gap.
- No preamble, no restating the task, no offers of further help.
- Aim for under 30 lines. The parent agent pays for every line you write.`,
review: (cwd) => `You are a review subagent inside a coding agent.
Workspace root: ${cwd}
Tools: read_file, glob, grep. You cannot write files, run commands, or ask questions.
Review what was asked and report once. Severity order: incorrect behaviour, missing validation at
trust boundaries, security, resource handling, then clarity. For each finding give file, line, what
breaks, and the fix. Say plainly when something is correct. Do not invent findings to look thorough.`,
};
const summarize = (input: unknown): string => {
if (input === null || typeof input !== 'object') return String(input);
const o = input as Record<string, unknown>;
const first = o['pattern'] ?? o['path'] ?? o['include'];
return typeof first === 'string' ? first : JSON.stringify(o).slice(0, 80);
};
let counter = 0;
/**
* Read-only child agent.
*
* It runs its own tool loop and returns one message, so the parent pays for the
* findings rather than the whole search transcript. No write, bash, or ask tool is
* passed in, which is also why a subagent can never trigger an approval prompt.
*/
export function createTaskTool(opts: {
model: LanguageModel;
cwd?: string;
maxSteps?: number;
report?: SubagentReporter;
}) {
return tool({
description:
'Delegate a read-only investigation to a subagent that can read, glob, and grep. Use it for questions ' +
'spanning many files ("where is auth handled", "every caller of X") and to keep a long search out of your ' +
'own context. The subagent sees none of this conversation, so its prompt must be self-contained. ' +
'It returns one text report. Do not delegate something you can answer with a single grep.',
inputSchema: z.object({
description: z.string().describe('Short label shown to the user, 3-6 words'),
prompt: z.string().describe('Self-contained instructions: what to find, where to look, what to return'),
kind: z
.enum(['explore', 'review'])
.optional()
.describe('explore: find and report. review: critique code for defects. Default explore.'),
}),
execute: async ({ description, prompt, kind }, { abortSignal }) => {
const id = `sub${++counter}`;
const flavour: SubagentKind = kind ?? 'explore';
const report = opts.report;
report?.({ type: 'start', id, kind: flavour, description });
let steps = 0;
let text = '';
try {
const result = streamText({
model: opts.model,
system: PROMPTS[flavour](opts.cwd ?? process.cwd()),
messages: [{ role: 'user', content: prompt }],
tools: READ_ONLY,
stopWhen: isStepCount(opts.maxSteps ?? 20),
...(abortSignal ? { abortSignal } : {}),
});
const sink = () => {};
void result.responseMessages.then(undefined, sink);
void result.usage.then(undefined, sink);
void result.steps.then(undefined, sink);
void result.finalStep.then(undefined, sink);
void result.finishReason.then(undefined, sink);
for await (const part of result.stream) {
if (part.type === 'tool-call') {
steps++;
report?.({ type: 'step', id, tool: part.toolName, summary: summarize(part.input) });
} else if (part.type === 'text-delta') {
text += part.text;
} else if (part.type === 'error') {
// A provider failure arrives as a stream part, not a throw, so it has to
// be rethrown here or the subagent silently returns nothing.
const message = part.error instanceof Error ? part.error.message : String(part.error);
throw part.error instanceof Error ? part.error : new Error(message);
}
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
report?.({ type: 'error', id, message });
throw e;
}
const trimmed = text.trim();
report?.({ type: 'end', id, ok: trimmed.length > 0, steps });
return trimmed || 'Subagent returned no findings.';
},
});
}
export const TASK_TOOL_NAME = 'task';
+281
View File
@@ -0,0 +1,281 @@
import { tool } from 'ai';
import { resolve } from 'node:path';
import { z } from 'zod';
import { jail, posix, walk } from './ignore';
/** Max chars returned by any single tool. Beyond this the output is truncated. */
const MAX_OUTPUT = 30_000;
const MAX_GREP_HITS = 200;
/** Bytes sniffed for a NUL to decide a file is not text. */
const SNIFF_BYTES = 8192;
function cap(s: string): string {
return s.length <= MAX_OUTPUT ? s : `${s.slice(0, MAX_OUTPUT)}\n... [truncated ${s.length - MAX_OUTPUT} chars]`;
}
/**
* A NUL byte in the first few KB means this is not text. Cheap, and the same
* heuristic git and ripgrep use; without it a model can burn its whole context
* on one accidental `read_file dist/binary`.
*/
async function isBinary(abs: string): Promise<boolean> {
const bytes = new Uint8Array(await Bun.file(abs).slice(0, SNIFF_BYTES).arrayBuffer());
return bytes.includes(0);
}
export const readFileTool = tool({
description: 'Read a UTF-8 text file. Returns contents with 1-based line numbers.',
inputSchema: z.object({
path: z.string().describe('File path relative to the workspace root'),
offset: z.number().int().min(1).optional().describe('First line to return (1-based)'),
limit: z.number().int().min(1).optional().describe('Max lines to return, default 2000'),
}),
execute: async ({ path, offset = 1, limit = 2000 }) => {
const abs = jail(path);
const file = Bun.file(abs);
if (!(await file.exists())) throw new Error(`No such file: ${path}`);
if (await isBinary(abs)) throw new Error(`${path} is a binary file, not text. Use bash if you need to inspect it.`);
const lines = (await file.text()).split('\n');
const slice = lines.slice(offset - 1, offset - 1 + limit);
return cap(slice.map((l, i) => `${offset + i}: ${l}`).join('\n'));
},
});
export const writeFileTool = tool({
description: 'Create a file or overwrite it completely. Prefer edit_file for existing files.',
inputSchema: z.object({
path: z.string(),
content: z.string(),
}),
execute: async ({ path, content }) => {
const abs = jail(path);
await Bun.write(abs, content);
return `Wrote ${content.length} chars to ${path}`;
},
});
export const editFileTool = tool({
description:
'Replace an exact string in a file. oldString must appear exactly once unless replaceAll is true. Include surrounding context to make oldString unique.',
inputSchema: z.object({
path: z.string(),
oldString: z.string().describe('Exact text to find, including whitespace and indentation'),
newString: z.string().describe('Replacement text'),
replaceAll: z.boolean().optional().describe('Replace every occurrence instead of requiring exactly one'),
}),
execute: async ({ path, oldString, newString, replaceAll = false }) => {
if (oldString === newString) throw new Error('oldString and newString are identical');
const abs = jail(path);
const file = Bun.file(abs);
if (!(await file.exists())) throw new Error(`No such file: ${path}`);
const before = await file.text();
const count = before.split(oldString).length - 1;
if (count === 0) throw new Error(`oldString not found in ${path}`);
if (count > 1 && !replaceAll) {
throw new Error(`oldString appears ${count} times in ${path}. Add surrounding context or set replaceAll.`);
}
const after = replaceAll ? before.split(oldString).join(newString) : before.replace(oldString, newString);
await Bun.write(abs, after);
return `Replaced ${replaceAll ? count : 1} occurrence(s) in ${path}`;
},
});
export const globTool = tool({
description:
'Find files by glob pattern, e.g. "src/**/*.ts". Skips anything .gitignore excludes. Returns paths relative to the workspace root.',
inputSchema: z.object({
pattern: z.string(),
limit: z.number().int().min(1).optional().describe('Max paths to return, default 200'),
includeIgnored: z.boolean().optional().describe('Also search files git ignores'),
}),
execute: async ({ pattern, limit = 200, includeIgnored = false }) => {
const glob = new Bun.Glob(pattern);
const hits: string[] = [];
for await (const rel of walk({ noIgnore: includeIgnored })) {
if (!glob.match(rel)) continue;
hits.push(rel);
if (hits.length >= limit) break;
}
return hits.length ? hits.join('\n') : 'No files matched.';
},
});
type GrepArgs = { pattern: string; include?: string; ignoreCase?: boolean; includeIgnored?: boolean };
/**
* ripgrep is 10-100x faster than walking in JS and already understands
* .gitignore and binary detection, so use it whenever it is installed.
* Output shape stays identical to the fallback so the model sees one format.
*/
async function grepWithRipgrep({ pattern, include, ignoreCase, includeIgnored }: GrepArgs): Promise<string | undefined> {
// --no-require-git: rg skips .gitignore outside a repo by default, but the JS
// fallback always honours it, and the two paths must agree.
const args = [
'--line-number',
'--no-heading',
'--color',
'never',
'--no-require-git',
'--max-count',
String(MAX_GREP_HITS),
];
if (ignoreCase) args.push('--ignore-case');
if (includeIgnored) args.push('--no-ignore');
if (include) args.push('--glob', include);
args.push('--regexp', pattern, '.');
let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>;
try {
proc = Bun.spawn(['rg', ...args], { cwd: process.cwd(), stdout: 'pipe', stderr: 'pipe', timeout: 60_000 });
} catch {
return undefined;
}
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
// 0 = matches, 1 = no matches. Anything else means rg could not run the search.
if (code > 1) {
if (/regex parse error|error parsing/i.test(stderr)) throw new Error(`Invalid regex: ${stderr.trim()}`);
return undefined;
}
if (code === 1) return 'No matches.';
const hits = stdout
.split('\n')
.map((line) => line.replace(/\r$/, ''))
.filter(Boolean)
.map((line) => {
const m = /^(.*?):(\d+):(.*)$/.exec(line);
if (!m) return line;
// rg prefixes every path with the search root and uses native separators.
const rel = posix(m[1]!).replace(/^\.\//, '');
return `${rel}:${m[2]}: ${m[3]!.slice(0, 300)}`;
})
.slice(0, MAX_GREP_HITS);
return cap(hits.join('\n'));
}
async function grepInJs({ pattern, include = '**/*', ignoreCase, includeIgnored }: GrepArgs): Promise<string> {
let re: RegExp;
try {
re = new RegExp(pattern, ignoreCase ? 'i' : '');
} catch (e) {
throw new Error(`Invalid regex: ${(e as Error).message}`);
}
const glob = new Bun.Glob(include);
const hits: string[] = [];
for await (const rel of walk({ noIgnore: includeIgnored })) {
if (!glob.match(rel)) continue;
const abs = resolve(process.cwd(), rel);
let text: string;
try {
if (await isBinary(abs)) continue;
text = await Bun.file(abs).text();
} catch {
continue;
}
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? '';
if (re.test(line)) hits.push(`${rel}:${i + 1}: ${line.slice(0, 300)}`);
if (hits.length >= MAX_GREP_HITS) return cap(`${hits.join('\n')}\n... [hit limit ${MAX_GREP_HITS}]`);
}
}
return hits.length ? cap(hits.join('\n')) : 'No matches.';
}
export const grepTool = tool({
description:
'Search file contents with a regular expression. Skips binaries and anything .gitignore excludes. Returns path:line:text hits.',
inputSchema: z.object({
pattern: z.string().describe('Regex source. ripgrep syntax when available, otherwise JavaScript'),
include: z.string().optional().describe('Glob limiting which files are searched, default "**/*"'),
ignoreCase: z.boolean().optional(),
includeIgnored: z.boolean().optional().describe('Also search files git ignores'),
}),
execute: async (args) => (await grepWithRipgrep(args)) ?? (await grepInJs(args)),
});
export type BashOutput = { toolCallId: string; chunk: string };
/** Set by Session so long-running commands can report progress before exiting. */
let bashListener: ((out: BashOutput) => void) | undefined;
export function onBashOutput(fn: ((out: BashOutput) => void) | undefined): void {
bashListener = fn;
}
async function pump(
stream: ReadableStream<Uint8Array> | undefined,
toolCallId: string,
): Promise<string> {
if (!stream) return '';
const decoder = new TextDecoder();
let all = '';
for await (const chunk of stream) {
const text = decoder.decode(chunk, { stream: true });
if (!text) continue;
all += text;
bashListener?.({ toolCallId, chunk: text });
}
return all;
}
export const bashTool = tool({
description: 'Run a shell command in the workspace root. Use for builds, tests, git, and package managers.',
inputSchema: z.object({
command: z.string(),
timeout: z.number().int().min(1000).max(600_000).optional().describe('Timeout in ms, default 120000'),
}),
execute: async ({ command, timeout = 120_000 }, { toolCallId, abortSignal }) => {
const shell = process.platform === 'win32' ? ['cmd', '/c', command] : ['bash', '-lc', command];
const proc = Bun.spawn(shell, {
cwd: process.cwd(),
stdout: 'pipe',
stderr: 'pipe',
timeout,
...(abortSignal ? { signal: abortSignal } : {}),
});
// Drained concurrently: a command that fills one pipe while we block on the
// other would deadlock, and buffering both hides progress for minutes.
const [stdout, stderr, exitCode] = await Promise.all([
pump(proc.stdout as ReadableStream<Uint8Array>, toolCallId),
pump(proc.stderr as ReadableStream<Uint8Array>, toolCallId),
proc.exited,
]);
return cap(
[
`exit: ${exitCode}`,
proc.signalCode && `(killed by ${proc.signalCode}; timeout is ${timeout}ms)`,
stdout.trim() && `stdout:\n${stdout.trim()}`,
stderr.trim() && `stderr:\n${stderr.trim()}`,
]
.filter(Boolean)
.join('\n\n'),
);
},
});
export const tools = {
read_file: readFileTool,
write_file: writeFileTool,
edit_file: editFileTool,
glob: globTool,
grep: grepTool,
bash: bashTool,
};
/** Tools that mutate the workspace or run arbitrary code always ask the user first. */
export const MUTATING_TOOLS = ['write_file', 'edit_file', 'bash'] as const;
export { jail };
+792
View File
@@ -0,0 +1,792 @@
import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
import SelectInput from 'ink-select-input';
import Spinner from 'ink-spinner';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { parseCommand, matchCommands, type CommandSpec } from '../commands';
import { THINKING_LEVELS, VARIANTS } from '../agents';
import type { Config } from '../config';
import { TODO_MARK, type NotebookState } from '../notebook';
import { costOf, formatUsd, usageLine } from '../pricing';
import type { ApprovalDecision, ApprovalRequest, Session } from '../session';
import type { SubagentEvent } from '../subagent';
import { AskPanel, type AskBridge, type AskPending } from './Ask';
import { Diff } from './Diff';
import { Markdown } from './Markdown';
import { Onboard, type OnboardResult } from './Onboard';
import { InfoPanel, OutputPanel, StatusBar, SubagentPanel, TodoPanel, type SubagentView } from './Panels';
import { PromptInput } from './PromptInput';
type Line =
| { key: string; kind: 'user'; text: string }
| { key: string; kind: 'assistant'; text: string }
| { key: string; kind: 'tool'; name: string; summary: string; ok: boolean }
| { key: string; kind: 'info'; text: string }
| { key: string; kind: 'error'; text: string };
type NewLine = Line extends infer T ? (T extends Line ? Omit<T, 'key'> : never) : never;
type Pending = { req: ApprovalRequest; resolve: (d: ApprovalDecision) => void };
/** Bridges Session's promise-based approval callback into React state. */
export type ApprovalBridge = {
bind: (fn: (p: Pending | undefined) => void) => void;
ask: (req: ApprovalRequest) => Promise<ApprovalDecision>;
};
export function createApprovalBridge(): ApprovalBridge {
let setter: ((p: Pending | undefined) => void) | undefined;
return {
bind(fn) {
setter = fn;
},
ask(req) {
return new Promise((resolve) => {
if (!setter) return resolve('deny'); // UI not mounted: fail closed
setter({
req,
resolve: (d) => {
setter?.(undefined);
resolve(d);
},
});
});
},
};
}
/** One-way channel for out-of-band notices, e.g. an endpoint fallback. */
export type NoticeBus = {
bind: (fn: (text: string) => void) => void;
emit: (text: string) => void;
};
export function createNoticeBus(): NoticeBus {
const queued: string[] = [];
let sink: ((text: string) => void) | undefined;
return {
bind(fn) {
sink = fn;
for (const text of queued.splice(0)) fn(text);
},
emit(text) {
if (sink) sink(text);
else queued.push(text);
},
};
}
/** Subagent progress, from the task tool to the panel. */
export type SubagentBus = {
bind: (fn: (event: SubagentEvent) => void) => void;
emit: (event: SubagentEvent) => void;
};
export function createSubagentBus(): SubagentBus {
const queued: SubagentEvent[] = [];
let sink: ((event: SubagentEvent) => void) | undefined;
return {
bind(fn) {
sink = fn;
for (const event of queued.splice(0)) fn(event);
},
emit(event) {
if (sink) sink(event);
else queued.push(event);
},
};
}
/** Folds a subagent event into the panel's view, keeping finished agents visible. */
export function applySubagentEvent(current: SubagentView[], event: SubagentEvent): SubagentView[] {
switch (event.type) {
case 'start':
return [
...current,
{ id: event.id, kind: event.kind, description: event.description, steps: [], status: 'running' },
];
case 'step':
return current.map((a) =>
a.id === event.id ? { ...a, steps: [...a.steps, { tool: event.tool, summary: event.summary }] } : a,
);
case 'end':
return current.map((a) => (a.id === event.id ? { ...a, status: event.ok ? 'done' : 'failed' } : a));
case 'error':
return current.map((a) => (a.id === event.id ? { ...a, status: 'failed', error: event.message } : a));
}
}
/** Everything the slash commands need from the outside world. */
export type AppHooks = {
sessionId: string;
config: () => Config;
switchModel: (id: string) => string;
switchAgent: (name: string) => string;
switchThinking: (level: string) => string;
agentName: () => string;
thinkingLevel: () => string;
applyProvider: (result: OnboardResult) => Promise<string>;
listModels: () => Promise<{ models: string[]; warning?: string }>;
listSessions: () => Promise<string>;
listSkills: () => string;
listPlugins: () => string;
listMemory: () => Promise<string>;
summarizeMemory: () => Promise<string>;
resumeSession: (idOrPrefix: string) => Promise<string>;
saveSession: () => Promise<string>;
/** Loaded AGENTS.md-style files, for /context. */
instructionFiles: () => string[];
/** Prompt to hand the model for /init. */
initPrompt: string;
history: string[];
recordPrompt: (text: string) => void;
};
let seq = 0;
const nextKey = () => `l${seq++}`;
function preview(input: unknown): string {
if (input === null || typeof input !== 'object') return String(input);
const o = input as Record<string, unknown>;
const first = o['command'] ?? o['path'] ?? o['pattern'] ?? o['description'] ?? o['question'] ?? o['name'];
if (typeof first === 'string') return first.length > 90 ? `${first.slice(0, 90)}...` : first;
// A tool with no obvious label, e.g. todo_write, gets a shape rather than a
// JSON dump; the panels below already show the content.
const todos = o['todos'];
if (Array.isArray(todos)) return `${todos.length} task${todos.length === 1 ? '' : 's'}`;
const keys = Object.keys(o);
return keys.length === 0 ? '' : keys.slice(0, 3).join(', ');
}
function ApprovalDetail({ name, input }: { name: string; input: unknown }) {
const o = (input ?? {}) as Record<string, unknown>;
if (name === 'bash') return <Text dimColor>{String(o['command'] ?? '')}</Text>;
if (name === 'write_file') {
const content = String(o['content'] ?? '');
return <Diff before="" after={content} path={`${String(o['path'])} (new content)`} />;
}
if (name === 'edit_file') {
return <Diff before={String(o['oldString'] ?? '')} after={String(o['newString'] ?? '')} path={String(o['path'])} />;
}
return <Text dimColor>{JSON.stringify(input, null, 2)}</Text>;
}
function Approval({ pending }: { pending: Pending }) {
useInput((input, key) => {
const c = input.toLowerCase();
if (c === 'y' || key.return) pending.resolve('once');
else if (c === 'a') pending.resolve('always');
else if (c === 'n' || key.escape) pending.resolve('deny');
});
return (
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
<Text color="yellow" bold>
{pending.req.toolName} wants to run
</Text>
<ApprovalDetail name={pending.req.toolName} input={pending.req.input} />
<Text>
<Text color="green">y</Text> allow once | <Text color="green">a</Text> always allow {pending.req.toolName} |{' '}
<Text color="red">n</Text> deny
</Text>
</Box>
);
}
function CommandMenu({ matches, index }: { matches: CommandSpec[]; index: number }) {
return (
<Box flexDirection="column" marginTop={1}>
{matches.map((c, i) => (
<Text key={c.name} color={i === index ? 'cyan' : undefined} dimColor={i !== index}>
{i === index ? '> ' : ' '}
{`/${c.name}${c.arg ? ` ${c.arg}` : ''}`.padEnd(18)} {c.summary}
</Text>
))}
<Text dimColor>up/down move | tab complete | enter run | esc dismiss</Text>
</Box>
);
}
export function App({
session,
bridge,
header,
hooks,
notices,
askBridge,
subagents,
needsProvider = false,
}: {
session: Session;
bridge: ApprovalBridge;
header: string;
hooks: AppHooks;
notices?: NoticeBus;
askBridge?: AskBridge;
subagents?: SubagentBus;
needsProvider?: boolean;
}) {
const { exit } = useApp();
const { write } = useStdout();
const [history, setHistory] = useState<Line[]>([]);
const [draft, setDraft] = useState('');
const [live, setLive] = useState('');
const [busy, setBusy] = useState(false);
const [pending, setPending] = useState<Pending | undefined>();
const [asking, setAsking] = useState<AskPending | undefined>();
const [onboarding, setOnboarding] = useState(needsProvider);
const [unconfigured, setUnconfigured] = useState(needsProvider);
const [modelPicker, setModelPicker] = useState<string[] | undefined>();
const [agentPicker, setAgentPicker] = useState(false);
const [thinkPicker, setThinkPicker] = useState(false);
const [menuIndex, setMenuIndex] = useState(0);
const [menuDismissed, setMenuDismissed] = useState(false);
const [inputGeneration, setInputGeneration] = useState(0);
const [toolOutput, setToolOutput] = useState('');
const [recall, setRecall] = useState<string[]>(hooks.history);
const [notebook, setNotebook] = useState<NotebookState>(session.notebook.state());
const [agents, setAgents] = useState<SubagentView[]>([]);
const [panel, setPanel] = useState<{ title: string; hint?: string; body: string } | undefined>();
const modal = pending !== undefined || asking !== undefined || onboarding;
const anyPicker = modelPicker !== undefined || agentPicker || thinkPicker;
const matches = matchCommands(draft);
const menuOpen = matches.length > 0 && !menuDismissed && !busy && !modal && !anyPicker && !panel;
const highlighted = matches[Math.min(menuIndex, matches.length - 1)];
useEffect(() => bridge.bind(setPending), [bridge]);
useEffect(() => askBridge?.bind(setAsking), [askBridge]);
useEffect(
() =>
subagents?.bind((event) => {
setAgents((current) => applySubagentEvent(current, event));
}),
[subagents],
);
// Ink re-renders the whole tree per setState, so deltas accumulate in a ref
// and are flushed on a timer instead of once per token.
const text = useRef('');
useEffect(() => {
const t = setInterval(() => {
setLive((s) => (s === text.current ? s : text.current));
}, 60);
return () => clearInterval(t);
}, []);
const push = useCallback((line: NewLine) => {
setHistory((h) => [...h, { ...line, key: nextKey() }]);
}, []);
useEffect(() => notices?.bind((text) => push({ kind: 'info', text })), [notices, push]);
useInput(
(_input, key) => {
if (key.escape) session.abort();
},
{ isActive: busy && !modal },
);
useInput(
(_input, key) => {
if (!key.escape) return;
setModelPicker(undefined);
setAgentPicker(false);
setThinkPicker(false);
},
{ isActive: anyPicker },
);
// PromptInput hands up/down/tab/esc to us first, so the menu and any open panel
// can claim them before the input treats them as editing keys.
const handleInputKey = useCallback(
(_input: string, key: { upArrow: boolean; downArrow: boolean; tab: boolean; escape: boolean }) => {
if (key.escape && panel) {
setPanel(undefined);
return true;
}
if (!menuOpen) return false;
if (key.escape) {
setMenuDismissed(true);
return true;
}
if (key.upArrow) {
setMenuIndex((i) => (i - 1 + matches.length) % matches.length);
return true;
}
if (key.downArrow) {
setMenuIndex((i) => (i + 1) % matches.length);
return true;
}
if (key.tab && highlighted) {
setDraft(highlighted.arg ? `/${highlighted.name} ` : `/${highlighted.name}`);
setMenuIndex(0);
setMenuDismissed(true);
setInputGeneration((g) => g + 1);
return true;
}
return false;
},
[highlighted, matches.length, menuOpen, panel],
);
const onDraftChange = useCallback((value: string) => {
setDraft(value);
setMenuIndex(0);
setMenuDismissed(false);
}, []);
const runTurn = useCallback(
async (value: string) => {
setBusy(true);
text.current = '';
for await (const ev of session.send(value)) {
switch (ev.type) {
case 'text':
text.current += ev.text;
break;
case 'tool-call':
push({ kind: 'tool', name: ev.name, summary: preview(ev.input), ok: true });
break;
case 'tool-output':
setToolOutput((s) => `${s}${ev.chunk}`.slice(-2000));
break;
case 'tool-error':
push({ kind: 'tool', name: ev.name, summary: String(ev.error), ok: false });
break;
case 'tool-result':
setToolOutput('');
setNotebook(session.notebook.state());
break;
case 'tool-denied':
push({ kind: 'info', text: `denied ${ev.name}` });
break;
case 'notice':
push({ kind: 'info', text: ev.text });
break;
case 'compacted':
push({ kind: 'info', text: `context compacted: ${ev.before} messages pruned to ${ev.after} on the wire` });
break;
case 'error':
push({ kind: 'error', text: ev.error instanceof Error ? ev.error.message : String(ev.error) });
break;
case 'done': {
const full = text.current.trim();
text.current = '';
setLive('');
setToolOutput('');
setAgents([]);
setHistory((h) => {
const merged: Line[] = [...h];
if (full) merged.push({ kind: 'assistant', text: full, key: nextKey() });
if (ev.inputTokens !== undefined) {
merged.push({
kind: 'info',
text: `${usageLine(hooks.config().model, ev.inputTokens, ev.outputTokens ?? 0)} (~${session.estimatedTokens()} in context)`,
key: nextKey(),
});
}
return merged;
});
break;
}
default:
break;
}
}
setBusy(false);
},
[hooks, push, session],
);
const submit = useCallback(
async (raw: string) => {
setDraft('');
setMenuIndex(0);
setMenuDismissed(false);
setPanel(undefined);
// Enter on an open menu runs the highlighted entry, so `/mo` + enter works.
const chosen = menuOpen && highlighted ? `/${highlighted.name}` : raw;
const action = parseCommand(chosen);
switch (action.type) {
case 'none':
return;
case 'exit':
return exit();
default:
break;
}
// Nothing can reach the model until a provider is configured.
if (unconfigured && action.type !== 'provider' && action.type !== 'info') {
push({ kind: 'user', text: chosen.trim() });
push({ kind: 'error', text: 'no provider configured yet - run /provider' });
return;
}
switch (action.type) {
case 'clear':
session.reset();
setHistory([]);
setNotebook(session.notebook.state());
// <Static> lines are already committed to the scrollback, so clearing
// React state alone leaves them on screen. Wipe screen + scrollback.
write('\u001B[2J\u001B[3J\u001B[H');
return;
case 'info':
push({ kind: 'user', text: chosen.trim() });
setPanel({ title: 'commands', hint: 'type / for the menu', body: action.text });
return;
case 'unknown':
push({ kind: 'user', text: chosen.trim() });
push({ kind: 'error', text: `unknown command /${action.name} - try /help` });
return;
case 'tools':
push({ kind: 'user', text: chosen.trim() });
setPanel({
title: 'tools',
hint: `${session.activeTools().length} offered this turn of ${Object.keys(session.tools).length} registered`,
body: session
.activeTools()
.sort()
.map((t) => `- \`${t}\``)
.join('\n'),
});
return;
case 'cost': {
push({ kind: 'user', text: chosen.trim() });
const model = hooks.config().model;
const spend = costOf(model, session.inputTokens, session.outputTokens);
setPanel({
title: 'cost',
hint: `session ${hooks.sessionId}`,
body: [
`- model: \`${model}\``,
`- billed: ${session.inputTokens} in / ${session.outputTokens} out`,
`- spend: ${spend === undefined ? 'unpriced model' : formatUsd(spend)}`,
`- context: ~${session.estimatedTokens()} tokens`,
`- agent: \`${hooks.agentName()}\` thinking \`${hooks.thinkingLevel()}\``,
].join('\n'),
});
return;
}
case 'context': {
push({ kind: 'user', text: chosen.trim() });
const files = hooks.instructionFiles();
setPanel({
title: 'project instructions',
body: files.length
? files.map((f) => `- \`${f}\``).join('\n')
: 'No `AGENTS.md`, `CLAUDE.md`, or `.shiro.md` found. Run `/init` to write one.',
});
return;
}
case 'todos': {
push({ kind: 'user', text: chosen.trim() });
const { todos } = session.notebook.state();
setPanel({
title: 'task list',
body: todos.length
? todos.map((t) => `- ${TODO_MARK[t.status]} ${t.content}${t.note ? ` (${t.note})` : ''}`).join('\n')
: 'No task list yet.',
});
return;
}
case 'notes': {
push({ kind: 'user', text: chosen.trim() });
setPanel({ title: 'project memory', body: await hooks.listMemory() });
return;
}
case 'agent': {
push({ kind: 'user', text: chosen.trim() });
if (action.agent) {
try {
push({ kind: 'info', text: hooks.switchAgent(action.agent) });
} catch (e) {
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
}
return;
}
setAgentPicker(true);
return;
}
case 'think': {
push({ kind: 'user', text: chosen.trim() });
if (action.level) {
try {
push({ kind: 'info', text: hooks.switchThinking(action.level) });
} catch (e) {
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
}
return;
}
setThinkPicker(true);
return;
}
case 'skills':
push({ kind: 'user', text: chosen.trim() });
setPanel({ title: 'skills', hint: 'the agent loads one with the skill tool', body: hooks.listSkills() });
return;
case 'plugins':
push({ kind: 'user', text: chosen.trim() });
setPanel({ title: 'plugins', body: hooks.listPlugins() });
return;
case 'memory': {
push({ kind: 'user', text: chosen.trim() });
setBusy(true);
try {
push({ kind: 'info', text: await hooks.summarizeMemory() });
} catch (e) {
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
}
setBusy(false);
return;
}
case 'init':
push({ kind: 'user', text: chosen.trim() });
await runTurn(hooks.initPrompt);
return;
case 'model':
push({ kind: 'user', text: chosen.trim() });
try {
push({ kind: 'info', text: hooks.switchModel(action.model) });
} catch (e) {
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
}
return;
case 'sessions':
push({ kind: 'user', text: chosen.trim() });
push({ kind: 'info', text: await hooks.listSessions() });
return;
case 'save':
push({ kind: 'user', text: chosen.trim() });
push({ kind: 'info', text: await hooks.saveSession() });
return;
case 'resume':
push({ kind: 'user', text: chosen.trim() });
try {
const msg = await hooks.resumeSession(action.id);
setHistory([]);
push({ kind: 'info', text: msg });
} catch (e) {
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
}
return;
case 'provider':
push({ kind: 'user', text: chosen.trim() });
setOnboarding(true);
return;
case 'models': {
push({ kind: 'user', text: chosen.trim() });
setBusy(true);
const { models, warning } = await hooks.listModels();
setBusy(false);
if (warning) push({ kind: 'info', text: `could not list models: ${warning}` });
if (models.length === 0) {
push({ kind: 'error', text: 'no models to choose from - use /model <id> or /provider' });
return;
}
setModelPicker(models);
return;
}
case 'compact': {
push({ kind: 'user', text: chosen.trim() });
setBusy(true);
try {
const { before, after } = await session.summarize();
push({ kind: 'info', text: `compacted ${before} messages into ${after}` });
} catch (e) {
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
}
setBusy(false);
return;
}
case 'prompt':
push({ kind: 'user', text: action.text });
hooks.recordPrompt(action.text);
setRecall((h) => (h.at(-1) === action.text ? h : [...h, action.text]));
await runTurn(action.text);
return;
}
},
[exit, highlighted, hooks, menuOpen, push, runTurn, session, unconfigured, write],
);
return (
<Box flexDirection="column">
<Static items={history}>
{(line) => (
<Box key={line.key} flexDirection="column" marginBottom={1}>
{line.kind === 'user' && <Text color="cyan">{`> ${line.text}`}</Text>}
{line.kind === 'assistant' && <Markdown text={line.text} />}
{line.kind === 'tool' && (
<Text color={line.ok ? 'magenta' : 'red'}>
{line.ok ? '*' : 'x'} {line.name}({line.summary})
</Text>
)}
{line.kind === 'info' && <Text dimColor>{line.text}</Text>}
{line.kind === 'error' && <Text color="red">error: {line.text}</Text>}
</Box>
)}
</Static>
{history.length === 0 && (
<Box marginBottom={1}>
<Text dimColor>{header}</Text>
</Box>
)}
{agents.length > 0 && <SubagentPanel agents={agents} />}
{notebook.todos.length > 0 && <TodoPanel todos={notebook.todos} />}
{live.length > 0 && (
<Box marginBottom={1}>
<Markdown text={live} />
</Box>
)}
{panel && (
<InfoPanel title={panel.title} {...(panel.hint ? { hint: panel.hint } : {})} lines={panel.body} />
)}
{asking && <AskPanel pending={asking} />}
{pending && <Approval pending={pending} />}
{onboarding && (
<Onboard
current={hooks.config()}
onCancel={() => {
setOnboarding(false);
push({ kind: 'info', text: 'provider setup cancelled' });
}}
onDone={async (result) => {
setOnboarding(false);
try {
push({ kind: 'info', text: await hooks.applyProvider(result) });
setUnconfigured(false);
} catch (e) {
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
}
}}
/>
)}
{modelPicker && (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Text color="cyan" bold>
Choose a model ({modelPicker.length} available)
</Text>
<Text dimColor>enter to select, esc to cancel</Text>
<SelectInput
items={modelPicker.map((m) => ({ key: m, label: m, value: m }))}
limit={10}
initialIndex={Math.max(0, modelPicker.indexOf(hooks.config().model))}
onSelect={(item) => {
setModelPicker(undefined);
try {
push({ kind: 'info', text: hooks.switchModel(item.value) });
} catch (e) {
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
}
}}
/>
</Box>
)}
{agentPicker && (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Text color="cyan" bold>
Choose an agent
</Text>
<Text dimColor>enter to select, esc to cancel</Text>
<SelectInput
items={VARIANTS.map((v) => ({
key: v.name,
label: `${v.name.padEnd(8)} ${v.summary}`,
value: v.name,
}))}
limit={8}
initialIndex={Math.max(
0,
VARIANTS.findIndex((v) => v.name === hooks.agentName()),
)}
onSelect={(item) => {
setAgentPicker(false);
try {
push({ kind: 'info', text: hooks.switchAgent(item.value) });
} catch (e) {
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
}
}}
/>
</Box>
)}
{thinkPicker && (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Text color="cyan" bold>
Thinking level
</Text>
<Text dimColor>higher costs more and is slower; enter to select, esc to cancel</Text>
<SelectInput
items={THINKING_LEVELS.map((l) => ({ key: l, label: l, value: l }))}
limit={8}
initialIndex={Math.max(0, THINKING_LEVELS.indexOf(hooks.thinkingLevel() as (typeof THINKING_LEVELS)[number]))}
onSelect={(item) => {
setThinkPicker(false);
try {
push({ kind: 'info', text: hooks.switchThinking(item.value) });
} catch (e) {
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
}
}}
/>
</Box>
)}
{busy && !modal && (
<Box flexDirection="column">
<OutputPanel text={toolOutput} />
<Text color="yellow">
<Spinner type="dots" /> <Text dimColor>working... esc to interrupt</Text>
</Text>
</Box>
)}
{!busy && !modal && !anyPicker && (
<Box flexDirection="column">
<Box>
<Text color="cyan">{'> '}</Text>
<PromptInput
key={inputGeneration}
value={draft}
onChange={onDraftChange}
onSubmit={submit}
history={recall}
onKey={handleInputKey}
placeholder="ask shiro-neko... (/ for commands)"
/>
</Box>
{menuOpen && <CommandMenu matches={matches} index={Math.min(menuIndex, matches.length - 1)} />}
<StatusBar
model={hooks.config().model}
agent={hooks.agentName()}
thinking={hooks.thinkingLevel()}
contextTokens={session.estimatedTokens()}
cost={(() => {
const spend = costOf(hooks.config().model, session.inputTokens, session.outputTokens);
return spend === undefined ? 'unpriced' : formatUsd(spend);
})()}
toolCount={session.activeTools().length}
/>
</Box>
)}
</Box>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { Box, Text, useInput } from 'ink';
import SelectInput from 'ink-select-input';
import React, { useState } from 'react';
import type { AskRequest } from '../ask';
import { InlineMarkdown } from './Markdown';
import { PromptInput } from './PromptInput';
export type AskPending = { req: AskRequest; resolve: (answers: string[] | undefined) => void };
/** Bridges the ask tool's promise into React state, the same shape as the approval bridge. */
export type AskBridge = {
bind: (fn: (p: AskPending | undefined) => void) => void;
ask: (req: AskRequest) => Promise<string[] | undefined>;
};
export function createAskBridge(): AskBridge {
let setter: ((p: AskPending | undefined) => void) | undefined;
return {
bind(fn) {
setter = fn;
},
ask(req) {
return new Promise((resolve) => {
// No UI mounted means no one can answer; resolving undefined lets the tool
// tell the model to decide for itself rather than hanging forever.
if (!setter) return resolve(undefined);
setter({
req,
resolve: (answers) => {
setter?.(undefined);
resolve(answers);
},
});
});
},
};
}
const TYPE_YOUR_OWN = '__own__';
/**
* The question popup.
*
* Options become a picker; no options, or "type your own", falls back to free text.
* Escape resolves undefined rather than leaving the tool waiting.
*/
export function AskPanel({ pending }: { pending: AskPending }) {
const { question, options, multiple } = pending.req;
const [chosen, setChosen] = useState<string[]>([]);
const [typing, setTyping] = useState(!options || options.length === 0);
const [draft, setDraft] = useState('');
useInput(
(_input, key) => {
if (key.escape) pending.resolve(undefined);
},
{ isActive: !typing },
);
const items = [
...(options ?? []).map((o) => ({
key: o.label,
label: chosen.includes(o.label) ? `[x] ${o.label}` : multiple ? `[ ] ${o.label}` : o.label,
value: o.label,
})),
...(multiple && chosen.length > 0 ? [{ key: '__done__', label: `-- submit ${chosen.length} --`, value: '__done__' }] : []),
{ key: TYPE_YOUR_OWN, label: 'type your own answer...', value: TYPE_YOUR_OWN },
];
const detailOf = (label: string) => (options ?? []).find((o) => o.label === label)?.detail;
return (
<Box flexDirection="column" borderStyle="double" borderColor="yellow" paddingX={1}>
<Text color="yellow" bold>
shiro is asking
</Text>
<Box marginBottom={1}>
<InlineMarkdown text={question} />
</Box>
{typing ? (
<Box>
<Text color="yellow">{'> '}</Text>
<PromptInput
value={draft}
onChange={setDraft}
onSubmit={(v) => pending.resolve(v.trim() ? [v.trim()] : undefined)}
placeholder="type your answer, enter to send"
/>
</Box>
) : (
<Box flexDirection="column">
<SelectInput
items={items}
limit={10}
onSelect={(item) => {
if (item.value === TYPE_YOUR_OWN) return setTyping(true);
if (item.value === '__done__') return pending.resolve(chosen);
if (!multiple) return pending.resolve([item.value]);
setChosen((c) => (c.includes(item.value) ? c.filter((x) => x !== item.value) : [...c, item.value]));
}}
onHighlight={(item) => {
const detail = detailOf(item.value);
if (detail) setDraft(detail);
else setDraft('');
}}
/>
{draft.length > 0 && <Text dimColor>{draft}</Text>}
<Text dimColor>
{multiple ? 'space/enter toggles, pick submit when done' : 'enter to choose'} | esc to skip
</Text>
</Box>
)}
</Box>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { Box, Text } from 'ink';
import React from 'react';
export type DiffLine = { kind: 'context' | 'add' | 'remove'; text: string };
/**
* Line-level diff by longest common subsequence. O(n*m) is fine here because an
* edit_file payload is a handful of lines, not a whole file.
*/
export function diffLines(before: string, after: string): DiffLine[] {
const a = before.split('\n');
const b = after.split('\n');
const n = a.length;
const m = b.length;
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!);
}
}
const out: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
out.push({ kind: 'context', text: a[i]! });
i++;
j++;
} else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
out.push({ kind: 'remove', text: a[i]! });
i++;
} else {
out.push({ kind: 'add', text: b[j]! });
j++;
}
}
while (i < n) out.push({ kind: 'remove', text: a[i++]! });
while (j < m) out.push({ kind: 'add', text: b[j++]! });
return out;
}
/** Drops runs of unchanged lines longer than `context` on both sides of a change. */
export function collapseContext(lines: DiffLine[], context = 2): (DiffLine | { kind: 'gap'; count: number })[] {
const keep = new Set<number>();
lines.forEach((line, i) => {
if (line.kind === 'context') return;
for (let k = i - context; k <= i + context; k++) if (k >= 0 && k < lines.length) keep.add(k);
});
const out: (DiffLine | { kind: 'gap'; count: number })[] = [];
let skipped = 0;
lines.forEach((line, i) => {
if (keep.has(i)) {
if (skipped > 0) {
out.push({ kind: 'gap', count: skipped });
skipped = 0;
}
out.push(line);
} else {
skipped++;
}
});
if (skipped > 0) out.push({ kind: 'gap', count: skipped });
return out;
}
const MAX_RENDERED = 40;
export function Diff({ before, after, path }: { before: string; after: string; path?: string }) {
const all = collapseContext(diffLines(before, after));
const shown = all.slice(0, MAX_RENDERED);
const hidden = all.length - shown.length;
const added = all.filter((l) => l.kind === 'add').length;
const removed = all.filter((l) => l.kind === 'remove').length;
return (
<Box flexDirection="column">
{path && (
<Text>
<Text bold>{path}</Text> <Text color="green">+{added}</Text> <Text color="red">-{removed}</Text>
</Text>
)}
{shown.map((line, i) =>
line.kind === 'gap' ? (
<Text key={i} dimColor>
{` ... ${line.count} unchanged line${line.count === 1 ? '' : 's'}`}
</Text>
) : (
<Text
key={i}
color={line.kind === 'add' ? 'green' : line.kind === 'remove' ? 'red' : undefined}
dimColor={line.kind === 'context'}
>
{`${line.kind === 'add' ? ' + ' : line.kind === 'remove' ? ' - ' : ' '}${line.text}`}
</Text>
),
)}
{hidden > 0 && <Text dimColor>{` ... ${hidden} more diff lines`}</Text>}
</Box>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { Box, Text } from 'ink';
import React from 'react';
import { parseInline, parseMarkdown, type Block, type Span } from '../markdown';
const HEADING_COLOR = ['cyan', 'cyan', 'blue', 'blue', 'gray', 'gray'] as const;
function Inline({ spans }: { spans: Span[] }) {
return (
<Text>
{spans.map((s, i) => (
<Text
key={i}
bold={s.bold}
italic={s.italic}
strikethrough={s.strike}
underline={s.link}
color={s.code ? 'yellow' : s.link ? 'blue' : undefined}
>
{s.text}
</Text>
))}
</Text>
);
}
function CodeBlock({ language, lines }: { language: string; lines: string[] }) {
return (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
{language.length > 0 && <Text dimColor>{language}</Text>}
{lines.map((l, i) => (
<Text key={i} color="green">
{l.length > 0 ? l : ' '}
</Text>
))}
</Box>
);
}
function BlockView({ block, width }: { block: Block; width: number }) {
switch (block.kind) {
case 'heading':
return (
<Box marginTop={block.level === 1 ? 1 : 0}>
<Text bold color={HEADING_COLOR[block.level - 1] ?? 'gray'}>
<Inline spans={block.spans} />
</Text>
</Box>
);
case 'paragraph':
return <Inline spans={block.spans} />;
case 'bullet':
return (
<Box>
<Text dimColor>{`${' '.repeat(block.indent)}${block.marker} `}</Text>
<Box flexGrow={1}>
<Inline spans={block.spans} />
</Box>
</Box>
);
case 'quote':
return (
<Box>
<Text color="gray">{'| '}</Text>
<Text dimColor italic>
<Inline spans={block.spans} />
</Text>
</Box>
);
case 'code':
return <CodeBlock language={block.language} lines={block.lines} />;
case 'rule':
return <Text dimColor>{'-'.repeat(Math.max(4, Math.min(width, 60)))}</Text>;
case 'blank':
return <Text> </Text>;
}
}
/**
* Renders agent output as styled terminal markdown.
*
* Parsing happens here rather than in the transcript because a partial stream is
* re-parsed on every flush; an unclosed fence simply renders as a code block that
* grows, which is what a reader expects while text is still arriving.
*/
export function Markdown({ text, width = 80 }: { text: string; width?: number }) {
const blocks = parseMarkdown(text);
return (
<Box flexDirection="column">
{blocks.map((b, i) => (
<BlockView key={i} block={b} width={width} />
))}
</Box>
);
}
/** One line of inline-styled markdown, for labels and summaries. */
export function InlineMarkdown({ text }: { text: string }) {
return <Inline spans={parseInline(text)} />;
}
+242
View File
@@ -0,0 +1,242 @@
import { Box, Text, useInput } from 'ink';
import SelectInput from 'ink-select-input';
import TextInput from 'ink-text-input';
import Spinner from 'ink-spinner';
import React, { useCallback, useState } from 'react';
import type { Config, ProviderName } from '../config';
import { fetchModels, PRESETS, type ProviderPreset } from '../providers';
export type OnboardResult = {
presetId: string;
provider: ProviderName;
baseURL: string;
apiKey: string;
model: string;
};
type Step =
| { name: 'pick-provider' }
| { name: 'base-url'; preset: ProviderPreset }
| { name: 'api-key'; preset: ProviderPreset; baseURL: string }
| { name: 'loading'; preset: ProviderPreset; baseURL: string; apiKey: string }
| { name: 'pick-model'; preset: ProviderPreset; baseURL: string; apiKey: string; models: string[]; warning?: string }
| { name: 'type-model'; preset: ProviderPreset; baseURL: string; apiKey: string; warning?: string };
const mask = (key: string) => (key.length <= 8 ? '*'.repeat(key.length) : `${key.slice(0, 4)}...${key.slice(-4)}`);
const MANUAL_ENTRY = '__type_it__';
/**
* Provider onboarding: pick a preset, supply a key, then choose a model from the
* server's own /models list. Rendered in place of the prompt input, so it owns
* the keyboard while open.
*/
export function Onboard({
current,
onDone,
onCancel,
}: {
current: Config;
onDone: (result: OnboardResult) => void;
onCancel: () => void;
}) {
const [step, setStep] = useState<Step>({ name: 'pick-provider' });
const [draft, setDraft] = useState('');
useInput(
(_input, key) => {
if (key.escape) onCancel();
},
{ isActive: step.name !== 'loading' },
);
const loadModels = useCallback(
async (preset: ProviderPreset, baseURL: string, apiKey: string) => {
setStep({ name: 'loading', preset, baseURL, apiKey });
const { models, warning } = await fetchModels({ ...preset, baseURL }, apiKey);
setDraft('');
if (models.length === 0) {
setStep({ name: 'type-model', preset, baseURL, apiKey, ...(warning ? { warning } : {}) });
} else {
setStep({ name: 'pick-model', preset, baseURL, apiKey, models, ...(warning ? { warning } : {}) });
}
},
[],
);
const afterBaseUrl = useCallback(
(preset: ProviderPreset, baseURL: string) => {
const fromEnv = preset.envKey ? process.env[preset.envKey] : undefined;
const key = preset.keyless ? 'local' : (fromEnv ?? '');
if (key) return void loadModels(preset, baseURL, key);
setDraft('');
setStep({ name: 'api-key', preset, baseURL });
},
[loadModels],
);
const pickProvider = useCallback(
(preset: ProviderPreset) => {
if (preset.baseURL) return afterBaseUrl(preset, preset.baseURL);
setDraft('');
setStep({ name: 'base-url', preset });
},
[afterBaseUrl],
);
switch (step.name) {
case 'pick-provider': {
const items = PRESETS.map((p) => ({
key: p.id,
label: p.id === current.presetId ? `${p.label} (current)` : p.label,
value: p.id,
}));
return (
<Frame title="Choose a provider" hint="up/down to move, enter to select, esc to cancel">
<SelectInput
items={items}
limit={12}
initialIndex={Math.max(
0,
PRESETS.findIndex((p) => p.id === (current.presetId ?? current.provider)),
)}
onSelect={(item) => {
const preset = PRESETS.find((p) => p.id === item.value);
if (preset) pickProvider(preset);
}}
/>
</Frame>
);
}
case 'base-url':
return (
<Frame title={`${step.preset.label}: endpoint URL`} hint="e.g. https://host/v1 - enter to continue">
<Row label="URL">
<TextInput
value={draft}
onChange={setDraft}
onSubmit={(v) => v.trim() && afterBaseUrl(step.preset, v.trim())}
placeholder="https://..."
/>
</Row>
</Frame>
);
case 'api-key':
return (
<Frame
title={`${step.preset.label}: API key`}
hint={`stored in the shiro config file${step.preset.envKey ? `, or set ${step.preset.envKey} instead` : ''}`}
>
<Row label="key">
<TextInput
value={draft}
onChange={setDraft}
mask="*"
onSubmit={(v) => v.trim() && void loadModels(step.preset, step.baseURL, v.trim())}
placeholder={step.preset.keyHint ?? 'paste it here'}
/>
</Row>
</Frame>
);
case 'loading':
return (
<Frame title={`${step.preset.label}: fetching models`} hint={step.baseURL}>
<Text color="yellow">
<Spinner type="dots" /> <Text dimColor>GET {step.baseURL}/models</Text>
</Text>
</Frame>
);
case 'pick-model': {
const items = [
...step.models.map((m) => ({ key: m, label: m, value: m })),
{ key: MANUAL_ENTRY, label: 'type a model id myself...', value: MANUAL_ENTRY },
];
return (
<Frame
title={`${step.preset.label}: choose a model`}
hint={`${step.models.length} models - key ${mask(step.apiKey)}`}
warning={step.warning}
>
<SelectInput
items={items}
limit={10}
initialIndex={Math.max(0, step.models.indexOf(current.model))}
onSelect={(item) => {
if (item.value === MANUAL_ENTRY) {
setDraft('');
setStep({ name: 'type-model', preset: step.preset, baseURL: step.baseURL, apiKey: step.apiKey });
return;
}
onDone({
presetId: step.preset.id,
provider: step.preset.kind,
baseURL: step.baseURL,
apiKey: step.apiKey,
model: item.value,
});
}}
/>
</Frame>
);
}
case 'type-model':
return (
<Frame title={`${step.preset.label}: model id`} hint="enter to finish, esc to cancel" warning={step.warning}>
<Row label="model">
<TextInput
value={draft}
onChange={setDraft}
onSubmit={(v) =>
v.trim() &&
onDone({
presetId: step.preset.id,
provider: step.preset.kind,
baseURL: step.baseURL,
apiKey: step.apiKey,
model: v.trim(),
})
}
placeholder="model-id"
/>
</Row>
</Frame>
);
}
}
function Frame({
title,
hint,
warning,
children,
}: {
title: string;
hint?: string;
warning?: string;
children: React.ReactNode;
}) {
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Text color="cyan" bold>
{title}
</Text>
{hint && <Text dimColor>{hint}</Text>}
{warning && <Text color="yellow">could not list models: {warning}</Text>}
{children}
</Box>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<Box>
<Text color="cyan">{label}: </Text>
{children}
</Box>
);
}
+155
View File
@@ -0,0 +1,155 @@
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import React from 'react';
import { TODO_MARK, type Todo } from '../notebook';
import type { SubagentKind } from '../subagent';
import { InlineMarkdown } from './Markdown';
const STATUS_COLOR: Record<Todo['status'], string | undefined> = {
pending: undefined,
in_progress: 'cyan',
done: 'green',
blocked: 'red',
};
/** Task list with a progress bar, shown above the input while a list exists. */
export function TodoPanel({ todos, width = 40 }: { todos: Todo[]; width?: number }) {
const done = todos.filter((t) => t.status === 'done').length;
const blocked = todos.filter((t) => t.status === 'blocked').length;
const filled = todos.length === 0 ? 0 : Math.round((done / todos.length) * width);
return (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1} marginBottom={1}>
<Box>
<Text bold>tasks </Text>
<Text color="green">{'#'.repeat(filled)}</Text>
<Text dimColor>{'.'.repeat(Math.max(0, width - filled))}</Text>
<Text dimColor>{` ${done}/${todos.length}`}</Text>
{blocked > 0 && <Text color="red">{` ${blocked} blocked`}</Text>}
</Box>
{todos.map((t, i) => (
<Box key={i}>
<Text color={STATUS_COLOR[t.status]}>{`${TODO_MARK[t.status]} `}</Text>
<Text dimColor={t.status === 'done'} strikethrough={t.status === 'done'}>
{t.content}
</Text>
{t.note && <Text dimColor>{` (${t.note})`}</Text>}
</Box>
))}
</Box>
);
}
export type SubagentView = {
id: string;
kind: SubagentKind;
description: string;
steps: { tool: string; summary: string }[];
status: 'running' | 'done' | 'failed';
error?: string;
};
const KIND_LABEL: Record<SubagentKind, string> = { explore: 'explore', review: 'review' };
/**
* Live view of delegated work.
*
* A subagent can run for a minute over many files; without this the parent's spinner
* is the only feedback and the user cannot tell progress from a hang.
*/
export function SubagentPanel({ agents }: { agents: SubagentView[] }) {
if (agents.length === 0) return null;
return (
<Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1} marginBottom={1}>
{agents.map((a) => (
<Box key={a.id} flexDirection="column">
<Box>
{a.status === 'running' ? (
<Text color="magenta">
<Spinner type="dots" />
</Text>
) : (
<Text color={a.status === 'done' ? 'green' : 'red'}>{a.status === 'done' ? '*' : 'x'}</Text>
)}
<Text bold>{` ${KIND_LABEL[a.kind]}`}</Text>
<Text>{`: ${a.description}`}</Text>
<Text dimColor>{` ${a.steps.length} step${a.steps.length === 1 ? '' : 's'}`}</Text>
</Box>
{a.steps.slice(-3).map((s, i) => (
<Text key={i} dimColor>
{` ${s.tool}(${s.summary.slice(0, 60)})`}
</Text>
))}
{a.error && <Text color="red">{` ${a.error}`}</Text>}
</Box>
))}
</Box>
);
}
/** Live tail of a running shell command. */
export function OutputPanel({ text, lines = 8 }: { text: string; lines?: number }) {
if (text.length === 0) return null;
return (
<Box flexDirection="column" marginBottom={1}>
{text
.split('\n')
.slice(-lines)
.map((l, i) => (
<Text key={i} dimColor>
{` | ${l}`}
</Text>
))}
</Box>
);
}
/** Status line under the transcript: model, agent, thinking, context, spend. */
export function StatusBar({
model,
agent,
thinking,
contextTokens,
cost,
toolCount,
}: {
model: string;
agent: string;
thinking: string;
contextTokens: number;
cost: string;
toolCount: number;
}) {
return (
<Box>
<Text dimColor>{`${model} `}</Text>
<Text color="cyan">{agent}</Text>
<Text dimColor>{`/${thinking} ${toolCount} tools ~${contextTokens} ctx ${cost}`}</Text>
</Box>
);
}
export type PanelLine = { label: string; value: string };
/** Bordered popup for a command's output, e.g. /skills or /cost. */
export function InfoPanel({ title, hint, lines }: { title: string; hint?: string; lines: PanelLine[] | string }) {
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginBottom={1}>
<Text color="cyan" bold>
{title}
</Text>
{hint && <Text dimColor>{hint}</Text>}
{typeof lines === 'string' ? (
<InlineMarkdown text={lines} />
) : (
lines.map((l, i) => (
<Box key={i}>
<Text color="gray">{l.label.padEnd(14)}</Text>
<Text>{l.value}</Text>
</Box>
))
)}
</Box>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { Text, useInput } from 'ink';
import React, { useEffect, useState } from 'react';
export type PromptInputProps = {
value: string;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
placeholder?: string;
focus?: boolean;
mask?: string;
/** Newest-last list of previously submitted prompts, walked by up/down. */
history?: readonly string[];
/** Intercept a key before the input consumes it. Return true to swallow it. */
onKey?: (input: string, key: KeyLike) => boolean;
};
type KeyLike = {
upArrow: boolean;
downArrow: boolean;
leftArrow: boolean;
rightArrow: boolean;
return: boolean;
escape: boolean;
tab: boolean;
backspace: boolean;
delete: boolean;
ctrl: boolean;
meta: boolean;
home?: boolean;
end?: boolean;
};
const INVERSE_ON = '\u001B[7m';
const INVERSE_OFF = '\u001B[27m';
const invert = (s: string) => `${INVERSE_ON}${s}${INVERSE_OFF}`;
/**
* Text input with a real cursor and shell-style history recall.
*
* ink-text-input cannot do this: it discards up/down before its own handler and
* only ever shrinks its internal cursor offset, so an externally driven value
* leaves the cursor stranded. Owning the cursor here also gives us home/end and
* ctrl-a/e/k/u/w for free.
*/
export function PromptInput({
value,
onChange,
onSubmit,
placeholder = '',
focus = true,
mask,
history = [],
onKey,
}: PromptInputProps) {
const [cursor, setCursor] = useState(value.length);
// -1 means "editing a fresh line"; 0+ indexes back from the newest entry.
const [recall, setRecall] = useState(-1);
const [stash, setStash] = useState('');
useEffect(() => {
setCursor((c) => Math.min(c, value.length));
}, [value]);
const set = (next: string, nextCursor = next.length) => {
onChange(next);
setCursor(Math.max(0, Math.min(nextCursor, next.length)));
};
useInput(
(input, key) => {
if (onKey?.(input, key as KeyLike)) return;
if (key.return) {
setRecall(-1);
setStash('');
setCursor(0);
onSubmit(value);
return;
}
if (key.upArrow || key.downArrow) {
if (history.length === 0) return;
if (key.upArrow) {
const next = Math.min(recall + 1, history.length - 1);
if (recall === -1) setStash(value);
setRecall(next);
set(history[history.length - 1 - next] ?? value);
} else {
const next = recall - 1;
setRecall(next);
set(next < 0 ? stash : (history[history.length - 1 - next] ?? ''));
}
return;
}
if (key.leftArrow) return setCursor((c) => Math.max(0, c - 1));
if (key.rightArrow) return setCursor((c) => Math.min(value.length, c + 1));
if (key.home || (key.ctrl && input === 'a')) return setCursor(0);
if (key.end || (key.ctrl && input === 'e')) return setCursor(value.length);
if (key.ctrl && input === 'k') return set(value.slice(0, cursor), cursor);
if (key.ctrl && input === 'u') return set(value.slice(cursor), 0);
if (key.ctrl && input === 'w') {
const upto = value.slice(0, cursor);
const trimmed = upto.replace(/\S+\s*$/, '');
return set(trimmed + value.slice(cursor), trimmed.length);
}
if (key.backspace || key.delete) {
if (cursor === 0) return;
return set(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1);
}
// Ignore remaining control sequences; a paste arrives as one multi-char input.
if (!input || key.tab || key.escape || key.meta || key.ctrl) return;
set(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
},
{ isActive: focus },
);
if (value.length === 0) {
if (!placeholder) return <Text>{focus ? invert(' ') : ' '}</Text>;
return (
<Text dimColor>
{focus ? invert(placeholder.slice(0, 1)) : placeholder.slice(0, 1)}
{placeholder.slice(1)}
</Text>
);
}
const shown = mask ? mask.repeat(value.length) : value;
if (!focus) return <Text>{shown}</Text>;
return (
<Text>
{shown.slice(0, cursor)}
{invert(shown.slice(cursor, cursor + 1) || ' ')}
{shown.slice(cursor + 1)}
</Text>
);
}
export type { KeyLike };
+18
View File
@@ -0,0 +1,18 @@
/**
* Single source of truth for the version.
*
* `bun build --compile` does not embed package.json, so reading it at runtime
* fails inside the shipped binary. A constant is compiled in and always correct.
* `scripts/release.ts` checks it against the release tag so the two cannot drift.
*/
export const VERSION = '0.1.0-beta.1';
/** What `--version` prints: enough to identify a build from a bug report. */
export function versionLine(): string {
return [
`shiro-neko ${VERSION}`,
`bun ${Bun.version}`,
`${process.platform}-${process.arch}`,
import.meta.path.startsWith('/$bunfs/') || import.meta.path.includes('~BUN') ? 'compiled' : 'source',
].join(' ');
}