@gotcos/glasses-server 7.0.30 → 7.0.34

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 (34) hide show
  1. package/.env.example +10 -8
  2. package/CHANGELOG.md +207 -0
  3. package/bin/hooks/cos-session-hook +3 -2
  4. package/docs/pairing-fixture.json +1 -1
  5. package/package.json +3 -1
  6. package/server/index.ts +4 -2
  7. package/server/lib/api-auth.ts +3 -2
  8. package/server/lib/archive.ts +4 -2
  9. package/server/lib/claude-hooks-installer.ts +101 -15
  10. package/server/lib/codex-hooks-installer.ts +4 -1
  11. package/server/lib/full-hold.ts +61 -5
  12. package/server/lib/glasses-pairing.ts +2 -1
  13. package/server/lib/permission-broker.ts +130 -14
  14. package/server/lib/question-hold-setting.ts +38 -0
  15. package/server/lib/question-queue.ts +111 -0
  16. package/server/lib/session-hooks-runtime.ts +28 -3
  17. package/server/lib/session-title-cache.ts +40 -0
  18. package/server/lib/turn-end-ask.ts +51 -1
  19. package/server/lib/web-session-auth.ts +17 -0
  20. package/server/routes/agent-sessions.ts +3 -0
  21. package/server/routes/permission-broker.ts +31 -2
  22. package/server/routes/session-hooks.ts +23 -0
  23. package/server/routes/sessions.ts +3 -0
  24. package/server/web-control/.manifest.json +10 -10
  25. package/server/web-control/app.css +26 -3
  26. package/server/web-control/app.js +381 -36
  27. package/server/web-control/control.css +5 -1
  28. package/server/web-control/index.html +7 -5
  29. package/server/web-control/panes/cos-chat.css +21 -0
  30. package/server/web-control/panes/memories.js +18 -9
  31. package/server/web-control/panes/messages.js +15 -3
  32. package/server/web-control/panes/questions.js +6 -1
  33. package/server/web-control/panes/settings.js +18 -10
  34. package/server/web-control/shell.js +8 -13
@@ -294,6 +294,26 @@
294
294
  return function () { if (!once) { once = true; WRITES.n = Math.max(0, WRITES.n - 1); } };
295
295
  }
296
296
  if (typeof window === 'object' && window) window.CosBusy = function () { return WRITES.n > 0 || !!(typeof state === 'object' && state && state.reading && state.reading.sending); };
297
+ // 7.0.33 A8 (Anand round 2, slide 19): an answer that is only a machine code ("cos_pipeline_not_configured") is never
298
+ // put on screen as is. Known codes get plain words that say what to do; any other bare code gets one plain sentence.
299
+ // err.code keeps the code, so a pane can still tell them apart.
300
+ var NOT_SET_UP = 'This part of COS is not set up on this Mac, so there is nothing to show here. Nothing is broken.';
301
+ // When trying again does not help, the next step: COS Control's menu restarts the server.
302
+ var STILL = ' If it keeps happening, restart COS from the COS Control menu on your Mac.';
303
+ var PLAIN_CODES = {
304
+ cos_pipeline_not_configured: NOT_SET_UP, pipeline_missing: NOT_SET_UP,
305
+ // Q1: a scripts folder is set (COS_SCRIPTS_DIR) but COS cannot run it: a broken setup, not a missing feature.
306
+ bridge_missing: 'COS on your Mac is set to use a scripts folder it cannot run, so this cannot be read. Check the COS_SCRIPTS_DIR setting on your Mac, then restart COS from the COS Control menu.',
307
+ task_runtime_unavailable: 'Work is not set up on this Mac yet. Open COS Control on your Mac to finish setting it up.',
308
+ unknown_command: 'COS on your Mac is older than this page. Update it from COS Control on your Mac.',
309
+ bridge_error: 'COS on your Mac could not read this just now. Try again in a moment.' + STILL
310
+ };
311
+ function plainMessage(msg) {
312
+ var m = String(msg || '').trim();
313
+ if (!/^[a-z0-9]+(?:_[a-z0-9]+)+$/.test(m)) return msg;
314
+ return PLAIN_CODES[m] || 'COS on your Mac could not do that just now. Try again in a moment.' + STILL;
315
+ }
316
+ function notSetUp(code) { return code === 'cos_pipeline_not_configured' || code === 'pipeline_missing'; }
297
317
  function api(path, opts) {
298
318
  var o = opts || {};
299
319
  var headers = { 'X-COS-Web': '1' };
@@ -322,7 +342,7 @@
322
342
  try { data = text ? JSON.parse(text) : {}; } catch (_e) { data = {}; }
323
343
  if (!res.ok) {
324
344
  var msg = (data && (data.message || (data.error && data.error.message) || data.error)) || ('The server answered ' + res.status + '.');
325
- var err = new Error(typeof msg === 'string' ? msg : 'The server answered ' + res.status + '.');
345
+ var err = new Error((typeof plainMessage === 'function' ? plainMessage : String)(typeof msg === 'string' ? msg : 'The server answered ' + res.status + '.'));
326
346
  err.status = res.status;
327
347
  // The machine-readable reason, so a pane can say the right thing for a refusal whose
328
348
  // body carries a code and no sentence (for example work-intake's {error: {code}}).
@@ -597,6 +617,10 @@
597
617
  else pos = NAV.replace ? NAV.replace.pos : history.length - 1;
598
618
  var back = null;
599
619
  if (!st && !NAV.replace && NAV.last && typeof NAV.last.pos === 'number' && pos === NAV.last.pos + 1) back = NAV.last.hash;
620
+ // 7.0.33 A11 (Anand round 2, slides 5 and 28): a place picked from the menu (the rail, the pane menu, Ask's Go to)
621
+ // starts a new trail. That page has no path behind it, so Back goes up from it, as after a deep link.
622
+ if (NAV.fresh && Date.now() - NAV.fresh < 2000) back = null;
623
+ NAV.fresh = 0;
600
624
  NAV.replace = null;
601
625
  // 7.0.30, Miles's pick B: the steps before this one ride in the same mark, the last of them being where Back goes.
602
626
  var trail = back ? (NAV.last.trail || []).concat([{ h: NAV.last.hash, t: NAV.last.label || '' }]).slice(-NAV_TRAIL_MAX) : null;
@@ -621,15 +645,22 @@
621
645
  var t = st.cosNav.trail;
622
646
  return t.map(function (s, i) { return { label: trailName(s.h, s.t), hash: s.h, back: t.length - i }; });
623
647
  }
624
- // A step is named by what kind of page it is, not its title (the title is the heading under the row): "Meeting",
625
- // "Session", "Naming voices". A list is its pane. A message, a day and a chat keep their own short name.
648
+ // 7.0.33 A11: a step is named by what it shows, its title (a meeting's, a session's, "Message #2", a day's date), cut
649
+ // to TRAIL_NAME_MAX characters with an ellipsis; a list is its pane. Only while the title is not loaded does the kind
650
+ // stand in ("Meeting", "Session", "Naming voices"). The same rule as COS Control's trail on the Mac (D4, 0.5.394).
651
+ var TRAIL_NAME_MAX = 28;
652
+ function trailCap(text) { var t = String(text || '').replace(/\s+/g, ' ').trim(); return t.length > TRAIL_NAME_MAX ? t.slice(0, TRAIL_NAME_MAX - 1).replace(/\s+$/, '') + '\u2026' : t; }
626
653
  function trailName(hash, crumb) {
627
654
  var a = String(hash || '').replace(/^#\/?/, '').split('/').filter(function (x) { return x !== ''; }), p = ROOT_PANES.indexOf(a[0]) >= 0 ? a[0] : 'today';
628
655
  if (a.length < 2 || isTabRoute(a.slice(1))) return TITLES[p] || 'Today';
656
+ // Naming voices is a page of its own about a meeting; its crumb is the meeting's title, so by title it read the same
657
+ // as the meeting after it ("Speakers / Pricing review / Pricing review"). It keeps its name.
658
+ if (p === 'speakers' && a[1] === 'review') return 'Naming voices';
659
+ if (crumb) return trailCap(crumb);
629
660
  if (p === 'meetings') return 'Meeting';
630
661
  if (p === 'sessions') return 'Session';
631
662
  if (p === 'speakers' && a[1] === 'review') return 'Naming voices';
632
- return crumb || TITLES[p] || 'Today';
663
+ return TITLES[p] || 'Today';
633
664
  }
634
665
  // One line that never wraps. When it is too wide, steps leave from the middle (the one after the first, then the next),
635
666
  // never the first, the one before this page, or this page; "…" stands where they were. widths: each step's, then this
@@ -742,7 +773,7 @@
742
773
  // The rail changes the reply box's width: an empty box re-fits its placeholder (autoGrow, F2).
743
774
  var si = typeof $ === 'function' ? $('session-input') : null; if (si && typeof sxPlaceholderFit === 'function' && si.phLong) sxPlaceholderFit(si); else if (si && !si.value && typeof autoGrow === 'function') autoGrow(si);
744
775
  // A change after boot (a fold, a rotation, a new screen) is reported once, with the screen's own numbers.
745
- if (RAIL.on !== null && RAIL.on !== on && typeof layoutCrumb === 'function') { var sc = window.screen || {}; layoutCrumb('rail', { sw: sc.width, sh: sc.height, rail: on, railHit: RAIL.hit }); }
776
+ if (RAIL.on !== null && RAIL.on !== on && typeof layoutCrumb === 'function') { var sc = window.screen || {}; layoutCrumb('rail', { sw: sc.width, sh: sc.height, rail: on, railHit: RAIL.hit, dvh: typeof lrProbe === 'function' ? lrProbe('height:100dvh', 'h') : null }); }
746
777
  RAIL.on = on;
747
778
  if (!RAIL.hooked && typeof window.matchMedia === 'function' && typeof window.addEventListener === 'function') {
748
779
  RAIL.hooked = true;
@@ -782,6 +813,7 @@
782
813
 
783
814
  var reading = r.args.length > 0 && !isTabRoute(r.args);
784
815
  if (typeof sxFrameSync === 'function') sxFrameSync();
816
+ if (typeof rdFrameSync === 'function') rdFrameSync(r);
785
817
  var paneEl = $('pane-' + r.pane);
786
818
  if (paneEl) paneEl.classList.toggle('reading', reading);
787
819
  var split = paneEl && paneEl.querySelector('.split');
@@ -806,8 +838,12 @@
806
838
  if (typeof paintTrail === 'function') paintTrail(r);
807
839
  closeMenu();
808
840
  if (changedPane) window.scrollTo(0, 0);
809
- $('topbar').classList.remove('tucked');
841
+ // 7.0.33 A12 (Q1): through barShow, so a bar sliding back after a page change catches its first tap too.
842
+ barShow($('topbar'));
810
843
  onScroll();
844
+ // 7.0.33 A13: the pet in the top bar stays on every page; its card closes when the page changes.
845
+ if (typeof petCardClose === 'function') petCardClose();
846
+ if (typeof petInBar === 'function' && petInBar() && state.sessions) { placePet(); if (changedPane && $('today-pet').classList.contains('hidden')) renderPet(state.sessions); }
811
847
 
812
848
  if (!PANES[r.pane] && LAZY()[r.pane]) {
813
849
  var lazyPane = r.pane, lazyEl = $('pane-' + lazyPane), wasEmpty = lazyEl && !lazyEl.getAttribute('data-lazy-ready');
@@ -860,16 +896,44 @@
860
896
  var lastY = 0;
861
897
  function onScroll() {
862
898
  var y = Math.max(0, window.scrollY || 0);
863
- // On a reader the bar stays (its Back is there). The folded Duo's Back is on the side rail (the bar's is hidden by CSS,
864
- // not the class), so there the bar tucks as before (Q1 7.0.30 M1).
865
- var bar = $('topbar'), back = $('topbar-back'), onReader = !!(back && !back.classList.contains('hidden')) && !(typeof sideRail === 'function' && sideRail());
899
+ // On a reader the bar stays (its Back is there). 7.0.33 A12: on the folded Duo too, where the bar now shows a named
900
+ // Back (app.css); before, the bar tucked there and a tap meant for it landed on the page.
901
+ var bar = $('topbar'), back = $('topbar-back'), onReader = !!(back && !back.classList.contains('hidden'));
866
902
  bar.classList.toggle('scrolled', y > 8);
867
- if (y < 60 || y < lastY - 4 || onReader) bar.classList.remove('tucked');
903
+ if (y < 60 || y < lastY - 4 || onReader) barShow(bar);
868
904
  else if (y > lastY + 4 && isPhone()) bar.classList.add('tucked');
869
905
  lastY = y;
870
906
  }
871
907
  function openMenu() { if (sideRail()) return; $('section-menu').classList.remove('hidden'); var cur = document.querySelector('#section-menu [aria-current="page"]') || document.querySelector('#section-menu [data-goto]'); if (cur) cur.focus(); }
872
908
  function closeMenu() { $('section-menu').classList.add('hidden'); }
909
+ // 7.0.33 A12 (Anand round 2, slide 27): no tap falls through a hidden top bar. Once the bar has slid away its band shows
910
+ // the page, and a tap meant for the bar (Ask, Back) landed on whatever was there; on Settings that armed, then fired,
911
+ // "Sign out every browser". Now, while the bar is away or still sliding back, the first tap in its band only brings
912
+ // the bar back. Every page and size: the class is the test, so wherever the bar tucks, its band catches.
913
+ var barBackAt = 0, barCatch = null;
914
+ function barShow(bar) { if (bar.classList.contains('tucked')) { bar.classList.remove('tucked'); barBackAt = Date.now(); } }
915
+ // Inside the bar's own band only: its height, and its own left and right (Q1: on the folded Duo the rail's top icon
916
+ // sits beside the bar, and a tap on it while the bar was away was swallowed). The bar only moves up and down, so its
917
+ // left and right hold while it is away.
918
+ function barCatches(x, y) {
919
+ var bar = $('topbar');
920
+ if (!bar || !(y >= 0) || y >= bar.offsetHeight) return false;
921
+ var r = bar.getBoundingClientRect ? bar.getBoundingClientRect() : null;
922
+ if (r && !(x >= r.left && x < r.right)) return false;
923
+ return bar.classList.contains('tucked') || Date.now() - barBackAt < BAR_SLIDE_MS;
924
+ }
925
+ var BAR_SLIDE_MS = 320; // app.css: the bar's transform takes 220 ms; a little more for the finger
926
+ document.addEventListener('pointerdown', function (e) {
927
+ barCatch = null;
928
+ if (!barCatches(e.clientX, e.clientY)) return;
929
+ barCatch = { y: e.clientY, until: Date.now() + 1500 };
930
+ barShow($('topbar')); lastY = Math.max(0, window.scrollY || 0);
931
+ }, true);
932
+ document.addEventListener('click', function (e) {
933
+ var c = barCatch; barCatch = null;
934
+ if (!c || Date.now() > c.until || Math.abs(e.clientY - c.y) > 24) return;
935
+ e.preventDefault(); e.stopPropagation();
936
+ }, true);
873
937
 
874
938
  // ── auth ────────────────────────────────────────────────
875
939
  // 7.0.19: the first paint (index.html #first-paint) ends the moment the sign-in card or the app shows, whichever
@@ -905,6 +969,7 @@
905
969
  // hidden, so the gate could not scroll and Connect sat below a keyboard-height screen. The gate is a normal page;
906
970
  // showShell's route() puts the frame back (sxFrameSync) when the shell returns.
907
971
  document.documentElement.classList.remove('sx-frame');
972
+ document.documentElement.classList.remove('rd-frame');
908
973
  // A later 401 from a pane still polling (W.api passes no message) must not blank what the gate already says:
909
974
  // the reason it opened, or "That code did not match" while the person retypes.
910
975
  if (!message && alreadyOpen) return;
@@ -1431,17 +1496,19 @@
1431
1496
  function petPrefs() {
1432
1497
  var p = readStore(PET_KEY);
1433
1498
  var pet = typeof p.pet === 'string' && /^(mac|(shipped|gallery):[a-z0-9][a-z0-9-]{0,80})$/.test(p.pet) ? p.pet : 'mac';
1434
- return { pet: pet, show: p.show !== false };
1499
+ // 7.0.33 A13 (Anand round 2, slide 9): where it shows. 'today' as before, or 'bar': small in the top bar on a phone.
1500
+ return { pet: pet, show: p.show !== false, place: p.place === 'bar' ? 'bar' : 'today' };
1435
1501
  }
1436
1502
  function setPetPrefs(next) {
1437
1503
  var cur = petPrefs();
1438
- var merged = { pet: next.pet != null ? next.pet : cur.pet, show: next.show != null ? !!next.show : cur.show };
1504
+ var merged = { pet: next.pet != null ? next.pet : cur.pet, show: next.show != null ? !!next.show : cur.show, place: next.place != null ? (next.place === 'bar' ? 'bar' : 'today') : cur.place };
1439
1505
  writeStore(PET_KEY, merged);
1440
1506
  // Forget the old pet's manifest and loop; Today repaints with the new choice next time it shows.
1441
1507
  PET.manifest = null; PET.fetchedAt = 0; PET.pose = null; PET.token++; petStop();
1442
1508
  var box = $('today-pet');
1443
1509
  if (box) { box.innerHTML = ''; box.classList.add('hidden'); }
1444
- if (state.pane === 'today' && state.sessions) renderPet(state.sessions);
1510
+ placePet();
1511
+ if ((state.pane === 'today' || petInBar()) && state.sessions) renderPet(state.sessions);
1445
1512
  return merged;
1446
1513
  }
1447
1514
  var PET_HEIGHT = 140;
@@ -1558,13 +1625,72 @@
1558
1625
  // Folded (one column) the pet sits above the story. Unfolded (two columns, the same 760px line as
1559
1626
  // .today-cols in app.css) it moves to the top of the right-hand column, above Needs you (Miles 11/06).
1560
1627
  var PET_WIDE = window.matchMedia ? window.matchMedia('(min-width: 760px)') : null;
1628
+ // 7.0.33 A13 (Anand round 2, slide 9; Miles 12:43, the three choices and Anand's top bar mock): "In the top bar" puts the
1629
+ // same pet, small and round, in a phone's top bar left of Ask, on every page; a tap opens a card under it (the pet, its
1630
+ // line, Change pet and Hide). The folded and the open Duo have the rail instead of that bar: there it stays on Today
1631
+ // until its place there is chosen (render sent to the lead). The default stays On Today.
1632
+ var PET_BAR_MQ = window.matchMedia ? window.matchMedia('(min-width: 600px)') : null;
1633
+ function petInBar() { var p = petPrefs(); return p.show && p.place === 'bar' && !(PET_BAR_MQ && PET_BAR_MQ.matches) && !(typeof sideRail === 'function' && sideRail()); }
1561
1634
  function placePet() {
1562
1635
  var box = $('today-pet'), side = document.querySelector('#pane-today .today-side'), rec = $('today-rec');
1563
1636
  if (!box || !side || !rec) return;
1637
+ var bar = $('topbar'), ask = bar && bar.querySelector('[data-open-ask]');
1638
+ if (petInBar() && ask) { if (ask.previousElementSibling !== box) bar.insertBefore(box, ask); box.classList.add('in-bar'); return; }
1639
+ box.classList.remove('in-bar'); petCardClose();
1564
1640
  if (PET_WIDE && PET_WIDE.matches) { if (side.firstElementChild !== box) side.insertBefore(box, side.firstElementChild); }
1565
1641
  else if (rec.previousElementSibling !== box) rec.parentNode.insertBefore(box, rec);
1566
1642
  }
1567
- if (PET_WIDE) { if (PET_WIDE.addEventListener) PET_WIDE.addEventListener('change', placePet); else if (PET_WIDE.addListener) PET_WIDE.addListener(placePet); }
1643
+ function petReplace() { placePet(); if (petInBar() && state.sessions) renderPet(state.sessions); }
1644
+ [PET_WIDE, PET_BAR_MQ].forEach(function (mq) { if (mq) { if (mq.addEventListener) mq.addEventListener('change', petReplace); else if (mq.addListener) mq.addListener(petReplace); } });
1645
+ // The card under the small pet, as in Anand's mock: the pet, its line (petCaption, the words Today shows under it), one
1646
+ // more line when nothing runs, then Change pet and Hide. With something to open, the pet and its line open it, as the
1647
+ // pet on Today does (petTap). Hide turns the pet off and says where it comes back.
1648
+ var PET_IDLE_LINE = 'I\u2019ll wave when an agent starts.';
1649
+ function petCardToggle() {
1650
+ var box = $('today-pet'); if (!box) return;
1651
+ if (box.querySelector('.pet-card')) return petCardClose();
1652
+ var cap = box.querySelector('.pet-caption'), txt = cap ? cap.textContent : '', btn = box.querySelector('.pet-btn');
1653
+ var live = btn && btn.getAttribute('aria-disabled') === 'false';
1654
+ box.insertAdjacentHTML('beforeend', '<div class="pet-card" role="dialog" aria-label="Your pet">' +
1655
+ '<button type="button" class="pet-card-head" data-today="pet-open"' + (live ? '' : ' aria-disabled="true"') + '><span class="pet-card-pet" aria-hidden="true"></span>' +
1656
+ '<span class="pet-card-text"><span class="pet-card-title"></span>' + (txt === 'Nothing running' ? '<span class="pet-card-sub">' + esc(PET_IDLE_LINE) + '</span>' : '') + '</span></button>' +
1657
+ '<div class="pet-card-acts"><button type="button" class="cx-btn quiet" data-goto="settings/pet">Change pet</button><button type="button" class="cx-btn quiet" data-action="pet-hide">Hide</button></div></div>');
1658
+ box.querySelector('.pet-card-title').textContent = txt;
1659
+ var art = box.querySelector('.pet-btn .pet-still, .pet-btn .pet-sprite');
1660
+ if (art) box.querySelector('.pet-card-pet').appendChild(art.cloneNode(true));
1661
+ if (btn) btn.setAttribute('aria-expanded', 'true');
1662
+ petCardPlace(box.querySelector('.pet-card'));
1663
+ }
1664
+ // The card hangs under the pet, but never over a banner's own buttons (the sign-in notice's Hide, the question banner's
1665
+ // Answer, lead 12:5x): if it would cover one, it sits just below the lowest such banner instead.
1666
+ function petCardPlace(card) {
1667
+ if (!card || !card.getBoundingClientRect) return;
1668
+ var c = card.getBoundingClientRect(), drop = 0;
1669
+ document.querySelectorAll('#conn-notice:not([hidden]) button, #sq-host:not([hidden]) button, #sq-host:not([hidden]) a, .rec-banner').forEach(function (b) {
1670
+ var r = b.getBoundingClientRect();
1671
+ if (r.width && r.height && r.left < c.right && r.right > c.left && r.top < c.bottom && r.bottom > c.top) {
1672
+ var host = b.closest('#conn-notice, #sq-host'), h = host ? host.getBoundingClientRect() : r; // the recording banner is its own button
1673
+ drop = Math.max(drop, h.bottom + 8 - c.top);
1674
+ }
1675
+ });
1676
+ if (drop > 0) card.style.top = 'calc(100% + 8px + ' + Math.ceil(drop) + 'px)';
1677
+ }
1678
+ function petHide() {
1679
+ petCardClose(); setPetPrefs({ show: false });
1680
+ toast('Pet hidden. Turn it back on in Settings, Pet.');
1681
+ }
1682
+ if (typeof document !== 'undefined' && document.addEventListener) {
1683
+ document.addEventListener('click', function (e) {
1684
+ var box = $('today-pet');
1685
+ if (box && box.querySelector('.pet-card') && !box.contains(e.target)) petCardClose();
1686
+ }, true);
1687
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape') petCardClose(); });
1688
+ }
1689
+ function petCardClose() {
1690
+ var box = $('today-pet'), card = box && box.querySelector('.pet-card');
1691
+ if (card) card.remove();
1692
+ var btn = box && box.querySelector('.pet-btn'); if (btn) btn.removeAttribute('aria-expanded');
1693
+ }
1568
1694
  var PET_CHANGE = '<button type="button" class="pet-change" data-goto="settings/pet">Change pet</button>';
1569
1695
  // A still pet (a gallery pick, Cloud Puff, or a single image on the Mac): one picture for every state.
1570
1696
  function renderPetStill(box, m, sessions) {
@@ -1624,9 +1750,11 @@
1624
1750
  if (still || spec.frames < 2) return;
1625
1751
  var step = function () {
1626
1752
  // Runs only while Today is on screen: leaving the pane or hiding the page stops the loop.
1627
- if (token !== PET.token || document.hidden || state.pane !== 'today') { PET.timer = null; return; }
1753
+ if (token !== PET.token || document.hidden || (state.pane !== 'today' && !petInBar())) { PET.timer = null; return; }
1628
1754
  PET.frame = (PET.frame + 1) % spec.frames;
1629
1755
  petPaint(el, spec, PET.frame, scale);
1756
+ // 7.0.33 A13: the card's copy of the pet moves with it.
1757
+ var cardArt = box.querySelector('.pet-card .pet-sprite'); if (cardArt) petPaint(cardArt, spec, PET.frame, scale);
1630
1758
  PET.timer = setTimeout(step, Math.max(20, spec.interval * 1000));
1631
1759
  };
1632
1760
  PET.timer = setTimeout(step, Math.max(20, spec.interval * 1000));
@@ -1860,7 +1988,10 @@
1860
1988
  var write = typeof storyWrite === 'function' ? storyWrite : function (e, kind, text, when) {
1861
1989
  if (kind === 'brief') e.innerHTML = esc(text) + '<span class="story-when">' + esc(when) + '</span>'; else e.textContent = text;
1862
1990
  };
1863
- if (TODAY.offline) { askBox(null); write(el, 'offline', 'COS on ' + state.serverName + ' is not answering right now. Today fills in when it does.'); return; }
1991
+ // 7.0.33 A6 (Anand round 2, slide 20): what the sentence says, Needs you and Running do not say again. TODAY.storySaid
1992
+ // is the list of failures this sentence named ('offline' when it says the Mac is not answering); renderNeeds reads it.
1993
+ var said = function (v, brief) { var was = JSON.stringify([TODAY.storySaid, TODAY.storyBrief]); TODAY.storySaid = v; TODAY.storyBrief = !!brief; if (was !== JSON.stringify([v, !!brief]) && typeof renderNeeds === 'function' && $('needs-list')) renderNeeds(m); };
1994
+ if (TODAY.offline) { askBox(null); write(el, 'offline', 'COS on ' + state.serverName + ' is not answering right now. Today fills in when it does.'); said('offline'); return; }
1864
1995
  // The model's summary when there is one (it never blocks: the counts line below shows until it lands). The test
1865
1996
  // harnesses that run this block alone have no requestBrief, so it is looked up, never assumed.
1866
1997
  askBox(null);
@@ -1868,7 +1999,7 @@
1868
1999
  requestBrief(m);
1869
2000
  var brief = briefShown();
1870
2001
  askBox(brief);
1871
- if (brief) { write(el, 'brief', brief.text, briefWhen(brief, m)); return; }
2002
+ if (brief) { write(el, 'brief', brief.text, briefWhen(brief, m)); said([], true); return; }
1872
2003
  }
1873
2004
  var first = '';
1874
2005
  if (m.sessionsWaiting > 0) first = countWord(m.sessionsWaiting) + ' ' + plural(m.sessionsWaiting, 'session is', 'sessions are') + ' waiting on you';
@@ -1889,7 +2020,11 @@
1889
2020
  else second = 'No agents are running';
1890
2021
  if (TODAY.recording) second += (unread || working > 0 || open > 0 ? ', and ' : '. ') + 'COS is recording a meeting';
1891
2022
  var out = (first ? first + '. ' : '') + second + '.';
1892
- var others = m.failed.filter(function (n) { return n !== 'sessions'; });
2023
+ // 7.0.33 A6: more than two sources unread is the Mac not answering, said once, here (it was Needs you's row).
2024
+ if (m.failed.length > 2) { said(m.failed.slice()); write(el, 'counts', (first ? first + '. ' : '') + 'Your Mac is not answering, so this may not be everything.'); return; }
2025
+ var others = m.failed.filter(function (n) { return n !== 'sessions'; }), named = unread ? ['sessions'] : [];
2026
+ if (!m.settled && !m.now.length && !m.later.length && (unread ? others : m.failed).length) named = named.concat(unread ? others : m.failed);
2027
+ said(named);
1893
2028
  if (!m.settled && !m.now.length && !m.later.length) out += (unread ? others : m.failed).length ? ' Couldn\u2019t read ' + listWords(unread ? others : m.failed) + ', so this may not be everything.' : unread ? ' This may not be everything that needs you.' : ' Still reading what needs you.';
1894
2029
  else if (unread) out += ' This may not be everything that needs you.';
1895
2030
  write(el, 'counts', out);
@@ -1919,6 +2054,10 @@
1919
2054
  $('run-count').textContent = working.length + jobs.n ? String(working.length + jobs.n) : '';
1920
2055
  var list = $('run-list');
1921
2056
  // Never "Nothing running" about a list that was not read.
2057
+ // 7.0.33 A6: when nothing runs, the sentence ("No agents are running", or that sessions could not be read) is all
2058
+ // Today says about it; the panel shows again the moment a session or a COS job runs.
2059
+ var panel = list.closest ? list.closest('.run-panel') : null;
2060
+ if (panel && panel.classList) panel.classList.toggle('hidden', !jobs.n && (TODAY.sessionsState === 'failed' || (TODAY.sessionsState === 'answered' && !working.length && !open.length)));
1922
2061
  if (TODAY.sessionsState === 'failed') { list.innerHTML = jobs.html + '<button type="button" class="run-more run-failed" data-today-needs="retry">Couldn\u2019t read sessions. Try again</button>'; return; }
1923
2062
  var shown = working.slice(0, 3);
1924
2063
  var html = jobs.html + shown.map(function (s) { return runRow(s, true); }).join('');
@@ -2424,12 +2563,18 @@
2424
2563
  if (m.later.length && !TODAY.showLater) html += '<button type="button" class="need-more" data-today-needs="later">' + esc(m.later.length + (m.later.length === 1 ? ' thing can' : ' things can') + ' wait until tomorrow. Show') + '</button>';
2425
2564
  // 6.99.30 (design QA): no empty line above a failed read (it left a 45 px gap), and one retry row, never one per
2426
2565
  // source: with the Mac not answering (sessions and Work both unread, or more than two sources) it says so once.
2427
- if (!m.rows.length) html = m.failed.length ? '' : '<p class="card-empty">' + esc(m.settled ? 'Nothing is waiting on you.' : 'Reading sessions, Work, voices and memories.') + '</p>';
2428
- if (m.failed.length) {
2429
- var silent = TODAY.offline || m.failed.length > 2;
2430
- html += '<button type="button" class="need-more need-failed" data-today-needs="retry">' + esc(silent ? 'Your Mac is not answering. Try again.' : 'Couldn’t read ' + listWords(m.failed) + '. Try again') + '</button>';
2566
+ // 7.0.33 A6: a failure the sentence above already names is not said again here (Refresh at the top tries again), and
2567
+ // with nothing to show (nothing waiting, which the sentence says, or only what it said) the card steps aside.
2568
+ var said = TODAY.storySaid || [], left = said === 'offline' ? [] : m.failed.filter(function (n) { return said.indexOf(n) < 0; });
2569
+ // A model summary does not say "Nothing is waiting on you", so under one the card still does.
2570
+ if (!m.rows.length) html = m.failed.length || (m.settled && !TODAY.storyBrief) ? '' : '<p class="card-empty">' + esc(m.settled ? 'Nothing is waiting on you.' : 'Reading sessions, Work, voices and memories.') + '</p>';
2571
+ if (left.length) {
2572
+ var silent = TODAY.offline || left.length > 2;
2573
+ html += '<button type="button" class="need-more need-failed" data-today-needs="retry">' + esc(silent ? 'Your Mac is not answering. Try again.' : 'Couldn’t read ' + listWords(left) + '. Try again') + '</button>';
2431
2574
  }
2432
2575
  list.innerHTML = html;
2576
+ var card = list.closest ? list.closest('.card') : null;
2577
+ if (card && card.classList) card.classList.toggle('hidden', !html);
2433
2578
  }
2434
2579
  // Weather sits under the date at the top left (Miles, 11/07), where the eye starts, not inside Next up.
2435
2580
  // It shows whether or not a meeting is next.
@@ -2762,7 +2907,7 @@
2762
2907
  view: sxPref('view', 'sessions', ['sessions', 'usage', 'cost']),
2763
2908
  recency: sxPref('recency', 'newest', ['newest', 'oldest', 'match']),
2764
2909
  q: '', instant: [], remote: [], searching: false, searchError: '', ranFor: '', semantic: null, semanticReason: '', searchSeq: 0, searchTimer: null,
2765
- hooks: null, hooksAt: 0,
2910
+ hooks: null, hooksAt: 0, hooksOffer: false, offerPutAway: false,
2766
2911
  usage: null, usageAt: 0, usageLoading: false, usageError: '',
2767
2912
  uPeriod: 'Last 30 days', uTool: 'All tools', uModel: 'All models',
2768
2913
  // Usage on a wide screen (uWideModel): the custom range, the chart sentence, the chosen bar, the table's order.
@@ -2900,7 +3045,8 @@
2900
3045
  var stats = view === 'sessions' ? sxStats(vis) : [];
2901
3046
  var busy = view === 'sessions' ? SX.loading : SX.usageLoading;
2902
3047
  hero.innerHTML = '<span class="cx-glyph"><svg class="icon"><use href="#i-sessions"/></svg></span>' +
2903
- '<div class="cx-hero-text"><h1 class="cx-title">Sessions</h1><p class="cx-detail-line">' + esc(detail) + liveHeroNote(view) + '</p></div>' +
3048
+ // 7.0.33 A5: the heading names the view the top bar names (Usage, COS cost), not the place, so the page has one name.
3049
+ '<div class="cx-hero-text"><h1 class="cx-title">' + esc(SX_VIEWS[view] || 'Sessions') + '</h1><p class="cx-detail-line">' + esc(detail) + liveHeroNote(view) + '</p></div>' +
2904
3050
  '<div class="cx-actions"><button type="button" class="cx-btn quiet" data-sx="refresh"' + (busy ? ' disabled' : '') + '><svg class="icon"><use href="#i-refresh"/></svg>Refresh</button></div>' +
2905
3051
  (stats.length ? '<div class="cx-stats">' + stats.map(function (s) { return '<span class="cx-stat"><b>' + esc(typeof s[0] === 'number' ? s[0].toLocaleString() : s[0]) + '</b><span>' + s[1] + '</span></span>'; }).join('') + '</div>' : '');
2906
3052
  // 7.0.23 step 2b: Updated and Opened are the sort menu now, and Pinned a group; no clock switches here.
@@ -2935,6 +3081,150 @@
2935
3081
  if (on) sxFrameHook();
2936
3082
  return on;
2937
3083
  }
3084
+ // 7.0.31 hotfix (Miles 17:25, the open Duo): the message reader in its own frame from 600 px. html.rd-frame makes the
3085
+ // page one screen, as Sessions' frame does (panes/cos-chat.css): the app shell is the visible box (--vv-top, --vv-h
3086
+ // from viewportFit, which falls back to innerHeight when the visual viewport reads 0 or less), and the reader scrolls
3087
+ // in the workspace under the navigation bar, with Reply pinned to its bottom. Before, the reader was the document: its Reply stuck to the
3088
+ // document's scrollport and the rail was 100dvh, so when Safari's viewport went stale after folds (his Duo reported
3089
+ // visual viewport heights of 0 and -33554432 from 17:17 to 17:24) both ended mid-screen and the message drew below
3090
+ // them. Below 600 px (phones, the folded Duo's side rail) the reader is unchanged; the CSS is gated the same way, so a
3091
+ // class left on across a fold does nothing there. A message's image (#/messages/m/<no>/a/<id>) has no Reply: no frame.
3092
+ function rdFrameOn(r) {
3093
+ var shell = $('app-shell'), wide = !!(window.matchMedia && window.matchMedia('(min-width: 600px)').matches);
3094
+ return !!(r && r.pane === 'messages' && r.args && r.args[0] === 'm' && r.args.length === 2 && wide && !(shell && shell.classList.contains('hidden')));
3095
+ }
3096
+ var RD_FRAME = { hooked: false, n: 0 };
3097
+ function rdFrameSync(r) {
3098
+ var de = document.documentElement, on = rdFrameOn(r || parseRoute());
3099
+ if (de && de.classList) de.classList.toggle('rd-frame', on);
3100
+ if (!RD_FRAME.hooked && window.matchMedia) {
3101
+ RD_FRAME.hooked = true;
3102
+ var mq = window.matchMedia('(min-width: 600px)');
3103
+ if (mq && mq.addEventListener) mq.addEventListener('change', function () { rdFrameSync(); }); else if (mq && mq.addListener) mq.addListener(function () { rdFrameSync(); });
3104
+ }
3105
+ var r2 = r || parseRoute();
3106
+ rdPosHook();
3107
+ // A different message just opened: its place is its top until he scrolls. (This also runs when the width crosses
3108
+ // 600 px; the same message then keeps its anchor and its pending restore.)
3109
+ if (window.requestAnimationFrame) window.requestAnimationFrame(function () {
3110
+ var cx = rdPosReader(), key = cx ? cx.getAttribute('data-reader-key') : '';
3111
+ if (!RD_POS.a || RD_POS.a.key !== key) { RD_POS.pending = false; rdPosCapture(); }
3112
+ });
3113
+ if (r2 && r2.pane === 'messages' && r2.args && r2.args[0] === 'm' && r2.args.length === 2 && window.requestAnimationFrame) window.requestAnimationFrame(function () { rdFrameCrumb('open'); });
3114
+ return on;
3115
+ }
3116
+ // One report per message reader opened, at any width, at most 8 a page load (numbers only; lead 17:48): whether the frame
3117
+ // is on, the visible box viewportFit uses, the reader's box (top, bottom, height), the workspace's height and content height (the frame's scroller), Reply's top
3118
+ // and bottom, the document's height and scroll, and 100dvh. layoutCrumb adds innerWidth and innerHeight and the visual
3119
+ // viewport's height, top and scale, so a pan like 17:25's (top about 409) shows in the same row. The 17:25 frame could
3120
+ // not be measured; the next one will be.
3121
+ function rdFrameCrumb(why) {
3122
+ if (RD_FRAME.n >= 8 || typeof layoutCrumb !== 'function') return false;
3123
+ try {
3124
+ var v = viewportFit(), f = document.querySelector('#pane-messages .cx-reply-form'), cx = document.querySelector('#pane-messages > .cx'), ws = $('workspace');
3125
+ var fr = f && f.getBoundingClientRect ? f.getBoundingClientRect() : null, cr = cx && cx.getBoundingClientRect ? cx.getBoundingClientRect() : null;
3126
+ var se = document.scrollingElement || document.documentElement;
3127
+ var out = { why: String(why || '').slice(0, 24), frame: !!(document.documentElement.classList && document.documentElement.classList.contains('rd-frame')), fb: !!v.bad,
3128
+ boxT: v.top, boxB: v.top + v.height, rdT: cr ? cr.top : -1, rdB: cr ? cr.bottom : -1, rdH: cx ? cx.clientHeight : -1, wsH: ws ? ws.clientHeight : -1, wsSH: ws ? ws.scrollHeight : -1,
3129
+ replyT: fr ? fr.top : -1, replyB: fr ? fr.bottom : -1, docH: se ? se.scrollHeight : -1, sy: window.scrollY || 0, dvh: typeof lrProbe === 'function' ? lrProbe('height:100dvh', 'h') : null };
3130
+ if (layoutCrumb('reader', out)) { RD_FRAME.n++; return true; }
3131
+ } catch (_e) { /* a report never stops the page */ }
3132
+ return false;
3133
+ }
3134
+ // 7.0.31 (Q1 FIX FIRST, lead 20:41): ONE job. A fold or an unfold that crosses 600 px switches the message between the
3135
+ // page's scroll (under 600) and the frame's workspace (600 and up), and the new scroller would open at the top. So the
3136
+ // page keeps an anchor, the character at the top of the reading area, read as he scrolls, and applies it ONCE after a
3137
+ // switch: when no finger is down and the scroll has been still for RD_POS_STILL_MS. If he scrolls on purpose first (a
3138
+ // touch that moves, the wheel, a key), his scroll wins and the anchor is dropped. Nothing else moves the text: no
3139
+ // other resize, no keyboard, no card or notice. Under 600 px with no switch, the reader is 7.0.30's.
3140
+ var RD_POS_STILL_MS = 300, RD_POS_MOVE_PX = 10;
3141
+ var RD_POS = { a: null, mode: '', pending: false, timer: 0, touching: false, ty: 0, moved: false, scrollAt: 0, queued: false, hooked: false };
3142
+ function rdPosReader() {
3143
+ var r = parseRoute();
3144
+ return r && r.pane === 'messages' && r.args && r.args[0] === 'm' && r.args.length === 2 ? document.querySelector('#pane-messages > .cx[data-reader-key]') : null;
3145
+ }
3146
+ function rdPosMode() { var ws = $('workspace'); return ws && getComputedStyle(ws).overflowY === 'auto' ? 'frame' : 'page'; }
3147
+ // The top of the reading area: the workspace's top in the frame; on the page, below the pinned top bar.
3148
+ function rdPosTop(mode) {
3149
+ if (mode === 'frame') return $('workspace').getBoundingClientRect().top;
3150
+ var t = 0;
3151
+ ['.topbar', '.navbar'].forEach(function (sel) {
3152
+ var e = document.querySelector(sel), st = e && getComputedStyle(e), r = e && e.getBoundingClientRect();
3153
+ if (e && st.display !== 'none' && r.height > 0 && r.bottom > 0 && (st.position === 'sticky' || st.position === 'fixed')) t = Math.max(t, r.bottom);
3154
+ });
3155
+ return t;
3156
+ }
3157
+ // One character's line box (a collapsed range has none in every engine).
3158
+ function rdPosRect(node, offset) {
3159
+ var len = node && node.nodeValue ? node.nodeValue.length : 0;
3160
+ if (!len) return null;
3161
+ var rg = document.createRange(), at = Math.min(offset, len - 1);
3162
+ rg.setStart(node, at); rg.setEnd(node, at + 1);
3163
+ var r = rg.getBoundingClientRect();
3164
+ return r.height || r.width ? r : null;
3165
+ }
3166
+ function rdPosCapture() {
3167
+ var cx = rdPosReader();
3168
+ if (!cx) { RD_POS.a = null; RD_POS.mode = ''; return null; }
3169
+ var mode = rdPosMode(), top = rdPosTop(mode), key = cx.getAttribute('data-reader-key');
3170
+ RD_POS.mode = mode;
3171
+ if ((mode === 'frame' ? $('workspace').scrollTop : window.scrollY || 0) <= 2) return (RD_POS.a = { key: key, atTop: true });
3172
+ var c = cx.getBoundingClientRect(), body = cx.querySelector('.cx-said.cos .cx-card-body'), xs = [c.left + 8, c.left + c.width / 2];
3173
+ if (body) xs.unshift(body.getBoundingClientRect().left + 6);
3174
+ for (var dy = 2; dy <= 62; dy += 12) {
3175
+ for (var i = 0; i < xs.length; i++) {
3176
+ var hit = document.caretRangeFromPoint ? document.caretRangeFromPoint(xs[i], top + dy) : null, n = hit && hit.startContainer;
3177
+ if (!n || n.nodeType !== 3 || !cx.contains(n) || n.parentElement.closest('.cx-reply-form')) continue;
3178
+ var r = rdPosRect(n, hit.startOffset);
3179
+ if (r) return (RD_POS.a = { key: key, node: n, offset: hit.startOffset, dy: r.top - top });
3180
+ }
3181
+ }
3182
+ return null;
3183
+ }
3184
+ function rdPosApply() {
3185
+ var cx = rdPosReader(), a = RD_POS.a, mode = rdPosMode(), ws = $('workspace');
3186
+ RD_POS.mode = mode;
3187
+ if (!cx || !a || a.key !== cx.getAttribute('data-reader-key')) return false;
3188
+ if (a.atTop) { if (mode === 'frame') ws.scrollTop = 0; else window.scrollTo(0, 0); return true; }
3189
+ var r = a.node.isConnected ? rdPosRect(a.node, a.offset) : null;
3190
+ if (!r) return false;
3191
+ var want = Math.round(r.top - rdPosTop(mode) - a.dy);
3192
+ if (mode === 'frame') ws.scrollTop += want; else window.scrollBy(0, want);
3193
+ return true;
3194
+ }
3195
+ // The scroller changed under him: wait for no finger and a still scroll, then apply once; his own scroll drops it.
3196
+ function rdPosTick() {
3197
+ RD_POS.timer = 0;
3198
+ if (!RD_POS.pending) return;
3199
+ if (RD_POS.moved) { RD_POS.pending = false; rdPosCapture(); return; }
3200
+ if (RD_POS.touching || Date.now() - RD_POS.scrollAt < RD_POS_STILL_MS) { RD_POS.timer = setTimeout(rdPosTick, 80); return; }
3201
+ RD_POS.pending = false;
3202
+ rdPosApply();
3203
+ }
3204
+ function rdPosCheck() {
3205
+ var mode = rdPosReader() ? rdPosMode() : '';
3206
+ if (!mode || !RD_POS.mode || mode === RD_POS.mode || RD_POS.pending) return;
3207
+ RD_POS.pending = true; RD_POS.moved = false;
3208
+ if (!RD_POS.timer) RD_POS.timer = setTimeout(rdPosTick, 80);
3209
+ }
3210
+ function rdPosHook() {
3211
+ if (RD_POS.hooked || !document.addEventListener) return;
3212
+ RD_POS.hooked = true;
3213
+ var o = { capture: true, passive: true };
3214
+ document.addEventListener('scroll', function () {
3215
+ RD_POS.scrollAt = Date.now();
3216
+ if (!rdPosReader()) return;
3217
+ rdPosCheck();
3218
+ if (RD_POS.pending || RD_POS.queued) return;
3219
+ RD_POS.queued = true;
3220
+ requestAnimationFrame(function () { RD_POS.queued = false; rdPosCheck(); if (!RD_POS.pending) rdPosCapture(); });
3221
+ }, o);
3222
+ window.addEventListener('resize', rdPosCheck);
3223
+ document.addEventListener('touchstart', function (e) { RD_POS.touching = true; RD_POS.ty = e.touches && e.touches[0] ? e.touches[0].clientY : 0; }, o);
3224
+ document.addEventListener('touchmove', function (e) { var y = e.touches && e.touches[0] ? e.touches[0].clientY : RD_POS.ty; if (RD_POS.pending && Math.abs(y - RD_POS.ty) > RD_POS_MOVE_PX) RD_POS.moved = true; }, o);
3225
+ ['touchend', 'touchcancel'].forEach(function (t) { document.addEventListener(t, function () { RD_POS.touching = false; }, o); });
3226
+ ['wheel', 'keydown'].forEach(function (t) { document.addEventListener(t, function () { if (RD_POS.pending) RD_POS.moved = true; }, o); });
3227
+ }
2938
3228
  // Where the frame's parts sit against the VISIBLE area (step 1's position report, lead 23:00): on a reader's open, and
2939
3229
  // KB_AFTER_MS after an on-screen keyboard closes while the frame is on. The visible area is viewportFit's box (a
2940
3230
  // visible height under 80 px is no reading, so it is innerHeight from 0, and fb says so) and, across, visualViewport's
@@ -3730,17 +4020,40 @@
3730
4020
  if (Date.now() - SX.hooksAt < 60000) { paintHooks(); return; }
3731
4021
  SX.hooksAt = Date.now();
3732
4022
  api('/api/session-hooks/status').then(function (d) {
3733
- SX.hooks = (d && d.sessionHooks && d.sessionHooks.state) || null; paintHooks();
3734
- }, function (err) { SX.hooks = err && err.status === 404 ? 'route_absent' : null; paintHooks(); });
4023
+ SX.hooks = (d && d.sessionHooks && d.sessionHooks.state) || null;
4024
+ // 7.0.34 (X1): the 8 hour question wait is on by default and offered until this Mac reinstalls its hooks.
4025
+ SX.hooksOffer = !!(d && d.sessionHooks && d.sessionHooks.questionWait && d.sessionHooks.questionWait.offered === true);
4026
+ paintHooks();
4027
+ }, function (err) { SX.hooks = err && err.status === 404 ? 'route_absent' : null; SX.hooksOffer = false; paintHooks(); });
4028
+ }
4029
+ // 7.0.34 (X1): "Not now" puts the 8 hour offer away in THIS browser only. A store that cannot be read or written
4030
+ // just means the line shows again; a hooks-problem line is never hidden by it.
4031
+ var SX_OFFER_HIDE_KEY = 'cos.sx.questionWaitOfferHidden';
4032
+ function offerHidden() {
4033
+ try { return typeof localStorage === 'object' && !!localStorage && localStorage.getItem(SX_OFFER_HIDE_KEY) === '1'; } catch (_e) { return false; }
4034
+ }
4035
+ function hideOffer() {
4036
+ try { if (typeof localStorage === 'object' && localStorage) localStorage.setItem(SX_OFFER_HIDE_KEY, '1'); } catch (_e) { /* not kept: it shows next visit */ }
3735
4037
  }
3736
4038
  function paintHooks() {
3737
4039
  var el = $('sessions-hooks'); if (!el) return;
3738
4040
  var st = SX.hooks;
3739
- if (['missing', 'drift', 'script_outdated', 'route_absent'].indexOf(st) < 0) { el.innerHTML = ''; return; }
3740
- var text = st === 'route_absent' ? 'Live session state needs COS server 6.48.0 or later.'
3741
- : st === 'missing' ? 'Claude Code hooks are not installed: sessions read from their transcripts only.'
3742
- : 'Claude Code hooks need a refresh (' + (st === 'drift' ? 'settings changed' : 'script outdated') + ').';
3743
- var where = st === 'route_absent' ? 'Update Server from COS Control on your Mac.' : (st === 'missing' ? 'Install hooks' : 'Reinstall hooks') + ' from COS Control on your Mac.';
4041
+ if (['missing', 'drift', 'script_outdated', 'route_absent'].indexOf(st) < 0) {
4042
+ // 7.0.34 (X1): read-only, no dot. A hooks problem above already says Reinstall hooks, so this shows only
4043
+ // when there is none (A8: each thing once). Installing hooks stays a Mac action.
4044
+ var offer = st === 'installed' && SX.hooksOffer && !SX.offerPutAway && !offerHidden();
4045
+ el.innerHTML = offer
4046
+ ? '<div class="sx-hooks"><span>' + esc('Questions can now wait up to 8 hours.') + ' <span class="sx-hooks-where">' + esc('In COS Control on your Mac, choose Reinstall hooks.') + '</span> <button type="button" class="sx-link" data-sx-offer="hide">' + esc('Not now') + '</button></span></div>'
4047
+ : '';
4048
+ var notNow = offer && el.querySelector ? el.querySelector('[data-sx-offer="hide"]') : null;
4049
+ if (notNow) notNow.onclick = function () { SX.offerPutAway = true; hideOffer(); paintHooks(); };
4050
+ return;
4051
+ }
4052
+ // 7.0.33 A8: plain words, then what to do.
4053
+ var text = st === 'route_absent' ? 'Whether a session is working or waiting needs a newer COS on your Mac.'
4054
+ : st === 'missing' ? 'COS cannot yet tell when a Claude Code session is working or waiting on you.'
4055
+ : 'COS\u2019s link to Claude Code needs a refresh, so working and waiting may be out of date.';
4056
+ var where = st === 'route_absent' ? 'Update it from COS Control on your Mac.' : 'In COS Control on your Mac, choose ' + (st === 'missing' ? 'Install hooks' : 'Reinstall hooks') + '.';
3744
4057
  el.innerHTML = '<div class="sx-hooks"><span class="sx-hooks-dot" aria-hidden="true"></span><span>' + esc(text) + ' <span class="sx-hooks-where">' + esc(where) + '</span></span></div>';
3745
4058
  }
3746
4059
 
@@ -7489,13 +7802,18 @@
7489
7802
  (recent.length ? '<h2 class="group-title">Recent sessions</h2><div class="list">' + recent.map(function (s) { return sessionRow(s); }).join('') + '</div>' : '');
7490
7803
  return;
7491
7804
  }
7492
- var jumps = JUMPS.filter(function (j) { return j[1].toLowerCase().indexOf(q.toLowerCase()) === 0; });
7493
- out.innerHTML = '<div class="list" style="margin-top:14px">' +
7805
+ // 7.0.33 A7 (Anand round 2, slide 20): typing "usage" put Go to Usage last, under four Ask rows. When the words name a
7806
+ // place (its whole name, or its first three letters or more, "go to" allowed in front), Go to comes first.
7807
+ var ql = q.toLowerCase().replace(/^go to\s+/, '');
7808
+ var jumps = JUMPS.filter(function (j) { return j[1].toLowerCase().indexOf(ql) === 0; });
7809
+ var placeFirst = jumps.length > 0 && (ql.length >= 3 || jumps.some(function (j) { return j[1].toLowerCase() === ql; }));
7810
+ var jumpRows = jumps.map(function (j) { return '<button type="button" class="nav-row" data-goto="' + j[0] + '"><svg class="icon"><use href="#i-' + j[0] + '"/></svg><span class="grow">Go to ' + esc(j[1]) + '</span><span class="chev"></span></button>'; }).join('');
7811
+ out.innerHTML = '<div class="list" style="margin-top:14px">' + (placeFirst ? jumpRows : '') +
7494
7812
  '<button type="button" class="nav-row" data-action="ask-cos"><svg class="icon"><use href="#i-ask"/></svg><span class="grow">Ask COS<span class="note">' + esc(askRowNote(q)) + '</span></span></button>' +
7495
7813
  '<button type="button" class="nav-row" data-action="ask-file"' + (ASK_FILE.busy ? ' disabled' : '') + '><svg class="icon"><use href="#i-work"/></svg><span class="grow">' + (ASK_FILE.busy ? 'Filing\u2026' : 'File to Work inbox') + fileNoteHtml(q) + '</span></button>' +
7496
7814
  '<button type="button" class="nav-row" data-action="ask-meaning"><svg class="icon"><use href="#i-sessions"/></svg><span class="grow">Find a session about this<span class="note">Ranks every session by meaning</span></span></button>' +
7497
7815
  '<button type="button" class="nav-row" data-action="ask-work"><svg class="icon"><use href="#i-work"/></svg><span class="grow">Find a task about this<span class="note">Searches Work by meaning</span></span></button>' +
7498
- jumps.map(function (j) { return '<button type="button" class="nav-row" data-goto="' + j[0] + '"><svg class="icon"><use href="#i-' + j[0] + '"/></svg><span class="grow">Go to ' + esc(j[1]) + '</span><span class="chev"></span></button>'; }).join('') +
7816
+ (placeFirst ? '' : jumpRows) +
7499
7817
  '</div><div id="ask-hits"></div><p class="status-line" id="ask-status"></p>';
7500
7818
  paintAskRow();
7501
7819
  clearTimeout(renderAsk.t);
@@ -7545,7 +7863,12 @@
7545
7863
  return;
7546
7864
  }
7547
7865
  if (t.hasAttribute('data-open-ask')) return openAsk();
7548
- if (t.hasAttribute('data-today')) return t.getAttribute('data-today') === 'pet' ? petTap(t) : openTodayAction(t);
7866
+ if (t.hasAttribute('data-today')) {
7867
+ var dt = t.getAttribute('data-today');
7868
+ if (dt === 'pet-open') { if (t.getAttribute('aria-disabled') !== 'true') { petCardClose(); petTap($('today-pet').querySelector('.pet-btn')); } return; }
7869
+ return dt === 'pet' ? (petInBar() ? petCardToggle() : petTap(t)) : openTodayAction(t);
7870
+ }
7871
+ if (t.getAttribute('data-action') === 'pet-hide') return petHide();
7549
7872
  if (t.hasAttribute('data-today-next')) return prepNext();
7550
7873
  if (t.hasAttribute('data-next-prep')) return toggleNextPrep(t.getAttribute('data-next-prep'));
7551
7874
  if (t.hasAttribute('data-next-prep-cancel')) return closeNextPrep(true);
@@ -7559,6 +7882,8 @@
7559
7882
  }
7560
7883
  if (t.hasAttribute('data-theme-choice')) { window.CosTheme.set(t.getAttribute('data-theme-choice')); paintAppearance(); return; }
7561
7884
  if (t.hasAttribute('data-goto') || t.hasAttribute('data-pane')) {
7885
+ // 7.0.33 A11: a pick from the menu (the rail, the pane menu, Ask's Go to) starts a new trail (navStamp).
7886
+ if (t.closest('.rail, #section-menu, #ask-results')) NAV.fresh = Date.now();
7562
7887
  if (inSheet) closeAsk(); closeMenu();
7563
7888
  // "work/sort" is a pane and its arguments, not one name.
7564
7889
  var dest = String(t.getAttribute('data-goto') || t.getAttribute('data-pane')).split('/');
@@ -7577,7 +7902,7 @@
7577
7902
  if (action === 'drop-after') return dropHeld();
7578
7903
  if (action === 'unqueue') return removeQueued(t.getAttribute('data-turn-key'));
7579
7904
  if (action === 'revoke-all') {
7580
- if (t.getAttribute('data-armed') !== '1') { t.setAttribute('data-armed', '1'); t.querySelector('.grow').firstChild.textContent = 'Tap again to sign out every browser'; return; }
7905
+ if (!window.COSWeb.armTap(t, t.querySelector('.grow').firstChild, 'Tap again to sign out every browser')) return;
7581
7906
  api('/api/web-session/revoke-all', { method: 'POST', body: {} }).then(function () { showAuth('Every browser is signed out, this one too.'); }).catch(function (err) { setStatus($('settings-status'), err.message, true); });
7582
7907
  return;
7583
7908
  }
@@ -7944,6 +8269,9 @@
7944
8269
  // once a detail has loaded; route() clears it on every navigation.
7945
8270
  setCrumb: function (text) { crumbDetail = String(text || ''); navLabel(crumbDetail); paintTrail(); runHooks(routeHooks, parseRoute()); },
7946
8271
  crumb: function () { return crumbDetail; },
8272
+ // 7.0.33 A11: the trail for the navigation bar from 600 px (shell.js paintNavbar): the same steps and names as the
8273
+ // phone's row, and this page's name.
8274
+ trailNow: function (r) { r = r || parseRoute(); var steps = r.args.length && !isTabRoute(r.args) ? navTrail() : null; return { steps: steps || [], here: steps || r.args.length ? trailName(location.hash, crumbDetail) : (crumbDetail || TITLES[r.pane] || 'Today') }; },
7947
8275
  // Panes that read with their own fetch call this on a 401 so the sign-in screen shows.
7948
8276
  showAuth: function (message) { showAuth(message); },
7949
8277
  // Control's session state (the Sessions pane's sxState) and warm-up test, so the rail's
@@ -7978,12 +8306,29 @@
7978
8306
  // Today as a place to act (panes/today-actions.js, Miles 11/06): what the Today cards show, a repaint after a
7979
8307
  // decision, and the Sessions reader's own pieces, so the overlay sends and reads through the reader's code.
7980
8308
  window.COSWeb.pane = function (name) { return PANES[name] || null; };
8309
+ window.COSWeb.plainMessage = plainMessage; window.COSWeb.notSetUp = notSetUp;
7981
8310
  // Tap, then tap again (Today's Dismiss, Stop and Send anyway; a question card's Allow, Deny and Send): the second tap
7982
8311
  // counts only once the first has been on screen ARM_CONFIRM_MS, so a double tap or a finger bounce arms and never
7983
8312
  // confirms (W2-r2). The one rule both panes/today-actions.js and panes/questions.js read.
7984
8313
  var ARM_CONFIRM_MS = 400;
7985
8314
  window.COSWeb.armConfirmMs = ARM_CONFIRM_MS;
7986
8315
  window.COSWeb.confirmReady = function (armedAt) { return typeof armedAt === 'number' && Date.now() - armedAt >= ARM_CONFIRM_MS; };
8316
+ // 7.0.33 A12: the same rule for a button armed in place (Sign out every browser, Settings' Release), which until now
8317
+ // stayed armed with no time limit, so a stray tap minutes later confirmed it. armTap(button, label, armedText) returns
8318
+ // true on a confirming tap; otherwise it arms (label says armedText) for ARM_EXPIRE_MS and puts the words back after.
8319
+ var ARM_EXPIRE_MS = 5000;
8320
+ window.COSWeb.armExpireMs = ARM_EXPIRE_MS;
8321
+ window.COSWeb.armTap = function (el, label, armedText) {
8322
+ var now = Date.now(), at = Number(el.getAttribute('data-armed-at')) || 0;
8323
+ if (el.getAttribute('data-armed') === '1' && now - at < ARM_EXPIRE_MS) return now - at >= ARM_CONFIRM_MS;
8324
+ if (el.getAttribute('data-armed') !== '1') el.setAttribute('data-idle-text', label.textContent);
8325
+ el.setAttribute('data-armed', '1'); el.setAttribute('data-armed-at', String(now)); label.textContent = armedText;
8326
+ setTimeout(function () {
8327
+ if (el.getAttribute('data-armed-at') !== String(now)) return;
8328
+ el.removeAttribute('data-armed'); el.removeAttribute('data-armed-at'); label.textContent = el.getAttribute('data-idle-text') || label.textContent;
8329
+ }, ARM_EXPIRE_MS);
8330
+ return false;
8331
+ };
7987
8332
  window.COSWeb.today = {
7988
8333
  data: function () {
7989
8334
  return { needs: TODAY.needs, working: TODAY.working, open: TODAY.open, welcome: TODAY.welcome, offline: TODAY.offline, at: TODAY.at,