SLOPSHOPPER

kith

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

newguardtoastpromptagents
★ 1v0.8.0MITupdated 2026-10-26Ying-Kai-Liao/kith/kernel/adapters/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kith
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ kith │ ⏺ Read(src/auth.ts) │ kith: no kith repo at │ ⎿ Read 6 lines │ /Users/dev/Workspace/kith; set kith_path │ ⏺ Update(src/auth.ts) │ in /plugin config │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

kith

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.

What makes it different

  • An agent is a folder on your computer, not a container or a hosted service. A personal agent's abilities come from its machine: your browser profile, Keychain, GPU, local models, the CLIs you're logged in to. kith runs agents where those are, as plain Claude Code sessions, instead of rebuilding them somewhere else.
  • An agent is a git repo. Install one from a GitHub link, change it however you like, and every version that passes its checks is committed. Roll back to any of them, share one, or keep it private. The agent learning a new skill is a change like any other: versioned, and undone the same way.
  • What it is and what it has lived are kept apart. Its role (instructions, skills, tools) is shareable; its memory, data and files sit next to it, in your private agents repo. Sharing an agent never shares your life, and upgrading one never touches its memory.
  • Agents carry no accounts. An agent says which services it needs; you grant it one of your accounts. Secrets reach only the process that uses them. Two people can run the same agent with their own logins.
  • Specialists stay cheap. Each capability belongs to one agent and loads only when that agent runs, so your main session sees one line per agent, and adding agents doesn't make every session heavier.

Core idea: simple, stable, scalable

  • Simple. An agent is markdown plus a few tools in a folder. The kernel is a handful of scripts (bash and Python standard library) on top of Claude Code and git: no server, database, daemon or container. If you can read a folder, you can read an agent.
  • Stable. Every run is pinned to a read-only snapshot, so an agent can change mid-session without breaking a run in progress. A version that fails its checks never runs (the last good one does), every good version is in git, and a missing secret or broken service costs a run that service, never the run itself.
  • Scalable. Adding an agent is adding a repo; nothing central grows. Because each agent loads only its own skills, context stays flat as you add more. Agents travel by GitHub link and are found by topic, so going from one person's agents to everyone's is sharing a repo, and serving many people is one machine per person running kith, not a platform.

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.

Agents

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.

Repos, and where each change goes

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.

RepoHoldsVisibility
kith (this one)the kernel: kernel/ (code, PROTOCOL.md, services/, the plugin), docs, install.shpublic
<you>/hq-state, cloned to ~/.kithkith's knowledge: user.md, projects, agents.json, private-wordsprivate
<you>/hq-agents, cloned to agents/each agent's life: memory, data (as text dumps), jobs, private skillsprivate
<you>/kith-agent-<name>, in agents/<name>/roleone agent's role: agent.md, settings, skills, tools, learned skillsprivate unless you share it
You changeIt goes toSaved by
An agent (you edit it, ask it to change, or it learns a skill)that agent's repocommitted 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 folderkith backup once creates its private GitHub repo; from then on as above
The kernel or PROTOCOL.mdthis repoa normal commit and push; it's public, so kith-pkg check --share first
A service (kernel/services/<name>.json)this repoas the kernel; it names its secrets, never their values
Memory and datathe agents repokith-state commit
Your profile, projects, which agents you havethe state repokith-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:

  • an agent you never shared: agents.json records it without a link, so install.sh skips it;
  • grants and profiles (~/.kith/secrets/auth.json), secrets (Keychain), ~/.kith/secrets/env: kith auth grant, kith auth set;
  • LaunchAgents (~/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.
  • what agents made (agents/<name>/files/): on disk only; back it up with the rest of the Mac.

Layout

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>/)

Versions

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:

  • On each run 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.
  • A version that fails its checks is never used; the last good one runs and the reason is printed. Checks: frontmatter, JSON, unknown {{PLACEHOLDERS}}, skills named in agent.md exist, needed services exist.
  • Multi-file edits: 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.
  • If versioning itself fails, the run uses the live folder and says so, rather than not running.
  • 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.

Third-party skills

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.

Services and auth

Three layers, so sharing an agent never shares an account:

LayerWhereSays
Servicekernel/services/<name>.json (repo)what it is (mcp, cli, login), how to start it, which env vars it needs
Needneeds: [...] 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.

Settings

kith <agent> runs a normal Claude Code session: your global settings, hoo

Source 1 files
hooks/register.tsx 493 lines
1import 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