Noctia visual mods for Claude Code: team panel, agent labels, task checklist and more.


A Claude Code plugin that makes Claude develop like a careful senior engineer, with rules you define: KISS, small modular functions, tests with every change, security checks, guarded git, and model routing.
/plugin marketplace add EtienneP-26/Noctia
then
/plugin install noctia@noctia-plugins
Then inside Claude Code, run /help and look for the noctia: skills.
Refresh the marketplace to fetch the latest version, then reinstall the plugin:
/plugin marketplace update noctia-plugins
then
/plugin install noctia@noctia-plugins
Reload without restarting Claude Code:
/reload-plugins
You can also open /plugin and, in the marketplace settings, enable auto-update for noctia-plugins so it refreshes at startup.
Claude Code decides a plugin changed from the version in .claude-plugin/plugin.json. If you maintain Noctia, bump that version (vX.Y.Z) with every release, or users will not see your changes.
A plugin cannot force the main model, so set it once yourself, either in a session with /model sonnet or permanently in ~/.claude/settings.json:
{ "model": "sonnet" }
Noctia then delegates by itself: bulk reading to reader (Haiku), hard problems to architect (Opus), everything else stays on Sonnet.
| File | Role |
|---|---|
.claude-plugin/plugin.json | Manifest: name, version. The name prefixes the commands (/noctia:...). |
rules/core.md | The core rules. Injected into every session by the SessionStart hook. |
hooks/hooks.json | Wires the scripts below to Claude Code events. |
hooks/scripts/session-start.sh | Injects the rules and scans the project (tests, CI, docs, architecture, CONTRIBUTING, linters). |
hooks/scripts/guard-git.sh | Blocks git add/commit/push/tag unless unlocked. |
hooks/scripts/guard-secrets.sh | Blocks writes containing API keys or private keys; asks on suspicious literals. |
hooks/scripts/set-git-mode.sh | Stores the git mode. Called by /noctia:git. |
skills/code-quality | How to write and test code. |
skills/security-check | Security checklist and review. |
skills/audit | Deep project audit (/noctia:audit). |
skills/release-and-deploy | Versioning vX.Y.Z, branches, CI/CD, changelog, docs. |
skills/git-workflow | How to commit and push once unlocked (co-author question included). |
skills/git | The user-only command that unlocks git (/noctia:git). |
skills/help-me-code-it | Mentor mode (/noctia:help-me-code-it): Claude guides you instead of coding, you write the code. |
agents/architect.md | Opus: complex code, blocked decisions. |
agents/reader.md | Haiku: bulk reading (codebase, git history, data). |
agents/security-reviewer.md | Opus: security review of a change. |
noctia-mods is a second plugin: it changes how Claude Code looks. Install it the same way:
/plugin install noctia-mods@noctia-plugins
| Mod | What you see | Since |
|---|---|---|
| Team panel | A side panel listing every subagent: colour, status, task, duration. /team reopens it. | v0.3.1 |
| Agent labels | A coloured [reader], [architect]… tag in front of each chat row that launches a subagent, same colours as the team panel. | v0.3.3 |
| Task checklist | A side panel with the task list of the session: done, in progress, to do, and a progress bar. Reads TodoWrite only for now; TaskCreate/TaskUpdate are not handled yet. | v0.3.4 |
| Visible guards | A toast when a Noctia guard blocks or questions a command (locked git, hard-coded secret), with the reason. | v0.3.5 |
| Mentor panel | In mentor mode, a side panel with the steps (done, doing, to do) and a gauge of the hints used. | v0.3.6 |
/noctia:help-me-code-it <project, feature or bug> turns Claude into a mentor. It does not write the solution: it asks questions, helps you split the work into small steps, reviews what you wrote, and guides your debugging with hints of growing strength. It only gives very specific commands, signatures or syntax reminders. You do the thinking and the typing.
Git write commands are locked by default. You unlock them per project:
/noctia:git locked: default, nothing allowed./noctia:git ask: allowed, but you approve every command in a prompt./noctia:git auto: allowed without a prompt, for full-auto projects. Claude still asks before pushing.- /noctia:git status: show the current mode.The mode is stored in .noctia/git-mode inside the project, which is git-ignored automatically.
Once git is unlocked (ask or auto), Noctia asks once per project whether you want Claude and Noctia credited as co-authors. If you say yes, every commit Claude makes ends with:
Co-Authored-By: Noctia <noctia@etienne-pouille.work>
Co-Authored-By: Claude <noreply@anthropic.com>
Both then appear as co-authors on the commit on GitHub. If you say no, no credit is added. Your answer is remembered in .noctia/coauthor, and you can change it by running bash "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/set-coauthor.sh" yes (or no). The credit is only attribution: it does not give anyone rights over your code, your LICENSE does.
gh commands (for example gh pr create) are not covered by the git guard.git-workflow skill, so it depends on Claude following it.bash. On Windows, use Git Bash or WSL.ask mode Noctia denies instead.License: MIT
hooks/register.tsx 22 lines1import type { Register } from 'claude-code'
2
3import { registerGuards } from './guards'
4import { registerLabels } from './labels'
5import { mentorTool, registerMentor } from './mentor'
6import { registerTasks } from './tasks'
7import { registerTeam, teamCommand } from './team'
8
9export const register: Register = (on, options) => {
10 on('session.start', async ($, e, next) => {
11 await $.command.register(teamCommand)
12 await $.tool.register(mentorTool)
13 return next(e)
14 })
15
16 registerTeam(on, options)
17 registerLabels(on, options)
18 registerTasks(on, options)
19 registerGuards(on, options)
20 registerMentor(on, options)
21}
22hooks/guards.ts 36 lines1import type { Register } from 'claude-code'
2
3const PREFIX = 'Noctia:'
4
5/**
6 * # Message of a Noctia guard
7 * ## Args
8 * - text: the text of a tool result
9 * ## Returns
10 * The first sentence of the guard message, or undefined when the text does not come from a Noctia guard.
11 * ## Example
12 * guardMessage('Noctia: git add, commit, push and tag are locked. Ask the user.') // 'git add, commit, push and tag are locked.'
13 */
14export const guardMessage = (text: string | undefined): string | undefined => {
15 const trimmed = text?.trim()
16 if (!trimmed?.startsWith(PREFIX)) return undefined
17 const body = trimmed.slice(PREFIX.length).trim()
18 const end = body.search(/[.!?](\s|$)/)
19 return end === -1 ? body : body.slice(0, end + 1)
20}
21
22/**
23 * # Visible guards
24 * Shows a toast when a Noctia guard blocks or questions a tool call.
25 * ## Args
26 * - on: the hook registrar
27 */
28export const registerGuards: Register = on => {
29 on('tool.call', async ($, e, next) => {
30 const result = await next(e)
31 const message = guardMessage(result.text)
32 if (message) $.ui.toast(`Noctia guard: ${message}`)
33 return result
34 })
35}
36hooks/labels.tsx 44 lines1import type { Register } from 'claude-code'
2
3import { colorOf, shortName } from './team'
4
5/**
6 * # Agent type of a tool row
7 * ## Args
8 * - tool: name of the tool the row draws
9 * - input: the tool's input as the row received it
10 * ## Returns
11 * The subagent type when the row launches a subagent, otherwise undefined.
12 * ## Example
13 * agentTypeOf('Agent', { subagent_type: 'noctia:reader' }) // 'noctia:reader'
14 */
15export const agentTypeOf = (tool: string, input: unknown): string | undefined => {
16 if (tool !== 'Agent') return undefined
17 const type = (input as { subagent_type?: string } | null)?.subagent_type
18 return type ?? 'general-purpose'
19}
20
21/**
22 * # Agent labels
23 * Puts a coloured tag in front of every chat row that launches a subagent,
24 * with the same colours as the team panel.
25 * ## Args
26 * - on: the hook registrar
27 */
28export const registerLabels: Register = on => {
29 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
30 const type = agentTypeOf(e.props.tool, e.props.input)
31 const inner = await next(e)
32 if (!type) return inner
33 const { Box, Text } = $.ui.resolve(e)
34 return (
35 <Box>
36 <Text color={colorOf(type)} bold>
37 [{shortName(type)}]{' '}
38 </Text>
39 {inner}
40 </Box>
41 )
42 })
43}
44hooks/mentor.tsx 102 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { MentorState, MentorStep } from '../types'
5
6const PANE = 'noctia-mentor'
7const TOOL = 'mentor_progress'
8const MAX_HINTS = 3
9const mentor = atom({ plugin: 'noctia-mods', key: 'mentor' } as const, {
10 steps: [],
11 hintLevel: 0,
12} as MentorState)
13
14const ICON = { done: '✔', doing: '▶', todo: '○' } as const
15const STATUSES = ['todo', 'doing', 'done']
16
17const isStep = (s: unknown): s is MentorStep =>
18 typeof s === 'object' &&
19 s !== null &&
20 typeof (s as MentorStep).title === 'string' &&
21 STATUSES.includes((s as MentorStep).status)
22
23/**
24 * # Parse the mentor progress
25 * ## Args
26 * - input: the raw input of the `mentor_progress` tool
27 * ## Returns
28 * The steps (invalid ones dropped) and the hint level kept between 0 and 3.
29 * ## Example
30 * parseProgress({ steps: [{ title: 'Parse', status: 'doing' }], hint_level: 2 })
31 */
32export const parseProgress = (input: unknown): MentorState => {
33 const raw = (input ?? {}) as { steps?: unknown; hint_level?: unknown }
34 const steps = Array.isArray(raw.steps) ? raw.steps.filter(isStep) : []
35 const level = typeof raw.hint_level === 'number' ? raw.hint_level : 0
36 return { steps, hintLevel: Math.min(MAX_HINTS, Math.max(0, Math.round(level))) }
37}
38
39/**
40 * # Hint gauge
41 * ## Args
42 * - level: hints used, from 0 to 3
43 * ## Returns
44 * A text gauge such as `●●○`.
45 */
46export const hintGauge = (level: number): string =>
47 '●'.repeat(level) + '○'.repeat(MAX_HINTS - level)
48
49/** The tool the model calls in mentor mode, registered at session start. */
50export const mentorTool = {
51 name: TOOL,
52 description:
53 'Mentor mode only. Send the full list of steps with their status and the hint level used (0 to 3). Shows the progress in a side panel.',
54 inputSchema: {
55 type: 'object',
56 properties: {
57 steps: {
58 type: 'array',
59 items: {
60 type: 'object',
61 properties: {
62 title: { type: 'string' },
63 status: { type: 'string', enum: STATUSES },
64 },
65 required: ['title', 'status'],
66 },
67 },
68 hint_level: { type: 'number', description: '0 = no hint, 3 = last hint before the answer' },
69 },
70 required: ['steps'],
71 },
72}
73
74/**
75 * # Mentor panel
76 * A tool the model calls to say where the lesson is, and a side panel that shows it.
77 * ## Args
78 * - on: the hook registrar
79 */
80export const registerMentor: Register = on => {
81 on('tool.call', { tool: 'mcp__noctia-mods__mentor_progress' }, async ($, e) => {
82 await update($, mentor, () => parseProgress(e.input))
83 void $.ui.open({ id: PANE, title: 'Mentor' })
84 return { result: { content: [{ type: 'text', text: 'Mentor panel updated.' }], isError: false } }
85 })
86
87 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
88 const { Box, Text } = $.ui.resolve(e)
89 const { steps, hintLevel } = await read($, mentor)
90 return (
91 <Box flexDirection="column">
92 <Text bold>Hints {hintGauge(hintLevel)}</Text>
93 {steps.map(s => (
94 <Text color={s.status === 'doing' ? 'yellow' : undefined} dimColor={s.status === 'done'}>
95 {ICON[s.status]} {s.title}
96 </Text>
97 ))}
98 </Box>
99 )
100 })
101}
102hooks/tasks.tsx 60 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Todo } from '../types'
5
6const PANE = 'noctia-tasks'
7const todos = atom({ plugin: 'noctia-mods', key: 'todos' } as const, [] as Todo[])
8
9const ICON = { completed: '✔', in_progress: '▶', pending: '○' } as const
10
11/**
12 * # Progress of a checklist
13 * ## Args
14 * - list: the todos
15 * ## Returns
16 * The done count, the total, and a text bar such as `███░░░░░░░`.
17 * ## Example
18 * progress([{ status: 'completed' }, { status: 'pending' }]) // { done: 1, total: 2, bar: '█████░░░░░' }
19 */
20export const progress = (list: Pick<Todo, 'status'>[], width = 10) => {
21 const done = list.filter(t => t.status === 'completed').length
22 const filled = list.length === 0 ? 0 : Math.round((done / list.length) * width)
23 return { done, total: list.length, bar: '█'.repeat(filled) + '░'.repeat(width - filled) }
24}
25
26/**
27 * # Task checklist
28 * Shows the todo list of the session in a side panel, with a progress bar.
29 * ## Args
30 * - on: the hook registrar
31 */
32export const registerTasks: Register = on => {
33 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
34 await update($, todos, () => e.input.todos)
35 void $.ui.open({ id: PANE, title: 'Tasks' })
36 return next(e)
37 })
38
39 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
40 const { Box, Text } = $.ui.resolve(e)
41 const list = await read($, todos)
42 const { done, total, bar } = progress(list)
43 return (
44 <Box flexDirection="column">
45 <Text bold>
46 {bar} {done}/{total}
47 </Text>
48 {list.map(t => (
49 <Text
50 color={t.status === 'in_progress' ? 'yellow' : undefined}
51 dimColor={t.status === 'completed'}
52 >
53 {ICON[t.status]} {t.status === 'in_progress' ? t.activeForm : t.content}
54 </Text>
55 ))}
56 </Box>
57 )
58 })
59}
60hooks/team.tsx 112 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { AgentRun } from '../types'
5
6const PANE = 'noctia-team'
7const agents = atom({ plugin: 'noctia-mods', key: 'agents' } as const, [] as AgentRun[])
8
9const COLORS: Record<string, string> = {
10 reader: 'blue',
11 architect: 'magenta',
12 'security-reviewer': 'red',
13}
14
15const ICON = { running: '▶', done: '✔', error: '✘' } as const
16
17/**
18 * # Short name of an agent type
19 * ## Args
20 * - type: resolved subagent type, possibly prefixed by its plugin (`noctia:reader`)
21 * ## Returns
22 * The type without its plugin prefix.
23 * ## Example
24 * shortName('noctia:reader') // 'reader'
25 */
26export const shortName = (type: string): string => type.split(':').pop() ?? type
27
28/**
29 * # Colour of an agent type
30 * ## Args
31 * - type: resolved subagent type
32 * ## Returns
33 * A terminal colour name, grey for unknown types.
34 * ## Example
35 * colorOf('noctia:architect') // 'magenta'
36 */
37export const colorOf = (type: string): string => COLORS[shortName(type)] ?? 'gray'
38
39/**
40 * # Elapsed seconds of a run
41 * ## Args
42 * - run: the agent run
43 * - now: current time in ms
44 * ## Returns
45 * Whole seconds between start and end (or now while running).
46 */
47export const seconds = (run: AgentRun, now: number): number =>
48 Math.round(((run.endedAt ?? now) - run.startedAt) / 1000)
49
50/** The slash command that reopens the team panel, registered at session start. */
51export const teamCommand = { name: 'team', description: 'Show which subagent does what' }
52
53/**
54 * # Team panel
55 * Lists every subagent of the session with its status, task and duration.
56 * ## Args
57 * - on: the hook registrar
58 */
59export const registerTeam: Register = on => {
60 on('command.run', { command: 'team' }, async $ => {
61 await $.ui.open({ id: PANE, title: 'Team' })
62 return { text: 'Team panel opened.' }
63 })
64
65 on('agent.spawn', async ($, e, next) => {
66 const id = e.tool_use_id
67 const finish = (status: AgentRun['status']) =>
68 update($, agents, list =>
69 list.map(a => (a.id === id ? { ...a, status, endedAt: Date.now() } : a)),
70 )
71 const run: AgentRun = {
72 id,
73 type: e.subagentType,
74 task: e.description,
75 status: 'running',
76 startedAt: Date.now(),
77 }
78 await update($, agents, list => [...list, run].slice(-50))
79 void $.ui.open({ id: PANE, title: 'Team' })
80 try {
81 const result = await next(e)
82 await finish('done')
83 return result
84 } catch (err) {
85 await finish('error')
86 throw err
87 }
88 })
89
90 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
91 const { Box, Text } = $.ui.resolve(e)
92 const list = await read($, agents)
93 const now = Date.now()
94 return (
95 <Box flexDirection="column">
96 {list.length === 0 && <Text dimColor>No subagent yet.</Text>}
97 {list.map(a => (
98 <Box key={a.id} flexDirection="column">
99 <Text color={colorOf(a.type)} bold>
100 {ICON[a.status]} {shortName(a.type)}
101 </Text>
102 <Text dimColor>
103 {' '}
104 {a.task} · {seconds(a, now)}s
105 </Text>
106 </Box>
107 ))}
108 </Box>
109 )
110 })
111}
112types/index.d.ts 25 lines1export type AgentRun = {
2 id: string
3 type: string
4 task: string
5 status: 'running' | 'done' | 'error'
6 startedAt: number
7 endedAt?: number
8}
9
10export type Todo = {
11 content: string
12 status: 'pending' | 'in_progress' | 'completed'
13 activeForm: string
14}
15
16export type MentorStep = { title: string; status: 'todo' | 'doing' | 'done' }
17
18export type MentorState = { steps: MentorStep[]; hintLevel: number }
19
20declare module 'claude-code' {
21 interface PluginState {
22 'noctia-mods': { agents: AgentRun[]; todos: Todo[]; mentor: MentorState }
23 }
24}
25