Claude Code adapter for kith: your persistent specialist agents, with their memory and projects, as subagents in every session.

Want a personal agent like Grok bots, Muse or Dots, but on your own computer, working with your files?<br> Want each agent kept separate, with its own skills, tools, memory and accounts?<br> Want one place to manage them all, with a history you can roll back?<br> Rather start from an agent someone already built than from scratch?
That's what kith is built for.
Persistent specialist agents you can call from any repo. Each has its own skills and layered memory, and each is its own git repo: install one from a GitHub link, change it, roll it back, share it or keep it private. Persistent means durable identity and memory, not a long-running process: every call is a fresh session that picks up where the last one left off.
How it fits together and where it's going: docs/architecture.md; one run, drawn: docs/how-hq-runs.html; every file and which one to open for a change: docs/codebase-map.md.
<img alt="How an kith agent runs: entry points, kith-pkg resolve, snapshot, bin/kith, Claude Code run; kith-auth with shared services and auth.json; MCP server" src="docs/img/how-hq-runs-light.png">
curl -fsSL https://raw.githubusercontent.com/Ying-Kai-Liao/kith/main/scripts/install.sh | sh # a release: no git or Rust needed
git clone https://github.com/Ying-Kai-Liao/kith ~/kith && ~/kith/install.sh # or from source; ~/kith: any folder you like
kith builder # meet the starter: it learns who you are, then builds your agents with you
kith install <owner>/<repo> # an agent someone shared: any GitHub repo with an agent.md at its root
kith new reading "tracks what I read" # or start one yourself
kith doctor # check everything, with what to fix
kith backup # everything to private GitHub repos: state, memories, your agents
Kith was called hq until 2026-10-22. The hq command (and hq-<tool>) still works as an alias, a Mac with an existing ~/.hq keeps using it until kith migrate moves it to ~/.kith, and ~/hq stays a link to the same folder as ~/kith. Old HQ_* variables, dev.hq.* jobs and hq-agent repos are still read.
The download installs the kith binary and the kernel under ~/.kith, then runs the same install.sh; run it again to upgrade. KITH_VERSION, KITH_PREFIX and KITH_INSTALL_TARBALL (a local file) are in the script's header. Homebrew: packaging/homebrew/kith.rb (for the tap Ying-Kai-Liao/homebrew-kith). Releases are built by .github/workflows/release.yml on a v* tag.
A Linux VM that only needs to join a computer and ask its agents (a cloud bot) installs the static client-only kith, no root: curl -fsSL https://raw.githubusercontent.com/Ying-Kai-Liao/kith/main/scripts/install-guest.sh | sh, then kith join <link> (x86_64 and aarch64; KITH_VERSION, KITH_GUEST_PREFIX in the script's header).
kith is one binary: kith serve <...> is the host, kith --version, and every other kith <args> runs the kernel's kith <args>. It finds the kernel in $KITH_KERNEL, else share/kith/kernel beside the binary (the download, Homebrew), else ~/kith/kernel (a git checkout).
This repo is the kernel only: no agents ship with it. install.sh links the kith CLI, installs the Claude Code adapter plugin (adapters/claude-code, served by this repo's own marketplace), then runs kith init: it checks what kith needs (git and its identity, Claude Code, gh), makes ~/.kith and the agents folder (each a git repo), a user.md for your agents to fill in, asks once whether to back everything up to private GitHub repos (off by default: it stays on this Mac, which Time Machine backs up; kith backup later), and offers the builder, the agent that builds the others, then lists the agents people shared (GitHub repos tagged kith-agent) to install any by number; kith market shows that list any time. Run kith init again any time; it only does what is missing. On a new Mac, install.sh finds your backup through your GitHub login and offers to bring it back. Then, in any Claude Code session: "have the designer critique this page", /kith branding <task>, or the mcp__kith__invoke tool. Agents appear as kith:<name>; your main session only sees one line per specialist, and their skills load only when they're called. Or run one directly in a terminal: kith branding.
The plugin reads the installed agents at session start: edit an agent.md or PROTOCOL.md and the next session has it. On every spawn it resolves the project, puts it and the agent's long-term memory in front of the task, and when the agent stops without writing its session file, asks it to once.
Each agent is a folder in the agents folder (agents/ in the kith folder): its role is a git repo in agents/<name>/role/, its life (memory, data, files) is next to it. Whoever changes it (you, a code agent, or the agent itself writing a learned skill) changes that checkout; every version that passes its checks is committed there automatically, so its history is the agent's history.
kith install <owner>/<repo> # or a github.com URL; .../tree/<ref>/<folder> for one agent in a bigger repo
kith install <link> --as <name> # under another name
kith install <link> --yes # without the question (the summary is always printed)
kith update <agent>|--all # pull upstream (fast-forward only; your edits are never overwritten)
kith history <agent> # every committed version
kith restore <agent> <commit> # bring one back, as a new commit (undo with another restore)
kith share <agent> [--public] # put it on GitHub as <you>/kith-agent-<name>, private unless --public
kith share <agent> --public --fresh # a public copy without its history, at <you>/kith-<name>
kith market [words] # agents people shared, most starred first: pick any by number to install
kith search [words] [--mine] # find agents: GitHub repos tagged kith-agent (yours incl. private, anyone's public)
kith deps <agent>|--all [--install] # programs an agent runs that this Mac lacks; --install adds them
kith push <agent>|--all # push committed versions (kith-state commit does this for every agent)
Installing shows what the agent would do on your Mac before it is kept: its permission mode, what it may run without asking, hooks, MCP servers, tools, skills, and the services it asks for (nothing is shared until you grant it). An agent may bring the definition of a service the kernel lacks in its own services/<name>.json. Programs it runs go in requires: in its capabilities.yaml (ffmpeg: brew install ffmpeg # cutting footage): installing lists the missing ones and offers to install them, kith doctor keeps checking, kith deps <agent> --install adds them later. --public runs the privacy check over the package and its whole history and refuses while either names you: a public repo shows every old commit. --fresh publishes a separate public copy instead: one commit per publish with the agent as it is now (its files still checked), while the agent's own repo keeps every version and stays private. Every share writes a README from agent.md unless the agent has one. An agent installed from someone else keeps their repo as upstream; kith share makes yours origin, so kith update still pulls their changes.
Agent repos carry the GitHub topic kith-agent (kith share adds it) and the kernel kith, so they group without an organization: kith search --mine lists yours, private ones included, and public agents from anyone show up in kith search and at github.com/topics/kith-agent. To publish an agent of your own made outside kith share, add the topic.
Running kith takes 3 + N repos: this kernel, your state repo, your agents repo, and one per agent role you've shared. Agents installed from someone else stay in their repo (+0 until you kith share your own copy); third-party skills are fetched from their authors' repos (+0). Several agents can share one repo, installed by folder (kith install <owner>/<repo>/tree/main/<folder>), though kith share makes one repo per agent.
| Repo | Holds | Visibility |
|---|---|---|
kith (this one) | the kernel: kernel/ (code, PROTOCOL.md, services/, the plugin), docs, install.sh | public |
<you>/hq-state, cloned to ~/.kith | kith's knowledge: user.md, projects, agents.json, private-words | private |
<you>/hq-agents, cloned to agents/ | each agent's life: memory, data (as text dumps), jobs, private skills | private |
<you>/kith-agent-<name>, in agents/<name>/role | one agent's role: agent.md, settings, skills, tools, learned skills | private unless you share it |
| You change | It goes to | Saved by |
|---|---|---|
| An agent (you edit it, ask it to change, or it learns a skill) | that agent's repo | committed automatically on its next run; pushed by kith-state commit or kith push <name> |
A new agent (kith-new, or ask the builder) | a new local repo in the agents folder | kith backup once creates its private GitHub repo; from then on as above |
The kernel or PROTOCOL.md | this repo | a normal commit and push; it's public, so kith-pkg check --share first |
A service (kernel/services/<name>.json) | this repo | as the kernel; it names its secrets, never their values |
| Memory and data | the agents repo | kith-state commit |
| Your profile, projects, which agents you have | the state repo | kith-state commit |
kith backup does all of it at once: a private repo for each piece that has none yet (it lists them and asks), then commits and pushes everything. Saying yes to backups in kith init also runs it every night at 03:00 (kith backup --nightly, a LaunchAgent; --nightly off stops it), so a new agent of yours gets its private repo the next night. kith doctor says in one line what is backed up, what changed since, and what is only on this Mac.
A kernel change reaches every agent on its next run without touching their repos. An agent that starts to need: a new service only passes its checks where that service exists, so push the service with the kernel.
On a new Mac, install as usual: kith init asks whether to bring back a GitHub backup (logging you in to GitHub if needed), finds <you>/hq-state, fetches it into ~/.kith and your hq-agents into agents/, then installs each agent's role (agents.json) into agents/<name>/role. It names any agent that was never backed up. install.sh --from <you> does the same without asking.
Not in any repo, so redo them by hand on a new Mac:
agents.json records it without a link, so install.sh skips it;~/.kith/secrets/auth.json), secrets (Keychain), ~/.kith/secrets/env: kith auth grant, kith auth set;~/Library/LaunchAgents/dev.kith.*.plist): kith schedule --install, kith-bridge --install, each tool's own install command. The jobs themselves are in the agents repo.agents/<name>/files/): on disk only; back it up with the rest of the Mac.Everything is in the kith folder, laid out by what each thing is (docs/architecture.md, Concepts): kith and each agent have what they are (shareable, versioned) and what they have lived (private). The kith folder is this repo; its code is in kernel/, which is what kith is. Each agent is a folder in agents/: role/ is what it is (its own repo), and next to it what it has lived: memory/, data/, files/. This repo ignores agents/ (its own private repo), so git pull only ever updates kith. Don't run git clean -x here: it deletes ignored folders. ~/kith is kith's stable address, a link to the kith folder (made by install.sh), so the folder can live anywhere, an external disk included; KITH_HOME points somewhere else instead.
~/.kith is this Mac's kith, a real folder on the internal disk, as ~/.claude is Claude Code's. What kith has lived (who it works for, projects, the team, rules) is its git repo (your hq-state); its .gitignore commits only those files, so everything else in it stays on this Mac: secrets/ (grants, tokens; agents' file tools are denied it), settings, job state, the cost ledger, role snapshots, logs/, cache/.
install.sh links ~/kith and ~/.local/bin/kith, makes ~/.kith, installs the Claude Code plugin
.claude-plugin/ this repo as a plugin marketplace (serves kernel/adapters/claude-code)
docs/ architecture, codebase map
kernel/ the code
adapters/claude-code/ the Claude Code adapter (plugin)
adapters/harness.py the other harnesses an agent can run on: codex, pi, agy, hermes, qwen, dsh, opencode
lib/kith_paths.py the one place that knows where things are
bin/project-id resolves the current directory to a project; --list, --register
bin/kith run an agent directly: kith ls · kith <agent> [-p ID] [task] · --print
bin/kith-pkg agents: install, update, history, restore, share; versions: resolve (pin a run), pin, check
bin/kith-new scaffold a new agent as its own repo
bin/kith-auth shared services: which agent may use which, as which account
bin/kith-state commit what was lived: state and agents repos (sqlite dumped as text), then each role
bin/kith-ask ask an agent and get its answer back; continue the conversation (-c)
bin/kith-live live agents (session: live): start, find and message their one session
bin/kith-remote agents on your other machines over ssh: kith remote add, kith ask agent@machine
bin/kith-memory search the agents' memory and shared notes
bin/kith-sched scheduled agent jobs (cron, launchd)
bin/kith-account extra Claude / Codex logins and the backup chain: kith account add, kith backup list|add|remove
PROTOCOL.md memory rules, appended to every agent's prompt
services/<name>.json shared services (MCP servers, APIs, logins): how to start them, which secrets by name
agents/ the agents folder (ignored by this repo; your hq-agents repo)
<name>/ one agent
role/ what it is: its own git repo (shareable)
agent.md identity, how it works, skill index
capabilities.yaml tools and data sources it may use, by name only — never credentials
settings.json Claude Code settings merged over your global ones for kith runs: model, plugins, permissions
skills/ tools/ its skills (read on demand) and the tools they drive
skills/learned/ skills it wrote itself; versioned and shared with it
memory/MEMORY.md long-term index (auto-loaded on every call)
memory/projects/<id>.md its notes on one project
memory/sessions/<id>.md where the last call on that project left off
data/ its databases; committed as text dumps (<name>.sql), not binaries
files/<project>/ what it made: renders, images, exports, backups (on disk, not in git)
skills/learned/ private skills, never shared (kith-pkg promote <agent> <skill> moves one into role/)
jobs.json its scheduled jobs (kith schedule)
~/.kith/ this Mac's kith; what kith has lived is your kith-state repo
projects.json project IDs → the git remotes / paths that belong to them
agents.json which agents you have and where their roles come from
private-words words no shared role may contain (kith-pkg check --share)
user.md who kith works for (incl. visual taste), read by every agent
projects/<id>/ codebase.md (shared project map) + verify.md (how to check a change) + decisions.md (append-only log)
secrets/ … store/ logs/ this Mac only, never committed (snapshots of roles: store/<name>@<version>/)
An agent's package is its role/; its life is the folder around it. Every run is pinned to a snapshot of it, so agents can be upgraded at any time, by anyone (you, the agent writing a learned skill, a code agent like this one), without waiting for running sessions:
bin/kith-pkg fingerprints the package. Unchanged: run the existing snapshot. Changed: check it (frontmatter, JSON, skills named in agent.md exist, needed services exist), then snapshot it into ~/.kith/store/<name>@<version>/ (an APFS clone on the same disk, else hard links to the previous one's unchanged files), and commit it to the agent's repo. Prompt and settings stay templates in the snapshot; placeholders are filled at run time. Running sessions keep the snapshot they started on.{{PLACEHOLDERS}}, skills named in agent.md exist, needed services exist.touch <agents folder>/<name>/.editing first and delete it when done; runs keep the last good version meanwhile, so nobody gets a half-edited agent. A marker older than an hour is ignored.kith versions <name> lists them; kith rollback <name> pins the previous one; kith pin <name> <version>; kith unpin <name> follows edits again. Old snapshots are removed after two days unless pinned, good or running; the commits stay, and kith restore brings any of them back.kith:<name> subagents are pinned per Claude Code session (the adapter resolves every agent at session start).In prompts and settings: {{AGENT_DIR}} is the pinned package (read skills and run tools from it), {{PACKAGE}} the agent's live role/ (where it writes learned skills), {{AGENTS}} the agents folder (another agent's tools: {{AGENTS}}/house/role/tools/house.py), {{HOME_DIR}} the agent's own folder (memory, data, files), {{HOME_REAL}} the same with symlinks resolved (use it in permission rules: Claude Code matches resolved paths), {{STATE}} the state folder, {{HQ}} the kernel repo, {{USER_HOME}} your home folder (for permission rules, instead of /Users/<name>). Tools find their data through the snapshot's .home file; run from the live role/ (launchd, another agent), they use the folder around it.
Skills from public GitHub repos are linked, not copied. An agent's skills.json names each one's repo, path and ref (a full commit sha) and its licence; kith-pkg fetch <agent>|--all downloads just those folders into skills/<name>/ (a shallow, sparse fetch: seconds, however big the repo), where they're git-ignored and carry a .source file. install.sh fetches every agent's; a run fetches anything missing on first use. A package whose linked skills aren't fetched fails its checks, so the last good version runs. To upgrade one, change its ref. Notes of your own about a linked skill go next to it (image's skills/anatomy-kith.md), never inside it. A skill for your own install only (one you can't or won't share) goes in the agent's own skills/learned/ (next to role/, not in it), not the package, listed in its MEMORY.md.
Three layers, so sharing an agent never shares an account:
| Layer | Where | Says |
|---|---|---|
| Service | kernel/services/<name>.json (repo) | what it is (mcp, cli, login), how to start it, which env vars it needs |
| Need | needs: [...] in the agent's capabilities.yaml (package) | what the agent asks for |
| Grant | ~/.kith/secrets/auth.json (yours, never in git) | profiles (an account: where each secret comes from) and grants (agent → service → profile) |
kith auth # who needs what, who has what, whether each secret resolves
kith auth grant personal notion notion/library
kith auth set notion/library NOTION_API_KEY # store in Keychain (prompts), point the profile at it
bin/kith starts only the MCP services an agent needs and is granted; secrets are resolved when the server starts (kith-auth exec) and exist only in that process, never in a config file or the agent's environment. A cli tool asks kith-auth get --for <agent>, which refuses agents without a grant. Every secret handed out is logged (time, service, profile, who asked, who was running; never the value) to ~/.kith/secrets/auth.log. A broken auth.json or a secret that doesn't resolve costs the agent that service for the run, never the run itself. Profile sources: keychain:SVC#ACCOUNT, env-file:PATH#VAR, env:VAR. This keeps cooperating agents to least privilege; it isn't a sandbox (an agent with a shell can read what your user can). kith:<name> subagents run inside the calling session and share its MCP servers; give an agent with its own credentials a full kith <name> run when it needs them.
kith <agent> runs a normal Claude Code session: your global settings, hoo
hooks/register.tsx 493 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Claude Code adapter for kith. The kernel (bin/, PROTOCOL.md) lives in the kith repo; the installed agents in the
4// agents folder (<kith>/agents/<name>/role, each its own git repo, next to the agent's memory, data and files; listed by bin/kith-pkg); this plugin only reads them:
5// session.start register every installed agent's agent.md as `kith:<name>`, pinned for this session to the
6// package version bin/kith-pkg resolves (edits made meanwhile apply to the next session)
7// agent.spawn resolve the project, prepend it and the agent's memory to the task
8// turn.step the agent's `effort:` (agent.md frontmatter) on its model requests; `model:` is set at register
9// classic.SubagentStop if the agent didn't write its session file, write a short stub from its last message (never
10// blocks: a second turn would replace the helper's report with the answer to the nudge)
11// /kith, mcp__kith__invoke list agents; call one with an explicit project (invoke waits for the report and returns it)
12// The kernel comes from kith_path (default ~/kith/kernel, else ~/hq/kernel, ~/.kith/kernel, ~/Workspace/kith); agents and state from the resolver, as every
13// kernel tool finds them (lib/kith_paths.py).
14
15type Spawned = { agent: string; project: string; isNamed: boolean; cwd: string; task: string; startedMs: number; sessionsDir?: string }
16
17const spawned = new Map<string, Spawned>()
18// A subagent's final report by agent id (turn.complete), for mcp__kith__invoke to hand to its caller; bounded.
19const reports = new Map<string, string>()
20const waiting = new Map<string, (report: string) => void>()
21// Reasoning effort per kith agent (from agent.md) and per running kith subagent. A spawn has no effort field, so
22// agent.spawn notes the agent and turn.step applies it to that agent's requests.
23const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
24const agentEffort = new Map<string, string>()
25const runningEffort = new Map<string, string>()
26// Agents that run as one long-lived session (`session: live` in agent.md): never a subagent here, since a subagent
27// runs with the caller's settings and permission mode, outside the agent's own session. Reached with `kith ask`.
28const liveAgents = new Set<string>()
29
30function frontmatter(text: string): { fields: Record<string, string>; body: string } {
31 const m = /^---\n([\s\S]*?)\n---\n?/.exec(text)
32 if (!m) return { fields: {}, body: text }
33 const fields: Record<string, string> = {}
34 for (const line of m[1].split('\n')) {
35 const kv = /^([A-Za-z_-]+):\s*(.*)$/.exec(line)
36 if (kv) fields[kv[1]] = kv[2].trim()
37 }
38 return { fields, body: text.slice(m[0].length) }
39}
40
41async function kithRoot($: EngineInterface, configured: unknown): Promise<string> {
42 const set = typeof configured === 'string' ? configured.trim() : ''
43 const home = (await $.env.get('HOME')) ?? ''
44 if (set) return set.replace(/^~(?=\/)/, home)
45 // ~/kith/kernel is where install.sh puts it; ~/hq/kernel, ~/.kith/kernel, ~/.hq/kernel and ~/Workspace/kith are where older installs have it (hq_path is the config key before the rename).
46 for (const k of [`${home}/kith/kernel`, `${home}/hq/kernel`, `${home}/.kith/kernel`, `${home}/.hq/kernel`]) if (await $.fs.exists(`${k}/bin/kith-pkg`)) return k
47 return `${home}/Workspace/kith`
48}
49
50// Where things are, from the kernel's one resolver (lib/kith_paths.py): the state folder (~/.kith) and
51// agents/, each as an address and with symlinks resolved (_REAL, what permission rules are matched against).
52async function kithPaths($: EngineInterface, kith: string): Promise<Record<string, string>> {
53 const run = await $.process.run(['/usr/bin/python3', `${kith}/lib/kith_paths.py`, '--json'], { timeoutMs: 10_000 })
54 try {
55 if (run.exitCode === 0) return JSON.parse(run.stdout) as Record<string, string>
56 } catch {}
57 return { KITH_STATE: `${kith}/state`, KITH_AGENTS: `${kith}/agents`, KITH_STATE_REAL: `${kith}/state`, KITH_AGENTS_REAL: `${kith}/agents` }
58}
59
60// Helpers that take $ must be top-level functions: the hooks loader refuses the whole module otherwise.
61
62// Installed agents, {name: package folder}: the agents folder first, then the repo's own agents/ (older installs).
63async function listAgents($: EngineInterface, kith: string): Promise<Record<string, string>> {
64 const run = await $.process.run([`${kith}/bin/kith-pkg`, 'list', '--json'], { timeoutMs: 30_000 })
65 try {
66 const out = run.exitCode === 0 ? (JSON.parse(run.stdout) as Record<string, { dir: string }>) : {}
67 return Object.fromEntries(Object.entries(out).map(([name, o]) => [name, o.dir]))
68 } catch {
69 return {}
70 }
71}
72
73// One call pins every agent: {name: {dir, version, note}}. Empty when kith-pkg fails; agents then run live.
74async function resolvePackages($: EngineInterface, kith: string): Promise<Record<string, { dir: string; note: string }>> {
75 const run = await $.process.run([`${kith}/bin/kith-pkg`, 'resolve', '--all', '--json'], { timeoutMs: 30_000 })
76 try {
77 return run.exitCode === 0 ? (JSON.parse(run.stdout) as Record<string, { dir: string; note: string }>) : {}
78 } catch {
79 return {}
80 }
81}
82
83// One line on where this session sits among the user's projects (bin/project-id --info, --children), or '' for a
84// project with no parent, home or children. In a repo: its parents and their files. At a parent: what it holds.
85async function projectContext($: EngineInterface, kith: string, cwd: string): Promise<string> {
86 const run = async (...args: string[]) => {
87 const r = await $.process.run([`${kith}/bin/project-id`, ...args], { timeoutMs: 10_000 })
88 return r.exitCode === 0 ? r.stdout.trim() : null
89 }
90 const id = await run(cwd)
91 if (!id) return ''
92 const rows = (out: string | null) => (out ? out.split('\n').map((l) => l.split('\t')) : [])
93 const chain = rows(await run('--info', id))
94 const held = rows(await run('--children', id))
95 const homes = chain.filter(([, h]) => h && h !== '-').map(([p, h]) => `${p}: ${h}`)
96 if (chain.length < 2 && !homes.length && !held.length) return ''
97 const parents = chain.slice(1).map(([p]) => p)
98 return (
99 `kith project: ${id}` + (parents.length ? `, part of ${parents.join(' → ')}` : '') + '.' +
100 (held.length
101 ? ` It holds ${held.map(([c, d]) => `${c} (${d === '-' ? 'not on this Mac' : d})`).join(', ')}.`
102 : '') +
103 (homes.length ? ` The user's own files for it (specs, briefs, contracts): ${homes.join('; ')}.` : '') +
104 (parents.length
105 ? ' Open the parent\'s files only when the task needs more than this project; `kith project` shows the rest.'
106 : ' `kith project` shows the rest.')
107 )
108}
109
110// A live agent takes work as a message to its session: the caller runs kith ask, which delivers it there.
111function liveAsk(kith: string, agent: string, task: string, project: string): string {
112 const q = (s: string) => `'${s.replaceAll("'", `'\\''`)}'`
113 return (
114 `kith:${agent} runs as one live session (session: live), not as a subagent. Send it the task with the Bash tool:\n\n` +
115 `${q(`${kith}/bin/kith`)} ask ${agent}${project ? ` -p ${project}` : ''} ${q(task)}\n\n` +
116 'and relay what it answers.'
117 )
118}
119
120// What a helper keeps of its own when it runs in the owner's session: its role's permission mode (as bin/kith picks it:
121// KITH_PERMISSION_MODE, the agent's settings, else kith's default), and a PreToolUse that allows writes to `dirs`.
122async function ownPermissions($: EngineInterface, kith: string, name: string, dirs: string[]) {
123 const mode = await $.process.run(['/usr/bin/python3', `${kith}/lib/kith_settings.py`, 'permission_mode', name], { timeoutMs: 10_000 })
124 const raw = mode.exitCode === 0 ? mode.stdout.trim() : ''
125 const permissionMode = ({ manual: 'default', dontAsk: 'auto' } as Record<string, string>)[raw] ?? raw
126 const q = (v: string) => `'${v.replaceAll("'", `'\\''`)}'`
127 return {
128 ...(permissionMode ? { permissionMode } : {}),
129 hooks: {
130 PreToolUse: [{
131 matcher: 'Write|Edit|MultiEdit|NotebookEdit',
132 hooks: [{ type: 'command', command: [q(`${kith}/bin/kith-helper-allow`), ...dirs.map(q)].join(' ') }],
133 }],
134 },
135 }
136}
137
138export const register: Register = (on, options) => {
139
140 // agentDir: the pinned snapshot from kith-pkg (or the live folder when versioning is unavailable).
141 // state: the state folder; stateReal: the same with symlinks resolved ({{HOME_REAL}}, used by permission rules).
142 let state = ''
143 let stateReal = ''
144 let userHome = ''
145 let agentsRoot = ''
146 let machine = ''
147 let installed: Record<string, string> = {}
148 let agentsReal = ''
149 let projectLine = ''
150 // An agent's life (memory, data, files): its folder next to role/; older layouts: <state>/agents/<name>.
151 const lifeOf = (agent: string, kith: string, real = false) => {
152 const dir = installed[agent]
153 if (dir?.endsWith('/role')) return real && agentsReal ? `${agentsReal}/${agent}` : dir.slice(0, -'/role'.length)
154 return `${(real && stateReal) || state || kith}/agents/${agent}`
155 }
156 // The run's memory layout, as bin/kith renders it: the owner's is the agent's memory/ itself; anyone else's is
157 // memory/people/<id>/, with their outputs in files/people/<id>/ and who they are in <state>/people/<id>.md.
158 // person '' is the owner.
159 const layout = (agent: string, kith: string, person: string) => {
160 const life = lifeOf(agent, kith)
161 return person
162 ? { mem: `${life}/memory/people/${person}`, files: `${life}/files/people/${person}`,
163 personFile: `${state || kith}/people/${person}.md`, shared: `${life}/memory/shared` }
164 : { mem: `${life}/memory`, files: `${life}/files`, personFile: `${state || kith}/user.md`, shared: `${life}/memory/shared` }
165 }
166 // Who a run's memory belongs to: '' for the owner (KITH_OWNER decides; KITH_PERSON can name the owner too, and without
167 // either the run is the owner's), else the person's id. A KITH_PERSON that is no valid id yields null: no memory at all.
168 const runPerson = async ($: any): Promise<string | null> => {
169 const flag = await $.env.get('KITH_OWNER')
170 const person = (await $.env.get('KITH_PERSON')) ?? ''
171 if (flag !== undefined && flag !== '' ? flag === '1' : !person) return ''
172 return /^[a-z][a-z0-9-]*$/.test(person) ? person : null
173 }
174 let runWho: string | null = ''
175 const fill = (text: string, kith: string, agent: string, agentDir = installed[agent] ?? `${kith}/agents/${agent}`) => {
176 const l = layout(agent, kith, runWho ?? '')
177 return text
178 .replaceAll('{{MEMORY_DIR}}', l.mem)
179 .replaceAll('{{SHARED_MEMORY}}', l.shared)
180 .replaceAll('{{FILES_DIR}}', l.files)
181 .replaceAll('{{PERSON_FILE}}', l.personFile)
182 .replaceAll('{{AGENT_DIR}}', agentDir)
183 .replaceAll('{{AGENTS}}', agentsRoot || `${state || kith}/packages`)
184 .replaceAll('{{PACKAGE}}', installed[agent] ?? `${kith}/agents/${agent}`)
185 .replaceAll('{{HOME_DIR}}', lifeOf(agent, kith))
186 .replaceAll('{{HOME_REAL}}', lifeOf(agent, kith, true))
187 .replaceAll('{{STATE}}', state || kith)
188 .replaceAll('{{MACHINE}}', machine || `${userHome}/.kith`)
189 .replaceAll('{{USER_HOME}}', userHome)
190 .replaceAll('{{KITH}}', kith)
191 .replaceAll('{{HQ}}', kith) // role repos written before the rename
192 .replaceAll('{{AGENT}}', agent)
193 }
194
195 on('session.start', async ($, e, next) => {
196 const result = await next(e)
197 const kith = await kithRoot($, (options.kith_path || options.hq_path))
198 if (!(await $.fs.exists(`${kith}/bin/kith-pkg`))) {
199 $.ui.toast(`kith: no kith repo at ${kith}; set kith_path in /plugin config`)
200 return result
201 }
202 const protocol = await $.fs.read(`${kith}/PROTOCOL.md`)
203 const where = await kithPaths($, kith)
204 state = where.KITH_STATE
205 stateReal = where.KITH_STATE_REAL
206 agentsRoot = where.KITH_AGENTS
207 machine = where.KITH_MACHINE ?? ''
208 agentsReal = where.KITH_AGENTS_REAL
209 installed = await listAgents($, kith)
210 userHome = (await $.env.get('HOME')) ?? ''
211 projectLine = await projectContext($, kith, e.cwd)
212 const packages = await resolvePackages($, kith)
213 // A subagent inherits the caller's permissions. For the owner's session the helper gets its own role's instead
214 // (its permission mode, and its own memory, files and notes writable). A run for anyone else (KITH_PERSON, set by
215 // bin/kith) stays capped by the caller: nothing is widened for a guest.
216 // bin/kith says whether the run is the owner's (KITH_OWNER; KITH_PERSON can name the owner too); without it, no person means owner.
217 runWho = await runPerson($)
218 const isOwner = runWho === ''
219 for (const [name, dir] of Object.entries(installed)) {
220 const file = `${dir}/agent.md`
221 if (!(await $.fs.exists(file))) continue
222 const pkg = packages[name]
223 // The pinned agent.md, so model and effort change with the version like everything else.
224 const pinned = pkg && (await $.fs.exists(`${pkg.dir}/agent.md`)) ? `${pkg.dir}/agent.md` : file
225 const { fields, body } = frontmatter(await $.fs.read(pinned))
226 if (fields.session === 'live') {
227 liveAgents.add(name)
228 continue
229 }
230 liveAgents.delete(name)
231 if (EFFORTS.has(fields.effort ?? '')) agentEffort.set(name, fields.effort)
232 else agentEffort.delete(name)
233 const rendered = pkg && (await $.fs.exists(`${pkg.dir}/prompt.md`)) ? await $.fs.read(`${pkg.dir}/prompt.md`) : ''
234 await $.agent.register({
235 name,
236 description: fields.description ?? `kith specialist: ${name}`,
237 prompt: rendered
238 ? fill(rendered, kith, name, pkg.dir)
239 : `${fill(body, kith, name)}\n${fill(protocol, kith, name)}`,
240 ...(fields.model ? { model: fields.model } : {}),
241 ...(isOwner
242 ? await ownPermissions($, kith, name, [
243 lifeOf(name, kith), lifeOf(name, kith, true),
244 `${dir}/skills/learned`, ...(agentsReal ? [`${agentsReal}/${name}/role/skills/learned`] : []),
245 `${state || kith}/projects`, ...(stateReal ? [`${stateReal}/projects`] : []),
246 ])
247 : {}),
248 })
249 }
250 await $.command.register({
251 name: 'kith',
252 description: 'kith specialists: list them, or delegate a task (/kith <agent> [-p project] <task>)',
253 argumentHint: '[agent] [-p project] [task]',
254 })
255 await $.tool.register({
256 name: 'invoke',
257 description:
258 'Call a kith specialist (persistent agent with its own memory) on a task. ' +
259 'Give `project` when the work is for a known project that the current directory does not belong to.',
260 inputSchema: {
261 type: 'object',
262 properties: {
263 agent: { type: 'string', description: 'Agent name, e.g. branding' },
264 task: { type: 'string', description: 'What to do' },
265 project: { type: 'string', description: 'Project ID from projects.json (optional)' },
266 },
267 required: ['agent', 'task'],
268 },
269 })
270 return result
271 })
272
273 // Every kith spawn, whoever starts it: project + fresh long-term memory go in front of the task.
274 // A session in a project that sits in a parent, or has a home, is told so in one line; nothing for any other.
275 on('prompt.compose', async ($, e, next) => {
276 const result = await next(e)
277 if (!projectLine) return result
278 return { sections: [...result.sections, { id: 'kith:project', text: projectLine, scope: 'session' as const }] }
279 }).catch(($, e, next) => next(e))
280
281 on('agent.spawn', async ($, e, next) => {
282 if (!e.subagentType.startsWith('kith:')) return next(e)
283 const kith = await kithRoot($, (options.kith_path || options.hq_path))
284 const agent = e.subagentType.slice(5) // after "kith:"
285 const cwd = e.cwd ?? (await $.session.cwd())
286
287 // A run for someone other than the owner (KITH_PERSON, from bin/kith) may start only the helpers that person may
288 // use, checked as this agent's helper. Fails closed: no answer from kith-auth, no spawn.
289 const person = (await $.env.get('KITH_PERSON')) ?? ''
290 if (person) {
291 const caller = (await $.env.get('KITH_AGENT')) ?? ''
292 let why = ''
293 try {
294 const r = await $.process.run([`${kith}/bin/kith-auth`, 'may', person, agent, ...(caller ? ['--via', caller] : [])],
295 { timeoutMs: 10_000 })
296 if (r.exitCode !== 0) why = r.stdout.trim() || r.stderr.trim() || `${person} may not use ${agent}`
297 } catch {
298 why = `can't check whether ${person} may use ${agent}`
299 }
300 if (why) return { deny: `kith: ${why}. This run is for ${person}; only the owner can give them ${agent}.` }
301 }
302
303 const named = /^<(?:kith|hq) project="([a-z0-9][a-z0-9._-]*)"\/>\n?/.exec(e.prompt)
304 let project = named?.[1] ?? ''
305 if (!project) {
306 const found = await $.process.run([`${kith}/bin/project-id`, cwd], { timeoutMs: 10_000 })
307 project = found.exitCode === 0 ? found.stdout.trim() : ''
308 }
309 const task = named ? e.prompt.slice(named[0].length) : e.prompt
310 // The project's parents, closest first (bin/project-id --info): agents read their memory too.
311 const info = project ? await $.process.run([`${kith}/bin/project-id`, '--info', project], { timeoutMs: 10_000 }) : null
312 const parents = info?.exitCode === 0
313 ? info.stdout.trim().split('\n').slice(1).map((l) => l.split('\t')[0]).join(' ')
314 : ''
315 if (!state) state = (await kithPaths($, kith)).KITH_STATE
316 const today = new Date().toISOString().slice(0, 10)
317
318 // Whose memory the helper gets. The owner's run: the agent's own MEMORY.md, as ever. Anyone else's: what
319 // kith_facets.py loads for that person, as bin/kith does for their own run (a bot guest, bot-*, is a guest). Fails
320 // closed: when that can't be had, the helper gets no memory, never the owner's.
321 const who = await runPerson($)
322 let memoryBlock = ''
323 let sessionsDir = who === null ? '' : `${layout(agent, kith, who).mem}/sessions`
324 if (who === '') {
325 const memoryFile = `${layout(agent, kith, '').mem}/MEMORY.md`
326 const memory = (await $.fs.exists(memoryFile)) ? await $.fs.read(memoryFile) : ''
327 memoryBlock = `<long-term-memory path="${memoryFile}">\n${memory}\n</long-term-memory>`
328 } else if (who) {
329 try {
330 const caller = (await $.env.get('KITH_AGENT')) ?? ''
331 const res = await $.process.run([`${kith}/bin/kith-auth`, 'resolve', agent, ...(caller && caller !== agent ? ['--via', caller] : [])],
332 { timeoutMs: 10_000 })
333 const r = JSON.parse(res.stdout)
334 const id = String(r.person ?? who)
335 if (res.exitCode !== 0 || r.allowed !== 1 || r.owner === 1 || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('not resolved')
336 const dirs = layout(agent, kith, id)
337 sessionsDir = `${dirs.mem}/sessions`
338 const rooms = (Array.isArray(r.rooms) ? r.rooms : []).filter((x: unknown) => typeof x === 'string').join(' ')
339 const home = lifeOf(agent, kith)
340 const out = await $.process.run(['python3', `${kith}/lib/kith_facets.py`, 'load', '--agent', agent, '--home', home,
341 '--person-file', dirs.personFile, '--kind', id.startsWith('bot-') ? 'guest' : 'person', '--person', id,
342 '--rooms', rooms, '--room', (await $.env.get('KITH_ROOM')) ?? '', '--project', project,
343 '--agent-md', `${installed[agent] ?? `${home}/role`}/agent.md`, '--json'], { timeoutMs: 10_000 })
344 if (out.exitCode !== 0) throw new Error('facets load failed')
345 memoryBlock = JSON.parse(out.stdout).block ?? ''
346 } catch {
347 memoryBlock = ''
348 }
349 }
350
351 // The helper's report is the last thing it says: the session file goes first.
352 const sessionFile = project && sessionsDir ? `${sessionsDir}/${project}.md` : ''
353 const prompt =
354 `<session project="${project || 'none'}"${parents ? ` parents="${parents}"` : ''} cwd="${cwd}" date="${today}"/>\n` +
355 `${memoryBlock}${memoryBlock ? '\n\n' : ''}${task}` +
356 (sessionFile
357 ? `\n\n(Before your final report, write ${sessionFile}: task, state, next step, open questions; max 30 lines. ` +
358 'Your final message is what the caller receives: make it the report.)'
359 : '')
360
361 const startedMs = Date.now() // before the spawn: a spawn that waits for the run must not date its files before it
362 const started = await next({ ...e, prompt })
363 if (started.agentId) {
364 spawned.set(started.agentId, { agent, project, isNamed: !!named, cwd, task, startedMs, sessionsDir })
365 const effort = agentEffort.get(agent)
366 if (effort) runningEffort.set(started.agentId, effort)
367 }
368 return started
369 }).catch(($, e, next) => next(e)) // never block a spawn because of kith
370
371 // Only requests that carry an effort (the model supports it) get the agent's.
372 on('turn.step', async function* ($, e, next) {
373 const effort = e.agentId !== undefined ? runningEffort.get(e.agentId) : undefined
374 if (effort === undefined || e.effort === undefined) return yield* next(e)
375 return yield* next({ ...e, effort: effort as typeof e.effort })
376 })
377
378 // The session file is how the next call picks up. Never block here: a second turn would make the nudge's answer the
379 // helper's last message, which is what the caller gets as its report. If the helper didn't write the file, write a short
380 // note from its last message. The project is resolved now, not at spawn (a rename in between), and a helper resumed with
381 // SendMessage is checked against its resume, not its first start.
382 on('classic.SubagentStop', async ($, e, next) => {
383 // A helper that stops without a turn.complete still answers a waiting invoke.
384 const waiter = waiting.get(e.agent_id)
385 if (waiter) {
386 waiting.delete(e.agent_id)
387 waiter(e.last_assistant_message ?? '')
388 }
389 const run = spawned.get(e.agent_id)
390 if (!run) return next(e)
391 const kith = await kithRoot($, (options.kith_path || options.hq_path))
392 if (!state) state = (await kithPaths($, kith)).KITH_STATE
393 let project = run.project
394 if (!run.isNamed) {
395 const found = await $.process.run([`${kith}/bin/project-id`, run.cwd], { timeoutMs: 10_000 })
396 if (found.exitCode === 0 && found.stdout.trim()) project = found.stdout.trim()
397 }
398 // Done with this run; a helper resumed with SendMessage comes through agent.spawn again under the same id.
399 spawned.delete(e.agent_id)
400 runningEffort.delete(e.agent_id)
401 const startedMs = run.startedMs
402 if (!project) return next(e)
403 // The dir the spawn pointed the helper at: a guest's run never writes into the owner's sessions.
404 const dir = run.sessionsDir ?? `${lifeOf(run.agent, kith)}/memory/sessions`
405 if (!dir) return next(e)
406 const file = `${dir}/${project}.md`
407 const exists = await $.fs.exists(file)
408 // Written when any of the agent's session files changed during the run (it may have used another project's name).
409 for (const f of (await $.fs.exists(dir)) ? await $.fs.list(dir) : []) {
410 if (f.name.endsWith('.md') && f.mtimeMs >= startedMs) return next(e)
411 }
412 const date = new Date().toISOString().slice(0, 10)
413 const last = (e.last_assistant_message ?? '').trim().slice(0, 3000)
414 const note =
415 `# ${run.agent} on ${project}, ${date} (written by the kith plugin: the agent did not write its session file)\n\n` +
416 `Task: ${run.task.replace(/\s+/g, ' ').trim().slice(0, 400)}\n\nLast report:\n${last || '(none)'}\n`
417 await $.fs.write(file, exists ? `${(await $.fs.read(file)).trimEnd()}\n\n${note}` : note)
418 return next(e)
419 }).catch(($, e, next) => next(e))
420
421 // A subagent's report, for mcp__kith__invoke below.
422 on('turn.complete', async ($, e, next) => {
423 if (e.agentId !== undefined) {
424 const wait = waiting.get(e.agentId)
425 if (wait) {
426 waiting.delete(e.agentId)
427 wait(e.answer)
428 } else {
429 reports.set(e.agentId, e.answer)
430 if (reports.size > 32) reports.delete(reports.keys().next().value as string)
431 }
432 }
433 return next(e)
434 }).catch(($, e, next) => next(e))
435
436 on('command.run', { command: 'kith' }, async ($, e) => {
437 const kith = await kithRoot($, (options.kith_path || options.hq_path))
438 const words = e.args.trim().split(/\s+/).filter(Boolean)
439 if (!Object.keys(installed).length) installed = await listAgents($, kith)
440 const agents = Object.keys(installed).sort()
441
442 if (words.length === 0) {
443 const project = await $.process.run([`${kith}/bin/project-id`, await $.session.cwd()])
444 return {
445 text:
446 `kith agents: ${agents.join(', ')}\n` +
447 `project here: ${project.exitCode === 0 ? project.stdout.trim() : 'none'}\n` +
448 'Delegate with /kith <agent> [-p project] <task>.',
449 }
450 }
451
452 const [agent, ...rest] = words
453 if (!agents.includes(agent)) return { text: `kith: no agent '${agent}'. Agents: ${agents.join(', ')}` }
454 let project = ''
455 if (rest[0] === '-p') {
456 project = rest[1] ?? ''
457 rest.splice(0, 2)
458 }
459 const task = rest.join(' ')
460 if (!task) return { text: `kith: what should ${agent} do? /kith ${agent} [-p project] <task>` }
461 if (liveAgents.has(agent)) return { context: [liveAsk(kith, agent, task, project)] }
462 return {
463 context: [
464 `The user asked to delegate this to the kith specialist \`kith:${agent}\`. Call the Agent tool with ` +
465 `subagent_type "kith:${agent}" and this prompt, word for word:\n\n` +
466 (project ? `<kith project="${project}"/>\n` : '') +
467 task +
468 '\n\nWhen it reports back, relay what it did and anything it needs from the user.',
469 ],
470 }
471 })
472
473 on('tool.call', { tool: 'mcp__kith__invoke' }, async ($, e) => {
474 const input = e as unknown as { agent: string; task: string; project?: string }
475 if (liveAgents.has(input.agent)) {
476 return { result: liveAsk(await kithRoot($, (options.kith_path || options.hq_path)), input.agent, input.task, input.project ?? '') }
477 }
478 const prompt = (input.project ? `<kith project="${input.project}"/>\n` : '') + input.task
479 const started = await $.agent.spawn({
480 subagentType: `kith:${input.agent}`,
481 description: `kith ${input.agent}`,
482 prompt,
483 })
484 if (started.deny) return { result: `kith: could not start ${input.agent}: ${started.deny}` }
485 // The answer is the subagent's turn.complete: the call waits for it and returns the report.
486 const id = started.agentId
487 const early = reports.get(id)
488 reports.delete(id)
489 const report = early ?? (await new Promise<string>((resolve) => waiting.set(id, resolve)))
490 return { result: report || `kith:${input.agent} finished without a report.` }
491 }).catch(() => ({ result: 'kith: invoke failed inside the kith plugin.' }))
492}
493