scsh 1.41.19

Scoped Skills Helper — preflight a git repo and run its scoped skills in ephemeral containers.
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
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
//! Session detail page with per-proc output panels.

use super::escape::esc;
use super::fleet::fleet_sections_by_anchor;
use super::layout::wrap_page;
use super::proc::{cast_embed_html, proc_elapsed_phrase, proc_has_cast, proc_meta_html, summary_stats_html};
use super::workflow::{proc_task_anchor_html, proc_task_attrs, workflow_graph_html};
use crate::daemon::model::{ProcStatus, Session, SessionLifecycle, Store};
use crate::daemon::paths::now_unix_secs;

pub fn session_page(store: &Store, session_id: &str) -> Option<String> {
  Some(session_page_for(store.sessions.get(session_id)?, store.port))
}

/// The session page from a session record alone — the render path shared by live sessions
/// (looked up in the store) and archived ones (read back from the store DB after eviction).
pub fn session_page_for(session: &Session, port: u16) -> String {
  let now = now_unix_secs();
  let mut procs_html = String::new();
  let mut fleet_sections = fleet_sections_by_anchor(session);
  for proc in &session.procs {
    let detail = proc.detail.as_deref().unwrap_or("");
    let elapsed = proc_elapsed_phrase(proc, now);
    // The collapsed row's trailing text: once the proc FINISHED we know its answer (the
    // finish detail — a result message like "2 + 3 = 5"), so show that; the transient
    // "<harness> run…" note is only for rows still working. A bare artifact path is SYSTEM
    // info and renders as code, so the eye can tell it from an agent's prose answer.
    let terminating = matches!(
      proc.fail_reason.as_deref(),
      Some(crate::failure::reason::STOP_REQUESTED) | Some(crate::failure::reason::RESTART_REQUESTED)
    );
    let finished = !matches!(proc.status, ProcStatus::Running | ProcStatus::Waiting);
    let note = if finished && !detail.is_empty() { detail } else { proc.note.as_deref().unwrap_or("") };
    let note_html =
      if finished && looks_like_artifact_path(note) { format!("<code>{}</code>", esc(note)) } else { esc(note) };
    // Recorded procs (skills and image builds alike — scsh records builds itself) show
    // the inline cast player; a proc without a recording — annotate rows are the
    // canonical case — stays a slim summary-only row. There is deliberately no text-log
    // body: the cast IS the output format.
    let body_html = if proc_has_cast(proc) { cast_embed_html(&session.id, proc) } else { String::new() };
    let snapshot_btn = proc_snapshot_btn_html(&session.id, proc);
    let diff_btn = proc_diff_btn_html(&session.id, proc);
    let annotation_target = annotation_target_link_html(session, proc);
    let attempt_chip = attempt_chip_html(session, proc);
    let retry_link = retry_link_html(session, proc);
    let original_link = original_attempt_link_html(session, proc);
    let task_anchor = proc_task_anchor_html(session, proc);
    procs_html.push_str(&format!(
      r#"<details class="chamfer proc {status_class}" id="proc-{index}" data-index="{index}"{task_attrs}>
<div class="proc-actions">{diff_btn}{snapshot_btn}{restart_btn}{kill_btn}</div>
<summary>
{task_anchor}
<span class="triangle" aria-hidden="true"></span>
<span class="label">{label}</span>{attempt_chip} {proc_stat}
<span class="meta" data-proc-elapsed="{index}">{elapsed}</span>{retry_link}{original_link}
<span class="note dim">{note}</span>
{annotation_target}
</summary>
{proc_meta}
<div class="detail">{detail}</div>
{container_line}
{body_html}
</details>
"#,
      status_class = if terminating { "terminating" } else { proc.status.as_str() },
      index = proc.index,
      task_attrs = proc_task_attrs(session, proc),
      task_anchor = task_anchor,
      label = esc(&proc.label),
      proc_stat = summary_stats_html(proc, now),
      diff_btn = diff_btn,
      annotation_target = annotation_target,
      snapshot_btn = snapshot_btn,
      restart_btn = proc_restart_btn_html(session, now, proc),
      kill_btn = proc_kill_btn_html(session, now, proc),
      proc_meta = proc_meta_html(proc),
      elapsed = esc(&elapsed),
      note = note_html,
      detail = esc(detail),
      container_line = container_line_html(proc),
      attempt_chip = attempt_chip,
      original_link = original_link,
      retry_link = retry_link,
      body_html = body_html
    ));
    if let Some(fleets) = fleet_sections.remove(&proc.index) {
      procs_html.push_str(&fleets);
    }
  }
  let id = esc(&session.id);
  let session_meta = session_meta_html(session, now);
  let lifecycle = session.lifecycle_status(now);
  let pending = chapters_pending_count(session);
  // Snapshot sits above Force stop in the island’s top-right. Mid-run → incomplete;
  // finished but chapters still landing → chapters pending; else job snapshot.
  let label = session_export_label(lifecycle, pending);
  let export_btn = format!(
    "<a class=\"chamfer btn btn--cyan btn--sm session-export\" href=\"/job/{id}/export.html\" download title=\"Offline HTML snapshot of this entire job\"><span>{label}</span></a>\n",
    id = id,
    label = label,
  );
  // Force restart / Force stop only while the job is running — hide them otherwise. A
  // control that can never act again is noise, not a missing feature: the lifecycle badge
  // already says completed / failed / cancelled. (Deliberate departure from the WEB-UI §2
  // gray-in-place rule; the offline export strips the whole actions island the same way.)
  // Restart is for repository jobs with a def/profile to respawn — image builds and
  // follow-on annotation sessions have no start recipe to replay.
  let restartable = session.profile.is_some()
    && session.parent_session.is_none()
    && session.repo != crate::daemon::server::IMAGE_BUILDS_REPO
    && session.repo != crate::daemon::server::INTERNAL_REPO;
  let stop_btn = if lifecycle == SessionLifecycle::Running {
    let restart = if restartable {
      format!(
        "<button type=\"button\" class=\"chamfer btn btn--orange btn--sm\" id=\"session-restart\" data-session=\"{id}\" title=\"Force-restart this job? The current run is stopped (containers killed) and the same job starts fresh.\"><span>Force restart</span></button>\n",
        id = id,
      )
    } else {
      String::new()
    };
    format!(
      "{restart}<button type=\"button\" class=\"chamfer btn btn--red btn--sm\" id=\"session-stop\" data-session=\"{id}\" title=\"Force-stop this job? Running containers will be killed.\"><span>Force stop</span></button>\n",
      id = id,
    )
  } else if restartable && matches!(lifecycle, SessionLifecycle::Failed | SessionLifecycle::Cancelled) {
    // A settled failure IS restartable — that's the whole point of keeping the recipe. The
    // primary action resumes: a fresh run of the same job that restores every step whose
    // result this session already produced and runs only what never completed (workflow jobs;
    // for flat jobs the resume request falls back server-side with a clear error). The
    // secondary action re-runs everything from scratch.
    let resume = if session.kind.as_deref() == Some("workflow") {
      format!(
        "<button type=\"button\" class=\"chamfer btn btn--orange btn--sm\" id=\"session-resume\" data-session=\"{id}\" title=\"Restart this job, reusing every completed step's result from this run — only the steps that never completed are run again.\"><span>Restart remaining</span></button>\n",
        id = id,
      )
    } else {
      String::new()
    };
    format!(
      "{resume}<button type=\"button\" class=\"chamfer btn btn--orange btn--sm\" id=\"session-restart-scratch\" data-session=\"{id}\" title=\"Start the same job again from scratch — every step runs anew.\"><span>Restart from scratch</span></button>\n",
      id = id,
    )
  } else {
    String::new()
  };
  let workflow = workflow_graph_html(session, now);
  let lede = session_lede_html(session, lifecycle);
  let chapters_pending = chapters_pending_html(pending);
  let job_diff_btn = if session.procs.iter().any(|proc| proc.diff_path.is_some()) {
    format!(
      "<a class=\"chamfer btn btn--purple btn--sm job-diff\" data-job-diff href=\"/diff/{}/all\" title=\"Browse the entire end-to-end commits diff\"><span>⇄ all commits</span></a>",
      esc(&session.id)
    )
  } else {
    String::new()
  };
  let body = format!(
    "<div class=\"chamfer card card--accent-left-purple\"><div class=\"session-actions\">{job_diff_btn}{export_btn}{stop_btn}</div>\
{session_meta}\n{chapters_pending}</div>\n\
{workflow}<div class=\"procs\" id=\"session-procs\">\n{procs}</div>",
    export_btn = export_btn,
    job_diff_btn = job_diff_btn,
    stop_btn = stop_btn,
    session_meta = session_meta,
    chapters_pending = chapters_pending,
    workflow = workflow,
    procs = procs_html,
  );
  wrap_page(&format!("job {}", session.id), port, Some(&session.id), None, &lede, &body)
}

fn container_runtime_name(runtime: Option<&str>) -> &'static str {
  match runtime {
    Some("container") => "Apple Containers",
    Some("docker") => "Docker",
    Some("podman") => "Podman",
    Some(_) => "Other runtime",
    None => "Not recorded (legacy run)",
  }
}

fn container_line_html(proc: &crate::daemon::model::ProcRecord) -> String {
  let Some(container) = proc.container_name.as_deref() else {
    return String::new();
  };
  format!(
    "<div class=\"container dim\"><span class=\"container-runtime-label\">runtime</span> \
<span class=\"container-runtime-name\">{runtime}</span> · container: {container}</div>\n",
    runtime = container_runtime_name(proc.container_runtime.as_deref()),
    container = esc(container),
  )
}

fn annotation_target_link_html(
  session: &crate::daemon::model::Session, proc: &crate::daemon::model::ProcRecord,
) -> String {
  if proc.kind != crate::daemon::model::ProcKind::Annotate {
    return String::new();
  }
  let Some(target) = proc.annotate_target.as_deref() else {
    return String::new();
  };
  let target_name = std::path::Path::new(target).file_name().and_then(|s| s.to_str()).unwrap_or("source recording");
  let href = session
    .procs
    .iter()
    .find(|candidate| candidate.cast_path.as_deref() == Some(target))
    .map(|candidate| format!("/job/{}#proc-{}", esc(&session.id), candidate.index))
    .or_else(|| session.parent_session.as_ref().map(|parent| format!("/job/{}", esc(parent))))
    .unwrap_or_else(|| format!("/job/{}", esc(&session.id)));
  format!(
    "<a class=\"annotation-target\" href=\"{href}\" title=\"Recording being annotated: {target}\">↩ source run</a>",
    target = esc(target_name)
  )
}

/// The one-line page lede shared by the live job page and the offline export: kind,
/// profile, lifecycle, and task count — enough to tell at a glance what ran and whether
/// it succeeded. A job started from the web UI has its planned tasks on `skills` before
/// any proc registers, so the count is the larger of the two — never "0 tasks" on a
/// freshly started job.
/// The supervisor row of the session meta list. Policy alone is not job history: keep a
/// fresh first run quiet, then surface the restart budget once supervision acts on it.
fn supervisor_meta_html(session: &Session, now: u64) -> String {
  let sup = &session.supervisor;
  let attempt = sup.attempt();
  if attempt == 1 && sup.next_retry_at.is_none() && sup.gave_up.is_none() && sup.restarted_as.is_none() {
    return String::new();
  }
  let (restarts, state) = if let Some(why) = &sup.gave_up {
    (attempt.saturating_sub(1), format!("gave up — {}", esc(why)))
  } else if let Some(new_id) = &sup.restarted_as {
    (attempt, format!("continued as <a href=\"/job/{id}\"><code class=\"job-id\">{id}</code></a>", id = esc(new_id)))
  } else if let Some(at) = sup.next_retry_at {
    let wait = at.saturating_sub(now);
    (attempt, format!("scheduled in {}", esc(&super::format::format_duration_secs(wait))))
  } else {
    (attempt.saturating_sub(1), "running replacement".to_string())
  };
  format!(
    "<dt data-session-supervisor-label>Job restarts</dt><dd data-session-supervisor>{restarts} of {max} · {state}</dd>\n",
    max = sup.retries,
  )
}

pub(crate) fn session_lede_html(session: &Session, lifecycle: SessionLifecycle) -> String {
  let kind = session.kind.as_deref().unwrap_or("profile");
  let profile = session.profile.as_deref().unwrap_or("default");
  let n = session.procs.len().max(session.skills.len());
  format!(
    "{kind} <strong>{profile}</strong> · {life} · {n} task{plural}",
    kind = esc(kind),
    profile = esc(profile),
    life = esc(lifecycle.label()),
    n = n,
    plural = if n == 1 { "" } else { "s" },
  )
}

/// The human "Ended" cell shared by the live meta and the offline export: the wall-clock
/// end when known, "still running" while live, and the phase deadline for a job whose
/// startup or running-idle timeout elapsed.
pub(crate) fn session_ended_text(session: &Session, lifecycle: SessionLifecycle) -> String {
  match (session.ended_at, lifecycle) {
    (Some(t), _) => format!("{} UTC", crate::runtime::format_utc_timestamp(t)),
    (None, SessionLifecycle::Running) => "still running".into(),
    (None, SessionLifecycle::Failed) => {
      format!("{} UTC", crate::runtime::format_utc_timestamp(session.liveness_deadline()))
    }
    (None, _) => "".into(),
  }
}

/// Non-annotate procs that have a cast but no chapters sidecar yet.
pub(crate) fn chapters_pending_count(session: &Session) -> usize {
  use crate::daemon::model::ProcKind;
  session
    .procs
    .iter()
    .filter(|p| {
      if p.kind == ProcKind::Annotate {
        return false;
      }
      let Some(cast) = p.cast_path.as_deref() else {
        return false;
      };
      match crate::daemon::chapters_sidecar_path(cast) {
        Some(path) => !path.exists(),
        None => false,
      }
    })
    .count()
}

fn session_export_label(lifecycle: SessionLifecycle, pending: usize) -> &'static str {
  if lifecycle == SessionLifecycle::Running {
    "Incomplete job ⬇"
  } else if pending > 0 {
    "Chapters pending ⬇"
  } else {
    "Job snapshot ⬇"
  }
}

fn chapters_pending_html(pending: usize) -> String {
  if pending == 0 {
    return String::new();
  }
  format!(
    r#"<p class="chapters-pending dim" id="chapters-pending" data-pending="{n}">{n} cast{plural} finalizing chapters</p>"#,
    n = pending,
    plural = if pending == 1 { "" } else { "s" },
  )
}

/// A muted "attempt N" chip next to the label of a retry proc (scsh re-runs routes that
/// failed transiently as new procs), so the second attempt is visibly a second attempt.
/// Mirrored by `attemptChipHtml` in the client JS.
pub(crate) fn attempt_chip_html(session: &Session, proc: &crate::daemon::model::ProcRecord) -> String {
  let (attempt, _) = session.proc_attempt(proc);
  if attempt <= 1 {
    return String::new();
  }
  format!(r#" <span class="chamfer agent-badge attempt-chip"><span>attempt {attempt}</span></span>"#)
}

/// A cross-link from a failed attempt to the retry that superseded it, so the red row and
/// the running/green node it contradicts visibly belong to the same route. Mirrored by
/// `retryLinkHtml` in the client JS.
pub(crate) fn retry_link_html(session: &Session, proc: &crate::daemon::model::ProcRecord) -> String {
  if proc.fail_reason.as_deref() == Some(crate::failure::reason::RESTART_REQUESTED) {
    return r#" <span class="proc-retry-pending">replacement starting…</span>"#.into();
  }
  if proc.status != ProcStatus::Fail {
    return String::new();
  }
  let Some(next) = session.proc_next_attempt(proc) else {
    return String::new();
  };
  format!(
    r##" <a class="proc-retry-link" href="#proc-{idx}" title="This attempt failed and was retried; the newest attempt is authoritative">superseded — see attempt {ord} ↓</a>"##,
    idx = next.index,
    ord = session.proc_attempt(next).0,
  )
}

/// Every replacement links directly to the immutable first attempt, so attempt 3 does not
/// make the user walk backward through attempt 2. Mirrored by `originalAttemptLinkHtml`.
pub(crate) fn original_attempt_link_html(session: &Session, proc: &crate::daemon::model::ProcRecord) -> String {
  let original = session.proc_first_attempt(proc);
  if original.index == proc.index {
    return String::new();
  }
  format!(
    r##" <a class="proc-original-link" href="#proc-{idx}" title="Jump to the original run in this attempt chain">original attempt ↑</a>"##,
    idx = original.index,
  )
}

/// A bare repo-relative artifact path (`tmp/scsh/<id>/add.json`-shaped) — system info, not
/// an agent's prose. Mirrored by `looksLikeArtifactPath` in the client JS.
fn looks_like_artifact_path(text: &str) -> bool {
  !text.is_empty()
    && (text.starts_with('/') || text.starts_with("tmp/") || text.starts_with(".harness/"))
    && !text.contains(char::is_whitespace)
}

/// A "⇄ commits diff" chip on a step's summary row, shown once the run integrated this
/// step's commits into the caller's branch and packed them (packdiff) into a review page.
/// Navigates to `/diff/<session>/<proc>` in THIS tab (cmd/ctrl+click for a new one — no
/// `target` override); the page is one self-contained HTML file. Mirrored by
/// `procDiffBtnHtml` in the client JS (the chip appears live: integration happens after
/// the step finished, so it lands on a late tick of the run).
fn proc_diff_btn_html(session_id: &str, proc: &crate::daemon::model::ProcRecord) -> String {
  if proc.diff_path.is_none() {
    return String::new();
  }
  format!(
    "<a class=\"chamfer btn btn--purple btn--sm proc-diff\" data-proc-diff href=\"/diff/{id}/{index}\" title=\"Browse the commits this step brought into your branch — one self-contained review page\"><span>⇄ commits diff</span></a>",
    index = proc.index,
    id = esc(session_id),
  )
}

/// Per-proc offline snapshot link (top-right of the proc island, above Force stop).
/// Hidden until the recording has frames — the export endpoint 404s on a frameless cast.
fn proc_snapshot_btn_html(session_id: &str, proc: &crate::daemon::model::ProcRecord) -> String {
  if !proc_has_cast(proc) {
    return String::new();
  }
  let live = matches!(proc.status, ProcStatus::Running | ProcStatus::Waiting);
  let label = if live { "Incomplete run ⬇" } else { "Run snapshot ⬇" };
  format!(
    r#"<a class="chamfer btn btn--cyan btn--sm proc-snapshot" href="/cast/{sid}/{idx}/export.html" data-cast-export download hidden title="Offline HTML snapshot of this run"><span>{label}</span></a>"#,
    sid = esc(session_id),
    idx = proc.index,
    label = label,
  )
}

/// True while a proc is still running with no browser stop or restart pending — the only
/// state in which its Force stop / Force restart buttons can act.
fn proc_accepts_kill(session: &Session, now: u64, proc: &crate::daemon::model::ProcRecord) -> bool {
  use crate::daemon::model::{ProcStatus, SessionLifecycle};
  session.lifecycle_status(now) == SessionLifecycle::Running
    && matches!(proc.status, ProcStatus::Running | ProcStatus::Waiting)
    && !matches!(
      proc.fail_reason.as_deref(),
      Some(crate::failure::reason::STOP_REQUESTED) | Some(crate::failure::reason::RESTART_REQUESTED)
    )
}

/// A small per-proc "Force restart" button, above Force stop. Skill runs only — builds and
/// annotations have no respawn path — and only while the run can still be acted on.
fn proc_restart_btn_html(session: &Session, now: u64, proc: &crate::daemon::model::ProcRecord) -> String {
  use crate::daemon::model::ProcKind;
  if proc.kind != ProcKind::Skill || !proc_accepts_kill(session, now, proc) {
    return String::new();
  }
  format!(
    "<button type=\"button\" class=\"chamfer btn btn--orange btn--sm proc-restart\" data-proc-restart=\"{index}\" data-session=\"{id}\" title=\"Force-restart this run only — the container is killed and a fresh attempt of the same route starts; the rest of the job continues\"><span>Force restart</span></button>",
    index = proc.index,
    id = esc(&session.id),
  )
}

/// A small per-proc "Force stop" button. Only rendered while that proc still runs on a
/// live session — finished/zombie steps omit it (no grayed-out stub).
fn proc_kill_btn_html(session: &Session, now: u64, proc: &crate::daemon::model::ProcRecord) -> String {
  use crate::daemon::model::ProcKind;
  if !proc_accepts_kill(session, now, proc) {
    return String::new();
  }
  format!(
    "<button type=\"button\" class=\"chamfer btn btn--red btn--sm proc-kill\" data-proc-stop=\"{index}\" data-proc-kind=\"{}\" data-session=\"{id}\" title=\"{}\"><span>{}</span></button>",
    proc.kind.as_str(),
    if proc.kind == ProcKind::Annotate {
      "Stop this annotation — the recording remains unchanged"
    } else {
      "Force-stop this container only — the rest of the job continues"
    },
    if proc.kind == ProcKind::Annotate { "Stop annotation" } else { "Force stop" },
    index = proc.index,
    id = esc(&session.id),
  )
}

fn session_meta_html(session: &Session, now: u64) -> String {
  use super::format::format_duration_secs;
  let lifecycle = session.lifecycle_status(now);
  let started = format!("{} UTC", crate::runtime::format_utc_timestamp(session.started_at));
  let ended = session_ended_text(session, lifecycle);
  let duration = session.duration_secs(now).map(format_duration_secs).unwrap_or_else(|| "".into());
  let last_seen = session.last_seen_at;
  format!(
    r#"<dl class="session-meta" id="session-meta"
 data-started="{started_at}" data-ended="{ended_at}" data-last-seen="{last_seen}"
 data-lifecycle="{lifecycle_class}" data-lifecycle-label="{lifecycle_label}"
 data-repo="{repo}" data-branch="{branch}">
<dt>Started</dt><dd data-session-started>{started}</dd>
<dt>Ended</dt><dd data-session-ended>{ended}</dd>
<dt>Duration</dt><dd data-session-duration>{duration}</dd>
<dt>Repo</dt><dd data-session-repo><code class="repo-path">{repo}</code></dd>
<dt>Branch</dt><dd data-session-branch><code>{branch}</code></dd>
{supervisor}</dl>"#,
    supervisor = supervisor_meta_html(session, now),
    started_at = session.started_at,
    ended_at = session.ended_at.map(|t| t.to_string()).unwrap_or_default(),
    last_seen = last_seen,
    lifecycle_class = lifecycle.css_class(),
    lifecycle_label = lifecycle.label(),
    repo = esc(&session.repo),
    branch = esc(&session.branch),
    started = esc(&started),
    ended = esc(&ended),
    duration = esc(&duration),
  )
}