Status: accepted (2026-06-14)
Failures crossed seams as bare strings. A tool error flattened to a text block — name, code, and stack lost — so a future sandbox/retry plugin couldn't tell ENOENT from EACCES, and the model got less actionable feedback than it could. A non-Error throw degraded further: the loop wrapped it in new Error(String(x)), dropping any code. And LlmError was the only typed error in the system, with no shared base, so there was nothing for a consumer to instanceof against generically.
This is the last of the RFC 005 pieces and the one the user was most skeptical of, so it was deliberately built last and in isolation: the earlier PRs (arg validation, dev invariants) threw plain Errors with a code field, decoupled from any shared base, so this change is a pure upgrade and is independently revertible without unpicking them.
A single HarnessError extends Error base in dsh-llm (the leaf package every other imports — no new dependency edge): a stable code distinct from message, cause chaining via ErrorOptions, and name defaulting to the subclass. isHarnessError narrows at seams.
LlmError, ToolArgsError (dsh-tools), and InvariantError (dsh-invariants) now extend it, keeping their existing codes.ToolExecutionResult gains optional error: { name, code }, populated in the registry's catch when the thrown value is a HarnessError. The agent loop forwards it onto the tool/result session event (which gained the same optional field), so the structured failure survives into the log for retry/sandbox plugins and replay. The model-facing text block is unchanged.toError wraps a non-Error throw in a HarnessError (code: 'UNKNOWN', original chained as cause) instead of a bare Error, so even a bad throw carries a routable code into the session error event (which already surfaced code).error.code rather than substring-matching a message.deriveMessages does not surface error into model history — the model still sees the text block; the structured field is for code and replay.Error+code form; nothing else in the stack depends on the shared base.