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, and nothing downstream of it does either: supercode records a
10//! Claude session's schedules and never fires them.  Compiling them into a
11//! scheduler that does is `supercode orchestrator import --from
12//! claude-session`, an explicit operator act against the orchestrator's own
13//! job store.
14
15use std::collections::{BTreeMap, HashMap, VecDeque};
16
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20use crate::{Error, Result, Session, SessionSource};
21
22/// Version of the persisted Claude runtime-state manifest schema.
23pub const CLAUDE_RUNTIME_MANIFEST_VERSION: u32 = 1;
24
25/// A pure, serializable reconstruction of runtime state in a Claude transcript.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct ClaudeRuntimeManifest {
28    /// Manifest schema version.
29    pub schema_version: u32,
30    /// Latest permission, prompt-leaf, and harness posture.
31    pub posture: ClaudeRuntimePosture,
32    /// Jobs created successfully and not subsequently cancelled.
33    pub active_crons: Vec<ClaudeCronJob>,
34    /// Successful wakeups for which no matching scheduled prompt has fired.
35    pub pending_wakeups: Vec<ClaudeWakeup>,
36    /// Queue totals and any content still enqueued at end of transcript.
37    pub queue: ClaudeQueueState,
38    /// Background Claude Agent calls and their latest known terminal/live state.
39    pub background_children: Vec<ClaudeBackgroundChild>,
40    /// Latest harness-reported background-child count, when present.
41    pub reported_pending_background_children: Option<u64>,
42    /// Verbatim runtime-affecting records, including records not interpreted.
43    pub residue: Vec<ClaudeRuntimeResidue>,
44}
45
46/// Latest Claude CLI mode and permission posture.
47#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ClaudeRuntimePosture {
49    /// Latest `permission-mode.permissionMode` value.
50    pub permission_mode: Option<String>,
51    /// Latest `last-prompt.leafUuid`, identifying Claude's selected prompt leaf.
52    pub last_prompt_leaf_uuid: Option<String>,
53    /// Latest abbreviated `last-prompt.lastPrompt` value.
54    pub last_prompt: Option<String>,
55    /// Latest runtime record timestamp.
56    pub timestamp: Option<String>,
57    /// Latest Claude CLI entrypoint (normally `cli`).
58    pub entrypoint: Option<String>,
59    /// Latest Claude user posture (normally `external`).
60    pub user_type: Option<String>,
61    /// Latest Claude Code version recorded by the runtime.
62    pub version: Option<String>,
63    /// Latest working directory recorded by the runtime.
64    pub cwd: Option<String>,
65}
66
67/// One active Claude Cron job.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct ClaudeCronJob {
70    /// Harness-assigned job ID recovered from the successful tool result.
71    pub id: String,
72    /// Tool-use ID of the `CronCreate` call.
73    pub tool_use_id: String,
74    /// Cron expression supplied by the model.
75    pub schedule: String,
76    /// Whether the job repeats.
77    pub recurring: bool,
78    /// Whether durable scheduling was requested by the caller.
79    pub durable_requested: bool,
80    /// Prompt to inject when the job fires.
81    pub prompt: String,
82    /// Timestamp of the create call, when present.
83    pub created_at: Option<String>,
84    /// Relative expiry advertised by Claude's result, when recoverable.
85    pub expires_after_seconds: Option<u64>,
86    /// Exact successful tool-result text that assigned the ID.
87    pub creation_result: String,
88}
89
90/// One scheduled one-shot wakeup that has not yet observably fired.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ClaudeWakeup {
93    /// Tool-use ID of the `ScheduleWakeup` call.
94    pub tool_use_id: String,
95    /// Requested delay in seconds.
96    pub delay_seconds: u64,
97    /// Human-readable reason supplied to the tool.
98    pub reason: Option<String>,
99    /// Prompt to inject, or the reason when Claude omitted a distinct prompt.
100    pub prompt: Option<String>,
101    /// Timestamp of the schedule call, when present.
102    pub created_at: Option<String>,
103    /// Harness-reported next wall-clock wake time, when recoverable.
104    pub scheduled_for: Option<String>,
105    /// Exact successful tool-result text.
106    pub creation_result: String,
107}
108
109/// End-of-transcript queue state.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct ClaudeQueueState {
112    /// Total enqueue records folded.
113    pub enqueued: u64,
114    /// Total dequeue records folded.
115    pub dequeued: u64,
116    /// Total explicit `remove` records folded.
117    pub removed: u64,
118    /// FIFO content left enqueued at end of transcript.
119    pub pending: Vec<String>,
120}
121
122/// Latest known state of a Claude `Agent` tool call.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct ClaudeBackgroundChild {
125    /// Parent tool-use ID.
126    pub tool_use_id: String,
127    /// Whether the originating tool call was present in this JSONL stream.
128    pub origin_observed: bool,
129    /// Claude task/agent ID, when the async launch returned one.
130    pub agent_id: Option<String>,
131    /// Agent definition requested by the parent.
132    pub agent_type: Option<String>,
133    /// Human-readable task description.
134    pub description: Option<String>,
135    /// Model requested by the parent.
136    pub requested_model: Option<String>,
137    /// Model Claude resolved for the child.
138    pub resolved_model: Option<String>,
139    /// Child prompt.
140    pub prompt: Option<String>,
141    /// Async transcript/output file, when Claude returned one.
142    pub output_file: Option<String>,
143    /// Latest known child state.
144    pub state: ClaudeBackgroundState,
145    /// Timestamp of the Agent call.
146    pub started_at: Option<String>,
147    /// Timestamp of a terminal result/notification.
148    pub finished_at: Option<String>,
149    /// Terminal summary, when present.
150    pub summary: Option<String>,
151}
152
153/// Lifecycle state recovered for a Claude background child.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum ClaudeBackgroundState {
157    /// The tool call was emitted but no launch result was observed.
158    LaunchPending,
159    /// Claude reported an async child was launched and no terminal event followed.
160    Running,
161    /// A successful synchronous result or `completed` notification was observed.
162    Completed,
163    /// Claude reported a terminal failure/error.
164    Failed,
165    /// Claude reported that the task or background command was killed.
166    Killed,
167    /// A terminal state unknown to this manifest schema was observed.
168    UnknownTerminal,
169}
170
171/// Verbatim evidence for one runtime-affecting source record.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct ClaudeRuntimeResidue {
174    /// One-based line number in the source JSONL.
175    pub line: usize,
176    /// Stable classification, or `unknown-runtime-record` for unmodeled data.
177    pub kind: String,
178    /// Original JSONL line, byte-for-byte excluding its line terminator.
179    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    /// Reconstruct a manifest from a loaded Claude Code session without
210    /// executing or contacting a provider.
211    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        // Claude's JSONL is append-only, but asynchronous runtime results can
238        // be persisted before the assistant tool-use record that owns them.
239        // Index every runtime call first so the state fold below can still
240        // correlate those forward references. Result records remain folded
241        // in source order because that is the observable state-transition
242        // order (for example, create/delete/create of the same cron job).
243        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                // Runtime calls were indexed in the pre-pass above so a
343                // physically earlier async result can resolve its tool-use
344                // id. The main fold intentionally leaves assistant records
345                // alone rather than registering every call twice.
346                "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        // The call-index pre-pass observes assistant residue before the main
385        // fold sees other runtime records. Restore source order for the
386        // manifest's verbatim evidence stream.
387        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    /// Render this manifest as deterministic, pretty JSON for a no-provider
408    /// dry report or persistence alongside a resumed session.
409    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                // Stock Claude persists model-generated calls that fail input
480                // validation. Keep the call pending so its error result can
481                // be correlated and preserved as runtime residue; only a
482                // successful result for invalid input is malformed state.
483                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                // The successful harness result is authoritative about the
672                // job actually created. Claude may omit the optional
673                // `recurring` request field while still returning the
674                // unambiguous recurring result shape; treating the omitted
675                // field as false incorrectly deletes that job after its first
676                // delivery. Preserve legacy `Scheduled job` behavior because
677                // that older result shape does not state either mode.
678                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                // Claude exposes a single "next wakeup" slot. A later
729                // successful ScheduleWakeup supersedes an earlier one.
730                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                // Stock Claude records the structured listing in the
746                // record-level `toolUseResult`.  Supercode's synthesized
747                // Claude tail stores an intrinsic result as plain tool text,
748                // so also accept the same `{jobs:[...]}` object serialized
749                // there.  This makes a paused CronList call re-importable
750                // instead of turning a valid continued transcript into a
751                // manifest parse failure.
752                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}