Skip to main content

supercode_harness/
claude_runtime_state.rs

1//! Lossless, non-executing reconstruction of Claude Code runtime state.
2//!
3//! Claude Code persists scheduling, queue, permission, and background-agent
4//! events in the same JSONL stream as chat messages.  Those records are not
5//! part of the provider-neutral [`crate::ChatMessage`] view, but they are
6//! required to decide whether a resumed session is semantically complete.
7//! This module folds the verbatim [`crate::Session::raw`] records into a
8//! serializable manifest.  It deliberately does **not** start jobs, agents, or
9//! timers; activation is a separate policy decision.
10
11use std::collections::{BTreeMap, HashMap, VecDeque};
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use crate::{Error, Result, Session, SessionSource};
17
18/// Version of the persisted Claude runtime-state manifest schema.
19pub const CLAUDE_RUNTIME_MANIFEST_VERSION: u32 = 1;
20
21/// A pure, serializable reconstruction of runtime state in a Claude transcript.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct ClaudeRuntimeManifest {
24    /// Manifest schema version.
25    pub schema_version: u32,
26    /// Imported schedulers are always restored inert.  Merely loading this
27    /// manifest must never start a timer or child agent.
28    #[serde(default)]
29    pub execution_state: ClaudeRuntimeExecutionState,
30    /// Deterministic scheduler cursor state. Absent in manifests written
31    /// before active emulation existed and therefore defaults empty.
32    #[serde(default)]
33    pub scheduler: crate::claude_runtime_scheduler::ClaudeRuntimeSchedulerState,
34    /// Latest permission, prompt-leaf, and harness posture.
35    pub posture: ClaudeRuntimePosture,
36    /// Jobs created successfully and not subsequently cancelled.
37    pub active_crons: Vec<ClaudeCronJob>,
38    /// Successful wakeups for which no matching scheduled prompt has fired.
39    pub pending_wakeups: Vec<ClaudeWakeup>,
40    /// Queue totals and any content still enqueued at end of transcript.
41    pub queue: ClaudeQueueState,
42    /// Background Claude Agent calls and their latest known terminal/live state.
43    pub background_children: Vec<ClaudeBackgroundChild>,
44    /// Latest harness-reported background-child count, when present.
45    pub reported_pending_background_children: Option<u64>,
46    /// Verbatim runtime-affecting records, including records not interpreted.
47    pub residue: Vec<ClaudeRuntimeResidue>,
48}
49
50/// Execution posture for imported Claude runtime state.
51///
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum ClaudeRuntimeExecutionState {
55    /// State is inspectable and mutable, but no timer or prompt is executed.
56    #[default]
57    Paused,
58    /// Explicitly activated for deterministic scheduler claims.
59    Active,
60}
61
62/// Latest Claude CLI mode and permission posture.
63#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ClaudeRuntimePosture {
65    /// Latest `permission-mode.permissionMode` value.
66    pub permission_mode: Option<String>,
67    /// Latest `last-prompt.leafUuid`, identifying Claude's selected prompt leaf.
68    pub last_prompt_leaf_uuid: Option<String>,
69    /// Latest abbreviated `last-prompt.lastPrompt` value.
70    pub last_prompt: Option<String>,
71    /// Latest runtime record timestamp.
72    pub timestamp: Option<String>,
73    /// Latest Claude CLI entrypoint (normally `cli`).
74    pub entrypoint: Option<String>,
75    /// Latest Claude user posture (normally `external`).
76    pub user_type: Option<String>,
77    /// Latest Claude Code version recorded by the runtime.
78    pub version: Option<String>,
79    /// Latest working directory recorded by the runtime.
80    pub cwd: Option<String>,
81}
82
83/// One active Claude Cron job.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct ClaudeCronJob {
86    /// Harness-assigned job ID recovered from the successful tool result.
87    pub id: String,
88    /// Tool-use ID of the `CronCreate` call.
89    pub tool_use_id: String,
90    /// Cron expression supplied by the model.
91    pub schedule: String,
92    /// Whether the job repeats.
93    pub recurring: bool,
94    /// Whether durable scheduling was requested by the caller.
95    pub durable_requested: bool,
96    /// Prompt to inject when the job fires.
97    pub prompt: String,
98    /// Timestamp of the create call, when present.
99    pub created_at: Option<String>,
100    /// Relative expiry advertised by Claude's result, when recoverable.
101    pub expires_after_seconds: Option<u64>,
102    /// Exact successful tool-result text that assigned the ID.
103    pub creation_result: String,
104}
105
106/// One scheduled one-shot wakeup that has not yet observably fired.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct ClaudeWakeup {
109    /// Tool-use ID of the `ScheduleWakeup` call.
110    pub tool_use_id: String,
111    /// Requested delay in seconds.
112    pub delay_seconds: u64,
113    /// Human-readable reason supplied to the tool.
114    pub reason: Option<String>,
115    /// Prompt to inject, or the reason when Claude omitted a distinct prompt.
116    pub prompt: Option<String>,
117    /// Timestamp of the schedule call, when present.
118    pub created_at: Option<String>,
119    /// Harness-reported next wall-clock wake time, when recoverable.
120    pub scheduled_for: Option<String>,
121    /// Exact successful tool-result text.
122    pub creation_result: String,
123}
124
125/// End-of-transcript queue state.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct ClaudeQueueState {
128    /// Total enqueue records folded.
129    pub enqueued: u64,
130    /// Total dequeue records folded.
131    pub dequeued: u64,
132    /// Total explicit `remove` records folded.
133    pub removed: u64,
134    /// FIFO content left enqueued at end of transcript.
135    pub pending: Vec<String>,
136}
137
138/// Latest known state of a Claude `Agent` tool call.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct ClaudeBackgroundChild {
141    /// Parent tool-use ID.
142    pub tool_use_id: String,
143    /// Whether the originating tool call was present in this JSONL stream.
144    pub origin_observed: bool,
145    /// Claude task/agent ID, when the async launch returned one.
146    pub agent_id: Option<String>,
147    /// Agent definition requested by the parent.
148    pub agent_type: Option<String>,
149    /// Human-readable task description.
150    pub description: Option<String>,
151    /// Model requested by the parent.
152    pub requested_model: Option<String>,
153    /// Model Claude resolved for the child.
154    pub resolved_model: Option<String>,
155    /// Child prompt.
156    pub prompt: Option<String>,
157    /// Async transcript/output file, when Claude returned one.
158    pub output_file: Option<String>,
159    /// Latest known child state.
160    pub state: ClaudeBackgroundState,
161    /// Timestamp of the Agent call.
162    pub started_at: Option<String>,
163    /// Timestamp of a terminal result/notification.
164    pub finished_at: Option<String>,
165    /// Terminal summary, when present.
166    pub summary: Option<String>,
167}
168
169/// Lifecycle state recovered for a Claude background child.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum ClaudeBackgroundState {
173    /// The tool call was emitted but no launch result was observed.
174    LaunchPending,
175    /// Claude reported an async child was launched and no terminal event followed.
176    Running,
177    /// A successful synchronous result or `completed` notification was observed.
178    Completed,
179    /// Claude reported a terminal failure/error.
180    Failed,
181    /// Claude reported that the task or background command was killed.
182    Killed,
183    /// A terminal state unknown to this manifest schema was observed.
184    UnknownTerminal,
185}
186
187/// Verbatim evidence for one runtime-affecting source record.
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189pub struct ClaudeRuntimeResidue {
190    /// One-based line number in the source JSONL.
191    pub line: usize,
192    /// Stable classification, or `unknown-runtime-record` for unmodeled data.
193    pub kind: String,
194    /// Original JSONL line, byte-for-byte excluding its line terminator.
195    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    /// Reconstruct a manifest from a loaded Claude Code session without
226    /// executing or contacting a provider.
227    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        // Claude's JSONL is append-only, but asynchronous runtime results can
254        // be persisted before the assistant tool-use record that owns them.
255        // Index every runtime call first so the state fold below can still
256        // correlate those forward references. Result records remain folded
257        // in source order because that is the observable state-transition
258        // order (for example, create/delete/create of the same cron job).
259        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                // Runtime calls were indexed in the pre-pass above so a
359                // physically earlier async result can resolve its tool-use
360                // id. The main fold intentionally leaves assistant records
361                // alone rather than registering every call twice.
362                "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        // The call-index pre-pass observes assistant residue before the main
401        // fold sees other runtime records. Restore source order for the
402        // manifest's verbatim evidence stream.
403        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    /// Render this manifest as deterministic, pretty JSON for a no-provider
426    /// dry report or persistence alongside a resumed session.
427    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                // Stock Claude persists model-generated calls that fail input
498                // validation. Keep the call pending so its error result can
499                // be correlated and preserved as runtime residue; only a
500                // successful result for invalid input is malformed state.
501                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                // The successful harness result is authoritative about the
690                // job actually created. Claude may omit the optional
691                // `recurring` request field while still returning the
692                // unambiguous recurring result shape; treating the omitted
693                // field as false incorrectly deletes that job after its first
694                // delivery. Preserve legacy `Scheduled job` behavior because
695                // that older result shape does not state either mode.
696                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                // Claude exposes a single "next wakeup" slot. A later
747                // successful ScheduleWakeup supersedes an earlier one.
748                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                // Stock Claude records the structured listing in the
764                // record-level `toolUseResult`.  Supercode's synthesized
765                // Claude tail stores an intrinsic result as plain tool text,
766                // so also accept the same `{jobs:[...]}` object serialized
767                // there.  This makes a paused CronList call re-importable
768                // instead of turning a valid continued transcript into a
769                // manifest parse failure.
770                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}