SLOPSHOPPER

decision-tree

Turns Claude's closing question, or its AskUserQuestion call, into a side panel: each option with what it implies, and a real forked session to try any answer…

newpanerowsguardcommandtoast
v0.3.0MITupdated 2026-10-12melandres8/claude-mods/decision-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · decision-tree
│ ┃ Decisión ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ decision-tree │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ Sin pregunta pendiente. Cuando Claude te p ⏺ Update(src/auth.ts) │ ┃ │ algo, aparecerá aquí. ⎿ 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 │ │ › /decision-tree │ ⎿ decision-tree: Panel de decision-tree abierto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Decisión
◆ decision-tree ╭──────────────────────────────────────────────────────╮ │ Sin pregunta pendiente. Cuando Claude te pregunte │ │ algo, aparecerá aquí. │ ╰──────────────────────────────────────────────────────╯
README

decision-tree

Convierte la pregunta con la que Claude cierra su respuesta, o su llamada a AskUserQuestion, en un panel lateral. Cada opción aparece con lo que conlleva. Además, puedes probar cualquier respuesta en una rama, una sesión bifurcada de verdad, antes de confirmarla.

La rama investiga en sólo lectura y te deja un informe: qué haría, qué cambiaría, riesgos y lo que aún no sabe. Tú decides después. La respuesta que confirmas llega como tu próximo mensaje.

Activarlo

Instalarlo desde el marketplace. La raíz de este repositorio trae .claude-plugin/marketplace.json:

claude plugin marketplace add melandres8/claude-mods
claude plugin install decision-tree@claude-mods

Para trabajar sobre una copia local, pasa la ruta del clon en lugar de melandres8/claude-mods. Claude Code lee el plugin desde la carpeta misma. Tras editarlo, /reload-plugins lo recarga en la sesión abierta.

Usa una sola forma de cargarlo. Si lo instalas y además lo pasas con --plugin-dir, los hooks se cargan dos veces.

Uso

No hay que hacer nada para que funcione: decision-tree escucha cada turno.

  • AskUserQuestion: cuando Claude abre su diálogo de preguntas, el panel lo toma en su lugar. En la conversación queda una tarjeta que apunta al panel. Si el panel no se puede colocar, sigue el diálogo nativo de siempre.
  • Pregunta final: si la respuesta de Claude termina con una pregunta, un modelo pequeño (haiku) la lee y propone de 2 a 4 opciones. Con el panel acoplado junto a la conversación, se abre solo. En la pantalla principal no se abre: la línea de estado avisa (decision-tree: una pregunta para explorar · /decision-tree).
  • /decision-tree abre el panel cuando quieras.

El panel

  1. Pregunta y opciones. Con varias preguntas, una fila de pestañas las separa; ✓ marca las ya respondidas.
  2. ⌥ Lo que conlleva: al elegir una opción, Claude analiza qué haría después, a favor, en contra o riesgos, y si es reversible. El análisis se hace una sola vez por opción. Si falla, lo dice en lugar de quedarse en «Analizando…».
  3. ✎ Otra respuesta: escribe la tuya y pulsa Enter. Se añade como opción y también se analiza. En el móvil no hay campo de texto.
  4. ⑂ Probar en rama: arranca una rama con esa respuesta. Mientras trabaja, la opción muestra ◐ rama en curso. Al terminar verás ✓ rama lista, un aviso y su informe en el panel.
  5. ✓ Confirmar envía la respuesta como tu mensaje. Con varias preguntas, ✓ Confirmar todas envía una lista con cada respuesta. ✎ Editar la deja en el prompt para que la retoques.

Ramas anteriores: cuando llega una pregunta nueva, los informes de las ramas ya terminadas siguen en el panel, bajo «Ramas anteriores». Se muestran los tres últimos y se guardan los diez últimos.

Si respondes por tu cuenta, escribiendo en el prompt, desde el móvil o desde el SDK, la pregunta del panel se da por respondida y desaparece. Así Confirmar nunca envía una respuesta pasada. Un comando con /, como /decision-tree, no cuenta como respuesta.

Qué puede hacer una rama

Una rama es un fork de la sesión: hereda la conversación y el modo de permisos. Para que pueda probar una respuesta sin consecuencias, decision-tree vigila cada llamada a herramienta que hace la rama y rechaza todo lo que no sea leer. El rechazo le pide que describa el cambio en su informe en lugar de aplicarlo.

Herramientas

PermitidasRechazadas
Read, Grep, Glob, LSP, ToolSearch y Bash sólo con los comandos de abajoTodas las demás: Write, Edit, NotebookEdit, Agent, AskUserQuestion, cualquier herramienta MCP y también WebFetch y WebSearch

Por qué no hay herramientas de red: una rama puede leer secretos, como un .env. Una URL o una búsqueda los sacaría fuera. Esto cierra las herramientas que salen a la red, no todo canal posible: mira las Notas.

Bash

Cada comando se lee como lo leería el shell (bash o zsh). Si algo no encaja con estas reglas, se rechaza.

Cómo se lee la línea

  • Se divide en comandos simples por |, ||, &&, ;, & y el salto de línea. Cada comando simple debe estar permitido.
  • Comillas: se aceptan, porque los patrones las necesitan (jq '.x', grep 'a|b'). El flag se juzga ya sin comillas: find . '-delete' es -delete y se rechaza. Dentro de comillas simples todo es literal. Dentro de comillas dobles se rechazan $, el acento grave y \. Una comilla sin cerrar se rechaza.
  • Fuera de comillas sólo pasan letras, dígitos y _ . / : = , @ % + ~ -, más ! como palabra suelta (para find). Por tanto, no hay expansión de variables, sustitución de comandos, redirecciones, globs (* ? [ ]), llaves ({a,b}) ni barras invertidas.
  • Sin globs: un glob puede expandirse a un archivo con nombre de flag (--pre=./x). Por eso ls src/*.ts se rechaza. Para listar por patrón, la rama usa Glob, o pasa el patrón entre comillas a la herramienta que lo interpreta: find . -name '*.ts', rg -g '*.ts', grep --include='*.ts'.
  • Redirecciones: sólo las de stderr, 2>&1, 2>/dev/null y 2> /dev/null.
  • No se aceptan asignaciones delante (FOO=1 ls), rutas a binarios (/bin/ls) ni envoltorios (env, command, xargs, sudo).

Flags

Cada binario tiene su lista cerrada de flags. Cualquier otro se rechaza.

  • Los flags cortos pueden ir juntos (-la). Una letra que lleva valor toma el resto de la palabra (-n5) o la palabra siguiente (-n 5).
  • Los flags largos deben coincidir exactos, nunca por prefijo: GNU y git aceptarían --out por --output.
  • El valor de un flag largo sólo se lee con = (--glob=x). La palabra siguiente se juzga siempre por separado.
  • -- termina los flags. Después de --, todo cuenta como operando, y un - suelto también.
ComandoFlags permitidosNotas
echo, truecualquierasólo escriben en la salida
pwd-L -Psin operandos
ls-aAbBcCdfFgGhHiklLmnopqQrRsStTuUvwx1, --all --almost-all --human-readable --recursive --reverse --color --classify --directory, --color= --sort= --time-style=
cat-AbeEnstTuv, --number --number-nonblank --show-all --squeeze-blank
head-q -v, -n N -c N, -N, --quiet --silent --verbose, --lines= --bytes=
tail-q -r -v, -n N -c N, -N, --quiet --silent --verbose, --lines= --bytes=sin -f, -F, --follow ni --retry: no terminaría nunca. Tampoco operandos que empiecen por +, porque la forma tradicional tail +5f también sigue el archivo
grep-rRnilLcvwxoqsHhEFGIPabU, -e -f -m -A -B -C -d -D con valor, -N, --recursive --line-number --ignore-case --files-with-matches --files-without-match --count --word-regexp --line-regexp --fixed-strings --extended-regexp --perl-regexp --only-matching --no-filename --with-filename --invert-match --color --colour --quiet --no-messages, --include= --exclude= --exclude-dir= --color= --colour= --max-count= --context= --binary-files=
rg-nNiIsSlcwxvFuoHLpq0.UPabh, -e -f -t -T -g -A -B -C -m -M -j -E -r -d con valor, --files --hidden --no-ignore --no-ignore-vcs --no-heading --heading --count --json --vimgrep --line-number --no-line-number --ignore-case --smart-case --case-sensitive --fixed-strings --word-regexp --files-with-matches --files-without-match --follow --type-list --multiline --only-matching --invert-match --no-messages --count-matches --with-filename, --glob= --iglob= --type= --type-not= --max-count= --max-depth= --max-columns= --color= --sort= --sortr= --context= --after-context= --before-context= --max-filesize=sin --pre, --pre-glob ni --hostname-bin (ejecutan programas), ni -z (ejecuta descompresores)
finddelante: -H -L -P -E; luego las rutas; en la expresión: -print -print0 -prune -empty -depth -xdev -mount -follow -readable -executable -nouser -nogroup -true -false -not -a -o -and -or ! ( ) -ls y, con valor, -name -iname -path -ipath -wholename -iwholename -regex -iregex -type -xtype -maxdepth -mindepth -size -mtime -mmin -atime -amin -ctime -cmin -newer -perm -user -group -uid -gid -links -inum -samefile -lname -ilnamesin -delete, -exec, -execdir, -ok, -okdir, -fprint* ni -fls
wc-lwcmL, --lines --words --bytes --chars --max-line-length
which-a -s
file-bhiLkN, --mime --mime-type --mime-encoding --brief --dereference --no-dereferencesin -C ni -m (compilan o eligen archivos magic), ni -z
stat-LflrsxFtq, -c con valor, --dereference --terse, --format= --printf=
tree-adfilspugDFCnNqvrtUxAShJX, -L -I -P con valor, --dirsfirst --noreport --gitignore --prunesin -o (escribe el listado), -H ni -R (escriben HTML)
du-ahcskmxLHP0, -d con valor, --human-readable --summarize --apparent-size, --max-depth=
df-hHkilTPa, --human-readable
date-u -R -j -I, -r -v con valor, --utc --universal --iso-8601 --rfc-email, --iso-8601= --rfc-3339= --reference= --date=sólo operandos +FORMATO: en BSD un operando suelto cambia la hora; sin -s, --set ni -d
sort-bdfgiMhnRrVuscCmz, -t -k -S con valor, --reverse --unique --numeric-sort --human-numeric-sort --version-sort --ignore-case --general-numeric-sort --month-sort --stable --check --merge --zero-terminated --dictionary-order --ignore-leading-blanks --random-sort, --key= --field-separator= --buffer-size= --sort=sin -o/--output, -T/--temporary-directory ni --compress-program (ejecuta un programa)
uniq-cdDuiz, -f -s -w con valor, --count --repeated --unique --ignore-case, --skip-fields= --skip-chars= --check-chars=como mucho un operando: el segundo sería el archivo de salida
cut-snw, -b -c -f -d con valor, --complement --only-delimited, --delimiter= --fields= --characters= --bytes=
tr-cCdsucomo mucho dos operandos
nl-p, -b -d -f -h -i -l -n -s -v -w con valor
diff-uaBbdEeiNnpqrsTtwy, -U -C -D -I -L -F -S -W -x -X con valor, --unified --recursive --brief --side-by-side --ignore-all-space --ignore-space-change --new-file --text, --unified= --color= --exclude= --label=como mucho dos operandos
jq-rcensSjaCMRf, --raw-output --compact-output --slurp --null-input --sort-keys --join-output --exit-status --raw-input --ascii-output --tab --indent --arg --argjson --slurpfile --rawfile --args --jsonargs
basename-a -z, -s con valor, --multiple --zero, --suffix=
dirname-z, --zero
realpath-eEmLPqsz, --relative-to= --relative-base=

git: el subcomando va justo después de git. Así quedan fuera -c, -C, --exec-path y similares.

SubcomandoFlags permitidos
status-s -b -z, --short --branch --porcelain --long --ignored --untracked-files, --porcelain= --untracked-files= --ignored=
diff-p -R -w -b -z, --stat --shortstat --numstat --name-only --name-status --cached --staged --patch --word-diff --color --no-color --minimal --patience --histogram --ignore-all-space --ignore-space-change --check --summary --raw --relative --exit-code --quiet --no-ext-diff --no-textconv, --unified= --diff-filter= --stat= --color= --word-diff= --relative=
log-p -z, -n -S -G con valor, -N, --oneline --stat --shortstat --numstat --name-only --name-status --graph --decorate --no-decorate --all --branches --tags --remotes --patch --follow --reverse --no-merges --merges --abbrev-commit --no-abbrev-commit --first-parent --color --no-color --summary --raw --topo-order --date-order --relative-date --pretty --format --full-history --source --left-right, --format= --pretty= --since= --until= --after= --before= --author= --committer= --grep= --max-count= --skip= --date= --decorate= --color= --abbrev= --diff-filter= --stat=
show-p -w -z, -n con valor, -N, y los largos de log y diff
blame-wslefnpct, -L con valor, --porcelain --line-porcelain --show-email
ls-files-cdmoiksutvfz, --others --cached --modified --deleted --exclude-standard --error-unmatch --stage --full-name --directory, --exclude=
rev-parse-q, --show-toplevel --abbrev-ref --short --git-dir --git-common-dir --absolute-git-dir --is-inside-work-tree --is-bare-repository --verify --symbolic-full-name --show-prefix --show-cdup, --short=
describe--tags --always --dirty --long --all --contains --exact-match, --abbrev= --match= --exclude= --dirty=
branch-a -r -v, --all --remotes --list --show-current --verbose; sin operandos
remote-v, --verbose; sin operandos

Ejemplos que funcionan: ls -la, grep -rn foo src, rg -n foo -g '*.ts', find . -name '*.ts' -type f, git log --oneline -5, git diff --stat, head -n 20 f, wc -l f, sort f | uniq -c | sort -rn, jq '.scripts' package.json.

Protecciones

  • Desde la primera llamada: una rama puede llamar herramientas antes de que $.agent.spawn devuelva su id. El guardia la reconoce de tres formas. Si su id ya está guardado, la vigila. Si $.agent.list() la nombra como lanzada por decision-tree, también. Y mientras haya un arranque en curso, rechaza a cualquier agente que la lista aún no nombra. Los subagentes que lanzan el modelo u otros plugins siguen con todas sus herramientas.
  • Sin caducidad: los ids de las ramas se guardan sin límite. Una rama vieja sigue vigilada.
  • Si la comprobación falla (la lista no responde, por ejemplo), el guardia decide con lo que recuerda. A una rama conocida le aplica las mismas reglas. A un agente desconocido lo rechaza sólo mientras arranca una rama. A cualquier otro lo deja pasar.
  • Ramas huérfanas: si llega una pregunta nueva, las ramas que siguen trabajando para la anterior se detienen con TaskStop y quedan marcadas como detenidas. Lo mismo ocurre con una rama que termina de arrancar cuando su pregunta ya cambió.
  • Avisos de las ramas: el aviso de fin de una rama no llega a la conversación principal, porque su informe ya está en el panel. Sólo se descarta si el panel tiene esa rama. Cualquier otro aviso pasa.
  • Preguntas viejas: si una lectura de la pregunta termina tarde (llegó otro turno o respondiste tú), se descarta. Una pregunta más vieja nunca reemplaza a una más nueva.
  • Un clic, una acción: pulsar dos veces una opción o Probar en rama no lanza dos análisis ni dos ramas.

Notas

  • git diff, git log -p y git show pueden ejecutar programas que tú configuraste en git, como diff.external, textconv o filtros. La rama no puede cambiar esa configuración, pero hereda la tuya.
  • git status y git describe --dirty pueden reescribir la caché de .git/index. No cambian archivos ni historia.
  • En macOS, leer una ruta bajo /net/<host> hace que el automontador resuelva ese nombre. Una consulta DNS puede sacar datos aunque la rama no tenga herramientas de red. Pasa con el shell y también con Read, Grep y Glob.
  • TaskStop pasa por la comprobación de permisos como cualquier herramienta. Si tu configuración la pide, puede aparecer un diálogo al cambiar de pregunta.
  • El estado (pregunta, opciones, análisis, ramas e ids) vive en $.state, así que aguanta una recarga en caliente. Los arranques en curso y las lecturas en curso viven en memoria del módulo: una recarga los olvida y descarta esas lecturas.

Desarrollo

claude plugin validate .
claude plugin test .

.claude-plugin/types lo genera Claude Code con los tipos del motor que usan el editor y los tests. No se sube al repositorio: la carpeta trae su propio .gitignore, que la excluye entera.

Source 2 files
hooks/register.tsx 1078 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5  DecisionAsk,
6  DecisionFork,
7  DecisionImplication,
8  DecisionOption,
9  DecisionQuestion,
10} from '../types'
11
12const PANE = 'decision-tree'
13const TITLE = 'Decisión'
14
15const phase = atom({ plugin: 'decision-tree', key: 'phase' } as const, 'idle')
16const ask = atom({ plugin: 'decision-tree', key: 'ask' } as const, null)
17const focus = atom({ plugin: 'decision-tree', key: 'focus' } as const, 0)
18const picks = atom({ plugin: 'decision-tree', key: 'picks' } as const, {})
19const implications = atom({ plugin: 'decision-tree', key: 'implications' } as const, {})
20const forks = atom({ plugin: 'decision-tree', key: 'forks' } as const, {})
21// Every fork this plugin started, never reset nor capped, so the guard below
22// keeps holding a fork that outlives the ask it was started for.
23const forkIds = atom({ plugin: 'decision-tree', key: 'forkIds' } as const, [])
24
25// The guard's own memory, for its .catch, which may not call $: the forks it
26// knows, the agents listed as someone else's, and the spawns under way, whose
27// forks can call tools before $.agent.spawn hands back their id.
28const knownForks = new Set<string>()
29const foreignAgents = new Set<string>()
30let pendingSpawns = 0
31
32// What the last drawing said about the layout; unknown until the pane draws.
33let isFullscreen: boolean | undefined
34
35// What a fork may call: reading the workspace, nothing that changes it.
36// Bash is judged per command by isReadOnlyShell. No WebFetch or WebSearch: a
37// fork may read secrets (.env), and a URL or a query would carry them out.
38const FORK_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LSP', 'ToolSearch'])
39
40// The shell guard reads a command the way the shell will, then judges each
41// simple command against an allowlist of binaries and, per binary, of flags.
42//
43// Quoting: the command is split into words by a small shell-word lexer, so
44// a quoted flag (`'-delete'`) is judged as the flag it becomes. Quotes are
45// allowed because patterns need them (`jq '.x'`, `grep 'a|b'`). Inside single
46// quotes every character is literal; inside double quotes `$`, backtick and
47// backslash are refused, since the shell expands them there. Unquoted, only
48// plain characters pass (letters, digits, `_ . / : = , @ % + ~ -`, and `!` as a
49// word of its own): no expansion, substitution, redirection, globbing or
50// brace expansion. A glob is refused because it can expand to a file named
51// like a flag (`--pre=./x`); `ls src/*.ts` is therefore refused, and the Glob
52// tool is the way to list by pattern. The one redirection read is stderr's:
53// `2>&1`, `2>/dev/null`, `2> /dev/null`. This is held for bash and zsh alike.
54//
55// Flags: every flag must be one the binary's spec lists, deny by default.
56// Short flags may be bundled (`-la`); a value letter takes the rest of the
57// word (`-n5`) or the next word (`-n 5`). Long flags match exactly, never by
58// prefix (GNU and git accept unique prefixes, `--out` for `--output`), and a
59// long flag's value is read only from `--name=value`: the next word is always
60// judged on its own. A letter is listed as taking a value only where every
61// GNU or BSD tool that knows it takes one (one that lacks it fails), so no
62// word the tool reads as a flag escapes the check.
63
64type ShellSpec = {
65  /** Short letters that take no value; any order, bundled. */
66  short?: string
67  /** Short letters that take a value: the rest of the word, else the next word. */
68  shortValue?: string
69  /** Long flags given bare. */
70  long?: readonly string[]
71  /** Long flags given as `--name=value`. */
72  longValue?: readonly string[]
73  /** Whether `-N` (a count, as `head -50`) is a flag. */
74  count?: boolean
75  /** The most operands it takes; a bare `-` counts. Any when absent. */
76  maxOperands?: number
77  /** What every operand must be. */
78  operand?: (word: string) => boolean
79}
80
81const LOG_LONG = [
82  '--oneline', '--stat', '--shortstat', '--numstat', '--name-only', '--name-status', '--graph',
83  '--decorate', '--no-decorate', '--all', '--branches', '--tags', '--remotes', '--patch', '--follow',
84  '--reverse', '--no-merges', '--merges', '--abbrev-commit', '--no-abbrev-commit', '--first-parent',
85  '--color', '--no-color', '--summary', '--raw', '--topo-order', '--date-order', '--relative-date',
86  '--pretty', '--format', '--full-history', '--source', '--left-right',
87]
88const LOG_VALUE = [
89  '--format', '--pretty', '--since', '--until', '--after', '--before', '--author', '--committer', '--grep',
90  '--max-count', '--skip', '--date', '--decorate', '--color', '--abbrev', '--diff-filter', '--stat',
91]
92const DIFF_LONG = [
93  '--stat', '--shortstat', '--numstat', '--name-only', '--name-status', '--cached', '--staged', '--patch',
94  '--word-diff', '--color', '--no-color', '--minimal', '--patience', '--histogram', '--ignore-all-space',
95  '--ignore-space-change', '--check', '--summary', '--raw', '--relative', '--exit-code', '--quiet',
96  '--no-ext-diff', '--no-textconv',
97]
98const DIFF_VALUE = ['--unified', '--diff-filter', '--stat', '--color', '--word-diff', '--relative']
99
100const GIT_SPECS: Record<string, ShellSpec> = {
101  status: {
102    short: 'sbz',
103    long: ['--short', '--branch', '--porcelain', '--long', '--ignored', '--untracked-files'],
104    longValue: ['--porcelain', '--untracked-files', '--ignored'],
105  },
106  diff: { short: 'pRwbz', long: DIFF_LONG, longValue: DIFF_VALUE },
107  log: { short: 'pz', shortValue: 'nSG', count: true, long: LOG_LONG, longValue: LOG_VALUE },
108  show: {
109    short: 'pwz',
110    shortValue: 'n',
111    count: true,
112    long: [...LOG_LONG, ...DIFF_LONG],
113    longValue: [...LOG_VALUE, ...DIFF_VALUE],
114  },
115  blame: { short: 'wslefnpct', shortValue: 'L', long: ['--porcelain', '--line-porcelain', '--show-email'] },
116  'ls-files': {
117    short: 'cdmoiksutvfz',
118    long: ['--others', '--cached', '--modified', '--deleted', '--exclude-standard', '--error-unmatch', '--stage', '--full-name', '--directory'],
119    longValue: ['--exclude'],
120  },
121  'rev-parse': {
122    short: 'q',
123    long: [
124      '--show-toplevel', '--abbrev-ref', '--short', '--git-dir', '--git-common-dir', '--absolute-git-dir',
125      '--is-inside-work-tree', '--is-bare-repository', '--verify', '--symbolic-full-name', '--show-prefix', '--show-cdup',
126    ],
127    longValue: ['--short'],
128  },
129  describe: {
130    long: ['--tags', '--always', '--dirty', '--long', '--all', '--contains', '--exact-match'],
131    longValue: ['--abbrev', '--match', '--exclude', '--dirty'],
132  },
133  branch: { short: 'arv', long: ['--all', '--remotes', '--list', '--show-current', '--verbose'], maxOperands: 0 },
134  remote: { short: 'v', long: ['--verbose'], maxOperands: 0 },
135}
136
137const SHELL_SPECS: Record<string, ShellSpec | 'any'> = {
138  echo: 'any',
139  true: 'any',
140  pwd: { short: 'LP', maxOperands: 0 },
141  ls: {
142    short: 'aAbBcCdfFgGhHiklLmnopqQrRsStTuUvwx1',
143    long: ['--all', '--almost-all', '--human-readable', '--recursive', '--reverse', '--color', '--classify', '--directory'],
144    longValue: ['--color', '--sort', '--time-style'],
145  },
146  cat: { short: 'AbeEnstTuv', long: ['--number', '--number-nonblank', '--show-all', '--squeeze-blank'] },
147  head: { short: 'qv', shortValue: 'nc', count: true, long: ['--quiet', '--silent', '--verbose'], longValue: ['--lines', '--bytes'] },
148  // No -f, -F, --follow or --retry: a fork that follows a file never ends. No
149  // operand starting with +: the traditional form `tail +5f` follows too.
150  tail: {
151    short: 'qrv',
152    shortValue: 'nc',
153    count: true,
154    long: ['--quiet', '--silent', '--verbose'],
155    longValue: ['--lines', '--bytes'],
156    operand: word => !word.startsWith('+'),
157  },
158  grep: {
159    short: 'rRnilLcvwxoqsHhEFGIPabU',
160    shortValue: 'efmABCdD',
161    count: true,
162    long: [
163      '--recursive', '--line-number', '--ignore-case', '--files-with-matches', '--files-without-match', '--count',
164      '--word-regexp', '--line-regexp', '--fixed-strings', '--extended-regexp', '--perl-regexp', '--only-matching',
165      '--no-filename', '--with-filename', '--invert-match', '--color', '--colour', '--quiet', '--no-messages',
166    ],
167    longValue: ['--include', '--exclude', '--exclude-dir', '--color', '--colour', '--max-count', '--context', '--binary-files'],
168  },
169  // No --pre, --pre-glob or --hostname-bin (each runs a program), nor -z
170  // (runs decompressors).
171  rg: {
172    short: 'nNiIsSlcwxvFuoHLpq0.UPabh',
173    shortValue: 'eftTgABCmMjErd',
174    long: [
175      '--files', '--hidden', '--no-ignore', '--no-ignore-vcs', '--no-heading', '--heading', '--count', '--json',
176      '--vimgrep', '--line-number', '--no-line-number', '--ignore-case', '--smart-case', '--case-sensitive',
177      '--fixed-strings', '--word-regexp', '--files-with-matches', '--files-without-match', '--follow', '--type-list',
178      '--multiline', '--only-matching', '--invert-match', '--no-messages', '--count-matches', '--with-filename',
179    ],
180    longValue: [
181      '--glob', '--iglob', '--type', '--type-not', '--max-count', '--max-depth', '--max-columns', '--color', '--sort',
182      '--sortr', '--context', '--after-context', '--before-context', '--max-filesize',
183    ],
184  },
185  wc: { short: 'lwcmL', long: ['--lines', '--words', '--bytes', '--chars', '--max-line-length'] },
186  which: { short: 'as' },
187  // No -C or -m (compile and choose magic files), nor -z (runs decompressors).
188  file: { short: 'bhiLkN', long: ['--mime', '--mime-type', '--mime-encoding', '--brief', '--dereference', '--no-dereference'] },
189  stat: { short: 'LflrsxFtq', shortValue: 'c', long: ['--dereference', '--terse'], longValue: ['--format', '--printf'] },
190  // No -o (writes the listing), -H or -R (write HTML files).
191  tree: {
192    short: 'adfilspugDFCnNqvrtUxAShJX',
193    shortValue: 'LIP',
194    long: ['--dirsfirst', '--noreport', '--gitignore', '--prune'],
195  },
196  du: { short: 'ahcskmxLHP0', shortValue: 'd', long: ['--human-readable', '--summarize', '--apparent-size'], longValue: ['--max-depth'] },
197  df: { short: 'hHkilTPa', long: ['--human-readable'] },
198  // Only `+FORMAT` operands: on BSD a bare operand sets the clock; no -s,
199  // --set or -d either.
200  date: {
201    short: 'uRjI',
202    shortValue: 'rv',
203    long: ['--utc', '--universal', '--iso-8601', '--rfc-email'],
204    longValue: ['--iso-8601', '--rfc-3339', '--reference', '--date'],
205    operand: word => word.startsWith('+'),
206  },
207  // No -o/--output, -T/--temporary-directory or --compress-program (runs a program).
208  sort: {
209    short: 'bdfgiMhnRrVuscCmz',
210    shortValue: 'tkS',
211    long: [
212      '--reverse', '--unique', '--numeric-sort', '--human-numeric-sort', '--version-sort', '--ignore-case',
213      '--general-numeric-sort', '--month-sort', '--stable', '--check', '--merge', '--zero-terminated',
214      '--dictionary-order', '--ignore-leading-blanks', '--random-sort',
215    ],
216    longValue: ['--key', '--field-separator', '--buffer-size', '--sort'],
217  },
218  // A second operand is the file uniq writes to.
219  uniq: {
220    short: 'cdDuiz',
221    shortValue: 'fsw',
222    long: ['--count', '--repeated', '--unique', '--ignore-case'],
223    longValue: ['--skip-fields', '--skip-chars', '--check-chars'],
224    maxOperands: 1,
225  },
226  cut: {
227    short: 'snw',
228    shortValue: 'bcfd',
229    long: ['--complement', '--only-delimited'],
230    longValue: ['--delimiter', '--fields', '--characters', '--bytes'],
231  },
232  tr: { short: 'cCdsu', maxOperands: 2 },
233  nl: { short: 'p', shortValue: 'bdfhilnsvw' },
234  diff: {
235    short: 'uaBbdEeiNnpqrsTtwy',
236    shortValue: 'UCDILFSWxX',
237    long: ['--unified', '--recursive', '--brief', '--side-by-side', '--ignore-all-space', '--ignore-space-change', '--new-file', '--text'],
238    longValue: ['--unified', '--color', '--exclude', '--label'],
239    maxOperands: 2,
240  },
241  jq: {
242    short: 'rcensSjaCMRf',
243    long: [
244      '--raw-output', '--compact-output', '--slurp', '--null-input', '--sort-keys', '--join-output', '--exit-status',
245      '--raw-input', '--ascii-output', '--tab', '--indent', '--arg', '--argjson', '--slurpfile', '--rawfile', '--args', '--jsonargs',
246    ],
247  },
248  basename: { short: 'az', shortValue: 's', long: ['--multiple', '--zero'], longValue: ['--suffix'] },
249  dirname: { short: 'z', long: ['--zero'] },
250  realpath: { short: 'eEmLPqsz', longValue: ['--relative-to', '--relative-base'] },
251}
252
253// find's expression: tests and actions that only read and print.
254const FIND_LEADING = new Set(['-H', '-L', '-P', '-E'])
255const FIND_PLAIN = new Set([
256  '-print', '-print0', '-prune', '-empty', '-depth', '-xdev', '-mount', '-follow', '-readable', '-executable',
257  '-nouser', '-nogroup', '-true', '-false', '-not', '-a', '-o', '-and', '-or', '!', '(', ')', '-ls',
258])
259const FIND_VALUE = new Set([
260  '-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex', '-type', '-xtype',
261  '-maxdepth', '-mindepth', '-size', '-mtime', '-mmin', '-atime', '-amin', '-ctime', '-cmin', '-newer', '-perm',
262  '-user', '-group', '-uid', '-gid', '-links', '-inum', '-samefile', '-lname', '-ilname',
263])
264
265/** Whether find's words (after `find`) read and print only. */
266function findReadsOnly(words: readonly string[]): boolean {
267  let i = 0
268  while (i < words.length && FIND_LEADING.has(words[i] ?? '')) i += 1
269  // The starting points: words up to the first that opens the expression.
270  while (i < words.length && !/^[-!()]/.test(words[i] ?? '')) i += 1
271  while (i < words.length) {
272    const word = words[i] ?? ''
273    if (FIND_VALUE.has(word)) {
274      if (i + 1 >= words.length) return false
275      i += 2
276    } else if (FIND_PLAIN.has(word)) {
277      i += 1
278    } else {
279      return false
280    }
281  }
282  return true
283}
284
285/** Whether the words after a binary (or a git subcommand) keep to its spec. */
286function keepsToSpec(words: readonly string[], spec: ShellSpec): boolean {
287  let operands = 0
288  let isPastFlags = false
289  for (let i = 0; i < words.length; i += 1) {
290    const word = words[i] ?? ''
291    if (!isPastFlags && word === '--') {
292      isPastFlags = true
293      continue
294    }
295    if (isPastFlags || word === '-' || !word.startsWith('-')) {
296      operands += 1
297      if (spec.operand !== undefined && !spec.operand(word)) return false
298      continue
299    }
300    if (word.startsWith('--')) {
301      const eq = word.indexOf('=')
302      const name = eq === -1 ? word : word.slice(0, eq)
303      const isListed = eq === -1 ? (spec.long ?? []).includes(name) : (spec.longValue ?? []).includes(name)
304      if (!isListed) return false
305      continue
306    }
307    if (spec.count === true && /^-\d+$/.test(word)) continue
308
309    const letters = word.slice(1)
310    for (let j = 0; j < letters.length; j += 1) {
311      const letter = letters[j] ?? ''
312      if ((spec.short ?? '').includes(letter)) continue
313      if (!(spec.shortValue ?? '').includes(letter)) return false
314      // A value letter takes the rest of the word, or else the next word.
315      if (j === letters.length - 1) {
316        if (i + 1 >= words.length) return false
317        i += 1
318      }
319      break
320    }
321  }
322  return spec.maxOperands === undefined || operands <= spec.maxOperands
323}
324
325// A spec of the table's own, never one inherited (`toString`, `constructor`).
326function specOf<T>(table: Record<string, T>, name: string): T | undefined {
327  return Object.prototype.hasOwnProperty.call(table, name) ? table[name] : undefined
328}
329
330function isReadOnlyStep(words: readonly string[]): boolean {
331  const [bin = '', ...rest] = words
332  if (bin === 'find') return findReadsOnly(rest)
333  if (bin === 'git') {
334    // Nothing between git and its subcommand: -c and -C change what runs.
335    const [sub = '', ...args] = rest
336    const spec = specOf(GIT_SPECS, sub)
337    return spec !== undefined && keepsToSpec(args, spec)
338  }
339  const spec = specOf(SHELL_SPECS, bin)
340  if (spec === undefined) return false
341  return spec === 'any' || keepsToSpec(rest, spec)
342}
343
344const PLAIN_CHAR = /[A-Za-z0-9_./:=,@%+~-]/
345const STDERR_REDIRECTS = ['2>&1', '2>/dev/null', '2> /dev/null']
346
347/**
348 * The simple commands of a command line, each as the words the shell would
349 * pass, quotes removed; null when the line holds anything the lexer does not
350 * read as plain (see the comment above ShellSpec).
351 */
352export function shellSteps(command: string): string[][] | null {
353  const steps: string[][] = []
354  let words: string[] = []
355  let word: string | null = null
356  const endWord = () => {
357    if (word !== null) words.push(word)
358    word = null
359  }
360  const endStep = () => {
361    endWord()
362    if (words.length > 0) steps.push(words)
363    words = []
364  }
365  const isBoundary = (at: number) => at >= command.length || /[\s;&|]/.test(command[at] ?? '')
366
367  let i = 0
368  while (i < command.length) {
369    const c = command[i] ?? ''
370    if (word === null) {
371      const redirect = STDERR_REDIRECTS.find(r => command.startsWith(r, i) && isBoundary(i + r.length))
372      if (redirect !== undefined) {
373        i += redirect.length
374        continue
375      }
376      if (c === '!' && isBoundary(i + 1)) {
377        words.push('!')
378        i += 1
379        continue
380      }
381    }
382    if (c === "'") {
383      const close = command.indexOf("'", i + 1)
384      if (close === -1) return null
385      word = (word ?? '') + command.slice(i + 1, close)
386      i = close + 1
387    } else if (c === '"') {
388      const close = command.indexOf('"', i + 1)
389      if (close === -1) return null
390      const inner = command.slice(i + 1, close)
391      if (/[$`\\]/.test(inner)) return null
392      word = (word ?? '') + inner
393      i = close + 1
394    } else if (c === ' ' || c === '\t') {
395      endWord()
396      i += 1
397    } else if (c === '\n' || c === ';' || c === '|' || c === '&') {
398      // `|`, `||`, `&&`, `&`, `;` and a newline all end a simple command.
399      endStep()
400      i += 1
401    } else if (PLAIN_CHAR.test(c)) {
402      word = (word ?? '') + c
403      i += 1
404    } else {
405      return null
406    }
407  }
408  endStep()
409  return steps
410}
411
412export function isReadOnlyShell(command: string): boolean {
413  const steps = shellSteps(command)
414  return steps !== null && steps.length > 0 && steps.every(isReadOnlyStep)
415}
416
417/** Whether a fork this plugin started may make this call: read and look up, never change. */
418export function forkMayCall(tool: string, input: { command?: unknown }): boolean {
419  if (FORK_TOOLS.has(tool)) return true
420
421  return tool === 'Bash' && typeof input.command === 'string' && isReadOnlyShell(input.command)
422}
423
424type AskedQuestion = {
425  question: string
426  header?: string
427  options?: readonly { label: string; description?: string }[]
428}
429
430/** An AskUserQuestion call's questions as the panel holds them; null when none has options. */
431export function fromToolQuestions(questions: readonly AskedQuestion[]): DecisionQuestion[] | null {
432  const held = questions
433    .filter(q => (q.options ?? []).length > 0)
434    .map(q => ({
435      text: q.question,
436      header: q.header,
437      options: (q.options ?? []).map(o => ({ label: o.label, reply: o.label, detail: o.description })),
438    }))
439  return held.length > 0 ? held : null
440}
441
442/** What is sent when the person confirms: the reply alone for one question, a list for several. */
443export function composeAnswer(a: DecisionAsk, chosen: Record<string, number>): string {
444  const replies = a.questions.map((q, i) => ({ q, option: q.options[chosen[i] ?? 0] }))
445  if (replies.length === 1 && a.source === 'text') return replies[0]?.option?.reply ?? ''
446
447  const lines = replies.map(({ q, option }) => `- ${q.text} → ${option?.reply ?? '(sin respuesta)'}`)
448  return `Mis respuestas:\n${lines.join('\n')}`
449}
450
451const ASK_PROMPT = (answer: string) => `You read the end of an assistant reply and decide whether it ends by asking the user a question they are expected to answer.
452
453Reply with JSON only, no prose, no code fence:
454- no question: {"question": null}
455- a question: {"question": "<the question, as asked, in its own language>", "options": [{"label": "<2-6 words>", "reply": "<what the user would type to give this answer, first person, in the question's language>"}]}
456
457Give 2 to 4 options: the ones the reply offered, else the natural answers (yes/no plus a meaningful alternative). Do not invent options unrelated to the reply.
458
459The end of the reply:
460<<<
461${answer.slice(-2500)}
462>>>`
463
464const IMPLY_PROMPT = (a: DecisionAsk, q: DecisionQuestion, reply: string) => `[decision-tree: análisis, no es mi respuesta]
465Tu última respuesta terminó así:
466<<<
467${a.context}
468>>>
469Y me preguntabas: «${q.text}»
470Si te respondiera: «${reply}»
471
472¿Qué conlleva esa respuesta? No hagas nada todavía, sólo analiza. Responde en markdown, máximo 10 líneas, en el idioma de la pregunta, con estas partes en negrita: **Qué harías después**, **A favor**, **En contra / riesgos**, **Reversible**.`
473
474const FORK_PROMPT = (a: DecisionAsk, q: DecisionQuestion, reply: string) => `[Rama de prueba de decision-tree: esta NO es mi respuesta definitiva]
475Tu última respuesta terminó así:
476<<<
477${a.context}
478>>>
479A tu pregunta «${q.text}» respondo: «${reply}»
480
481Sigue como si esta fuera mi respuesta real, pero como ensayo: investiga y lee lo que necesites; no modifiques archivos ni ejecutes nada que cambie estado (en esta rama esas herramientas están bloqueadas, así que no lo intentes en bucle). Cuando tengas suficiente, termina con un informe en markdown:
482**Qué haría**: los pasos concretos.
483**Cambios**: archivos y cambios propuestos (diffs cortos si ayudan).
484**Riesgos** y **Lo que aún no sé**.`
485
486// Marks the refusal as this plugin's handoff, for the transcript row that draws it.
487const HANDOFF_MARK = '[decision-tree]'
488const HANDOFF = `${HANDOFF_MARK} La persona va a decidir en el panel lateral de decision-tree, donde ve estas mismas opciones, lo que conlleva cada una y puede probarlas en ramas. Su respuesta llegará como su próximo mensaje. Termina tu turno ahora con una frase breve, sin repetir la pregunta ni las opciones.`
489
490function parseQuestion(text: string): DecisionQuestion | null {
491  const json = text.match(/\{[\s\S]*\}/)?.[0]
492  if (json === undefined) return null
493  try {
494    const value = JSON.parse(json) as { question?: unknown; options?: unknown }
495    if (typeof value.question !== 'string' || !Array.isArray(value.options)) return null
496    const options = value.options
497      .filter((o): o is DecisionOption => typeof o?.label === 'string' && typeof o?.reply === 'string')
498      .slice(0, 6)
499    return options.length > 0 ? { text: value.question, options } : null
500  } catch {
501    return null
502  }
503}
504
505function endsWithQuestion(answer: string): boolean {
506  return /\?/.test(answer.trim().slice(-600))
507}
508
509const slotOf = (id: string, qi: number, oi: number) => `${id}:${qi}:${oi}`
510
511async function settle($: EngineInterface) {
512  await update($, ask, () => null)
513  await update($, phase, () => 'idle')
514  $.ui.status(undefined)
515}
516
517// How many finished forks of earlier asks stay readable in the panel.
518const KEPT_REPORTS = 10
519const STOPPED = 'Rama detenida: la pregunta cambió antes de que terminara.'
520
521/** Stops a fork whose question is gone, best effort: TaskStop takes a background agent by its id. */
522function stopFork($: EngineInterface, agentId: string) {
523  void $.tool.call({ tool: 'TaskStop', task_id: agentId }).catch(() => undefined)
524}
525
526const isFinished = (fork: DecisionFork) => fork.status === 'done' || fork.status === 'error'
527
528/**
529 * Hands the forks of earlier asks over to a new one: a running fork is
530 * stopped, a finished one stays readable (the latest KEPT_REPORTS of them).
531 */
532async function retireForks($: EngineInterface, askId: string) {
533  const isEarlier = (slot: string) => !slot.startsWith(`${askId}:`)
534  const running = Object.entries(await read($, forks)).filter(([slot, fork]) => isEarlier(slot) && fork.status === 'running')
535  for (const [, fork] of running) if (fork.agentId !== undefined) stopFork($, fork.agentId)
536
537  await update($, forks, all => {
538    const entries = Object.entries(all).map(([slot, fork]): [string, DecisionFork] =>
539      isEarlier(slot) && fork.status === 'running' ? [slot, { ...fork, status: 'error', text: STOPPED }] : [slot, fork],
540    )
541    const finished = entries.filter(([slot, fork]) => isEarlier(slot) && isFinished(fork))
542    const dropped = new Set(finished.slice(0, Math.max(0, finished.length - KEPT_REPORTS)).map(([slot]) => slot))
543    return Object.fromEntries(entries.filter(([slot]) => !dropped.has(slot)))
544  })
545}
546
547/** Seats a new ask, clearing the last one's analysis and retiring its forks; resolves whether the pane is drawn. */
548async function present($: EngineInterface, next: DecisionAsk, isAsked: boolean) {
549  await update($, ask, () => next)
550  await update($, focus, () => 0)
551  await update($, picks, () => ({}))
552  await update($, implications, () => ({}))
553  await retireForks($, next.id)
554  await update($, phase, () => 'ready')
555  const count = next.questions.length > 1 ? `${next.questions.length} preguntas` : 'una pregunta'
556  $.ui.status(`decision-tree: ${count} para explorar · /decision-tree`)
557
558  // Unasked, open only where the pane docks beside the transcript; on the
559  // main screen it would open inline, so the status line points to it instead.
560  const shouldOpen = isAsked || isFullscreen !== false
561  const isPlaced = shouldOpen ? (await $.ui.open({ id: PANE, title: TITLE })).isPlaced : false
562  // A dialog the pane could not take goes back to the native one: nothing to analyse.
563  if (!isAsked || isPlaced) void imply($, next.id, 0, 0)
564  return isPlaced
565}
566
567// The main turn that ended last; undefined once the person spoke after it. A
568// question read for any other turn is stale and dropped.
569let latestTurn: string | undefined
570
571async function readQuestion($: EngineInterface, answer: string, turnId: string) {
572  await update($, phase, () => 'reading')
573  const reply = await $.model.complete({ model: 'haiku', prompt: ASK_PROMPT(answer), maxTokens: 800 })
574  if (latestTurn !== turnId) return
575  const found = reply.isAnswered ? parseQuestion(reply.text) : null
576
577  if (found === null) {
578    await settle($)
579    await update($, phase, () => 'none')
580    return
581  }
582  const context = answer.trim().slice(-2000)
583  await present($, { id: turnId, source: 'text', context, questions: [found] }, false)
584}
585
586async function current($: EngineInterface, id: string) {
587  const a = await read($, ask)
588  return a !== null && a.id === id ? a : null
589}
590
591// Slots whose analysis or fork is being started: a second press of the same
592// option waits for nothing and starts nothing.
593const implying = new Set<string>()
594const branching = new Set<string>()
595
596const reasonOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
597
598/** Analyses an option once; a failure is shown as such, never left loading. */
599async function imply($: EngineInterface, id: string, qi: number, oi: number) {
600  const slot = slotOf(id, qi, oi)
601  if (implying.has(slot)) return
602  implying.add(slot)
603  try {
604    await analyse($, id, qi, oi, slot)
605  } catch (error) {
606    const failed: DecisionImplication = { status: 'error', text: `No se pudo analizar (${reasonOf(error)}).` }
607    await update($, implications, all => ({ ...all, [slot]: failed })).catch(() => undefined)
608  } finally {
609    implying.delete(slot)
610  }
611}
612
613async function analyse($: EngineInterface, id: string, qi: number, oi: number, slot: string) {
614  const a = await current($, id)
615  const q = a?.questions[qi]
616  const option = q?.options[oi]
617  if (a === null || q === undefined || option === undefined) return
618  if ((await read($, implications))[slot] !== undefined) return
619
620  const loading: DecisionImplication = { status: 'loading', text: '' }
621  await update($, implications, all => ({ ...all, [slot]: loading }))
622  const r = await $.model.fork({ prompt: IMPLY_PROMPT(a, q, option.reply) })
623  if ((await current($, id)) === null) return
624
625  const result: DecisionImplication = r.isAnswered
626    ? { status: 'ready', text: r.text }
627    : { status: 'error', text: `No se pudo analizar (${r.reason}).` }
628  await update($, implications, all => ({ ...all, [slot]: result }))
629}
630
631async function rememberFork($: EngineInterface, agentId: string) {
632  knownForks.add(agentId)
633  await update($, forkIds, ids => (ids.includes(agentId) ? ids : [...ids, agentId]))
634}
635
636/** Whether the agent is a fork this plugin started, its id handed back yet or not. */
637async function isOwnFork($: EngineInterface, agentId: string): Promise<boolean> {
638  if (knownForks.has(agentId)) return true
639  if (foreignAgents.has(agentId)) return false
640  if ((await read($, forkIds)).includes(agentId)) {
641    knownForks.add(agentId)
642    return true
643  }
644  const listed = (await $.agent.list()).find(one => one.id === agentId)
645  if (listed?.spawnedBy === 'decision-tree') {
646    await rememberFork($, agentId)
647    return true
648  }
649  if (listed !== undefined) {
650    foreignAgents.add(agentId)
651    return false
652  }
653  // Listed by nobody yet: while a spawn of this plugin is under way, it may
654  // be that fork. Not remembered either way; the next call asks again.
655  return pendingSpawns > 0
656}
657
658/** Starts one fork for an option; a second press while it starts does nothing. */
659async function branch($: EngineInterface, id: string, qi: number, oi: number) {
660  const slot = slotOf(id, qi, oi)
661  if (branching.has(slot)) return
662  branching.add(slot)
663  try {
664    await startFork($, id, qi, oi, slot)
665  } finally {
666    branching.delete(slot)
667  }
668}
669
670async function startFork($: EngineInterface, id: string, qi: number, oi: number, slot: string) {
671  const a = await current($, id)
672  const q = a?.questions[qi]
673  const option = q?.options[oi]
674  if (a === null || q === undefined || option === undefined) return
675  const existing = (await read($, forks))[slot]
676  if (existing !== undefined && existing.status !== 'error') return
677
678  const about = { label: option.label, question: q.text }
679  const setFork = (fork: DecisionFork) => update($, forks, all => ({ ...all, [slot]: { ...about, ...fork } }))
680  await setFork({ status: 'starting', text: '' })
681  // Held until the id is remembered: the fork may call tools before then.
682  pendingSpawns += 1
683  try {
684    const spawned = await $.agent.spawn({
685      subagentType: 'fork',
686      description: `Rama: ${option.label}`.slice(0, 60),
687      prompt: FORK_PROMPT(a, q, option.reply),
688    })
689    if (spawned.deny !== undefined || spawned.agentId === undefined) {
690      await setFork({ status: 'error', text: `La rama no arrancó: ${spawned.deny ?? 'sin id de agente'}` })
691      return
692    }
693    const agentId = spawned.agentId
694    await rememberFork($, agentId)
695    // Its question was replaced while it started: nobody waits for it now.
696    if ((await current($, id)) === null) {
697      stopFork($, agentId)
698      await setFork({ status: 'error', agentId, text: STOPPED })
699      return
700    }
701    await setFork({ status: 'running', agentId, text: '' })
702  } catch (error) {
703    await setFork({ status: 'error', text: `La rama no arrancó: ${String(error)}` })
704  } finally {
705    pendingSpawns -= 1
706  }
707}
708
709async function confirm($: EngineInterface, id: string, mode: 'send' | 'edit') {
710  const a = await current($, id)
711  if (a === null) return
712  const text = composeAnswer(a, await read($, picks))
713  if (mode === 'edit') {
714    await $.prompt.fill({ text })
715    return
716  }
717  await settle($)
718  await $.prompt.submit({ text, asUser: true })
719}
720
721export const register: Register = on => {
722  on('session.start', async ($, e, next) => {
723    await $.command.register({
724      name: 'decision-tree',
725      description: 'Abre el panel de la última pregunta: opciones, lo que conllevan y ramas de prueba',
726    })
727    return next(e)
728  })
729
730  on('command.run', { command: 'decision-tree' }, async $ => {
731    await $.ui.open({ id: PANE, title: TITLE, focus: true })
732    return { text: 'Panel de decision-tree abierto.' }
733  })
734
735  // The model's own question dialog: where the panel can be drawn, the panel
736  // takes the questions and the answer comes back as the person's next message.
737  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
738    if (e.agentId !== undefined || e.tool !== 'AskUserQuestion') return next(e)
739    const questions = fromToolQuestions(e.questions)
740    if (questions === null) return next(e)
741
742    const context = questions
743      .map(q => `${q.text}\n${q.options.map(o => `- ${o.label}${o.detail ? `: ${o.detail}` : ''}`).join('\n')}`)
744      .join('\n\n')
745    const isPlaced = await present($, { id: e.tool_use_id, source: 'tool', context, questions }, true)
746    if (!isPlaced) {
747      await settle($)
748      return next(e)
749    }
750    return { deny: HANDOFF }
751  })
752
753  on('turn.complete', async ($, e, next) => {
754    if (e.agentId !== undefined) {
755      // A fork's answer is its report, kept with the fork even when its ask is gone.
756      const agentId = e.agentId
757      const all = await read($, forks)
758      const slot = Object.keys(all).find(key => all[key]?.agentId === agentId)
759      const was = slot === undefined ? undefined : all[slot]
760      if (slot !== undefined && was !== undefined) {
761        if (e.reason === 'answer') {
762          const done: DecisionFork = { ...was, status: 'done', agentId, text: e.answer }
763          await update($, forks, now => ({ ...now, [slot]: done }))
764          $.ui.toast('decision-tree: rama lista, mírala en el panel')
765        } else if (was.status !== 'error') {
766          // One already marked (stopped when its question changed) keeps its line.
767          const failed: DecisionFork = { ...was, status: 'error', agentId, text: `La rama terminó sin respuesta (${e.reason}).` }
768          await update($, forks, now => ({ ...now, [slot]: failed }))
769        }
770      }
771      return next(e)
772    }
773
774    // Any reading still under way is for an older turn now.
775    latestTurn = e.turnId
776
777    // An AskUserQuestion handed to the panel stays until it is answered.
778    const pending = await read($, ask)
779    if (pending?.source === 'tool') return next(e)
780
781    if (e.reason === 'answer' && endsWithQuestion(e.answer)) {
782      const turnId = e.turnId
783      void readQuestion($, e.answer, turnId).catch(() =>
784        latestTurn === turnId ? update($, phase, () => 'none') : undefined,
785      )
786    }
787    return next(e)
788  })
789
790  on('prompt.submit', async ($, e, next) => {
791    // A fork's completion would otherwise wake the main conversation with its
792    // report before the person chose anything; the panel shows it instead.
793    // Dropped only for a fork the panel holds: any other reaches the conversation.
794    if (e.origin?.kind === 'task-notification') {
795      const held = Object.values(await read($, forks)).some(
796        fork => fork.agentId !== undefined && e.text.includes(fork.agentId),
797      )
798      if (held) return { drop: 'decision-tree: la rama terminó; su informe está en el panel (/decision-tree).' }
799      return next(e)
800    }
801    // The person answered on their own (at the prompt, from the phone or as
802    // the SDK host): whatever question the panel holds is settled, and one
803    // still being read is dropped. A slash command is no answer: /decision-tree
804    // is how the person opens the question they are about to answer.
805    const kind = e.origin?.kind
806    const isPerson = kind === undefined || kind === 'composer' || kind === 'bridge' || kind === 'sdk'
807    if (isPerson && !e.text.trimStart().startsWith('/')) {
808      latestTurn = undefined
809      if ((await read($, ask)) !== null || (await read($, phase)) === 'reading') await settle($)
810    }
811    return next(e)
812  })
813
814  const forkDeny = (tool: string) => ({
815    deny: `decision-tree: esta es una rama de prueba; ${tool} no está permitido aquí. Describe el cambio en tu informe en lugar de aplicarlo.`,
816  })
817
818  on('tool.call', async ($, e, next) => {
819    if (e.agentId === undefined) return next(e)
820    if (!(await isOwnFork($, e.agentId))) return next(e)
821
822    const tool = String(e.tool)
823    if (forkMayCall(tool, { command: 'command' in e ? e.command : undefined })) return next(e)
824    return forkDeny(tool)
825  }).catch(($, e, next) => {
826    // Judged from what this module holds: $ may not answer here.
827    if (next.called || e.agentId === undefined || foreignAgents.has(e.agentId)) return next(e)
828    const tool = String(e.tool)
829    if (knownForks.has(e.agentId)) {
830      return forkMayCall(tool, { command: 'command' in e ? e.command : undefined }) ? next(e) : forkDeny(tool)
831    }
832    if (pendingSpawns > 0) {
833      return { deny: 'decision-tree: no se pudo comprobar si esta llamada viene de una rama de prueba.' }
834    }
835    return next(e)
836  })
837
838  // The handed-off dialog's transcript row: a card pointing to the panel in
839  // place of the refusal text the model reads.
840  const isHandoff = (output: unknown) => typeof output === 'string' && (output.includes(HANDOFF_MARK) || output.includes('panel lateral de decision-tree'))
841
842  on('ui.render', { component: 'ToolUse', props: { tool: 'AskUserQuestion' } }, async ($, e, next) => {
843    if (!e.props.isErrored || !isHandoff(e.props.output)) return next(e)
844    const { Box, Text } = $.ui.resolve(e)
845    const input = e.props.input as { questions?: readonly AskedQuestion[] } | undefined
846    const headers = (input?.questions ?? []).map((q, i) => q.header ?? `Pregunta ${i + 1}`)
847
848    return (
849      <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
850        <Text>
851          <Text color="claude" bold>◆ decision-tree</Text>
852          <Text dimColor>  {headers.length === 1 ? '1 pregunta' : `${headers.length} preguntas`} en el panel lateral</Text>
853        </Text>
854        <Text wrap="wrap">
855          {headers.map((header, i) => (
856            <Text key={`h-${i}`}>
857              <Text color="suggestion">{header}</Text>
858              {i < headers.length - 1 ? <Text dimColor> · </Text> : ''}
859            </Text>
860          ))}
861        </Text>
862        <Text dimColor>Explora, prueba en rama y confirma desde el panel →</Text>
863      </Box>
864    )
865  })
866
867  on('ui.render', { component: 'ToolResult', props: { tool: 'AskUserQuestion' } }, async ($, e, next) => {
868    if (!e.props.isErrored || !isHandoff(e.props.output)) return next(e)
869    const { Box } = $.ui.resolve(e)
870    return <Box display="none" />
871  })
872
873  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
874    isFullscreen = e.viewport?.isFullscreen ?? isFullscreen
875    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
876    const a = await read($, ask)
877    const state = await read($, phase)
878    const allForks = await read($, forks)
879    const width = e.props.bodyColumns
880
881    // The finished forks of earlier asks, newest first: their reports stay readable.
882    const earlier = Object.entries(allForks)
883      .filter(([slot, f]) => (a === null || !slot.startsWith(`${a.id}:`)) && isFinished(f))
884      .slice(-3)
885      .reverse()
886    const earlierBox =
887      earlier.length === 0 ? null : (
888        <Box flexDirection="column" borderStyle="round" borderColor="subtle" borderDimColor paddingX={1}>
889          <Text dimColor bold>⑂ Ramas anteriores</Text>
890          {earlier.map(([, f], i) => (
891            <Box key={`prev-${i}`} flexDirection="column" marginTop={1}>
892              <Text wrap="wrap">
893                <Text bold>{f.label ?? 'Rama'}</Text>
894                {f.question !== undefined ? <Text dimColor> · {f.question}</Text> : ''}
895              </Text>
896              <Markdown key={`prev-report-${i}`} text={f.text} dimColor={f.status === 'error'} />
897            </Box>
898          ))}
899        </Box>
900      )
901
902    const header = (
903      <Box flexDirection="row" justifyContent="space-between">
904        <Text>
905          <Text color="claude" bold>◆ decision-tree</Text>
906        </Text>
907        {a !== null && (
908          <Text dimColor>{a.source === 'tool' ? 'pregunta de Claude' : 'pregunta final'}</Text>
909        )}
910      </Box>
911    )
912
913    if (a === null) {
914      const message =
915        state === 'reading' ? '◐ Leyendo la pregunta…' : 'Sin pregunta pendiente. Cuando Claude te pregunte algo, aparecerá aquí.'
916      return (
917        <Box flexDirection="column" gap={1} width={width}>
918          {header}
919          <Box borderStyle="round" borderColor="subtle" paddingX={1}>
920            <Text dimColor wrap="wrap">{message}</Text>
921          </Box>
922          {earlierBox}
923        </Box>
924      )
925    }
926
927    const qi = Math.min(await read($, focus), a.questions.length - 1)
928    const q = a.questions[qi]
929    if (q === undefined) return header
930    const chosen = await read($, picks)
931    const oi = Math.min(chosen[qi] ?? 0, q.options.length - 1)
932    const option = q.options[oi]
933    const slot = slotOf(a.id, qi, oi)
934    const implication = (await read($, implications))[slot]
935    const fork = allForks[slot]
936
937    const showQuestion = (i: number) => {
938      void update($, focus, () => i)
939      void read($, picks).then(now => imply($, a.id, i, now[i] ?? 0))
940    }
941    const choose = (i: number) => {
942      void update($, picks, now => ({ ...now, [qi]: i }))
943      void imply($, a.id, qi, i)
944    }
945    const addCustom = (text: string) => {
946      const reply = text.trim()
947      if (reply.length === 0) return
948      const added = { label: reply.length > 40 ? `${reply.slice(0, 39)}…` : reply, reply }
949      void update($, ask, now => {
950        if (now === null || now.id !== a.id) return now
951        const questions = now.questions.map((one, i) => (i === qi ? { ...one, options: [...one.options, added] } : one))
952        return { ...now, questions }
953      }).then(() => choose(q.options.length))
954    }
955
956    const chip = (f: DecisionFork | undefined) => {
957      if (f === undefined) return null
958      if (f.status === 'done') return <Text color="success"> ✓ rama lista</Text>
959      if (f.status === 'error') return <Text color="error"> ✗ rama falló</Text>
960      return <Text color="warning"> ◐ rama en curso</Text>
961    }
962
963    // Mobile draws no Input: there the options offered are the choice.
964    let custom = null
965    if (e.surface !== 'mobile') {
966      const { Input } = $.ui.resolve(e)
967      custom = (
968        <Box borderStyle="round" borderColor="subtle" borderDimColor paddingX={1}>
969          <Input
970            key="custom"
971            label="✎ Otra respuesta"
972            placeholder="Escribe la tuya y pulsa Enter"
973            submitLabel="Añadir"
974            onSubmit={addCustom}
975          />
976        </Box>
977      )
978    }
979
980    return (
981      <Box flexDirection="column" gap={1} width={width}>
982        {header}
983
984        {a.questions.length > 1 && (
985          <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
986            {a.questions.map((one, i) => {
987              const isDone = chosen[i] !== undefined
988              return (
989                <Button key={`q-${i}`} variant={i === qi ? 'primary' : 'secondary'} onPress={() => showQuestion(i)}>
990                  {isDone ? '✓ ' : ''}
991                  {one.header ?? `Pregunta ${i + 1}`}
992                </Button>
993              )
994            })}
995          </Box>
996        )}
997
998        <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
999          <Text bold wrap="wrap">{q.text}</Text>
1000          <Box flexDirection="column" marginTop={1}>
1001            {q.options.map((o, i) => {
1002              const isPicked = i === oi
1003              return (
1004                <Box
1005                  key={`row-${i}`}
1006                  flexDirection="column"
1007                  borderStyle="round"
1008                  borderColor={isPicked ? 'suggestion' : 'subtle'}
1009                  borderDimColor={!isPicked}
1010                  paddingX={1}
1011                  hover={{ borderColor: 'suggestion' }}
1012                >
1013                  <Button key={`opt-${i}`} plain onPress={() => choose(i)}>
1014                    <Text color={isPicked ? 'suggestion' : 'inactive'}>{isPicked ? '● ' : '○ '}</Text>
1015                    <Text bold={isPicked}>{o.label}</Text>
1016                    {chip(allForks[slotOf(a.id, qi, i)])}
1017                  </Button>
1018                  {o.detail !== undefined && o.detail.length > 0 && (
1019                    <Text dimColor wrap="wrap">  {o.detail}</Text>
1020                  )}
1021                </Box>
1022              )
1023            })}
1024          </Box>
1025        </Box>
1026
1027        {option !== undefined && (
1028          <Box flexDirection="column" borderStyle="round" borderColor="permission" paddingX={1}>
1029            <Text color="permission" bold>⌥ Lo que conlleva · {option.label}</Text>
1030            {option.reply !== option.label && <Text dimColor wrap="wrap">Respuesta: «{option.reply}»</Text>}
1031            {implication === undefined || implication.status === 'loading' ? (
1032              <Text color="warning">◐ Analizando…</Text>
1033            ) : (
1034              <Markdown key="implication" text={implication.text} dimColor={implication.status === 'error'} />
1035            )}
1036          </Box>
1037        )}
1038
1039        {option !== undefined && (
1040          <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
1041            <Button key="try" onPress={() => void branch($, a.id, qi, oi)}>
1042              ⑂ Probar en rama
1043            </Button>
1044            <Button key="send" variant="primary" onPress={() => void confirm($, a.id, 'send')}>
1045              {a.questions.length > 1 ? '✓ Confirmar todas' : '✓ Confirmar'}
1046            </Button>
1047            <Button key="edit" onPress={() => void confirm($, a.id, 'edit')}>
1048              ✎ Editar
1049            </Button>
1050          </Box>
1051        )}
1052
1053        {fork !== undefined && (
1054          <Box
1055            flexDirection="column"
1056            borderStyle={fork.status === 'done' ? 'round' : 'dashed'}
1057            borderColor={fork.status === 'done' ? 'success' : fork.status === 'error' ? 'error' : 'warning'}
1058            paddingX={1}
1059          >
1060            <Text bold color={fork.status === 'done' ? 'success' : fork.status === 'error' ? 'error' : 'warning'}>
1061              ⑂ Rama de prueba · {option?.label}
1062            </Text>
1063            {fork.status === 'starting' && <Text dimColor>◐ Arrancando la rama…</Text>}
1064            {fork.status === 'running' && <Text dimColor>◐ Trabajando en sólo lectura. Te aviso al terminar.</Text>}
1065            {(fork.status === 'done' || fork.status === 'error') && (
1066              <Markdown key="fork" text={fork.text} dimColor={fork.status === 'error'} />
1067            )}
1068          </Box>
1069        )}
1070
1071        {custom}
1072
1073        {earlierBox}
1074      </Box>
1075    )
1076  })
1077}
1078
types/index.d.ts 49 lines
1export type DecisionOption = {
2  label: string
3  /** What is sent as the person's answer when this option is confirmed. */
4  reply: string
5  /** The asker's own line on the option, when it gave one. */
6  detail?: string
7}
8
9export type DecisionQuestion = { text: string; header?: string; options: DecisionOption[] }
10
11export type DecisionAsk = {
12  id: string
13  /** `text`: a question closing a reply; `tool`: an AskUserQuestion call. */
14  source: 'text' | 'tool'
15  /** The end of the reply (or the call's questions), for the prompts that reason about it. */
16  context: string
17  questions: DecisionQuestion[]
18}
19
20export type DecisionPhase = 'idle' | 'reading' | 'ready' | 'none'
21
22export type DecisionImplication = { status: 'loading' | 'ready' | 'error'; text: string }
23
24export type DecisionFork = {
25  status: 'starting' | 'running' | 'done' | 'error'
26  agentId?: string
27  text: string
28  /** The option it tries and the question it answers, to show it once its ask is gone. */
29  label?: string
30  question?: string
31}
32
33declare module 'claude-code' {
34  interface PluginState {
35    'decision-tree': {
36      phase: DecisionPhase
37      ask: DecisionAsk | null
38      /** The question of the ask shown now. */
39      focus: number
40      /** The option picked per question, by the question's index. */
41      picks: Record<string, number>
42      implications: Record<string, DecisionImplication>
43      /** Every fork by slot (`<ask id>:<question>:<option>`), kept past its ask. */
44      forks: Record<string, DecisionFork>
45      forkIds: string[]
46    }
47  }
48}
49