cc-transcript 10.0.0__tar.gz → 10.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/Cargo.lock +3 -2
  2. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/PKG-INFO +1 -1
  3. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/activity.py +5 -3
  4. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/activity_probe.py +20 -23
  5. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/pyproject.toml +1 -1
  6. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/Cargo.toml +4 -2
  7. cc_transcript-10.2.0/rust/build.rs +20 -0
  8. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/activity.rs +260 -28
  9. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/mining.rs +2 -8
  10. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/parse.rs +16 -3
  11. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/Cargo.toml +0 -0
  12. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/LICENSE +0 -0
  13. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/README.md +0 -0
  14. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/__init__.py +0 -0
  15. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/__main__.py +0 -0
  16. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/_parser_rs.pyi +0 -0
  17. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/backend.py +0 -0
  18. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/builders.py +0 -0
  19. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/cli.py +0 -0
  20. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/command.py +0 -0
  21. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/context.py +0 -0
  22. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/corrections.py +0 -0
  23. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/corrections_cli.py +0 -0
  24. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/cost.py +0 -0
  25. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/decisions.py +0 -0
  26. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/discovery.py +0 -0
  27. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/disktruth.py +0 -0
  28. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/evidence.py +0 -0
  29. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/extract/__init__.py +0 -0
  30. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/extract/correct.py +0 -0
  31. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/facts.py +0 -0
  32. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/filterspec.py +0 -0
  33. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/ids.py +0 -0
  34. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/judge/__init__.py +0 -0
  35. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/judge/llm.py +0 -0
  36. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/judge/similar.py +0 -0
  37. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/judge/verdicts.py +0 -0
  38. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/__init__.py +0 -0
  39. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/candidates.py +0 -0
  40. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/confidence.py +0 -0
  41. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/engine.py +0 -0
  42. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/filterspec.py +0 -0
  43. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/formats.py +0 -0
  44. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/signals.py +0 -0
  45. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/sourcekind.py +0 -0
  46. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/spec.py +0 -0
  47. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/store.py +0 -0
  48. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/models.py +0 -0
  49. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/notifications.py +0 -0
  50. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/parser.py +0 -0
  51. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/py.typed +0 -0
  52. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/query.py +0 -0
  53. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/render.py +0 -0
  54. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/rust.py +0 -0
  55. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/sentiment/__init__.py +0 -0
  56. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/sentiment/buckets.py +0 -0
  57. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/sentiment/engine.py +0 -0
  58. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/sentiment/lexicon.py +0 -0
  59. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/sentiment/scorespec.py +0 -0
  60. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/store.py +0 -0
  61. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/tools.py +0 -0
  62. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/data/afinn-en-165.tsv +0 -0
  63. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/data/domain_overrides.tsv +0 -0
  64. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/command.rs +0 -0
  65. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/event.rs +0 -0
  66. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/filter.rs +0 -0
  67. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/lexicon.rs +0 -0
  68. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/lib.rs +0 -0
  69. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/model.rs +0 -0
  70. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/protocol.rs +0 -0
  71. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/python.rs +0 -0
  72. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/score.rs +0 -0
  73. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/types.rs +0 -0
  74. {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/value.rs +0 -0
@@ -72,7 +72,7 @@ dependencies = [
72
72
 
73
73
  [[package]]
74
74
  name = "cc_transcript_parser"
75
- version = "0.8.0"
75
+ version = "0.9.0"
76
76
  dependencies = [
77
77
  "chrono",
78
78
  "crossbeam-channel",
@@ -81,6 +81,7 @@ dependencies = [
81
81
  "num_cpus",
82
82
  "once_cell",
83
83
  "pyo3",
84
+ "pyo3-build-config",
84
85
  "rayon",
85
86
  "regex",
86
87
  "sonic-rs",
@@ -91,7 +92,7 @@ dependencies = [
91
92
 
92
93
  [[package]]
93
94
  name = "cc_transcript_swift"
94
- version = "0.8.0"
95
+ version = "0.9.0"
95
96
  dependencies = [
96
97
  "cc_transcript_parser",
97
98
  "swift-bridge",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-transcript
3
- Version: 10.0.0
3
+ Version: 10.2.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -39,10 +39,12 @@ UserClassifier = Callable[["UserEvent"], bool]
39
39
  def native_user_classifier(event: UserEvent) -> bool:
40
40
  """Whether a user event is a real prompt under native Claude Code semantics.
41
41
 
42
- A prompt is non-meta, non-sidechain, not an interruption marker, and not
43
- tool-result-only — it must carry real text.
42
+ A prompt is non-meta, non-sidechain, not a compact summary, not an
43
+ interruption marker, and not tool-result-only — it must carry real text.
44
44
  """
45
- return not (event.meta.is_meta or event.meta.is_sidechain or event.interrupted) and bool(event.text.strip())
45
+ return not (
46
+ event.meta.is_meta or event.meta.is_sidechain or event.meta.is_compact_summary or event.interrupted
47
+ ) and bool(event.text.strip())
46
48
 
47
49
 
48
50
  @dataclass(frozen=True, slots=True)
@@ -4,11 +4,14 @@
4
4
  to the Python reference. :func:`session_activity_probe` is the public dual-backend entry:
5
5
  it reads a ``.jsonl`` transcript, runs the oracle over its parsed events, and returns a
6
6
  :class:`SessionActivityProbe`; :func:`probe_events` is the Python reference the Rust probe
7
- stays identical to. The verdict spans ephemeral waits (waiting tools, backgrounded
8
- Agent/Task/Bash, subagentless Agent/Task) over the current turn plus pending async
9
- launches (async Agent/Task, live Workflow) session-wide, with completion read from
10
- queue-operation ``<tool-use-id>`` markers, and flags unanswered non-human-facing tool
11
- calls in the current turn as mid-tool.
7
+ stays identical to. The verdict spans undelivered task notifications (the
8
+ :class:`~cc_transcript.notifications.Notifications` queue replay), ephemeral waits
9
+ (waiting tools, backgrounded Agent/Task/Bash, subagentless Agent/Task) over the current
10
+ turn, and pending async launches (async Agent/Task, live Workflow) session-wide — a
11
+ launch counts as completed only once its notification was delivered or drained, never
12
+ when merely enqueued — and flags unanswered non-human-facing tool calls in the current
13
+ turn as mid-tool. Compact-summary user lines do not open turns, deliberately leading
14
+ captain-hook, which still lets compaction close the turn.
12
15
  """
13
16
 
14
17
  from __future__ import annotations
@@ -20,12 +23,12 @@ from typing import TYPE_CHECKING, Literal
20
23
  from cc_transcript.activity import native_user_classifier
21
24
  from cc_transcript.models import (
22
25
  AssistantEvent,
23
- OtherEvent,
24
26
  SystemEvent,
25
27
  ToolResultBlock,
26
28
  ToolUseBlock,
27
29
  UserEvent,
28
30
  )
31
+ from cc_transcript.notifications import Notifications
29
32
  from cc_transcript.parser import parse_events_from_bytes
30
33
 
31
34
  if TYPE_CHECKING:
@@ -81,11 +84,6 @@ class SessionActivityProbe:
81
84
  last_event_epoch: int | None
82
85
 
83
86
 
84
- def completed(completions: Sequence[str], tool_use_id: str) -> bool:
85
- marker = f"<tool-use-id>{tool_use_id}</tool-use-id>"
86
- return any(marker in content for content in completions)
87
-
88
-
89
87
  def ephemeral_wait(block: ToolUseBlock, waiting_tools: frozenset[str]) -> PendingKind | None:
90
88
  if block.name in waiting_tools:
91
89
  return "waiting_tool"
@@ -98,12 +96,12 @@ def ephemeral_wait(block: ToolUseBlock, waiting_tools: frozenset[str]) -> Pendin
98
96
 
99
97
 
100
98
  def pending_async(
101
- block: ToolUseBlock, result: ToolResultBlock | None, completions: Sequence[str]
99
+ block: ToolUseBlock, result: ToolResultBlock | None, notifications: Notifications
102
100
  ) -> PendingKind | None:
103
101
  match block.name:
104
- case "Agent" | "Task" if result is not None and result.is_async and not completed(completions, block.id):
102
+ case "Agent" | "Task" if result is not None and result.is_async and not notifications.completed(block.id):
105
103
  return "pending_async_task"
106
- case "Workflow" if not completed(completions, block.id):
104
+ case "Workflow" if not notifications.completed(block.id):
107
105
  return "pending_async_workflow"
108
106
  return None
109
107
 
@@ -114,7 +112,11 @@ def probe_events(
114
112
  waiting_tools: frozenset[str] = DEFAULT_WAITING_TOOLS,
115
113
  human_facing_tools: frozenset[str] = DEFAULT_HUMAN_FACING_TOOLS,
116
114
  ) -> SessionActivityProbe:
117
- """The Python reference oracle over parsed events, twin of the Rust probe."""
115
+ """The Python reference oracle over parsed events, twin of the Rust probe.
116
+
117
+ An undelivered task notification alone sets ``is_waiting`` with no pending
118
+ item — a resumed session's orphan has no launch to point at.
119
+ """
118
120
  results = {
119
121
  block.tool_use_id: block
120
122
  for event in events
@@ -122,12 +124,7 @@ def probe_events(
122
124
  for block in event.blocks
123
125
  if isinstance(block, ToolResultBlock)
124
126
  }
125
- completions = [
126
- content
127
- for event in events
128
- if isinstance(event, OtherEvent) and event.type == "queue-operation"
129
- if isinstance(content := event.raw.get("content"), str)
130
- ]
127
+ notifications = Notifications.from_events(events)
131
128
  turn_start = next(
132
129
  (
133
130
  index
@@ -137,7 +134,7 @@ def probe_events(
137
134
  0,
138
135
  )
139
136
 
140
- is_waiting = False
137
+ is_waiting = notifications.has_pending
141
138
  mid_tool = False
142
139
  pending: list[PendingItem] = []
143
140
  seen: set[str] = set()
@@ -152,7 +149,7 @@ def probe_events(
152
149
  continue
153
150
  in_current_turn = index >= turn_start
154
151
  waiting_kind = (ephemeral_wait(block, waiting_tools) if in_current_turn else None) or pending_async(
155
- block, result, completions
152
+ block, result, notifications
156
153
  )
157
154
  unmatched = in_current_turn and result is None and block.name not in human_facing_tools
158
155
  is_waiting = is_waiting or waiting_kind is not None
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cc-transcript"
3
- version = "10.0.0"
3
+ version = "10.2.0"
4
4
  description = "Grep every Claude Code session you've ever run."
5
5
  readme = "README.md"
6
6
  license = "PolyForm-Noncommercial-1.0.0"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "cc_transcript_parser"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  edition = "2021"
5
5
 
6
6
  [lib]
@@ -20,7 +20,6 @@ python = [
20
20
  "dep:tree-sitter-bash",
21
21
  ]
22
22
  extension-module = ["python", "pyo3/extension-module"]
23
- swift = []
24
23
 
25
24
  [dependencies]
26
25
  pyo3 = { version = "0.28", features = ["abi3-py313", "chrono"], optional = true }
@@ -38,3 +37,6 @@ dirs = { version = "5", optional = true }
38
37
  # bash grammar), tree-sitter core 0.26 matches the wheel's tree-sitter 0.26.0.
39
38
  tree-sitter = { version = "0.26", optional = true }
40
39
  tree-sitter-bash = { version = "0.25", optional = true }
40
+
41
+ [build-dependencies]
42
+ pyo3-build-config = { version = "0.28", features = ["resolve-config"] }
@@ -0,0 +1,20 @@
1
+ // Local `cargo test` on the default (python) feature links a shared libpython
2
+ // whose install name is `@rpath/libpython3.X.dylib`. The cargo test harness
3
+ // carries no rpath to the interpreter's lib dir, so the binary aborts at load.
4
+ // Emit that rpath, derived from the same interpreter pyo3 linked. Gated off when
5
+ // `extension-module` is active, so the shipped wheel (which links no libpython
6
+ // and resolves symbols from the host interpreter) keeps its exact build flags.
7
+ fn main() {
8
+ let python = std::env::var_os("CARGO_FEATURE_PYTHON").is_some();
9
+ let extension_module = std::env::var_os("CARGO_FEATURE_EXTENSION_MODULE").is_some();
10
+ if !python || extension_module {
11
+ return;
12
+ }
13
+ println!("cargo:rerun-if-env-changed=PYO3_PYTHON");
14
+ println!("cargo:rerun-if-env-changed=PYO3_CONFIG_FILE");
15
+ let lib_dir = pyo3_build_config::get()
16
+ .lib_dir
17
+ .as_ref()
18
+ .expect("pyo3 linked a shared libpython but reported no lib_dir for the test rpath");
19
+ println!("cargo:rustc-link-arg=-Wl,-rpath,{lib_dir}");
20
+ }
@@ -1,7 +1,9 @@
1
1
  use std::collections::{HashMap, HashSet};
2
2
 
3
3
  use crate::types::{Entry, ToolResultBlock, ToolUseBlock, UserEntry};
4
- use crate::value::field_str;
4
+ use crate::value::{field, field_str};
5
+
6
+ const NOTIFICATION_MARKER: &str = "<task-notification>";
5
7
 
6
8
  pub struct ActivityOpts {
7
9
  pub waiting_tools: HashSet<String>,
@@ -59,9 +61,10 @@ pub struct SessionActivity {
59
61
 
60
62
  /// Whether a user entry opens a turn (activity.py ``native_user_classifier``):
61
63
  /// a real prompt — non-meta, non-sidechain, not an interruption marker, with
62
- /// non-blank text.
64
+ /// non-blank text. Compact-summary exclusion deliberately leads captain-hook,
65
+ /// which still lets compaction close the turn; convergence is a follow-up there.
63
66
  fn opens_turn(user: &UserEntry) -> bool {
64
- !(user.meta.is_meta || user.meta.is_sidechain || user.interrupted())
67
+ !(user.meta.is_meta || user.meta.is_sidechain || user.meta.is_compact_summary || user.interrupted())
65
68
  && !user.content.text().trim().is_empty()
66
69
  }
67
70
 
@@ -90,51 +93,114 @@ fn ephemeral_wait(tool_use: &ToolUseBlock, waiting_tools: &HashSet<String>) -> O
90
93
  }
91
94
 
92
95
  /// conditions.py ``pending_async``: an async Agent/Task launch or a live
93
- /// Workflow with no completion notification yet.
96
+ /// Workflow whose completion notification has not reached the agent.
94
97
  fn pending_async(
95
98
  tool_use: &ToolUseBlock,
96
99
  result: Option<&ToolResultBlock>,
97
- completions: &[&str],
100
+ notifications: &Notifications,
98
101
  ) -> Option<PendingKind> {
99
102
  match tool_use.name.as_str() {
100
103
  "Agent" | "Task"
101
- if result.is_some_and(|r| r.is_async) && !completed(completions, &tool_use.id) =>
104
+ if result.is_some_and(|r| r.is_async) && !notifications.completed(&tool_use.id) =>
102
105
  {
103
106
  Some(PendingKind::PendingAsyncTask)
104
107
  }
105
- "Workflow" if !completed(completions, &tool_use.id) => Some(PendingKind::PendingAsyncWorkflow),
108
+ "Workflow" if !notifications.completed(&tool_use.id) => {
109
+ Some(PendingKind::PendingAsyncWorkflow)
110
+ }
106
111
  _ => None,
107
112
  }
108
113
  }
109
114
 
110
- fn completion_contents(entries: &[Entry]) -> Vec<&str> {
111
- entries
112
- .iter()
113
- .filter_map(|entry| match entry {
114
- Entry::Other(other) if other.ty == "queue-operation" => field_str(&other.raw, "content"),
115
- _ => None,
116
- })
117
- .collect()
115
+ /// notifications.py ``Notifications``: the harness delivery queue replayed
116
+ /// from the transcript's ``queue-operation`` audit records.
117
+ struct Notifications {
118
+ queued: Vec<String>,
119
+ delivered: Vec<String>,
120
+ enqueued: Vec<String>,
118
121
  }
119
122
 
120
- fn completed(contents: &[&str], id: &str) -> bool {
121
- let marker = format!("<tool-use-id>{id}</tool-use-id>");
122
- contents.iter().any(|content| content.contains(&marker))
123
+ impl Notifications {
124
+ fn from_entries(entries: &[Entry]) -> Self {
125
+ let mut queued: Vec<String> = Vec::new();
126
+ let mut delivered: Vec<String> = Vec::new();
127
+ let mut enqueued: Vec<String> = Vec::new();
128
+ for entry in entries {
129
+ if let Entry::Other(other) = entry {
130
+ if other.ty == "queue-operation" {
131
+ match field_str(&other.raw, "operation") {
132
+ Some("enqueue") => {
133
+ let content =
134
+ field_str(&other.raw, "content").unwrap_or_default().to_string();
135
+ enqueued.push(content.clone());
136
+ queued.push(content);
137
+ }
138
+ Some("dequeue" | "remove") if !queued.is_empty() => {
139
+ queued.remove(0);
140
+ }
141
+ Some("popAll") => {
142
+ let content = field_str(&other.raw, "content").unwrap_or_default();
143
+ queued.retain(|item| !content.contains(item.as_str()));
144
+ }
145
+ _ => {}
146
+ }
147
+ }
148
+ }
149
+ if let Some(text) = delivered_text(entry) {
150
+ delivered.push(text);
151
+ }
152
+ }
153
+ Self { queued, delivered, enqueued }
154
+ }
155
+
156
+ /// notifications.py ``Notifications.completed``: delivered, or enqueued at
157
+ /// some point yet no longer sitting undelivered in the queue.
158
+ fn completed(&self, tool_use_id: &str) -> bool {
159
+ let marker = format!("<tool-use-id>{tool_use_id}</tool-use-id>");
160
+ let holds = |texts: &[String]| texts.iter().any(|text| text.contains(&marker));
161
+ holds(&self.delivered) || (holds(&self.enqueued) && !holds(&self.queued))
162
+ }
163
+
164
+ /// notifications.py ``Notifications.has_pending``: any queued item is an
165
+ /// undelivered task notification.
166
+ fn has_pending(&self) -> bool {
167
+ self.queued.iter().any(|text| text.contains(NOTIFICATION_MARKER))
168
+ }
169
+ }
170
+
171
+ /// notifications.py ``delivered_text``: a user turn carrying a task
172
+ /// notification, or a ``queued_command`` attachment replayed to the model.
173
+ fn delivered_text(entry: &Entry) -> Option<String> {
174
+ match entry {
175
+ Entry::User(user) => {
176
+ let text = user.content.text();
177
+ text.contains(NOTIFICATION_MARKER).then_some(text)
178
+ }
179
+ Entry::Other(other) if other.ty == "attachment" => {
180
+ let attachment = field(&other.raw, "attachment")?;
181
+ (field_str(attachment, "type") == Some("queued_command"))
182
+ .then(|| field_str(attachment, "prompt").unwrap_or_default().to_string())
183
+ }
184
+ _ => None,
185
+ }
123
186
  }
124
187
 
125
188
  /// The session-activity oracle over parsed entries: captain-hook's
126
- /// ``is_waiting`` verdict over ephemeral waits and pending async launches,
127
- /// the mid-tool flag, and the contributing tool calls.
189
+ /// ``is_waiting`` verdict (post-d2e07cc, minus its hook-side Stop-payload
190
+ /// layer) over undelivered notifications, ephemeral waits, and pending async
191
+ /// launches, plus the mid-tool flag and the contributing tool calls. An
192
+ /// undelivered notification alone sets ``is_waiting`` with no pending item —
193
+ /// a resumed session's orphan has no launch to point at.
128
194
  pub fn session_activity(entries: &[Entry], opts: &ActivityOpts) -> SessionActivity {
129
195
  let results: HashMap<&str, &ToolResultBlock> = entries
130
196
  .iter()
131
197
  .flat_map(Entry::tool_results)
132
198
  .map(|result| (result.tool_use_id.as_str(), result))
133
199
  .collect();
134
- let completions = completion_contents(entries);
200
+ let notifications = Notifications::from_entries(entries);
135
201
  let turn_start = current_turn_start(entries);
136
202
 
137
- let mut is_waiting = false;
203
+ let mut is_waiting = notifications.has_pending();
138
204
  let mut mid_tool = false;
139
205
  let mut pending: Vec<PendingItem> = Vec::new();
140
206
  let mut seen: HashSet<&str> = HashSet::new();
@@ -148,7 +214,7 @@ pub fn session_activity(entries: &[Entry], opts: &ActivityOpts) -> SessionActivi
148
214
  let waiting_kind = in_current_turn
149
215
  .then(|| ephemeral_wait(tool_use, &opts.waiting_tools))
150
216
  .flatten()
151
- .or_else(|| pending_async(tool_use, result, &completions));
217
+ .or_else(|| pending_async(tool_use, result, &notifications));
152
218
  let unmatched = in_current_turn
153
219
  && result.is_none()
154
220
  && !opts.human_facing_tools.contains(&tool_use.name);
@@ -215,12 +281,27 @@ mod tests {
215
281
  }
216
282
 
217
283
  fn queue_op(content: &str) -> Entry {
284
+ queue_entry("enqueue", content)
285
+ }
286
+
287
+ fn queue_entry(operation: &str, content: &str) -> Entry {
218
288
  parse(&format!(
219
- r#"{{"type":"queue-operation","operation":"enqueue","content":{}}}"#,
289
+ r#"{{"type":"queue-operation","operation":"{operation}","content":{}}}"#,
220
290
  sonic_rs::to_string(&content).unwrap()
221
291
  ))
222
292
  }
223
293
 
294
+ fn attachment(prompt: &str) -> Entry {
295
+ parse(&format!(
296
+ r#"{{"type":"attachment","attachment":{{"type":"queued_command","prompt":{}}}}}"#,
297
+ sonic_rs::to_string(&prompt).unwrap()
298
+ ))
299
+ }
300
+
301
+ fn delivered_notification(id: &str) -> Vec<Entry> {
302
+ vec![queue_op(&notification(id)), queue_entry("dequeue", ""), user(&notification(id))]
303
+ }
304
+
224
305
  fn notification(id: &str) -> String {
225
306
  format!(
226
307
  "<task-notification><task-id>t</task-id><tool-use-id>{id}</tool-use-id><status>completed</status></task-notification>"
@@ -252,18 +333,169 @@ mod tests {
252
333
  }
253
334
 
254
335
  #[test]
255
- fn workflow_completion_marker_clears_waiting() {
336
+ fn workflow_delivered_notification_clears_waiting() {
337
+ let mut entries = vec![
338
+ user("run the workflow"),
339
+ tool_use("Workflow", "wf1", r#"{"script":"return 1"}"#),
340
+ tool_result("wf1"),
341
+ ];
342
+ entries.extend(delivered_notification("wf1"));
343
+ let activity = activity(&entries);
344
+ assert!(!activity.is_waiting);
345
+ assert!(activity.pending.is_empty());
346
+ }
347
+
348
+ #[test]
349
+ fn enqueued_undelivered_notification_keeps_waiting() {
350
+ let entries = vec![
351
+ user("run the workflow"),
352
+ tool_use("Workflow", "wf1", r#"{"script":"return 1"}"#),
353
+ tool_result("wf1"),
354
+ queue_op(&notification("wf1")),
355
+ ];
356
+ let activity = activity(&entries);
357
+ assert!(activity.is_waiting);
358
+ assert_eq!(
359
+ activity.pending,
360
+ [PendingItem {
361
+ tool_use_id: Some("wf1".to_string()),
362
+ name: "Workflow".to_string(),
363
+ kind: PendingKind::PendingAsyncWorkflow
364
+ }]
365
+ );
366
+ }
367
+
368
+ #[test]
369
+ fn removed_notification_counts_completed() {
256
370
  let entries = vec![
257
371
  user("run the workflow"),
258
372
  tool_use("Workflow", "wf1", r#"{"script":"return 1"}"#),
259
373
  tool_result("wf1"),
260
374
  queue_op(&notification("wf1")),
375
+ queue_entry("remove", ""),
261
376
  ];
262
377
  let activity = activity(&entries);
263
378
  assert!(!activity.is_waiting);
264
379
  assert!(activity.pending.is_empty());
265
380
  }
266
381
 
382
+ #[test]
383
+ fn popall_drains_command_but_not_notification() {
384
+ let notif = notification("wf1");
385
+ let entries = vec![
386
+ user("run the workflow"),
387
+ tool_use("Workflow", "wf1", r#"{"script":"return 1"}"#),
388
+ tool_result("wf1"),
389
+ queue_op(&notif),
390
+ queue_op("run the tests please"),
391
+ queue_entry("popAll", "run the tests please"),
392
+ ];
393
+ let notifications = Notifications::from_entries(&entries);
394
+ assert_eq!(
395
+ notifications.queued,
396
+ vec![notif],
397
+ "popAll drains the matching command yet leaves the notification queued"
398
+ );
399
+ assert!(notifications.has_pending());
400
+ let activity = activity(&entries);
401
+ assert!(activity.is_waiting);
402
+ assert_eq!(
403
+ activity.pending,
404
+ [PendingItem {
405
+ tool_use_id: Some("wf1".to_string()),
406
+ name: "Workflow".to_string(),
407
+ kind: PendingKind::PendingAsyncWorkflow
408
+ }]
409
+ );
410
+ }
411
+
412
+ #[test]
413
+ fn popall_drains_the_notification_completes() {
414
+ let notif = notification("wf1");
415
+ let entries = vec![
416
+ user("run the workflow"),
417
+ tool_use("Workflow", "wf1", r#"{"script":"return 1"}"#),
418
+ tool_result("wf1"),
419
+ queue_op(&notif),
420
+ queue_entry("popAll", &notif),
421
+ ];
422
+ let notifications = Notifications::from_entries(&entries);
423
+ assert!(
424
+ notifications.queued.is_empty(),
425
+ "popAll subtracts the notification itself out of the queue"
426
+ );
427
+ assert!(
428
+ notifications.completed("wf1"),
429
+ "an enqueued-then-drained notification counts as completed"
430
+ );
431
+ assert!(!notifications.has_pending());
432
+ let activity = activity(&entries);
433
+ assert!(!activity.is_waiting, "draining the notification closes the workflow's async wait");
434
+ assert!(!activity.mid_tool);
435
+ assert!(activity.pending.is_empty());
436
+ }
437
+
438
+ #[test]
439
+ fn orphan_undelivered_notification_is_waiting() {
440
+ let entries = vec![user("hi"), queue_op(&notification("tu_ghost"))];
441
+ let activity = activity(&entries);
442
+ assert!(activity.is_waiting, "a queued task notification holds the session on its own");
443
+ assert!(!activity.mid_tool);
444
+ assert!(activity.pending.is_empty());
445
+ }
446
+
447
+ #[test]
448
+ fn attachment_delivery_completes() {
449
+ let entries = vec![
450
+ user("go"),
451
+ tool_use("Agent", "a1", r#"{"subagent_type":"Explore","prompt":"look"}"#),
452
+ result_entry("a1", false, true),
453
+ attachment(&notification("a1")),
454
+ ];
455
+ let activity = activity(&entries);
456
+ assert!(!activity.is_waiting, "the queued_command attachment alone closes the async task");
457
+ assert!(!activity.mid_tool);
458
+ assert!(activity.pending.is_empty());
459
+ let expected = chrono::DateTime::parse_from_rfc3339("2026-01-02T03:04:07Z")
460
+ .unwrap()
461
+ .timestamp();
462
+ assert_eq!(activity.last_event_epoch, Some(expected));
463
+ }
464
+
465
+ #[test]
466
+ fn plain_user_delivery_completes() {
467
+ let entries = vec![
468
+ user("go"),
469
+ tool_use("Agent", "a1", r#"{"subagent_type":"Explore","prompt":"look"}"#),
470
+ result_entry("a1", false, true),
471
+ user(&notification("a1")),
472
+ ];
473
+ let activity = activity(&entries);
474
+ assert!(!activity.is_waiting, "a user turn carrying the notification closes the async task");
475
+ assert!(!activity.mid_tool);
476
+ assert!(activity.pending.is_empty());
477
+ let expected = chrono::DateTime::parse_from_rfc3339("2026-01-02T03:04:07Z")
478
+ .unwrap()
479
+ .timestamp();
480
+ assert_eq!(activity.last_event_epoch, Some(expected));
481
+ }
482
+
483
+ #[test]
484
+ fn compact_summary_user_does_not_open_turn() {
485
+ let entries = vec![
486
+ user("build it"),
487
+ tool_use("Bash", "b1", r#"{"command":"make","run_in_background":true}"#),
488
+ tool_result("b1"),
489
+ user_with("compact recap", r#""isCompactSummary":true,"#),
490
+ ];
491
+ let activity = activity(&entries);
492
+ assert!(
493
+ activity.is_waiting,
494
+ "auto-compaction must not retire a running background task"
495
+ );
496
+ assert_eq!(activity.pending[0].kind, PendingKind::Background);
497
+ }
498
+
267
499
  #[test]
268
500
  fn unrelated_completion_marker_keeps_waiting() {
269
501
  let entries = vec![
@@ -349,14 +581,14 @@ mod tests {
349
581
  }
350
582
 
351
583
  #[test]
352
- fn async_agent_completion_marker_clears_waiting() {
353
- let entries = vec![
584
+ fn async_agent_delivered_notification_clears_waiting() {
585
+ let mut entries = vec![
354
586
  user("go"),
355
587
  tool_use("Agent", "a1", r#"{"subagent_type":"Explore","prompt":"look"}"#),
356
588
  result_entry("a1", false, true),
357
589
  user("while that runs, plan"),
358
- queue_op(&notification("a1")),
359
590
  ];
591
+ entries.extend(delivered_notification("a1"));
360
592
  assert!(!activity(&entries).is_waiting);
361
593
  }
362
594
 
@@ -8,7 +8,7 @@ use regex::Regex;
8
8
  use sonic_rs::{Index, JsonContainerTrait, JsonValueTrait, Value};
9
9
 
10
10
  use crate::filter::compile_group_array;
11
- use crate::parse::{parse_entry, ParseError};
11
+ use crate::parse::{parse_bytes, ParseError};
12
12
  use crate::protocol::{
13
13
  embedded_user_text, interrupt_marker, is_bare_interrupt_marker, ANSWERED_PREFIX, ANSWERED_TRAILER,
14
14
  DENIAL_PREFIX, INTERRUPT_MARKER_RE,
@@ -434,13 +434,7 @@ impl Events {
434
434
  /// non-object values (parser.py decode_line) so event indices agree with the
435
435
  /// Python reference; a malformed entry fails the mine, as in the reference.
436
436
  fn parse(raw: &[u8]) -> Result<Self, ParseError> {
437
- let entries = raw
438
- .split(|&b| b == b'\n')
439
- .filter(|line| !line.iter().all(u8::is_ascii_whitespace))
440
- .filter_map(|line| sonic_rs::from_slice::<Value>(line).ok())
441
- .filter(|value| value.is_object())
442
- .map(parse_entry)
443
- .collect::<Result<Vec<_>, _>>()?;
437
+ let entries = parse_bytes(raw, |_| true)?;
444
438
  let texts = entries
445
439
  .iter()
446
440
  .map(|entry| match entry {
@@ -265,9 +265,10 @@ pub fn parse_entry(data: Value) -> Result<Entry, ParseError> {
265
265
  Ok(Entry::Other(OtherEntry { ty, raw: data }))
266
266
  }
267
267
 
268
- // Lines that are not valid JSON are skipped; a JSON line that fails the typed
269
- // parse (e.g. a missing required field) fails the whole file whole-file
270
- // parity with PythonBackend, which parses every line before filtering.
268
+ // Non-JSON lines and valid-JSON lines that are not objects (bare scalars or
269
+ // arrays) are skipped; a JSON object that fails the typed parse (e.g. a missing
270
+ // required field) fails the whole file whole-file parity with PythonBackend,
271
+ // which decodes every line, skips non-objects, then parses the rest.
271
272
  fn parse_line<F: Fn(&Entry) -> bool>(
272
273
  line: &[u8],
273
274
  lines: &mut Vec<Entry>,
@@ -277,6 +278,9 @@ fn parse_line<F: Fn(&Entry) -> bool>(
277
278
  return Ok(());
278
279
  }
279
280
  if let Ok(value) = sonic_rs::from_slice::<Value>(line) {
281
+ if !value.is_object() {
282
+ return Ok(());
283
+ }
280
284
  let entry = parse_entry(value)?;
281
285
  if keep(&entry) {
282
286
  lines.push(entry);
@@ -614,6 +618,15 @@ mod tests {
614
618
  assert_eq!(other.raw, parse(raw));
615
619
  }
616
620
 
621
+ #[test]
622
+ fn parse_bytes_skips_valid_json_non_object_lines() {
623
+ let real = format!(r#"{{"type":"user",{META},"message":{{"content":"hi"}}}}"#);
624
+ let bytes = format!("{real}\n42\n\"bare\"\n[1,2,3]\n{real}");
625
+ let entries = parse_bytes(bytes.as_bytes(), |_| true).unwrap();
626
+ assert_eq!(entries.len(), 2, "bare scalar and array lines are skipped");
627
+ assert!(entries.iter().all(|e| matches!(e, Entry::User(_))));
628
+ }
629
+
617
630
  #[test]
618
631
  fn print_envelope_requires_result_element() {
619
632
  assert!(matches!(
File without changes
File without changes