use std::collections::{BTreeMap, HashMap, VecDeque};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{Error, Result, Session, SessionSource};
pub const CLAUDE_RUNTIME_MANIFEST_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClaudeRuntimeManifest {
pub schema_version: u32,
#[serde(default)]
pub execution_state: ClaudeRuntimeExecutionState,
#[serde(default)]
pub scheduler: crate::claude_runtime_scheduler::ClaudeRuntimeSchedulerState,
pub posture: ClaudeRuntimePosture,
pub active_crons: Vec<ClaudeCronJob>,
pub pending_wakeups: Vec<ClaudeWakeup>,
pub queue: ClaudeQueueState,
pub background_children: Vec<ClaudeBackgroundChild>,
pub reported_pending_background_children: Option<u64>,
pub residue: Vec<ClaudeRuntimeResidue>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClaudeRuntimeExecutionState {
#[default]
Paused,
Active,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClaudeRuntimePosture {
pub permission_mode: Option<String>,
pub last_prompt_leaf_uuid: Option<String>,
pub last_prompt: Option<String>,
pub timestamp: Option<String>,
pub entrypoint: Option<String>,
pub user_type: Option<String>,
pub version: Option<String>,
pub cwd: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClaudeCronJob {
pub id: String,
pub tool_use_id: String,
pub schedule: String,
pub recurring: bool,
pub durable_requested: bool,
pub prompt: String,
pub created_at: Option<String>,
pub expires_after_seconds: Option<u64>,
pub creation_result: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClaudeWakeup {
pub tool_use_id: String,
pub delay_seconds: u64,
pub reason: Option<String>,
pub prompt: Option<String>,
pub created_at: Option<String>,
pub scheduled_for: Option<String>,
pub creation_result: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClaudeQueueState {
pub enqueued: u64,
pub dequeued: u64,
pub removed: u64,
pub pending: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClaudeBackgroundChild {
pub tool_use_id: String,
pub origin_observed: bool,
pub agent_id: Option<String>,
pub agent_type: Option<String>,
pub description: Option<String>,
pub requested_model: Option<String>,
pub resolved_model: Option<String>,
pub prompt: Option<String>,
pub output_file: Option<String>,
pub state: ClaudeBackgroundState,
pub started_at: Option<String>,
pub finished_at: Option<String>,
pub summary: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClaudeBackgroundState {
LaunchPending,
Running,
Completed,
Failed,
Killed,
UnknownTerminal,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClaudeRuntimeResidue {
pub line: usize,
pub kind: String,
pub raw: String,
}
#[derive(Debug, Clone)]
enum PendingRuntimeCall {
Invalid {
name: String,
},
CronCreate {
tool_use_id: String,
schedule: String,
recurring: bool,
durable_requested: bool,
prompt: String,
created_at: Option<String>,
},
CronDelete {
id: String,
},
Wakeup {
tool_use_id: String,
delay_seconds: u64,
reason: Option<String>,
prompt: Option<String>,
created_at: Option<String>,
},
CronList,
}
impl ClaudeRuntimeManifest {
pub fn from_session(session: &Session) -> Result<Self> {
if session.meta.source != SessionSource::ClaudeCode {
return Err(Error::Other(
"Claude runtime state can only be extracted from a Claude Code session".into(),
));
}
if session.parse_error_lines != 0 {
return Err(Error::Other(format!(
"cannot reconstruct Claude runtime state: {} malformed JSONL line(s)",
session.parse_error_lines
)));
}
let mut posture = ClaudeRuntimePosture::default();
let mut active_crons = BTreeMap::<String, ClaudeCronJob>::new();
let mut pending_calls = HashMap::<String, PendingRuntimeCall>::new();
let mut wakeups = BTreeMap::<String, ClaudeWakeup>::new();
let mut queue = VecDeque::<String>::new();
let mut enqueued = 0_u64;
let mut dequeued = 0_u64;
let mut removed = 0_u64;
let mut children = BTreeMap::<String, ClaudeBackgroundChild>::new();
let mut task_notifications = Vec::<(TaskNotification, Option<String>, usize)>::new();
let mut reported_pending_background_children = None;
let mut residue = Vec::new();
for (offset, raw) in session.raw.iter().enumerate() {
if raw.trim().is_empty() {
continue;
}
let line = offset + 1;
let value: Value = serde_json::from_str(raw).map_err(|error| {
Error::Other(format!(
"cannot reconstruct Claude runtime state: malformed JSON at line {line}: {error}"
))
})?;
if value.get("type").and_then(Value::as_str) == Some("assistant") {
fold_assistant_calls(
&value,
line,
&mut pending_calls,
&mut children,
&mut residue,
raw,
)?;
}
}
for (offset, raw) in session.raw.iter().enumerate() {
if raw.trim().is_empty() {
continue;
}
let line = offset + 1;
let value: Value = serde_json::from_str(raw).map_err(|error| {
Error::Other(format!(
"cannot reconstruct Claude runtime state: malformed JSON at line {line}: {error}"
))
})?;
update_posture(&mut posture, &value);
let record_type = value.get("type").and_then(Value::as_str).unwrap_or("");
match record_type {
"permission-mode" => {
let mode = required_str(&value, "permissionMode", line, "permission-mode")?;
posture.permission_mode = Some(mode.to_owned());
push_residue(&mut residue, line, "permission-mode", raw);
}
"last-prompt" => {
posture.last_prompt_leaf_uuid = value
.get("leafUuid")
.and_then(Value::as_str)
.map(str::to_owned);
posture.last_prompt = value
.get("lastPrompt")
.and_then(Value::as_str)
.map(str::to_owned);
push_residue(&mut residue, line, "last-prompt", raw);
}
"queue-operation" => {
let operation = required_str(&value, "operation", line, "queue-operation")?;
match operation {
"enqueue" => {
let content = required_str(&value, "content", line, "queue enqueue")?;
enqueued += 1;
queue.push_back(content.to_owned());
mark_matching_wakeup_fired(content, &mut wakeups);
if let Some(notification) = parse_task_notification(content) {
task_notifications.push((
notification,
value
.get("timestamp")
.and_then(Value::as_str)
.map(str::to_owned),
line,
));
}
}
"dequeue" => {
dequeued += 1;
queue.pop_front().ok_or_else(|| {
Error::Other(format!(
"malformed Claude queue reference at line {line}: dequeue with an empty queue"
))
})?;
}
"remove" => {
removed += 1;
queue.pop_front().ok_or_else(|| {
Error::Other(format!(
"malformed Claude queue reference at line {line}: remove with an empty queue"
))
})?;
}
other => {
push_residue(
&mut residue,
line,
&format!("unknown-queue-operation:{other}"),
raw,
);
continue;
}
}
push_residue(&mut residue, line, "queue-operation", raw);
}
"assistant" => {}
"user" => {
fold_tool_results(
&value,
line,
&mut pending_calls,
&mut active_crons,
&mut wakeups,
&mut children,
&mut task_notifications,
&mut residue,
raw,
)?;
}
"system" => {
if let Some(count) = value
.get("pendingBackgroundAgentCount")
.and_then(Value::as_u64)
{
reported_pending_background_children = Some(count);
push_residue(&mut residue, line, "background-count", raw);
} else if value.get("subtype").and_then(Value::as_str)
== Some("scheduled_task_fire")
{
push_residue(&mut residue, line, "scheduled-task-fire", raw);
}
}
other if looks_runtime_type(other) => {
push_residue(&mut residue, line, "unknown-runtime-record", raw);
}
_ => {}
}
}
for (notification, timestamp, line) in task_notifications {
apply_task_notification(notification, timestamp, line, &mut children)?;
}
residue.sort_by_key(|record| record.line);
let pending_wakeups = wakeups.into_values().collect();
Ok(Self {
schema_version: CLAUDE_RUNTIME_MANIFEST_VERSION,
execution_state: ClaudeRuntimeExecutionState::Paused,
scheduler: Default::default(),
posture,
active_crons: active_crons.into_values().collect(),
pending_wakeups,
queue: ClaudeQueueState {
enqueued,
dequeued,
removed,
pending: queue.into_iter().collect(),
},
background_children: children.into_values().collect(),
reported_pending_background_children,
residue,
})
}
pub fn to_pretty_json(&self) -> Result<String> {
serde_json::to_string_pretty(self).map_err(Error::Decode)
}
}
fn update_posture(posture: &mut ClaudeRuntimePosture, value: &Value) {
for (key, target) in [
("timestamp", &mut posture.timestamp),
("entrypoint", &mut posture.entrypoint),
("userType", &mut posture.user_type),
("version", &mut posture.version),
("cwd", &mut posture.cwd),
] {
if let Some(text) = value.get(key).and_then(Value::as_str) {
*target = Some(text.to_owned());
}
}
}
fn fold_assistant_calls(
value: &Value,
line: usize,
pending_calls: &mut HashMap<String, PendingRuntimeCall>,
children: &mut BTreeMap<String, ClaudeBackgroundChild>,
residue: &mut Vec<ClaudeRuntimeResidue>,
raw: &str,
) -> Result<()> {
let timestamp = value
.get("timestamp")
.and_then(Value::as_str)
.map(str::to_owned);
let Some(content) = value.pointer("/message/content").and_then(Value::as_array) else {
return Ok(());
};
for block in content {
if block.get("type").and_then(Value::as_str) != Some("tool_use") {
continue;
}
let Some(name) = block.get("name").and_then(Value::as_str) else {
continue;
};
let Some(id) = block.get("id").and_then(Value::as_str) else {
if matches!(
name,
"CronCreate" | "CronDelete" | "ScheduleWakeup" | "Agent"
) {
return Err(Error::Other(format!(
"malformed Claude runtime tool call at line {line}: {name} has no id"
)));
}
continue;
};
let input = block.get("input").unwrap_or(&Value::Null);
let call = match name {
"CronCreate" => Some(PendingRuntimeCall::CronCreate {
tool_use_id: id.to_owned(),
schedule: required_str(input, "cron", line, "CronCreate")?.to_owned(),
recurring: input
.get("recurring")
.and_then(Value::as_bool)
.unwrap_or(false),
durable_requested: input
.get("durable")
.and_then(Value::as_bool)
.unwrap_or(false),
prompt: required_str(input, "prompt", line, "CronCreate")?.to_owned(),
created_at: timestamp.clone(),
}),
"CronDelete" => Some(match input.get("id").and_then(Value::as_str) {
Some(id) => PendingRuntimeCall::CronDelete { id: id.to_owned() },
None => PendingRuntimeCall::Invalid {
name: "CronDelete".to_owned(),
},
}),
"ScheduleWakeup" => Some(PendingRuntimeCall::Wakeup {
tool_use_id: id.to_owned(),
delay_seconds: input
.get("delaySeconds")
.and_then(Value::as_u64)
.ok_or_else(|| {
Error::Other(format!(
"malformed ScheduleWakeup at line {line}: missing integer delaySeconds"
))
})?,
reason: input
.get("reason")
.and_then(Value::as_str)
.map(str::to_owned),
prompt: input
.get("prompt")
.and_then(Value::as_str)
.map(str::to_owned),
created_at: timestamp.clone(),
}),
"CronList" => Some(PendingRuntimeCall::CronList),
"Agent" => {
let child = ClaudeBackgroundChild {
tool_use_id: id.to_owned(),
origin_observed: true,
agent_id: None,
agent_type: input
.get("subagent_type")
.and_then(Value::as_str)
.map(str::to_owned),
description: input
.get("description")
.and_then(Value::as_str)
.map(str::to_owned),
requested_model: input
.get("model")
.and_then(Value::as_str)
.map(str::to_owned),
resolved_model: None,
prompt: input
.get("prompt")
.and_then(Value::as_str)
.map(str::to_owned),
output_file: None,
state: ClaudeBackgroundState::LaunchPending,
started_at: timestamp.clone(),
finished_at: None,
summary: None,
};
if children.insert(id.to_owned(), child).is_some() {
return Err(Error::Other(format!(
"malformed Claude Agent reference at line {line}: duplicate tool-use id {id}"
)));
}
push_residue(residue, line, "agent-call", raw);
None
}
other if other.starts_with("Cron") || other.contains("Wakeup") => {
push_residue(residue, line, "unknown-runtime-tool-call", raw);
None
}
_ => None,
};
if let Some(call) = call {
if pending_calls.insert(id.to_owned(), call).is_some() {
return Err(Error::Other(format!(
"malformed Claude runtime reference at line {line}: duplicate tool-use id {id}"
)));
}
push_residue(residue, line, "runtime-tool-call", raw);
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn fold_tool_results(
value: &Value,
line: usize,
pending_calls: &mut HashMap<String, PendingRuntimeCall>,
active_crons: &mut BTreeMap<String, ClaudeCronJob>,
wakeups: &mut BTreeMap<String, ClaudeWakeup>,
children: &mut BTreeMap<String, ClaudeBackgroundChild>,
task_notifications: &mut Vec<(TaskNotification, Option<String>, usize)>,
residue: &mut Vec<ClaudeRuntimeResidue>,
raw: &str,
) -> Result<()> {
let timestamp = value
.get("timestamp")
.and_then(Value::as_str)
.map(str::to_owned);
let tool_use_result = value.get("toolUseResult");
let Some(content) = value.pointer("/message/content").and_then(Value::as_array) else {
return Ok(());
};
for block in content {
if block.get("type").and_then(Value::as_str) != Some("tool_result") {
continue;
}
let Some(tool_use_id) = block.get("tool_use_id").and_then(Value::as_str) else {
continue;
};
let text = tool_result_text(block.get("content"));
let is_error = block
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false);
if let Some(child) = children.get_mut(tool_use_id) {
if is_error {
child.state = ClaudeBackgroundState::Failed;
child.finished_at = timestamp.clone();
child.summary = text.clone();
} else if tool_use_result
.and_then(|v| v.get("isAsync"))
.and_then(Value::as_bool)
== Some(true)
{
child.state = ClaudeBackgroundState::Running;
child.agent_id = tool_use_result
.and_then(|v| v.get("agentId"))
.and_then(Value::as_str)
.map(str::to_owned);
child.resolved_model = tool_use_result
.and_then(|v| v.get("resolvedModel"))
.and_then(Value::as_str)
.map(str::to_owned);
child.output_file = tool_use_result
.and_then(|v| v.get("outputFile"))
.and_then(Value::as_str)
.map(str::to_owned);
} else {
child.state = ClaudeBackgroundState::Completed;
child.finished_at = timestamp.clone();
child.summary = text.clone();
}
push_residue(residue, line, "agent-result", raw);
continue;
}
if let Some(notification) = text.as_deref().and_then(parse_task_notification) {
task_notifications.push((notification, timestamp.clone(), line));
push_residue(residue, line, "agent-notification", raw);
continue;
}
let Some(call) = pending_calls.remove(tool_use_id) else {
if text.as_deref().is_some_and(looks_runtime_result) {
return Err(Error::Other(format!(
"malformed Claude runtime result at line {line}: unknown tool-use id {tool_use_id}"
)));
}
continue;
};
push_residue(residue, line, "runtime-tool-result", raw);
if is_error {
continue;
}
let text = text.ok_or_else(|| {
Error::Other(format!(
"malformed Claude runtime result at line {line}: non-text result for {tool_use_id}"
))
})?;
match call {
PendingRuntimeCall::Invalid { name } => {
return Err(Error::Other(format!(
"malformed {name} result at line {line}: invalid input unexpectedly succeeded"
)));
}
PendingRuntimeCall::CronCreate {
tool_use_id,
schedule,
mut recurring,
durable_requested,
prompt,
created_at,
} => {
let id = parse_created_cron_id(&text)
.ok_or_else(|| {
Error::Other(format!(
"malformed CronCreate result at line {line}: no assigned job id"
))
})?
.to_owned();
if text.starts_with("Scheduled recurring job ") {
recurring = true;
} else if text.starts_with("Scheduled one-shot task ") {
recurring = false;
}
let job = ClaudeCronJob {
id: id.clone(),
tool_use_id,
schedule,
recurring,
durable_requested,
prompt,
created_at,
expires_after_seconds: text
.contains("Auto-expires after 7 days")
.then_some(7 * 24 * 60 * 60),
creation_result: text,
};
if active_crons.insert(id.clone(), job).is_some() {
return Err(Error::Other(format!(
"malformed CronCreate result at line {line}: duplicate active job id {id}"
)));
}
}
PendingRuntimeCall::CronDelete { id } => {
if !text.starts_with("Cancelled job ") {
return Err(Error::Other(format!(
"malformed CronDelete result at line {line}: unexpected success text"
)));
}
active_crons.remove(&id).ok_or_else(|| {
Error::Other(format!(
"malformed CronDelete reference at line {line}: unknown active job id {id}"
))
})?;
}
PendingRuntimeCall::Wakeup {
tool_use_id,
delay_seconds,
reason,
prompt,
created_at,
} => {
let scheduled_for =
parse_between(&text, "Next wakeup scheduled for ", " (in ").map(str::to_owned);
if scheduled_for.is_none() {
return Err(Error::Other(format!(
"malformed ScheduleWakeup result at line {line}: no scheduled time"
)));
}
wakeups.clear();
wakeups.insert(
tool_use_id.clone(),
ClaudeWakeup {
tool_use_id,
delay_seconds,
reason,
prompt,
created_at,
scheduled_for,
creation_result: text,
},
);
}
PendingRuntimeCall::CronList => {
let text_result = serde_json::from_str::<Value>(&text).ok();
let jobs = tool_use_result
.or(text_result.as_ref())
.and_then(|result| result.get("jobs"))
.and_then(Value::as_array)
.ok_or_else(|| {
Error::Other(format!(
"malformed CronList result at line {line}: missing jobs array"
))
})?;
let mut listed = BTreeMap::new();
for job in jobs {
let id = required_str(job, "id", line, "CronList job")?.to_owned();
let cron = required_str(job, "cron", line, "CronList job")?.to_owned();
let previous = active_crons.get(&id);
listed.insert(
id.clone(),
ClaudeCronJob {
id,
tool_use_id: previous
.map(|job| job.tool_use_id.clone())
.unwrap_or_else(|| tool_use_id.to_owned()),
schedule: cron,
recurring: job
.get("recurring")
.and_then(Value::as_bool)
.unwrap_or(false),
durable_requested: previous
.map(|job| job.durable_requested)
.unwrap_or_else(|| {
job.get("durable").and_then(Value::as_bool).unwrap_or(false)
}),
prompt: required_str(job, "prompt", line, "CronList job")?.to_owned(),
created_at: previous.and_then(|job| job.created_at.clone()),
expires_after_seconds: previous
.and_then(|job| job.expires_after_seconds),
creation_result: previous
.map(|job| job.creation_result.clone())
.unwrap_or_else(|| text.clone()),
},
);
}
*active_crons = listed;
}
}
}
Ok(())
}
fn mark_matching_wakeup_fired(content: &str, wakeups: &mut BTreeMap<String, ClaudeWakeup>) {
let matching = wakeups.iter().find_map(|(id, wakeup)| {
let prompt = wakeup.prompt.as_deref().or(wakeup.reason.as_deref());
(prompt == Some(content)).then(|| id.clone())
});
if let Some(id) = matching {
wakeups.remove(&id);
}
}
fn tool_result_text(content: Option<&Value>) -> Option<String> {
match content? {
Value::String(text) => Some(text.clone()),
Value::Array(blocks) => {
let joined = blocks
.iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|block| block.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n");
(!joined.is_empty()).then_some(joined)
}
_ => None,
}
}
#[derive(Debug)]
struct TaskNotification {
task_id: Option<String>,
tool_use_id: Option<String>,
status: Option<String>,
summary: Option<String>,
}
fn parse_task_notification(text: &str) -> Option<TaskNotification> {
text.contains("<task-notification>")
.then(|| TaskNotification {
task_id: tag_value(text, "task-id"),
tool_use_id: tag_value(text, "tool-use-id"),
status: tag_value(text, "status"),
summary: tag_value(text, "summary"),
})
}
fn apply_task_notification(
notification: TaskNotification,
timestamp: Option<String>,
line: usize,
children: &mut BTreeMap<String, ClaudeBackgroundChild>,
) -> Result<()> {
let referenced = notification
.tool_use_id
.clone()
.or_else(|| {
notification.task_id.as_deref().and_then(|task_id| {
children
.iter()
.find(|(_, child)| child.agent_id.as_deref() == Some(task_id))
.map(|(id, _)| id.clone())
})
})
.or_else(|| notification.task_id.as_ref().map(|id| format!("task:{id}")))
.ok_or_else(|| {
Error::Other(format!(
"malformed Claude task notification at line {line}: missing tool-use-id and task-id"
))
})?;
if !children.contains_key(&referenced) {
let child_type = notification
.summary
.as_deref()
.filter(|summary| summary.starts_with("Background command "))
.map(|_| "background-command")
.unwrap_or("unresolved-task");
children.insert(
referenced.clone(),
ClaudeBackgroundChild {
tool_use_id: referenced.clone(),
origin_observed: false,
agent_id: notification.task_id.clone(),
agent_type: Some(child_type.to_owned()),
description: notification.summary.clone(),
requested_model: None,
resolved_model: None,
prompt: None,
output_file: None,
state: ClaudeBackgroundState::LaunchPending,
started_at: None,
finished_at: None,
summary: None,
},
);
}
let child = children
.get_mut(&referenced)
.expect("child inserted or observed above");
child.agent_id = child.agent_id.clone().or(notification.task_id);
child.state = match notification.status.as_deref() {
Some("completed") => ClaudeBackgroundState::Completed,
Some("failed") | Some("error") => ClaudeBackgroundState::Failed,
Some("killed") => ClaudeBackgroundState::Killed,
Some(_) => ClaudeBackgroundState::UnknownTerminal,
None => ClaudeBackgroundState::UnknownTerminal,
};
child.finished_at = timestamp;
child.summary = notification.summary;
Ok(())
}
fn tag_value(text: &str, tag: &str) -> Option<String> {
let start = format!("<{tag}>");
let end = format!("</{tag}>");
parse_between(text, &start, &end).map(str::to_owned)
}
fn parse_created_cron_id(text: &str) -> Option<&str> {
let rest = text
.strip_prefix("Scheduled recurring job ")
.or_else(|| text.strip_prefix("Scheduled job "))
.or_else(|| text.strip_prefix("Scheduled one-shot task "))?;
rest.split_whitespace().next()
}
fn parse_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
let rest = text.split_once(start)?.1;
Some(rest.split_once(end)?.0)
}
fn required_str<'a>(value: &'a Value, key: &str, line: usize, kind: &str) -> Result<&'a str> {
value.get(key).and_then(Value::as_str).ok_or_else(|| {
Error::Other(format!(
"malformed Claude {kind} at line {line}: missing string {key}"
))
})
}
fn looks_runtime_result(text: &str) -> bool {
text.starts_with("Scheduled recurring job ")
|| text.starts_with("Scheduled job ")
|| text.starts_with("Scheduled one-shot task ")
|| text.starts_with("Cancelled job ")
|| text.starts_with("Next wakeup scheduled for ")
}
fn looks_runtime_type(record_type: &str) -> bool {
record_type.contains("queue")
|| record_type.contains("permission")
|| record_type.contains("schedule")
|| record_type.contains("cron")
|| record_type.contains("background")
}
fn push_residue(residue: &mut Vec<ClaudeRuntimeResidue>, line: usize, kind: &str, raw: &str) {
residue.push(ClaudeRuntimeResidue {
line,
kind: kind.to_owned(),
raw: raw.to_owned(),
});
}