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