diff --git a/README.md b/README.md index 28c4c63..7665d7a 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ This is **best-effort**: it catches secrets with recognizable shapes but will mi ### Startup reminder (`--on-start`) -Wired as a Claude Code `SessionStart` hook, `code-trace --on-start` records the session and prints one line — `tracing ENABLED → ` or `tracing PAUSED for this session` — which Claude Code injects as agent context. It prints nothing when tracing is not configured, and never emits. +Wired as a Claude Code `SessionStart` hook, `code-trace --on-start` records the session and emits a JSON `systemMessage` — `tracing ENABLED → ` or `tracing PAUSED for this session` — which Claude Code shows to the **user** as a terminal banner (not injected into the model's context; the warning is for the human). It prints nothing when tracing is not configured, and never emits traces. ```json { diff --git a/harness/run-scenarios.sh b/harness/run-scenarios.sh index c776b97..3e3778b 100755 --- a/harness/run-scenarios.sh +++ b/harness/run-scenarios.sh @@ -106,6 +106,15 @@ transcript_of() { # session-id wait_for_services +# reminder_shown_to_user — true when the SessionStart +# reminder was delivered to the USER, i.e. recorded as a `hook_system_message` +# attachment (Claude Code renders these as a terminal banner). Distinct from +# text merely injected into the model's context. +reminder_shown_to_user() { + local t; t=$(transcript_of "$1") + [ -n "$t" ] && grep '"type":"hook_system_message"' "$t" | grep -q "$2" +} + # --- (a) one turn -> one trace with the session id --------------------------- say "scenario a: one turn produces one trace" new_home; write_settings_env_mode; reset_fake @@ -122,11 +131,10 @@ run_claude "$S" "say hi" [ "$(events_py "traces == [('$S', 1)]")" = "True" ] || fail "config-file-only turn did not trace, scenario b" PASS=$((PASS+1)) -# --- (c) --on-start reminder appears; --on-start never ingests ---------------- -say "scenario c: startup reminder in session context" -T=$(transcript_of "$S") -[ -n "$T" ] || fail "no transcript found for $S, scenario c" -grep -q "tracing ENABLED" "$T" || fail "reminder line missing from transcript, scenario c" +# --- (c) --on-start reminder shown to the user; --on-start never ingests ------ +say "scenario c: startup reminder shown to the user" +reminder_shown_to_user "$S" "tracing ENABLED" \ + || fail "ENABLED reminder not shown to user (no hook_system_message), scenario c" # The only ingestion so far is scenario b's single Stop-hook post. [ "$(events_py "len(posts)")" = "1" ] || fail "--on-start must never ingest, scenario c" PASS=$((PASS+1)) @@ -145,7 +153,8 @@ PASS=$((PASS+1)) say "scenario e: suppression survives resume" run_claude_resume "$S" "and again" [ "$(events_py "len(posts)")" = "1" ] || fail "resumed paused session emitted, scenario e" -grep -q "tracing PAUSED" "$(transcript_of "$S")" || fail "paused reminder missing on resume, scenario e" +reminder_shown_to_user "$S" "tracing PAUSED" \ + || fail "PAUSED reminder not shown to user on resume, scenario e" PASS=$((PASS+1)) say "all $PASS scenarios passed" diff --git a/src/cli.rs b/src/cli.rs index c3d1997..73b19bd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -34,14 +34,19 @@ pub fn on_start() -> i32 { let Some(config) = langfuse::config_from_env() else { return 0; }; - if suppressed { - println!("code-trace: tracing PAUSED for this session (private mode)."); + let message = if suppressed { + "code-trace: tracing PAUSED for this session (private mode).".to_string() } else { - println!( + format!( "⚠️ code-trace: tracing ENABLED → {}. Use the pause command to make this session private.", config.host - ); - } + ) + }; + // Surface the reminder to the USER via a SessionStart `systemMessage` + // (top-level JSON, exit 0), which Claude Code renders as a terminal banner. + // Plain stdout would instead be injected into the model's context — invisible + // to the user, which is who this warning is for. + println!("{}", serde_json::json!({ "systemMessage": message })); 0 } diff --git a/tests/cli_test.rs b/tests/cli_test.rs index 720a5af..5206730 100644 --- a/tests/cli_test.rs +++ b/tests/cli_test.rs @@ -229,7 +229,17 @@ fn on_start_records_session_and_prints_enabled_reminder() { let payload = r#"{"hook_event_name":"SessionStart","source":"startup","session_id":"sess-on-start","transcript_path":"/tmp/t.jsonl","cwd":"/tmp"}"#; let (code, out, _) = env.run(&["--on-start"], Some(payload)); assert_eq!(code, 0); - assert!(out.contains("ENABLED"), "got: {out}"); + // Emitted as a SessionStart `systemMessage` (JSON) so the USER sees a + // terminal banner; plain stdout would only reach the model's context. + let v: serde_json::Value = serde_json::from_str(&out).expect("on-start emits JSON"); + assert!( + v["systemMessage"].as_str().unwrap_or("").contains("ENABLED"), + "got: {out}" + ); + assert!( + v.get("hookSpecificOutput").is_none(), + "reminder is user-facing only; no agent-context line: {out}" + ); let state = env.read_state(); let record = &state.sessions["sess-on-start"]; assert_eq!(record.transcript_path.as_deref(), Some("/tmp/t.jsonl")); @@ -260,7 +270,11 @@ fn on_start_reports_paused_for_suppressed_session() { let payload = r#"{"hook_event_name":"SessionStart","source":"resume","session_id":"sess-private","transcript_path":"/tmp/t.jsonl","cwd":"/tmp"}"#; let (code, out, _) = env.run(&["--on-start"], Some(payload)); assert_eq!(code, 0); - assert!(out.contains("PAUSED"), "got: {out}"); + let v: serde_json::Value = serde_json::from_str(&out).expect("on-start emits JSON"); + assert!( + v["systemMessage"].as_str().unwrap_or("").contains("PAUSED"), + "got: {out}" + ); assert!(env.read_state().sessions["sess-private"].suppressed); }