Skip to content

Commit 07dd623

Browse files
authored
feat: add Codex-style lifecycle hooks (#10)
* feat: add hook support * fix: preserve hook exit code on drain timeout * feat: add explicit hook environment forwarding Allow hook configs to forward named parent environment variables and define literal hook environment values while preserving the cleared-env default for credentials. * fix: close turn event channel after agent completes Drop the outer tool runtime context before waiting for the event-drain future so the mpsc channel closes once the agent future returns. This lets turns finish, save transcripts, run stop hooks, and return to raw input mode. * fix: keep hidden reasoning from splitting responses Ignore reasoning deltas for response layout when the reasoning panel is disabled. This prevents models that stream thinking fields from repeatedly closing and reopening the visible response block. * style: satisfy clippy field_reassign_with_default in session_turn test
1 parent d512fc5 commit 07dd623

36 files changed

Lines changed: 6474 additions & 222 deletions

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,10 @@ AGENT_MAX_STEPS=20
6565
# Skip the background GitHub release check. Only outbound call the harness
6666
# makes that isn't user-initiated; opt out if you want zero network at idle.
6767
# SMALL_HARNESS_NO_UPDATE_CHECK=true
68+
69+
# Ephemeral managed hooks for process-owning launchers such as terminal
70+
# orchestrators or agent status dashboards. These must be exported in the
71+
# launcher process; repo .env files are intentionally ignored for managed hooks.
72+
# Prefer the file form for larger hook documents to avoid shell quoting JSON.
73+
# SMALL_HARNESS_MANAGED_HOOKS_FILE=/tmp/agent-status-hooks.json
74+
# SMALL_HARNESS_MANAGED_HOOKS_JSON={"source":"terminal-orchestrator","hooks":{}}

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **Hooks.** `agent.config.json` can define trusted command hooks for session,
12+
prompt, tool, permission, plan, stop, and session-end events. Hooks receive
13+
JSON on stdin, can block/allow/deny/stop, add context or feedback, and are
14+
traced quietly unless they warn or affect execution. Project hooks run only
15+
after their current hash is trusted in user-owned state; project-controlled
16+
hook state is ignored for execution safety. `/hooks` lists and manages trust
17+
state stored under `~/.config/small-harness/`.
18+
- **Managed launch hooks.** Launchers can inject ephemeral trusted hooks with
19+
`SMALL_HARNESS_MANAGED_HOOKS_JSON` or
20+
`SMALL_HARNESS_MANAGED_HOOKS_FILE`, enabling terminal/orchestrator
21+
status and progress integrations without mutating user config. These are
22+
process-local launcher-trusted hooks, not cryptographically signed hooks or
23+
Codex enterprise-managed hooks. Hook commands can now use `envVars` to
24+
forward selected parent process variables and `env` for literal values,
25+
while keeping parent credentials absent by default.
26+
- **Hook hardening.** Gating hook runner failures now fail closed, invalid
27+
matchers are visible but skipped, regex matchers use full-match semantics,
28+
`task` uses the dedicated subagent hook events without generic `PostToolUse`,
29+
hook subprocesses use process-group cleanup on timeout, and hook-provided
30+
model context is bounded and redacted before injection.
31+
932
## [1.0.4] - 2026-06-20
1033

1134
### Added

README.md

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@ this exact call`. The session cache resets on `/new`.
356356
/reasoning on|off toggle the streaming reasoning panel
357357
/verbose on|off show every tool call with its full args + result
358358
/trace on|off show nested subagent/critic tool calls (indented)
359+
/hooks list, trust, enable, or disable configured hooks
359360
/compare [model] re-send the last prompt against OpenRouter for A/B
360361
/fusion on|tool|off use OpenRouter Fusion alias or attach Fusion to a model
361362
/route select|apply select or apply a configured multi-model stack route
@@ -592,6 +593,151 @@ Small Harness spawns each server at startup, lists its tools, and exposes
592593
them through the same approval-gated tool layer with names like
593594
`mcp__fs__read_file`. JSON-RPC over stdio; no extra dependencies.
594595

596+
### Hooks
597+
598+
Hooks let trusted local commands observe or influence harness events. They are
599+
useful for terminal integrations, status tracking, policy checks, and progress
600+
bridges for launchers, terminal orchestrators, and agent status dashboards.
601+
602+
Project hooks live in `agent.config.json`:
603+
604+
```json
605+
{
606+
"hooks": {
607+
"PlanUpdated": [
608+
{
609+
"hooks": [
610+
{ "type": "command", "command": "$HOME/bin/agent-plan-hook" }
611+
]
612+
}
613+
],
614+
"PreToolUse": [
615+
{
616+
"matcher": "shell|file_write",
617+
"hooks": [
618+
{
619+
"type": "command",
620+
"command": "$HOME/bin/check-tool-policy",
621+
"timeoutSec": 5
622+
}
623+
]
624+
}
625+
]
626+
}
627+
}
628+
```
629+
630+
Command hooks receive a JSON payload on stdin and may print JSON on stdout:
631+
632+
```json
633+
{ "decision": "block", "reason": "shell command not allowed" }
634+
```
635+
636+
Supported decisions are `allow`, `deny`, `block`, and `stop`. Hooks can also
637+
return `additionalContext`, `updatedInput`, or `feedback`. For `PreToolUse`,
638+
`updatedInput` is honored only with `{"decision":"allow"}` and is discarded if
639+
any hook blocks, denies, or stops. Exit code `2` maps to a blocking decision using
640+
stderr as the reason. For `PreToolUse` and `PermissionRequest`, hook runner
641+
failures such as timeouts, spawn/pipe failures, and shell infrastructure exits
642+
`126`/`127` fail closed and block gated execution; ordinary nonzero exits still
643+
warn unless the hook explicitly blocks. A pre-execution `stop` prevents other
644+
pending tool calls in the same assistant step from running; a `PostToolUse` stop
645+
applies after that tool has already run and stops the next model step.
646+
647+
Hook events include `SessionStart`, `UserPromptSubmit`, `PreToolUse`,
648+
`PermissionRequest`, `PostToolUse`, `PreCompact`, `PostCompact`,
649+
`PlanUpdated`, `SubagentStart`, `SubagentStop`, `Stop`, and `SessionEnd`.
650+
Payloads include common fields such as `hook_event_name`, `session_id`,
651+
`turn_id`, `cwd`, `workspace_root`, `transcript_path`, `events_path`, `backend`,
652+
`model`, `approval_policy`, and `source`, plus event-specific fields like
653+
`tool_name`, `tool_input`, `tool_response`, and `progress`. `source` is
654+
`interactive`, `one-shot`, `auto`, `fix`, `iterate`, or `play` depending on
655+
what started the turn.
656+
657+
Hook payload stdin is raw and unredacted so trusted hooks can make decisions on
658+
the actual prompt/tool data; do not log it unless your hook performs its own
659+
redaction. Hook child processes start with a cleared environment and receive the
660+
minimal inherited shell environment (`PATH`, `HOME` or Windows home/system vars),
661+
explicit parent process variables listed in `envVars`, literal values from
662+
`env`, plus `SMALL_HARNESS_HOOK_EVENT`, `SMALL_HARNESS_SESSION_ID`,
663+
`SMALL_HARNESS_TURN_ID`, `SMALL_HARNESS_TRANSCRIPT_PATH`, and
664+
`SMALL_HARNESS_EVENTS_PATH`. Parent LLM provider credentials are not passed
665+
through unless a hook explicitly names them in `envVars`.
666+
667+
Matchers are Codex-style: absent, empty, or `*` matches all; exact `|`
668+
alternation matches tool/event names; other matchers are treated as full-match
669+
regexes. Use `.*` when partial regex matching is intended. Invalid matcher
670+
regexes are shown by `/hooks` and skipped. `UserPromptSubmit`, `PlanUpdated`,
671+
`Stop`, and `SessionEnd` ignore matchers. The default hook timeout is 600
672+
seconds for Codex parity; status/progress hooks should set a shorter
673+
`timeoutSec` if a slow hook would make the turn feel stuck.
674+
675+
The `task` tool uses `SubagentStart` and `SubagentStop`; it does not also run
676+
generic `PostToolUse` hooks. `Stop` hook `additionalContext` and `feedback` are
677+
bounded, redacted, and added as context for the next turn.
678+
679+
`PermissionRequest` runs only when the harness would otherwise ask for approval.
680+
Use `PreToolUse` for blanket policy gates that must also cover auto-approved or
681+
read-only tools.
682+
683+
Project hooks are skipped until their current hash is trusted in user-owned
684+
state. Project-controlled `hooks.state` entries are ignored for execution
685+
safety. Manage trust with:
686+
687+
```text
688+
/hooks list hooks and trust state
689+
/hooks trust <key> trust one hook hash in user state
690+
/hooks trust-all trust all new/modified hooks
691+
/hooks disable <key> disable one hook
692+
/hooks enable <key> enable one hook
693+
```
694+
695+
Trust is stored in `$XDG_CONFIG_HOME/small-harness/hooks-state.json`, falling
696+
back to `~/.config/small-harness/hooks-state.json`. Trusted hook successes are
697+
quiet in the normal TUI; warnings, blocks, denies, stops, and feedback are
698+
shown. The event log records hook start/end/decision records with redacted,
699+
bounded stdout/stderr previews.
700+
701+
Launchers can inject ephemeral managed launch hooks without changing user
702+
config:
703+
704+
```bash
705+
SMALL_HARNESS_MANAGED_HOOKS_FILE="$TMPDIR/agent-status-hooks.json" small-harness
706+
```
707+
708+
`SMALL_HARNESS_MANAGED_HOOKS_JSON` accepts the same document inline for small
709+
launchers. Managed launch hooks are not cryptographic signatures or Codex
710+
enterprise-managed hooks; they are process-local launcher-trusted commands for
711+
wrappers that own the process invocation. Small Harness intentionally reads
712+
these only from the real process environment, not repo `.env` files. This lets
713+
integrations observe status without mutating the user's config.
714+
715+
Use `envVars` when a managed hook command needs launcher state:
716+
717+
```json
718+
{
719+
"source": "terminal-orchestrator",
720+
"hooks": {
721+
"Stop": [
722+
{
723+
"hooks": [
724+
{
725+
"type": "command",
726+
"command": "zentty ipc agent-event",
727+
"envVars": [
728+
"ZENTTY_INSTANCE_SOCKET",
729+
"ZENTTY_WORKLANE_ID",
730+
"ZENTTY_PANE_ID",
731+
"ZENTTY_PANE_TOKEN"
732+
]
733+
}
734+
]
735+
}
736+
]
737+
}
738+
}
739+
```
740+
595741
### Image input
596742

597743
`/image <path>` attaches an image to your next prompt. Small Harness encodes
@@ -703,6 +849,8 @@ AGENT_TOOL_SELECTION=auto # auto | fixed
703849
WARMUP=true # pre-warm prompt cache at startup
704850
SMALL_HARNESS_NO_WIZARD=false # skip first-run setup
705851
SMALL_HARNESS_NO_UPDATE_CHECK=false # skip the GitHub release check
852+
SMALL_HARNESS_MANAGED_HOOKS_JSON='{"source":"terminal-orchestrator","hooks":{...}}'
853+
SMALL_HARNESS_MANAGED_HOOKS_FILE=/tmp/agent-status-hooks.json
706854
```
707855

708856
Full list with comments in [`.env.example`](.env.example).
@@ -795,6 +943,11 @@ root. Common shape:
795943
},
796944
"mcpServers": {
797945
"fs": { "command": "/usr/local/bin/some-mcp-server", "args": [] }
946+
},
947+
"hooks": {
948+
"PlanUpdated": [
949+
{ "hooks": [{ "type": "command", "command": "$HOME/bin/plan-hook" }] }
950+
]
798951
}
799952
}
800953
```

0 commit comments

Comments
 (0)