SLOPSHOPPER

open-question

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

newpanebandguardprompttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · open-question
│ ┃ open-question ✕ › fix the failing auth test and add an audit log call │ ┃ No question to show. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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

Draws

Pane · open-question
No question to show.
README

claude-config

🇫🇷 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.

Ce que ça apporte

  • Une chaîne d'agents — 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.
  • Des règles de délégation — 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.
  • Des briefs courts — le style caveman (CAVEMAN.md) pour tout ce qui va vers un sous-agent et en revient, avec ce qui ne se compresse jamais.
  • Des gardes — 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.
  • Des skills de méthode — cadrer un projet, challenger un cadrage, déboguer en quatre phases, traiter une review, vérifier avant d'annoncer, auditer un dépôt ou le SEO d'une page publique.
  • Des commandes — /pr, /pr-review, /start-ticket, /new-ticket.
  • De la télémétrie — l'OpenTelemetry natif de Claude Code, plus un collecteur qui rend aux sous-agents leur nom. Ce dépôt émet ; la pile qui reçoit et affiche est home-server-telemetry.

Inspiration

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.

Démarrer

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.

  1. Cloner là où le dépôt restera. L'installation lie ~/.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
  1. Installer, depuis le dossier du dépôt.

⚠️ 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
  1. Vérifier — les liens et les hooks, § Maintenance.

L'installation est idempotente : la relancer après un git pull met le poste à jour.

Le dossier perso

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 :

FichierEffet
settings.jsonfusionné 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.jsonfusionné ensuite, sur cet OS seulement
CLAUDE.mdinséré dans le CLAUDE.md rendu, à la place de {{PERSO_CLAUDE_MD}}
CLAUDE.complet.mdremplace 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/.

Comment ça marche

~/.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) :

CibleSource
~/.claude/agentsagents/
~/.claude/commandscommands/
~/.claude/skillsskills/
~/.claude/hookshooks/
~/.claude/git-hooksgit-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é :

  1. settings.base.json — commun aux deux OS ;
  2. settings.macos.json ou settings.windows.json — les commandes de hooks diffèrent (.sh contre .ps1, python3 contre l'interpréteur trouvé sous Windows) ;
  3. <perso>/settings.json ;
  4. <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.

MarqueurRemplacé 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.

Les hooks

Hooks Claude Code

HookQuandCe qu'il fait
guardrailavant Bash, Edit, Write — et PowerShell sous Windowscoupe les boucles (même appel répété) et les commandes destructrices passées par Bash — pas encore par l'outil PowerShell
garde-pushavant Bash — et PowerShell sous Windowsrefuse une commande qui citerait de quoi désarmer le pre-push
session-git-contextau démarrageaffiche la branche, le dernier commit et l'état de l'arbre
post-write-lintaprès Write, Editlance pnpm lint — sur un projet AdonisJS seulement
collecteur-agentsau démarrage, à la fin d'un sous-agentpousse les métriques par sous-agent ; sans adresse configurée, il s'abstient sans faire échouer la session
statuslineen continumodè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.

Hooks git globaux

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.

HookCe qu'il fait
pre-pushrefuse 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-validaterelaient 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.

Adapter à un projet

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.

Maintenance

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.

  1. Retirer les cinq liens — le lien seulement, jamais son contenu : rm ~/.claude/{agents,commands,skills,hooks,git-hooks} sous macOS ; cmd /c rmdir %USERPROFILE%\.claude\agents (et les quatre autres) sous Windows.
  2. Retirer le dossier de hooks git : git config --global --unset core.hooksPath.
  3. Restaurer, ou retirer, les fichiers rendus. La première installation a copié vos 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.
  4. Supprimer ~/.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.

Ce qui n'entre jamais dans le dépôt

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.

Inventaire

DossierContenu
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.

Licence

MIT.

Source 2 files
hooks/register.tsx 379 lines
1import { 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}
379
types/index.d.ts 26 lines
1/** 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