Skip to content

Commit f7dfe77

Browse files
Shahinyanmclaude
andauthored
feat(session): stamp live session_id on emitted events (additive) (#19)
Stamp the active Claude Code session_id onto events the journal emits itself — sync hook events (FileChanged, PreCompact), the async classify-worker path (via the pending-v2 chunk), and the MCP tools (task_create, event_add, task_close). Source: hook payload session_id field → CLAUDE_CODE_SESSION_ID env fallback → none. Backward-compatible and opt-in: no source present (standalone) → meta unchanged, behavior byte-identical. Distinct from existing transcript session_id parsing. semver minor 0.10.3 → 0.11.0. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e1f5807 commit f7dfe77

9 files changed

Lines changed: 213 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.11.0] - 2026-06-08
11+
12+
**Live `session_id` on emitted events (additive, opt-in).** The journal now
13+
stamps the active Claude Code session id onto the events it emits itself —
14+
hook-driven events (synchronous FileChanged/PreCompact and the async
15+
classify-worker path) and the MCP tools (`task_create`, `event_add`,
16+
`task_close`). This lets external consumers correlate journal events with
17+
the originating session without time-window heuristics.
18+
19+
Fully backward-compatible: the id is read from the hook payload's
20+
`session_id` field, falling back to the `CLAUDE_CODE_SESSION_ID` env var.
21+
When neither is present (standalone use), nothing is added and behavior is
22+
byte-identical to before. This is distinct from the existing transcript
23+
`session_id` *parsing* — that passive read-only lookup is unchanged.
24+
25+
### Added
26+
- `tj_core::session_id` — helpers to resolve the live session id
27+
(`live_session_id`, `session_id_from_payload`, `session_id_from_env`) and
28+
additively stamp it into an event's free-form `meta` (`stamp_session_id`).
29+
- `meta.session_id` on live hook events and MCP events when a source is
30+
available. The pending-v2 chunk now carries `session_id` so async
31+
classify-worker events inherit it.
32+
1033
## [0.10.3] - 2026-06-06
1134

1235
**Search & pack quality fixes from real user feedback.** Five bugs hit

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ members = [
77
]
88

99
[workspace.package]
10-
version = "0.10.3"
10+
version = "0.11.0"
1111
edition = "2021"
1212
rust-version = "1.88"
1313
license = "MIT"

crates/tj-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ name = "task-journal"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
tj-core = { package = "task-journal-core", version = "0.10.3", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.11.0", path = "../tj-core" }
2020
anyhow = { workspace = true }
2121
clap = { workspace = true }
2222
tracing = { workspace = true }

crates/tj-cli/src/main.rs

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,6 +1512,12 @@ fn main() -> Result<()> {
15121512
let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl"));
15131513
std::fs::create_dir_all(events_path.parent().unwrap())?;
15141514

1515+
// Live Claude Code session id (hook payload → env fallback),
1516+
// stamped additively onto the live events this hook emits so
1517+
// consumers can correlate them with the session. None when
1518+
// neither source is present (standalone behaviour unchanged).
1519+
let live_session_id = tj_core::session_id::live_session_id(Some(&payload));
1520+
15151521
// SessionStart: emit a JSON envelope with compact resume-packs of
15161522
// open tasks so Claude Code injects them into its system context
15171523
// automatically. This is the load-bearing UX for "the journal
@@ -1680,6 +1686,7 @@ fn main() -> Result<()> {
16801686
);
16811687
event.confidence = Some(0.9);
16821688
event.status = tj_core::event::EventStatus::Confirmed;
1689+
tj_core::session_id::stamp_session_id(&mut event.meta, live_session_id.as_deref());
16831690
let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?;
16841691
writer.append(&event)?;
16851692
writer.flush_durable()?;
@@ -1732,6 +1739,7 @@ fn main() -> Result<()> {
17321739
&backend,
17331740
last_event_ts.as_deref(),
17341741
"PreCompactChunk",
1742+
live_session_id.as_deref(),
17351743
)
17361744
.unwrap_or(0);
17371745
if enq > 0 && std::env::var("TJ_DISABLE_CLASSIFY_SPAWN").is_err() {
@@ -1793,6 +1801,7 @@ fn main() -> Result<()> {
17931801
);
17941802
event.confidence = Some(1.0);
17951803
event.status = tj_core::event::EventStatus::Confirmed;
1804+
tj_core::session_id::stamp_session_id(&mut event.meta, live_session_id.as_deref());
17961805
let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?;
17971806
writer.append(&event)?;
17981807
writer.flush_durable()?;
@@ -1865,6 +1874,7 @@ fn main() -> Result<()> {
18651874
&backend,
18661875
last_event_ts.as_deref(),
18671876
"StopChunk",
1877+
live_session_id.as_deref(),
18681878
)
18691879
.unwrap_or(0);
18701880
if enq > 0 && std::env::var("TJ_DISABLE_CLASSIFY_SPAWN").is_err() {
@@ -1947,7 +1957,7 @@ fn main() -> Result<()> {
19471957
.unwrap_or(false);
19481958
let is_mock = mock_event_type.is_some() && mock_task_id.is_some();
19491959
if !is_mock && !force_sync {
1950-
let _ = persist_pending_v2(&events_path, &kind, &text, &project_hash, &backend)?;
1960+
let _ = persist_pending_v2(&events_path, &kind, &text, &project_hash, &backend, live_session_id.as_deref())?;
19511961
// Fire-and-forget worker. Errors here are best-effort —
19521962
// a failure to spawn just means the entry sits in
19531963
// pending/ until the next hook fires another spawn.
@@ -3150,6 +3160,7 @@ fn persist_pending_v2(
31503160
text: &str,
31513161
project_hash: &str,
31523162
backend: &str,
3163+
session_id: Option<&str>,
31533164
) -> anyhow::Result<std::path::PathBuf> {
31543165
let pending_dir = events_path
31553166
.parent()
@@ -3159,7 +3170,7 @@ fn persist_pending_v2(
31593170
.join("pending");
31603171
std::fs::create_dir_all(&pending_dir)?;
31613172
let id = ulid::Ulid::new().to_string();
3162-
let payload = serde_json::json!({
3173+
let mut payload = serde_json::json!({
31633174
"schema": "v2",
31643175
"kind": kind,
31653176
"text": text,
@@ -3168,6 +3179,9 @@ fn persist_pending_v2(
31683179
"backend": backend,
31693180
"queued_at": chrono::Utc::now().to_rfc3339(),
31703181
});
3182+
if let Some(sid) = session_id {
3183+
payload["session_id"] = serde_json::Value::String(sid.to_string());
3184+
}
31713185
let path = pending_dir.join(format!("{id}.json"));
31723186
std::fs::write(&path, serde_json::to_string_pretty(&payload)?)?;
31733187
Ok(path)
@@ -3194,6 +3208,7 @@ fn enqueue_transcript_chunks_since_last_event(
31943208
backend: &str,
31953209
last_event_ts: Option<&str>,
31963210
assistant_chunk_kind: &str,
3211+
session_id: Option<&str>,
31973212
) -> anyhow::Result<usize> {
31983213
use tj_core::session::parser::{
31993214
extract_assistant_texts, extract_user_text, parse_session, SessionEntry,
@@ -3226,7 +3241,7 @@ fn enqueue_transcript_chunks_since_last_event(
32263241
continue;
32273242
}
32283243
}
3229-
persist_pending_v2(events_path, kind, &text, project_hash, backend)?;
3244+
persist_pending_v2(events_path, kind, &text, project_hash, backend, session_id)?;
32303245
count += 1;
32313246
}
32323247
Ok(count)
@@ -3394,6 +3409,9 @@ fn process_pending_entry(
33943409
.unwrap_or("")
33953410
.to_string();
33963411

3412+
// Inherit the session id queued on the v2 chunk (additive; absent → None).
3413+
let chunk_session_id = tj_core::session_id::session_id_from_payload(&v);
3414+
33973415
// Mirror the synchronous flow that used to live in IngestHook —
33983416
// see commit history of v0.6.1 for the original. Auto-open, run
33993417
// classifier, apply integrity safeguards, persist event, telemetry.
@@ -3510,6 +3528,7 @@ fn process_pending_entry(
35103528
event.confidence = Some(confidence);
35113529
event.status = tj_core::classifier::decide_status(confidence);
35123530
event.evidence_strength = evidence_strength;
3531+
tj_core::session_id::stamp_session_id(&mut event.meta, chunk_session_id.as_deref());
35133532

35143533
let mut writer = tj_core::storage::JsonlWriter::open(events_path)?;
35153534
writer.append(&event)?;
@@ -3686,6 +3705,33 @@ mod inline_tests {
36863705
// declared before this module begins.
36873706
use super::*;
36883707

3708+
#[test]
3709+
fn persist_pending_v2_includes_session_id_when_present() {
3710+
let dir = tempfile::tempdir().unwrap();
3711+
let events_path = dir.path().join("events").join("h.jsonl");
3712+
std::fs::create_dir_all(events_path.parent().unwrap()).unwrap();
3713+
let p = persist_pending_v2(&events_path, "PostToolUse", "txt", "h", "hybrid", Some("sess-9"))
3714+
.unwrap();
3715+
let body = std::fs::read_to_string(&p).unwrap();
3716+
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
3717+
assert_eq!(v["session_id"], serde_json::json!("sess-9"));
3718+
assert_eq!(
3719+
tj_core::session_id::session_id_from_payload(&v).as_deref(),
3720+
Some("sess-9")
3721+
);
3722+
}
3723+
3724+
#[test]
3725+
fn persist_pending_v2_omits_session_id_when_none() {
3726+
let dir = tempfile::tempdir().unwrap();
3727+
let events_path = dir.path().join("events").join("h.jsonl");
3728+
std::fs::create_dir_all(events_path.parent().unwrap()).unwrap();
3729+
let p = persist_pending_v2(&events_path, "PostToolUse", "txt", "h", "hybrid", None).unwrap();
3730+
let body = std::fs::read_to_string(&p).unwrap();
3731+
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
3732+
assert!(v.get("session_id").is_none());
3733+
}
3734+
36893735
#[test]
36903736
fn is_rewind_prompt_simple() {
36913737
assert!(is_rewind_prompt("/rewind"));

crates/tj-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ pub mod pack;
5656
pub mod paths;
5757
pub mod project_hash;
5858
pub mod session;
59+
pub mod session_id;
5960
pub mod storage;
6061

6162
#[cfg(test)]

crates/tj-core/src/session_id.rs

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//! Live Claude Code session id helpers.
2+
//!
3+
//! task-journal already *parses* session ids out of Claude Code
4+
//! transcripts (`session::parser`) — that is a passive, read-only
5+
//! lookup of someone else's identifier. This module is the other
6+
//! direction: additively stamping the live session id onto the events
7+
//! the journal itself emits (hooks + MCP tools), so downstream
8+
//! consumers can correlate those events with the originating session
9+
//! without time-window heuristics.
10+
//!
11+
//! Source order: hook payload field `session_id` → `CLAUDE_CODE_SESSION_ID`
12+
//! env var → `None`. `None` means standalone behaviour is unchanged —
13+
//! nothing is added to `meta`.
14+
15+
use serde_json::Value;
16+
17+
/// Pull `session_id` out of a Claude Code hook payload (or a pending-v2
18+
/// chunk, which carries the same field). Empty strings count as absent.
19+
pub fn session_id_from_payload(payload: &Value) -> Option<String> {
20+
payload
21+
.get("session_id")
22+
.and_then(|s| s.as_str())
23+
.filter(|s| !s.is_empty())
24+
.map(str::to_string)
25+
}
26+
27+
/// Read `CLAUDE_CODE_SESSION_ID` from the environment. Empty counts as absent.
28+
pub fn session_id_from_env() -> Option<String> {
29+
std::env::var("CLAUDE_CODE_SESSION_ID")
30+
.ok()
31+
.filter(|s| !s.is_empty())
32+
}
33+
34+
/// Resolve the live session id: hook payload first, env var as fallback.
35+
/// `None` when neither source provides one (standalone — caller adds nothing).
36+
pub fn live_session_id(payload: Option<&Value>) -> Option<String> {
37+
payload
38+
.and_then(session_id_from_payload)
39+
.or_else(session_id_from_env)
40+
}
41+
42+
/// Additively record `session_id` into a free-form `meta` value.
43+
///
44+
/// No-op when `sid` is `None` or `meta` is not a JSON object. Never
45+
/// overwrites or removes existing keys — additive by construction.
46+
pub fn stamp_session_id(meta: &mut Value, sid: Option<&str>) {
47+
if let (Some(sid), Some(obj)) = (sid, meta.as_object_mut()) {
48+
obj.insert("session_id".to_string(), Value::String(sid.to_string()));
49+
}
50+
}
51+
52+
#[cfg(test)]
53+
mod tests {
54+
use super::*;
55+
use serde_json::json;
56+
use std::sync::Mutex;
57+
58+
// Serialises the env-touching tests — std env is process-global.
59+
static ENV_LOCK: Mutex<()> = Mutex::new(());
60+
61+
#[test]
62+
fn payload_session_id_extracted() {
63+
let p = json!({"session_id": "abc-123", "hook_event_name": "PostToolUse"});
64+
assert_eq!(session_id_from_payload(&p).as_deref(), Some("abc-123"));
65+
}
66+
67+
#[test]
68+
fn payload_empty_or_missing_is_none() {
69+
assert_eq!(session_id_from_payload(&json!({"session_id": ""})), None);
70+
assert_eq!(session_id_from_payload(&json!({})), None);
71+
assert_eq!(session_id_from_payload(&Value::Null), None);
72+
}
73+
74+
#[test]
75+
fn stamp_adds_to_object_meta() {
76+
let mut meta = json!({"title": "Goal"});
77+
stamp_session_id(&mut meta, Some("s-1"));
78+
assert_eq!(meta["session_id"], json!("s-1"));
79+
assert_eq!(meta["title"], json!("Goal"));
80+
}
81+
82+
#[test]
83+
fn stamp_none_is_noop() {
84+
let mut meta = json!({"title": "Goal"});
85+
stamp_session_id(&mut meta, None);
86+
assert!(meta.get("session_id").is_none());
87+
}
88+
89+
#[test]
90+
fn stamp_on_non_object_is_noop() {
91+
let mut meta = Value::Null;
92+
stamp_session_id(&mut meta, Some("s-1"));
93+
assert_eq!(meta, Value::Null);
94+
}
95+
96+
#[test]
97+
fn live_payload_wins_over_env() {
98+
let _g = ENV_LOCK.lock().unwrap();
99+
std::env::set_var("CLAUDE_CODE_SESSION_ID", "from-env");
100+
let p = json!({"session_id": "from-payload"});
101+
assert_eq!(live_session_id(Some(&p)).as_deref(), Some("from-payload"));
102+
std::env::remove_var("CLAUDE_CODE_SESSION_ID");
103+
}
104+
105+
#[test]
106+
fn live_falls_back_to_env() {
107+
let _g = ENV_LOCK.lock().unwrap();
108+
std::env::set_var("CLAUDE_CODE_SESSION_ID", "from-env");
109+
let p = json!({"hook_event_name": "Stop"});
110+
assert_eq!(live_session_id(Some(&p)).as_deref(), Some("from-env"));
111+
assert_eq!(live_session_id(None).as_deref(), Some("from-env"));
112+
std::env::remove_var("CLAUDE_CODE_SESSION_ID");
113+
}
114+
115+
#[test]
116+
fn live_none_when_no_source() {
117+
let _g = ENV_LOCK.lock().unwrap();
118+
std::env::remove_var("CLAUDE_CODE_SESSION_ID");
119+
assert_eq!(live_session_id(None), None);
120+
assert_eq!(live_session_id(Some(&json!({}))), None);
121+
}
122+
}

crates/tj-mcp/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ name = "task-journal-mcp"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
tj-core = { package = "task-journal-core", version = "0.10.3", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.11.0", path = "../tj-core" }
2020
anyhow = { workspace = true }
2121
tokio = { workspace = true }
2222
tracing = { workspace = true }

crates/tj-mcp/src/main.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,10 @@ impl TaskJournalServer {
431431
p.initial_context.clone().unwrap_or_else(|| p.title.clone()),
432432
);
433433
event.meta = serde_json::json!({"title": p.title.clone()});
434+
tj_core::session_id::stamp_session_id(
435+
&mut event.meta,
436+
tj_core::session_id::session_id_from_env().as_deref(),
437+
);
434438

435439
let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?;
436440
writer.append(&event)?;
@@ -483,6 +487,10 @@ impl TaskJournalServer {
483487
);
484488
event.corrects = p.corrects.clone();
485489
event.supersedes = p.supersedes.clone();
490+
tj_core::session_id::stamp_session_id(
491+
&mut event.meta,
492+
tj_core::session_id::session_id_from_env().as_deref(),
493+
);
486494

487495
let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?;
488496
writer.append(&event)?;
@@ -557,6 +565,10 @@ impl TaskJournalServer {
557565
meta.insert("outcome_tag".into(), serde_json::Value::String(t.clone()));
558566
}
559567
event.meta = serde_json::Value::Object(meta);
568+
tj_core::session_id::stamp_session_id(
569+
&mut event.meta,
570+
tj_core::session_id::session_id_from_env().as_deref(),
571+
);
560572

561573
let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?;
562574
writer.append(&event)?;

0 commit comments

Comments
 (0)