release 1.0.0: cost control, 41 tools, 29 skills, custom commands, auto-load
This commit is contained in:
+88
-29
@@ -3,6 +3,7 @@ import { render } from 'ink';
|
||||
import React from 'react';
|
||||
import type { LanguageModel, ModelMessage } from 'ai';
|
||||
import { resolveAgent, VARIANTS, isThinkingLevel, type AgentVariant } from './agents';
|
||||
import { loadExternalPlugins, loadExternalTools } from './autoload';
|
||||
import { configPath, loadConfig, missingKeyMessage, resolveModel, writeConfigFile, type Config } from './config';
|
||||
import type { FallbackEvent } from './fallback';
|
||||
import { farewell } from './farewell';
|
||||
@@ -18,12 +19,14 @@ import { createHost } from './plugins';
|
||||
import { fetchModels, presetById } from './providers';
|
||||
import * as registry from './registry';
|
||||
import { Session } from './session';
|
||||
import { loadCustomCommands } from './custom-commands';
|
||||
import { loadSkills } from './skills';
|
||||
import * as store from './store';
|
||||
import { createTaskTool, type SubagentApproval } from './subagent';
|
||||
import { VERSION, versionLine } from './version';
|
||||
import { createAskBridge } from './ui/Ask';
|
||||
import { App, createApprovalBridge, createNoticeBus, createSubagentBus, type AppHooks } from './ui/App';
|
||||
import { Header, type HeaderFact } from './ui/Header';
|
||||
import type { RegistryRow as AppRegistryRow } from './ui/Panels';
|
||||
|
||||
// SDK warnings go straight to stderr, which tears up the Ink render.
|
||||
@@ -154,6 +157,7 @@ if (resumeArg) {
|
||||
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 customCommands = await loadCustomCommands();
|
||||
const promptHistory = await store.loadHistory();
|
||||
|
||||
const installedPlugins = has('--no-plugins') ? { plugins: [], errors: [] } : await registry.loadInstalledPlugins();
|
||||
@@ -202,9 +206,29 @@ const enabledPlugins = has('--no-plugins') ? [] : (cfg.plugins ?? DEFAULT_ENABLE
|
||||
const pluginErrors = enabledPlugins
|
||||
.filter((name) => !BUILTIN_PLUGINS.some((p) => p.name === name))
|
||||
.map((name) => ({ plugin: name, message: 'no such plugin' }));
|
||||
|
||||
// External skills, tools, and plugins auto-load from ~/.shiro-neko/<kind> and
|
||||
// .shiro/<kind>. All are data, never code; a bad file is reported, not fatal.
|
||||
const externalPlugins = has('--no-plugins') ? { plugins: [], errors: [] } : await loadExternalPlugins(process.cwd());
|
||||
|
||||
const plugins = createHost(
|
||||
[...BUILTIN_PLUGINS.filter((p) => enabledPlugins.includes(p.name)), ...installedPlugins.plugins],
|
||||
[...pluginErrors, ...installedPlugins.errors],
|
||||
[
|
||||
...BUILTIN_PLUGINS.filter((p) => enabledPlugins.includes(p.name)),
|
||||
...installedPlugins.plugins,
|
||||
...externalPlugins.plugins,
|
||||
],
|
||||
[
|
||||
...pluginErrors,
|
||||
...installedPlugins.errors,
|
||||
...externalPlugins.errors.map((e) => ({ plugin: e.name, message: e.message })),
|
||||
],
|
||||
);
|
||||
|
||||
// External shell tools run through the same guard chain as a built-in bash call,
|
||||
// so an installed tool cannot do what the agent itself may not. Late-bound because
|
||||
// the host above is what runs the chain.
|
||||
const externalTools = await loadExternalTools(process.cwd(), async (command) =>
|
||||
plugins.guard({ toolName: 'bash', input: { command }, cwd: process.cwd() }),
|
||||
);
|
||||
|
||||
const memory = has('--no-memory') ? undefined : new Memory(process.cwd(), languageModel);
|
||||
@@ -261,8 +285,23 @@ const subagentGate: SubagentApproval = (req) => {
|
||||
return approveSubagent(req);
|
||||
};
|
||||
|
||||
// A subagent doing search rather than reasoning can run on a cheaper model.
|
||||
// It resolves against the same provider and key, so a configured `subagentModel`
|
||||
// never needs a second credential.
|
||||
const subagentModel =
|
||||
cfg.subagentModel && cfg.subagentModel !== cfg.model && cfg.apiKey
|
||||
? resolveModel({ ...cfg, model: cfg.subagentModel }, reportFallback)
|
||||
: (languageModel ?? unconfiguredModel);
|
||||
|
||||
// Late-bound like `approveSubagent`: the task tool is built into `extraTools`
|
||||
// before the Session that owns the spend ledger exists, so the usage callback is
|
||||
// wired after construction.
|
||||
let recordSubagent: (usage: { inputTokens: number; outputTokens: number }) => void = () => {};
|
||||
|
||||
const session = new Session({
|
||||
model: languageModel ?? unconfiguredModel,
|
||||
modelId: cfg.model,
|
||||
...(cfg.subagentModel ? { subagentModelId: cfg.subagentModel } : {}),
|
||||
askApproval: bridge.ask,
|
||||
yolo,
|
||||
instructions,
|
||||
@@ -276,8 +315,10 @@ const session = new Session({
|
||||
...(memory ? { memory } : {}),
|
||||
...(record.notebook ? { notebook: record.notebook } : {}),
|
||||
...(cfg.maxRetries !== undefined ? { maxRetries: cfg.maxRetries } : {}),
|
||||
...(cfg.maxSpendUsd !== undefined ? { maxSpendUsd: cfg.maxSpendUsd } : {}),
|
||||
extraTools: {
|
||||
...(mcp?.tools ?? {}),
|
||||
...externalTools.tools,
|
||||
git_commit_message: createCommitMessageTool({
|
||||
model: languageModel ?? unconfiguredModel,
|
||||
...(headless ? {} : { cwd: process.cwd() }),
|
||||
@@ -287,6 +328,9 @@ const session = new Session({
|
||||
: {
|
||||
task: createTaskTool({
|
||||
model: languageModel ?? unconfiguredModel,
|
||||
subagentModel,
|
||||
subagentModelId: cfg.subagentModel,
|
||||
onUsage: (u) => recordSubagent(u),
|
||||
...(headless ? {} : { report: subagents.emit }),
|
||||
// A worker's writes go through the parent's rules and the parent's
|
||||
// prompt. Headless has nobody to answer, so `worker` is withheld there
|
||||
@@ -295,7 +339,7 @@ const session = new Session({
|
||||
}),
|
||||
}),
|
||||
},
|
||||
autoApprove: ['task', 'git_commit_message'],
|
||||
autoApprove: ['task', 'git_commit_message', ...externalTools.autoApprove],
|
||||
messages: [...record.messages],
|
||||
onChange: (messages) => {
|
||||
// Debounced so a long tool loop does not hit the disk on every step.
|
||||
@@ -305,6 +349,7 @@ const session = new Session({
|
||||
});
|
||||
|
||||
approveSubagent = session.approveForSubagent();
|
||||
recordSubagent = (u) => session.recordSubagentUsage(u);
|
||||
|
||||
async function shutdown(code: number): Promise<never> {
|
||||
clearTimeout(saveTimer);
|
||||
@@ -344,6 +389,7 @@ const hooks: AppHooks = {
|
||||
for await (const rel of walk({ limit: 5000 })) found.push(rel);
|
||||
return found;
|
||||
},
|
||||
customCommands: () => customCommands,
|
||||
registry: {
|
||||
list: async () => {
|
||||
const entries = await registry.fetchIndex(cfg.registryUrl);
|
||||
@@ -548,35 +594,46 @@ const hooks: AppHooks = {
|
||||
},
|
||||
};
|
||||
|
||||
const header = [
|
||||
needsProvider
|
||||
? `shiro-neko ${VERSION} no provider configured`
|
||||
: `shiro-neko ${VERSION} ${cfg.provider}/${record.model} session ${record.id.slice(0, 8)}`,
|
||||
`agent: ${agentVariant.name} thinking: ${agentVariant.thinking}`,
|
||||
`cwd: ${process.cwd()}`,
|
||||
restored ? `resumed ${record.messages.length} messages` : undefined,
|
||||
// The welcome dashboard's environment facts, in scan order. Anything that should
|
||||
// stop the user — a failed plugin, `--yolo`, a missing key — is given a tone so it
|
||||
// lifts out of the quiet metadata rather than blending into it.
|
||||
const facts: HeaderFact[] = [
|
||||
{ label: 'agent', value: `${agentVariant.name} thinking ${agentVariant.thinking}` },
|
||||
restored ? { label: 'resumed', value: `${record.messages.length} messages` } : undefined,
|
||||
instructions.length > 0
|
||||
? `instructions: ${instructions.map((i) => i.path.split(/[\\/]/).at(-1)).join(', ')}`
|
||||
: 'no AGENTS.md found - /init writes one',
|
||||
skills.length > 0 ? `skills: ${skills.map((s) => s.name).join(', ')}` : undefined,
|
||||
plugins.plugins.length > 0 ? `plugins: ${plugins.plugins.map((p) => p.name).join(', ')}` : undefined,
|
||||
...plugins.errors.map((e) => `plugin ${e.plugin}: ${e.message}`),
|
||||
memory && memory.all().length > 0 ? `memory: ${memory.all().length} notes about this project` : undefined,
|
||||
mcp && Object.keys(mcp.tools).length > 0 ? `mcp: ${Object.keys(mcp.tools).length} tools` : undefined,
|
||||
!mcp && cfg.mcpServers && Object.keys(cfg.mcpServers).length > 0
|
||||
? `mcp: ${Object.keys(cfg.mcpServers).length} configured, not connected (--no-mcp)`
|
||||
? { label: 'instructions', value: instructions.map((i) => i.path.split(/[\\/]/).at(-1)!).join(', ') }
|
||||
: { label: 'instructions', value: 'none - /init writes an AGENTS.md', tone: 'info' },
|
||||
skills.length > 0 ? { label: 'skills', value: skills.map((s) => s.name).join(', ') } : undefined,
|
||||
plugins.plugins.length > 0
|
||||
? { label: 'plugins', value: plugins.plugins.map((p) => p.name).join(', ') }
|
||||
: undefined,
|
||||
...(mcp?.errors ?? []).map((e) => `mcp ${e.server} failed: ${e.message}`),
|
||||
...plugins.errors.map((e) => ({ label: 'plugin error', value: `${e.plugin}: ${e.message}`, tone: 'err' as const })),
|
||||
memory && memory.all().length > 0
|
||||
? { label: 'memory', value: `${memory.all().length} notes about this project` }
|
||||
: undefined,
|
||||
mcp && Object.keys(mcp.tools).length > 0 ? { label: 'mcp', value: `${Object.keys(mcp.tools).length} tools` } : undefined,
|
||||
!mcp && cfg.mcpServers && Object.keys(cfg.mcpServers).length > 0
|
||||
? { label: 'mcp', value: `${Object.keys(cfg.mcpServers).length} configured, not connected (--no-mcp)`, tone: 'warn' as const }
|
||||
: undefined,
|
||||
...(mcp?.errors ?? []).map((e) => ({ label: 'mcp error', value: `${e.server}: ${e.message}`, tone: 'err' as const })),
|
||||
yolo
|
||||
? 'approvals: OFF (--yolo), but deny rules and the guard still apply'
|
||||
? { label: 'approvals', value: 'OFF (--yolo) - deny rules and the guard still apply', tone: 'warn' as const }
|
||||
: cfg.permission
|
||||
? `approvals: rules for ${Object.keys(cfg.permission).join(', ')}, defaults elsewhere`
|
||||
: 'approvals: ask for write_file, edit_file, multi_edit, apply_patch, move_file, delete_file, bash, web_fetch, mcp__*',
|
||||
cfg.toolSets ? `tool sets: core, ${cfg.toolSets.join(', ')}` : undefined,
|
||||
'/help for commands',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
? { label: 'approvals', value: `rules for ${Object.keys(cfg.permission).join(', ')}, defaults elsewhere` }
|
||||
: { label: 'approvals', value: 'ask for writes, bash, web_fetch, mcp' },
|
||||
cfg.toolSets ? { label: 'tool sets', value: `core, ${cfg.toolSets.join(', ')}` } : undefined,
|
||||
].filter((f): f is HeaderFact => f !== undefined);
|
||||
|
||||
const headerNode = (
|
||||
<Header
|
||||
version={VERSION}
|
||||
{...(needsProvider ? {} : { provider: cfg.provider, model: record.model })}
|
||||
sessionId={record.id.slice(0, 8)}
|
||||
cwd={process.cwd()}
|
||||
title={restored ? record.title : undefined}
|
||||
facts={facts}
|
||||
/>
|
||||
);
|
||||
|
||||
// ctrl-c has to reach the App: with a command running it kills that command and
|
||||
// keeps the turn. Ink's own handler would exit the process before we saw the key.
|
||||
@@ -584,7 +641,9 @@ const app = render(
|
||||
<App
|
||||
session={session}
|
||||
bridge={bridge}
|
||||
header={header}
|
||||
header=""
|
||||
headerNode={headerNode}
|
||||
version={VERSION}
|
||||
hooks={hooks}
|
||||
notices={notices}
|
||||
askBridge={askBridge}
|
||||
|
||||
Reference in New Issue
Block a user