@gotcos/glasses-server 6.99.25 → 6.99.33

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 (53) hide show
  1. package/CHANGELOG.md +224 -0
  2. package/package.json +1 -1
  3. package/server/index.ts +46 -4
  4. package/server/lib/agent-session-output-images.ts +280 -0
  5. package/server/lib/agent-session-store.ts +3 -1
  6. package/server/lib/agent-session-turns.ts +18 -7
  7. package/server/lib/client-liveness.ts +38 -3
  8. package/server/lib/codex-subagent-activity.ts +36 -0
  9. package/server/lib/g2-turn-provenance.ts +679 -0
  10. package/server/lib/media-store.ts +41 -2
  11. package/server/lib/permission-broker.ts +94 -17
  12. package/server/lib/queue-turn-evidence.ts +19 -2
  13. package/server/lib/session-halt.ts +15 -1
  14. package/server/lib/session-stream-events.ts +4 -0
  15. package/server/lib/session-subagents.ts +155 -0
  16. package/server/lib/session-turn-attachments.ts +245 -0
  17. package/server/lib/thread-turn-queue-deliver.ts +17 -2
  18. package/server/lib/thread-turn-queue.ts +10 -0
  19. package/server/lib/today-brief.ts +731 -0
  20. package/server/lib/turn-file-store.ts +202 -0
  21. package/server/lib/turn-footer.ts +81 -0
  22. package/server/routes/agent-session-bindings.ts +164 -16
  23. package/server/routes/agent-sessions.ts +77 -8
  24. package/server/routes/client-instance.ts +12 -1
  25. package/server/routes/cursor-stop-followup.ts +30 -1
  26. package/server/routes/g2-turns.ts +46 -0
  27. package/server/routes/health.ts +9 -0
  28. package/server/routes/media.ts +32 -8
  29. package/server/routes/permission-broker.ts +47 -4
  30. package/server/routes/thread-turn-queue.ts +148 -8
  31. package/server/routes/today-brief.ts +148 -0
  32. package/server/web-control/app.css +42 -9
  33. package/server/web-control/app.js +975 -114
  34. package/server/web-control/control.css +12 -6
  35. package/server/web-control/index.html +6 -1
  36. package/server/web-control/panes/cos-chat.css +27 -3
  37. package/server/web-control/panes/cos-chat.js +240 -46
  38. package/server/web-control/panes/meetings.css +5 -2
  39. package/server/web-control/panes/meetings.js +21 -10
  40. package/server/web-control/panes/memories.css +1 -1
  41. package/server/web-control/panes/memories.js +3 -8
  42. package/server/web-control/panes/questions.css +56 -2
  43. package/server/web-control/panes/questions.js +454 -56
  44. package/server/web-control/panes/reader-media.js +154 -0
  45. package/server/web-control/panes/sessions.css +20 -7
  46. package/server/web-control/panes/settings.js +34 -2
  47. package/server/web-control/panes/speakers.css +3 -5
  48. package/server/web-control/panes/speakers.js +7 -4
  49. package/server/web-control/panes/threads.js +3 -2
  50. package/server/web-control/panes/today-actions.css +81 -3
  51. package/server/web-control/panes/today-actions.js +527 -62
  52. package/server/web-control/panes/work.css +1 -1
  53. package/server/web-control/shell.js +3 -0
@@ -58,6 +58,19 @@
58
58
  if (diff === 1) return 'tomorrow';
59
59
  return d.toLocaleDateString(undefined, { weekday: 'long' });
60
60
  }
61
+ // 6.99.30 (design QA): a day in a row, in plain words: "Today", else "Nov 7" ("Nov 7, 2025" in another year). Takes
62
+ // a YYYY-MM-DD (a meeting's date, read as that calendar day, never shifted by time zone) or a Date. Anything else comes
63
+ // back as it was.
64
+ var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
65
+ function plainDay(v, now) {
66
+ var y, m, d;
67
+ if (v instanceof Date && isFinite(v.getTime())) { y = v.getFullYear(); m = v.getMonth(); d = v.getDate(); }
68
+ else { var p = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v == null ? '' : v)); if (!p) return v == null ? '' : String(v); y = +p[1]; m = +p[2] - 1; d = +p[3]; }
69
+ if (m < 0 || m > 11 || d < 1 || d > 31) return String(v);
70
+ var n = now || new Date();
71
+ if (y === n.getFullYear() && m === n.getMonth() && d === n.getDate()) return 'Today';
72
+ return MONTHS[m] + ' ' + d + (y === n.getFullYear() ? '' : ', ' + y);
73
+ }
61
74
  var PROVIDERS = { claude: 'Claude Code', codex: 'Codex', cursor: 'Cursor', glasses: 'Glasses', cos: 'COS' };
62
75
  function providerLabel(p) { return PROVIDERS[p] || (p ? p.charAt(0).toUpperCase() + p.slice(1) : 'Session'); }
63
76
  function badge(p) { var cls = PROVIDERS[p] ? (p === 'cos' ? 'glasses' : p) : 'work'; return '<span class="badge ' + esc(cls) + '">' + esc(providerLabel(p)) + '</span>'; }
@@ -100,9 +113,14 @@
100
113
  var limit = o.timeoutMs != null ? o.timeoutMs : (method === 'GET' ? API_READ_TIMEOUT_MS : 0);
101
114
  var ctrl = limit && window.AbortController ? new AbortController() : null;
102
115
  var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, limit) : null;
116
+ var sentAt = Date.now();
103
117
  return fetch(path, { method: method, headers: headers, credentials: 'same-origin', body: body, signal: ctrl ? ctrl.signal : undefined }).then(function (res) {
104
- if (timer) clearTimeout(timer);
118
+ // Blob bodies keep their deadline after headers: a stalled download must release its queue slot.
119
+ if (timer && o.responseType !== 'blob') clearTimeout(timer);
120
+ sxNoteClock(res, sentAt);
121
+ noteServer(true);
105
122
  if (res.status === 401 && path.indexOf('/api/web-session/') !== 0) { showAuth(); var gone = new Error('Signed out. Connect this browser again.'); gone.status = 401; throw gone; }
123
+ if (o.responseType === 'blob' && res.ok) return res.blob();
106
124
  return res.text().then(function (text) {
107
125
  var data = {};
108
126
  try { data = text ? JSON.parse(text) : {}; } catch (_e) { data = {}; }
@@ -118,17 +136,74 @@
118
136
  }
119
137
  return data;
120
138
  });
139
+ }).then(function (value) {
140
+ if (timer) clearTimeout(timer);
141
+ return value;
121
142
  }, function (err) {
122
143
  if (timer) clearTimeout(timer);
123
144
  if (err && err.name === 'AbortError' && ctrl && ctrl.signal.aborted) {
145
+ // 6.99.32 (QA 6.99.30 canary W1): a read the Mac never answered in time means it is not answering. A write has
146
+ // no deadline unless its caller set one, and a slow save is not a Mac that went away, so only reads say so.
147
+ if (method === 'GET' && !(SERVER.lastUpAt >= sentAt)) noteServer(false);
124
148
  var late = new Error('Your Mac did not answer in ' + Math.round(limit / 1000) + ' seconds. Check that it is awake, then try again.');
125
149
  late.code = 'timeout'; throw late;
126
150
  }
151
+ // 6.99.30: the browser's own words for a request that never reached the Mac ("Failed to fetch" in Chrome and
152
+ // Comet, "Load failed" in Safari, "NetworkError..." in Firefox) never reach the page. Anything with no status
153
+ // that is not a timeout or an abort the caller asked for is the Mac out of reach, said once, here.
154
+ if (err && err.status == null && !err.code && err.name !== 'AbortError') { noteServer(false); throw unreachableError(err); }
127
155
  throw err;
128
156
  });
129
157
  }
130
158
  var API_READ_TIMEOUT_MS = 20000;
131
159
 
160
+ // ── what every request learns about the Mac (6.99.30) ──
161
+ // api() above and apiAnswer() (the Sessions reader's transport) share these; they sit here, between api() and the
162
+ // state, so a test that loads api() alone has them.
163
+ // 6.99.30: the one sentence for a request that never reached the Mac (api() and apiAnswer() below, and the panes with
164
+ // their own fetch: COSWeb.unreachableCopy). Never the browser's own "Failed to fetch" or "Load failed".
165
+ var UNREACHABLE_COPY = 'Could not reach COS on your Mac. Check that it is awake and on Tailscale, then try again.';
166
+ function unreachableError(cause) { var e = new Error(UNREACHABLE_COPY); e.code = 'unreachable'; e.cause = cause; return e; }
167
+
168
+ // 6.99.30 (QA: clock skew): the Mac's clock, as this page's offset from it. The session card's one state (sxTruth)
169
+ // weighs the Mac's own times (state_since, a row's activity) against moments this page stamped with its own clock (a
170
+ // gate answer, a Stop). A phone a minute behind made every Mac time look a minute newer, so a stale Idle beat a
171
+ // Working the page had just been told about. Each answer's Date header (whole seconds, read at the midpoint of the
172
+ // request) gives the offset; the answer with the shortest round trip in the last ten minutes wins, so one slow answer
173
+ // never moves it. Zero until the first answer. A missing or unreadable header changes nothing.
174
+ var SX_CLOCK = { skew: 0, rtt: Infinity, at: 0 };
175
+ var SX_CLOCK_KEEP_MS = 600000;
176
+ function sxNoteClock(res, sentAt) {
177
+ var got = Date.now(), h = null;
178
+ try { h = res && res.headers && typeof res.headers.get === 'function' ? res.headers.get('date') : null; } catch (_e) { h = null; }
179
+ var server = h ? Date.parse(h) : NaN;
180
+ if (!isFinite(server) || typeof sentAt !== 'number') return;
181
+ var rtt = Math.max(0, got - sentAt);
182
+ // The header drops the milliseconds: on average the Mac's moment is half a second after the stamped second.
183
+ var skew = Math.round(server + 500 - (sentAt + rtt / 2));
184
+ if (rtt <= SX_CLOCK.rtt || got - SX_CLOCK.at > SX_CLOCK_KEEP_MS) { SX_CLOCK.skew = skew; SX_CLOCK.rtt = rtt; SX_CLOCK.at = got; }
185
+ }
186
+ // 6.99.30 (QA: "Server connected" stayed green for five minutes after the Mac went away): every request says whether
187
+ // the Mac answered. A request that never reached it marks it down; any HTTP answer, whatever its status, marks it up.
188
+ // QA 6.99.30 (Skeptic): never a 502. Nothing sits in front of the Mac (direct HTTPS over Tailscale) and COS sends its
189
+ // own 502s (an unsupported Memories workspace, a bad Meetings Ask answer), so a 502 flapped the rail, the question
190
+ // cards and an open sheet down and up again. The rail's server line, Today's sheet and the question cards hear each change at once
191
+ // (COSWeb.onServer). null until the first answer.
192
+ // lastUpAt (6.99.32, QA Skeptic W-2): when the Mac last answered anything, on this page's clock. A read that timed out
193
+ // marks it down only if nothing has answered since that read went out (a slow image whose headers already came, a busy
194
+ // Mac whose question poll keeps answering), so a slow but alive Mac never flaps.
195
+ var SERVER = { up: null, hooks: [], lastUpAt: 0 };
196
+ function noteServer(up) {
197
+ up = !!up;
198
+ if (up) SERVER.lastUpAt = Date.now();
199
+ if (SERVER.up === up) return;
200
+ SERVER.up = up;
201
+ SERVER.hooks.slice().forEach(function (fn) { try { fn(up); } catch (err) { console.error(err); } });
202
+ }
203
+
204
+ /** This page's moment `t` (its own Date.now()) on the Mac's clock. */
205
+ function sxServerTime(t) { return t == null ? t : t + SX_CLOCK.skew; }
206
+
132
207
  // ── state ───────────────────────────────────────────────
133
208
  var state = {
134
209
  pane: 'today', serverName: '', tailnetUrl: '',
@@ -204,6 +279,17 @@
204
279
  if (location.hash === hash) route(); else location.hash = hash;
205
280
  }
206
281
  function isPhone() { return !window.matchMedia('(min-width: 600px)').matches; }
282
+ /** A keyboard and a fine pointer are the way in (a Mac, a PC): Return sends. Touch screens type a new line. */
283
+ function keyboardFirst() { return !!(window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches); }
284
+ // Return in a text box sends only where a keyboard and a pointer are the way in (cos-chat.js keyboardFirst): never on
285
+ // a touch screen, whatever its width (6.99.30: the unfolded Duo's on-screen keyboard sent on Return), and never
286
+ // mid-composition (an IME's Return picks the word; keyCode 229 is Safari's composing key). Shift-Return is a new line.
287
+ // QA 6.99.30 (Skeptic): Cmd-Return or Ctrl-Return sends on every device, so a Bluetooth keyboard on the Duo or an iPad
288
+ // (a coarse pointer, so plain Return is a new line there) still has a send key.
289
+ function returnSends(e) {
290
+ if (!e || e.key !== 'Enter' || e.shiftKey || e.isComposing || e.keyCode === 229 || !e.target || e.target.tagName !== 'TEXTAREA') return false;
291
+ return !!(e.metaKey || e.ctrlKey || keyboardFirst());
292
+ }
207
293
  function route(forceChanged) {
208
294
  var r = parseRoute();
209
295
  var changedPane = forceChanged === true || r.pane !== state.pane || !document.querySelector('#pane-' + r.pane + ':not(.hidden)');
@@ -215,7 +301,7 @@
215
301
  var railPane = r.pane === 'usage' ? 'sessions' : r.pane;
216
302
  document.querySelectorAll('.rail-btn,.rail-settings').forEach(function (b) { if (b.getAttribute('data-pane') === railPane) b.setAttribute('aria-current', 'page'); else b.removeAttribute('aria-current'); });
217
303
  if (state.stream && !(r.pane === 'sessions' && r.args.length === 2)) { state.stream.close(); state.stream = null; }
218
- if (!(r.pane === 'sessions' && r.args.length === 2)) { clearInterval(state.turnPoll); if (state.reading) clearInterval(state.reading.heldTimer); state.reading = null; }
304
+ if (!(r.pane === 'sessions' && r.args.length === 2)) { readerImagesStop($('session-turns')); clearInterval(state.turnPoll); if (state.reading) clearInterval(state.reading.heldTimer); state.reading = null; }
219
305
  if (r.pane !== 'meetings' && state.livePoll) { clearInterval(state.livePoll); state.livePoll = null; }
220
306
 
221
307
  var reading = r.args.length > 0 && !isTabRoute(r.args);
@@ -292,6 +378,7 @@
292
378
 
293
379
  // ── auth ────────────────────────────────────────────────
294
380
  function showAuth(message) {
381
+ readerImagesStop($('session-turns')); readerImagesStop($('ta-last'));
295
382
  var alreadyOpen = !$('auth-gate').classList.contains('hidden');
296
383
  $('app-shell').classList.add('hidden');
297
384
  $('auth-gate').classList.remove('hidden');
@@ -332,16 +419,35 @@
332
419
  // stories all asked for it separately. Now there is one copy: a request younger than maxAgeMs is
333
420
  // answered from it, a request already on the wire is shared, and panes can paint the last copy at
334
421
  // once while a fresh one loads (sessionListCached).
335
- var SL = { data: null, at: 0, inflight: null };
422
+ var SL = { data: null, at: 0, inflight: null, waiting: null };
336
423
  function sessionList(maxAgeMs) {
337
424
  if (SL.data && Date.now() - SL.at < (maxAgeMs || 0)) return Promise.resolve(SL.data);
338
425
  if (SL.inflight) return SL.inflight;
339
426
  SL.inflight = api('/api/agent-sessions?limit=80&sort=updated').then(function (d) {
340
- SL.data = d; SL.at = Date.now(); SL.inflight = null; return d;
427
+ SL.data = d; SL.at = Date.now(); SL.inflight = null;
428
+ noteWaiting(d);
429
+ return d;
341
430
  }, function (err) { SL.inflight = null; throw err; });
342
431
  return SL.inflight;
343
432
  }
344
433
  function sessionListCached() { return SL.data ? { data: SL.data, at: SL.at } : null; }
434
+ // 6.99.27 (Miles 11/07 09:29, "a big delay to see the questions"): a session that has just gone to waiting may have
435
+ // parked a question for this page. The question poll (panes/questions.js) reads at once rather than at its next turn.
436
+ function questionsNudge() {
437
+ var q = window.COSWeb && window.COSWeb.sessionQuestions;
438
+ if (q && typeof q.nudge === 'function') { try { q.nudge(); } catch (_e) {} }
439
+ }
440
+ function noteWaiting(d) {
441
+ var now = {}, fresh = false;
442
+ ((d && d.sessions) || []).forEach(function (s) {
443
+ if (!s || sxState(s) !== 'waiting') return;
444
+ var k = splitSessionId(s.provider, s.session_id || s.id), key = k.provider + '|' + String(k.id).toLowerCase();
445
+ now[key] = 1;
446
+ if (!SL.waiting || !SL.waiting[key]) fresh = true;
447
+ });
448
+ SL.waiting = now;
449
+ if (fresh) questionsNudge();
450
+ }
345
451
 
346
452
  // ── the session pet (Today) ─────────────────────────────
347
453
  // Same pet and the same pose rule as COS Control (PetSpritePose.resolve): waiting with nothing running
@@ -367,27 +473,99 @@
367
473
  return merged;
368
474
  }
369
475
  var PET_HEIGHT = 140;
370
- function petPose(sessions) {
371
- var working = 0, waiting = 0, alive = 0;
476
+ // 6.99.27 (Miles 11/07 09:29, "there wasn't an option for the pet to interact with the questions"): the questions
477
+ // this page holds (panes/questions.js), the ones that can be answered here. One key per session, as Needs you keys
478
+ // them.
479
+ function petQuestions() {
480
+ var q = window.COSWeb && window.COSWeb.sessionQuestions;
481
+ var items = q && typeof q.itemsFor === 'function' ? q.itemsFor('') : [];
482
+ return (items || []).filter(function (it) { return it && it.sessionId; });
483
+ }
484
+ function petQKey(it) { var k = splitSessionId(it.provider, it.sessionId); return k.provider + '|' + k.id; }
485
+ function petPose(sessions, questions) {
486
+ var working = 0, waiting = 0, alive = 0, asked = {};
487
+ var qs = questions || [];
488
+ qs.forEach(function (it) { asked[petQKey(it).toLowerCase()] = 1; });
372
489
  sessions.forEach(function (s) {
373
490
  var st = sxState(s);
374
491
  // Working is the Running card's own test (running_active), so the caption under the pet never
375
492
  // disagrees with the Running count beside it (11/06: the pet said 3 while the card said 2).
376
493
  if (s.running_active) working++;
377
- else if (st === 'waiting') waiting++;
494
+ // A session whose question the caption already leads with is not counted again as waiting on you.
495
+ else if (st === 'waiting' && !asked[petKey(s).toLowerCase()]) waiting++;
378
496
  if (s.running || s.running_active) alive++;
379
497
  });
380
498
  var pose;
381
- if (waiting > 0 && working === 0) pose = 'waiting';
499
+ // COS Control's rule (PetSpritePose.resolve): attention outranks every count pose. A question waiting here is the
500
+ // web's attention: it needs you, and this page can answer it.
501
+ if (qs.length) pose = 'attention';
502
+ else if (waiting > 0 && working === 0) pose = 'waiting';
382
503
  else if (working + waiting >= 4) pose = 'swarm';
383
504
  else if (working + waiting === 3) pose = 'trio';
384
505
  else if (working + waiting === 2) pose = 'duel';
385
506
  else if (working + waiting === 1) pose = 'working';
386
507
  else pose = alive > 0 ? 'patrol' : 'idle';
387
- return { pose: pose, working: working, waiting: waiting };
508
+ // QA 6.99.27 (User 6): a permission request is an approval, counted apart from the questions.
509
+ var approvals = qs.filter(function (it) { return it.kind === 'approval'; }).length;
510
+ return { pose: pose, working: working, waiting: waiting, questions: qs.length - approvals, approvals: approvals };
511
+ }
512
+ // COS Control's fallbacks (PetSpritePose.fallbackPoses) for a pet that has no strip for the pose.
513
+ var PET_FALLBACK = { attention: ['error', 'waiting', 'idle'], patrol: ['idle'], duel: ['swarm', 'working', 'idle'], trio: ['swarm', 'duel', 'working', 'idle'],
514
+ swarm: ['duel', 'trio', 'working', 'idle'], working: ['idle'], waiting: ['idle'] };
515
+ function petPoseIn(poses, want) {
516
+ if (poses[want]) return want;
517
+ var chain = PET_FALLBACK[want] || [];
518
+ for (var i = 0; i < chain.length; i++) if (poses[chain[i]]) return chain[i];
519
+ return poses.idle ? 'idle' : Object.keys(poses)[0];
520
+ }
521
+ // 6.99.26 (Miles 11/07, "Open what matters"): the sessions behind the pet's caption, by the same test petPose
522
+ // counts with, so a tap opens what the caption says. In play: waiting on you, then running. Done today rides along in
523
+ // the list, never in the count.
524
+ function petPlay(sessions) {
525
+ var waiting = [], running = [], done = [], today = new Date().toDateString();
526
+ (sessions || []).forEach(function (s) {
527
+ var st = sxState(s);
528
+ if (s.running_active) running.push(s);
529
+ else if (st === 'waiting') waiting.push(s);
530
+ else if ((st === 'recent' || st === 'stale') && !sxKeepWarm(s)) {
531
+ var d = toDate(s.last_activity_at || s.state_since);
532
+ if (d && d.toDateString() === today) done.push(s);
533
+ }
534
+ });
535
+ return { waiting: waiting, running: running, done: done };
536
+ }
537
+ function petKey(s) { var k = splitSessionId(s.provider, s.session_id || s.id); return k.provider + '|' + k.id; }
538
+ // One session in play opens its own sheet (waiting: the question card or reply box; else the Running peek); several
539
+ // open the short list; none does nothing.
540
+ function petTap(t) {
541
+ // A question waiting here comes first: one session's question opens its card in the overlay; questions from
542
+ // several sessions open the list, question rows first.
543
+ var qs = petQuestions(), qkeys = {};
544
+ qs.forEach(function (it) { qkeys[petQKey(it)] = 1; });
545
+ var qk = Object.keys(qkeys);
546
+ if (qk.length === 1) return openTodayAction(t, 'wait', qk[0]);
547
+ if (qk.length > 1) return openTodayAction(t, 'pet', '');
548
+ var p = petPlay(state.sessions || []), n = p.waiting.length + p.running.length;
549
+ if (!n) return;
550
+ if (n > 1) return openTodayAction(t, 'pet', '');
551
+ var s = p.waiting[0] || p.running[0];
552
+ openTodayAction(t, sxState(s) === 'waiting' ? 'wait' : 'run', petKey(s));
553
+ }
554
+ function petButtonState(box, c, label) {
555
+ var btn = box.querySelector('.pet-btn');
556
+ if (!btn) return;
557
+ var any = c.working + c.waiting + (c.questions || 0) + (c.approvals || 0) > 0;
558
+ btn.setAttribute('aria-label', any ? label + '. Open' : label);
559
+ btn.setAttribute('aria-disabled', any ? 'false' : 'true');
388
560
  }
389
561
  function petCaption(c) {
390
562
  var parts = [];
563
+ // A question or an approval waiting here leads (6.99.27): "1 question for you", "1 approval for you", or
564
+ // "1 question and 1 approval for you" (QA 6.99.27, User 6: approvals were counted as questions).
565
+ var asks = [];
566
+ if (c.questions) asks.push(c.questions === 1 ? '1 question' : c.questions + ' questions');
567
+ if (c.approvals) asks.push(c.approvals === 1 ? '1 approval' : c.approvals + ' approvals');
568
+ if (asks.length) parts.push(asks.join(' and ') + ' for you');
391
569
  if (c.working) parts.push(c.working + ' running');
392
570
  if (c.waiting) parts.push(c.waiting + ' waiting on you');
393
571
  return parts.length ? parts.join(', ') : 'Nothing running';
@@ -418,17 +596,17 @@
418
596
  var PET_CHANGE = '<button type="button" class="pet-change" data-goto="settings/pet">Change pet</button>';
419
597
  // A still pet (a gallery pick, Cloud Puff, or a single image on the Mac): one picture for every state.
420
598
  function renderPetStill(box, m, sessions) {
421
- var c = petPose(sessions), label = petCaption(c);
599
+ var c = petPose(sessions, petQuestions()), label = petCaption(c);
422
600
  petStop(); PET.pose = 'still:' + m.still;
423
601
  var img = box.querySelector('.pet-still');
424
602
  if (!img || img.getAttribute('src') !== m.still) {
425
- box.innerHTML = '<button type="button" class="pet-btn" data-goto="sessions"><img class="pet-still" alt="" src="' + esc(m.still) + '"><span class="pet-caption"></span></button>' + PET_CHANGE;
603
+ box.innerHTML = '<button type="button" class="pet-btn" data-today="pet"><img class="pet-still" alt="" src="' + esc(m.still) + '"><span class="pet-caption"></span></button>' + PET_CHANGE;
426
604
  img = box.querySelector('.pet-still');
427
605
  img.addEventListener('error', function () { box.classList.add('hidden'); });
428
606
  img.addEventListener('load', function () { img.classList.toggle('pixel', img.naturalHeight > 0 && img.naturalHeight < 140); });
429
607
  }
430
608
  box.querySelector('.pet-caption').textContent = label;
431
- box.querySelector('.pet-btn').setAttribute('aria-label', 'Sessions: ' + label);
609
+ petButtonState(box, c, label);
432
610
  box.classList.remove('hidden');
433
611
  }
434
612
  function renderPet(sessions) {
@@ -448,15 +626,14 @@
448
626
  if (!now.show || now.pet !== prefs.pet) return;
449
627
  if (!m || !m.available) { box.classList.add('hidden'); petStop(); return; }
450
628
  if (m.kind === 'still') { renderPetStill(box, m, sessions); return; }
451
- var c = petPose(sessions);
452
- var pose = m.poses[c.pose] ? c.pose : (m.poses.idle ? 'idle' : Object.keys(m.poses)[0]);
629
+ var c = petPose(sessions, petQuestions());
630
+ var pose = petPoseIn(m.poses, c.pose);
453
631
  var spec = m.poses[pose];
454
632
  var label = petCaption(c);
455
633
  box.classList.remove('hidden');
456
634
  var cap = box.querySelector('.pet-caption');
457
635
  if (cap) cap.textContent = label;
458
- var btn = box.querySelector('.pet-btn');
459
- if (btn) btn.setAttribute('aria-label', 'Sessions: ' + label);
636
+ petButtonState(box, c, label);
460
637
  if (PET.pose === pose && PET.timer) return;
461
638
  var token = ++PET.token;
462
639
  petLoadImages(spec).then(function () {
@@ -464,10 +641,10 @@
464
641
  petStop();
465
642
  PET.pose = pose; PET.frame = 0;
466
643
  if (!box.querySelector('.pet-sprite')) {
467
- box.innerHTML = '<button type="button" class="pet-btn" data-goto="sessions"><span class="pet-sprite" aria-hidden="true"></span><span class="pet-caption"></span></button>' + PET_CHANGE;
644
+ box.innerHTML = '<button type="button" class="pet-btn" data-today="pet"><span class="pet-sprite" aria-hidden="true"></span><span class="pet-caption"></span></button>' + PET_CHANGE;
468
645
  }
469
646
  box.querySelector('.pet-caption').textContent = label;
470
- box.querySelector('.pet-btn').setAttribute('aria-label', 'Sessions: ' + label);
647
+ petButtonState(box, c, label);
471
648
  var el = box.querySelector('.pet-sprite'), scale = PET_HEIGHT / spec.height;
472
649
  el.style.width = Math.round(spec.frameWidth * scale) + 'px';
473
650
  petPaint(el, spec, 0, scale);
@@ -531,6 +708,7 @@
531
708
  renderRunning(working, open);
532
709
  if (!offline) renderPet(sessions);
533
710
  paintNeeds();
711
+ renderWeather(welcome);
534
712
  renderNext(welcome);
535
713
  loadTodayIntake();
536
714
  setBadge('sessions', working.length > 0);
@@ -563,11 +741,94 @@
563
741
  '<span class="rec-copy"><strong>Recording now</strong><span>' + esc(rec.source + (rec.startTime ? ', ' + span(rec.startTime) + ' so far' : '')) + '</span></span>' +
564
742
  '<span class="btn small">Live view</span></button>';
565
743
  }
744
+ // ── The Today summary (6.99.26 candidate) ───────────────
745
+ // "The orchestrator's eyes" (Miles, 11/07): two or three sentences on what the sessions add up to, written by Haiku on
746
+ // the server (lib/today-brief.ts). Today sends what it already read: session titles, states, how long, each session's
747
+ // latest line and what needs you. The server decides whether a new one is written (off unless switched on, only when
748
+ // the inputs changed, 10 minutes apart, 60 a day, a breaker). Today never waits for it: the counts line paints at once
749
+ // and the summary replaces it when one arrives. Never shown over a session list that was not read.
750
+ var BRIEF = { view: null, inflight: false, sentKey: '', sentAt: 0 };
751
+ // 6.99.26 QA: the same rows are asked about again only after five minutes (a Today that sits open still learns what
752
+ // the server reads itself: sub-agents and Work cards); changed rows are asked about at once, one ask at a time.
753
+ var BRIEF_RESEND_MS = 5 * 60000, BRIEF_OFF_RECHECK_MS = 5 * 60000, BRIEF_TICK_MS = 60000;
754
+ function briefMinutes(v) { var d = toDate(v); return d ? Math.max(0, Math.round((Date.now() - d.getTime()) / 60000)) : 0; }
755
+ function briefInputs(m) {
756
+ var sessions = [];
757
+ (state.sessions || []).forEach(function (s) {
758
+ if (sxKeepWarm(s)) return;
759
+ var kind = sxState(s) === 'waiting' ? 'waiting' : s.running_active ? 'working' : s.running ? 'open' : '';
760
+ if (!kind) return; // only what Today counts: waiting, working and open
761
+ var k = splitSessionId(s.provider, s.session_id || s.id);
762
+ sessions.push({ title: sessionTitle(s), provider: k.provider, state: kind,
763
+ minutes: briefMinutes(kind === 'open' ? (s.last_activity_at || s.state_since) : (s.state_since || s.last_activity_at)),
764
+ line: String(s.discussion_summary || '').slice(0, 400), waiting: kind === 'waiting' ? askedWhy(s) : '',
765
+ // Only so the server can read this session's sub-agents (names, tasks, status); it never reaches the model.
766
+ key: k.provider + '|' + k.id,
767
+ agents: s.subagent_activity ? { active: s.subagent_activity.active, completed: s.subagent_activity.completed, total: s.subagent_activity.total } : undefined });
768
+ });
769
+ var needs = m.rows.filter(function (r) { return r.kind !== 'session'; }).slice(0, 12).map(function (r) {
770
+ if (r.kind === 'work') return { title: r.item.sessionTitle || r.item.title || 'Handoff', why: needWhy(r.item) };
771
+ return { title: r.title, why: r.why || '' };
772
+ });
773
+ // Today folds all but waiting sessions under "can wait until tomorrow" in quiet hours; the summary says the same.
774
+ var later = m.later.filter(function (r) { return r.kind !== 'session'; }).length;
775
+ return { sessions: sessions.slice(0, 40), needs: needs, later: Math.min(later, needs.length), sessionsRead: TODAY.sessionsState === 'answered', needsComplete: !!m.settled,
776
+ unread: m.failed.slice(), recording: !!TODAY.recording };
777
+ }
778
+ // Asked once every source answered or failed: at once when what Today read changed, else at most every five minutes
779
+ // (the server gates the spend; this only saves requests). When the switch is off, every five minutes.
780
+ function requestBrief(m) {
781
+ if (BRIEF.inflight || state.pane !== 'today') return; // renderStory already answered an offline Today
782
+ if (Object.keys(m.states).some(function (k) { return m.states[k] === 'pending'; })) return;
783
+ var body = briefInputs(m);
784
+ var key = JSON.stringify([body.sessions.map(function (s) { return [s.title, s.state, s.line, s.waiting, s.agents]; }), body.needs, body.later, body.sessionsRead, body.needsComplete, body.unread, body.recording]);
785
+ var since = Date.now() - BRIEF.sentAt;
786
+ if (BRIEF.view && BRIEF.view.state === 'off' ? since < BRIEF_OFF_RECHECK_MS : key === BRIEF.sentKey && since < BRIEF_RESEND_MS) return;
787
+ BRIEF.inflight = true; BRIEF.sentKey = key; BRIEF.sentAt = Date.now();
788
+ api('/api/today/brief', { method: 'POST', body: body, timeoutMs: 45000 }).then(function (v) {
789
+ BRIEF.view = v && typeof v === 'object' ? v : null;
790
+ }, function () { /* keep what was shown: the counts line or the last summary */ }).then(function () {
791
+ BRIEF.inflight = false;
792
+ if (TODAY.model && state.pane === 'today') renderStory(TODAY.model);
793
+ });
794
+ }
795
+ /** The summary to show, or null: only with text, only over a session list that was read. */
796
+ function briefShown() {
797
+ var v = BRIEF.view;
798
+ if (!v || !v.text || (v.state !== 'fresh' && v.state !== 'stale') || TODAY.sessionsState !== 'answered') return null;
799
+ return v;
800
+ }
801
+ // Forget the summary (Settings turned it on or off): the next Today paint asks at once.
802
+ function resetBrief() { BRIEF.view = null; BRIEF.sentKey = ''; BRIEF.sentAt = 0; }
803
+ // Every minute while Today is on screen: "Updated N min ago" moves on, and an unchanged Today asks again once its
804
+ // five minutes are up (requestBrief's own throttle). Hidden or another pane: nothing.
805
+ setInterval(function () {
806
+ if (document.hidden || state.pane !== 'today' || !TODAY.model || TODAY.offline) return;
807
+ requestBrief(TODAY.model);
808
+ var v = briefShown(), when = document.querySelector('#today-story .story-when');
809
+ if (v && when) when.textContent = briefWhen(v, TODAY.model);
810
+ }, BRIEF_TICK_MS);
811
+ function briefWhen(v, m) {
812
+ var mins = briefMinutes(v.at);
813
+ var out = 'Updated ' + (mins < 1 ? 'just now' : span(v.at) + ' ago');
814
+ // Stale says so once it is five minutes old: a summary from a minute ago over one new reply is still the picture.
815
+ if (v.state === 'stale' && mins >= 5) out += '. Things have moved since';
816
+ if (m.failed.length) out += '. Couldn’t read ' + listWords(m.failed) + ', so this may not be everything';
817
+ return out + '.';
818
+ }
819
+
566
820
  // The headline: COS Control's brief (Models.swift TodayHome.brief) over the same rows Needs you lists.
567
821
  function renderStory(m) {
568
822
  var el = $('today-story');
569
823
  el.className = 'story';
570
824
  if (TODAY.offline) { el.textContent = 'COS on ' + state.serverName + ' is not answering right now. Today fills in when it does.'; return; }
825
+ // The model's summary when there is one (it never blocks: the counts line below shows until it lands). The test
826
+ // harnesses that run this block alone have no requestBrief, so it is looked up, never assumed.
827
+ if (typeof requestBrief === 'function') {
828
+ requestBrief(m);
829
+ var brief = briefShown();
830
+ if (brief) { el.innerHTML = esc(brief.text) + '<span class="story-when">' + esc(briefWhen(brief, m)) + '</span>'; return; }
831
+ }
571
832
  var first = '';
572
833
  if (m.sessionsWaiting > 0) first = countWord(m.sessionsWaiting) + ' ' + plural(m.sessionsWaiting, 'session is', 'sessions are') + ' waiting on you';
573
834
  else if (m.now.length > 0) first = countWord(m.now.length) + ' ' + plural(m.now.length, 'thing needs', 'things need') + ' you' + (m.settled ? '' : ' so far');
@@ -658,6 +919,29 @@
658
919
  if (s.waiting_kind === 'question' && d) return d.length > 160 ? d.slice(0, 159) + '…' : d;
659
920
  return 'Waiting for you' + (sxProvider(s.provider) === 'claude' ? '. Paused until you answer.' : '');
660
921
  }
922
+ // 6.99.26: where a waiting session's question can be answered. 'here': this page holds it (panes/questions.js), as
923
+ // cards in its waiting sheet. 'mac': a Claude session's own prompt (or a held one that went back to the Mac), which
924
+ // only the Mac can answer, in person or through Screens. 'answered': answered on some screen in the last two minutes,
925
+ // the list not caught up yet. '': a reply answers it (a Codex or Cursor session).
926
+ function sessionQs() { var q = window.COSWeb && window.COSWeb.sessionQuestions; return q && q.itemsFor ? q : null; }
927
+ function askedHere(items) {
928
+ var it = items[0];
929
+ if (!it) return '';
930
+ if (it.kind === 'approval') return it.approval && it.approval.summary ? 'Wants to run ' + clipWords(it.approval.summary, 80) : '';
931
+ var q = (it.questions || [])[0];
932
+ return q && q.question ? clipWords(q.question, 160) : '';
933
+ }
934
+ function clipWords(text, n) { var t = String(text || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; }
935
+ function whereFor(provider, id) {
936
+ var q = sessionQs();
937
+ var held = q ? q.itemsFor(id) : [], mac = q && q.atMacFor ? q.atMacFor(id) : [];
938
+ if (held.length) return { where: 'here', ask: askedHere(held) };
939
+ if (q && q.answeredFor && q.answeredFor(id)) return { where: 'answered', ask: '' };
940
+ var words = mac.length && q.wordsOf ? q.wordsOf(mac[0].id) : null;
941
+ var ask = words ? clipWords(words.questions[0] || (words.summary ? 'Wants to run ' + words.summary : ''), 160) : '';
942
+ if (mac.length || sxProvider(provider) === 'claude') return { where: 'mac', ask: ask };
943
+ return { where: '', ask: '' };
944
+ }
661
945
  function needsModel() {
662
946
  var states = { sessions: TODAY.sessionsState, work: TODAY.workState, voices: TODAY.voices.state, memories: TODAY.memories.state };
663
947
  var rows = [], seen = {};
@@ -665,7 +949,19 @@
665
949
  if (sxKeepWarm(s) || sxState(s) !== 'waiting') return;
666
950
  var k = splitSessionId(s.provider, s.session_id || s.id);
667
951
  seen[k.provider + '|' + String(k.id).toLowerCase()] = 1;
668
- rows.push({ kind: 'session', tier: 1, title: askedTitle(s), why: askedWhy(s), since: s.state_since || s.last_activity_at, key: k.provider + '|' + k.id });
952
+ var w = whereFor(k.provider, k.id);
953
+ rows.push({ kind: 'session', tier: 1, title: askedTitle(s), why: askedWhy(s), since: s.state_since || s.last_activity_at, key: k.provider + '|' + k.id, where: w.where, ask: w.ask });
954
+ });
955
+ // A question this page holds for a session the list has not shown waiting yet: its own row, so every held
956
+ // question is in front of you (two at once each get one).
957
+ var qs = sessionQs();
958
+ (qs ? qs.itemsFor('') : []).forEach(function (it) {
959
+ var k = splitSessionId(it.provider, it.sessionId), sk = k.provider + '|' + String(k.id).toLowerCase();
960
+ if (!k.id || seen[sk]) return;
961
+ seen[sk] = 1;
962
+ var s = (state.sessions || []).filter(function (x) { var xk = splitSessionId(x.provider, x.session_id || x.id); return xk.provider === k.provider && String(xk.id).toLowerCase() === String(k.id).toLowerCase(); })[0];
963
+ var title = s ? askedTitle(s) : it.kind === 'approval' ? 'A session wants your permission' : 'A session asked you a question';
964
+ rows.push({ kind: 'session', tier: 1, title: title, why: 'Waiting for you. Paused until you answer.', since: it.createdAt, key: k.provider + '|' + k.id, where: 'here', ask: askedHere([it]) });
669
965
  });
670
966
  (TODAY.needs || []).forEach(function (it) {
671
967
  var k = splitSessionId(it.provider, it.sessionId);
@@ -721,9 +1017,11 @@
721
1017
  (k.id ? '<div class="need-actions"><button type="button" class="btn small' + (i === 0 ? ' primary' : '') + '" data-session="' + esc(k.provider) + '|' + esc(k.id) + '">Open session</button></div>' : '') + '</div>';
722
1018
  }
723
1019
  if (r.kind === 'session') {
724
- // The session's question shows in the overlay's peek, with its cards to answer.
1020
+ // The session's question shows in the overlay's peek, with its cards to answer. Under it, where it can be answered.
1021
+ var q = sessionQs(), whereLine = r.where === 'here' ? 'Answer it here.' : r.where === 'mac' ? (q && q.macLine) || '' : r.where === 'answered' ? (q && q.answeredLine) || '' : '';
725
1022
  return '<div class="need-row">' +
726
- '<button type="button" class="need-open" data-today="wait" data-key="' + esc(r.key) + '">' + top(r.title) + '<span class="need-foot">' + esc(r.why) + '</span></button>' +
1023
+ '<button type="button" class="need-open" data-today="wait" data-key="' + esc(r.key) + '">' + top(r.title) + '<span class="need-foot">' + esc(r.ask || r.why) + '</span>' +
1024
+ (whereLine ? '<span class="need-foot need-where" data-where="' + esc(r.where) + '">' + esc(whereLine) + '</span>' : '') + '</button>' +
727
1025
  '<div class="need-actions"><button type="button" class="btn small' + (i === 0 ? ' primary' : '') + '" data-session="' + esc(r.key) + '">Open session</button></div></div>';
728
1026
  }
729
1027
  var go2 = r.kind === 'voices' ? 'speakers' : 'memories/view/review', label = r.kind === 'voices' ? 'Name' : 'Review';
@@ -739,18 +1037,32 @@
739
1037
  var html = visible.map(needRowHtml).join('');
740
1038
  if (shown.length > visible.length) html += '<button type="button" class="need-more" data-today-needs="all">Show ' + (shown.length - visible.length) + ' more</button>';
741
1039
  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>';
742
- if (!m.rows.length) html = '<p class="card-empty">' + esc(m.settled ? 'Nothing is waiting on you.' : m.failed.length ? '' : 'Reading sessions, Work, voices and memories.') + '</p>';
743
- m.failed.forEach(function (name) { html += '<button type="button" class="need-more need-failed" data-today-needs="retry">' + esc('Couldn’t read ' + name + '. Try again') + '</button>'; });
1040
+ // 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
1041
+ // source: with the Mac not answering (sessions and Work both unread, or more than two sources) it says so once.
1042
+ 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>';
1043
+ if (m.failed.length) {
1044
+ var silent = TODAY.offline || m.failed.length > 2;
1045
+ 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>';
1046
+ }
744
1047
  list.innerHTML = html;
745
1048
  }
1049
+ // Weather sits under the date at the top left (Miles, 11/07), where the eye starts, not inside Next up.
1050
+ // It shows whether or not a meeting is next.
1051
+ function renderWeather(w) {
1052
+ var el = $('today-weather'); if (!el) return;
1053
+ var x = w && w.weather;
1054
+ if (!x || !x.temp) { el.classList.add('hidden'); el.textContent = ''; return; }
1055
+ var desc = String(x.desc || '').toLowerCase(), place = x.location ? String(x.location).split(',')[0] : '';
1056
+ el.textContent = x.temp + (desc ? ' and ' + desc : '') + (place ? ' in ' + place : '');
1057
+ el.classList.remove('hidden');
1058
+ }
746
1059
  function renderNext(w) {
747
1060
  var card = $('next-card');
748
1061
  var n = w && w.nextEvent; var d = n && toDate(n.time);
749
1062
  if (!d || !n.title) { card.classList.add('hidden'); return; }
750
- var weather = w.weather && w.weather.temp ? w.weather.temp + ' and ' + String(w.weather.desc || '').toLowerCase() + (w.weather.location ? ' in ' + w.weather.location.split(',')[0] : '') : '';
751
1063
  var dw = dayWord(d);
752
1064
  $('next-body').innerHTML = '<span class="when">' + esc(clock(d)) + '</span><span><span class="what">' + esc(n.title) + '</span>' +
753
- '<span class="sub" style="display:block">' + esc((dw.charAt(0).toUpperCase() + dw.slice(1)) + (weather ? '. Now ' + weather : '')) + '</span></span>';
1065
+ '<span class="sub" style="display:block">' + esc(dw.charAt(0).toUpperCase() + dw.slice(1)) + '</span></span>';
754
1066
  // Control's Next up (todayPrepButtons): Prep me opens the Prep composer in the COS chat (panes/cos-chat.js prepFor),
755
1067
  // Open meeting opens Meetings. Without a server to answer, Prep me is not offered.
756
1068
  var prep = !TODAY.offline && window.COSWeb && window.COSWeb.cosChat && window.COSWeb.cosChat.prepFor;
@@ -800,8 +1112,8 @@
800
1112
  }).join('') + '<p class="intake-foot">Moves to Work at midnight.</p>';
801
1113
  card.classList.remove('hidden');
802
1114
  }
803
- function openTodayAction(t) {
804
- var kind = t.getAttribute('data-today'), key = t.getAttribute('data-key') || '';
1115
+ function openTodayAction(t, kindIn, keyIn) {
1116
+ var kind = kindIn || t.getAttribute('data-today'), key = keyIn != null ? keyIn : t.getAttribute('data-key') || '';
805
1117
  ensurePane('todayActions', 4000).then(function (m) {
806
1118
  if (!m || !m.open) throw new Error('The Today overlay did not start.');
807
1119
  m.open(kind, key, t);
@@ -811,9 +1123,9 @@
811
1123
  // history entry, which the fallback replaces, so Back from there still lands on Today.
812
1124
  var s = String(kind === 'run' || kind === 'wait' ? key : t.getAttribute('data-session-key') || '').split('|');
813
1125
  var session = (kind === 'run' || kind === 'wait' || kind === 'need') && !!s[1];
814
- var dest = session ? ['sessions', s[0], s.slice(1).join('|')] : ['work'].concat(kind === 'need' ? [] : ['sort']);
1126
+ var dest = session ? ['sessions', s[0], s.slice(1).join('|')] : kind === 'pet' ? ['sessions'] : ['work'].concat(kind === 'need' ? [] : ['sort']);
815
1127
  console.error('[today] the overlay did not open (' + kind + '); opening ' + dest.join('/') + ' instead.', err);
816
- toast(session ? 'Could not open that here. Opening the session.' : 'Could not open that here. Opening it in Work.');
1128
+ toast(session ? 'Could not open that here. Opening the session.' : kind === 'pet' ? 'Could not open that here. Opening Sessions.' : 'Could not open that here. Opening it in Work.');
817
1129
  if (history.state && history.state.cosTodayOverlay) location.replace('#/' + dest.map(encodeURIComponent).join('/'));
818
1130
  else go(dest[0], dest.slice(1));
819
1131
  });
@@ -1059,6 +1371,7 @@
1059
1371
  var meta = sxBadge(key.provider) +
1060
1372
  (s.pinned ? '<span class="sx-pinned">PINNED</span>' : '') +
1061
1373
  (label ? '<span class="sx-chip ' + st + '">' + esc(label) + '</span>' : '') +
1374
+ (notSentFor(key.provider, key.id) ? '<span class="sx-chip refused" title="' + esc(notSentFor(key.provider, key.id).line) + '">Not sent</span>' : '') +
1062
1375
  (upd ? '<span class="sx-when">Updated ' + esc(sxShortDate(upd)) + '</span>' : '') +
1063
1376
  (ambiguous[title] && opened ? '<span class="sx-opened" title="Another session on screen has the same name. This one was opened ' + esc(sxShortDate(opened)) + '.">Opened ' + esc(sxShortDate(opened)) + '</span>' : '') +
1064
1377
  (s.project && String(s.project) !== title ? '<span class="sx-ws">' + esc(s.project) + '</span>' : '');
@@ -1335,18 +1648,49 @@
1335
1648
  }
1336
1649
  function sessionPath(provider, id, tail) { return '/api/agent-sessions/' + encodeURIComponent(provider) + '/' + encodeURIComponent(id) + (tail || ''); }
1337
1650
 
1651
+ // 6.99.30 (QA, held from Codex for .30): a queued message the server refused or let expire is said where the person
1652
+ // looks, not only in the Sessions reader: a "Not sent" line in Today's sheet for that session and a chip on its row in
1653
+ // the Sessions list. What the server's queue said last, per session, in this browser for a day; each read of the
1654
+ // queue replaces it, so a row the server drops clears the mark.
1655
+ var NOT_SENT_KEY = 'cos_web_not_sent', NOT_SENT_KEEP_MS = 86400000;
1656
+ function notSentRows(rows) { return (rows || []).filter(function (t) { return t && (t.status === 'refused' || t.status === 'expired'); }); }
1657
+ function notSentLine(t) {
1658
+ var why = String((t && t.reasonCopy) || (t && t.status === 'expired' ? 'the thread stayed busy too long.' : 'the session refused it.')).trim();
1659
+ return /^not sent\b/i.test(why) ? why : 'Not sent: ' + why.charAt(0).toLowerCase() + why.slice(1);
1660
+ }
1661
+ function noteNotSent(provider, id, rows) {
1662
+ var all = readStore(NOT_SENT_KEY), k = provider + '|' + id, now = Date.now(), list = notSentRows(rows);
1663
+ Object.keys(all).forEach(function (x) { if (!all[x] || now - (Number(all[x].at) || 0) > NOT_SENT_KEEP_MS) delete all[x]; });
1664
+ if (list.length) all[k] = { n: list.length, line: notSentLine(list[list.length - 1]).slice(0, 240), at: now }; else delete all[k];
1665
+ writeStore(NOT_SENT_KEY, all);
1666
+ }
1667
+ function notSentFor(provider, id) {
1668
+ var v = readStore(NOT_SENT_KEY)[provider + '|' + id];
1669
+ return v && Date.now() - (Number(v.at) || 0) <= NOT_SENT_KEEP_MS ? v : null;
1670
+ }
1671
+
1338
1672
  // Like api(), but a 4xx refusal comes back as { status, body } instead of throwing, because
1339
1673
  // these routes answer "busy", "changed" or "not now" with a body the person should read.
1674
+ // A timeout here stays the caller's (an abort: "may have saved", a probe that gives up), never a down mark; api()
1675
+ // reads and the question poll are what say a hung Mac is not answering (6.99.32).
1340
1676
  function apiAnswer(path, opts) {
1341
1677
  var o = opts || {};
1342
1678
  var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
1343
1679
  var timer = ctrl && o.timeoutMs ? setTimeout(function () { ctrl.abort(); }, o.timeoutMs) : null;
1344
1680
  var headers = { 'X-COS-Web': '1' };
1345
1681
  if (o.body !== undefined) headers['Content-Type'] = 'application/json';
1682
+ var sentAt = Date.now();
1346
1683
  return fetch(path, { method: o.method || 'GET', headers: headers, credentials: 'same-origin', body: o.body !== undefined ? JSON.stringify(o.body) : undefined, signal: ctrl ? ctrl.signal : undefined })
1347
1684
  .then(function (res) {
1685
+ sxNoteClock(res, sentAt);
1686
+ noteServer(true);
1348
1687
  if (res.status === 401) { showAuth(); throw new Error('Signed out. Connect this browser again.'); }
1349
1688
  return res.text().then(function (t) { var b = {}; try { b = t ? JSON.parse(t) : {}; } catch (_e) { b = {}; } return { status: res.status, body: b || {} }; });
1689
+ }, function (err) {
1690
+ // A timeout this call asked for stays an abort (its callers say "may have saved"); the rest never reached the Mac.
1691
+ if (err && err.name === 'AbortError') throw err;
1692
+ noteServer(false);
1693
+ throw unreachableError(err);
1350
1694
  })
1351
1695
  .finally(function () { if (timer) clearTimeout(timer); });
1352
1696
  }
@@ -1358,7 +1702,11 @@
1358
1702
  function writeStore(key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch (_e) {} }
1359
1703
  function saveHeld(r) {
1360
1704
  var all = readStore(HELD_KEY), k = r.provider + '|' + r.id;
1361
- var keep = r.held.filter(function (h) { return !h.cancelled; }).map(function (h) { return { text: h.text, key: h.key, since: h.since }; });
1705
+ var keep = r.held.filter(function (h) { return !h.cancelled; }).map(function (h) {
1706
+ var o = { text: h.text, key: h.key, since: h.since };
1707
+ if (h.ids && h.ids.length) { o.ids = h.ids; if (h.files && h.files.length) o.files = h.files; }
1708
+ return o;
1709
+ });
1362
1710
  if (keep.length) all[k] = keep; else delete all[k];
1363
1711
  writeStore(HELD_KEY, all);
1364
1712
  }
@@ -1393,6 +1741,7 @@
1393
1741
  function openSession(provider, id) {
1394
1742
  var reader = $('session-reader');
1395
1743
  if (!provider || !id) return;
1744
+ readerImagesStop($('session-turns'));
1396
1745
  if (state.stream) { state.stream.close(); state.stream = null; }
1397
1746
  clearInterval(state.turnPoll);
1398
1747
  if (state.reading) { clearInterval(state.reading.heldTimer); clearInterval(state.reading.feedTimer); }
@@ -1422,15 +1771,21 @@
1422
1771
  '<p class="sx-copy-note hidden" id="session-copy-note" role="status"></p>' +
1423
1772
  '<div class="sx-chat">' +
1424
1773
  '<div id="session-verdict"></div>' +
1425
- '<form class="composer" id="session-form" data-provider="' + esc(provider) + '" data-id="' + esc(id) + '">' +
1774
+ '<form class="composer att-composer" id="session-form" data-provider="' + esc(provider) + '" data-id="' + esc(id) + '">' +
1775
+ '<div class="cc-chips att-files hidden" id="session-files"></div>' +
1426
1776
  '<textarea id="session-input" rows="1" placeholder="Continue this session…" enterkeyhint="send" aria-label="Continue this session"></textarea>' +
1427
- '<div class="composer-actions"><button type="button" class="btn small" data-action="fork-session" title="Copy this session and send the message to the copy">Fork</button>' +
1428
- '<button type="submit" class="btn primary small">Send</button></div>' +
1777
+ '<div class="composer-actions"><span id="session-clip" class="att-clip"></span><button type="button" class="btn small" data-action="fork-session" title="Copy this session and send the message to the copy">Fork</button>' +
1778
+ '<button type="submit" class="btn primary small" id="session-send">Send</button></div>' +
1429
1779
  '</form><div class="status-line" id="session-status"></div>' +
1430
1780
  '<p class="sx-caution hidden" id="session-caution"></p>' +
1431
1781
  '<div id="session-queue"></div>' +
1432
1782
  '</div>';
1433
- setLive({ working: !!d.running_active });
1783
+ readerImagesMount($('session-turns'), r);
1784
+ sxNoteList(r, d);
1785
+ setLive({});
1786
+ paintReaderFiles();
1787
+ // The Mac's answer about files may not be in yet: ask once, then draw the clip.
1788
+ var kitR = readerKit(); if (kitR && !kitR.turnFiles()) kitR.ensureCaps().then(paintReaderFiles, function () {});
1434
1789
  renderHeld(state.reading);
1435
1790
  loadQueue(provider, id);
1436
1791
  sxProbeAttach(r);
@@ -1449,20 +1804,30 @@
1449
1804
  // ACTIVITY (ActivityWindow.swift SessionActivityFeed 6800-6890, SessionLiveFeed.swift): the state
1450
1805
  // word with its clock, the current prompt, the last eight tool lines, and the honest counters.
1451
1806
  // The reducer follows SessionLiveFeed.applying: a reseed (seq 1 after anything) starts over.
1452
- function newFeed() { return { transport: 'idle', agentState: null, stateSince: null, waitingKind: null, waitingDetail: null, failure: null, lastReply: null, prompt: '', tools: [], lastSeq: 0, lastEventAt: null, missed: 0, connected: false, fallback: null }; }
1807
+ function newFeed() { return { transport: 'idle', transportSeenAt: null, agentState: null, stateSince: null, stateSeenAt: null, seenMemo: null, statusAt: null, waitingKind: null, waitingDetail: null, failure: null, lastReply: null, prompt: '', tools: [], lastSeq: 0, lastEventAt: null, missed: 0, connected: false, fallback: null }; }
1453
1808
  function applyFeed(r, e) {
1454
1809
  if (typeof e.seq !== 'number') return;
1455
1810
  var f = r.feed;
1456
- if (e.seq === 1 && f.lastSeq > 0) { var keep = f.connected; f = r.feed = newFeed(); f.connected = keep; }
1811
+ if (e.seq === 1 && f.lastSeq > 0) { var keep = f.connected, seenMemo = f.seenMemo; f = r.feed = newFeed(); f.connected = keep; f.seenMemo = seenMemo; }
1457
1812
  else if (f.lastSeq > 0 && e.seq > f.lastSeq + 1) f.missed += e.seq - f.lastSeq - 1;
1458
1813
  f.lastSeq = e.seq;
1459
1814
  f.lastEventAt = typeof e.at === 'number' ? new Date(e.at) : new Date();
1460
1815
  f.connected = true;
1461
1816
  if (f.fallback === 'reconnecting') f.fallback = null;
1462
1817
  if (e.kind === 'status') {
1463
- if (e.state) f.transport = e.state;
1818
+ f.statusAt = f.lastEventAt;
1819
+ // When this page first saw each state (its own clock): the Stop's echo window is measured by it. The
1820
+ // memo outlives a reseed, so a reconnect that repeats a state seen before a Stop is not news.
1821
+ var memo = f.seenMemo || (f.seenMemo = {});
1822
+ if (e.state) {
1823
+ if (!memo.stream || memo.stream.key !== e.state) memo.stream = { key: e.state, seen: Date.now() };
1824
+ f.transport = e.state; f.transportSeenAt = memo.stream.seen;
1825
+ }
1464
1826
  if (e.agent_state) {
1465
- f.agentState = e.agent_state; f.stateSince = toDate(e.state_since);
1827
+ var since = toDate(e.state_since), hookKey = e.agent_state + '|' + sxMs(since);
1828
+ if (!memo.hook || memo.hook.key !== hookKey) memo.hook = { key: hookKey, seen: Date.now() };
1829
+ f.stateSeenAt = memo.hook.seen;
1830
+ f.agentState = e.agent_state; f.stateSince = since;
1466
1831
  f.waitingKind = e.waiting_kind || null; f.waitingDetail = e.waiting_detail || null; f.failure = e.failure || null;
1467
1832
  if (e.last_reply) f.lastReply = e.last_reply;
1468
1833
  }
@@ -1476,16 +1841,110 @@
1476
1841
  // `other` is the stream's verb for every tool it has no verb for; its target names the tool ("Used SendMessage").
1477
1842
  var SX_VERBS = { read: 'Read', edit: 'Edited', write: 'Edited', bash: 'Ran', run: 'Ran', search: 'Searched', grep: 'Searched', glob: 'Searched', fetch: 'Fetched', web: 'Fetched', agent: 'Delegated', task: 'Delegated', other: 'Used' };
1478
1843
  function sxToolLine(t) { var v = String(t.verb || 'tool'); var w = SX_VERBS[v] || v.charAt(0).toUpperCase() + v.slice(1); return t.target ? w + ' ' + t.target : w; }
1479
- // The word follows the page's own working flag, which a Stop or a finished turn settles before
1480
- // the stream's status catches up; otherwise the server's derived state, as Control reads it.
1844
+ // ONE STATE FOR THE SESSION CARD (6.99.29). Miles, 11/07 11:01: Today's session overlay showed
1845
+ // "Idle 40s" over "Working on: Yes install and proceed", beside "Working right now" and "The
1846
+ // session is busy" while the session ran its turn. Each piece read its own source: the badge the
1847
+ // hooks' derived state (which a turn delivered through the peer inbox does not move), the composer
1848
+ // the server's write gate, the send note the park answer, and a delivered live turn was read as a
1849
+ // finished one. Now every piece of the card (the badge word and clock, the prompt line's label,
1850
+ // Stop, the composer's park note and the reader's state line) reads sxTruth, and each signal says
1851
+ // what it saw and WHEN that became true; the freshest wins:
1852
+ // hook the stream's derived agent_state, since its state_since
1853
+ // stream the stream's own state, when no derived state rides it, as of that status line
1854
+ // list the session row or detail: running_active as of its last activity, else since state_since
1855
+ // gate the server's write gate: native_thread_working on the attachability probe, a refused
1856
+ // turn or a parked send, or a turn delivered live into the open session, as of the answer;
1857
+ // a later free answer, or a COS child's turn that finished, clears it
1858
+ // stop this page's Stop (and a finished COS child turn): idle as of then, and no working signal
1859
+ // that ARRIVED in the next SX_STOP_ECHO_MS counts (the stream's status trails a Stop). The
1860
+ // window is the page's own clock (`seen`, when this page observed each signal), never the
1861
+ // signal's server time, and a turn this page sends ends it (continueSession clears stoppedAt).
1862
+ // Waiting, failed and ended, from the hook or the session row, are the session's own words and stay:
1863
+ // a gate or stream that sees a busy file does not unseat a question (QA 6.99.29: the row and the
1864
+ // hook carry the same state_since, so the row's Waiting must hold as the hook's does). Signals with
1865
+ // the same time are ordered gate, stream, list, hook, stop: the hook's word wins a tie. Idle yields
1866
+ // only to working seen more than SX_IDLE_GRACE_MS after it, the server's rule for the flush that
1867
+ // trails a Stop (TRANSCRIPT_AFTER_IDLE_GRACE_MS).
1868
+ var SX_IDLE_GRACE_MS = 5000, SX_STOP_ECHO_MS = 8000, SX_GATE_RECHECK_MS = 20000, SX_PROBE_TIMEOUT_MS = 15000;
1869
+ var SX_TIE_RANK = { gate: 0, stream: 1, list: 2, hook: 3, stop: 4 };
1870
+ function sxMs(v) {
1871
+ var t = v instanceof Date ? v.getTime() : typeof v === 'number' ? v : typeof v === 'string' && v ? Date.parse(v) : NaN;
1872
+ return isFinite(t) ? t : null;
1873
+ }
1874
+ var SX_HOOK_WORD = { running: 'Working', waiting: 'Waiting', failed: 'Failed', ended: 'Done', idle: 'Idle' };
1875
+ function sxSignals(r) {
1876
+ var f = r.feed || {}, out = [];
1877
+ // `seen`: when this page observed the signal (its own clock), for the Stop's echo window.
1878
+ if (f.agentState && SX_HOOK_WORD[f.agentState]) out.push({ word: SX_HOOK_WORD[f.agentState], at: sxMs(f.stateSince) || sxMs(f.statusAt) || 0, src: 'hook', seen: sxMs(f.stateSeenAt) });
1879
+ else if (f.statusAt) out.push({ word: f.transport === 'working' ? 'Working' : f.transport === 'done' ? 'Done' : 'Idle', at: sxMs(f.statusAt), src: 'stream', seen: sxMs(f.transportSeenAt) });
1880
+ if (r.list && r.list.at != null) out.push({ word: r.list.word, at: r.list.at, src: 'list', seen: sxMs(r.list.seen) });
1881
+ // A gate answer and a Stop are this page's own moments: ranked on the Mac's clock (sxServerTime) against the Mac's
1882
+ // own times, and kept on this page's clock as `seen` for the echo window (6.99.30, QA: clock skew).
1883
+ if (r.gate && r.gate.busy && r.gate.at != null) out.push({ word: 'Working', at: sxServerTime(r.gate.at), src: 'gate', seen: r.gate.at });
1884
+ var stop = sxMs(r.stoppedAt);
1885
+ if (stop !== null) out.push({ word: 'Idle', at: sxServerTime(stop), src: 'stop', seen: stop });
1886
+ return out;
1887
+ }
1888
+ /** { word, at, src }: the one state the whole card shows. Null before any signal. */
1889
+ function sxTruth(r) {
1890
+ var stop = sxMs(r.stoppedAt);
1891
+ var all = sxSignals(r).sort(function (a, b) { return a.at - b.at || SX_TIE_RANK[a.src] - SX_TIE_RANK[b.src]; });
1892
+ var best = null;
1893
+ all.forEach(function (c) {
1894
+ if (!best) { best = c; return; }
1895
+ if (c.word === 'Working') {
1896
+ // The Stop's echo, judged by when this page saw the signal (a signal with no arrival time: by its own).
1897
+ var seen = c.seen != null ? c.seen : c.at;
1898
+ if (stop !== null && seen >= stop && seen - stop < SX_STOP_ECHO_MS) return;
1899
+ if (stop !== null && seen < stop) return;
1900
+ if ((best.src === 'hook' || best.src === 'list') && (c.src === 'gate' || c.src === 'stream') && /^(Waiting|Failed|Done)$/.test(best.word)) return;
1901
+ if (best.word === 'Idle' && best.src !== 'stop' && c.src !== best.src && c.at <= best.at + SX_IDLE_GRACE_MS) return;
1902
+ }
1903
+ best = c;
1904
+ });
1905
+ return best;
1906
+ }
1907
+ /** Settle the page's working flag from the one state; true when it changed. */
1908
+ function sxSettle(r) {
1909
+ var t = sxTruth(r), working = !!(t && t.word === 'Working');
1910
+ r.live = r.live || {};
1911
+ var was = !!r.live.working;
1912
+ r.live.working = working;
1913
+ r.live.waiting = !!(t && t.word === 'Waiting');
1914
+ if (!working) { r.live.step = null; r.live.prose = null; }
1915
+ return was !== working;
1916
+ }
1917
+ /** What the server's write gate said, and when. A free answer clears an earlier busy one. */
1918
+ function sxNoteGate(r, busy) { r.gate = { busy: !!busy, at: Date.now() }; }
1919
+ /** The list or detail row's view, timed by its own evidence. */
1920
+ function sxNoteList(r, row) {
1921
+ if (!row) return;
1922
+ var waiting = sxState(row) === 'waiting', working = !!row.running_active && !waiting;
1923
+ var at = working ? (sxMs(row.last_activity_at) || sxMs(row.state_since)) : (sxMs(row.state_since) || sxMs(row.last_activity_at));
1924
+ // A row with no time of its own is the oldest word: the live feed outranks it the moment it speaks.
1925
+ var word = working ? 'Working' : waiting ? 'Waiting' : 'Idle', was = r.list;
1926
+ // Arrival is the first time this page saw THIS word at THIS time: a re-read of the same row is not news.
1927
+ r.list = { word: word, at: at != null ? at : 0, seen: was && was.word === word && was.at === (at != null ? at : 0) && was.seen != null ? was.seen : Date.now() };
1928
+ }
1929
+ /** A gate busy answer that decides the card is re-asked while the card shows it: 20 s after the last attempt, and
1930
+ * each failed attempt doubles the wait, up to 16 times (QA 6.99.29: a failing probe was re-asked every second).
1931
+ * Every attempt is stamped (gate.triedAt) and ends within SX_PROBE_TIMEOUT_MS (6.99.30: before that a probe that
1932
+ * never answered left r.probing set, and the gate was never asked again). So a busy gate stands only while the
1933
+ * server keeps answering busy, or keeps not answering, and a fresher signal (a Stop, the hook going idle more than
1934
+ * the grace after it) still outranks it. `at` stays the time of the last ANSWER: a failure is no evidence of busy. */
1935
+ function sxGateStale(r) {
1936
+ var t = sxTruth(r);
1937
+ if (!(t && t.src === 'gate' && r.gate) || r.probing) return false;
1938
+ var last = Math.max(r.gate.at || 0, r.gate.triedAt || 0), fails = r.gate.failures || 0;
1939
+ return Date.now() - last > SX_GATE_RECHECK_MS * Math.min(Math.pow(2, fails), 16);
1940
+ }
1481
1941
  function sxFeedWord(r) {
1482
- var f = r.feed;
1483
- if (r.live && r.live.working) return 'Working';
1484
- var s = f.agentState || f.transport;
1485
- if (s === 'waiting') return 'Waiting';
1486
- if (s === 'failed') return 'Failed';
1487
- if (s === 'ended' || s === 'done') return 'Done';
1488
- return 'Idle';
1942
+ var t = sxTruth(r);
1943
+ return t ? t.word : 'Idle';
1944
+ }
1945
+ /** The prompt line: what the session works on, or what it was last asked. */
1946
+ function sxPromptLine(r, text) {
1947
+ return (sxFeedWord(r) === 'Working' ? 'Working on: ' : 'Last asked: ') + text;
1489
1948
  }
1490
1949
  function sxFeedDetail(f, word) {
1491
1950
  if (word === 'Waiting' && f.agentState === 'waiting') {
@@ -1500,10 +1959,13 @@
1500
1959
  if (word === 'Idle' && f.agentState === 'idle') { var reply = String(f.lastReply || '').trim(); return reply ? 'last reply: ' + reply : ''; }
1501
1960
  return '';
1502
1961
  }
1503
- function sxElapsed(f) {
1504
- var since = f.stateSince || f.lastEventAt;
1962
+ function sxElapsed(f, r) {
1963
+ var t = r ? sxTruth(r) : null;
1964
+ var since = t && t.at ? new Date(t.at) : f.stateSince || f.lastEventAt;
1505
1965
  if (!since) return '';
1506
- var s = Math.max(0, Math.floor((Date.now() - since.getTime()) / 1000));
1966
+ // QA 6.99.30 (Skeptic): every moment here is on the Mac's clock (the truth's own time, or the Mac's state_since), so
1967
+ // now is too; a phone a minute behind read "0s" for a minute.
1968
+ var s = Math.max(0, Math.floor((sxServerTime(Date.now()) - since.getTime()) / 1000));
1507
1969
  if (s < 60) return s + 's';
1508
1970
  if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
1509
1971
  return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
@@ -1513,6 +1975,7 @@
1513
1975
  if (reason === 'http_503') return 'The server has no room for another live feed right now; showing the stored turns.';
1514
1976
  if (reason === 'reconnecting') return 'Reconnecting to the live feed…';
1515
1977
  if (reason === 'stream_lost') return 'The live feed stopped answering; showing the stored turns. Reopen the session to try again.';
1978
+ if (reason === 'unreachable') return 'Your Mac is not answering. This is the last state it sent.';
1516
1979
  return 'Live feed unavailable (' + reason + '); showing the stored turns.';
1517
1980
  }
1518
1981
  // The block keeps one size whatever it holds (/qa: the turns below moved while Miles read, as tool
@@ -1533,7 +1996,7 @@
1533
1996
  var word = sxFeedWord(r), detail = sxFeedDetail(f, word);
1534
1997
  html += '<div class="sx-act-state"><span class="sx-dot ' + word.toLowerCase() + '" aria-hidden="true"></span><strong>' + word + '</strong>' +
1535
1998
  '<span class="sx-act-detail">' + esc(detail) + '</span>' +
1536
- '<span class="sx-act-clock" id="session-elapsed">' + esc(sxElapsed(f)) + '</span>' +
1999
+ '<span class="sx-act-clock" id="session-elapsed">' + esc(sxElapsed(f, r)) + '</span>' +
1537
2000
  (live.working ? '<button type="button" class="cx-btn quiet sx-act-stop" data-action="stop-session">Stop</button>' : '') + '</div>' +
1538
2001
  '<p class="sx-act-prompt">' + esc(f.prompt) + '</p>' +
1539
2002
  '<div class="sx-act-tail">' + f.tools.map(function (t) { return '<div class="sx-act-tool">' + esc(sxToolLine(t)) + '</div>'; }).join('') +
@@ -1542,13 +2005,15 @@
1542
2005
  html += '<p class="sx-act-fallback">' + esc(f.fallback ? sxFallbackLine(f.fallback) : 'Connecting to the live feed…') + '</p>';
1543
2006
  }
1544
2007
  box.innerHTML = html;
1545
- // The clock ticks only while the session works or waits (Control 0.5.337).
2008
+ // The clock ticks only while the session works or waits (Control 0.5.337). A Working that only the write gate
2009
+ // says is re-asked even with no live feed (QA 6.99.29), so it cannot stand forever once the feed is lost.
1546
2010
  clearInterval(r.feedTimer); r.feedTimer = null;
1547
- var w = sxFeedWord(r);
1548
- if ((f.connected || f.lastSeq > 0) && (w === 'Working' || w === 'Waiting')) {
2011
+ var t = sxTruth(r), w = t ? t.word : 'Idle';
2012
+ if (((f.connected || f.lastSeq > 0) && (w === 'Working' || w === 'Waiting')) || (t && t.src === 'gate' && w === 'Working')) {
1549
2013
  r.feedTimer = setInterval(function () {
1550
2014
  if (state.reading !== r) { clearInterval(r.feedTimer); return; }
1551
- var el = $('session-elapsed'); if (el) el.textContent = sxElapsed(r.feed);
2015
+ var el = $('session-elapsed'); if (el) el.textContent = sxElapsed(r.feed, r);
2016
+ if (sxGateStale(r)) sxProbeAttach(r);
1552
2017
  }, 1000);
1553
2018
  }
1554
2019
  }
@@ -1571,7 +2036,7 @@
1571
2036
  var es;
1572
2037
  try { es = new EventSource(sessionPath(provider, id, '/stream?seed=turn')); } catch (_e) { if (r0) { r0.feed.fallback = 'unsupported'; paintActivity(r0); } return; }
1573
2038
  state.stream = es;
1574
- var wasWorking = false;
2039
+ var wasWorking = false, wasWaiting = false;
1575
2040
  es.onerror = function () {
1576
2041
  var r = state.reading;
1577
2042
  if (!r || r.id !== id || state.stream !== es) return;
@@ -1586,15 +2051,17 @@
1586
2051
  if (!r || r.id !== id) return;
1587
2052
  applyFeed(r, e);
1588
2053
  if (e.kind === 'status') {
1589
- var working = e.state === 'working' || e.agent_state === 'running';
1590
- // The stream's status trails a Stop by a few seconds; do not let that echo reopen it.
1591
- if (working && r.stoppedAt && Date.now() - r.stoppedAt < 8000) working = false;
1592
- var waiting = e.state === 'waiting' || e.state === 'needs_input' || e.agent_state === 'waiting';
2054
+ // 6.99.29: the one state (sxTruth), never this line's own reading of itself.
2055
+ setLive({});
2056
+ var working = !!(r.live && r.live.working);
2057
+ var waiting = !working && (e.state === 'waiting' || e.state === 'needs_input' || e.agent_state === 'waiting');
1593
2058
  var el = $('session-state');
1594
2059
  if (el) el.textContent = working ? 'Working now' : waiting ? 'Waiting for you' : 'Open';
1595
- setLive({ working: working });
1596
2060
  if (wasWorking && !working) { refreshTurns(provider, id); replyEnded(r); }
1597
2061
  wasWorking = working;
2062
+ // Gone to waiting: its question may be parked for this page now (6.99.27).
2063
+ if (waiting && !working && !wasWaiting) questionsNudge();
2064
+ wasWaiting = waiting && !working;
1598
2065
  } else if (e.kind === 'tool') {
1599
2066
  setLive({ step: [e.verb, e.target].filter(Boolean).join(' ') });
1600
2067
  } else if (e.kind === 'prose' && e.text) {
@@ -1606,16 +2073,20 @@
1606
2073
  }
1607
2074
  function setLive(patch) {
1608
2075
  var r = state.reading; if (!r) return;
1609
- r.live = Object.assign(r.live || {}, patch);
1610
- if (!r.live.working) { r.live.step = null; r.live.prose = null; }
2076
+ // The working flag is never set here: it is the one state's (sxSettle).
2077
+ var p = Object.assign({}, patch); delete p.working;
2078
+ r.live = Object.assign(r.live || {}, p);
2079
+ var moved = sxSettle(r);
1611
2080
  paintActivity(r);
2081
+ if (moved) renderVerdict(r);
1612
2082
  }
1613
2083
  function refreshTurns(provider, id) {
2084
+ var reading = state.reading;
1614
2085
  api(sessionPath(provider, id, '?turns=' + SX_TURNS)).then(function (d) {
1615
2086
  var r = state.reading;
1616
- if (!r || r.id !== id) return;
2087
+ if (!r || r !== reading || r.id !== id || r.provider !== provider) return;
1617
2088
  var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : [];
1618
- var box = $('session-turns'); if (box) sxMergeTurns(box, r.turns || [], turns, d);
2089
+ var box = $('session-turns'); if (box) { sxMergeTurns(box, r.turns || [], turns, d); readerImagesMount(box, r); }
1619
2090
  // The header keeps the subtitle it opened with: a rewrap above the turns would move them.
1620
2091
  r.detail = d; r.turns = turns; r.more = d.recent_turns_more === true;
1621
2092
  renderHeld(r);
@@ -1638,13 +2109,21 @@
1638
2109
  var box = $('session-queue'); if (!box || !r || r.id !== id) return;
1639
2110
  var rows = (q.status === 200 && q.body && q.body.turns) || [];
1640
2111
  var waiting = rows.filter(function (t) { return t.status === 'waiting' || t.status === 'queued' || t.status === 'pending'; });
2112
+ // QA 6.99.29: a queued message the server refused (or let expire) stays in view with the server's reason,
2113
+ // instead of vanishing from the line.
2114
+ var notSent = notSentRows(rows);
2115
+ if (q.status === 200) noteNotSent(provider, id, rows);
1641
2116
  if (q.status === 200 && r.queued !== waiting.length) { r.queued = waiting.length; paintActivity(r); }
1642
- if (!waiting.length) { box.innerHTML = ''; return; }
1643
- box.innerHTML = '<div class="sx-queue"><p class="sx-queue-head">' + (waiting.length === 1 ? '1 follow-up queued. It sends when this session is available.' : waiting.length + ' follow-ups queued. They send in order when this session is available.') + '</p>' +
2117
+ if (!waiting.length && !notSent.length) { box.innerHTML = ''; return; }
2118
+ box.innerHTML = '<div class="sx-queue">' + (waiting.length ? '<p class="sx-queue-head">' + (waiting.length === 1 ? '1 follow-up queued. It sends when this session is available.' : waiting.length + ' follow-ups queued. They send in order when this session is available.') + '</p>' : '') +
1644
2119
  waiting.map(function (t) {
1645
2120
  var mine = t.owner !== 'codex' && parkedByMe(r, t.clientTurnId);
1646
2121
  return '<div class="sx-queue-row"><span class="sx-queue-state">' + esc(sxQueueLine(t)) + '</span><span class="sx-queue-text">' + esc(t.preview || t.prompt || 'Message') + '</span>' +
1647
2122
  (mine ? '<button type="button" class="cx-btn quiet" data-action="unqueue" data-turn-key="' + esc(t.clientTurnId) + '" title="Cancel this queued message. It will not be sent.">Cancel</button>' : '') + '</div>';
2123
+ }).join('') +
2124
+ notSent.map(function (t) {
2125
+ return '<div class="sx-queue-row sx-queue-refused"><span class="sx-queue-state">Not sent</span><span class="sx-queue-text">' + esc(t.preview || t.prompt || 'Message') +
2126
+ '<span class="sx-queue-reason">' + esc(notSentLine(t)) + '</span></span></div>';
1648
2127
  }).join('') + '</div>';
1649
2128
  }).catch(function () {});
1650
2129
  }
@@ -1654,21 +2133,33 @@
1654
2133
  // and anything else is the server's refusal, word for word.
1655
2134
  function sxProbeAttach(r) {
1656
2135
  // Today's overlay probes its own session object (r.view): the same verdict, painted by its view.
1657
- return apiAnswer(sessionPath(r.provider, r.id, '/attachability')).then(function (a) {
2136
+ r.probing = true;
2137
+ // Every attempt is stamped; a failed one backs the next off (sxGateStale). A gate answer resets both.
2138
+ if (r.gate) r.gate.triedAt = Date.now();
2139
+ var failed = function () { if (r.gate) r.gate.failures = (r.gate.failures || 0) + 1; };
2140
+ return apiAnswer(sessionPath(r.provider, r.id, '/attachability'), { timeoutMs: SX_PROBE_TIMEOUT_MS }).then(function (a) {
1658
2141
  if (state.reading !== r && !r.view) return;
1659
- if (a.status === 404) { r.verdict = { attachable: false, reason: 'route_absent', reasonCopy: 'Update the COS server to continue a thread from here.', ownerCount: 0 }; viewOf(r).verdict(r); return; }
2142
+ if (a.status === 404) { failed(); r.verdict = { attachable: false, reason: 'route_absent', reasonCopy: 'Update the COS server to continue a thread from here.', ownerCount: 0 }; viewOf(r).verdict(r); return; }
1660
2143
  var b = a.body || {};
1661
- if (a.status !== 200 || typeof b.attachable !== 'boolean') return;
2144
+ if (a.status !== 200 || typeof b.attachable !== 'boolean') { failed(); return; }
1662
2145
  var owners = Number(b.ownerCount) || 0;
1663
2146
  r.verdict = { attachable: b.attachable, reason: String(b.reason || ''), reasonCopy: String(b.reasonCopy || ''), ownerCount: owners, caution: b.attachable === true && owners > 0 };
1664
- viewOf(r).verdict(r);
1665
- }).catch(function () {});
2147
+ // 6.99.29: the gate's answer is a signal of the one state, as of now.
2148
+ sxNoteGate(r, b.attachable === false && b.reason === 'native_thread_working');
2149
+ sxRepaint(r);
2150
+ }).catch(failed).finally(function () { r.probing = false; });
1666
2151
  }
1667
2152
  var SX_PARK = {
1668
2153
  cursor: 'Working right now. A message you send goes into this Cursor chat when its turn ends.',
1669
2154
  codex: 'Working right now. A message you send joins the Codex app’s queue for this thread.',
1670
2155
  claude: 'Working right now. A message you send waits and lands when this turn ends.'
1671
2156
  };
2157
+ /** The composer's "Working right now" note: a busy gate verdict, and the one state agrees. */
2158
+ function sxParkNoteShows(r) {
2159
+ var v = r.verdict;
2160
+ if (!v || v.attachable !== false || !/^(native_thread_working|native_target_busy)$/.test(v.reason)) return false;
2161
+ return sxFeedWord(r) === 'Working' || v.reason === 'native_target_busy';
2162
+ }
1672
2163
  function sxSupplement(reason) {
1673
2164
  if (reason === 'native_target_busy') return 'It frees itself within 30 minutes. Detaching is not available in Control yet.';
1674
2165
  if (reason === 'native_target_fenced') return 'Fences can be released from the Fences card in this panel.';
@@ -1694,7 +2185,8 @@
1694
2185
  (f.retry ? '<button type="button" class="cx-btn quiet" data-sx="retry">Retry</button>' : '') +
1695
2186
  (f.fork ? forkBtn : ''));
1696
2187
  } else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
1697
- html = '<div class="sx-park"><p>' + esc(SX_PARK[sxProvider(r.provider)]) + '</p>' + (/fork/i.test(v.reasonCopy) ? '<div class="sx-box-actions">' + forkBtn + '</div>' : '') + '</div>';
2188
+ // 6.99.29: "Working right now" only while the one state says so.
2189
+ if (sxParkNoteShows(r)) html = '<div class="sx-park"><p>' + esc(SX_PARK[sxProvider(r.provider)]) + '</p>' + (/fork/i.test(v.reasonCopy) ? '<div class="sx-box-actions">' + forkBtn + '</div>' : '') + '</div>';
1698
2190
  } else if (v && v.attachable === false && v.reasonCopy) {
1699
2191
  html = refusalBox(v.reasonCopy, sxSupplement(v.reason), /fork/i.test(v.reasonCopy) ? forkBtn : '');
1700
2192
  }
@@ -1707,14 +2199,23 @@
1707
2199
  }
1708
2200
  }
1709
2201
 
2202
+ /** Repaint whichever card shows this session after its one state moved (the reader or a view). */
2203
+ function sxRepaint(r) {
2204
+ sxSettle(r);
2205
+ if (r.view) { if (typeof r.view.stateChanged === 'function') r.view.stateChanged(r); else r.view.verdict(r); return; }
2206
+ if (state.reading === r) { paintActivity(r); renderVerdict(r); }
2207
+ }
1710
2208
  function sessionStatus(text, kind, actionHtml) {
1711
2209
  var el = $('session-status'); if (!el) return;
1712
2210
  el.className = 'status-line' + (kind === 'error' ? ' error' : '');
1713
2211
  el.innerHTML = esc(text || '') + (actionHtml || '');
1714
2212
  }
1715
- function draftFor(text, r) {
2213
+ // 11/07: the files sent with the words (media ids) are part of the draft: other files are another message, with a
2214
+ // key of its own, so the server never answers a new message with an old one's replay.
2215
+ function draftFor(text, r, ids) {
1716
2216
  r = r || state.reading;
1717
- if (!r.draft || r.draft.text !== text) r.draft = { text: text, key: newTurnKey() };
2217
+ var files = Array.isArray(ids) ? ids.slice() : [];
2218
+ if (!r.draft || r.draft.text !== text || (r.draft.ids || []).join(',') !== files.join(',')) r.draft = { text: text, key: newTurnKey(), ids: files };
1718
2219
  return r.draft;
1719
2220
  }
1720
2221
  // The box is cleared only of the very words that were sent; anything typed since stays.
@@ -1723,11 +2224,12 @@
1723
2224
  if (input && input.value.trim() === String(text).trim()) { input.value = ''; autoGrow(input); }
1724
2225
  if (state.reading && state.reading.draft && state.reading.draft.text === text) state.reading.draft = null;
1725
2226
  }
1726
- // Words go back in the box only if the box is empty; otherwise the status line keeps them.
1727
- function returnToBox(text, why) {
2227
+ // Words go back in the box only if the box is empty; otherwise the status line keeps them. `files` says what became
2228
+ // of the message's files (heldFilesBack).
2229
+ function returnToBox(text, why, files) {
1728
2230
  var input = $('session-input');
1729
- if (input && !input.value.trim()) { input.value = text; autoGrow(input); sessionStatus(why + ' Your message is back in the box.', 'error'); }
1730
- else sessionStatus(why + ' Not sent: “' + String(text).slice(0, 80) + (text.length > 80 ? '…' : '') + '”', 'error');
2231
+ if (input && !input.value.trim()) { input.value = text; autoGrow(input); sessionStatus(why + ' Your message is back in the box.' + (files || ''), 'error'); }
2232
+ else sessionStatus(why + ' Not sent: “' + String(text).slice(0, 80) + (text.length > 80 ? '…' : '') + '”' + (files || ''), 'error');
1731
2233
  }
1732
2234
  // Your words show in the conversation the moment the Mac accepts them; the next refresh of
1733
2235
  // the transcript replaces the bubble with the real turn.
@@ -1761,11 +2263,96 @@
1761
2263
  };
1762
2264
  function viewOf(r) { return (r && r.view) || READER_VIEW; }
1763
2265
 
2266
+ // ── files with a message to a session (6.99.27, Miles: "We still don't have the ability to take picture, or add photo
2267
+ // or files from the web") ──
2268
+ // The COS chat's attach kit (panes/cos-chat.js, W.cosChat.attach), as on Today's reply box: Take photo and the clip,
2269
+ // any file the Mac takes on a turn (threadTurnFileBytes, 64 MB; a video 100 MB), up to 5, sent as it is. The ids go
2270
+ // with the words; the server writes where each file is on the Mac under them. One box per session, kept in this tab.
2271
+ var READER_FILES = {};
2272
+ function readerKit() { var c = window.COSWeb && window.COSWeb.cosChat; return c && c.attach ? c.attach : null; }
2273
+ function readerFilesOn() { var k = readerKit(); return !!(k && k.turnFiles() > 0 && state.reading); }
2274
+ function readerBox(r) {
2275
+ var kit = readerKit(); if (!kit || !r) return null;
2276
+ var key = r.provider + '|' + r.id;
2277
+ if (!READER_FILES[key]) READER_FILES[key] = kit.box({
2278
+ changed: function (box) { if (state.reading && READER_FILES[state.reading.provider + '|' + state.reading.id] === box) paintReaderFiles(); },
2279
+ removed: function () { sessionStatus(''); },
2280
+ signedOut: function () { sessionStatus('This browser was signed out. Your message and files are kept. Sign in, then tap Retry on each file.', 'error'); }
2281
+ }, { anyBytes: kit.turnFileBytes() });
2282
+ if (!READER_FILES[key].anyBytes && !READER_FILES[key].atts.length && kit.turnFileBytes()) READER_FILES[key].anyBytes = kit.turnFileBytes();
2283
+ return READER_FILES[key];
2284
+ }
2285
+ function readerFilesState(box) {
2286
+ var kit = readerKit();
2287
+ if (!kit || !box || !box.atts.length) return 'none';
2288
+ return kit.pending(box) ? 'waiting' : kit.failed(box) ? 'failed' : 'ready';
2289
+ }
2290
+ function paintReaderFiles() {
2291
+ var kit = readerKit(), r = state.reading, slot = $('session-clip'), list = $('session-files');
2292
+ if (!slot || !list) return;
2293
+ if (!readerFilesOn()) { slot.innerHTML = ''; list.innerHTML = ''; list.classList.add('hidden'); return; }
2294
+ var box = readerBox(r), full = box.atts.length >= kit.max, mode = String(box.anyBytes || 0);
2295
+ if (slot.getAttribute('data-mode') !== mode) { slot.innerHTML = kit.cameraHtml('data-sx-file') + kit.clipHtml('data-sx-file', { any: box.anyBytes }); slot.setAttribute('data-mode', mode); }
2296
+ Array.prototype.forEach.call(slot.querySelectorAll('.cc-attach'), function (l) { l.classList.toggle('full', full); });
2297
+ Array.prototype.forEach.call(slot.querySelectorAll('[data-sx-file]'), function (i) { i.disabled = full || !!r.sending; });
2298
+ list.innerHTML = kit.chipsHtml(box, 'data-action') + (box.atts.length ? '<p class="att-limit">' + esc(kit.limitLine(box)) + '</p>' : '');
2299
+ list.classList.toggle('hidden', !box.atts.length);
2300
+ // Send and Fork wait while a file uploads. A failed file never greys them out (QA 6.99.27): a tap says why.
2301
+ var st = readerFilesState(box), form = $('session-form');
2302
+ if (!form || r.sending) return;
2303
+ var send = $('session-send'), forkBtn = form.querySelector('[data-action="fork-session"]');
2304
+ if (send) { send.disabled = st === 'waiting'; send.textContent = st === 'waiting' ? 'Uploading' : 'Send'; }
2305
+ if (forkBtn) forkBtn.disabled = st === 'waiting';
2306
+ }
2307
+ function addReaderFiles(list) {
2308
+ var kit = readerKit(), r = state.reading; if (!kit || !r || !readerFilesOn() || !list || !list.length) return;
2309
+ var refusals = kit.add(readerBox(r), list);
2310
+ if (refusals.length) sessionStatus(refusals.join(' '), 'error');
2311
+ paintReaderFiles();
2312
+ }
2313
+ // The files that went with a message leave the box; any added since stay. Taken, not removed: the status line stays
2314
+ // (a held message's Don't send is on it). Answers what went, for a held message to keep.
2315
+ function readerFilesSent(box, ids) {
2316
+ var kit = readerKit(); if (!kit || !box || !ids || !ids.length) return [];
2317
+ return kit.take(box, ids);
2318
+ }
2319
+ // QA 6.99.27: a held message keeps what its files were, so if it does not go, it comes back whole (heldFilesBack).
2320
+ function keepHeldFiles(r, text, ids, went) {
2321
+ if (!went || !went.length) return;
2322
+ r.held.forEach(function (h) { if (!h.files && h.text === text && (h.ids || []).join(',') === ids.join(',')) h.files = went; });
2323
+ saveHeld(r);
2324
+ }
2325
+ // The Mac keeps a file no turn took for 4 hours (lib/turn-file-store.ts, and the media store's own rule); this
2326
+ // trusts a file for 5 minutes less. Past that it says the file has gone rather than offer one the send would refuse.
2327
+ var HELD_FILE_KEEP_MS = 4 * 60 * 60000 - 5 * 60000;
2328
+ function heldFilesBack(r, h) {
2329
+ var ids = h.ids || []; if (!ids.length) return '';
2330
+ var kit = readerKit(), box = readerFilesOn() ? readerBox(r) : null, now = Date.now();
2331
+ var known = (h.files || []).filter(function (f) { return f && ids.indexOf(f.mediaId) >= 0; });
2332
+ var kept = known.filter(function (f) { return f.keptAt > 0 && now - f.keptAt < HELD_FILE_KEEP_MS; });
2333
+ var back = box && kept.length ? kit.restore(box, kept) : 0;
2334
+ var lost = ids.length - back, expired = known.length - kept.length;
2335
+ var said = !back ? '' : back === ids.length ? (back === 1 ? ' Its file is attached again.' : ' Its ' + back + ' files are attached again.')
2336
+ : ' ' + back + ' of its files ' + (back === 1 ? 'is' : 'are') + ' attached again.';
2337
+ if (!lost) return said;
2338
+ var gone = lost === expired
2339
+ ? (lost === 1 ? ' The file it carried was kept for 4 hours and has gone.' : ' The ' + lost + ' files it carried were kept for 4 hours and have gone.')
2340
+ : (lost === 1 ? ' A file it carried did not come back.' : ' ' + lost + ' files it carried did not come back.');
2341
+ return said + gone + ' Attach ' + (lost === 1 ? 'it' : 'them') + ' again.';
2342
+ }
2343
+
1764
2344
  function sendSessionTurn(fork, opts) {
1765
2345
  var r = state.reading; var form = $('session-form');
1766
2346
  if (!r || !form) return;
1767
2347
  var input = $('session-input'), text = input.value.trim();
1768
2348
  if (!text) { sessionStatus(fork ? 'Write what the copy should do first.' : 'Write a message first.', 'error'); return; }
2349
+ // 6.99.27: files. Never before every upload has finished, never with one that failed. A fork carries them too: its
2350
+ // first turn is this message, so the copy gets the same files (the fork route writes their paths as a turn does).
2351
+ var fb = readerFilesOn() ? readerBox(r) : null, fst = readerFilesState(fb);
2352
+ if (fst === 'waiting') { sessionStatus('A file is still uploading. ' + (fork ? 'Fork' : 'Send') + ' when it finishes.'); return; }
2353
+ if (fst === 'failed') { sessionStatus('A file did not upload. Retry it or remove it.', 'error'); return; }
2354
+ var fileIds = fst === 'ready' ? readerKit().ids(fb) : [];
2355
+ if (fileIds.length && !fork) opts = Object.assign({}, opts || {}, { attachmentIds: fileIds });
1769
2356
  // Control's caution (SessionChatVerdict.caution): another app holds this session and looks
1770
2357
  // idle, so the first send asks before it writes. A fork writes to a copy and never asks.
1771
2358
  if (!fork && r.verdict && r.verdict.caution && !r.cautionAck) { r.cautionAsk = true; renderVerdict(r); return; }
@@ -1775,8 +2362,12 @@
1775
2362
  if (r.refusal || r.cautionAsk) { r.refusal = null; r.cautionAsk = false; renderVerdict(r); }
1776
2363
  r.sending = true;
1777
2364
  var btns = form.querySelectorAll('button'); btns.forEach(function (b) { b.disabled = true; });
1778
- var done = function () { r.sending = false; btns.forEach(function (b) { b.disabled = false; }); };
1779
- (fork ? forkSession(r, text) : continueSession(r, text, opts || {})).then(done, function (err) {
2365
+ var done = function () { r.sending = false; btns.forEach(function (b) { b.disabled = false; }); paintReaderFiles(); };
2366
+ (fork ? forkSession(r, text, fileIds) : continueSession(r, text, opts || {}).then(function (how) {
2367
+ // The Mac took the message (sent, waiting in line, or held until the reply ends): its files went with it.
2368
+ if (/^(sent|parked|held)$/.test(how)) { var went = readerFilesSent(fb, fileIds); if (how === 'held') keepHeldFiles(r, text, fileIds, went); }
2369
+ return how;
2370
+ })).then(done, function (err) {
1780
2371
  done();
1781
2372
  if (state.reading !== r) return;
1782
2373
  var why = err && err.name === 'AbortError'
@@ -1794,7 +2385,9 @@
1794
2385
  // Resolves to 'sent' | 'parked' | 'held' | 'refused' | 'changed'.
1795
2386
  function continueSession(r, text, opts) {
1796
2387
  var provider = r.provider, id = r.id;
1797
- var draft = opts.held ? { text: opts.held.text, key: opts.held.key } : draftFor(text, r), view = viewOf(r);
2388
+ var draft = opts.held ? { text: opts.held.text, key: opts.held.key, ids: opts.held.ids || [] } : draftFor(text, r, opts.attachmentIds), view = viewOf(r);
2389
+ // Files go only to a server that said it takes them (threadTurnAttachments); the caller shows no clip otherwise.
2390
+ var files = draft.ids && draft.ids.length ? { attachmentIds: draft.ids } : {};
1798
2391
  if (!opts.held) view.status(r, 'Sending');
1799
2392
  var bound = bindingFor(provider, id);
1800
2393
  var attach = bound ? Promise.resolve({ ok: true, binding: bound }) : apiAnswer(sessionPath(provider, id, '/attach'), { method: 'POST', body: { cosSessionId: cosSessionId() }, timeoutMs: 20000 }).then(function (a) {
@@ -1823,9 +2416,12 @@
1823
2416
  }
1824
2417
  return 'refused';
1825
2418
  }
2419
+ // QA 6.99.29: a turn this page sends starts new work, so the last Stop (or finished COS turn) no longer
2420
+ // mutes the working signals that follow (a held follow-up goes 800 ms after the end, inside the echo window).
2421
+ r.stoppedAt = null;
1826
2422
  return apiAnswer('/api/agent-sessions/bindings/' + encodeURIComponent(at.binding.bindingId) + '/turns', {
1827
2423
  method: 'POST', timeoutMs: 25000,
1828
- body: Object.assign({ prompt: draft.text, epoch: at.binding.epoch, targetKey: targetKey(provider, id), clientTurnId: draft.key }, opts.acknowledgedRevision ? { acknowledgedRevision: opts.acknowledgedRevision } : {}),
2424
+ body: Object.assign({ prompt: draft.text, epoch: at.binding.epoch, targetKey: targetKey(provider, id), clientTurnId: draft.key }, files, opts.acknowledgedRevision ? { acknowledgedRevision: opts.acknowledgedRevision } : {}),
1829
2425
  }).then(function (t) {
1830
2426
  var b = t.body || {};
1831
2427
  if (t.status >= 200 && t.status < 300 && (b.outcome === 'queued' || b.outcome === 'completed')) {
@@ -1833,6 +2429,8 @@
1833
2429
  else { view.pending(r, draft.text, b.replayed ? 'already sent' : 'sent'); view.clear(r, draft.text); }
1834
2430
  view.status(r, b.replayed ? 'Already sent. Watching for the reply.' : 'Sent. Watching for the reply.');
1835
2431
  view.watch(r, at.binding.bindingId, draft.key);
2432
+ // 6.99.29: a turn that went live is in the open session, which now works on it.
2433
+ if (b.outcome === 'completed' && b.via === 'live') { sxNoteGate(r, true); try { sxRepaint(r); } catch (_e) {} }
1836
2434
  return 'sent';
1837
2435
  }
1838
2436
  var reason = b.reason || '';
@@ -1867,7 +2465,7 @@
1867
2465
  var HELD_MAX_MS = 5 * 60000, HELD_RETRY_MS = 8000;
1868
2466
  function holdUntilReplyEnds(r, draft) {
1869
2467
  if (!r.held.some(function (h) { return h.key === draft.key; })) {
1870
- r.held.push({ text: draft.text, key: draft.key, since: Date.now() });
2468
+ r.held.push({ text: draft.text, key: draft.key, since: Date.now(), ids: draft.ids || [] });
1871
2469
  showPendingTurn(draft.text, 'sends after this reply', draft.key);
1872
2470
  saveHeld(r);
1873
2471
  }
@@ -1903,8 +2501,8 @@
1903
2501
  if (bubble) { bubble.removeAttribute('data-held-key'); bubble.querySelector('.who').textContent = 'You, waiting in line'; }
1904
2502
  } else {
1905
2503
  if (bubble) bubble.remove();
1906
- if (how === 'cancelled') returnToBox(h.text, 'Not sent.');
1907
- else returnToBox(h.text, copy || 'Not sent.');
2504
+ // The whole message comes back: its words, and its files where the Mac still keeps them.
2505
+ returnToBox(h.text, how === 'cancelled' ? 'Not sent.' : copy || 'Not sent.', heldFilesBack(r, h));
1908
2506
  }
1909
2507
  if (!r.held.some(function (x) { return !x.cancelled; })) clearInterval(r.heldTimer);
1910
2508
  }
@@ -1921,7 +2519,7 @@
1921
2519
  var view = viewOf(r);
1922
2520
  return apiAnswer(sessionPath(r.provider, r.id, '/queued-turns'), {
1923
2521
  method: 'POST', timeoutMs: 15000,
1924
- body: { cosSessionId: cosSessionId(), clientTurnId: draft.key, prompt: draft.text },
2522
+ body: Object.assign({ cosSessionId: cosSessionId(), clientTurnId: draft.key, prompt: draft.text }, draft.ids && draft.ids.length ? { attachmentIds: draft.ids } : {}),
1925
2523
  }).then(function (q) {
1926
2524
  var b = q.body || {};
1927
2525
  if (b.queued === true || b.error === 'duplicate_turn') {
@@ -1930,10 +2528,14 @@
1930
2528
  else { view.pending(r, draft.text, 'waiting in line'); view.clear(r, draft.text); }
1931
2529
  view.status(r, b.error === 'duplicate_turn' ? 'Already waiting to send.' : (b.position ? 'The session is busy. Yours is #' + (b.position + 1) + ' in line and sends on its own.' : 'The session is busy. Yours sends next, on its own.'));
1932
2530
  view.queue(r);
2531
+ // 6.99.29: "The session is busy" is the gate's word as of now; the card shows the same. Last, and
2532
+ // never allowed to cost the parked message its settlement.
2533
+ if (b.queued === true && refusal && refusal.reason === 'native_thread_working') { sxNoteGate(r, true); try { sxRepaint(r); } catch (_e) {} }
1933
2534
  return 'parked';
1934
2535
  }
1935
2536
  if (b.error === 'thread_free' && !opts.freeRetry) return continueSession(r, draft.text, Object.assign({}, opts, { freeRetry: true }));
1936
- var copy = (refusal && refusal.reasonCopy) || (b.error === 'queue_full' ? 'That session already has a full line of waiting messages.' : 'COS could not send or queue that.');
2537
+ // 11/07: a file that could not be kept refuses the park with its own words, which say which step to take.
2538
+ var copy = (b.attachmentError && b.reasonCopy) || (refusal && refusal.reasonCopy) || (b.error === 'queue_full' ? 'That session already has a full line of waiting messages.' : 'COS could not send or queue that.');
1937
2539
  if (opts.held) settleHeld(r, opts.held, 'refused', copy); else view.status(r, copy + ' It is still in the box.', 'error');
1938
2540
  return 'refused';
1939
2541
  });
@@ -1949,10 +2551,13 @@
1949
2551
  apiAnswer('/api/agent-sessions/bindings/' + encodeURIComponent(bindingId) + '/turns/' + encodeURIComponent(key)).then(function (s) {
1950
2552
  var o = s.body && s.body.outcome;
1951
2553
  if (o === 'completed') {
1952
- clearInterval(state.turnPoll); sessionStatus('Done.'); refreshTurns(provider, id);
1953
- // The finished turn is the authority; the stream's idle status can trail it.
1954
- setLive({ working: false }); var st = $('session-state'); if (st) st.textContent = 'Open';
1955
- replyEnded(r);
2554
+ clearInterval(state.turnPoll); refreshTurns(provider, id);
2555
+ // 6.99.29: a turn delivered LIVE is in the open session, which is now working on it; only a
2556
+ // COS child's finished turn is the end of the work. Either way the one state settles it.
2557
+ if (s.body.via === 'live') { sxNoteGate(r, true); sessionStatus('Sent into the open session.'); }
2558
+ else { r.stoppedAt = Date.now(); sessionStatus('Done.'); }
2559
+ setLive({}); var st = $('session-state'); if (st) st.textContent = r.live && r.live.working ? 'Working now' : 'Open';
2560
+ if (s.body.via !== 'live') replyEnded(r);
1956
2561
  } else if (o === 'refused') { clearInterval(state.turnPoll); sessionStatus(s.body.reasonCopy || 'The session refused that message.', 'error'); replyEnded(r); }
1957
2562
  else if (o === 'ambiguous') { clearInterval(state.turnPoll); sessionStatus(s.body.reasonCopy || 'COS cannot tell whether that landed. Look at the session before sending it again.', 'error'); }
1958
2563
  }).catch(function () {});
@@ -1961,13 +2566,15 @@
1961
2566
 
1962
2567
  // Fork copies the session and runs the message there. One key per draft, so a second tap or
1963
2568
  // a resend after a timeout finds the same job instead of making a second copy.
1964
- function forkSession(r, text) {
1965
- if (!r.forkDraft || r.forkDraft.text !== text) r.forkDraft = { text: text, key: 'fk' + newTurnKey().slice(2) };
2569
+ function forkSession(r, text, ids) {
2570
+ ids = Array.isArray(ids) ? ids : [];
2571
+ // Other files are another fork, with its own key.
2572
+ if (!r.forkDraft || r.forkDraft.text !== text || (r.forkDraft.ids || []).join(',') !== ids.join(',')) r.forkDraft = { text: text, key: 'fk' + newTurnKey().slice(2), ids: ids.slice() };
1966
2573
  var forkId = r.forkDraft.key;
1967
2574
  sessionStatus('Copying the session. This can take up to a minute.');
1968
2575
  var settle = function (b) {
1969
2576
  if (b.forked === true) {
1970
- r.forkDraft = null; clearBoxOf(text);
2577
+ r.forkDraft = null; clearBoxOf(text); readerFilesSent(readerFilesOn() ? readerBox(r) : null, ids);
1971
2578
  sessionStatus('Forked. The copy has your message and shows in Sessions in a moment.');
1972
2579
  setTimeout(function () { if (state.pane === 'sessions') loadSessions(true); }, 4000);
1973
2580
  return true;
@@ -1975,7 +2582,7 @@
1975
2582
  if (b.forked === false || b.state === 'failed' || b.state === 'refused') { sessionStatus((b.reasonCopy || 'COS could not copy that session.'), 'error'); return true; }
1976
2583
  return false;
1977
2584
  };
1978
- return apiAnswer(sessionPath(r.provider, r.id, '/fork'), { method: 'POST', timeoutMs: 65000, body: { prompt: text, cosSessionId: cosSessionId(), clientForkId: forkId } }).then(function (f) {
2585
+ return apiAnswer(sessionPath(r.provider, r.id, '/fork'), { method: 'POST', timeoutMs: 65000, body: Object.assign({ prompt: text, cosSessionId: cosSessionId(), clientForkId: forkId }, ids.length ? { attachmentIds: ids } : {}) }).then(function (f) {
1979
2586
  if (settle(f.body || {})) return;
1980
2587
  if (f.status !== 202) { sessionStatus('COS could not copy that session.', 'error'); return; }
1981
2588
  // 202: running in the background. Follow its job for up to three minutes.
@@ -2000,7 +2607,7 @@
2000
2607
  if (b.cancelled) {
2001
2608
  r.stoppedAt = Date.now();
2002
2609
  clearInterval(state.turnPoll);
2003
- setLive({ working: false }); var st = $('session-state'); if (st) st.textContent = 'Open';
2610
+ setLive({}); var st = $('session-state'); if (st) st.textContent = 'Open';
2004
2611
  setTimeout(function () { if (state.reading === r) refreshTurns(r.provider, r.id); }, 1500);
2005
2612
  }
2006
2613
  }).catch(function (err) { sessionStatus(err.message, 'error'); });
@@ -2014,6 +2621,36 @@
2014
2621
  });
2015
2622
  }
2016
2623
 
2624
+ // Output previews are separate from composer attachments. Only the DTO fields reach the lazy viewer.
2625
+ function readerImagesHtml(turn) {
2626
+ var images = turn && Array.isArray(turn.outputImages) ? turn.outputImages.filter(function (x) {
2627
+ return x && /^ai_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable');
2628
+ }).map(function (x) { return { id: x.id, label: String(x.label || 'Output image').slice(0, 180), state: x.state, reason: x.reason }; }) : [];
2629
+ return images.length ? '<div data-reader-images="' + esc(JSON.stringify(images)) + '"><p class="rm-status" role="status">Loading image previews…</p></div>' : '';
2630
+ }
2631
+ function readerImagesStop(host) {
2632
+ if (!host) return;
2633
+ host.__cosImageOwner = null;
2634
+ if (window.COSWeb.readerMedia) window.COSWeb.readerMedia.unmount(host);
2635
+ }
2636
+ function readerImagesMount(host, r) {
2637
+ if (!host || !r) return;
2638
+ if (!host.querySelectorAll('[data-reader-images]').length) { readerImagesStop(host); return; }
2639
+ var owner = host.__cosImageOwner = {};
2640
+ ensurePane('readerMedia', 4000).then(function (kit) {
2641
+ if (host.__cosImageOwner !== owner || host.isConnected === false) return;
2642
+ if (!kit) throw new Error('Image previews could not load.');
2643
+ kit.mount(host, r.provider, r.id);
2644
+ }).catch(function () {
2645
+ if (host.__cosImageOwner !== owner || host.isConnected === false) return;
2646
+ host.querySelectorAll('[data-reader-images]').forEach(function (slot) {
2647
+ slot.textContent = 'Image previews could not load. ';
2648
+ var retry = document.createElement('button'); retry.type = 'button'; retry.className = 'link-btn'; retry.textContent = 'Retry images';
2649
+ retry.addEventListener('click', function () { readerImagesMount(host, r); }); slot.appendChild(retry);
2650
+ });
2651
+ });
2652
+ }
2653
+
2017
2654
  // Copy session (Control's Kickstart brief, HelperSources/main.swift claudeKickstartCopy). Control
2018
2655
  // builds it from the Mac's transcript; the web builds it from the turns the server serves and
2019
2656
  // the session's first prompt, so it keeps the original request and the newest context.
@@ -2146,7 +2783,7 @@
2146
2783
  // One turn's identity, for telling which turns a refresh brought that the page already shows.
2147
2784
  function sxTurnSig(t) {
2148
2785
  var text = String(t.text || t.content || '').trim();
2149
- return (String(t.role || '').toLowerCase() === 'user' ? 'u' : 'a') + '|' + (t.at || '') + '|' + text.length + '|' + text.slice(0, 48);
2786
+ return (String(t.role || '').toLowerCase() === 'user' ? 'u' : 'a') + '|' + (t.at || '') + '|' + text.length + '|' + text.slice(0, 48) + '|' + readerImagesHtml(t);
2150
2787
  }
2151
2788
  function sxTurnHtml(t) {
2152
2789
  var role = String(t.role || '').toLowerCase() === 'user' ? 'user' : 'assistant';
@@ -2155,7 +2792,7 @@
2155
2792
  var when = at ? (Date.now() - at.getTime() < 86400000 ? clock(at) : sxShortDate(at)) : '';
2156
2793
  return '<div class="turn ' + role + ' sx-turn" data-sig="' + esc(sxTurnSig(t)) + '"><span class="who"><span>' + (role === 'user' ? 'YOU' : 'ASSISTANT') + '</span>' +
2157
2794
  (when ? '<span class="sx-turn-when">' + esc(when) + '</span>' : '') + '</span>' +
2158
- (role === 'user' ? '<div class="sx-plain">' + esc(text) + '</div>' : '<div class="sx-md">' + sxMd(text) + '</div>') + '</div>';
2795
+ (role === 'user' ? '<div class="sx-plain">' + esc(text) + '</div>' : '<div class="sx-md">' + sxMd(text) + '</div>' + readerImagesHtml(t)) + '</div>';
2159
2796
  }
2160
2797
  // A refresh never rebuilds what you are reading (Miles, /qa: "start to scroll and then it reloads
2161
2798
  // the section"). The server's window slides as a session grows, so the turns the page already
@@ -2200,7 +2837,8 @@
2200
2837
  return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20);
2201
2838
  }
2202
2839
  var TODAY_REFERENCE = 'What Today in COS Control shows right now';
2203
- function askCos(prompt, statusEl) {
2840
+ // files: { attachmentIds, atts } from the Ask sheet's clip (askSheetFiles), or nothing.
2841
+ function askCos(prompt, statusEl, files) {
2204
2842
  // 6.99.12: "it shows Sent but never shows up" (Miles, 11/04). With Ask COS in Messages, the sheet hands its words to
2205
2843
  // that composer (panes/cos-chat.js submitAsk), so the answer streams on screen and lands as a numbered turn. The
2206
2844
  // request below is the fallback for a page without it; it resolves to the Messages route to open.
@@ -2209,17 +2847,27 @@
2209
2847
  // quoted reference, the query-jobs field for context. Asked from any other pane, nothing extra is sent.
2210
2848
  var context = state.pane === 'today' ? ensurePane('todayActions', 1500).then(function (m) {
2211
2849
  // A broken summary never costs the Ask: it goes without it, and the console says why.
2212
- try { return m && m.summary ? m.summary() : ''; } catch (err) { console.error('[today] the Today summary failed; asking without it.', err); return ''; }
2850
+ var ctx;
2851
+ try { ctx = m && m.summary ? m.summary() : ''; } catch (err) { console.error('[today] the Today summary failed; asking without it.', err); return ''; }
2852
+ // The model's summary of the sessions leads it when there is one, inside the same cap; past the cap it is left out.
2853
+ var b = typeof briefShown === 'function' ? briefShown() : null;
2854
+ var lead = b ? 'Summary of the sessions, written by a model ' + (briefMinutes(b.at) < 1 ? 'just now' : span(b.at) + ' ago') + (b.state === 'stale' ? ' (things have moved since)' : '') + ': ' + b.text : '';
2855
+ return ctx && lead && lead.length + 1 + ctx.length <= ((m && m.summaryMax) || 4000) ? lead + '\n' + ctx : ctx;
2213
2856
  }, function () { return ''; }) : Promise.resolve('');
2214
2857
  if (chat && chat.askFromSheet) {
2215
2858
  setStatus(statusEl, 'Sending…');
2216
- return context.then(function (ctx) { return chat.askFromSheet(prompt, ctx ? { context: ctx } : undefined); }).then(function (where) { setStatus(statusEl, ''); return where; }, function (err) {
2859
+ return context.then(function (ctx) {
2860
+ var extra = files ? { attachmentIds: files.attachmentIds, atts: files.atts } : {};
2861
+ if (ctx) extra.context = ctx;
2862
+ return chat.askFromSheet(prompt, ctx || files ? extra : undefined);
2863
+ }).then(function (where) { setStatus(statusEl, ''); return where; }, function (err) {
2217
2864
  setStatus(statusEl, err.message, true);
2218
2865
  throw err;
2219
2866
  });
2220
2867
  }
2221
2868
  setStatus(statusEl, 'Sent. COS is answering.');
2222
2869
  var body = { clientJobId: webJobId(), generation: 1, query: prompt, source: 'web', activityToolMode: 'status' };
2870
+ if (files && files.attachmentIds && files.attachmentIds.length) body.attachmentIds = files.attachmentIds;
2223
2871
  return context.then(function (ctx) {
2224
2872
  if (ctx) body.reference = { query: TODAY_REFERENCE, response: ctx };
2225
2873
  return api('/api/query-jobs', { method: 'POST', body: body, timeoutMs: 30000 });
@@ -3901,12 +4549,87 @@
3901
4549
  var q = ($('ask-q') && $('ask-q').value.trim()) || '';
3902
4550
  g.innerHTML = esc(ASK_FILE.busy ? 'Filing\u2026' : 'File to Work inbox') + fileNoteHtml(q);
3903
4551
  }
4552
+ // ── files with Ask COS (11/07, Miles: "Sometimes I need to pass context via a screenshot, photo or file") ──
4553
+ // The COS chat's own clip, chips, limits and upload (panes/cos-chat.js, W.cosChat.attach): photos, PDF, TXT, MD, CSV,
4554
+ // JSON, XLSX, MP4 and MOV, up to 5, pasted, dropped or picked. They go with Ask COS only; the sheet's other rows take
4555
+ // words. Ask COS waits for every upload, and a file that failed has to be retried or removed first. Closing the sheet
4556
+ // keeps them for the next Ask in this tab.
4557
+ var ASK_FILES = null, ASK_SENDING = false;
4558
+ function askKit() { var c = window.COSWeb && window.COSWeb.cosChat; return c && c.attach ? c.attach : null; }
4559
+ function askFiles() {
4560
+ var kit = askKit(); if (!kit) return null;
4561
+ if (!ASK_FILES) ASK_FILES = kit.box({
4562
+ changed: paintAskFiles,
4563
+ removed: function () { setStatus($('ask-status'), ''); },
4564
+ signedOut: function () { setStatus($('ask-status'), 'This browser was signed out. Your files are kept. Sign in, then tap Retry on each file.', true); }
4565
+ });
4566
+ return ASK_FILES;
4567
+ }
4568
+ function askFilesState() {
4569
+ var kit = askKit(), b = ASK_FILES;
4570
+ if (!kit || !b || !b.atts.length) return 'none';
4571
+ return kit.pending(b) ? 'waiting' : kit.failed(b) ? 'failed' : 'ready';
4572
+ }
4573
+ function paintAskClip() {
4574
+ var slot = $('ask-clip'), kit = askKit(); if (!slot) return;
4575
+ var on = !!(kit && kit.askOn());
4576
+ if (!on) { slot.innerHTML = ''; return; }
4577
+ // 6.99.27: Take photo beside the clip (a phone or tablet only; kit.cameraHtml).
4578
+ if (!slot.querySelector('[data-ask-file]')) slot.innerHTML = kit.cameraHtml('data-ask-file') + kit.clipHtml('data-ask-file');
4579
+ var full = !!(ASK_FILES && ASK_FILES.atts.length >= kit.max);
4580
+ Array.prototype.forEach.call(slot.querySelectorAll ? slot.querySelectorAll('.cc-attach') : [], function (l) { l.classList.toggle('full', full); });
4581
+ Array.prototype.forEach.call(slot.querySelectorAll ? slot.querySelectorAll('[data-ask-file]') : [], function (i) { i.disabled = full || ASK_SENDING; });
4582
+ }
4583
+ function paintAskFiles() {
4584
+ var kit = askKit(), box = $('ask-files'), b = ASK_FILES; if (!box || !kit) return;
4585
+ var st = askFilesState();
4586
+ box.innerHTML = st === 'none' ? '' : kit.chipsHtml(b, 'data-action') + '<p class="ask-files-note">' + esc(
4587
+ st === 'waiting' ? 'Uploading. Ask COS waits for it.' : st === 'failed' ? 'A file did not upload. Retry it or remove it, then Ask COS.'
4588
+ : (b.atts.length === 1 ? 'This file goes' : 'These files go') + ' with Ask COS. Write the question above.') + '</p>';
4589
+ box.classList.toggle('hidden', st === 'none');
4590
+ paintAskClip(); paintAskRow();
4591
+ }
4592
+ function addAskFiles(list) {
4593
+ var kit = askKit(), b = askFiles(); if (!kit || !b || !list || !list.length) return;
4594
+ var refusals = kit.add(b, list);
4595
+ setStatus($('ask-status'), refusals.join(' '), !!refusals.length);
4596
+ paintAskFiles();
4597
+ }
4598
+ // The Ask COS row says what it sends, and waits while a file uploads.
4599
+ function askRowNote(q) {
4600
+ var n = ASK_FILES ? ASK_FILES.atts.length : 0;
4601
+ // The files first: on the folded phone the line is cut, and they are what changed.
4602
+ return '“' + q + '”' + (n ? ' with ' + (n === 1 ? '1 file' : n + ' files') : '') + (state.pane === 'today' ? (n ? ' and' : ' with') + ' what Today shows' : '');
4603
+ }
4604
+ function paintAskRow() {
4605
+ var row = document.querySelector('#ask-results [data-action="ask-cos"]'); if (!row) return;
4606
+ var st = askFilesState();
4607
+ row.disabled = ASK_SENDING || st === 'waiting' || st === 'failed';
4608
+ var note = row.querySelector('.note'), q = ($('ask-q') && $('ask-q').value.trim()) || '';
4609
+ if (note) note.textContent = ASK_SENDING ? 'Sending…' : st === 'waiting' ? 'Waiting for the upload' : askRowNote(q);
4610
+ }
4611
+ function askSheetSend(q) {
4612
+ if (ASK_SENDING) return;
4613
+ var kit = askKit(), b = ASK_FILES, st = askFilesState();
4614
+ if (st === 'waiting') { setStatus($('ask-status'), 'A file is still uploading. Ask COS when it finishes.'); return; }
4615
+ if (st === 'failed') { setStatus($('ask-status'), 'A file did not upload. Retry it or remove it, then Ask COS.', true); return; }
4616
+ var files = st === 'ready' ? { attachmentIds: kit.ids(b), atts: kit.sent(b) } : null;
4617
+ ASK_SENDING = true; paintAskRow(); paintAskClip();
4618
+ var done = function () { ASK_SENDING = false; paintAskRow(); paintAskClip(); };
4619
+ askCos(q, $('ask-status'), files).then(function (where) {
4620
+ done();
4621
+ // Sent: these files went with it. Anything added while it was sending stays for the next Ask.
4622
+ if (files && b) b.atts.filter(function (t) { return files.attachmentIds.indexOf(t.mediaId) >= 0; }).forEach(function (t) { kit.remove(b, t.id); });
4623
+ closeAsk(); go('messages', Array.isArray(where) ? where : []);
4624
+ }).catch(done);
4625
+ }
3904
4626
  function openAsk(prefill) {
3905
4627
  loadFileDomains().then(function () { if (!$('ask-sheet').classList.contains('hidden') && $('ask-q').value.trim()) renderAsk(); });
3906
4628
  $('ask-sheet').classList.remove('hidden');
3907
4629
  document.body.style.overflow = 'hidden';
3908
4630
  var input = $('ask-q'); input.value = prefill || '';
3909
- renderAsk();
4631
+ renderAsk(); paintAskFiles(); paintAskClip();
4632
+ var kit = askKit(); if (kit && !kit.askOn()) kit.ensureCaps().then(paintAskClip, function () {});
3910
4633
  setTimeout(function () { input.focus(); }, 30);
3911
4634
  }
3912
4635
  function closeAsk() { $('ask-sheet').classList.add('hidden'); document.body.style.overflow = ''; }
@@ -3923,12 +4646,13 @@
3923
4646
  }
3924
4647
  var jumps = JUMPS.filter(function (j) { return j[1].toLowerCase().indexOf(q.toLowerCase()) === 0; });
3925
4648
  out.innerHTML = '<div class="list" style="margin-top:14px">' +
3926
- '<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(q) + '”' + (state.pane === 'today' ? ' with what Today shows' : '') + '</span></span></button>' +
4649
+ '<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>' +
3927
4650
  '<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>' +
3928
4651
  '<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>' +
3929
4652
  '<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>' +
3930
4653
  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('') +
3931
4654
  '</div><div id="ask-hits"></div><p class="status-line" id="ask-status"></p>';
4655
+ paintAskRow();
3932
4656
  clearTimeout(renderAsk.t);
3933
4657
  if (q.length >= 2) renderAsk.t = setTimeout(function () {
3934
4658
  api('/api/agent-sessions/search?q=' + encodeURIComponent(q) + '&limit=6').then(function (d) {
@@ -3972,7 +4696,7 @@
3972
4696
  return;
3973
4697
  }
3974
4698
  if (t.hasAttribute('data-open-ask')) return openAsk();
3975
- if (t.hasAttribute('data-today')) return openTodayAction(t);
4699
+ if (t.hasAttribute('data-today')) return t.getAttribute('data-today') === 'pet' ? petTap(t) : openTodayAction(t);
3976
4700
  if (t.hasAttribute('data-today-next')) return prepNext();
3977
4701
  if (t.hasAttribute('data-today-needs')) {
3978
4702
  var which = t.getAttribute('data-today-needs');
@@ -4004,7 +4728,18 @@
4004
4728
  }
4005
4729
  if (action === 'expand-turn') { var tr = document.querySelector('.turn[data-turn="' + t.getAttribute('data-turn') + '"]'); if (tr) tr.classList.remove('clamped'); t.remove(); return; }
4006
4730
  var q = $('ask-q').value.trim();
4007
- if (action === 'ask-cos' && q) { askCos(q, $('ask-status')).then(function (where) { closeAsk(); go('messages', Array.isArray(where) ? where : []); }).catch(function () {}); return; }
4731
+ if ((action === 'att-remove' || action === 'att-retry') && t.closest('#session-form') && state.reading) {
4732
+ var kitR = readerKit(), boxR = kitR && readerBox(state.reading); if (!boxR) return;
4733
+ sessionStatus('');
4734
+ if (action === 'att-remove') kitR.remove(boxR, t.getAttribute('data-att')); else kitR.retry(boxR, t.getAttribute('data-att'));
4735
+ return;
4736
+ }
4737
+ if (inSheet && (action === 'att-remove' || action === 'att-retry') && ASK_FILES) {
4738
+ var kitA = askKit(); setStatus($('ask-status'), '');
4739
+ if (action === 'att-remove') kitA.remove(ASK_FILES, t.getAttribute('data-att')); else kitA.retry(ASK_FILES, t.getAttribute('data-att'));
4740
+ return;
4741
+ }
4742
+ if (action === 'ask-cos' && q) return askSheetSend(q);
4008
4743
  if (action === 'ask-file' && q) return fileToInbox(q);
4009
4744
  if (action === 'ask-meaning' && q) { $('ask-hits').innerHTML = skeletonRows(3); findByMeaning(q).then(function (html) { var el = $('ask-hits'); if (el) el.innerHTML = html; }); return; }
4010
4745
  if (action === 'ask-work' && q) {
@@ -4026,7 +4761,7 @@
4026
4761
  .catch(function (err) {
4027
4762
  // The server names what failed (wrong token, wrong code, the glasses code, lockout).
4028
4763
  // No status means the request never reached COS (the browser's own "Load failed").
4029
- showAuth(err && err.status ? err.message : 'Could not reach COS on your Mac. Check that this phone is on Tailscale and the Mac is awake.');
4764
+ showAuth(err && err.status ? err.message : UNREACHABLE_COPY);
4030
4765
  });
4031
4766
  } else if (f.id === 'session-form') sendSessionTurn(false);
4032
4767
  else if (f.id === 'sessions-search-form') { $('sessions-q').blur(); searchSessions(); }
@@ -4035,16 +4770,47 @@
4035
4770
  }
4036
4771
  });
4037
4772
 
4773
+ // The Ask sheet's clip, a pasted screenshot in its box, and files dropped on it (askFiles above).
4774
+ document.addEventListener('change', function (e) {
4775
+ if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-ask-file')) { addAskFiles(e.target.files); e.target.value = ''; }
4776
+ if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-sx-file')) { addReaderFiles(e.target.files); e.target.value = ''; }
4777
+ });
4778
+ // The Sessions reader's box: a pasted screenshot, and files dropped on it.
4779
+ document.addEventListener('paste', function (e) {
4780
+ if (!e.target || e.target.id !== 'session-input') return;
4781
+ var kit = readerKit(), files = kit && readerFilesOn() ? kit.pasted(e) : null;
4782
+ if (files) { e.preventDefault(); addReaderFiles(files); }
4783
+ });
4784
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (type) {
4785
+ document.addEventListener(type, function (e) {
4786
+ var form = e.target && e.target.closest ? e.target.closest('#session-form') : null, kit = readerKit();
4787
+ if (!form || !kit || !readerFilesOn()) return;
4788
+ var files = kit.dragged(e, form); if (files) addReaderFiles(files);
4789
+ });
4790
+ });
4791
+ document.addEventListener('paste', function (e) {
4792
+ if (!e.target || e.target.id !== 'ask-q') return;
4793
+ var kit = askKit(), files = kit && kit.askOn() ? kit.pasted(e) : null;
4794
+ if (files) { e.preventDefault(); addAskFiles(files); }
4795
+ });
4796
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (type) {
4797
+ var sheet = $('ask-sheet'); if (!sheet) return;
4798
+ sheet.addEventListener(type, function (e) {
4799
+ var kit = askKit(), zone = sheet.querySelector('.sheet'); if (!kit || !zone || !kit.askOn()) return;
4800
+ var files = kit.dragged(e, zone); if (files) addAskFiles(files);
4801
+ });
4802
+ });
4038
4803
  document.addEventListener('input', function (e) {
4039
4804
  if (e.target.tagName === 'TEXTAREA') autoGrow(e.target);
4040
4805
  if (e.target.id === 'ask-q') renderAsk();
4041
4806
  if (e.target.id === 'sessions-q' && !e.target.value.trim()) renderSessionGroups(state.sessions);
4042
4807
  });
4043
4808
  document.addEventListener('keydown', function (e) {
4809
+ if (window.COSWeb.readerMedia && window.COSWeb.readerMedia.isOpen()) return;
4044
4810
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openAsk(); return; }
4045
4811
  if (e.key === 'Escape' && !$('ask-sheet').classList.contains('hidden')) { closeAsk(); return; }
4046
4812
  if (e.key === 'Escape' && !$('section-menu').classList.contains('hidden')) { closeMenu(); $('section-btn').focus(); return; }
4047
- if (e.key === 'Enter' && !e.shiftKey && e.target.tagName === 'TEXTAREA' && !isPhone()) { e.preventDefault(); var form = e.target.closest('form'); if (form) form.requestSubmit(); }
4813
+ if (returnSends(e)) { e.preventDefault(); var form = e.target.closest('form'); if (form) form.requestSubmit(); }
4048
4814
  });
4049
4815
  document.querySelectorAll('[data-mode]').forEach(function (b) {
4050
4816
  b.addEventListener('click', function () {
@@ -4055,6 +4821,17 @@
4055
4821
  });
4056
4822
  window.addEventListener('hashchange', route);
4057
4823
  window.addEventListener('scroll', onScroll, { passive: true });
4824
+ // 6.99.30: back from the browser's back-forward cache (a swipe back, the app switcher) the open reader's live stream
4825
+ // is closed and the page's last reading is however old the visit was. Open the stream again and read the turns.
4826
+ window.addEventListener('pageshow', function (e) {
4827
+ if (!e || !e.persisted) return;
4828
+ var r = state.reading;
4829
+ if (r && r.provider && r.id) {
4830
+ if (state.stream) { try { state.stream.close(); } catch (_e) {} state.stream = null; }
4831
+ watchSession(r.provider, r.id); refreshTurns(r.provider, r.id);
4832
+ }
4833
+ if (state.pane === 'today' && !$('app-shell').classList.contains('hidden')) loadToday();
4834
+ });
4058
4835
  document.addEventListener('visibilitychange', function () {
4059
4836
  if (document.hidden || $('app-shell').classList.contains('hidden')) return;
4060
4837
  // Coming back to the page refreshes Today in place. Other panes keep what is on screen:
@@ -4095,10 +4872,80 @@
4095
4872
  });
4096
4873
  }
4097
4874
 
4875
+ // ── the visible viewport, read one way (6.99.30) ──────
4876
+ // Today's session sheet (panes/today-actions.js) and the question card's footer on Today (panes/questions.js) both
4877
+ // place things above whatever covers the bottom of the page: an on-screen keyboard, a hardware keyboard's strip, or a
4878
+ // browser's own bottom bar (Comet, Chrome, Safari's, whichever: nothing here knows any browser's bar). Only what the
4879
+ // browser reports is used: window.visualViewport's height, offsetTop and scale against the layout viewport.
4880
+ // layoutH the layout viewport's height. Read whenever the page is not pinch-zoomed (then innerHeight is the layout
4881
+ // viewport everywhere) and kept while it is, since some browsers report the zoomed size there.
4882
+ // inset what covers the bottom, in unzoomed CSS px: layoutH less the visible height times the zoom. A pinch
4883
+ // zoom alone is no inset (half the height at 2x is all of the page).
4884
+ // top, height the box to fill, in layout px. Unzoomed it is the visible viewport itself (offsetTop is where a
4885
+ // browser panned the page to keep a focused field in view). Zoomed it is the layout viewport less the
4886
+ // inset, so a pinch zoom magnifies the sheet instead of shrinking its layout into the zoomed window.
4887
+ // below what of the layout viewport lies under that box: the backdrop alone reaches down over it.
4888
+ // No visualViewport (old browsers): the whole layout viewport, nothing covered.
4889
+ var VP = { layoutH: 0 };
4890
+ function viewportFit() {
4891
+ var vv = window.visualViewport, de = document.documentElement;
4892
+ var inner = window.innerHeight || (de && de.clientHeight) || 0;
4893
+ var scale = vv && vv.scale > 0 ? vv.scale : 1, zoomed = scale > 1.01;
4894
+ if (!zoomed || !VP.layoutH) VP.layoutH = zoomed ? Math.max(inner, (de && de.clientHeight) || 0) : inner;
4895
+ var H = VP.layoutH;
4896
+ if (!vv || !(vv.height > 0)) return { layoutH: H, top: 0, height: H, inset: 0, below: 0, scale: 1, zoomed: false, vv: false };
4897
+ var inset = Math.max(0, Math.round(H - vv.height * scale));
4898
+ var top, height;
4899
+ if (!zoomed) { top = Math.max(0, Math.round(vv.offsetTop || 0)); height = Math.min(Math.round(vv.height), H); }
4900
+ else { height = H - inset; top = Math.min(Math.max(0, Math.round(vv.offsetTop || 0)), inset); }
4901
+ if (top + height > H) top = Math.max(0, H - height);
4902
+ return { layoutH: H, top: top, height: height, inset: inset, below: Math.max(0, H - top - height), scale: scale, zoomed: zoomed, vv: true };
4903
+ }
4904
+
4905
+ // ── layout breadcrumbs (6.99.30) ──────────────────────
4906
+ // When a sheet lands wrong on a phone nobody here holds, the numbers have to come back on their own. A breadcrumb
4907
+ // goes to the server's existing client diagnostics log (POST /api/diag/client, routes/diag.ts: 10 MB with one
4908
+ // rotation, 30 lines per 10 s per client) when an overlay opens and each time its keyboard state changes. Numbers and
4909
+ // a browser family only: no address, no text, no ids, never the user agent string itself. At most one identical
4910
+ // line per 2 s and LAYOUT_CRUMB_MAX a page load, fire and forget.
4911
+ var LAYOUT_CRUMB_MAX = 40, LAYOUT_CRUMB_GAP_MS = 2000;
4912
+ var crumbs = { sent: 0, last: '', lastAt: 0 };
4913
+ /** The browser family, from the UA string, for the breadcrumb. Comet first: it says Chrome or Safari too. */
4914
+ function uaClass(ua) {
4915
+ ua = String(ua == null ? (navigator && navigator.userAgent) || '' : ua);
4916
+ var ios = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && typeof navigator !== 'undefined' && navigator.maxTouchPoints > 1);
4917
+ var android = /Android/.test(ua);
4918
+ var os = ios ? 'ios' : android ? 'android' : /Mac OS X|Macintosh/.test(ua) ? 'mac' : /Windows/.test(ua) ? 'windows' : /Linux|CrOS/.test(ua) ? 'linux' : 'other';
4919
+ var hints = typeof navigator !== 'undefined' && navigator.userAgentData && navigator.userAgentData.brands ? navigator.userAgentData.brands.map(function (b) { return b && b.brand; }).join(' ') : '';
4920
+ var brand = /Comet\//i.test(ua) || /\bComet\b/i.test(ua) || /\bComet\b/i.test(hints) ? 'comet'
4921
+ : /CriOS\//.test(ua) ? 'crios' : /FxiOS\//.test(ua) ? 'firefox' : /EdgiOS\/|Edg\//.test(ua) ? 'edge'
4922
+ : /Firefox\//.test(ua) ? 'firefox' : /Chrome\/|Chromium\//.test(ua) ? 'chrome'
4923
+ : /Safari\//.test(ua) ? 'safari' : 'other';
4924
+ return { os: os, brand: brand };
4925
+ }
4926
+ /** One breadcrumb: `event` (open, kb), the viewport as the browser reports it, and the overlay's own state. */
4927
+ function layoutCrumb(event, extra) {
4928
+ try {
4929
+ var vv = window.visualViewport, de = document.documentElement, ua = uaClass();
4930
+ var r = function (n) { return typeof n === 'number' && isFinite(n) ? Math.round(n * 100) / 100 : null; };
4931
+ var o = (screen && screen.orientation && screen.orientation.type) || (typeof window.orientation === 'number' ? 'deg' + window.orientation : '');
4932
+ var data = { what: String(event || ''), ua: ua.brand, os: ua.os, iw: r(window.innerWidth), ih: r(window.innerHeight), ch: r(de && de.clientHeight),
4933
+ vvh: vv ? r(vv.height) : null, vvw: vv ? r(vv.width) : null, vvt: vv ? r(vv.offsetTop) : null, vvs: vv ? r(vv.scale) : null,
4934
+ orient: String(o).slice(0, 24), coarse: !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches) };
4935
+ var x = extra || {};
4936
+ Object.keys(x).forEach(function (k) { var v = x[k]; if (/^[a-z][a-zA-Z0-9]{0,15}$/.test(k) && (typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 24))) data[k] = typeof v === 'number' ? r(v) : v; });
4937
+ var key = JSON.stringify(data), now = Date.now();
4938
+ if (crumbs.sent >= LAYOUT_CRUMB_MAX || (key === crumbs.last && now - crumbs.lastAt < LAYOUT_CRUMB_GAP_MS)) return false;
4939
+ crumbs.sent++; crumbs.last = key; crumbs.lastAt = now;
4940
+ api('/api/diag/client', { method: 'POST', body: { ts: now, level: 'info', event: 'web.layout', data: data }, timeoutMs: 5000 }).catch(function () {});
4941
+ return true;
4942
+ } catch (_e) { return false; }
4943
+ }
4944
+
4098
4945
  // ── the shared kit for pane modules (panes/*.js) ───────
4099
4946
  window.COSWeb = {
4100
4947
  $: $, esc: esc, md: md, plain: plain, api: api, toast: toast, ago: ago, clock: clock, toDate: toDate,
4101
- plural: plural, countWord: countWord, dayWord: dayWord, providerLabel: providerLabel, domainLabel: domainLabel,
4948
+ plural: plural, countWord: countWord, dayWord: dayWord, plainDay: plainDay, providerLabel: providerLabel, domainLabel: domainLabel,
4102
4949
  skeletonRows: skeletonRows, emptyBox: emptyBox, setStatus: setStatus, autoGrow: autoGrow,
4103
4950
  readStore: readStore, writeStore: writeStore, copyText: copyText, isPhone: isPhone,
4104
4951
  state: state, titles: TITLES, go: go, parseRoute: parseRoute, goBack: goBack,
@@ -4106,6 +4953,7 @@
4106
4953
  sessionList: sessionList,
4107
4954
  petPrefs: petPrefs,
4108
4955
  setPetPrefs: setPetPrefs,
4956
+ resetTodayBrief: resetBrief,
4109
4957
  onRoute: function (fn) { routeHooks.push(fn); },
4110
4958
  onShell: function (fn) { shellHooks.push(fn); },
4111
4959
  // The detail title after "COS Control > Section" (Control's breadcrumb). Pane modules set it
@@ -4117,7 +4965,13 @@
4117
4965
  // Control's session state (the Sessions pane's sxState) and warm-up test, so the rail's
4118
4966
  // "N waiting on you, N working" counts exactly what the pane lists.
4119
4967
  sessionState: function (s) { return sxState(s); },
4120
- isKeepWarm: function (s) { return sxKeepWarm(s); }
4968
+ isKeepWarm: function (s) { return sxKeepWarm(s); },
4969
+ // 6.99.30: the visible viewport read one way, layout breadcrumbs, the one unreachable sentence, Return-sends.
4970
+ viewportFit: viewportFit, layoutCrumb: layoutCrumb, uaClass: uaClass, unreachableCopy: UNREACHABLE_COPY, keyboardFirst: keyboardFirst,
4971
+ serverUp: function () { return SERVER.up; },
4972
+ // Panes with their own fetch (the questions poll) say whether the Mac answered, so a "down" mark never outlives it.
4973
+ noteServer: function (up) { noteServer(up); },
4974
+ onServer: function (fn) { SERVER.hooks.push(fn); }
4121
4975
  };
4122
4976
  // Today as a place to act (panes/today-actions.js, Miles 11/06): what the Today cards show, a repaint after a
4123
4977
  // decision, and the Sessions reader's own pieces, so the overlay sends and reads through the reader's code.
@@ -4139,7 +4993,11 @@
4139
4993
  railLine: function () { return TODAY.model ? { text: todayRailLine(TODAY.model), hot: TODAY.model.now.length > 0 } : null; },
4140
4994
  markGone: function (id, gone) { if (gone) TODAY.gone[id] = 1; else delete TODAY.gone[id]; paintTodayIntake(); },
4141
4995
  reloadIntake: function () { return loadTodayIntake(); },
4142
- refresh: function () { if (state.pane === 'today') loadToday(); }
4996
+ refresh: function () { if (state.pane === 'today') loadToday(); },
4997
+ // 6.99.26: the pet's sessions (petPlay), and a repaint of Needs you when the held questions change.
4998
+ petPlay: function () { return petPlay(state.sessions || []); },
4999
+ // 6.99.27: the pet's caption and pose follow the held questions too.
5000
+ questionsChanged: function () { if (TODAY.at) { paintNeeds(); if (state.pane === 'today' && !TODAY.offline) renderPet(state.sessions || []); } }
4143
5001
  };
4144
5002
  window.COSWeb.sessionKit = {
4145
5003
  // A session object for continueSession, with the overlay's own view (READER_VIEW's methods).
@@ -4150,7 +5008,10 @@
4150
5008
  newKey: newTurnKey,
4151
5009
  probe: function (r) { return sxProbeAttach(r); },
4152
5010
  path: sessionPath, answer: apiAnswer,
4153
- feed: { fresh: newFeed, apply: applyFeed, word: sxFeedWord, detail: sxFeedDetail, tool: sxToolLine, elapsed: sxElapsed, fallback: sxFallbackLine },
5011
+ notSent: { rows: notSentRows, line: notSentLine, note: noteNotSent, get: notSentFor },
5012
+ imageHtml: readerImagesHtml, imagesMount: readerImagesMount, imagesStop: readerImagesStop,
5013
+ feed: { fresh: newFeed, apply: applyFeed, word: sxFeedWord, detail: sxFeedDetail, tool: sxToolLine, elapsed: sxElapsed, fallback: sxFallbackLine,
5014
+ truth: sxTruth, settle: sxSettle, noteList: sxNoteList, noteGate: sxNoteGate, gateStale: sxGateStale, promptLine: sxPromptLine, parkNoteShows: sxParkNoteShows },
4154
5015
  title: sxTitle, stateOf: sxState, stateLabel: function (s) { return sxStateLabel(s, sxState(s)); },
4155
5016
  parkCopy: function (p) { return SX_PARK[sxProvider(p)]; }, providerLabel: sxProviderLabel, markdown: sxMd
4156
5017
  };
@@ -4163,7 +5024,7 @@
4163
5024
  history.replaceState({}, document.title, location.pathname + location.hash);
4164
5025
  api('/api/web-session/login', { method: 'POST', body: { code: linkCode } }).then(showShell)
4165
5026
  .catch(function (err) {
4166
- showAuth(err && err.status ? err.message : 'Could not reach COS on your Mac. Check that this phone is on Tailscale and the Mac is awake.');
5027
+ showAuth(err && err.status ? err.message : UNREACHABLE_COPY);
4167
5028
  var box = $('auth-code'); if (box) box.value = linkCode;
4168
5029
  });
4169
5030
  return;
@@ -4175,7 +5036,7 @@
4175
5036
  .catch(function (err) { showAuth(err && err.status ? err.message : 'That sign-in link did not work. Get a new one from COS Control on your Mac or the COS Glasses app, or use a code or the token.'); });
4176
5037
  return;
4177
5038
  }
4178
- api('/api/web-session/status').then(function (st) { if (st && st.authenticated) showShell(st); else showAuth(); }).catch(function () { showAuth('COS on your Mac is not answering. Check that the Mac is awake and on Tailscale.'); });
5039
+ api('/api/web-session/status').then(function (st) { if (st && st.authenticated) showShell(st); else showAuth(); }).catch(function () { showAuth(UNREACHABLE_COPY); });
4179
5040
  }
4180
5041
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
4181
5042
  })();