Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Claude batched tool calls under an open permission prompt

Date: 2026-08-23. Binary: Claude Code 2.1.237 (Fable 5 bundle), Linux, driven under `pty`.
Purpose: take the capture DQ-H1 (and #268 §C) said was missing — a batch where one call needs
permission — and settle whether the shipped blocked-exit rule
(`src/claude_session.rs::observe_hook_event`: clear on next `PreToolUse`/`PostToolUse`/`Stop`)
false-clears while a prompt is open.

## Method

A scratch project (isolated CWD; hooks are project-scoped) with `.claude/settings.local.json`
allowlisting `Bash(echo:*)` and registering logging hooks that append every payload as one JSON
line and always exit 0, for: `PreToolUse`, `PostToolUse`, `PermissionRequest`, `Stop`,
`SubagentStop` — later runs added `PermissionDenied`, `UserPromptSubmit`, `SessionEnd`.

- Runs 1–2: `claude -p` (non-interactive), 120–180 s timeouts.
- Runs 3–4: interactive `claude --permission-mode default` in a detached ephemeral `pty` session
(`pty run -d -e --id <id> --cwd <proj> --env LOGFILE=… -- claude --permission-mode default`),
prompts sent with `pty send --seq … --seq key:return`, screen sampled with `pty peek --plain`,
the log dumped **while the prompt was open** and again after answering.

Four short turns total, ≈$0.61.

## Measured sequences

**Run 2, `-p`, batch allowlisted-first** (`echo a` + `touch scratch-file.txt`):

```
PreToolUse Bash "echo a" tool_use_id=toolu_014yt…
PostToolUse Bash "echo a" (before the second call starts)
PreToolUse Bash "touch scratch-file.txt" tool_use_id=toolu_01RrZ…
PreToolUse Write … (fallback attempt, then waits)
Stop
```

No `PermissionRequest` fires in `-p` mode; the non-interactive path blocks/denies without one.
Execution is strictly serial: the first call's `Post` precedes the second call's `Pre`.

**Run 3, interactive, batch permission-first** (`touch scratch2.txt` + allowlisted `echo b`):

```
481.34 PreToolUse Bash "touch scratch2.txt" tool_use_id=toolu_01HK5… prompt_id=0ea832de…
481.67 PermissionRequest Bash "touch scratch2.txt" NO tool_use_id prompt_id=0ea832de…
— prompt open 33 s; log dumped during: NO further event of any kind,
although the TUI already rendered the batched `echo b` —
514.10 PostToolUse Bash "touch scratch2.txt" tool_use_id=toolu_01HK5… (the grant)
514.30 PreToolUse Bash "echo b" tool_use_id=toolu_01NhR…
514.59 PostToolUse Bash "echo b"
516.17 Stop
519.44 SubagentStop agent_id="a5c61ec4ef268c3cc" agent_type="" (phantom, +3.3 s)
```

**Runs 3b/4, deny path** ("3. No" on the prompt, run 4 with `PermissionDenied` + `SessionEnd`
registered): after `PreToolUse` + `PermissionRequest`, denial produced **zero further events** —
no `PostToolUse`, no `Stop`, no `PermissionDenied`. The turn ends silently.

## Findings

1. **Execution serializes around an open permission prompt.** In both orderings no hook event
fires while a prompt is up — a parallel-batched allowlisted call waits out the grant. The
false-clear #268 §C predicted (first call's `Post` clearing `blocked` during the second call's
prompt) is unobservable in this build: the next `Pre`/`Post`/`Stop` after `PermissionRequest`
is always the blocked call's own resolution. The shipped exit rule is correct, not merely
conservative.
2. **`PermissionRequest` still carries no call identity.** `tool_use_id` is present on
`PreToolUse`/`PostToolUse` but absent on `PermissionRequest`; its `prompt_id` is turn-scoped
(identical across every event of the turn) and cannot correlate a call.
3. **Denial is eventless.** Even with `PermissionDenied` registered, "No" ends the turn with no
event, so `blockedOn: human` stands until the next `UserPromptSubmit`/`SessionStart`. Half-true
semantically (a person's direction is still what the session waits on), but the state axis
reads `active` while the model is stopped. Pinned as DQ-H1's residual limit.
4. **The phantom `SubagentStop` reproduces**: 3.3 s after `Stop`, `agent_id` non-empty,
`agent_type` the empty string, no subagent ran — the guard's emergent-property basis holds in
this build.
5. `-p` mode never fires `PermissionRequest`; permission evidence exists only interactively.

## VRS Impact

- DQ-H1 re-resolved: the batched-capture gate is met; the exit rule stands on measured ground and
the open question narrows to the eventless deny window.
- The measured grant sequence is replayed verbatim by
`src/claude_session.rs::measured_batched_grant_sequence_holds_blocked_until_the_granted_calls_own_post`.
- No requirement text changes: OHS-R05's producer rule is confirmed, not amended.
50 changes: 31 additions & 19 deletions docs/vrs/05-harness-state/open-questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,27 @@ Each entry links a spec `DQ-H*`. Questions leave this file when resolved —
into [spec.md](./spec.md) as decisions or `.experiments/` as tested
hypotheses.

- **DQ-H1 Claude blocked-exit edge.** `PermissionRequest` carries no
`tool_use_id`, so leaving `blockedOn: human` can only match on tool *name* —
and Claude batches tool calls (`PostToolBatch` carries plural `tool_calls`),
so the first call's `PostToolUse` would clear `blocked` while the human still
faces the second call's prompt. The corpus enters `blocked` in 2 of 9
captures and exits in 1, with one tool and no batching: a rule validated on
a single exit path is not validated. Until then the producer holds
`blockedOn: human` until turn end (`Stop`) rather than encoding an exit rule
that cannot hold. Resolves by: a capture with batched tool calls where the
second call needs permission, then specifying the exit edge against it.
- **DQ-H1 Claude blocked-exit edge.** The batched capture #268 §C asked for
was taken on 2026-08-23 against Claude Code 2.1.237
(`.experiments/2026-08-23-claude-batched-permission.md`), and it resolves
the predicted false-clear in the rule's favor: **tool execution is
serialized around an open permission prompt.** In both batch orderings
(allowlisted-first and permission-first), no hook event of any kind fires
while the prompt is open — a parallel-batched allowlisted call renders on
screen but its `PreToolUse` waits 33 s for the grant — so the next
`PreToolUse`/`PostToolUse`/`Stop` after `PermissionRequest` is precisely the
blocked call's own resolution, and the shipped rule
(`src/claude_session.rs::observe_hook_event`) is correct, not merely
conservative. `PermissionRequest` still carries no `tool_use_id` (its
`prompt_id` is turn-scoped, shared by every event in the turn — not a call
correlator). The residual limit is the **deny path**: selecting "No" ends
the turn with *zero* further events — no `PostToolUse`, no `Stop`, and no
`PermissionDenied` even when that hook is registered — so `blockedOn: human`
stands until the next `UserPromptSubmit` or `SessionStart`. That reading is
semantically half-true (a human's direction is still what the session
waits on) and it under-reports nothing, but the state axis says `active`
while the model is not running. Resolves by: a Claude build whose denial
emits any hook event; until then the deny window is the pinned limit.
- **DQ-H2 Transport cost of per-transition writes.** Presence refreshes every
five minutes; turn boundaries are far more frequent, and burst coalescing
measured 4 transitions per turn 0.1–0.4 ms apart. No measurement establishes
Expand Down Expand Up @@ -48,12 +59,13 @@ hypotheses.
`unknown` is no fresh observation, never proof of ill health, and never
gates local work. Resolves by: specifying remote-reader semantics with a
proof, or explicitly scoping the record same-host advisory.
- **DQ-H6 OpenCode state source.** The producer needs a verified source.
Candidate: OpenCode's server/SDK event surface (session state, message
lifecycle); fallback: the shipped composer adapter's positive markers
(`ctrl+p commands` footer) with documented limits. Nothing is measured yet —
the DING adapter (#313) proves only composer classification. Resolves by:
an `.experiments/` capture of OpenCode's event surface on a pinned version,
then choosing the source and its skew policy — the repo's standing rule
(pin where version skew fails silently, as `checks.pi-extension-types` and
`SUPPORTED_CODEX_CLI_VERSIONS` do) applies.
- **DQ-H6 OpenCode blocked-entry capture.** The state source itself is
resolved: the server's SSE event surface, measured on 1.18.19
(`.experiments/2026-08-23-opencode-surface.md`) and gated by
`SUPPORTED_OPENCODE_VERSIONS` plus the live `/doc` subset check. What
remains open is the blocked-on-human pair: `permission.asked` /
`permission.replied` are schema-backed with explicit `^per` ids — the exit
edge is clean by construction, unlike Claude's — but no live capture of a
real permission prompt exists (headless runs with `{"bash":"ask"}` never
asked). Resolves by: one capture from a TUI seat with a real permission
prompt, confirming the events fire and carry the id the producer matches.
25 changes: 18 additions & 7 deletions docs/vrs/05-harness-state/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,24 @@ classified from the payload's `tool_name` (`AskUserQuestion` → `question`,
anything else → `permission`) — (its meaning is
specifically "a human is about to be asked" — it fires only under permission
modes that ask). Events carrying `agent_id` are subagent-nested and never move
top-level state. The blocked *exit* edge under batched tool calls is
unspecified until a capture proves a rule (`DQ-H1`); until then the spec
states the limit rather than a rule that cannot hold. The wrapper side owns
liveness: it re-stamps the heartbeat on its existing presence cadence while
the child is alive, and writes the terminal record from its `try_wait` reap
and its SIGTERM path — before any SIGKILL escalation into its own process
group, which no in-process write survives (OHS-T04).
top-level state. The blocked *exit* edge is the next `PreToolUse`,
`PostToolUse`, or `Stop` — measured-correct, not merely conservative: the
2026-08-23 batched-permission capture (`DQ-H1`) shows tool execution
serializes around an open permission prompt, so no event can clear the
blocked state early. The residual limit is the eventless deny path pinned in
`DQ-H1`. The wrapper side owns liveness: it re-stamps the heartbeat on its
existing presence cadence while the child is alive — through a fresh writer
each time, so it never clobbers a state a hook process wrote in between —
and writes the terminal record from its `try_wait` reap and its SIGTERM
path, before any SIGKILL escalation into its own process group, which no
in-process write survives (OHS-T04). Hook registration has one canonical
shape (`hooks::claude_settings_registration`): the maintained example
declaration (`examples/native/agent-claude.kdl`) carries it by hand, and
`expand_claude` renders the same `.claude/settings.local.json` upsert for
driver-declared seats, so both surfaces register identical hooks and a test
fails if they drift. A live seat converges without disruption: render output
is not part of the launch fingerprint, the merged settings land on the next
materialization pass, and the hooks take effect at the next session start.

## pi producer (OHS-R05, OHS-R08)

Expand Down
68 changes: 64 additions & 4 deletions examples/native/agent-claude.kdl
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ agent "<identity>" {
copy "assets/bus.st2.md" ".st2/bus.md"
ensure-line ".claude/rules/st2.md" "@../../.st2/PERSONA.md"
ensure-line ".claude/rules/st2.md" "@../../.st2/bus.md"
json-upsert ".claude/settings.local.json" #"""
// Hooks-only observability: this seat launches claude directly (no session wrapper), so the
// registrations below give it transitions and blocked-on-you — but no heartbeat owner and no
// terminal record. A live-but-idle seat ages to `unknown` after the staleness horizon and an
// exit leaves the last state to age out; both read indeterminate, never wrong. The full
// producer (heartbeats, terminal exits) comes with the session wrapper, i.e. a typed
// `claude {}` driver seat or `deliver "mcp"`.
json-upsert ".claude/settings.local.json" arrays="union" #"""
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
Expand All @@ -29,7 +35,11 @@ agent "<identity>" {
"type": "command",
"async": true,
"asyncRewake": true,
"command": "$ST_HOOKS/claude-session-start.sh"
"command": "\"$ST_HOOKS/claude-session-start.sh\""
},
{
"type": "command",
"command": "\"$ST_HOOKS/claude-observe.sh\" SessionStart"
}
]
}
Expand All @@ -39,7 +49,7 @@ agent "<identity>" {
"hooks": [
{
"type": "command",
"command": "$ST_HOOKS/claude-pre-compact.sh"
"command": "\"$ST_HOOKS/claude-pre-compact.sh\""
}
]
}
Expand All @@ -49,7 +59,57 @@ agent "<identity>" {
"hooks": [
{
"type": "command",
"command": "$ST_HOOKS/claude-stop-failure.sh"
"command": "\"$ST_HOOKS/claude-stop-failure.sh\""
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "\"$ST_HOOKS/claude-observe.sh\" UserPromptSubmit"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$ST_HOOKS/claude-observe.sh\" Stop"
}
]
}
],
"PermissionRequest": [
{
"hooks": [
{
"type": "command",
"command": "\"$ST_HOOKS/claude-observe.sh\" PermissionRequest"
}
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "\"$ST_HOOKS/claude-observe.sh\" PreToolUse"
}
]
}
],
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "\"$ST_HOOKS/claude-observe.sh\" PostToolUse"
}
]
}
Expand Down
21 changes: 21 additions & 0 deletions hooks/claude-observe.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# st2 Claude observe hook: forward one hook event (name in $1, payload on stdin) to the agent's
# observed-harness-state record. Fail-open; observation must never wedge or slow the harness.

set -u

event="${1:-}"
identity="${ST_AGENT:-}"
# CATALOG-first, deliberately diverging from the sibling hooks' ST_ROOT-first order: their
# ST_ROOT is a bus root for message writes, while --catalog here resolves the agent DECLARATION —
# with a custom bus root (ST_ROOT != CATALOG) declaration resolution under ST_ROOT finds nothing
# and every transition would silently drop.
root="${CATALOG:-${ST_ROOT:-}}"
runtime_id="${ST2_CLAUDE_RUNTIME_ID:-$identity}"
if [[ -z "$event" || -z "$identity" || -z "$root" ]] || ! command -v st2 >/dev/null 2>&1; then
exit 0
fi

st2 --catalog "$root" driver claude-observe --identity "$identity" --runtime-id "$runtime_id" \
--event "$event" >/dev/null 2>&1 || true
exit 0
9 changes: 9 additions & 0 deletions src/catalog_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,7 @@ fn normalize_agent(spec: &agent_spec::AgentSpec) -> Result<BTreeMap<String, Sema
RenderOp::JsonUpsert {
destination,
content,
arrays,
} => {
insert_value(
&mut fields,
Expand All @@ -945,6 +946,14 @@ fn normalize_agent(spec: &agent_spec::AgentSpec) -> Result<BTreeMap<String, Sema
SemanticType::String,
&normalized,
);
if *arrays == crate::materialize::ArrayMerge::Union {
insert_value(
&mut fields,
&format!("{root}/arrays"),
SemanticType::String,
"union",
);
}
}
RenderOp::EnsureLine { destination, line } => {
insert_value(
Expand Down
Loading
Loading