Pins the questions awaiting the user's answer above the prompt, their context in a pane, without blocking the session

🇫🇷 Une configuration Claude Code de développeur solo, éprouvée au quotidien : des sous-agents qui se relaient (
dev→tester→reviewer), des gardes qui empêchent les gestes irréversibles, et des règles de délégation mesurées plutôt que supposées. Windows et macOS, installée par liens, avec un dossier perso pour ce qui ne regarde que vous.🇬🇧 A solo developer's Claude Code setup, used daily: subagents that hand off to each other (
dev→tester→reviewer), guards against irreversible actions, and delegation rules that were measured rather than assumed. Windows and macOS, installed through links, with a personal folder for what is yours alone. The content is written in French.
dev écrit, tester audite et prouve que ses tests tombent sans le changement, reviewer relit et poste sur la PR, qa exerce la fonctionnalité de l'extérieur, ci rejoue la CI en local avant un push.CLAUDE.md dit quand déléguer et à qui (une question fermée : l'agent doit-il écrire ?), qui écrit le code (la session principale jusqu'à deux fichiers, dev au-delà), combien d'agents tournent à la fois, et ce qu'un brief porte : des extraits plutôt que des chemins, une condition d'arrêt, ce que l'agent doit rendre.CAVEMAN.md) pour tout ce qui va vers un sous-agent et en revient, avec ce qui ne se compresse jamais.guardrail coupe les boucles et les commandes destructrices ; un pre-push global refuse un push direct vers main d'un dépôt GitHub, et garde-push empêche l'agent de le désarmer./pr, /pr-review, /start-ticket, /new-ticket.Ce dépôt doit beaucoup à S.C.R.O.O.G.E. — Smart Context Reducer & Optimized Observability Governance Engine —, une pile de télémétrie et d'optimisation pour IDE assistés par IA. Il en a tiré son inspiration, et une partie de sa pile.
Prérequis : Claude Code, lancé au moins une fois (l'installation s'arrête si ~/.claude n'existe pas) ; git ; Python 3 (bibliothèque standard seulement). Sous Windows, en plus : PowerShell 7 (pwsh) et Git for Windows, dont le sh fait tourner les hooks git.
~/.claude à ce dossier : le déplacer ensuite casse les liens, jusqu'à la prochaine installation. git clone https://github.com/AxiaCoder/claude-config.git ~/dev/claude-config
cd ~/dev/claude-config
⚠️ L'installation remplace votre ~/.claude/CLAUDE.md et votre ~/.claude/CAVEMAN.md, et dans ~/.claude/settings.json les clés que ce dépôt définit — hooks, statusLine, permissions : les permissions ajoutées à la main dans ~/.claude/settings.json sont effacées à chaque passe. Les hooks d'autres outils (iTerm2…) sont gardés, ceux ajoutés à la main aussi ; seuls disparaissent ceux que le dépôt ou le dossier perso ne déclarent plus. À la première passe, ces trois fichiers sont d'abord copiés dans ~/.claude/config-backup-<date>/. Ce qui doit rester se remet ensuite dans le dossier perso, vos permissions comprises : elles s'y ajoutent à celles du dépôt, sans en retirer aucune. statusLine, elle, remplace celle du dépôt.
bash install.sh # macOS / Linux
bash install.sh --perso ~/notes/claude-perso # avec un dossier perso
bash install.sh --dry-run # voir ce qui se passerait
.\install.ps1 # Windows
.\install.ps1 -Perso 'D:\notes\claude-perso' # avec un dossier perso
.\install.ps1 -WhatIfOnly # voir ce qui se passerait
L'installation est idempotente : la relancer après un git pull met le poste à jour.
Tout ce qui ne regarde que vous — votre nom, vos adresses de télémétrie, vos préférences d'interface, vos notes — vit hors de ce dépôt, dans un dossier perso que l'installation reçoit par --perso (-Perso sous Windows). Elle le mémorise dans ~/.claude/claude-config.perso : les passes suivantes le reprennent seules, et supprimer ce fichier revient à s'en passer. Sans dossier perso, on obtient une config générique complète.
Le dossier peut contenir, chacun facultatif :
| Fichier | Effet |
|---|---|
settings.json | fusionné par-dessus les réglages du dépôt, sur les deux OS — env clé par clé, hooks et permissions ajoutés à ceux du dépôt, les autres clés — statusLine comprise — en bloc |
settings.macos.json / settings.windows.json | fusionné ensuite, sur cet OS seulement |
CLAUDE.md | inséré dans le CLAUDE.md rendu, à la place de {{PERSO_CLAUDE_MD}} |
CLAUDE.complet.md | remplace tout le CLAUDE.md du dépôt, et fait ignorer le CLAUDE.md perso — pour une machine qui n'a que des sessions sans humain, un serveur ou une CI. ⚠️ Les règles du dépôt ne suivent plus : à recopier quand elles changent |
Dans ce CLAUDE.md comme dans CLAUDE.complet.md, {{PERSO}} est remplacé par le chemin du dossier perso, normalisé : {{PERSO}}/../notes/USER.md devient le chemin absolu du notes/USER.md voisin.
➡️ Un exemple prêt à copier : exemple-perso/.
~/.claude reste un vrai dossier. Seuls cinq dossiers y sont montés vers ce dépôt, par jonction NTFS (Windows) ou lien symbolique (macOS) :
| Cible | Source |
|---|---|
~/.claude/agents | agents/ |
~/.claude/commands | commands/ |
~/.claude/skills | skills/ |
~/.claude/hooks | hooks/ |
~/.claude/git-hooks | git-hooks/ — déclaré en core.hooksPath global |
L'état d'exécution de Claude Code — .credentials.json, history.jsonl, projects/, sessions/, settings.local.json — reste dans ~/.claude et n'entre jamais dans un arbre de travail git, où un git clean -xdf l'emporterait.
CLAUDE.md, CAVEMAN.md et settings.json ne sont pas liés mais rendus : ils portent des chemins propres à la machine. Éditer ~/.claude/CLAUDE.md ou ~/.claude/settings.json à la main ne sert donc à rien — la prochaine installation les écrase. La source est ici, ou dans le dossier perso.
settings.json naît de quatre couches, chacune l'emportant sur la précédente, env fusionné clé par clé :
settings.base.json — commun aux deux OS ;settings.macos.json ou settings.windows.json — les commandes de hooks diffèrent (.sh contre .ps1, python3 contre l'interpréteur trouvé sous Windows) ;<perso>/settings.json ;<perso>/settings.<os>.json.hooks et permissions s'additionnent au lieu de se remplacer :
hooks — par événement, les blocs des couches mis bout à bout dans l'ordre ; un bloc identique à un bloc déjà présent n'est pas ajouté une deuxième fois ;permissions — chaque liste (allow, deny, ask, additionalDirectories…) est l'union des couches, dans l'ordre de première apparition ; une valeur seule (defaultMode) revient à la dernière couche qui la pose.La fusion repart du settings.json existant : les clés que Claude Code écrit lui-même et que ce dépôt ne gère pas sont conservées. Sauf hooks et permissions, rebâtis à chaque passe à partir des couches : un hook retiré du dépôt ou du dossier perso disparaît du fichier rendu. Des hooks existants, ceux d'autres outils — iTerm2… — sont conservés, après ceux des couches. L'installation mémorise les hooks qu'elle rend dans ~/.claude/claude-config.hooks.json pour les reconnaître à la passe suivante : est à nous un hook qui y figure, ou dont la commande désigne un fichier sous ~/.claude/hooks ou sous hooks/ du dépôt ; tout autre est gardé. ⚠️ Un script à vous ne se range donc pas dans ~/.claude/hooks : c'est un lien vers ce dépôt, et son hook y serait pris pour un hook du dépôt, effacé s'il n'y est pas déclaré. Le ranger ailleurs — dans le dossier perso, par exemple. L'état d'avant la passe est copié en settings.json.prev.
| Marqueur | Remplacé par |
|---|---|
{{CLAUDE_HOME}} | le chemin de ~/.claude |
{{REPO}} | le chemin de ce dépôt, ouvert à Claude Code par permissions.additionalDirectories |
{{PYTHON}} | sous Windows : l'interpréteur trouvé par py -3, sinon python |
{{MODS}} | chaque mods/*/ qui porte un .claude-plugin/plugin.json, triés, joints par ; (Windows) ou : (macOS). Sans mod, la clé qui le porte est retirée de env |
{{PERSO_CLAUDE_MD}} | le CLAUDE.md du dossier perso, ou rien |
{{PERSO}} | dans le CLAUDE.md ou le CLAUDE.complet.md perso : le chemin du dossier perso |
Un marqueur qui survit au rendu arrête l'installation avant d'écrire le fichier.
| Hook | Quand | Ce qu'il fait |
|---|---|---|
guardrail | avant Bash, Edit, Write — et PowerShell sous Windows | coupe les boucles (même appel répété) et les commandes destructrices passées par Bash — pas encore par l'outil PowerShell |
garde-push | avant Bash — et PowerShell sous Windows | refuse une commande qui citerait de quoi désarmer le pre-push |
session-git-context | au démarrage | affiche la branche, le dernier commit et l'état de l'arbre |
post-write-lint | après Write, Edit | lance pnpm lint — sur un projet AdonisJS seulement |
collecteur-agents | au démarrage, à la fin d'un sous-agent | pousse les métriques par sous-agent ; sans adresse configurée, il s'abstient sans faire échouer la session |
statusline | en continu | modèle, dossier, branche, contexte, quotas |
La télémétrie demande une instance VictoriaMetrics et son adresse dans le dossier perso (TELEMETRIE_ENDPOINTS, OTEL_EXPORTER_OTLP_METRICS_ENDPOINT) — VictoriaMetrics, Grafana et les dashboards qui lisent ces métriques sont dans home-server-telemetry. Déclarer un hook, le vérifier, et ce dont chacun a besoin → HOOKS.md.
L'installation déclare ~/.claude/git-hooks en core.hooksPath global. Git ne cumule jamais deux dossiers de hooks : tant que ce réglage vaut, .git/hooks/ de chaque dépôt n'est plus lu. D'où le relais — chaque hook client de git-hooks/ lance le hook du même nom dans .git/hooks/ du dépôt, s'il existe et est exécutable, avec les mêmes arguments et la même entrée. Un pre-commit ou un commit-msg local continue donc de tourner.
| Hook | Ce qu'il fait |
|---|---|
pre-push | refuse un push vers main ou master d'un remote GitHub, puis relaie |
applypatch-msg, pre-applypatch, post-applypatch, pre-commit, pre-merge-commit, prepare-commit-msg, commit-msg, post-commit, pre-rebase, post-checkout, post-merge, post-rewrite, pre-auto-gc, sendemail-validate | relaient seulement (git-hooks/_relais) |
Ne sont pas relayés : reference-transaction et post-index-change, que git lance à chaque mise à jour de ref ou d'index — un relais coûte une quinzaine de millisecondes par appel ; fsmonitor-watchman, les hooks p4-* de git p4 et les hooks serveur.
Le garde de push. Il ne vise que les remotes GitHub : vers un autre remote, le push direct sur main passe. Pour le lever :
ALLOW_PUSH_MAIN=1 git push … # une fois
git config --local garde.pushMain off # pour ce dépôt, durablement
git config --local --unset garde.pushMain # le rétablir
Ces gestes sont réservés à l'utilisateur : le hook Claude garde-push refuse qu'un agent lance une commande qui les cite, ou qui cite core.hooksPath. Il lit le texte de la commande : un outil qui pose ce réglage sans le nommer (npx husky init) passe.
Un dépôt peut reprendre la main. core.hooksPath suit la hiérarchie normale de git : une valeur posée dans le dépôt (git config --local core.hooksPath <dossier>) remplace la globale, et ni le relais ni le garde de push n'y jouent plus. C'est ce que fait Husky (.husky/) : ses hooks tournent, le garde de push ne s'y applique pas. Le framework pre-commit refuse de s'installer tant qu'un core.hooksPath est posé ; ses hooks installés à la main dans .git/hooks/ sont, eux, relayés.
⚠️ Ne pas lier un seul hook de .git/hooks/ vers un stub de git-hooks/ : le stub y chercherait _relais à côté de lui, ne le trouverait pas, et ferait échouer le hook. Lier le dossier .git/hooks entier fonctionne — le relais se reconnaît et s'arrête.
Si un autre dossier est déjà déclaré en core.hooksPath global, l'installation avertit et n'y touche pas : le garde de push et le relais ne sont alors pas actifs.
Une commande ou un skill porte le cas général ; un projet le complète par un fichier .claude/surcharges/<nom>.md, que la commande lit en premier. La surcharge complète la base, et la remplace là où elles se contredisent. Le permanent d'un projet — coordonnées Jira, format des branches — reste dans son CLAUDE.md.
Lisent une surcharge : /start-ticket, /new-ticket, /pr-review, verifier-avant-d-annoncer et audit-seo — qui lit aussi les pages publiques que le CLAUDE.md du projet déclare.
⛔ Un projet ne redéclare pas une commande ou un skill sous le même nom : à nom égal, la version globale gagne et celle du projet est ignorée sans bruit.
Vérifier les liens. Un lien cassé, ou remplacé par un vrai dossier, ne produit aucune erreur : la configuration disparaît simplement. Au moindre doute :
ls -l ~/.claude | grep -E 'agents|commands|skills|hooks' # macOS
git config --global --get core.hooksPath
Get-Item ~\.claude\agents, ~\.claude\commands, ~\.claude\skills, ~\.claude\hooks,
~\.claude\git-hooks | Select-Object Name, LinkType, Target # Windows : <JONCTION>
Un lien manque → relancer l'installation.
Vérifier les hooks après chaque fusion qui y touche → HOOKS.md, § La vérification.
Les tests :
sh scripts/install.test.sh # fusion des réglages, rendu de CLAUDE.md
sh git-hooks/relais.test.sh # relais des hooks git
sh git-hooks/pre-push.test.sh # garde de push
python3 hooks/guardrail.test.py
python3 hooks/garde-push.test.py
python3 scripts/verifier-les-ecrits.py . # renvois morts et notes en double dans les .md
Désinstaller.
rm ~/.claude/{agents,commands,skills,hooks,git-hooks} sous macOS ; cmd /c rmdir %USERPROFILE%\.claude\agents (et les quatre autres) sous Windows.git config --global --unset core.hooksPath.CLAUDE.md, CAVEMAN.md et settings.json d'origine dans le plus ancien ~/.claude/config-backup-<date>/ — avec les vrais dossiers qu'un lien a remplacés : les remettre en place. Un fichier absent de la sauvegarde n'existait pas avant : le supprimer. ⚠️ Ne pas garder le settings.json rendu : ses hooks pointeraient vers ~/.claude/hooks/, qui n'existe plus, et échoueraient à chaque appel d'outil. settings.json.prev n'aide pas — il ne garde que l'état d'avant la dernière passe.~/.claude/claude-config.perso, ~/.claude/claude-config.hooks.json, ~/.claude/settings.json.prev, puis les ~/.claude/config-backup-* une fois leur contenu remis en place.Le .gitignore est un deny-all : rien n'est versionné sans figurer dans sa liste d'exceptions. Cette forme est délibérée — elle protège des fichiers qui n'existent pas encore.
Ne jamais committer : .claude.json (il contient les jetons MCP en clair), .credentials.json, history.jsonl, settings.local.json.
| Dossier | Contenu |
|---|---|
agents/ | Sous-agents : dev, tester, reviewer, qa, ci |
commands/ | Commandes globales : /pr, /pr-review, /start-ticket, /new-ticket |
skills/ | Skills globaux |
hooks/ | Hooks Claude Code et statusline |
mods/ | Mods Claude Code : chaque sous-dossier qui a un .claude-plugin/plugin.json est chargé par l'installation, et porte sa propre règle. Retirer un mod = supprimer son dossier, puis relancer l'installation |
git-hooks/ | Hooks git globaux : garde de push et relais |
scripts/ | Outils de maintenance et tests de l'installation |
exemple-perso/ | Un dossier perso d'exemple |
CAVEMAN.md définit le format de sortie que tous les agents reprennent, et CLAUDE.md l'importe pour les briefs du parent — le supprimer laisserait chaque agent pointer vers un fichier absent.
MIT.
hooks/register.tsx 379 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AskedQuestion, OpenQuestion } from '../types'
5
6const PANE = 'open-question'
7const PIN_TOOL = 'mcp__open-question__pin_question'
8const UNPIN_TOOL = 'mcp__open-question__unpin_question'
9const MAX_QUESTION_LENGTH = 100
10const MAX_OPEN_QUESTIONS = 1
11const MAX_HISTORY = 100
12const ELLIPSIS = '…'
13const PREFIX = '❓ '
14const PREFIX_CELLS = 3
15const GAP = ' '
16const FRAME_CELLS = 4
17const FRAME_TITLE = 'En attente de ta réponse'
18const DETAILS_LABEL = 'détails'
19const REMOVE_LABEL = 'x'
20const ANSWER_LABEL = 'répondre'
21const MIN_OPTIONS = 2
22const MAX_OPTIONS = 4
23const MAX_OPTION_LENGTH = 30
24
25const RULE_SECTION = {
26 id: 'open-question:rule',
27 text: "While the open-question mod is loaded, a question still awaiting the user's answer when a subagent report comes in is pinned with `pin_question` instead of being asked again. Ask questions in your message first; never pin a question as you ask it. One pinned at a time: if two questions await when the report comes in, pin the first and ask the second again in full. An answer through the bar arrives as `Réponse à « … » :` and unpins by itself; call `unpin_question` only when an answer typed directly settles the question.",
28 scope: 'session',
29} as const
30
31const questions = atom({ plugin: 'open-question', key: 'questions' } as const, [])
32const nextId = atom({ plugin: 'open-question', key: 'nextId' } as const, 1)
33const shownId = atom({ plugin: 'open-question', key: 'shownId' } as const, null)
34const asked = atom({ plugin: 'open-question', key: 'asked' } as const, [])
35
36/**
37 * Cells the terminal takes to draw a Button labelled `label`: `[ label ]`.
38 *
39 * @param label the Button's label
40 * @returns its width in cells
41 */
42const buttonCells = (label: string): number => label.length + 4
43
44/**
45 * Cuts `text` to at most `cells` characters, ending it with an ellipsis when cut.
46 *
47 * @param text the question to fit
48 * @param cells the room left on the line, in cells; below 1, the ellipsis alone
49 * @returns `text` whole when it fits, else its head and `…`
50 */
51const fitToCells = (text: string, cells: number): string => {
52 if (text.length <= cells) {
53 return text
54 }
55
56 return text.slice(0, Math.max(0, cells - 1)).trimEnd() + ELLIPSIS
57}
58
59/**
60 * Checks a `pin_question` input against the rules the model is held to.
61 *
62 * @param question the question as the model sent it
63 * @param openCount how many questions are pinned already
64 * @returns the refusal the model reads, or undefined when the input is accepted
65 */
66const refusePin = (question: unknown, openCount: number): string | undefined => {
67 if (typeof question !== 'string' || question.trim() === '') {
68 return 'pin_question: `question` must be a non-empty string.'
69 }
70 if (/[\r\n]/.test(question)) {
71 return 'pin_question: `question` must fit on one line. Rephrase it as one short line and put the detail in `context`.'
72 }
73 if (question.length > MAX_QUESTION_LENGTH) {
74 return `pin_question: \`question\` is ${question.length} characters, the limit is ${MAX_QUESTION_LENGTH}. Rephrase it shorter and put the detail in \`context\`.`
75 }
76 if (openCount >= MAX_OPEN_QUESTIONS) {
77 return 'pin_question: A question is already pinned: ask this one in your message, or unpin the open one first.'
78 }
79
80 return undefined
81}
82
83/**
84 * Checks the `options` of a `pin_question` input.
85 *
86 * @param options the options as the model sent them; undefined for an open question
87 * @returns the refusal the model reads, or undefined when they are accepted
88 */
89const refuseOptions = (options: unknown): string | undefined => {
90 if (options === undefined) {
91 return undefined
92 }
93 if (!Array.isArray(options) || options.length < MIN_OPTIONS || options.length > MAX_OPTIONS) {
94 return `pin_question: \`options\` takes ${MIN_OPTIONS} to ${MAX_OPTIONS} choices. Rephrase the question, or put the detail in \`context\`.`
95 }
96 for (const option of options) {
97 if (typeof option !== 'string' || option.trim() === '') {
98 return 'pin_question: each option must be a non-empty string.'
99 }
100 if (/[\r\n]/.test(option) || option.length > MAX_OPTION_LENGTH) {
101 return `pin_question: each option fits on one line, at most ${MAX_OPTION_LENGTH} characters; ${JSON.stringify(option)} does not. Rephrase it shorter, or put the detail in \`context\`.`
102 }
103 }
104
105 return undefined
106}
107
108/**
109 * The words that open the user's answer to a question, up to the colon.
110 *
111 * @param question the question as pinned
112 * @returns `Réponse à « <question> » :`
113 */
114const answerTag = (question: string): string => `Réponse à « ${question} » :`
115
116/**
117 * Removes from a draft the answer opening of any question pinned this session,
118 * and the option it carried, so a press relabels the draft instead of stacking.
119 *
120 * The longest matching tag wins, then the longest matching option.
121 *
122 * @param draft the prompt box's text
123 * @param history every question pinned this session
124 * @returns what the person typed beside the answer opening
125 */
126const stripAnswer = (draft: string, history: readonly AskedQuestion[]): string => {
127 const matching = history.filter(one => draft.startsWith(answerTag(one.question)))
128 if (matching.length === 0) {
129 return draft
130 }
131
132 const question = matching.reduce((longest, one) => (one.question.length > longest.length ? one.question : longest), '')
133 const rest = draft.slice(answerTag(question).length).trimStart()
134 const option = matching
135 .filter(one => one.question === question)
136 .flatMap(one => one.options ?? [])
137 .filter(choice => rest === choice || rest.startsWith(`${choice} `))
138 .reduce((longest, choice) => (choice.length > longest.length ? choice : longest), '')
139
140 return rest.slice(option.length).trimStart()
141}
142
143/**
144 * Puts the answer to `one` in the prompt box, the draft already typed kept after it.
145 *
146 * @param $ the engine interface of the pressing hook
147 * @param one the question answered
148 * @param option the option pressed; undefined for a free answer
149 */
150const fillAnswer = async ($: EngineInterface, one: OpenQuestion, option?: string): Promise<void> => {
151 const draft = stripAnswer((await $.prompt.read()).text, await read($, asked))
152 const head = option === undefined ? `${answerTag(one.question)} ` : `${answerTag(one.question)} ${option}`
153 const separator = option === undefined || draft === '' ? '' : ' '
154 await $.prompt.fill({ text: head + separator + draft, mode: 'replace' })
155}
156
157/**
158 * Removes a pinned question, and closes the details pane when it shows that one.
159 *
160 * @param $ the engine interface of the calling hook
161 * @param id the question's id
162 * @returns true when a question was removed, false when no pinned question has that id
163 */
164const removeQuestion = async ($: EngineInterface, id: string): Promise<boolean> => {
165 let isFound = false
166 await update($, questions, list => {
167 isFound = list.some(one => one.id === id)
168
169 return list.filter(one => one.id !== id)
170 })
171 if (isFound && (await read($, shownId)) === id) {
172 await update($, shownId, () => null)
173 await $.ui.close({ id: PANE })
174 }
175
176 return isFound
177}
178
179export const register: Register = on => {
180 on('session.start', async ($, e, next) => {
181 await $.tool.register({
182 name: 'pin_question',
183 description:
184 'Pins a question that awaits the user\'s answer above the prompt, so it stays visible while you keep working. Ask the question in your message first; pin it only when a subagent report has come in while it still awaits the answer, since that is what pushes it off screen. Pinning never blocks your work. `question`: one line, at most 100 characters. `context`: optional Markdown the user can open for details. `options`: 2 to 4 choices, one line and at most 30 characters each, for a multiple-choice question; leave it out for an open question. An answer given through the bar arrives as `Réponse à « <question> » : …` and unpins the question by itself. At most one pinned at once. Returns the question\'s id.',
185 inputSchema: {
186 type: 'object',
187 properties: {
188 question: { type: 'string', description: 'The question, one line, at most 100 characters.' },
189 context: { type: 'string', description: 'Optional Markdown context shown in a details pane.' },
190 options: {
191 type: 'array',
192 items: { type: 'string' },
193 minItems: MIN_OPTIONS,
194 maxItems: MAX_OPTIONS,
195 description: 'Optional choices for a multiple-choice question, one line and at most 30 characters each.',
196 },
197 },
198 required: ['question'],
199 },
200 isDeferred: false,
201 })
202 await $.tool.register({
203 name: 'unpin_question',
204 description:
205 'Removes a pinned question by its id. Call it when an answer typed directly (not through the bar) settles the question; an answer through the bar has already unpinned it; an answer that misses the question leaves it pinned.',
206 inputSchema: {
207 type: 'object',
208 properties: {
209 id: { type: 'string', description: 'The id pin_question returned.' },
210 },
211 required: ['id'],
212 },
213 isDeferred: false,
214 })
215
216 return next(e)
217 })
218
219 on('prompt.compose', async ($, e, next) => {
220 const composed = await next(e)
221 if (!e.tools.includes(PIN_TOOL)) {
222 return composed
223 }
224
225 return { sections: [...composed.sections, RULE_SECTION] }
226 })
227
228 on('session.end', async ($, e, next) => {
229 await update($, questions, () => [])
230 await update($, shownId, () => null)
231 await update($, asked, () => [])
232 await $.ui.close({ id: PANE })
233
234 return next(e)
235 })
236
237 on('tool.call', { tool: PIN_TOOL }, async ($, e) => {
238 const raw = e as unknown as { question?: unknown; context?: unknown; options?: unknown }
239 const trim = (value: unknown): unknown => (typeof value === 'string' ? value.trim() : value)
240 const input = {
241 question: trim(raw.question),
242 context: raw.context,
243 options: Array.isArray(raw.options) ? raw.options.map(trim) : raw.options,
244 }
245 const refusal = refusePin(input.question, (await read($, questions)).length) ?? refuseOptions(input.options)
246 if (refusal !== undefined) {
247 return { deny: refusal }
248 }
249 if (input.context !== undefined && typeof input.context !== 'string') {
250 return { deny: 'pin_question: `context` must be a string of Markdown.' }
251 }
252
253 let id = ''
254 await update($, nextId, n => {
255 id = `q${n}`
256
257 return n + 1
258 })
259 const pinned: OpenQuestion = { id, question: String(input.question) }
260 if (typeof input.context === 'string' && input.context.trim() !== '') {
261 pinned.context = input.context
262 }
263 if (Array.isArray(input.options)) {
264 pinned.options = input.options.map(String)
265 }
266 let isFull = false
267 await update($, questions, list => {
268 isFull = list.length >= MAX_OPEN_QUESTIONS
269
270 return isFull ? list : [...list, pinned]
271 })
272 if (isFull) {
273 return { deny: refusePin(pinned.question, MAX_OPEN_QUESTIONS) ?? 'pin_question: refused.' }
274 }
275 const record: AskedQuestion = pinned.options ? { question: pinned.question, options: pinned.options } : { question: pinned.question }
276 await update($, asked, history => [...history, record].slice(-MAX_HISTORY))
277
278 return { result: id }
279 }).catch(() => ({ deny: 'pin_question: the question could not be pinned.' }))
280
281 on('tool.call', { tool: UNPIN_TOOL }, async ($, e) => {
282 const { id } = e as unknown as { id?: unknown }
283 if (typeof id !== 'string' || !(await removeQuestion($, id))) {
284 return { deny: `unpin_question: no pinned question has the id ${JSON.stringify(id)}.` }
285 }
286
287 return { result: `Unpinned ${id}.` }
288 }).catch(() => ({ deny: 'unpin_question: the question could not be unpinned.' }))
289
290 on('prompt.submit', async ($, e, next) => {
291 const sent = await next(e)
292 if (sent.drop !== undefined) {
293 return sent
294 }
295 const answered = (await read($, questions)).find(one => e.text.startsWith(answerTag(one.question)))
296 if (answered !== undefined) {
297 await removeQuestion($, answered.id)
298 }
299
300 return sent
301 }).catch(($, e, next) => next(e))
302
303 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
304 const list = await read($, questions)
305 if (e.props.hasSurvey || list.length === 0) {
306 return next(e)
307 }
308
309 const { Box, Button, Text } = $.ui.resolve(e)
310 const columns = e.props.bodyColumns - FRAME_CELLS
311
312 return (
313 <Box key="frame" flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
314 <Text bold color="claude">
315 {FRAME_TITLE}
316 </Text>
317 {list.map(one => {
318 const buttons = (one.context ? buttonCells(DETAILS_LABEL) + 1 : 0) + buttonCells(REMOVE_LABEL)
319 const room = columns - PREFIX_CELLS - GAP.length - buttons
320
321 return (
322 <Box key={`question:${one.id}`} flexDirection="column">
323 <Box key={`row:${one.id}`} flexDirection="row">
324 <Text wrap="truncate-end">
325 {PREFIX}
326 {fitToCells(one.question, room)}
327 {GAP}
328 </Text>
329 {one.context && (
330 <Button
331 key={`details:${one.id}`}
332 label={DETAILS_LABEL}
333 onPress={async () => {
334 await update($, shownId, () => one.id)
335 await $.ui.open({ id: PANE, title: one.question })
336 }}
337 />
338 )}
339 {one.context && <Text> </Text>}
340 <Button
341 key={`remove:${one.id}`}
342 label={REMOVE_LABEL}
343 onPress={async () => {
344 await removeQuestion($, one.id)
345 }}
346 />
347 </Box>
348 <Box
349 key={`answers:${one.id}`}
350 flexDirection="row"
351 flexWrap="wrap"
352 columnGap={1}
353 marginLeft={PREFIX_CELLS}
354 >
355 {(one.options ?? []).map((option, index) => (
356 <Button key={`option:${one.id}:${index}`} label={option} onPress={() => fillAnswer($, one, option)} />
357 ))}
358 <Button key={`answer:${one.id}`} label={ANSWER_LABEL} onPress={() => fillAnswer($, one)} />
359 </Box>
360 </Box>
361 )
362 })}
363 </Box>
364 )
365 })
366
367 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
368 const { Markdown, Text } = $.ui.resolve(e)
369 const id = await read($, shownId)
370 const shown = (await read($, questions)).find(one => one.id === id)
371
372 if (shown?.context === undefined) {
373 return <Text dimColor>No question to show.</Text>
374 }
375
376 return <Markdown key="context" text={shown.context} />
377 })
378}
379types/index.d.ts 26 lines1/** One question pinned above the prompt, awaiting the user's answer. */
2export type OpenQuestion = {
3 /** Stable handle the model passes to `unpin_question`. */
4 id: string
5 /** One line, at most 100 characters. */
6 question: string
7 /** Markdown shown in the details pane; absent when none was given. */
8 context?: string
9 /** 2 to 4 one-line choices, at most 30 characters each; absent for an open question. */
10 options?: string[]
11}
12
13/** A question pinned earlier in the session, kept to recognise its answer tag in a draft. */
14export type AskedQuestion = Pick<OpenQuestion, 'question' | 'options'>
15
16declare module 'claude-code' {
17 interface PluginState {
18 'open-question': {
19 questions: OpenQuestion[]
20 nextId: number
21 shownId: string | null
22 asked: AskedQuestion[]
23 }
24 }
25}
26