Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 → <host>` 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 → <host>` 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
{
Expand Down
21 changes: 15 additions & 6 deletions harness/run-scenarios.sh
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ transcript_of() { # session-id

wait_for_services

# reminder_shown_to_user <session-id> <substring> — 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
Expand All @@ -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))
Expand All @@ -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"
15 changes: 10 additions & 5 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
18 changes: 16 additions & 2 deletions tests/cli_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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);
}

Expand Down