feat(hooks): add turn_end observer hook
Harvested the narrow Rust/docs slice of PR #2578 by @AresNing for #1364. The event uses the maintained structured observer path: JSON stdin, stdout ignored, warn-only failures, and no ability to block or mutate the turn. The hook fires after post-turn app state, usage totals, cost, notification, receipt, and queue-recovery state are updated, before queued follow-up dispatch. Docs, RFC notes, /hooks discovery, and v0.9 tracking now describe the observer-only contract. Co-authored-by: AresNing <49557311+AresNing@users.noreply.github.com>
This commit is contained in:
@@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Added `.github/AUTHOR_MAP` and a CI co-author credit check so harvested
|
||||
commits use GitHub-mappable numeric noreply identities instead of `.local`,
|
||||
placeholder, bot/tool, or raw third-party emails.
|
||||
- Added a `turn_end` observer hook that fires after post-turn TUI state and
|
||||
token totals are updated. Hooks receive structured JSON with status, usage,
|
||||
totals, duration, tool count, and queued-message count on stdin; stdout is
|
||||
ignored and failures are warn-only (#1364, #2578).
|
||||
- Added rich PlanArtifact support to `update_plan`: Plan mode can now carry
|
||||
grounded objectives, context, sources, critical files, constraints,
|
||||
verification, risks, and handoff notes through the transcript card, Plan
|
||||
|
||||
@@ -720,7 +720,7 @@ Current and recurring contributors include:
|
||||
- **[yuanchenglu](https://github.com/yuanchenglu)** — Feishu per-chat model switching (#2149)
|
||||
- **[HUQIANTAO](https://github.com/HUQIANTAO)** — Xiaomi balance/status work, stalled-turn recovery, approval intent summaries, mobile smoke/QR support, Claude theme, and broad docs/test/CI coverage (#2257, #2267, #2283, #2384, #2385, #2389, #2403, #2440-#2458, #2460)
|
||||
- **[h3c-hexin](https://github.com/h3c-hexin)** — web-search URL decoding, prompt/instructions override hooks, sub-agent guidance, SSRF fake-IP trust configuration, and prompt-cache-friendly environment placement (#2245, #2311, #2313, #2314, #2354, #2355, #2356)
|
||||
- **[AresNing](https://github.com/AresNing)** — first-run guide and message-submit hook transform design harvested into the maintained hooks path (#2278, #2318, #2434)
|
||||
- **[AresNing](https://github.com/AresNing)** — first-run guide, message-submit hook transform design, and turn-end observer hook work harvested into the maintained hooks path (#2278, #2318, #2434, #2578)
|
||||
- **[Implementist](https://github.com/Implementist)** — Volcengine Ark search provider and reliability hardening (#2426, #2429, #2439)
|
||||
- **[lihuan215](https://github.com/lihuan215)** — Unix socket hook sink design harvested into the opt-in hook event path (#2333, #2430)
|
||||
- **[AdityaVG13](https://github.com/AdityaVG13)** — Xiaomi MiMo provider support (#2246)
|
||||
|
||||
@@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Added `.github/AUTHOR_MAP` and a CI co-author credit check so harvested
|
||||
commits use GitHub-mappable numeric noreply identities instead of `.local`,
|
||||
placeholder, bot/tool, or raw third-party emails.
|
||||
- Added a `turn_end` observer hook that fires after post-turn TUI state and
|
||||
token totals are updated. Hooks receive structured JSON with status, usage,
|
||||
totals, duration, tool count, and queued-message count on stdin; stdout is
|
||||
ignored and failures are warn-only (#1364, #2578).
|
||||
- Added rich PlanArtifact support to `update_plan`: Plan mode can now carry
|
||||
grounded objectives, context, sources, critical files, constraints,
|
||||
verification, risks, and handoff notes through the transcript card, Plan
|
||||
|
||||
@@ -43,6 +43,10 @@ fn events() -> CommandResult {
|
||||
let ordered = [
|
||||
(HookEvent::SessionStart, "fires once when the TUI launches"),
|
||||
(HookEvent::SessionEnd, "fires once on graceful shutdown"),
|
||||
(
|
||||
HookEvent::TurnEnd,
|
||||
"fires after a turn completes (observer-only)",
|
||||
),
|
||||
(
|
||||
HookEvent::MessageSubmit,
|
||||
"fires before model dispatch; can transform or block submitted text",
|
||||
@@ -146,6 +150,7 @@ fn event_label(event: HookEvent) -> &'static str {
|
||||
HookEvent::ToolCallAfter => "tool_call_after",
|
||||
HookEvent::ModeChange => "mode_change",
|
||||
HookEvent::OnError => "on_error",
|
||||
HookEvent::TurnEnd => "turn_end",
|
||||
HookEvent::SubagentSpawn => "subagent_spawn",
|
||||
HookEvent::SubagentComplete => "subagent_complete",
|
||||
HookEvent::ShellEnv => "shell_env",
|
||||
@@ -266,6 +271,7 @@ mod tests {
|
||||
let positions: Vec<(usize, &str)> = [
|
||||
"session_start",
|
||||
"session_end",
|
||||
"turn_end",
|
||||
"message_submit",
|
||||
"tool_call_before",
|
||||
"tool_call_after",
|
||||
@@ -310,6 +316,7 @@ mod tests {
|
||||
assert_eq!(event_label(HookEvent::MessageSubmit), "message_submit");
|
||||
assert_eq!(event_label(HookEvent::ModeChange), "mode_change");
|
||||
assert_eq!(event_label(HookEvent::OnError), "on_error");
|
||||
assert_eq!(event_label(HookEvent::TurnEnd), "turn_end");
|
||||
assert_eq!(event_label(HookEvent::SubagentSpawn), "subagent_spawn");
|
||||
assert_eq!(
|
||||
event_label(HookEvent::SubagentComplete),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! - Mode changes
|
||||
//! - Message submission
|
||||
//! - Error events
|
||||
//! - Turn completion
|
||||
//!
|
||||
//! Configuration is done via `[[hooks.hooks]]` in config.toml.
|
||||
|
||||
@@ -41,6 +42,8 @@ pub enum HookEvent {
|
||||
ModeChange,
|
||||
/// Triggered when an error occurs
|
||||
OnError,
|
||||
/// Triggered after a turn completes and post-turn state has been updated
|
||||
TurnEnd,
|
||||
/// Triggered when a sub-agent is spawned
|
||||
SubagentSpawn,
|
||||
/// Triggered when a sub-agent reaches a terminal state
|
||||
@@ -66,6 +69,7 @@ impl HookEvent {
|
||||
HookEvent::ToolCallAfter => "tool_call_after",
|
||||
HookEvent::ModeChange => "mode_change",
|
||||
HookEvent::OnError => "on_error",
|
||||
HookEvent::TurnEnd => "turn_end",
|
||||
HookEvent::SubagentSpawn => "subagent_spawn",
|
||||
HookEvent::SubagentComplete => "subagent_complete",
|
||||
HookEvent::ShellEnv => "shell_env",
|
||||
@@ -480,6 +484,28 @@ enum MessageSubmitStdout {
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
/// Post-turn accumulated totals included in the `turn_end` observer payload.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TurnEndTotals {
|
||||
pub session_tokens: u32,
|
||||
pub conversation_tokens: u32,
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
}
|
||||
|
||||
/// Input used to build the structured `turn_end` observer payload.
|
||||
pub struct TurnEndPayloadInput<'a> {
|
||||
pub context: &'a HookContext,
|
||||
pub turn_id: Option<&'a str>,
|
||||
pub status: &'a str,
|
||||
pub error: Option<&'a str>,
|
||||
pub duration: Duration,
|
||||
pub usage: &'a crate::models::Usage,
|
||||
pub totals: TurnEndTotals,
|
||||
pub tool_count: usize,
|
||||
pub queued_message_count: usize,
|
||||
}
|
||||
|
||||
/// Executor for running hooks
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HookExecutor {
|
||||
@@ -1121,6 +1147,41 @@ fn message_submit_payload(context: &HookContext, text: &str) -> serde_json::Valu
|
||||
})
|
||||
}
|
||||
|
||||
pub fn turn_end_payload(input: TurnEndPayloadInput<'_>) -> serde_json::Value {
|
||||
json!({
|
||||
"event": HookEvent::TurnEnd.as_str(),
|
||||
"session_id": input.context.session_id.as_deref(),
|
||||
"workspace": input.context.workspace.as_ref().map(|path| path.display().to_string()),
|
||||
"mode": input.context.mode.as_deref(),
|
||||
"model": input.context.model.as_deref(),
|
||||
"turn_id": input.turn_id,
|
||||
"status": input.status,
|
||||
"error": input.error,
|
||||
"duration_ms": duration_ms_saturating(input.duration),
|
||||
"usage": {
|
||||
"input_tokens": input.usage.input_tokens,
|
||||
"output_tokens": input.usage.output_tokens,
|
||||
"prompt_cache_hit_tokens": input.usage.prompt_cache_hit_tokens,
|
||||
"prompt_cache_miss_tokens": input.usage.prompt_cache_miss_tokens,
|
||||
"reasoning_tokens": input.usage.reasoning_tokens,
|
||||
"reasoning_replay_tokens": input.usage.reasoning_replay_tokens,
|
||||
},
|
||||
"totals": {
|
||||
"session_tokens": input.totals.session_tokens,
|
||||
"conversation_tokens": input.totals.conversation_tokens,
|
||||
"input_tokens": input.totals.input_tokens,
|
||||
"output_tokens": input.totals.output_tokens,
|
||||
},
|
||||
"tool_count": input.tool_count,
|
||||
"queued_message_count": input.queued_message_count,
|
||||
"stop_hook_active": false,
|
||||
})
|
||||
}
|
||||
|
||||
fn duration_ms_saturating(duration: Duration) -> u64 {
|
||||
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
fn parse_message_submit_stdout(stdout: &str) -> MessageSubmitStdout {
|
||||
let trimmed = stdout.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -1343,10 +1404,70 @@ NOEQUAL line dropped
|
||||
assert_eq!(HookEvent::SessionStart.as_str(), "session_start");
|
||||
assert_eq!(HookEvent::ToolCallAfter.as_str(), "tool_call_after");
|
||||
assert_eq!(HookEvent::ModeChange.as_str(), "mode_change");
|
||||
assert_eq!(HookEvent::TurnEnd.as_str(), "turn_end");
|
||||
assert_eq!(HookEvent::SubagentSpawn.as_str(), "subagent_spawn");
|
||||
assert_eq!(HookEvent::SubagentComplete.as_str(), "subagent_complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_end_payload_contains_post_turn_observer_fields() {
|
||||
let context = HookContext::new()
|
||||
.with_session_id("sess_test")
|
||||
.with_workspace(PathBuf::from("/tmp/codewhale"))
|
||||
.with_mode("agent")
|
||||
.with_model("deepseek-v4")
|
||||
.with_tokens(125);
|
||||
let usage = crate::models::Usage {
|
||||
input_tokens: 40,
|
||||
output_tokens: 9,
|
||||
prompt_cache_hit_tokens: Some(10),
|
||||
prompt_cache_miss_tokens: Some(30),
|
||||
reasoning_tokens: Some(4),
|
||||
reasoning_replay_tokens: Some(2),
|
||||
server_tool_use: None,
|
||||
};
|
||||
|
||||
let payload = super::turn_end_payload(TurnEndPayloadInput {
|
||||
context: &context,
|
||||
turn_id: Some("turn_123"),
|
||||
status: "completed",
|
||||
error: None,
|
||||
duration: Duration::from_millis(321),
|
||||
usage: &usage,
|
||||
totals: TurnEndTotals {
|
||||
session_tokens: 125,
|
||||
conversation_tokens: 100,
|
||||
input_tokens: 100,
|
||||
output_tokens: 25,
|
||||
},
|
||||
tool_count: 2,
|
||||
queued_message_count: 1,
|
||||
});
|
||||
|
||||
assert_eq!(payload["event"], "turn_end");
|
||||
assert_eq!(payload["session_id"], "sess_test");
|
||||
assert_eq!(payload["workspace"], "/tmp/codewhale");
|
||||
assert_eq!(payload["mode"], "agent");
|
||||
assert_eq!(payload["model"], "deepseek-v4");
|
||||
assert_eq!(payload["turn_id"], "turn_123");
|
||||
assert_eq!(payload["status"], "completed");
|
||||
assert_eq!(payload["error"], serde_json::Value::Null);
|
||||
assert_eq!(payload["duration_ms"], 321);
|
||||
assert_eq!(payload["usage"]["input_tokens"], 40);
|
||||
assert_eq!(payload["usage"]["output_tokens"], 9);
|
||||
assert_eq!(payload["usage"]["prompt_cache_hit_tokens"], 10);
|
||||
assert_eq!(payload["usage"]["prompt_cache_miss_tokens"], 30);
|
||||
assert_eq!(payload["usage"]["reasoning_tokens"], 4);
|
||||
assert_eq!(payload["usage"]["reasoning_replay_tokens"], 2);
|
||||
assert_eq!(payload["totals"]["session_tokens"], 125);
|
||||
assert_eq!(payload["totals"]["conversation_tokens"], 100);
|
||||
assert_eq!(payload["totals"]["input_tokens"], 100);
|
||||
assert_eq!(payload["totals"]["output_tokens"], 25);
|
||||
assert_eq!(payload["tool_count"], 2);
|
||||
assert_eq!(payload["queued_message_count"], 1);
|
||||
assert_eq!(payload["stop_hook_active"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_context_to_env_vars() {
|
||||
let ctx = HookContext::new()
|
||||
@@ -1578,6 +1699,76 @@ cat > "{}"
|
||||
assert_eq!(captured["prompt_truncated"], false);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn turn_end_observer_hook_receives_stdin_json_and_ignores_stdout_contract() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let out = dir.path().join("turn_end.json");
|
||||
let command = write_hook_script(
|
||||
&dir,
|
||||
"capture_turn_end.sh",
|
||||
&format!(
|
||||
r#"#!/bin/sh
|
||||
cat > "{}"
|
||||
printf '%s\n' '{{"text":"stdout is not a mutation contract"}}'
|
||||
"#,
|
||||
out.display()
|
||||
),
|
||||
);
|
||||
let executor = HookExecutor::new(
|
||||
HooksConfig {
|
||||
enabled: true,
|
||||
hooks: vec![Hook::new(HookEvent::TurnEnd, &command)],
|
||||
..Default::default()
|
||||
},
|
||||
dir.path().to_path_buf(),
|
||||
);
|
||||
let usage = crate::models::Usage {
|
||||
input_tokens: 12,
|
||||
output_tokens: 3,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
reasoning_replay_tokens: None,
|
||||
server_tool_use: None,
|
||||
};
|
||||
let context = submit_context(&dir).with_tokens(15);
|
||||
let payload = super::turn_end_payload(TurnEndPayloadInput {
|
||||
context: &context,
|
||||
turn_id: Some("turn_observed"),
|
||||
status: "completed",
|
||||
error: None,
|
||||
duration: Duration::from_millis(7),
|
||||
usage: &usage,
|
||||
totals: TurnEndTotals {
|
||||
session_tokens: 15,
|
||||
conversation_tokens: 15,
|
||||
input_tokens: 12,
|
||||
output_tokens: 3,
|
||||
},
|
||||
tool_count: 0,
|
||||
queued_message_count: 0,
|
||||
});
|
||||
|
||||
let results = executor.execute_json_observer(HookEvent::TurnEnd, &context, &payload);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(results[0].success);
|
||||
assert!(
|
||||
results[0]
|
||||
.stdout
|
||||
.contains("stdout is not a mutation contract"),
|
||||
"stdout is still captured for diagnostics"
|
||||
);
|
||||
let captured: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(out).expect("payload written"))
|
||||
.expect("valid JSON payload");
|
||||
assert_eq!(captured["event"], "turn_end");
|
||||
assert_eq!(captured["turn_id"], "turn_observed");
|
||||
assert_eq!(captured["totals"]["input_tokens"], 12);
|
||||
assert_eq!(captured["totals"]["output_tokens"], 3);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn json_observer_hook_failure_does_not_stop_later_hooks() {
|
||||
@@ -1912,6 +2103,7 @@ exit 7
|
||||
HookEvent::ToolCallAfter,
|
||||
HookEvent::ModeChange,
|
||||
HookEvent::OnError,
|
||||
HookEvent::TurnEnd,
|
||||
HookEvent::SubagentSpawn,
|
||||
HookEvent::SubagentComplete,
|
||||
] {
|
||||
|
||||
@@ -49,7 +49,7 @@ use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEve
|
||||
use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
|
||||
use crate::core::events::Event as EngineEvent;
|
||||
use crate::core::ops::{Op, USER_SHELL_TOOL_ID_PREFIX};
|
||||
use crate::hooks::{HookEvent, HookExecutor};
|
||||
use crate::hooks::{HookEvent, HookExecutor, TurnEndPayloadInput, TurnEndTotals};
|
||||
use crate::llm_client::LlmClient;
|
||||
use crate::localization::{MessageId, tr};
|
||||
use crate::models::{
|
||||
@@ -699,6 +699,41 @@ fn execute_subagent_observer_hook(
|
||||
});
|
||||
}
|
||||
|
||||
fn execute_turn_end_observer_hook(
|
||||
app: &App,
|
||||
usage: &Usage,
|
||||
duration: Duration,
|
||||
error: Option<&str>,
|
||||
) {
|
||||
if !app.hooks.has_hooks_for_event(HookEvent::TurnEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
let context = app.base_hook_context();
|
||||
let payload = crate::hooks::turn_end_payload(TurnEndPayloadInput {
|
||||
context: &context,
|
||||
turn_id: app.runtime_turn_id.as_deref(),
|
||||
status: app.runtime_turn_status.as_deref().unwrap_or("unknown"),
|
||||
error,
|
||||
duration,
|
||||
usage,
|
||||
totals: TurnEndTotals {
|
||||
session_tokens: app.session.total_tokens,
|
||||
conversation_tokens: app.session.total_conversation_tokens,
|
||||
input_tokens: app.session.total_input_tokens,
|
||||
output_tokens: app.session.total_output_tokens,
|
||||
},
|
||||
tool_count: app.tool_evidence.len(),
|
||||
queued_message_count: app.queued_message_count(),
|
||||
});
|
||||
let hooks = app.hooks.clone();
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("turn_end-observer-hook".to_string())
|
||||
.spawn(move || {
|
||||
let _ = hooks.execute_json_observer(HookEvent::TurnEnd, &context, &payload);
|
||||
});
|
||||
}
|
||||
|
||||
fn bounded_subagent_hook_preview(text: &str) -> (String, bool) {
|
||||
if text.len() <= SUBAGENT_HOOK_PREVIEW_LIMIT {
|
||||
return (text.to_string(), false);
|
||||
@@ -1769,7 +1804,7 @@ async fn run_event_loop(
|
||||
reasoning_replay_tokens: usage.reasoning_replay_tokens,
|
||||
recorded_at: Instant::now(),
|
||||
});
|
||||
if let Some(error) = error {
|
||||
if let Some(error) = error.as_deref() {
|
||||
// Only show "Turn failed:" in the composer status
|
||||
// area when an EngineEvent::Error has NOT already
|
||||
// posted the same message into the transcript.
|
||||
@@ -1940,6 +1975,8 @@ async fn run_event_loop(
|
||||
}
|
||||
}
|
||||
|
||||
execute_turn_end_observer_hook(app, &usage, turn_elapsed, error.as_deref());
|
||||
|
||||
if queued_to_send.is_none() {
|
||||
queued_to_send = app.pop_queued_message();
|
||||
}
|
||||
|
||||
@@ -592,6 +592,61 @@ the message. Existing environment variables remain available.
|
||||
`shell_env` hooks keep their existing `KEY=VALUE` stdout contract;
|
||||
the JSON stdout contract applies only to `message_submit`.
|
||||
|
||||
### Turn-end observer hooks
|
||||
|
||||
`turn_end` hooks observe the end of each model turn after post-turn
|
||||
state, usage totals, cost accounting, notifications, receipts, and
|
||||
queue recovery have been updated. They receive JSON on stdin and are
|
||||
observer-only: stdout is ignored, failures are logged as warnings, and
|
||||
the hook cannot block user input, mutate the transcript, or change the
|
||||
next queued follow-up.
|
||||
|
||||
```toml
|
||||
[[hooks.hooks]]
|
||||
event = "turn_end"
|
||||
command = "~/.codewhale/hooks/turn-audit.sh"
|
||||
timeout_secs = 2
|
||||
continue_on_error = true
|
||||
```
|
||||
|
||||
The payload includes common hook metadata plus post-turn accounting:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "turn_end",
|
||||
"session_id": "sess_12345678",
|
||||
"workspace": "/path/to/workspace",
|
||||
"mode": "agent",
|
||||
"model": "deepseek-chat",
|
||||
"turn_id": "turn_12345678",
|
||||
"status": "completed",
|
||||
"error": null,
|
||||
"duration_ms": 1834,
|
||||
"usage": {
|
||||
"input_tokens": 1200,
|
||||
"output_tokens": 180,
|
||||
"prompt_cache_hit_tokens": 900,
|
||||
"prompt_cache_miss_tokens": 300,
|
||||
"reasoning_tokens": null,
|
||||
"reasoning_replay_tokens": null
|
||||
},
|
||||
"totals": {
|
||||
"session_tokens": 1380,
|
||||
"conversation_tokens": 1380,
|
||||
"input_tokens": 1200,
|
||||
"output_tokens": 180
|
||||
},
|
||||
"tool_count": 2,
|
||||
"queued_message_count": 1,
|
||||
"stop_hook_active": false
|
||||
}
|
||||
```
|
||||
|
||||
For `interrupted` or `failed` turns, `status` reflects that terminal
|
||||
state and `error` carries the engine error string when one is available.
|
||||
`stop_hook_active` is reserved for future re-entry protection and is
|
||||
currently always `false`.
|
||||
|
||||
### Sub-agent lifecycle hooks
|
||||
|
||||
`subagent_spawn` and `subagent_complete` hooks observe sub-agent lifecycle
|
||||
|
||||
@@ -124,7 +124,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit.
|
||||
| #2529 workspace shell opt-in | Draft/conflicting | Review with permissions/sandbox stabilization. |
|
||||
| #2530 mention depth cap hint | Draft/mergeable | Already present locally as `a97675824` and `29f57665e`; close/comment after branch is public. |
|
||||
| #2576 PrefixCacheChange events | Mergeable | Already present locally through `29acb87a9d`; close/comment after branch is public or merged. |
|
||||
| #2578 turn_end observer hook | Conflicting | Defer to hook lifecycle lane. |
|
||||
| #2578 turn_end observer hook | Conflicting / locally harvested | Narrow Rust/docs slice landed in the hook lifecycle lane: `turn_end` now uses the existing structured observer path, fires after post-turn state updates and before queued follow-up dispatch, and includes status, usage, totals, duration, tool count, and queued-message count. Close/comment after branch is public, crediting @AresNing and #1364 reporter @esinecan. |
|
||||
| #2579 AppendLog session messages | Conflicting | Defer; large architectural change. |
|
||||
| #2581 provider fallback chain design doc | Mergeable / empty diff | Manually harvested into `docs/rfcs/2574-provider-fallback-chain.md`; close original PR after branch is public, keep #2574 open for implementation. |
|
||||
| #2623 plan prompt modal scroll support | Mergeable | Already harvested into the 22-commit stack. Comment/close original after integration branch is public. |
|
||||
|
||||
@@ -64,6 +64,13 @@ Non-goals:
|
||||
- no blocking of user input
|
||||
- no transcript mutation from `turn_end`
|
||||
|
||||
Implementation note for the v0.9 branch: the narrow #2578 harvest uses the
|
||||
shared structured observer path introduced for sub-agent lifecycle hooks. It
|
||||
fires before queued follow-up dispatch, after queue-recovery state is known, so
|
||||
the payload can report the queued-message count without letting a hook change
|
||||
what gets sent next. Stdout is ignored for `turn_end`; only `message_submit`
|
||||
has a stdout mutation contract.
|
||||
|
||||
### PR 3: Subagent lifecycle observer hooks
|
||||
|
||||
Expose subagent start and completion as observer-only hook events.
|
||||
@@ -251,7 +258,9 @@ transcript content in the first version.
|
||||
- Existing observer-only hooks keep working.
|
||||
- Existing env vars remain available.
|
||||
- `shell_env` keeps its existing stdout `KEY=VALUE` contract.
|
||||
- Structured stdout is interpreted only by `message_submit` in PR 1.
|
||||
- Structured stdout is interpreted only by `message_submit` in PR 1. Structured
|
||||
observer hooks such as `turn_end`, `subagent_spawn`, and `subagent_complete`
|
||||
receive JSON on stdin, but their stdout is ignored by the caller.
|
||||
|
||||
## 6. Review checkpoints
|
||||
|
||||
|
||||
Reference in New Issue
Block a user