Make silent shell failures loud: a glob that matched nothing, a command missing from the agent's shell, or a pipe that hid a failing exit status gets a note…

█ █ █▀█ █▄ █ █▀▀ █▀ ▀█▀ █▀▀ ▀▄▀ █ ▀█▀
█▀█ █▄█ █ ▀█ ██▄ ▄█ █ ██▄ █ █ █ █ EXIT ▸ 3 CAUGHT

The problem: the shell fails quietly, and the model reads the quiet as success. A zsh glob that matches nothing makes zsh refuse to run the command that holds it. A command that works in your terminal is missing from the agent's shell, because your alias isn't loaded there. A test run piped into tail exits 0 even though the tests failed, because the pipeline's status is tail's. The study behind this library counted 186 zsh "no matches found" errors (each one a command that never ran), exit codes hidden by pipes, and exit=$? echoed by hand ~1,000 times to work around it.
honest-exit reads every Bash result, in the main loop and in subagents. When it spots one of these quiet failures, it attaches a note that only the model reads, explaining what really happened, and shows you a toast. It never blocks a command.
/plugin marketplace add pourya7/claude-code-mods
/plugin install honest-exit@claude-code-mods
| Catch | How it is spotted | What the model is told | ||||
|---|---|---|---|---|---|---|
| GLOB | The shell's own error text has zsh's no matches found: <pattern>, bash's failglob no match: <pattern>, or csh's No match. | The glob didn't match, so the command that contained it never ran. Other commands on the same line (joined by ;, `\ | \ | or newlines) may still have run, so read their output before re-running anything. Check the path, or quote the pattern if a tool such as grep or find` should expand it. | ||
| NOT FOUND | The output has the shell's own command not found line (zsh, bash or sh) for a name people commonly alias: cp, rm, mv, grep, ls, ll, la, l, cat, python, pip. Other missing names are left alone. | It is often an alias or function in your interactive shell, and agent shells may differ. The command that used the name did not run, but other commands on the same line may have, so read their output before re-running anything. Locate the real binary instead of assuming the alias. | ||||
| PIPE | The call exited 0, the output contains a failure word (FAILED, failed, Error: and TypeError:-style names, ✗ or ✖, N failing, Traceback, and Node's test runner summary ℹ fail N / failing tests:), and the command pipes into head, tail, tee or grep, or ends in `\ | \ | true. Every word counts when a test, lint or build command (pytest, npm test, node --test, cargo build, make, and the like) runs before the pipe; for those, the operator: '…' line that ends a Node assertion dump counts too. For any other program only FAILED, N failing, ℹ fail N, failing tests: and Traceback count, and not when the command spells the word itself (a search term). Commands that only print existing text (cat, grep, rg, git, sed, jq and the like) never count: a failure word in a log or a commit message is data. 0 failed, 0 failing and ℹ fail 0 don't count. A pipe behind set -o pipefail doesn't count either, but \ | \ | true` still does. | The pipeline hid the exit status of the first command. Treat the run as failed until it is re-run without the pipe, or with set -o pipefail; and tail (not head, which can turn a pass into status 141). |
GLOB and NOT FOUND read only the shell's own error text: everything the call printed when it failed, and only stderr when it succeeded (zsh carries on to the next command after a failed one, so the line can still show up then). A no matches found line in a file you cat or a search you run is never mistaken for the shell.
The note is attached as the call's context: the model reads it right after the tool result, and you never see it in the transcript. Denied calls, interrupted calls and commands still running in the background are skipped. honest-exit doesn't watch any tool other than Bash.
Rewrites are off by default. With rewrite on, honest-exit fixes two shapes before the command runs, and tells the model exactly what changed:
grep -r --include=*.ts foo . becomes grep -r --include='*.ts' foo ., so the shell can't expand the glob (or refuse to run when nothing matches). Values that are already quoted, or contain $ or a backtick, are left alone. So is anything inside a quoted string. So are brace lists such as --include=*.{ts,tsx}: the shell expands those into one flag per pattern, and grep itself doesn't know braces, so quoting them would make the search match nothing.set -o pipefail; goes in front of a command that pipes a test, lint or build command (pytest, jest, vitest, npm test, cargo build, tsc, eslint, make, and the like) into tail. Never head: head stops reading early, the command before it is killed by SIGPIPE, and with pipefail a passing run would report status 141. A command that already sets pipefail is left alone.Both rewrites are idempotent. A command that has already been rewritten runs as it is, and the model gets no second note.
| Command | What it does |
|---|---|
/honest-exit | Opens the HONEST EXIT pane. The text reply lists the count and each recent catch: time, kind, command and clue. Subagent catches are marked SUBAGENT. |
/honest-exit clear | Resets the count and the list, and clears the status line. |
userConfig)| Field | Type | Default | Meaning |
|---|---|---|---|
rewrite | boolean | false | Before a Bash call runs, quote unquoted globs in --flag=*.x arguments and add set -o pipefail; when a test, lint or build command is piped into tail. |
You can change it in the /config menu, or under pluginConfigs.honest-exit in your settings.
A toast for each catch:
HONEST EXIT ▸ GLOB MATCHED NOTHING: src/**/*.tsx
HONEST EXIT ▸ NOT IN THIS SHELL: ll
HONEST EXIT ▸ PIPE HID A FAILURE: 2 failing
The status line shows a counter once something has been caught (EXIT ▸ 3 CAUGHT). It stays empty until the first catch.
The /honest-exit pane:
█████▄ █ ▶ HONEST EXIT
██████ ▄▄ 3 CAUGHT THIS SESSION
██████▄████▄ REWRITES OFF
██████ ▄▀▀▄ ■ GLOB ■ NOT FOUND ■ PIPE
▄███████▄▄▄▄█▄
────────────────────────────────────────────────────────────
PIPE 12:41 npm test 2>&1 | tail -5 ▸ 2 failing
NOT FOUND 12:38 SUB ll src ▸ ll
GLOB 12:30 wc -l src/**/*.tsx ▸ src/**/*.tsx
[ CLEAR ]
This capture is plain text. In the terminal, the sprite is a green EXIT light over a dark doorway. A peach runner is caught on the way out, under a red alarm. The kinds are coloured too: GLOB orange, NOT FOUND yellow, PIPE red. All the colours come from the PICO-8 palette. VS Code and claude -p draw no pane, so the toasts, the status line and the /honest-exit text reply are what you see there.
| Network | Runs processes | Files | Calls a model | Auto-submits prompts | Changes your commands | Data leaving the machine |
|---|---|---|---|---|---|---|
None. No $.http. | None. No $.process. It only reads the result of the Bash calls the model already made. | None. No $.fs. The count and the last 20 catches live in $.state for the session. Nothing goes to $.store. | No. There is no $.model call: detection is deterministic text matching. | No. There is no $.prompt.submit. | Only with rewrite on: it quotes --flag= globs and adds set -o pipefail;, and every rewrite is reported to the model. Off by default. | Nothing of its own. The notes become part of the tool result your own model already receives. |
These are the engine calls it makes: $.clock.now, $.command.register, $.state.get/set, $.ui.open, $.ui.resolve, $.ui.status and $.ui.toast.
isError), not its exit code. "Exited 0" means the tool did not report an error.FAILED, N failing, ℹ fail N, failing tests:, Traceback), and a check command that prints Error: while passing still gets a note. A false hit adds only a note, never a block.2>&1) and still exits 0 hides the shell's line from them.set -o pipefail works in bash and zsh, which are the shells Claude Code runs commands in. It doesn't work in plain sh.claude plugin validate honest-exit
claude plugin test honest-exit
The pure logic lives in a few files:
hooks/detect.ts: the three detectors, the notes and the outcome reader.hooks/rewrite.ts: the two idempotent rewrites.hooks/shell.ts: quote masking and pipe reading.hooks/sprite.ts: the palette and the half-block renderer.hooks/register.tsx connects them to the engine. The tests cover each detector with positive and negative cases, show that rewriting is off by default, check that a rewrite is idempotent, and mount the pane on both terminal and desktop.
hooks/register.tsx 204 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HonestExitCatch, HonestExitKind } from '../types'
5import { KIND_LABEL, detect, outcomeOf, shortCommand, statusText } from './detect'
6import type { Finding } from './detect'
7import { rewriteCommand, rewriteNote } from './rewrite'
8import { EXIT_SPRITE, PICO8, spriteRuns } from './sprite'
9
10type Engine = EngineInterface
11
12const PLUGIN = 'honest-exit'
13const PANE = 'honest-exit'
14const LOG_SIZE = 20
15
16const CAUGHT = { plugin: 'honest-exit', key: 'caught' } as const
17const caughtAtom = atom(CAUGHT, 0)
18const LOG = { plugin: 'honest-exit', key: 'log' } as const
19const logAtom = atom(LOG, [])
20
21const KIND_COLOR: Record<HonestExitKind, string> = {
22 'no-match': PICO8.o,
23 'not-found': PICO8.y,
24 'hidden-exit': PICO8.r,
25}
26
27const USAGE = [
28 'usage: /honest-exit show what was caught this session (opens the pane)',
29 ' /honest-exit clear reset the count',
30].join('\n')
31
32const two = (value: number) => String(value).padStart(2, '0')
33const clockText = (ms: number) => {
34 const at = new Date(ms)
35 return `${two(at.getHours())}:${two(at.getMinutes())}`
36}
37
38const showStatus = async ($: Engine) => {
39 $.ui.status(statusText(await read($, caughtAtom)))
40}
41
42/** Counts the findings, keeps the newest LOG_SIZE, toasts each and redraws the status. */
43const record = async ($: Engine, command: string, findings: readonly Finding[], agentId: string | undefined) => {
44 try {
45 const at = await $.clock.now()
46 const entries: HonestExitCatch[] = findings.map(finding => ({
47 kind: finding.kind,
48 command: shortCommand(command),
49 clue: finding.clue,
50 at,
51 ...(agentId !== undefined ? { agentId } : {}),
52 }))
53 await update($, caughtAtom, count => count + findings.length)
54 await update($, logAtom, log => [...log, ...entries].slice(-LOG_SIZE))
55 await showStatus($)
56 } catch {
57 // Counting is decoration; the model note below is the point.
58 }
59 for (const finding of findings) $.ui.toast(finding.toast)
60}
61
62const clear = async ($: Engine) => {
63 await $.state.set(CAUGHT, 0)
64 await $.state.set(LOG, [])
65 await showStatus($)
66}
67
68const summaryText = async ($: Engine, isRewriteOn: boolean): Promise<string> => {
69 const caught = await read($, caughtAtom)
70 const log = await read($, logAtom)
71 const lines = [`HONEST EXIT ▸ ${caught} CAUGHT · rewrites ${isRewriteOn ? 'ON' : 'OFF'}`]
72 if (log.length === 0) lines.push(' ALL CLEAR: no quiet shell failures this session')
73 for (const entry of log) {
74 const who = entry.agentId !== undefined ? ' SUBAGENT' : ''
75 lines.push(` ${clockText(entry.at)} ${KIND_LABEL[entry.kind].padEnd(9)}${who} ${entry.command} (${entry.clue})`)
76 }
77 return lines.join('\n')
78}
79
80const openPane = async ($: Engine) => {
81 try {
82 await $.ui.open({ id: PANE, title: 'HONEST EXIT' })
83 } catch {
84 // No surface places panes (a -p run): the command's text reply stands.
85 }
86}
87
88export const register: Register = (on, options) => {
89 const isRewriteOn = options.rewrite === true
90
91 on('session.start', async ($, e, next) => {
92 try {
93 await $.command.register({
94 name: PLUGIN,
95 description: 'Show the quiet shell failures caught this session',
96 argumentHint: '[clear]',
97 })
98 await showStatus($)
99 } catch {
100 $.ui.toast('HONEST EXIT: /honest-exit could not be registered')
101 }
102 return next(e)
103 })
104
105 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
106 const notes: string[] = []
107 let input = e
108 if (isRewriteOn && typeof e.command === 'string') {
109 const rewrite = rewriteCommand(e.command)
110 if (rewrite.changes.length > 0) {
111 input = { ...e, command: rewrite.command }
112 notes.push(rewriteNote(e.command, rewrite))
113 }
114 }
115
116 const ran = await next(input)
117 if (ran.deny !== undefined) return notes.length > 0 ? { deny: [ran.deny, ...notes].join('\n') } : ran
118
119 let findings: Finding[] = []
120 try {
121 const outcome = outcomeOf(ran)
122 if (outcome !== undefined) findings = detect(input.command, outcome)
123 } catch {
124 findings = []
125 }
126 if (findings.length > 0) await record($, input.command, findings, e.agentId)
127
128 const context = [...notes, ...findings.map(finding => finding.note)]
129 if (context.length === 0) return ran
130 return { ...ran, context: [...(ran.context ?? []), ...context] } as typeof ran
131 })
132
133 on('command.run', { command: PLUGIN }, async ($, e) => {
134 const word = e.args.trim().toLowerCase()
135 if (word === 'clear') {
136 await clear($)
137 return { text: await summaryText($, isRewriteOn) }
138 }
139 if (word !== '') return { text: USAGE }
140 await openPane($)
141 return { text: await summaryText($, isRewriteOn) }
142 })
143
144 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
145 const { Box, Text, Button } = $.ui.resolve(e)
146 const caught = await read($, caughtAtom)
147 const log = await read($, logAtom)
148 const width = Math.max(24, e.props.bodyColumns)
149 const sprite = spriteRuns(EXIT_SPRITE)
150
151 return (
152 <Box flexDirection="column" width={width}>
153 <Box flexDirection="row" gap={2}>
154 <Box flexDirection="column">
155 {sprite.map((row, y) => (
156 <Box key={`exit-row-${y}`} flexDirection="row">
157 {row.map(run => (
158 <Text color={run.color} backgroundColor={run.backgroundColor}>
159 {run.text}
160 </Text>
161 ))}
162 </Box>
163 ))}
164 </Box>
165 <Box flexDirection="column">
166 <Text bold color={PICO8.e}>
167 {'▶ HONEST EXIT'}
168 </Text>
169 <Text color={PICO8.w}>{`${caught} CAUGHT THIS SESSION`}</Text>
170 <Text color={PICO8.l}>{`REWRITES ${isRewriteOn ? 'ON' : 'OFF'}`}</Text>
171 <Box flexDirection="row" gap={1}>
172 <Text color={PICO8.o}>■ GLOB</Text>
173 <Text color={PICO8.y}>■ NOT FOUND</Text>
174 <Text color={PICO8.r}>■ PIPE</Text>
175 </Box>
176 </Box>
177 </Box>
178 <Text color={PICO8.d}>{'─'.repeat(Math.min(width, 60))}</Text>
179 {log.length === 0 && <Text color={PICO8.i}>ALL CLEAR · NO QUIET FAILURES YET</Text>}
180 {log
181 .slice()
182 .reverse()
183 .map((entry, index) => (
184 <Box key={`catch-${index}`} flexDirection="row" gap={1}>
185 <Text color={KIND_COLOR[entry.kind]}>{KIND_LABEL[entry.kind].padEnd(9)}</Text>
186 <Text color={PICO8.l}>{clockText(entry.at)}</Text>
187 {entry.agentId !== undefined && <Text color={PICO8.v}>SUB</Text>}
188 <Box flexShrink={1}>
189 <Text color={PICO8.w} wrap="truncate-end">
190 {`${entry.command} ▸ ${entry.clue}`}
191 </Text>
192 </Box>
193 </Box>
194 ))}
195 {log.length > 0 && (
196 <Box flexDirection="row" marginTop={1}>
197 <Button key="honest-exit-clear" label="CLEAR" onPress={() => clear($)} />
198 </Box>
199 )}
200 </Box>
201 )
202 })
203}
204hooks/detect.ts 198 lines1// The three quiet shell failures honest-exit catches, as pure functions of a
2// Bash command and what it printed.
3
4import type { HonestExitKind } from '../types'
5import { hasPipefail, isCheckCommand, isReader, maskQuoted, pipesInto, producersBefore, simpleCommands } from './shell'
6
7export type Finding = {
8 kind: HonestExitKind
9 /** What gave it away: the glob, the missing name, or the failure word. */
10 clue: string
11 /** The model-visible note. */
12 note: string
13 /** The toast the user sees. */
14 toast: string
15}
16
17export type Outcome = {
18 isError: boolean
19 /** Everything the model read: stdout and stderr together. */
20 output: string
21 /** The stderr part alone, when the call succeeded and the tool kept it apart. */
22 stderr?: string
23}
24
25// ── glob matched nothing ─────────────────────────────────────────────────
26
27const ZSH_NO_MATCH = /^(?:[\w./()-]+:\s*)?(?:\d+:\s*)?no matches found: (.+)$/m
28const BASH_NO_MATCH = /^(?:[\w./-]+:\s*)?(?:line \d+:\s*)?no match: (.+)$/m
29const CSH_NO_MATCH = /^No match\.?$/m
30
31/** The glob the shell refused to expand, `(a glob)` when it did not say which. */
32export const noMatchClue = (output: string): string | undefined => {
33 const zsh = ZSH_NO_MATCH.exec(output)?.[1] ?? BASH_NO_MATCH.exec(output)?.[1]
34 if (zsh !== undefined) return zsh.trim()
35 return CSH_NO_MATCH.test(output) ? '(a glob)' : undefined
36}
37
38// ── command not found ────────────────────────────────────────────────────
39
40/** Names people commonly alias or wrap in their interactive shell. */
41export const ALIAS_PRONE = ['cp', 'rm', 'mv', 'grep', 'ls', 'll', 'la', 'l', 'cat', 'python', 'pip'] as const
42
43const NOT_FOUND = [
44 /^(?:[\w./()-]+:\s*)?(?:\d+:\s*)?command not found: (\S+)\s*$/m, // zsh
45 /^(?:[\w./-]+:\s*)(?:line \d+:\s*)?(\S+): command not found\s*$/m, // bash
46 /^(?:[\w./-]+:\s*)(?:\d+:\s*)(\S+): not found\s*$/m, // dash / sh
47]
48
49/** The name a shell said it could not find, if the output has a shell's own error line. */
50export const notFoundName = (output: string): string | undefined => {
51 for (const pattern of NOT_FOUND) {
52 const name = pattern.exec(output)?.[1]
53 if (name !== undefined) return name
54 }
55 return undefined
56}
57
58// ── exit status hidden by a pipe or || true ─────────────────────────────
59
60export const FILTERS = ['head', 'tail', 'tee', 'grep', 'egrep', 'fgrep'] as const
61
62// `strong` signatures come only from a crashed program or a test runner's
63// summary; the others are ordinary words in logs, commit messages and code.
64const SIGNATURES: { pattern: RegExp; clue: (hit: RegExpExecArray) => string; isStrong: boolean }[] = [
65 { pattern: /\bFAILED\b/, clue: () => 'FAILED', isStrong: true },
66 { pattern: /\b([1-9]\d*) failing\b/, clue: hit => `${hit[1]} failing`, isStrong: true },
67 // node --test's summary (`ℹ fail 1`, `# fail 1` under TAP) and its `✖ failing tests:` list.
68 { pattern: /^[ℹ#] fail ([1-9]\d*)$/m, clue: hit => `fail ${hit[1]}`, isStrong: true },
69 { pattern: /\bfailing tests:/, clue: () => 'failing tests:', isStrong: true },
70 { pattern: /(?<!\b0 )\bfailed\b/, clue: () => 'failed', isStrong: false },
71 // `Error:`, `TypeError:`, `AssertionError [ERR_ASSERTION]:`.
72 { pattern: /\w*Error(?: \[[A-Z_]+\])?:/, clue: hit => hit[0], isStrong: false },
73 // The tail of a Node AssertionError dump, all a short `tail` may show of it.
74 { pattern: /^\s+operator: '\w+',?$/m, clue: hit => hit[0].trim().replace(/,$/, ''), isStrong: false },
75 { pattern: /[✗✖]/, clue: hit => hit[0], isStrong: false },
76 { pattern: /^Traceback \(most recent call last\)/m, clue: () => 'Traceback', isStrong: true },
77]
78
79export const pipesIntoFilter = (command: string): boolean => pipesInto(command, FILTERS)
80
81/** Ends in `|| true` or `|| :`, so any failure before it exits 0. */
82export const swallowsStatus = (command: string): boolean =>
83 /\|\|\s*(?:true|:)\s*;?\s*$/.test(maskQuoted(command))
84
85/**
86 * The first failure signature in `output`, if it ran behind a status-hiding pipe or `|| true` and exited 0.
87 *
88 * A test, lint or build command before the pipe counts every signature. Any
89 * other program counts only the strong ones, and only when the command does
90 * not spell the word out itself (a search term). Commands that just print
91 * existing text (cat, grep, git log...) count nothing: their failure words are data.
92 */
93export const hiddenExitClue = (command: string, output: string, isError: boolean): string | undefined => {
94 if (isError) return undefined
95 const isSwallowed = swallowsStatus(command)
96 // With pipefail, a pipe alone no longer hides a failure; || true still does.
97 const isPiped = pipesIntoFilter(command) && !hasPipefail(command)
98 if (!isSwallowed && !isPiped) return undefined
99 const producers = [
100 ...(isPiped ? producersBefore(command, FILTERS) : []),
101 ...(isSwallowed ? simpleCommands(command).filter(part => !/^(?:true|:)\s*;?$/.test(part)) : []),
102 ]
103 const isCheck = producers.some(isCheckCommand)
104 if (!isCheck && producers.every(isReader)) return undefined
105 let first: { at: number; clue: string } | undefined
106 for (const signature of SIGNATURES) {
107 if (!isCheck && !signature.isStrong) continue
108 const hit = signature.pattern.exec(output)
109 if (hit === null) continue
110 if (!isCheck && command.includes(hit[0])) continue
111 if (first === undefined || hit.index < first.at) first = { at: hit.index, clue: signature.clue(hit) }
112 }
113 return first?.clue
114}
115
116// ── notes ────────────────────────────────────────────────────────────────
117
118const noMatchNote = (glob: string) =>
119 `honest-exit: the shell reported "no matches found" for ${glob}: the glob didn't match, so the command that contained it never ran. ` +
120 'Other commands on the same line (joined by `;`, `||` or newlines) may still have run: read their output before re-running anything. ' +
121 'Check the path, or quote the pattern ' +
122 `('${glob === '(a glob)' ? '*.x' : glob}') if a tool such as grep or find should expand it instead of the shell.`
123
124const notFoundNote = (name: string) =>
125 `honest-exit: "${name}" was not found in this shell. It is often an alias or function in the user's interactive shell, ` +
126 'and agent shells may differ: they do not load interactive aliases, functions or every PATH entry. ' +
127 `The command that used "${name}" did not run, but other commands on the same line (joined by \`;\`, \`||\` or newlines) may have: read their output before re-running anything. ` +
128 `Use the real binary (for example \`command -v ${name}\` to locate it) instead of assuming the alias exists.`
129
130const hiddenExitNote = (clue: string, command: string) => {
131 const how = swallowsStatus(command) ? 'ends in `|| true`' : 'pipes into a filter (head, tail, tee or grep)'
132 return (
133 `honest-exit: the command exited 0, but its output contains "${clue}". It ${how}, so the pipeline hid the exit status of the first command. ` +
134 'Treat this run as failed until proven otherwise: re-run it without the pipe, or with `set -o pipefail;` in front and piped into `tail` (with `head`, the early exit can turn a pass into status 141), and read the real exit status.'
135 )
136}
137
138/** Every quiet failure in one Bash result, in a fixed order. */
139export const detect = (command: string, outcome: Outcome): Finding[] => {
140 const findings: Finding[] = []
141 // The shell's own error lines. A failed call: everything it printed. A call
142 // that succeeded: only stderr, so file contents and search hits on stdout
143 // (a log, this README) are never read as the shell talking.
144 const shellText = outcome.isError ? outcome.output : (outcome.stderr ?? '')
145 const glob = noMatchClue(shellText)
146 if (glob !== undefined) {
147 findings.push({ kind: 'no-match', clue: glob, note: noMatchNote(glob), toast: `HONEST EXIT ▸ GLOB MATCHED NOTHING: ${glob}` })
148 }
149 const name = notFoundName(shellText)
150 if (name !== undefined && (ALIAS_PRONE as readonly string[]).includes(name)) {
151 findings.push({ kind: 'not-found', clue: name, note: notFoundNote(name), toast: `HONEST EXIT ▸ NOT IN THIS SHELL: ${name}` })
152 }
153 const clue = hiddenExitClue(command, outcome.output, outcome.isError)
154 if (clue !== undefined) {
155 findings.push({ kind: 'hidden-exit', clue, note: hiddenExitNote(clue, command), toast: `HONEST EXIT ▸ PIPE HID A FAILURE: ${clue}` })
156 }
157 return findings
158}
159
160// ── labels ───────────────────────────────────────────────────────────────
161
162export const KIND_LABEL: Record<HonestExitKind, string> = {
163 'no-match': 'GLOB',
164 'not-found': 'NOT FOUND',
165 'hidden-exit': 'PIPE',
166}
167
168export const statusText = (caught: number): string | undefined => (caught > 0 ? `EXIT ▸ ${caught} CAUGHT` : undefined)
169
170/** Cuts a command to one short line for the log. */
171export const shortCommand = (command: string, width = 60): string => {
172 const line = command.replace(/\s+/g, ' ').trim()
173 return line.length > width ? `${line.slice(0, width - 1)}…` : line
174}
175
176/**
177 * What a Bash call printed and whether the tool reported an error, read from
178 * the `tool.call` result. Undefined when there is nothing honest to read: a
179 * deny, an interrupted run, or a command still running in the background.
180 */
181export const outcomeOf = (ran: {
182 deny?: string
183 isError?: true
184 result?: unknown
185 text?: string
186}): Outcome | undefined => {
187 if (ran.deny !== undefined) return undefined
188 if (ran.isError === true) {
189 const output = ran.text ?? (typeof ran.result === 'string' ? ran.result : '')
190 return { isError: true, output }
191 }
192 const result = (typeof ran.result === 'object' && ran.result !== null ? ran.result : {}) as Record<string, unknown>
193 if (result.interrupted === true || typeof result.backgroundTaskId === 'string') return undefined
194 const streams = [result.stdout, result.stderr].filter((part): part is string => typeof part === 'string' && part !== '')
195 const output = streams.length > 0 ? streams.join('\n') : (ran.text ?? '')
196 return { isError: false, output, stderr: typeof result.stderr === 'string' ? result.stderr : '' }
197}
198hooks/rewrite.ts 55 lines1// Optional, idempotent rewrites (userConfig.rewrite, off by default).
2
3import { hasPipefail, isCheckCommand, maskQuoted, producersBefore } from './shell'
4
5// `--flag=value` where the value has a glob character and no quote, `$` or backtick.
6const FLAG_GLOB = /(^|\s)(--?[A-Za-z][\w-]*=)([^\s'"$`;|&()<>]*[*?[][^\s'"$`;|&()<>]*)(?=\s|$|;|\||&|\))/g
7
8/** Quotes unquoted glob values in `--include=*.x`-style flags, so the shell leaves them to the tool. */
9export const quoteFlagGlobs = (command: string): string => {
10 const bare = maskQuoted(command)
11 let out = ''
12 let last = 0
13 for (const hit of bare.matchAll(FLAG_GLOB)) {
14 const lead = hit[1] ?? ''
15 const flag = hit[2] ?? ''
16 const start = (hit.index ?? 0) + lead.length + flag.length
17 const value = command.slice(start, start + (hit[3] ?? '').length)
18 // The mask must agree with the original: no hidden quotes inside.
19 if (value !== hit[3]) continue
20 // A brace list (`*.{ts,tsx}`) is the shell's to expand: grep's --include and
21 // most tools' globs don't know braces, so quoting it would match nothing.
22 if (value.includes('{')) continue
23 out += `${command.slice(last, start)}'${value}'`
24 last = start + value.length
25 }
26 return out + command.slice(last)
27}
28
29/**
30 * Prefixes `set -o pipefail;` when a test, lint or build command is piped into tail.
31 * Not head: head exits early, the producer dies of SIGPIPE, and pipefail turns a pass into status 141.
32 */
33export const addPipefail = (command: string): string => {
34 if (hasPipefail(command)) return command
35 const producers = producersBefore(command, ['tail'])
36 return producers.some(isCheckCommand) ? `set -o pipefail; ${command}` : command
37}
38
39export type Rewrite = { command: string; changes: string[] }
40
41/** Both rewrites, with a line per change for the model. Running it on its own output changes nothing. */
42export const rewriteCommand = (command: string): Rewrite => {
43 const changes: string[] = []
44 let next = quoteFlagGlobs(command)
45 if (next !== command) changes.push('quoted the glob in a --flag=pattern argument so the shell does not expand it')
46 const piped = addPipefail(next)
47 if (piped !== next) changes.push('added `set -o pipefail;` so a failing check is not hidden by tail')
48 next = piped
49 return { command: next, changes }
50}
51
52export const rewriteNote = (before: string, rewrite: Rewrite): string =>
53 `honest-exit rewrote this Bash command before it ran (${rewrite.changes.join('; ')}).\n` +
54 `You wrote: ${before}\nIt ran: ${rewrite.command}`
55hooks/sprite.ts 75 lines1// PICO-8 palette and a tiny half-block sprite renderer: two pixel rows per
2// terminal row, the top pixel as the text color, the bottom as the background.
3
4export const PICO8 = {
5 k: '#000000', // black
6 n: '#1D2B53', // navy
7 p: '#7E2553', // plum
8 g: '#008751', // green
9 b: '#AB5236', // brown
10 d: '#5F574F', // dark grey
11 l: '#C2C3C7', // light grey
12 w: '#FFF1E8', // white
13 r: '#FF004D', // red
14 o: '#FFA300', // orange
15 y: '#FFEC27', // yellow
16 i: '#00E436', // lime
17 u: '#29ADFF', // blue
18 v: '#83769C', // lavender
19 m: '#FF77A8', // pink
20 e: '#FFCCAA', // peach (honest-exit's signature)
21} as const
22
23export type Run = { text: string; color?: string; backgroundColor?: string }
24
25const colorOf = (pixel: string | undefined): string | undefined =>
26 pixel === undefined || pixel === '.' ? undefined : (PICO8 as Record<string, string>)[pixel]
27
28/** Turns a grid of palette keys ('.' transparent) into rows of merged runs. */
29export const spriteRuns = (grid: readonly string[]): Run[][] => {
30 const rows: Run[][] = []
31 for (let y = 0; y < grid.length; y += 2) {
32 const top = grid[y] ?? ''
33 const bottom = grid[y + 1] ?? ''
34 const width = Math.max(top.length, bottom.length)
35 const runs: Run[] = []
36 for (let x = 0; x < width; x += 1) {
37 const up = colorOf(top[x])
38 const down = colorOf(bottom[x])
39 const cell: Run =
40 up === undefined && down === undefined
41 ? { text: ' ' }
42 : up === undefined
43 ? { text: '▄', color: down }
44 : down === undefined
45 ? { text: '▀', color: up }
46 : { text: '▀', color: up, backgroundColor: down }
47 const last = runs[runs.length - 1]
48 if (last && last.text[0] === cell.text && last.color === cell.color && last.backgroundColor === cell.backgroundColor) {
49 last.text += cell.text
50 } else {
51 runs.push(cell)
52 }
53 }
54 rows.push(runs)
55 }
56 return rows
57}
58
59/**
60 * A green EXIT light over a dark doorway, and a peach runner caught on the
61 * way out with a red alarm above: 14 x 10 pixels, 5 terminal rows.
62 */
63export const EXIT_SPRITE = [
64 '.giiig....r...',
65 '.gggggg...r...',
66 '.dddddd.......',
67 '.dkkkkd..ee...',
68 '.dkkkkd.ekek..',
69 '.dkkkkdeeeeee.',
70 '.dkkkkd..ee...',
71 '.dkkkkd.e..e..',
72 '.dkkkkde....e.',
73 'dddddddddddddd',
74]
75hooks/shell.ts 95 lines1// Tiny, conservative shell reading: enough to tell quoted text from the
2// shell's own syntax. It is not a parser; when unsure, it says "quoted".
3
4/**
5 * The command with every quoted character (and the quotes) replaced by `_`,
6 * same length, so positions line up with the original. Backslash escapes
7 * outside quotes are blanked too.
8 */
9export const maskQuoted = (command: string): string => {
10 let out = ''
11 let quote: '"' | "'" | undefined
12 for (let index = 0; index < command.length; index += 1) {
13 const char = command[index] ?? ''
14 if (quote === undefined) {
15 if (char === '\\') {
16 out += '__'
17 index += 1
18 } else if (char === "'" || char === '"') {
19 quote = char
20 out += '_'
21 } else {
22 out += char
23 }
24 } else if (quote === '"' && char === '\\') {
25 out += '__'
26 index += 1
27 } else {
28 if (char === quote) quote = undefined
29 out += '_'
30 }
31 }
32 return out.slice(0, command.length)
33}
34
35const pipePattern = (names: readonly string[]) =>
36 new RegExp(`(?<!\\|)\\|&?\\s*(?:${names.join('|')})(?=\\s|$|;|\\))`, 'g')
37
38/** Unquoted pipe (`|` or `|&`, never `||`) into one of `names`. */
39export const pipesInto = (command: string, names: readonly string[]): boolean =>
40 pipePattern(names).test(maskQuoted(command))
41
42const SEPARATOR = /;|&&|\|\||\||\(|\n/g
43
44/** The simple command right before each unquoted pipe into one of `names`. */
45export const producersBefore = (command: string, names: readonly string[]): string[] => {
46 const bare = maskQuoted(command)
47 const producers: string[] = []
48 for (const hit of bare.matchAll(pipePattern(names))) {
49 const pipeAt = hit.index ?? 0
50 let from = 0
51 for (const separator of bare.slice(0, pipeAt).matchAll(SEPARATOR)) {
52 from = (separator.index ?? 0) + separator[0].length
53 }
54 producers.push(command.slice(from, pipeAt).trim())
55 }
56 return producers
57}
58
59/** Already runs with pipefail (`set -o pipefail`, `set -euo pipefail`, zsh `setopt pipefail`). */
60export const hasPipefail = (command: string): boolean =>
61 /\bset\s+-[a-z]*o\s+pipefail\b|\bsetopt\s+pipe_?fail\b/i.test(command)
62
63/** Every simple command in `command`, split on unquoted `;`, `&&`, `||`, `|`, `(` and newlines. */
64export const simpleCommands = (command: string): string[] => {
65 const bare = maskQuoted(command)
66 const parts: string[] = []
67 let from = 0
68 for (const separator of bare.matchAll(SEPARATOR)) {
69 parts.push(command.slice(from, separator.index ?? 0).trim())
70 from = (separator.index ?? 0) + separator[0].length
71 }
72 parts.push(command.slice(from).trim())
73 return parts.filter(part => part !== '' && part !== ')')
74}
75
76// Leading `VAR=x`, and launchers that run the real command after them.
77const LEAD = /^(?:[A-Za-z_]\w*=\S*\s+)*(?:(?:time|env|npx|bunx|uv run|poetry run|pipenv run|bundle exec|(?:pnpm|yarn) exec|python3? -m)\s+)*/
78
79/** The simple command with its quoted text masked and env/launcher prefixes stripped. */
80const commandWords = (simple: string): string => maskQuoted(simple.trim()).replace(LEAD, '')
81
82const CHECK_COMMAND =
83 /^(?:pytest|jest|vitest|mocha|tsc|eslint|ruff|mypy|pyright|rspec|phpunit|go (?:test|build|vet)|cargo (?:test|build|check|clippy)|(?:npm|pnpm|yarn|bun)(?: run)? (?:test|lint|build|typecheck|check)|node --test|make|gradle|\.\/gradlew|mvn|dotnet (?:test|build))(?=[\s:]|$)/
84
85/** A test, lint or build command, judged by its first word(s), not by a word anywhere in it. */
86export const isCheckCommand = (simple: string): boolean => CHECK_COMMAND.test(commandWords(simple))
87
88const READERS = new Set([
89 'cat', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'git', 'gh', 'sed', 'awk', 'jq', 'yq', 'find', 'ls', 'echo', 'printf',
90 'less', 'more', 'head', 'tail', 'sort', 'uniq', 'wc', 'diff', 'cut', 'tr', 'nl', 'zcat', 'journalctl',
91])
92
93/** A command that only prints existing text (files, history, search hits), so failure words in it are data. */
94export const isReader = (simple: string): boolean => READERS.has(commandWords(simple).split(/\s+/)[0] ?? '')
95types/index.d.ts 28 lines1// honest-exit's $.state contract. Values live for the session and survive a
2// hot reload.
3
4export type HonestExitKind = 'no-match' | 'not-found' | 'hidden-exit'
5
6export type HonestExitCatch = {
7 kind: HonestExitKind
8 /** The command as it ran, cut to a short line. */
9 command: string
10 /** What gave it away: the glob, the missing name, or the failure word. */
11 clue: string
12 /** Set when a subagent's call was caught. */
13 agentId?: string
14 /** When it was caught, in ms since the epoch. */
15 at: number
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'honest-exit': {
21 /** How many failures were caught this session. */
22 caught: number
23 /** The most recent catches, newest last. */
24 log: HonestExitCatch[]
25 }
26 }
27}
28