1use std::collections::{BTreeMap, HashMap, VecDeque};
16
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20use crate::{Error, Result, Session, SessionSource};
21
22pub const CLAUDE_RUNTIME_MANIFEST_VERSION: u32 = 1;
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct ClaudeRuntimeManifest {
28 pub schema_version: u32,
30 pub posture: ClaudeRuntimePosture,
32 pub active_crons: Vec<ClaudeCronJob>,
34 pub pending_wakeups: Vec<ClaudeWakeup>,
36 pub queue: ClaudeQueueState,
38 pub background_children: Vec<ClaudeBackgroundChild>,
40 pub reported_pending_background_children: Option<u64>,
42 pub residue: Vec<ClaudeRuntimeResidue>,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ClaudeRuntimePosture {
49 pub permission_mode: Option<String>,
51 pub last_prompt_leaf_uuid: Option<String>,
53 pub last_prompt: Option<String>,
55 pub timestamp: Option<String>,
57 pub entrypoint: Option<String>,
59 pub user_type: Option<String>,
61 pub version: Option<String>,
63 pub cwd: Option<String>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct ClaudeCronJob {
70 pub id: String,
72 pub tool_use_id: String,
74 pub schedule: String,
76 pub recurring: bool,
78 pub durable_requested: bool,
80 pub prompt: String,
82 pub created_at: Option<String>,
84 pub expires_after_seconds: Option<u64>,
86 pub creation_result: String,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ClaudeWakeup {
93 pub tool_use_id: String,
95 pub delay_seconds: u64,
97 pub reason: Option<String>,
99 pub prompt: Option<String>,
101 pub created_at: Option<String>,
103 pub scheduled_for: Option<String>,
105 pub creation_result: String,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct ClaudeQueueState {
112 pub enqueued: u64,
114 pub dequeued: u64,
116 pub removed: u64,
118 pub pending: Vec<String>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct ClaudeBackgroundChild {
125 pub tool_use_id: String,
127 pub origin_observed: bool,
129 pub agent_id: Option<String>,
131 pub agent_type: Option<String>,
133 pub description: Option<String>,
135 pub requested_model: Option<String>,
137 pub resolved_model: Option<String>,
139 pub prompt: Option<String>,
141 pub output_file: Option<String>,
143 pub state: ClaudeBackgroundState,
145 pub started_at: Option<String>,
147 pub finished_at: Option<String>,
149 pub summary: Option<String>,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum ClaudeBackgroundState {
157 LaunchPending,
159 Running,
161 Completed,
163 Failed,
165 Killed,
167 UnknownTerminal,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct ClaudeRuntimeResidue {
174 pub line: usize,
176 pub kind: String,
178 pub raw: String,
180}
181
182#[derive(Debug, Clone)]
183enum PendingRuntimeCall {
184 Invalid {
185 name: String,
186 },
187 CronCreate {
188 tool_use_id: String,
189 schedule: String,
190 recurring: bool,
191 durable_requested: bool,
192 prompt: String,
193 created_at: Option<String>,
194 },
195 CronDelete {
196 id: String,
197 },
198 Wakeup {
199 tool_use_id: String,
200 delay_seconds: u64,
201 reason: Option<String>,
202 prompt: Option<String>,
203 created_at: Option<String>,
204 },
205 CronList,
206}
207
208impl ClaudeRuntimeManifest {
209 pub fn from_session(session: &Session) -> Result<Self> {
212 if session.meta.source != SessionSource::ClaudeCode {
213 return Err(Error::Other(
214 "Claude runtime state can only be extracted from a Claude Code session".into(),
215 ));
216 }
217 if session.parse_error_lines != 0 {
218 return Err(Error::Other(format!(
219 "cannot reconstruct Claude runtime state: {} malformed JSONL line(s)",
220 session.parse_error_lines
221 )));
222 }
223
224 let mut posture = ClaudeRuntimePosture::default();
225 let mut active_crons = BTreeMap::<String, ClaudeCronJob>::new();
226 let mut pending_calls = HashMap::<String, PendingRuntimeCall>::new();
227 let mut wakeups = BTreeMap::<String, ClaudeWakeup>::new();
228 let mut queue = VecDeque::<String>::new();
229 let mut enqueued = 0_u64;
230 let mut dequeued = 0_u64;
231 let mut removed = 0_u64;
232 let mut children = BTreeMap::<String, ClaudeBackgroundChild>::new();
233 let mut task_notifications = Vec::<(TaskNotification, Option<String>, usize)>::new();
234 let mut reported_pending_background_children = None;
235 let mut residue = Vec::new();
236
237 for (offset, raw) in session.raw.iter().enumerate() {
244 if raw.trim().is_empty() {
245 continue;
246 }
247 let line = offset + 1;
248 let value: Value = serde_json::from_str(raw).map_err(|error| {
249 Error::Other(format!(
250 "cannot reconstruct Claude runtime state: malformed JSON at line {line}: {error}"
251 ))
252 })?;
253 if value.get("type").and_then(Value::as_str) == Some("assistant") {
254 fold_assistant_calls(
255 &value,
256 line,
257 &mut pending_calls,
258 &mut children,
259 &mut residue,
260 raw,
261 )?;
262 }
263 }
264
265 for (offset, raw) in session.raw.iter().enumerate() {
266 if raw.trim().is_empty() {
267 continue;
268 }
269 let line = offset + 1;
270 let value: Value = serde_json::from_str(raw).map_err(|error| {
271 Error::Other(format!(
272 "cannot reconstruct Claude runtime state: malformed JSON at line {line}: {error}"
273 ))
274 })?;
275 update_posture(&mut posture, &value);
276 let record_type = value.get("type").and_then(Value::as_str).unwrap_or("");
277
278 match record_type {
279 "permission-mode" => {
280 let mode = required_str(&value, "permissionMode", line, "permission-mode")?;
281 posture.permission_mode = Some(mode.to_owned());
282 push_residue(&mut residue, line, "permission-mode", raw);
283 }
284 "last-prompt" => {
285 posture.last_prompt_leaf_uuid = value
286 .get("leafUuid")
287 .and_then(Value::as_str)
288 .map(str::to_owned);
289 posture.last_prompt = value
290 .get("lastPrompt")
291 .and_then(Value::as_str)
292 .map(str::to_owned);
293 push_residue(&mut residue, line, "last-prompt", raw);
294 }
295 "queue-operation" => {
296 let operation = required_str(&value, "operation", line, "queue-operation")?;
297 match operation {
298 "enqueue" => {
299 let content = required_str(&value, "content", line, "queue enqueue")?;
300 enqueued += 1;
301 queue.push_back(content.to_owned());
302 mark_matching_wakeup_fired(content, &mut wakeups);
303 if let Some(notification) = parse_task_notification(content) {
304 task_notifications.push((
305 notification,
306 value
307 .get("timestamp")
308 .and_then(Value::as_str)
309 .map(str::to_owned),
310 line,
311 ));
312 }
313 }
314 "dequeue" => {
315 dequeued += 1;
316 queue.pop_front().ok_or_else(|| {
317 Error::Other(format!(
318 "malformed Claude queue reference at line {line}: dequeue with an empty queue"
319 ))
320 })?;
321 }
322 "remove" => {
323 removed += 1;
324 queue.pop_front().ok_or_else(|| {
325 Error::Other(format!(
326 "malformed Claude queue reference at line {line}: remove with an empty queue"
327 ))
328 })?;
329 }
330 other => {
331 push_residue(
332 &mut residue,
333 line,
334 &format!("unknown-queue-operation:{other}"),
335 raw,
336 );
337 continue;
338 }
339 }
340 push_residue(&mut residue, line, "queue-operation", raw);
341 }
342 "assistant" => {}
347 "user" => {
348 fold_tool_results(
349 &value,
350 line,
351 &mut pending_calls,
352 &mut active_crons,
353 &mut wakeups,
354 &mut children,
355 &mut task_notifications,
356 &mut residue,
357 raw,
358 )?;
359 }
360 "system" => {
361 if let Some(count) = value
362 .get("pendingBackgroundAgentCount")
363 .and_then(Value::as_u64)
364 {
365 reported_pending_background_children = Some(count);
366 push_residue(&mut residue, line, "background-count", raw);
367 } else if value.get("subtype").and_then(Value::as_str)
368 == Some("scheduled_task_fire")
369 {
370 push_residue(&mut residue, line, "scheduled-task-fire", raw);
371 }
372 }
373 other if looks_runtime_type(other) => {
374 push_residue(&mut residue, line, "unknown-runtime-record", raw);
375 }
376 _ => {}
377 }
378 }
379
380 for (notification, timestamp, line) in task_notifications {
381 apply_task_notification(notification, timestamp, line, &mut children)?;
382 }
383
384 residue.sort_by_key(|record| record.line);
388
389 let pending_wakeups = wakeups.into_values().collect();
390 Ok(Self {
391 schema_version: CLAUDE_RUNTIME_MANIFEST_VERSION,
392 posture,
393 active_crons: active_crons.into_values().collect(),
394 pending_wakeups,
395 queue: ClaudeQueueState {
396 enqueued,
397 dequeued,
398 removed,
399 pending: queue.into_iter().collect(),
400 },
401 background_children: children.into_values().collect(),
402 reported_pending_background_children,
403 residue,
404 })
405 }
406
407 pub fn to_pretty_json(&self) -> Result<String> {
410 serde_json::to_string_pretty(self).map_err(Error::Decode)
411 }
412}
413
414fn update_posture(posture: &mut ClaudeRuntimePosture, value: &Value) {
415 for (key, target) in [
416 ("timestamp", &mut posture.timestamp),
417 ("entrypoint", &mut posture.entrypoint),
418 ("userType", &mut posture.user_type),
419 ("version", &mut posture.version),
420 ("cwd", &mut posture.cwd),
421 ] {
422 if let Some(text) = value.get(key).and_then(Value::as_str) {
423 *target = Some(text.to_owned());
424 }
425 }
426}
427
428fn fold_assistant_calls(
429 value: &Value,
430 line: usize,
431 pending_calls: &mut HashMap<String, PendingRuntimeCall>,
432 children: &mut BTreeMap<String, ClaudeBackgroundChild>,
433 residue: &mut Vec<ClaudeRuntimeResidue>,
434 raw: &str,
435) -> Result<()> {
436 let timestamp = value
437 .get("timestamp")
438 .and_then(Value::as_str)
439 .map(str::to_owned);
440 let Some(content) = value.pointer("/message/content").and_then(Value::as_array) else {
441 return Ok(());
442 };
443 for block in content {
444 if block.get("type").and_then(Value::as_str) != Some("tool_use") {
445 continue;
446 }
447 let Some(name) = block.get("name").and_then(Value::as_str) else {
448 continue;
449 };
450 let Some(id) = block.get("id").and_then(Value::as_str) else {
451 if matches!(
452 name,
453 "CronCreate" | "CronDelete" | "ScheduleWakeup" | "Agent"
454 ) {
455 return Err(Error::Other(format!(
456 "malformed Claude runtime tool call at line {line}: {name} has no id"
457 )));
458 }
459 continue;
460 };
461 let input = block.get("input").unwrap_or(&Value::Null);
462 let call = match name {
463 "CronCreate" => Some(PendingRuntimeCall::CronCreate {
464 tool_use_id: id.to_owned(),
465 schedule: required_str(input, "cron", line, "CronCreate")?.to_owned(),
466 recurring: input
467 .get("recurring")
468 .and_then(Value::as_bool)
469 .unwrap_or(false),
470 durable_requested: input
471 .get("durable")
472 .and_then(Value::as_bool)
473 .unwrap_or(false),
474 prompt: required_str(input, "prompt", line, "CronCreate")?.to_owned(),
475 created_at: timestamp.clone(),
476 }),
477 "CronDelete" => Some(match input.get("id").and_then(Value::as_str) {
478 Some(id) => PendingRuntimeCall::CronDelete { id: id.to_owned() },
479 None => PendingRuntimeCall::Invalid {
484 name: "CronDelete".to_owned(),
485 },
486 }),
487 "ScheduleWakeup" => Some(PendingRuntimeCall::Wakeup {
488 tool_use_id: id.to_owned(),
489 delay_seconds: input
490 .get("delaySeconds")
491 .and_then(Value::as_u64)
492 .ok_or_else(|| {
493 Error::Other(format!(
494 "malformed ScheduleWakeup at line {line}: missing integer delaySeconds"
495 ))
496 })?,
497 reason: input
498 .get("reason")
499 .and_then(Value::as_str)
500 .map(str::to_owned),
501 prompt: input
502 .get("prompt")
503 .and_then(Value::as_str)
504 .map(str::to_owned),
505 created_at: timestamp.clone(),
506 }),
507 "CronList" => Some(PendingRuntimeCall::CronList),
508 "Agent" => {
509 let child = ClaudeBackgroundChild {
510 tool_use_id: id.to_owned(),
511 origin_observed: true,
512 agent_id: None,
513 agent_type: input
514 .get("subagent_type")
515 .and_then(Value::as_str)
516 .map(str::to_owned),
517 description: input
518 .get("description")
519 .and_then(Value::as_str)
520 .map(str::to_owned),
521 requested_model: input
522 .get("model")
523 .and_then(Value::as_str)
524 .map(str::to_owned),
525 resolved_model: None,
526 prompt: input
527 .get("prompt")
528 .and_then(Value::as_str)
529 .map(str::to_owned),
530 output_file: None,
531 state: ClaudeBackgroundState::LaunchPending,
532 started_at: timestamp.clone(),
533 finished_at: None,
534 summary: None,
535 };
536 if children.insert(id.to_owned(), child).is_some() {
537 return Err(Error::Other(format!(
538 "malformed Claude Agent reference at line {line}: duplicate tool-use id {id}"
539 )));
540 }
541 push_residue(residue, line, "agent-call", raw);
542 None
543 }
544 other if other.starts_with("Cron") || other.contains("Wakeup") => {
545 push_residue(residue, line, "unknown-runtime-tool-call", raw);
546 None
547 }
548 _ => None,
549 };
550 if let Some(call) = call {
551 if pending_calls.insert(id.to_owned(), call).is_some() {
552 return Err(Error::Other(format!(
553 "malformed Claude runtime reference at line {line}: duplicate tool-use id {id}"
554 )));
555 }
556 push_residue(residue, line, "runtime-tool-call", raw);
557 }
558 }
559 Ok(())
560}
561
562#[allow(clippy::too_many_arguments)]
563fn fold_tool_results(
564 value: &Value,
565 line: usize,
566 pending_calls: &mut HashMap<String, PendingRuntimeCall>,
567 active_crons: &mut BTreeMap<String, ClaudeCronJob>,
568 wakeups: &mut BTreeMap<String, ClaudeWakeup>,
569 children: &mut BTreeMap<String, ClaudeBackgroundChild>,
570 task_notifications: &mut Vec<(TaskNotification, Option<String>, usize)>,
571 residue: &mut Vec<ClaudeRuntimeResidue>,
572 raw: &str,
573) -> Result<()> {
574 let timestamp = value
575 .get("timestamp")
576 .and_then(Value::as_str)
577 .map(str::to_owned);
578 let tool_use_result = value.get("toolUseResult");
579 let Some(content) = value.pointer("/message/content").and_then(Value::as_array) else {
580 return Ok(());
581 };
582 for block in content {
583 if block.get("type").and_then(Value::as_str) != Some("tool_result") {
584 continue;
585 }
586 let Some(tool_use_id) = block.get("tool_use_id").and_then(Value::as_str) else {
587 continue;
588 };
589 let text = tool_result_text(block.get("content"));
590 let is_error = block
591 .get("is_error")
592 .and_then(Value::as_bool)
593 .unwrap_or(false);
594
595 if let Some(child) = children.get_mut(tool_use_id) {
596 if is_error {
597 child.state = ClaudeBackgroundState::Failed;
598 child.finished_at = timestamp.clone();
599 child.summary = text.clone();
600 } else if tool_use_result
601 .and_then(|v| v.get("isAsync"))
602 .and_then(Value::as_bool)
603 == Some(true)
604 {
605 child.state = ClaudeBackgroundState::Running;
606 child.agent_id = tool_use_result
607 .and_then(|v| v.get("agentId"))
608 .and_then(Value::as_str)
609 .map(str::to_owned);
610 child.resolved_model = tool_use_result
611 .and_then(|v| v.get("resolvedModel"))
612 .and_then(Value::as_str)
613 .map(str::to_owned);
614 child.output_file = tool_use_result
615 .and_then(|v| v.get("outputFile"))
616 .and_then(Value::as_str)
617 .map(str::to_owned);
618 } else {
619 child.state = ClaudeBackgroundState::Completed;
620 child.finished_at = timestamp.clone();
621 child.summary = text.clone();
622 }
623 push_residue(residue, line, "agent-result", raw);
624 continue;
625 }
626
627 if let Some(notification) = text.as_deref().and_then(parse_task_notification) {
628 task_notifications.push((notification, timestamp.clone(), line));
629 push_residue(residue, line, "agent-notification", raw);
630 continue;
631 }
632
633 let Some(call) = pending_calls.remove(tool_use_id) else {
634 if text.as_deref().is_some_and(looks_runtime_result) {
635 return Err(Error::Other(format!(
636 "malformed Claude runtime result at line {line}: unknown tool-use id {tool_use_id}"
637 )));
638 }
639 continue;
640 };
641 push_residue(residue, line, "runtime-tool-result", raw);
642 if is_error {
643 continue;
644 }
645 let text = text.ok_or_else(|| {
646 Error::Other(format!(
647 "malformed Claude runtime result at line {line}: non-text result for {tool_use_id}"
648 ))
649 })?;
650 match call {
651 PendingRuntimeCall::Invalid { name } => {
652 return Err(Error::Other(format!(
653 "malformed {name} result at line {line}: invalid input unexpectedly succeeded"
654 )));
655 }
656 PendingRuntimeCall::CronCreate {
657 tool_use_id,
658 schedule,
659 mut recurring,
660 durable_requested,
661 prompt,
662 created_at,
663 } => {
664 let id = parse_created_cron_id(&text)
665 .ok_or_else(|| {
666 Error::Other(format!(
667 "malformed CronCreate result at line {line}: no assigned job id"
668 ))
669 })?
670 .to_owned();
671 if text.starts_with("Scheduled recurring job ") {
679 recurring = true;
680 } else if text.starts_with("Scheduled one-shot task ") {
681 recurring = false;
682 }
683 let job = ClaudeCronJob {
684 id: id.clone(),
685 tool_use_id,
686 schedule,
687 recurring,
688 durable_requested,
689 prompt,
690 created_at,
691 expires_after_seconds: text
692 .contains("Auto-expires after 7 days")
693 .then_some(7 * 24 * 60 * 60),
694 creation_result: text,
695 };
696 if active_crons.insert(id.clone(), job).is_some() {
697 return Err(Error::Other(format!(
698 "malformed CronCreate result at line {line}: duplicate active job id {id}"
699 )));
700 }
701 }
702 PendingRuntimeCall::CronDelete { id } => {
703 if !text.starts_with("Cancelled job ") {
704 return Err(Error::Other(format!(
705 "malformed CronDelete result at line {line}: unexpected success text"
706 )));
707 }
708 active_crons.remove(&id).ok_or_else(|| {
709 Error::Other(format!(
710 "malformed CronDelete reference at line {line}: unknown active job id {id}"
711 ))
712 })?;
713 }
714 PendingRuntimeCall::Wakeup {
715 tool_use_id,
716 delay_seconds,
717 reason,
718 prompt,
719 created_at,
720 } => {
721 let scheduled_for =
722 parse_between(&text, "Next wakeup scheduled for ", " (in ").map(str::to_owned);
723 if scheduled_for.is_none() {
724 return Err(Error::Other(format!(
725 "malformed ScheduleWakeup result at line {line}: no scheduled time"
726 )));
727 }
728 wakeups.clear();
731 wakeups.insert(
732 tool_use_id.clone(),
733 ClaudeWakeup {
734 tool_use_id,
735 delay_seconds,
736 reason,
737 prompt,
738 created_at,
739 scheduled_for,
740 creation_result: text,
741 },
742 );
743 }
744 PendingRuntimeCall::CronList => {
745 let text_result = serde_json::from_str::<Value>(&text).ok();
753 let jobs = tool_use_result
754 .or(text_result.as_ref())
755 .and_then(|result| result.get("jobs"))
756 .and_then(Value::as_array)
757 .ok_or_else(|| {
758 Error::Other(format!(
759 "malformed CronList result at line {line}: missing jobs array"
760 ))
761 })?;
762 let mut listed = BTreeMap::new();
763 for job in jobs {
764 let id = required_str(job, "id", line, "CronList job")?.to_owned();
765 let cron = required_str(job, "cron", line, "CronList job")?.to_owned();
766 let previous = active_crons.get(&id);
767 listed.insert(
768 id.clone(),
769 ClaudeCronJob {
770 id,
771 tool_use_id: previous
772 .map(|job| job.tool_use_id.clone())
773 .unwrap_or_else(|| tool_use_id.to_owned()),
774 schedule: cron,
775 recurring: job
776 .get("recurring")
777 .and_then(Value::as_bool)
778 .unwrap_or(false),
779 durable_requested: previous
780 .map(|job| job.durable_requested)
781 .unwrap_or_else(|| {
782 job.get("durable").and_then(Value::as_bool).unwrap_or(false)
783 }),
784 prompt: required_str(job, "prompt", line, "CronList job")?.to_owned(),
785 created_at: previous.and_then(|job| job.created_at.clone()),
786 expires_after_seconds: previous
787 .and_then(|job| job.expires_after_seconds),
788 creation_result: previous
789 .map(|job| job.creation_result.clone())
790 .unwrap_or_else(|| text.clone()),
791 },
792 );
793 }
794 *active_crons = listed;
795 }
796 }
797 }
798 Ok(())
799}
800
801fn mark_matching_wakeup_fired(content: &str, wakeups: &mut BTreeMap<String, ClaudeWakeup>) {
802 let matching = wakeups.iter().find_map(|(id, wakeup)| {
803 let prompt = wakeup.prompt.as_deref().or(wakeup.reason.as_deref());
804 (prompt == Some(content)).then(|| id.clone())
805 });
806 if let Some(id) = matching {
807 wakeups.remove(&id);
808 }
809}
810
811fn tool_result_text(content: Option<&Value>) -> Option<String> {
812 match content? {
813 Value::String(text) => Some(text.clone()),
814 Value::Array(blocks) => {
815 let joined = blocks
816 .iter()
817 .filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
818 .filter_map(|block| block.get("text").and_then(Value::as_str))
819 .collect::<Vec<_>>()
820 .join("\n");
821 (!joined.is_empty()).then_some(joined)
822 }
823 _ => None,
824 }
825}
826
827#[derive(Debug)]
828struct TaskNotification {
829 task_id: Option<String>,
830 tool_use_id: Option<String>,
831 status: Option<String>,
832 summary: Option<String>,
833}
834
835fn parse_task_notification(text: &str) -> Option<TaskNotification> {
836 text.contains("<task-notification>")
837 .then(|| TaskNotification {
838 task_id: tag_value(text, "task-id"),
839 tool_use_id: tag_value(text, "tool-use-id"),
840 status: tag_value(text, "status"),
841 summary: tag_value(text, "summary"),
842 })
843}
844
845fn apply_task_notification(
846 notification: TaskNotification,
847 timestamp: Option<String>,
848 line: usize,
849 children: &mut BTreeMap<String, ClaudeBackgroundChild>,
850) -> Result<()> {
851 let referenced = notification
852 .tool_use_id
853 .clone()
854 .or_else(|| {
855 notification.task_id.as_deref().and_then(|task_id| {
856 children
857 .iter()
858 .find(|(_, child)| child.agent_id.as_deref() == Some(task_id))
859 .map(|(id, _)| id.clone())
860 })
861 })
862 .or_else(|| notification.task_id.as_ref().map(|id| format!("task:{id}")))
863 .ok_or_else(|| {
864 Error::Other(format!(
865 "malformed Claude task notification at line {line}: missing tool-use-id and task-id"
866 ))
867 })?;
868 if !children.contains_key(&referenced) {
869 let child_type = notification
870 .summary
871 .as_deref()
872 .filter(|summary| summary.starts_with("Background command "))
873 .map(|_| "background-command")
874 .unwrap_or("unresolved-task");
875 children.insert(
876 referenced.clone(),
877 ClaudeBackgroundChild {
878 tool_use_id: referenced.clone(),
879 origin_observed: false,
880 agent_id: notification.task_id.clone(),
881 agent_type: Some(child_type.to_owned()),
882 description: notification.summary.clone(),
883 requested_model: None,
884 resolved_model: None,
885 prompt: None,
886 output_file: None,
887 state: ClaudeBackgroundState::LaunchPending,
888 started_at: None,
889 finished_at: None,
890 summary: None,
891 },
892 );
893 }
894 let child = children
895 .get_mut(&referenced)
896 .expect("child inserted or observed above");
897 child.agent_id = child.agent_id.clone().or(notification.task_id);
898 child.state = match notification.status.as_deref() {
899 Some("completed") => ClaudeBackgroundState::Completed,
900 Some("failed") | Some("error") => ClaudeBackgroundState::Failed,
901 Some("killed") => ClaudeBackgroundState::Killed,
902 Some(_) => ClaudeBackgroundState::UnknownTerminal,
903 None => ClaudeBackgroundState::UnknownTerminal,
904 };
905 child.finished_at = timestamp;
906 child.summary = notification.summary;
907 Ok(())
908}
909
910fn tag_value(text: &str, tag: &str) -> Option<String> {
911 let start = format!("<{tag}>");
912 let end = format!("</{tag}>");
913 parse_between(text, &start, &end).map(str::to_owned)
914}
915
916fn parse_created_cron_id(text: &str) -> Option<&str> {
917 let rest = text
918 .strip_prefix("Scheduled recurring job ")
919 .or_else(|| text.strip_prefix("Scheduled job "))
920 .or_else(|| text.strip_prefix("Scheduled one-shot task "))?;
921 rest.split_whitespace().next()
922}
923
924fn parse_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
925 let rest = text.split_once(start)?.1;
926 Some(rest.split_once(end)?.0)
927}
928
929fn required_str<'a>(value: &'a Value, key: &str, line: usize, kind: &str) -> Result<&'a str> {
930 value.get(key).and_then(Value::as_str).ok_or_else(|| {
931 Error::Other(format!(
932 "malformed Claude {kind} at line {line}: missing string {key}"
933 ))
934 })
935}
936
937fn looks_runtime_result(text: &str) -> bool {
938 text.starts_with("Scheduled recurring job ")
939 || text.starts_with("Scheduled job ")
940 || text.starts_with("Scheduled one-shot task ")
941 || text.starts_with("Cancelled job ")
942 || text.starts_with("Next wakeup scheduled for ")
943}
944
945fn looks_runtime_type(record_type: &str) -> bool {
946 record_type.contains("queue")
947 || record_type.contains("permission")
948 || record_type.contains("schedule")
949 || record_type.contains("cron")
950 || record_type.contains("background")
951}
952
953fn push_residue(residue: &mut Vec<ClaudeRuntimeResidue>, line: usize, kind: &str, raw: &str) {
954 residue.push(ClaudeRuntimeResidue {
955 line,
956 kind: kind.to_owned(),
957 raw: raw.to_owned(),
958 });
959}