Live pane of what the agent does behind the scenes: tool calls, skills, subagents and permission decisions

<img src="docs/assets/tack-logo.png" alt="tack geometric t mark" width="144" height="144"> <h1 align="center">tack</h1> <a href="https://github.com/nbfrodri/agent-tack/actions/workflows/ci.yml"><img src="https://github.com/nbfrodri/agent-tack/actions/workflows/ci.yml/badge.svg" alt="CI status"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-0d9488" alt="MIT license"></a> <a href="docs/editors.md"><img src="https://img.shields.io/badge/AI_tools-7-0d9488" alt="Seven supported AI tools"></a> <img src="https://img.shields.io/badge/runtime-Bash%20%2B%20Python-334155" alt="Bash and Python"> <a href="#quick-start">Quick start</a> · <a href="docs/sharing.md">Team walkthrough</a> · <a href="docs/README.md">Documentation</a> · <a href="docs/results.md">Results</a>
Your next AI session should not need the same project briefing. tack turns your development preferences into reusable project guidance: how to make changes, where context belongs and what to check before calling the work done. Keep it personal, commit a shared setup for your team, or customize a fork across projects.
It works around your existing coding agent. It does not provide a model or replace your test framework.
Use tack when portable preferences and project coordination solve a real problem. If a short AGENTS.md and your existing CI already do the job, that simpler setup may be enough. Our benchmarks include cases where tack costs more without better code.
| Agree once | Carry context forward | Make verification visible |
|---|---|---|
| Share conventions and preferences in Git instead of repeating them in every prompt. | Give the next session the same architecture, plan and handoff locations. | Run the project's real checks and see failures, timeouts and work that still needs review. |
flowchart LR
A[Project rules and settings] --> B[Your AI coding tool]
B --> C[Focused change]
C --> D[Project checks]
D --> E[Reviewable result]
Ask for the change: "Fix the checkout bug" or "Add CSV export." Tack guides the assistant through work sized to the risk, meaningful tests, maintainable design and a reviewable Git history. TDD, pragmatic SOLID and documentation upkeep remain part of that guidance; executable checks provide narrower guarantees. Engineering practices.
The default catalog is just workflow and onboarding; specialist skills and roles are opt-in. Add procedures when they solve a real problem. Installation choices | Why this direction.
Requires Git, Bash 3.2+ and Python 3.9+. Linux, macOS and Windows options are described in tool and platform support.
# Keep this checkout: installed files link to it.
git clone https://github.com/nbfrodri/agent-tack.git ~/Projects/agent-tack
~/Projects/agent-tack/install.sh --skip-plugins
cd ~/Projects/my-app
tack enable # local to this clone; adds no project files
tack setup # see clone settings, local differences and existing guidance
Start a new AI session and ask:
Configure tack for this project. Reuse the existing conventions and docs. Propose useful additions and let me choose what to create.
Existing guidance is enough to start; scaffold files are optional. tack setup --check checks the guidance you use without requiring extra templates. Review commands with tack verify --plan, then grant local execution trust with tack trust when you are ready to run them. Full setup guide.
| Your situation | Start here |
|---|---|
| Personal project | Enable your clone, choose preferences locally and work normally. No shared profile required. |
| Several developers on one project | Commit .tack, tack.json, project guidance and useful check maps. Each clone keeps its own trust and overrides. |
| Custom defaults across several projects | Maintain a personal or team fork and install from it. How forks work. |
# Optional shared defaults for one repository
tack enable --shared
tack mode auto --shared
tack config reply-style brief --shared
tack config collaboration team --shared
Leave auto as the usual mode: a small fix and a risky migration need different levels of work. Say "use strict for this task" when needed; that need not change the saved default. Daily use | End-to-end team example.
Your project stays portable: the shared setup is ordinary files in Git, and you can disable tack or tidy old records without deleting useful project knowledge.
Working on backend and frontend in parallel? Share canonical contracts and run declared checks against a prospective merge with tack team --verify --against REF, before changing your branch. New collaborators can use a pinned setup command. Team workflow and limits.
Claude Code, Codex, Gemini CLI, GitHub Copilot, OpenCode, Crush and Cursor receive the integrations they support. Git hooks are shared; native agent and runtime hook coverage varies. Support matrix and setup.
tack verify selects existing checks from changed paths; tack verify --all also checks a clean clone or completed integration. An optional checks-map.json maps code areas to commands. Results expose failures, timeouts and unmapped work. Verification guide.
Already happy with your project guidance? Run only the CLI checks, without installing global instructions, skills or hooks.
The small-core comparison completed 24 development sessions and eight blind code reviews. The short guide and small core each passed acceptance in 8/8 deliveries, but both still had defects outside those checks. Mean code-quality grades did not improve. Compared with the guide, the core cost 61.9% more time / 67.6% more input tokens with Luna, and 5.0% more time / 25.4% more input with Sol. Complete results.
The earlier full lifecycle study also found higher cost without a general quality advantage. Tack's concrete checks can detect configuration drift and incompatible prospective merges; broader savings remain unproven. Enable what earns its cost in your project. All results | Direction.
| I want to... | Guide |
|---|---|
| Install and enable tack | Setup |
| Work with it every day | Usage |
| Share it or customize a fork | Sharing |
| Change settings and context locations | Configuration |
| Add selected external skills | External skills |
| Understand the implementation | Architecture |
All documentation includes optional features, engineering practices, installation details and benchmark history.
Useful contributions include reproducible bugs, better adapters, checks that catch real failures and honest benchmarks. Read AGENTS.md and Development; use the issue forms and PR template.
tests/validate.sh
tests/lint.sh
tests/run-all.sh -j 4
Tests use temporary homes and repositories. Code and original project assets use the MIT license.
hooks/register.tsx 148 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Activity, ActivityStatus } from '../types'
5
6const PANE = 'agent-activity'
7const KEEP = 300
8const entries = atom({ plugin: 'agent-activity', key: 'entries' } as const, [] as Activity[])
9
10const STATUS_STYLE: Record<ActivityStatus, { mark: string; color: string }> = {
11 running: { mark: '…', color: 'cyan' },
12 ok: { mark: '✓', color: 'green' },
13 error: { mark: '✗', color: 'red' },
14 denied: { mark: '⛔', color: 'red' },
15 ask: { mark: '?', color: 'yellow' },
16}
17
18// The field that says most about a call, in the order the built-in tools use them.
19export function summarize(input: unknown): string {
20 if (!input || typeof input !== 'object') return ''
21 const fields = input as Record<string, unknown>
22 for (const key of ['command', 'skill', 'file_path', 'pattern', 'url', 'query', 'description', 'prompt']) {
23 const value = fields[key]
24 if (typeof value === 'string' && value.trim()) return value.replace(/\s+/g, ' ').trim().slice(0, 160)
25 }
26 return ''
27}
28
29export function kindOf(tool: string): Activity['kind'] {
30 if (tool === 'Skill') return 'skill'
31 if (tool === 'Agent' || tool === 'Task') return 'subagent'
32 return 'tool'
33}
34
35export function statusOf(result: { deny?: string; isError?: boolean } | undefined): ActivityStatus {
36 if (!result) return 'error'
37 if (typeof result.deny === 'string') return 'denied'
38 return result.isError ? 'error' : 'ok'
39}
40
41export function clock(at: number): string {
42 const time = new Date(at)
43 return [time.getHours(), time.getMinutes(), time.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
44}
45
46// /activity toggles: it closes the pane when it is already open and opens it otherwise.
47export function paneToggle(openPaneIds: readonly string[]): 'open' | 'close' {
48 return openPaneIds.includes(PANE) ? 'close' : 'open'
49}
50
51export const register: Register = on => {
52 let sequence = 0
53
54 on('session.start', async ($, e, next) => {
55 await $.command.register({ name: 'activity', description: 'Open or close the live pane of tool calls, skills, subagents and permission decisions' })
56 void $.ui.open({ id: PANE, title: 'Agent activity' })
57 return next(e)
58 })
59
60 on('command.run', { command: 'activity' }, async $ => {
61 const open = (await $.ui.panes()).map(pane => pane.id)
62 if (paneToggle(open) === 'close') {
63 await $.ui.close({ id: PANE })
64 return { text: 'Agent activity pane closed.' }
65 }
66 await $.ui.open({ id: PANE, title: 'Agent activity' })
67 return { text: 'Agent activity pane opened.' }
68 })
69
70 on('tool.call', async ($, e, next) => {
71 const id = e.tool_use_id || `call-${(sequence += 1)}`
72 const entry: Activity = {
73 id,
74 at: await $.clock.now(),
75 kind: kindOf(e.tool),
76 label: e.tool,
77 detail: summarize(e),
78 status: 'running',
79 isSubagent: Boolean(e.agentId),
80 }
81 await update($, entries, list => [...list, entry].slice(-KEEP))
82 const result = await next(e)
83 const status = statusOf(result)
84 await update($, entries, list => list.map(one => (one.id === id ? { ...one, status } : one)))
85 return result
86 })
87
88 on('tool.check', async ($, e, next) => {
89 const verdict = await next(e)
90 if (verdict.decision !== 'allow') {
91 const entry: Activity = {
92 id: `check-${(sequence += 1)}`,
93 at: await $.clock.now(),
94 kind: 'permission',
95 label: `${verdict.decision} ${e.tool}`,
96 detail: verdict.reason ?? summarize(e.input),
97 status: verdict.decision === 'deny' ? 'denied' : 'ask',
98 isSubagent: false,
99 }
100 await update($, entries, list => [...list, entry].slice(-KEEP))
101 }
102 return verdict
103 })
104
105 on('agent.spawn', async ($, e, next) => {
106 const decision = await next(e)
107 const entry: Activity = {
108 id: `spawn-${(sequence += 1)}`,
109 at: await $.clock.now(),
110 kind: 'subagent',
111 label: `spawn ${e.subagentType}`,
112 detail: `${e.description}${'model' in decision && decision.model ? ` (${decision.model})` : ''}`,
113 status: 'deny' in decision ? 'denied' : 'ok',
114 isSubagent: false,
115 }
116 await update($, entries, list => [...list, entry].slice(-KEEP))
117 return decision
118 })
119
120 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
121 const { Box, Text } = $.ui.resolve(e)
122 const list = await read($, entries)
123 const room = Math.max(1, (e.viewport?.rows ?? 24) - 3)
124 const tools = list.filter(one => one.kind !== 'permission').length
125 const asks = list.filter(one => one.kind === 'permission').length
126
127 return (
128 <Box flexDirection="column">
129 <Text dimColor>{`${tools} calls · ${asks} permission prompts or denials`}</Text>
130 {list.length === 0 && <Text dimColor>Nothing yet.</Text>}
131 {list.slice(-room).map(entry => {
132 const style = STATUS_STYLE[entry.status]
133 return (
134 <Box key={entry.id} flexDirection="row" gap={1}>
135 <Text dimColor>{clock(entry.at)}</Text>
136 <Text color={style.color}>{style.mark}</Text>
137 <Text bold color={entry.kind === 'tool' ? undefined : 'magenta'}>
138 {`${entry.isSubagent ? '↳ ' : ''}${entry.label}`}
139 </Text>
140 <Text dimColor wrap="truncate-end">{entry.detail}</Text>
141 </Box>
142 )
143 })}
144 </Box>
145 )
146 })
147}
148types/index.d.ts 18 lines1export type ActivityStatus = 'running' | 'ok' | 'error' | 'denied' | 'ask'
2
3export type Activity = {
4 id: string
5 at: number
6 kind: 'tool' | 'skill' | 'subagent' | 'permission'
7 label: string
8 detail: string
9 status: ActivityStatus
10 isSubagent: boolean
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'agent-activity': { entries: Activity[] }
16 }
17}
18