ai agent in Claude Code: /ai compacts an attached session into its thread's view

The ai CLI: an all-in-one AI tool.
ai memory: permanent memory for AI agents. An agent records what it learns as one-line notes. Claude Code folds older notes into summaries, and ai memory view prints the whole memory as one short view.ai notes: a global knowledge store for AI agents. Each note is one claim, filed in a tree of topics; Claude Code summarises every topic from its children, so an agent can browse the tree or search it.ai agent: named agents, each with one thread that spans its sessions. The thread copies its Claude Code sessions into the repository's git, summarises them into a tree you can zoom, and on /ai compacts a session into a view of the whole thread.cargo install ai-tools
or with Nix:
nix profile add github:hatimthayyil/ai-tools
ai agent needs the Claude Code plugin ai@ai. The Nix package ships it in a plugin marketplace, share/ai/plugins (plugins/ here), whose .claude-plugin/marketplace.json lists it as ./claude. Below, <prefix> is where the package is installed, such as ~/.nix-profile.
Without a Team or Enterprise login, on a machine with no managed settings:
claude plugin marketplace add <prefix>/share/ai/plugins
claude plugin install ai@ai
Under a Team or Enterprise login, or on a machine with managed settings, Claude Code runs a built-in policy plugin ahead of every plugin a user installs, and it withholds from them the session start, turn end and compaction events ai agent needs. Only a plugin that managed settings install receives them. As root, add these managed settings:
{
"extraKnownMarketplaces": {
"ai": { "source": { "source": "directory", "path": "<prefix>/share/ai/plugins" } }
},
"enabledPlugins": { "ai@ai": true },
"appendPlugins": ["ai@ai"]
}
either to managed-settings.json or as a file of their own in managed-settings.d/ beside it, in:
/etc/claude-code//Library/Application Support/ClaudeCode/C:\Program Files\ClaudeCode\Claude Code installs the plugin at the next session start. <prefix> must be absolute: /home/<you>/.nix-profile on your own machine, or for a fleet a path every machine has and only administrators can write, such as the NixOS system profile /run/current-system/sw with environment.pathsToLink = [ "/share/ai" ];. Where the organization also delivers server-managed settings from the claude.ai console, Claude Code ignores this file unless that source sets "managedSourcesBehavior": "merge".
ai memory init # create the memory; prints the block for your agent instructions
ai memory view # read the memory
ai view # the whole memory, then this session's thread
ai memory note "what happened" # record one line
ai memory zoom <id> # open a line of the view
ai memory grep <regex> # search every message
Views print whole on a terminal. Piped, as an agent reads them, they come in parts of at most 20,000 bytes. The first read saves every part in $XDG_CACHE_HOME/ai/views/ (default ~/.cache/ai/views/), and each part ends with the command for the next, like ai page 17:2. A saved view is gone 10 minutes after it was last read.
ai stores memories in ~/.ai/memory, or in $AI_MEMORY_DIR if you set it. A newer ai upgrades the memory on first use and prints the commit holding its previous state; older releases then fail on it without writing.
# file a note (or pipe its text on stdin)
ai notes add physics.qm "the claim, in detail" --title "Decoherence explains collapse" --confidence verified
ai notes # the top of the tree: each topic's scope
ai notes zoom physics.qm # a topic's subtopics and notes
ai notes grep <regex> # search every note and summary
ai notes show <id> # one note, whole
ai notes mv <id|topic> <topic> # move a note or a topic
ai notes link <id> <id> # link one note to another
Notes are Markdown files in a git repository at ~/.ai/notes, or at $AI_NOTES_DIR if you set it. Every change is a commit.
ai init # make this directory a project, once: mints its id into ai.toml, links ai.local.toml to .ai/local/ai.toml, ignores both ai.local.toml and .ai/local/ in .gitignore, prints the id
ai agent works in a project: the directory of the nearest ai.toml at or above the current directory, never ~/.ai/ai.toml. It need not be a git repository. Its id is a TypeID, project_ and a UUIDv7; commit ai.toml, so every clone and worktree is the same project. ai writes nothing to a repository's .git.
A project keeps three places for ai: ai/, the agents' visible workspace; .ai/, hidden and tracked by the project's git; and .ai/local/, one user's private data, which the project's git ignores. .ai/local/ holds the agents' threads and the private settings, in the project's main checkout. It is a git repository of its own, made on its first write, so they keep their history. It commits on events, once each, naming the command: creating an agent, attaching, detaching, resuming, /ai and other builds, a session's end (ai agent attach dev, ai agent end-session). A turn's sync, a view and the background summariser write without committing; the next event commits what they wrote. No commit is made when nothing changed. Its .gitignore leaves out locks and logs. A command that fails still commits what it wrote; a commit that fails is an error.
ai agent new dev # create agent dev, with an empty thread
ai agent resume dev [-- claude args] # continue dev's newest session, in the directory it ran in,
# else a new one, whose context is the thread's view;
# --harness: claude is the only one yet
ai resume dev [-- claude args] # the same, shorter
ai agent list # the agents here, newest first, and their sessions
ai agent view dev # dev's view, from the summaries written so far
ai agent zoom -a dev <id> # open a line of it; --tool-output adds tool output
ai agent grep -a dev <regex> # search it; repeat -a to search several agents
ai agent build -a dev # write its summaries now, with a progress bar on a terminal; --rebuild, --model, --dry-run
Only new creates an agent; resume, attach and the reading commands refuse a name it has not made. Unnamed, a command reads the session's agent, else the project's only agent, else the one you pick on a terminal; elsewhere it lists the agents. Inside a session, /ai new <name> creates an agent and attaches the session to it, and /ai attach <name> and /ai detach attach and detach it, so the agent reads its thread with ai agent view and ai agent zoom <id>. /ai replaces the context with ai view: the whole memory, then, in an attached session, the thread's view, so the agent wakes with no further reads. If it cannot build the thread's view, it says why and compacts nothing. An unattached session compacts into the memory alone. /compact and auto-compaction stay Claude Code's own. Threads live in .ai/local/agents/ in the project's main checkout, so its linked worktrees share them; ai init adds /.ai/local/ to .gitignore.
A session is attached to one agent at a time. Detach it explicitly before attaching it to another; the previous agent's thread keeps the recorded history.
Each turn's work, the agent's steps between your message and its final reply, is summarised first; the turn's line is then written from your message, that summary and the final reply. A summary line is asked for 50 to 200 words, more where the AI wrote more and ran more tools, and holds at most 512 characters per 50 words. A command you run, /ai included, writes the lines it needs, however long they take, and stops at the first that fails, naming each failed line with its error: /ai then compacts nothing, and ai agent resume starts no new session. Press Esc while /ai runs to cancel it: its model calls stop, nothing is compacted, the lines it wrote stay, and the next /ai goes on from them. The background summariser, started after each turn, builds what it can and only logs, to <thread>.log beside the threads.
A thread is JSON Lines files: append-only records and summaries, beside byte-for-byte copies of the original transcripts and companion session files. Each file is a directory of 1 MiB segments, so a commit of a growing thread stores only the newest segment. A newer store format is refused; an older one is converted, once you agree, the first time an ai agent command runs on a terminal. Re-attaching a session rebuilds its thread from the transcript.
This needs the Claude Code plugin.
ai reads ~/.ai/ai.toml, then the nearest ai.toml at or above the current directory, then the project's private settings, .ai/local/ai.toml beside it; each file's keys win over the one before. The private settings are one user's overrides of a project's settings, kept in the private store so they have history. ai init creates them and links ai.local.toml to them, so you find them beside ai.toml; it adds the link to the project's .gitignore. An ai.local.toml that is a file of its own, not the link, is an error that says to move it. In a linked git worktree, ai reads the private settings from the same folder in the main checkout, so one file serves every worktree. They take the same tables, except [project]. Every key is optional:
[project] # a project's ai.toml only
id = "project_…" # written by ai init
name = "widget" # its name; default the directory's
[chat]
background = true # summarise in the background after each turn
[agent.view] # how many entries `ai agent view` shows; a turn or a line is one
total = 64 # entries in all
latest = 1 # the newest turns, every item
recent = 3 # the turns before them, message and final reply word for word, work summarised
single = 0 # the fewest older turns kept on a line each; shared lines take the rest
[summariser] # what writes every summary: agents, memory, notes
backend = "claude" # claude -p; the only backend yet
model = "sonnet" # in the backend's own spelling
[memory]
entry-chars = 280 # the length a note aims for, in characters, at most 512; 40 more are accepted
[memory.view]
lines = 96 # how many lines `ai memory view` prints
[agents.stabiliser] # one table per agent that needs one
instructions = "ai/agents/stabiliser.md" # its own instructions, a Markdown file
worktree = "../ai-tools-stabiliser" # the directory it runs in
An agent's instructions is relative to the folder of the file that sets it, so an agent in ~/.ai/ai.toml keeps its instructions in ~/.ai and works in every project; in the private settings, it is relative to the project's root, where their link ai.local.toml sits. Instructions add up: the agent gets the user's, then the project's, then the private settings', each file's text in turn. Its worktree is relative to the project's root, the directory of its ai.toml, in whichever file sets it; in a linked git worktree, the root in the main checkout, so ../dev names the same directory from every checkout.
ai agent resume starts a new session in the agent's worktree, else in the current directory, and adds its instructions to the harness's system prompt, for a new session and a resumed one (Claude Code's --append-system-prompt). ai creates no worktree: a missing one fails, printing the git worktree add command. The worktree must be a git worktree of the project's main checkout, at a commit holding its ai.toml, so its sessions find the threads in the main checkout's .ai/local/agents; a plain copy, or another clone, has its own. A session in the worktree reads the private settings from the main checkout.
ai memory config prints the memory's sizes in effect. An unknown key or a bad value fails the command, naming the file and the key.
ai memory ports Victor Taelin's OptMem to Rust. A separate Claude Code model writes its summaries, using the compaction prompt from his UniiChat (aka OptChat).
hooks/register.ts 173 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const NOT_ATTACHED = 3
4const LONG_MS = 600_000
5const FLUSH_MS = 1_100
6const USAGE = 'Usage: /ai | /ai new <name> | /ai attach <name> | /ai detach'
7const WITHHELD =
8 "Not compacted: this session's start never reached the ai plugin, so its compaction would not either and /compact would summarise natively. Where an organization manages Claude Code, only a plugin its managed settings install receives these events."
9
10const transcript = { plugin: 'ai', key: 'transcript' } as const
11const prepared = { plugin: 'ai', key: 'view' } as const
12const requested = { plugin: 'ai', key: 'compress' } as const
13
14type Ran = { exitCode: number; stdout: string; stderr: string }
15
16async function ai($: EngineInterface, args: string[], timeoutMs?: number): Promise<Ran> {
17 try {
18 return await $.process.run(['ai', ...args], { timeoutMs })
19 } catch (err) {
20 return { exitCode: -1, stdout: '', stderr: String(err) }
21 }
22}
23
24function agent($: EngineInterface, args: string[], timeoutMs?: number): Promise<Ran> {
25 return ai($, ['agent', ...args], timeoutMs)
26}
27
28async function compactSession($: EngineInterface, args: string[], cancelled?: AbortSignal): Promise<Ran> {
29 const out = { stdout: '', stderr: '' }
30 const child = $.process.spawn({ argv: ['ai', 'agent', 'compact-session', ...args] })
31 const pieces: AsyncIterator<unknown> = child
32 const stop = () => void pieces.return?.().catch(() => undefined)
33 cancelled?.addEventListener('abort', stop, { once: true })
34 try {
35 for await (const { stream, text } of child) out[stream] += text
36 return { exitCode: (await child.result).code ?? -1, ...out }
37 } catch (err) {
38 return { exitCode: -1, stdout: out.stdout, stderr: String(err) }
39 } finally {
40 cancelled?.removeEventListener('abort', stop)
41 }
42}
43
44function sessionArgs(id: string, path: string | undefined): string[] {
45 return ['--harness', 'claude', '--session', id, ...(path === undefined ? [] : ['--transcript', path])]
46}
47
48async function session($: EngineInterface): Promise<string[]> {
49 return sessionArgs(await $.session.id(), (await $.state.get(transcript)).value)
50}
51
52async function view($: EngineInterface, args: string[], fallback: string): Promise<string | undefined> {
53 const ran = await compactSession($, args)
54 if (ran.exitCode === 0 && ran.stdout !== '') return ran.stdout
55 if (ran.exitCode !== 0 && ran.exitCode !== NOT_ATTACHED) {
56 $.ui.log(`ai agent compact-session failed; ${fallback}. ${ran.stderr.trim()}`)
57 }
58 return undefined
59}
60
61async function sync($: EngineInterface, command: 'sync-session' | 'end-session', args: string[]): Promise<void> {
62 const ran = await agent($, [command, ...args])
63 if (ran.exitCode !== 0 && ran.exitCode !== NOT_ATTACHED) $.ui.log(`ai agent ${command} failed: ${ran.stderr.trim()}`)
64}
65
66function reply(ran: Ran): string {
67 if (ran.exitCode === 0) return ran.stdout.trim()
68 if (ran.exitCode === NOT_ATTACHED) return 'This session is not attached to an agent. Attach it with /ai attach <name>.'
69 return ran.stderr.trim() || `ai agent exited with code ${ran.exitCode}.`
70}
71
72async function attach($: EngineInterface, name: string): Promise<string> {
73 return reply(await agent($, ['attach', name, ...(await session($))], LONG_MS))
74}
75
76async function isWithheld($: EngineInterface): Promise<boolean> {
77 return (await $.state.get(transcript)).value === undefined
78}
79
80async function compress($: EngineInterface, cancelled?: AbortSignal): Promise<string | undefined> {
81 if (await isWithheld($)) return WITHHELD
82 await $.clock.sleep(FLUSH_MS)
83 const args = await session($)
84 const ready = await compactSession($, args, cancelled)
85 if (ready.exitCode !== 0 && ready.exitCode !== NOT_ATTACHED) return `Not compacted: ${reply(ready)}`
86 if (ready.exitCode === 0 && ready.stdout === '') return 'Not compacted: the thread holds nothing yet.'
87 const shown = await ai($, ['view', '--whole', ...args])
88 if (shown.exitCode !== 0) return `Not compacted: ${reply(shown)}`
89 if (cancelled?.aborted) return 'Not compacted: cancelled.'
90 await $.state.set(prepared, shown.stdout)
91 $.clock.after(0, () => $.command.run({ command: 'compact' }))
92 return undefined
93}
94
95async function compressNow($: EngineInterface): Promise<void> {
96 const failed = await compress($)
97 if (failed !== undefined) $.ui.log(failed)
98}
99
100export const register: Register = on => {
101 on('session.start', async ($, e, next) => {
102 await $.command.register({
103 name: 'ai',
104 description: "ai: compact into your memory, and the thread's view when attached; new <name>, attach <name>, detach",
105 argumentHint: '[new <name> | attach <name> | detach]',
106 })
107 await $.tool.register({
108 name: 'compress',
109 description:
110 "Compact this session into your memory, and its thread's view when it is attached to an agent, when the current turn ends.",
111 inputSchema: { type: 'object', properties: {} },
112 })
113 return next(e)
114 })
115
116 on('classic.SessionStart', async ($, e, next) => {
117 await $.state.set(transcript, e.transcript_path)
118 const started = await next(e)
119 if (e.source !== 'startup') return started
120 const seed = await view($, sessionArgs(e.session_id, e.transcript_path), "starting without the thread's view")
121 if (seed === undefined) return started
122 return { ...started, additionalContext: [...(started.additionalContext ?? []), seed] }
123 })
124
125 on('session.compact', async ($, e, next) => {
126 if (e.agentId !== undefined) return next(e)
127 const text = (await $.state.get(prepared)).value
128 if (!text) return next(e)
129 await $.state.set(prepared, '')
130 return { messages: [{ role: 'user', text, toolUses: [] }] }
131 })
132
133 on('classic.Stop', async ($, e, next) => {
134 if ((await $.state.get(requested)).value) {
135 await $.state.set(requested, false)
136 $.clock.after(0, () => compressNow($))
137 return next(e)
138 }
139 const args = sessionArgs(e.session_id, e.transcript_path)
140 $.clock.after(FLUSH_MS, () => sync($, 'sync-session', args))
141 return next(e)
142 })
143
144 on('session.end', async ($, e, next) => {
145 const ended = await next(e)
146 await sync($, 'end-session', sessionArgs(e.sessionId, (await $.state.get(transcript)).value))
147 return ended
148 })
149
150 on('command.run', { command: 'ai' }, async ($, e, next) => {
151 const [sub, ...rest] = e.args.trim().split(/\s+/)
152 if (sub === '' && rest.length === 0) return { text: (await compress($, next.signal)) ?? 'Compacting.' }
153 if (sub === 'new' && rest.length === 1) {
154 const made = await agent($, ['new', rest[0]!])
155 if (made.exitCode !== 0) return { text: reply(made) }
156 return { text: `Created agent ${rest[0]}. ${await attach($, rest[0]!)}` }
157 }
158 if (sub === 'attach' && rest.length === 1) return { text: await attach($, rest[0]!) }
159 if (sub === 'detach' && rest.length === 0) {
160 return { text: reply(await agent($, ['detach', ...(await session($))])) }
161 }
162 return { text: USAGE }
163 })
164
165 on('tool.call', { tool: 'mcp__ai__compress' }, async $ => {
166 if (await isWithheld($)) return { result: WITHHELD }
167 const ran = await agent($, ['sync-session', ...(await session($))])
168 if (ran.exitCode !== 0 && ran.exitCode !== NOT_ATTACHED) return { result: reply(ran) }
169 await $.state.set(requested, true)
170 return { result: 'Compaction queued for the end of this turn.' }
171 })
172}
173types/index.d.ts 10 lines1export type AiTranscript = string
2export type AiView = string
3export type AiCompress = boolean
4
5declare module 'claude-code' {
6 interface PluginState {
7 ai: { transcript: AiTranscript; view: AiView; compress: AiCompress }
8 }
9}
10