1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
//! `wait --until` condition grammar: parse + per-event matching.
use super::*;
/// One parsed `--until` condition.
#[derive(Debug)]
pub(crate) enum Cond {
/// Verdict becomes idle-eot or stale-dead - a TRUE stop. `idle-background-open`
/// (the turn ended but a background task the lens counts has not returned) never
/// satisfies it; narrow the lens or wait for the timeout.
Stop,
/// Verdict becomes waiting-hitl.
Hitl,
/// The elicitation sidecar gains an unanswered question (or a native AUQ ask lands).
Auq,
/// A task-notification is delivered (normally to the MAIN transcript; a child lane
/// receives one when the harness routes it to the owning agent - 2 of 2906
/// delivered records in the reference corpus at Claude Code 2.1.258, v0.10.3),
/// optionally payload-matched.
Notification(Option<regex::Regex>),
/// A `tool_use` of NAME appears whose serialized input matches (any watched lane).
Tool {
name: String,
input_re: Option<regex::Regex>,
},
/// A Write/Edit/MultiEdit/NotebookEdit whose path matches (and whose written content
/// contains a line matching, when given).
Write {
path_re: regex::Regex,
line_re: Option<regex::Regex>,
},
/// Any verdict from the status table.
VerdictIs(Verdict),
}
impl Cond {
/// True when this condition needs a fresh VERDICT evaluation each poll (vs a
/// record-event match on appended lines).
#[must_use]
pub(crate) fn needs_verdict(&self) -> bool {
matches!(self, Cond::Stop | Cond::Hitl | Cond::VerdictIs(_))
}
}
/// Parse one `--until` token. The grammar is closed; an unknown head is a hard error
/// naming the set (never a silent no-op condition).
pub(crate) fn parse_condition(s: &str) -> Result<Cond> {
let mk_re = |p: &str, what: &str| -> Result<regex::Regex> {
regex::Regex::new(p).map_err(|e| anyhow::anyhow!("--until {what}: bad regex `{p}`: {e}"))
};
if let Some(rest) = s.strip_prefix("notification") {
return Ok(match rest.strip_prefix(':') {
Some(re) => Cond::Notification(Some(mk_re(re, "notification")?)),
None if rest.is_empty() => Cond::Notification(None),
_ => bail!("--until: unknown condition `{s}` (did you mean `notification:{rest}`?)"),
});
}
if let Some(rest) = s.strip_prefix("tool:") {
let mut it = rest.splitn(2, ':');
let name = it.next().unwrap_or_default();
if name.is_empty() {
bail!("--until tool: needs a tool NAME (`tool:NAME[:REGEX]`)");
}
let input_re = it.next().map(|re| mk_re(re, "tool")).transpose()?;
return Ok(Cond::Tool {
name: name.to_string(),
input_re,
});
}
if let Some(rest) = s.strip_prefix("write:") {
let mut it = rest.splitn(2, ':');
let path = it.next().unwrap_or_default();
if path.is_empty() {
bail!("--until write: needs a path regex (`write:PATH_RE[:LINE_RE]`)");
}
let line_re = it.next().map(|re| mk_re(re, "write")).transpose()?;
return Ok(Cond::Write {
path_re: mk_re(path, "write")?,
line_re,
});
}
if let Some(v) = s.strip_prefix("verdict:") {
let verdict = match v {
"running" => Verdict::Running,
"waiting-children" => Verdict::WaitingChildren,
"waiting-hitl" => Verdict::WaitingHitl,
"idle-background-open" => Verdict::IdleBackgroundOpen,
"idle-eot" => Verdict::IdleEot,
"stale-dead" => Verdict::StaleDead,
"unknown" => Verdict::Unknown,
other => bail!(
"--until verdict: unknown verdict `{other}` (running | waiting-children | \
waiting-hitl | idle-background-open | idle-eot | stale-dead | unknown)"
),
};
return Ok(Cond::VerdictIs(verdict));
}
match s {
"stop" => Ok(Cond::Stop),
"hitl" => Ok(Cond::Hitl),
"auq" => Ok(Cond::Auq),
other => bail!(
"--until: unknown condition `{other}`. The set: stop | hitl | auq | \
notification[:REGEX] | tool:NAME[:REGEX] | write:PATH_RE[:LINE_RE] | \
verdict:V"
),
}
}
/// Match a freshly appended RECORD line against the record-event conditions (every
/// watched lane; v0.10.3 dropped the main-only scope on the notification carrier).
pub(crate) fn record_matches(cond: &Cond, rec: &crate::model::Record) -> bool {
match cond {
Cond::Notification(re) => {
// Any watched lane: the harness normally delivers to the main transcript, but
// a pulse addressed to the owning agent lands in that agent's lane (v0.10.3).
// The idle delivery is a user record; a pulse absorbed mid-turn lands ONLY
// on a queue-operation enqueue line and a queued_command attachment
// (v0.10.2: the three carriers `background_scan` joins, not the user
// record alone - the agents-stopped notice stays a user-record label).
let mut labels = crate::live::delivered_pulse_labels(rec);
if labels.is_empty() {
let Some(label) = rec.automation_label() else {
return false;
};
labels.push(label);
}
re.as_ref().is_none_or(|r| {
labels.iter().any(|l| r.is_match(l))
|| rec
.reconstructed_user_text(None)
.as_deref()
.is_some_and(|t| r.is_match(t))
|| crate::live::carrier_text(rec)
.as_deref()
.is_some_and(|t| r.is_match(t))
})
}
Cond::Auq => {
// The sidecar ask (real-time) OR a native AskUserQuestion tool_use landing
// (an answered AUQ's buffered turn - post-hoc but still the ask on disk).
if rec.is_elicitation_marker() {
return rec.csift_phase.as_deref() == Some("pending");
}
rec.blocks().is_some_and(|bs| {
bs.iter().any(|b| {
matches!(b, crate::model::Block::ToolUse { name: Some(n), .. }
if n == "AskUserQuestion")
})
})
}
Cond::Tool { name, input_re } => rec.blocks().is_some_and(|bs| {
bs.iter().any(|b| match b {
crate::model::Block::ToolUse {
name: Some(n),
input,
..
} if n == name => input_re.as_ref().is_none_or(|re| {
input
.as_ref()
.map(|i| re.is_match(&i.to_string()))
.unwrap_or(false)
}),
_ => false,
})
}),
Cond::Write { path_re, line_re } => rec.blocks().is_some_and(|bs| {
bs.iter().any(|b| match b {
crate::model::Block::ToolUse {
name: Some(n),
input: Some(input),
..
} if matches!(n.as_str(), "Write" | "Edit" | "MultiEdit" | "NotebookEdit") => {
let path = input
.get("file_path")
.or_else(|| input.get("notebook_path"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if !path_re.is_match(path) {
return false;
}
line_re.as_ref().is_none_or(|re| {
["content", "new_string", "new_source"].iter().any(|k| {
input
.get(*k)
.and_then(serde_json::Value::as_str)
.is_some_and(|c| c.lines().any(|l| re.is_match(l)))
})
})
}
_ => false,
})
}),
// Verdict-class conditions are evaluated on the assessment, not per record.
Cond::Stop | Cond::Hitl | Cond::VerdictIs(_) => false,
}
}
/// Match a fresh assessment against the verdict-class conditions.
pub(crate) fn verdict_matches(cond: &Cond, verdict: Verdict) -> bool {
match cond {
Cond::Stop => matches!(verdict, Verdict::IdleEot | Verdict::StaleDead),
Cond::Hitl => verdict == Verdict::WaitingHitl,
Cond::VerdictIs(v) => verdict == *v,
_ => false,
}
}