Skip to content

Post-processing hook: pipe finished text through a user script (#259) - #417

Merged
Nabzx merged 6 commits into
mainfrom
post-process-hook-259
Sep 20, 2026
Merged

Nabzx merged 6 commits into
mainfrom
post-process-hook-259

Conversation

@Nabzx

@Nabzx Nabzx commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Closes #259. A post-processing hook: an optional, user-supplied script the finished text is piped through right before delivery.

Scope, decided with @Nabzx before building

Three calls made up front rather than guessed at, since this touches a real security surface:

  1. Shell script only - no separate LLM-prompt hook type. A script subsumes it: anyone who wants a local model in their post-processing step can write a script that calls one. No second process-execution surface, no dependency on the rewrite model server's instruction-following (already found unreliable - Can a prompted local model follow a spoken edit command faithfully enough to ship voice editing? #222's spike).
  2. A path to an existing script file, chosen via a file picker - not an inline shell command typed into Settings. The user writes and maintains the script in their own editor; OpenStream only ever points at it.
  3. Silent fallback on failure, logged only - matches the issue's own "never block delivery" ask.

Full reasoning, including why this is a deliberate arbitrary-code-execution feature and not an oversight, is in ADR-0004.

What's built

  • postProcessHook.js: spawns the script, pipes the finished text in on stdin, collects stdout, enforces a 10s timeout (kills the process on expiry). Rejects on a non-zero exit, a missing/unexecutable script, or the timeout - never decides what a failure means, just reports it.
  • dictationCoordinator.js: the last transform before delivery, after break placement. A throw falls back to the un-hooked text (logged via postProcess.failure, never surfaced). An empty string back from the hook is treated as no-speech rather than delivered as nothing - so a deliberate redaction filter doesn't silently type an empty result. A held delivery carries the hook's output, not the pre-hook text.
  • Settings: postProcessScriptPath (null = off, the default), a file-picker button, a chip showing the current path with a remove button, and a plain-language warning that the script runs with full access.

Verified for real, not compile-only

This session's sandbox can spawn plain subprocesses fine (confirmed separately - it's transcription-helper's CoreML/ANE dependency that gets killed here, not process spawning generally), so postProcessHook.test.js runs against real executable scripts: a real uppercase transform, a real non-zero exit with stderr, a real missing-script rejection, and a real hang killed at the timeout (asserted to reject in well under the script's full sleep, proving the kill actually happened). No manual-check doc needed for the mechanism this time.

Judgement call to flag

The 10s timeout isn't exposed in Settings - a user who wants a slower hook (one that calls out to a local LLM, say) is out of luck until that's revisited. Kept minimal for v1 rather than guessed at; noted in ADR-0004.

Tests

postProcessHook.test.js (8, real subprocesses), dictationCoordinator.test.js (+6, plus 5 pre-existing full-diagnostics-array assertions updated for the new postProcess.applied entry - same class of change as #401's), settingsStore.test.js (+1). Full suite (391) + typecheck + build green.

🤖 Generated with Claude Code

A user-configured script gets the finished dictation on stdin, gets its
stdout back. Kills the process and rejects on timeout (10s default);
rejects on a non-zero exit or a missing/unexecutable script. Never
decides what a failure MEANS for the dictation - that policy lives in
dictationCoordinator.js, which is what gets unit-tested against a fake.
This file is what gets tested against real child processes instead,
since this session's sandbox can actually spawn plain subprocesses
(confirmed separately - unlike transcription-helper, this touches no
CoreML/ANE).

See ADR-0004 for why this is a script path rather than an inline shell
command or a second LLM-prompt hook type.

Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj
null by default - the feature is opt-in. Not checked against the
filesystem at settings-validation time; a moved or deleted script is a
runtime concern for postProcessHook.js (falls back, logged), the same
whether the path came from the file picker or a hand-edited settings
file.

Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj
…#259)

New optional adapter, defaulting to a no-op passthrough so every
existing caller and test sees unchanged behaviour. Runs after break
placement, on the fully-finished text - a "held" outcome from a later
delivery failure carries the hook's output too, not the pre-hook text,
since by that point the hook's result is the real finished text.

A throw (script failure, timeout, a non-string return) falls back to
the un-hooked text rather than failing the dictation - logged via
postProcess.failure, never surfaced. An empty string back from the
hook is treated as no-speech rather than delivered as nothing, so a
deliberate redaction filter doesn't silently type an empty result.

Five pre-existing full-diagnostics-array assertions needed the new
postProcess.applied entry appended - same class of update as #401's.

Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj
postProcessHook is created at module scope, same as dictationIntake
itself - its getScriptPath reads settingsStore fresh on every
dictation, so a change in Settings applies to the very next utterance.

IPC: settings:set-post-process-script (clears when passed null) and
settings:pick-post-process-script (opens a file dialog, sets the path
in one round trip - same pattern as the break-safe app picker).

Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj
A file-picker button when nothing's configured, a chip with the
current path and a remove button once one is. The description says
plainly that the script runs with full access and should only point
at something the user trusts - the mitigation ADR-0004 leans on most,
alongside off-by-default and a file (not a pasted command) being what
gets pointed at.

Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj
@Nabzx
Nabzx merged commit 6d9d9f4 into main Sep 20, 2026
1 check passed
@Nabzx
Nabzx deleted the post-process-hook-259 branch September 20, 2026 09:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Post-processing hooks after cleanup

1 participant