stuntdouble 0.1.2 → 0.2.0

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 (46) hide show
  1. package/README.md +37 -9
  2. package/dist/{chunk-VWTYG7G4.js → chunk-BFI6P5DO.js} +65 -25
  3. package/dist/chunk-BFI6P5DO.js.map +1 -0
  4. package/dist/chunk-DFXASIGV.js +431 -0
  5. package/dist/chunk-DFXASIGV.js.map +1 -0
  6. package/dist/{chunk-JAB46QV7.js → chunk-F3R3QL7V.js} +64 -8
  7. package/dist/chunk-F3R3QL7V.js.map +1 -0
  8. package/dist/{mcp-CYPMT37O.js → chunk-FMZTT2MC.js} +72 -1059
  9. package/dist/chunk-FMZTT2MC.js.map +1 -0
  10. package/dist/chunk-MSV5OLSY.js +117 -0
  11. package/dist/chunk-MSV5OLSY.js.map +1 -0
  12. package/dist/chunk-P3CI4FTE.js +469 -0
  13. package/dist/chunk-P3CI4FTE.js.map +1 -0
  14. package/dist/chunk-QDA5FTUH.js +79 -0
  15. package/dist/chunk-QDA5FTUH.js.map +1 -0
  16. package/dist/chunk-XHSDRB3X.js +996 -0
  17. package/dist/chunk-XHSDRB3X.js.map +1 -0
  18. package/dist/cli.js +180 -68
  19. package/dist/cli.js.map +1 -1
  20. package/dist/cloud-DLLNKYND.js +34 -0
  21. package/dist/credentials-QHESDY4K.js +27 -0
  22. package/dist/credentials-QHESDY4K.js.map +1 -0
  23. package/dist/doctor-ZAYOOCPT.js +13 -0
  24. package/dist/doctor-ZAYOOCPT.js.map +1 -0
  25. package/dist/files-UBPF7PKW.js +45 -0
  26. package/dist/files-UBPF7PKW.js.map +1 -0
  27. package/dist/mcp-HFIAQ6HP.js +133 -0
  28. package/dist/mcp-HFIAQ6HP.js.map +1 -0
  29. package/dist/open-browser-CTOGH5HQ.js +17 -0
  30. package/dist/open-browser-CTOGH5HQ.js.map +1 -0
  31. package/dist/run-MPIAR3LQ.js +20 -0
  32. package/dist/run-MPIAR3LQ.js.map +1 -0
  33. package/dist/runner-E45EJ76B.js +17 -0
  34. package/dist/runner-E45EJ76B.js.map +1 -0
  35. package/dist/studio-FBC66BER.js +360 -0
  36. package/dist/studio-FBC66BER.js.map +1 -0
  37. package/dist/studio.js +1621 -0
  38. package/dist/ui-server-C7CFJWRD.js +12 -0
  39. package/dist/ui-server-C7CFJWRD.js.map +1 -0
  40. package/dist/ui.html +210 -103
  41. package/package.json +2 -2
  42. package/dist/chunk-JAB46QV7.js.map +0 -1
  43. package/dist/chunk-VWTYG7G4.js.map +0 -1
  44. package/dist/mcp-CYPMT37O.js.map +0 -1
  45. package/dist/ui-server-AYIR7DTN.js +0 -10
  46. /package/dist/{ui-server-AYIR7DTN.js.map → cloud-DLLNKYND.js.map} +0 -0
@@ -0,0 +1,12 @@
1
+ import {
2
+ FIRST_PORT,
3
+ send,
4
+ startUi
5
+ } from "./chunk-BFI6P5DO.js";
6
+ import "./chunk-F3R3QL7V.js";
7
+ export {
8
+ FIRST_PORT,
9
+ send,
10
+ startUi
11
+ };
12
+ //# sourceMappingURL=ui-server-C7CFJWRD.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/dist/ui.html CHANGED
@@ -831,21 +831,6 @@
831
831
  align-items: center;
832
832
  gap: 0.5rem;
833
833
  }
834
- .thumb {
835
- display: none;
836
- width: 3rem;
837
- height: 2rem;
838
- border: 1px solid var(--border);
839
- border-radius: var(--radius-md);
840
- object-fit: cover;
841
- object-position: top;
842
- background: var(--muted);
843
- }
844
- @media (min-width: 40rem) {
845
- .thumb {
846
- display: block;
847
- }
848
- }
849
834
  .chevron {
850
835
  color: var(--muted-foreground);
851
836
  }
@@ -1102,15 +1087,16 @@
1102
1087
  color: var(--muted-foreground);
1103
1088
  white-space: nowrap;
1104
1089
  }
1105
- .activity code {
1106
- font-size: 0.75rem;
1107
- }
1108
1090
  .activity .text {
1109
1091
  grid-column: 1 / -1;
1110
1092
  white-space: pre-wrap;
1111
1093
  overflow-wrap: anywhere;
1112
1094
  color: var(--muted-foreground);
1113
1095
  }
1096
+ .activity summary {
1097
+ cursor: pointer;
1098
+ white-space: normal;
1099
+ }
1114
1100
  @media (min-width: 40rem) {
1115
1101
  .activity li {
1116
1102
  grid-template-columns: 5.5rem 8rem minmax(0, 1fr);
@@ -1349,9 +1335,10 @@
1349
1335
  <footer>
1350
1336
  <div class="container">
1351
1337
  <p>
1352
- Local runs use your own Claude plan and a browser on this machine, one at a time while
1353
- your session is open. The cloud runs on schedules and in parallel, records every run and
1354
- shares reports with your team. <a href="https://stuntdouble.io/pricing">See plans</a>
1338
+ Local runs use the account Claude Code is signed in with and a browser on this machine,
1339
+ one at a time while your session is open. The cloud runs on schedules and in parallel,
1340
+ records every run and shares reports with your team.
1341
+ <a href="https://stuntdouble.io/pricing">See plans</a>
1355
1342
  </p>
1356
1343
  </div>
1357
1344
  </footer>
@@ -1476,8 +1463,9 @@
1476
1463
  if (style === 'relative') return h('time', { datetime: iso, title: exact }, ago(date));
1477
1464
  return h('time', { datetime: iso }, style === 'clock' ? clock.format(date) : exact);
1478
1465
  }
1479
- function took(start, end) {
1480
- const seconds = Math.round((new Date(end) - new Date(start)) / 1000);
1466
+ const between = (start, end) => (new Date(end) - new Date(start)) / 1000;
1467
+ function took(seconds) {
1468
+ seconds = Math.round(seconds);
1481
1469
  if (!(seconds >= 0)) return null;
1482
1470
  if (seconds < 60) return seconds + 's';
1483
1471
  const minutes = Math.floor(seconds / 60);
@@ -1528,6 +1516,7 @@
1528
1516
  // passed", the same count the finish_run reply gives. Informational and
1529
1517
  // skipped checks are left out, as the scorer leaves them out.
1530
1518
  const tally = (run) => {
1519
+ if (run.tally) return run.tally;
1531
1520
  const checks = (run.checklist_snapshot && run.checklist_snapshot.checks) || [];
1532
1521
  const status = Object.fromEntries((run.results || []).map((r) => [r.check, r.status]));
1533
1522
  const scored = checks.filter(
@@ -1545,12 +1534,59 @@
1545
1534
  return t.total ? t.passed + ' of ' + t.total + ' passed' : '';
1546
1535
  };
1547
1536
 
1548
- const runner = (run) =>
1537
+ // A Studio run names its model; the runs list leaves "self-judged" to the run's page.
1538
+ const runner = (run, full) =>
1549
1539
  run.runner.kind === 'cloud'
1550
1540
  ? { icon: 'server', label: 'Cloud' }
1551
- : { icon: 'monitor', label: 'Local, self-judged' };
1541
+ : {
1542
+ icon: 'monitor',
1543
+ label: run.runner.model
1544
+ ? 'Local, ' + run.runner.model.id + (full ? ', self-judged' : '')
1545
+ : 'Local, self-judged',
1546
+ };
1552
1547
  const checklistName = (run) =>
1553
- (run.checklist_snapshot && run.checklist_snapshot.name) || run.checklist;
1548
+ run.checklist_name ||
1549
+ (run.checklist_snapshot && run.checklist_snapshot.name) ||
1550
+ run.checklist;
1551
+ const actorName = (run) => run.actor_name || run.actor;
1552
+
1553
+ // What each tool call did, in words. Page reads fold away: they are the
1554
+ // page's own text, long and full of refs.
1555
+ const ACTIVITY = {
1556
+ start_run: 'Started',
1557
+ navigate: 'Opened a page',
1558
+ read_page: 'Read the page',
1559
+ get_page_text: 'Read the page text',
1560
+ find: 'Looked for',
1561
+ click: 'Clicked',
1562
+ type: 'Typed',
1563
+ form_input: 'Filled a field',
1564
+ key: 'Pressed a key',
1565
+ scroll: 'Scrolled',
1566
+ screenshot: 'Took a screenshot',
1567
+ evaluate_check: 'Judged a check',
1568
+ finish_run: 'Finished',
1569
+ stop: 'Stopped',
1570
+ };
1571
+ const PAGE_READS = new Set(['read_page', 'get_page_text', 'find']);
1572
+
1573
+ // How long the actor worked: a resumed run's idle gap left out. Each
1574
+ // start_run opens a session that ends at its last line.
1575
+ function activeSeconds(run, activity) {
1576
+ let total = 0;
1577
+ let from = null;
1578
+ let last = null;
1579
+ for (const line of activity) {
1580
+ if (line.tool === 'start_run') {
1581
+ if (from && last) total += between(from, last);
1582
+ from = line.at;
1583
+ }
1584
+ last = line.at;
1585
+ }
1586
+ const end = run.finished_at || last;
1587
+ if (from && end) total += between(from, end);
1588
+ return activity.length ? total : between(run.started_at, run.finished_at);
1589
+ }
1554
1590
 
1555
1591
  const RESULT = {
1556
1592
  passed: ['Passed', 'success', 'check-circle'],
@@ -1747,7 +1783,7 @@
1747
1783
  'span',
1748
1784
  { class: 'fact' },
1749
1785
  icon('actor'),
1750
- h('span', { class: 'truncate' }, entry.actor)
1786
+ h('span', { class: 'truncate' }, actorName(entry))
1751
1787
  ),
1752
1788
  h(
1753
1789
  'span',
@@ -1799,6 +1835,9 @@
1799
1835
  // were and only something that just landed moves.
1800
1836
  const toggled = new Map();
1801
1837
  const seen = new Map();
1838
+ // Bumped only when the live frame changes, so a redraw reuses the
1839
+ // picture already loaded rather than fetching it again.
1840
+ let frameVersion = Date.now();
1802
1841
 
1803
1842
  function checkRow(run, check, result, opts) {
1804
1843
  if (!result) {
@@ -1853,8 +1892,6 @@
1853
1892
  })
1854
1893
  )
1855
1894
  );
1856
- const thumb =
1857
- shot && h('img', { class: 'thumb', src: shot, alt: '', loading: 'lazy', hidden: open });
1858
1895
  const row = h(
1859
1896
  'li',
1860
1897
  { class: 'check' + (open ? ' open' : '') + (opts.arrived ? ' animate-rise' : '') },
@@ -1871,7 +1908,6 @@
1871
1908
  const now = detail.hidden;
1872
1909
  toggled.set(key, now);
1873
1910
  detail.hidden = !now;
1874
- if (thumb) thumb.hidden = now;
1875
1911
  row.classList.toggle('open', now);
1876
1912
  row.firstChild.setAttribute('aria-expanded', String(now));
1877
1913
  },
@@ -1886,7 +1922,12 @@
1886
1922
  h(
1887
1923
  'span',
1888
1924
  { class: 'check-side' },
1889
- thumb,
1925
+ opts.earlier &&
1926
+ h(
1927
+ 'span',
1928
+ { class: 'badge outline sm', title: 'Judged before the run was resumed' },
1929
+ 'Earlier'
1930
+ ),
1890
1931
  h('span', { class: 'badge sm ' + tone, id: 'label-' + check.key }, label),
1891
1932
  icon('chevron-down', 'chevron')
1892
1933
  )
@@ -1926,15 +1967,17 @@
1926
1967
  ? inUnit(score.score, score.unit)
1927
1968
  : [String(t.passed), h('span', { class: 'of' }, '/' + t.total)]
1928
1969
  ),
1929
- // Unweighted, the figure is the tally: no line repeats it.
1930
- weighted &&
1931
- h(
1932
- 'p',
1933
- { class: 'summary-line' },
1934
- score.pass_threshold == null
1970
+ // Unweighted, there is no score: say how the outcome was decided,
1971
+ // in the words finish_run uses.
1972
+ h(
1973
+ 'p',
1974
+ { class: 'summary-line' },
1975
+ !weighted
1976
+ ? 'No score: with no weighted checks, it passes only when every pass/fail check passes'
1977
+ : score.pass_threshold == null
1935
1978
  ? 'No pass threshold set'
1936
1979
  : 'Passes at ' + inUnit(score.pass_threshold, score.unit)
1937
- )
1980
+ )
1938
1981
  ),
1939
1982
  h(
1940
1983
  'div',
@@ -2002,8 +2045,10 @@
2002
2045
 
2003
2046
  // Stop sits in the live card, beside the tally, as CancelRunButton does.
2004
2047
  const pending = data.stop_requested;
2048
+ // A cloud run is polled, not driven from here: nothing to stop.
2005
2049
  const stopButton =
2006
2050
  running &&
2051
+ run.runner.kind === 'local' &&
2007
2052
  h(
2008
2053
  'button',
2009
2054
  {
@@ -2044,66 +2089,77 @@
2044
2089
 
2045
2090
  const rerun = '/stuntdouble run ' + run.checklist + (resumable ? ' resume' : '');
2046
2091
  const cloud = '/stuntdouble cloud ' + run.checklist;
2047
- const actions = running
2048
- ? null
2049
- : run.runner.kind === 'local'
2050
- ? [
2051
- h(
2052
- 'button',
2053
- {
2054
- type: 'button',
2055
- class: 'btn outline',
2056
- title: rerun,
2057
- 'data-focus-key': 'rerun',
2058
- onclick: (event) => copy(rerun, event.currentTarget),
2059
- },
2060
- icon('copy'),
2061
- h('span', {}, resumable ? 'Copy resume command' : 'Copy re-run command')
2062
- ),
2063
- h(
2064
- 'button',
2065
- {
2066
- type: 'button',
2067
- class: 'btn outline',
2068
- title: cloud,
2069
- 'data-focus-key': 'cloud',
2070
- onclick: (event) => copy(cloud, event.currentTarget),
2071
- },
2072
- icon('copy'),
2073
- h('span', {}, 'Run in the cloud')
2074
- ),
2075
- ]
2076
- : webUrl(run.runner.url)
2092
+ const actions = studio.runActions
2093
+ ? studio.runActions(run, running, resumable)
2094
+ : running
2095
+ ? null
2096
+ : run.runner.kind === 'local'
2077
2097
  ? [
2078
2098
  h(
2079
- 'a',
2099
+ 'button',
2080
2100
  {
2101
+ type: 'button',
2081
2102
  class: 'btn outline',
2082
- href: run.runner.url,
2083
- target: '_blank',
2084
- rel: 'noopener noreferrer',
2103
+ title: rerun,
2104
+ 'data-focus-key': 'rerun',
2105
+ onclick: (event) => copy(rerun, event.currentTarget),
2085
2106
  },
2086
- icon('external-link'),
2087
- h('span', {}, 'Open this run in Stunt Double')
2107
+ icon('copy'),
2108
+ h('span', {}, resumable ? 'Copy resume command' : 'Copy re-run command')
2109
+ ),
2110
+ h(
2111
+ 'button',
2112
+ {
2113
+ type: 'button',
2114
+ class: 'btn outline',
2115
+ title: cloud,
2116
+ 'data-focus-key': 'cloud',
2117
+ onclick: (event) => copy(cloud, event.currentTarget),
2118
+ },
2119
+ icon('copy'),
2120
+ h('span', {}, 'Copy cloud command')
2088
2121
  ),
2089
2122
  ]
2090
- : null;
2123
+ : webUrl(run.runner.url)
2124
+ ? [
2125
+ h(
2126
+ 'a',
2127
+ {
2128
+ class: 'btn outline',
2129
+ href: run.runner.url,
2130
+ target: '_blank',
2131
+ rel: 'noopener noreferrer',
2132
+ },
2133
+ icon('external-link'),
2134
+ h('span', {}, 'Open this run in Stunt Double')
2135
+ ),
2136
+ ]
2137
+ : null;
2091
2138
 
2092
- const duration = run.finished_at && took(run.started_at, run.finished_at);
2139
+ const starts = data.activity.filter((line) => line.tool === 'start_run');
2140
+ const sessions = starts.length;
2141
+ const duration =
2142
+ run.finished_at &&
2143
+ took(activeSeconds(run, data.activity)) +
2144
+ (sessions > 1 ? ' over ' + sessions + ' sessions' : '');
2145
+ // Resumed: the verdicts from before the last start_run stood.
2146
+ const resumedAt = sessions > 1 ? new Date(starts[sessions - 1].at) : null;
2147
+ const earlier = (result) => resumedAt && result.at && new Date(result.at) < resumedAt;
2093
2148
  const header = pageHeader({
2094
2149
  back: { href: '/', label: 'Runs', trail: run.id },
2095
2150
  title: checklistName(run),
2096
- badge: stateBadge(run, 'lg'),
2151
+ // A finished run's verdict is the score card's stamp: once is enough.
2152
+ badge: run.score && runState(run).outcome ? null : stateBadge(run, 'lg'),
2097
2153
  chips: [
2098
- { icon: runner(run).icon, label: runner(run).label },
2099
- { icon: 'actor', label: run.actor, title: 'Actor' },
2154
+ { icon: runner(run).icon, label: runner(run, true).label },
2155
+ { icon: 'actor', label: actorName(run), title: 'Actor' },
2100
2156
  { icon: 'globe', label: run.url, href: webUrl(run.url), title: 'Target' },
2101
2157
  { icon: 'calendar', label: time(run.started_at), title: 'Started' },
2102
2158
  duration && {
2103
2159
  icon: 'timer',
2104
2160
  label: duration,
2105
2161
  mono: true,
2106
- title: 'How long the run took',
2162
+ title: 'How long the actor worked, idle time between sessions left out',
2107
2163
  },
2108
2164
  ].filter(Boolean),
2109
2165
  actions,
@@ -2168,12 +2224,13 @@
2168
2224
  style: 'width:' + (checks.length ? (judged / checks.length) * 100 : 0) + '%',
2169
2225
  })
2170
2226
  ),
2171
- h('img', {
2172
- class: 'frame',
2173
- id: 'frame',
2174
- src: fileUrl('.live/latest.png') + '?v=' + Date.now(),
2175
- alt: 'The browser now',
2176
- })
2227
+ run.runner.kind === 'local' &&
2228
+ h('img', {
2229
+ class: 'frame',
2230
+ id: 'frame',
2231
+ src: fileUrl('.live/latest.png') + '?v=' + frameVersion,
2232
+ alt: 'The browser now',
2233
+ })
2177
2234
  );
2178
2235
 
2179
2236
  // What went wrong first, since that is what somebody opens a report to
@@ -2210,6 +2267,7 @@
2210
2267
  'div',
2211
2268
  { class: 'stack' },
2212
2269
  live,
2270
+ studio.runExtras && studio.runExtras(run),
2213
2271
  notice,
2214
2272
  summaryCard(run, fresh),
2215
2273
  groups.map((group) =>
@@ -2230,6 +2288,7 @@
2230
2288
  running,
2231
2289
  openByDefault: group.openByDefault,
2232
2290
  arrived: results.has(check.key) && arrived(check.key),
2291
+ earlier: results.has(check.key) && earlier(results.get(check.key)),
2233
2292
  })
2234
2293
  )
2235
2294
  )
@@ -2253,8 +2312,21 @@
2253
2312
  'li',
2254
2313
  {},
2255
2314
  time(line.at, 'clock'),
2256
- h('code', {}, line.tool),
2257
- h('span', { class: 'text' }, line.text)
2315
+ h('span', { title: line.tool }, ACTIVITY[line.tool] || line.tool),
2316
+ PAGE_READS.has(line.tool)
2317
+ ? h(
2318
+ 'details',
2319
+ { class: 'text' },
2320
+ h(
2321
+ 'summary',
2322
+ {},
2323
+ line.tool === 'find' && line.input && line.input.query
2324
+ ? '"' + line.input.query + '": show matches'
2325
+ : 'Show page text'
2326
+ ),
2327
+ line.text
2328
+ )
2329
+ : h('span', { class: 'text' }, line.text)
2258
2330
  )
2259
2331
  )
2260
2332
  )
@@ -2382,28 +2454,59 @@
2382
2454
  ];
2383
2455
  }
2384
2456
 
2457
+ // What Stunt Double Studio's script (studio.js) builds on. The viewer on
2458
+ // its own leaves it empty: inside Claude Code it reads and stops, no more.
2459
+ const studio = (window.stuntdouble = {
2460
+ routes: [],
2461
+ runActions: null,
2462
+ runExtras: null,
2463
+ h,
2464
+ icon,
2465
+ ICONS,
2466
+ pageHeader,
2467
+ emptyState,
2468
+ getJson,
2469
+ announce,
2470
+ copy,
2471
+ time,
2472
+ webUrl,
2473
+ checkMeta,
2474
+ renderRuns,
2475
+ render: (enter) => render(enter),
2476
+ });
2477
+
2385
2478
  let rendering = Promise.resolve();
2386
- function render(enter) {
2479
+ // `live`: a redraw for a file change. A view holding a form the reader
2480
+ // is filling in (`keep`) is left alone.
2481
+ function render(enter, live) {
2387
2482
  rendering = rendering.then(async () => {
2388
2483
  const path = location.pathname;
2484
+ const kept = studio.routes.find((r) => r.match(path));
2485
+ if (live && kept && kept.keep) return;
2389
2486
  for (const link of document.querySelectorAll('.bar nav a')) {
2390
- const current = link.getAttribute('href') === (path.startsWith('/runs/') ? '/' : path);
2487
+ const href = link.getAttribute('href');
2488
+ const current =
2489
+ href === (path.startsWith('/runs/') ? '/' : path) ||
2490
+ (href !== '/' && path.startsWith(href + '/'));
2391
2491
  if (current) link.setAttribute('aria-current', 'page');
2392
2492
  else link.removeAttribute('aria-current');
2393
2493
  }
2394
2494
  let view;
2495
+ const route = studio.routes.find((r) => r.match(path));
2395
2496
  try {
2396
- view = path.startsWith('/runs/')
2397
- ? await renderRun(decodeURIComponent(path.slice('/runs/'.length)))
2398
- : path === '/definitions'
2399
- ? await renderDefinitions()
2400
- : await renderRuns();
2497
+ view = route
2498
+ ? await route.render(path)
2499
+ : path.startsWith('/runs/')
2500
+ ? await renderRun(decodeURIComponent(path.slice('/runs/'.length)))
2501
+ : path === '/definitions'
2502
+ ? await renderDefinitions()
2503
+ : await renderRuns();
2401
2504
  } catch (err) {
2402
2505
  view = [
2403
2506
  h('div', { class: 'notice destructive' }, icon('octagon-x'), h('p', {}, err.message)),
2404
2507
  ];
2405
2508
  }
2406
- app.classList.toggle('narrow', path === '/definitions');
2509
+ app.classList.toggle('narrow', path === '/definitions' || Boolean(route && route.narrow));
2407
2510
  // A live redraw replaces the view: keep keyboard focus where it was.
2408
2511
  const focused = document.activeElement?.closest?.('[data-focus-key]');
2409
2512
  const focusKey = focused && app.contains(focused) && focused.dataset.focusKey;
@@ -2433,24 +2536,28 @@
2433
2536
  });
2434
2537
  window.addEventListener('popstate', () => render(false));
2435
2538
 
2436
- // Live updates: a new frame swaps the picture, anything else redraws the page.
2539
+ // Live updates: a new frame swaps the picture, anything else redraws the
2540
+ // page. The server already coalesces changes, so a redraw waits only long
2541
+ // enough to fold a burst into one.
2437
2542
  let queued = false;
2438
2543
  new EventSource('/api/events').onmessage = (event) => {
2439
2544
  const { paths } = JSON.parse(event.data);
2440
- const frame = document.getElementById('frame');
2441
- if (frame && paths.includes('.live/latest.png')) {
2442
- frame.src = fileUrl('.live/latest.png') + '?v=' + Date.now();
2545
+ if (paths.includes('.live/latest.png')) {
2546
+ frameVersion++;
2547
+ const frame = document.getElementById('frame');
2548
+ if (frame) frame.src = fileUrl('.live/latest.png') + '?v=' + frameVersion;
2443
2549
  }
2444
2550
  if (paths.every((p) => p.startsWith('.live/')) || queued) return;
2445
2551
  queued = true;
2446
2552
  setTimeout(() => {
2447
2553
  queued = false;
2448
- render(false);
2449
- }, 300);
2554
+ render(false, true);
2555
+ }, 50);
2450
2556
  };
2451
2557
  // Relative times on the runs list stay current.
2452
2558
  setInterval(() => location.pathname === '/' && render(false), 60_000);
2453
- render(false);
2559
+ // After every script on the page: the Studio's registers its views first.
2560
+ document.addEventListener('DOMContentLoaded', () => render(false));
2454
2561
  </script>
2455
2562
  </body>
2456
2563
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stuntdouble",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Stunt Double Local: run Stunt Double checklists in a local Chrome from your own coding agent session, with the runs kept as files and a local viewer",
5
5
  "keywords": [
6
6
  "stunt-double",
@@ -43,7 +43,7 @@
43
43
  "zod": "^4.6.7"
44
44
  },
45
45
  "devDependencies": {
46
- "@stunt-double/browser-toolset": "workspace:*"
46
+ "@stuntkit/browser-toolset": "workspace:*"
47
47
  },
48
48
  "engines": {
49
49
  "node": ">=22.18"