@gotcos/glasses-server 6.99.25 → 6.99.29

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 (44) hide show
  1. package/CHANGELOG.md +166 -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 +2 -0
  33. package/server/web-control/app.js +738 -102
  34. package/server/web-control/index.html +5 -1
  35. package/server/web-control/panes/cos-chat.css +18 -0
  36. package/server/web-control/panes/cos-chat.js +239 -45
  37. package/server/web-control/panes/meetings.js +20 -9
  38. package/server/web-control/panes/questions.css +39 -1
  39. package/server/web-control/panes/questions.js +368 -51
  40. package/server/web-control/panes/reader-media.js +154 -0
  41. package/server/web-control/panes/sessions.css +2 -0
  42. package/server/web-control/panes/settings.js +34 -2
  43. package/server/web-control/panes/today-actions.css +62 -4
  44. package/server/web-control/panes/today-actions.js +384 -53
@@ -101,8 +101,10 @@
101
101
  var ctrl = limit && window.AbortController ? new AbortController() : null;
102
102
  var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, limit) : null;
103
103
  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);
104
+ // Blob bodies keep their deadline after headers: a stalled download must release its queue slot.
105
+ if (timer && o.responseType !== 'blob') clearTimeout(timer);
105
106
  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; }
107
+ if (o.responseType === 'blob' && res.ok) return res.blob();
106
108
  return res.text().then(function (text) {
107
109
  var data = {};
108
110
  try { data = text ? JSON.parse(text) : {}; } catch (_e) { data = {}; }
@@ -118,6 +120,9 @@
118
120
  }
119
121
  return data;
120
122
  });
123
+ }).then(function (value) {
124
+ if (timer) clearTimeout(timer);
125
+ return value;
121
126
  }, function (err) {
122
127
  if (timer) clearTimeout(timer);
123
128
  if (err && err.name === 'AbortError' && ctrl && ctrl.signal.aborted) {
@@ -215,7 +220,7 @@
215
220
  var railPane = r.pane === 'usage' ? 'sessions' : r.pane;
216
221
  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
222
  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; }
223
+ 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
224
  if (r.pane !== 'meetings' && state.livePoll) { clearInterval(state.livePoll); state.livePoll = null; }
220
225
 
221
226
  var reading = r.args.length > 0 && !isTabRoute(r.args);
@@ -292,6 +297,7 @@
292
297
 
293
298
  // ── auth ────────────────────────────────────────────────
294
299
  function showAuth(message) {
300
+ readerImagesStop($('session-turns')); readerImagesStop($('ta-last'));
295
301
  var alreadyOpen = !$('auth-gate').classList.contains('hidden');
296
302
  $('app-shell').classList.add('hidden');
297
303
  $('auth-gate').classList.remove('hidden');
@@ -332,16 +338,35 @@
332
338
  // stories all asked for it separately. Now there is one copy: a request younger than maxAgeMs is
333
339
  // answered from it, a request already on the wire is shared, and panes can paint the last copy at
334
340
  // once while a fresh one loads (sessionListCached).
335
- var SL = { data: null, at: 0, inflight: null };
341
+ var SL = { data: null, at: 0, inflight: null, waiting: null };
336
342
  function sessionList(maxAgeMs) {
337
343
  if (SL.data && Date.now() - SL.at < (maxAgeMs || 0)) return Promise.resolve(SL.data);
338
344
  if (SL.inflight) return SL.inflight;
339
345
  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;
346
+ SL.data = d; SL.at = Date.now(); SL.inflight = null;
347
+ noteWaiting(d);
348
+ return d;
341
349
  }, function (err) { SL.inflight = null; throw err; });
342
350
  return SL.inflight;
343
351
  }
344
352
  function sessionListCached() { return SL.data ? { data: SL.data, at: SL.at } : null; }
353
+ // 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
354
+ // parked a question for this page. The question poll (panes/questions.js) reads at once rather than at its next turn.
355
+ function questionsNudge() {
356
+ var q = window.COSWeb && window.COSWeb.sessionQuestions;
357
+ if (q && typeof q.nudge === 'function') { try { q.nudge(); } catch (_e) {} }
358
+ }
359
+ function noteWaiting(d) {
360
+ var now = {}, fresh = false;
361
+ ((d && d.sessions) || []).forEach(function (s) {
362
+ if (!s || sxState(s) !== 'waiting') return;
363
+ var k = splitSessionId(s.provider, s.session_id || s.id), key = k.provider + '|' + String(k.id).toLowerCase();
364
+ now[key] = 1;
365
+ if (!SL.waiting || !SL.waiting[key]) fresh = true;
366
+ });
367
+ SL.waiting = now;
368
+ if (fresh) questionsNudge();
369
+ }
345
370
 
346
371
  // ── the session pet (Today) ─────────────────────────────
347
372
  // Same pet and the same pose rule as COS Control (PetSpritePose.resolve): waiting with nothing running
@@ -367,27 +392,99 @@
367
392
  return merged;
368
393
  }
369
394
  var PET_HEIGHT = 140;
370
- function petPose(sessions) {
371
- var working = 0, waiting = 0, alive = 0;
395
+ // 6.99.27 (Miles 11/07 09:29, "there wasn't an option for the pet to interact with the questions"): the questions
396
+ // this page holds (panes/questions.js), the ones that can be answered here. One key per session, as Needs you keys
397
+ // them.
398
+ function petQuestions() {
399
+ var q = window.COSWeb && window.COSWeb.sessionQuestions;
400
+ var items = q && typeof q.itemsFor === 'function' ? q.itemsFor('') : [];
401
+ return (items || []).filter(function (it) { return it && it.sessionId; });
402
+ }
403
+ function petQKey(it) { var k = splitSessionId(it.provider, it.sessionId); return k.provider + '|' + k.id; }
404
+ function petPose(sessions, questions) {
405
+ var working = 0, waiting = 0, alive = 0, asked = {};
406
+ var qs = questions || [];
407
+ qs.forEach(function (it) { asked[petQKey(it).toLowerCase()] = 1; });
372
408
  sessions.forEach(function (s) {
373
409
  var st = sxState(s);
374
410
  // Working is the Running card's own test (running_active), so the caption under the pet never
375
411
  // disagrees with the Running count beside it (11/06: the pet said 3 while the card said 2).
376
412
  if (s.running_active) working++;
377
- else if (st === 'waiting') waiting++;
413
+ // A session whose question the caption already leads with is not counted again as waiting on you.
414
+ else if (st === 'waiting' && !asked[petKey(s).toLowerCase()]) waiting++;
378
415
  if (s.running || s.running_active) alive++;
379
416
  });
380
417
  var pose;
381
- if (waiting > 0 && working === 0) pose = 'waiting';
418
+ // COS Control's rule (PetSpritePose.resolve): attention outranks every count pose. A question waiting here is the
419
+ // web's attention: it needs you, and this page can answer it.
420
+ if (qs.length) pose = 'attention';
421
+ else if (waiting > 0 && working === 0) pose = 'waiting';
382
422
  else if (working + waiting >= 4) pose = 'swarm';
383
423
  else if (working + waiting === 3) pose = 'trio';
384
424
  else if (working + waiting === 2) pose = 'duel';
385
425
  else if (working + waiting === 1) pose = 'working';
386
426
  else pose = alive > 0 ? 'patrol' : 'idle';
387
- return { pose: pose, working: working, waiting: waiting };
427
+ // QA 6.99.27 (User 6): a permission request is an approval, counted apart from the questions.
428
+ var approvals = qs.filter(function (it) { return it.kind === 'approval'; }).length;
429
+ return { pose: pose, working: working, waiting: waiting, questions: qs.length - approvals, approvals: approvals };
430
+ }
431
+ // COS Control's fallbacks (PetSpritePose.fallbackPoses) for a pet that has no strip for the pose.
432
+ var PET_FALLBACK = { attention: ['error', 'waiting', 'idle'], patrol: ['idle'], duel: ['swarm', 'working', 'idle'], trio: ['swarm', 'duel', 'working', 'idle'],
433
+ swarm: ['duel', 'trio', 'working', 'idle'], working: ['idle'], waiting: ['idle'] };
434
+ function petPoseIn(poses, want) {
435
+ if (poses[want]) return want;
436
+ var chain = PET_FALLBACK[want] || [];
437
+ for (var i = 0; i < chain.length; i++) if (poses[chain[i]]) return chain[i];
438
+ return poses.idle ? 'idle' : Object.keys(poses)[0];
439
+ }
440
+ // 6.99.26 (Miles 11/07, "Open what matters"): the sessions behind the pet's caption, by the same test petPose
441
+ // counts with, so a tap opens what the caption says. In play: waiting on you, then running. Done today rides along in
442
+ // the list, never in the count.
443
+ function petPlay(sessions) {
444
+ var waiting = [], running = [], done = [], today = new Date().toDateString();
445
+ (sessions || []).forEach(function (s) {
446
+ var st = sxState(s);
447
+ if (s.running_active) running.push(s);
448
+ else if (st === 'waiting') waiting.push(s);
449
+ else if ((st === 'recent' || st === 'stale') && !sxKeepWarm(s)) {
450
+ var d = toDate(s.last_activity_at || s.state_since);
451
+ if (d && d.toDateString() === today) done.push(s);
452
+ }
453
+ });
454
+ return { waiting: waiting, running: running, done: done };
455
+ }
456
+ function petKey(s) { var k = splitSessionId(s.provider, s.session_id || s.id); return k.provider + '|' + k.id; }
457
+ // One session in play opens its own sheet (waiting: the question card or reply box; else the Running peek); several
458
+ // open the short list; none does nothing.
459
+ function petTap(t) {
460
+ // A question waiting here comes first: one session's question opens its card in the overlay; questions from
461
+ // several sessions open the list, question rows first.
462
+ var qs = petQuestions(), qkeys = {};
463
+ qs.forEach(function (it) { qkeys[petQKey(it)] = 1; });
464
+ var qk = Object.keys(qkeys);
465
+ if (qk.length === 1) return openTodayAction(t, 'wait', qk[0]);
466
+ if (qk.length > 1) return openTodayAction(t, 'pet', '');
467
+ var p = petPlay(state.sessions || []), n = p.waiting.length + p.running.length;
468
+ if (!n) return;
469
+ if (n > 1) return openTodayAction(t, 'pet', '');
470
+ var s = p.waiting[0] || p.running[0];
471
+ openTodayAction(t, sxState(s) === 'waiting' ? 'wait' : 'run', petKey(s));
472
+ }
473
+ function petButtonState(box, c, label) {
474
+ var btn = box.querySelector('.pet-btn');
475
+ if (!btn) return;
476
+ var any = c.working + c.waiting + (c.questions || 0) + (c.approvals || 0) > 0;
477
+ btn.setAttribute('aria-label', any ? label + '. Open' : label);
478
+ btn.setAttribute('aria-disabled', any ? 'false' : 'true');
388
479
  }
389
480
  function petCaption(c) {
390
481
  var parts = [];
482
+ // A question or an approval waiting here leads (6.99.27): "1 question for you", "1 approval for you", or
483
+ // "1 question and 1 approval for you" (QA 6.99.27, User 6: approvals were counted as questions).
484
+ var asks = [];
485
+ if (c.questions) asks.push(c.questions === 1 ? '1 question' : c.questions + ' questions');
486
+ if (c.approvals) asks.push(c.approvals === 1 ? '1 approval' : c.approvals + ' approvals');
487
+ if (asks.length) parts.push(asks.join(' and ') + ' for you');
391
488
  if (c.working) parts.push(c.working + ' running');
392
489
  if (c.waiting) parts.push(c.waiting + ' waiting on you');
393
490
  return parts.length ? parts.join(', ') : 'Nothing running';
@@ -418,17 +515,17 @@
418
515
  var PET_CHANGE = '<button type="button" class="pet-change" data-goto="settings/pet">Change pet</button>';
419
516
  // A still pet (a gallery pick, Cloud Puff, or a single image on the Mac): one picture for every state.
420
517
  function renderPetStill(box, m, sessions) {
421
- var c = petPose(sessions), label = petCaption(c);
518
+ var c = petPose(sessions, petQuestions()), label = petCaption(c);
422
519
  petStop(); PET.pose = 'still:' + m.still;
423
520
  var img = box.querySelector('.pet-still');
424
521
  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;
522
+ 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
523
  img = box.querySelector('.pet-still');
427
524
  img.addEventListener('error', function () { box.classList.add('hidden'); });
428
525
  img.addEventListener('load', function () { img.classList.toggle('pixel', img.naturalHeight > 0 && img.naturalHeight < 140); });
429
526
  }
430
527
  box.querySelector('.pet-caption').textContent = label;
431
- box.querySelector('.pet-btn').setAttribute('aria-label', 'Sessions: ' + label);
528
+ petButtonState(box, c, label);
432
529
  box.classList.remove('hidden');
433
530
  }
434
531
  function renderPet(sessions) {
@@ -448,15 +545,14 @@
448
545
  if (!now.show || now.pet !== prefs.pet) return;
449
546
  if (!m || !m.available) { box.classList.add('hidden'); petStop(); return; }
450
547
  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]);
548
+ var c = petPose(sessions, petQuestions());
549
+ var pose = petPoseIn(m.poses, c.pose);
453
550
  var spec = m.poses[pose];
454
551
  var label = petCaption(c);
455
552
  box.classList.remove('hidden');
456
553
  var cap = box.querySelector('.pet-caption');
457
554
  if (cap) cap.textContent = label;
458
- var btn = box.querySelector('.pet-btn');
459
- if (btn) btn.setAttribute('aria-label', 'Sessions: ' + label);
555
+ petButtonState(box, c, label);
460
556
  if (PET.pose === pose && PET.timer) return;
461
557
  var token = ++PET.token;
462
558
  petLoadImages(spec).then(function () {
@@ -464,10 +560,10 @@
464
560
  petStop();
465
561
  PET.pose = pose; PET.frame = 0;
466
562
  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;
563
+ 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
564
  }
469
565
  box.querySelector('.pet-caption').textContent = label;
470
- box.querySelector('.pet-btn').setAttribute('aria-label', 'Sessions: ' + label);
566
+ petButtonState(box, c, label);
471
567
  var el = box.querySelector('.pet-sprite'), scale = PET_HEIGHT / spec.height;
472
568
  el.style.width = Math.round(spec.frameWidth * scale) + 'px';
473
569
  petPaint(el, spec, 0, scale);
@@ -563,11 +659,94 @@
563
659
  '<span class="rec-copy"><strong>Recording now</strong><span>' + esc(rec.source + (rec.startTime ? ', ' + span(rec.startTime) + ' so far' : '')) + '</span></span>' +
564
660
  '<span class="btn small">Live view</span></button>';
565
661
  }
662
+ // ── The Today summary (6.99.26 candidate) ───────────────
663
+ // "The orchestrator's eyes" (Miles, 11/07): two or three sentences on what the sessions add up to, written by Haiku on
664
+ // the server (lib/today-brief.ts). Today sends what it already read: session titles, states, how long, each session's
665
+ // latest line and what needs you. The server decides whether a new one is written (off unless switched on, only when
666
+ // the inputs changed, 10 minutes apart, 60 a day, a breaker). Today never waits for it: the counts line paints at once
667
+ // and the summary replaces it when one arrives. Never shown over a session list that was not read.
668
+ var BRIEF = { view: null, inflight: false, sentKey: '', sentAt: 0 };
669
+ // 6.99.26 QA: the same rows are asked about again only after five minutes (a Today that sits open still learns what
670
+ // the server reads itself: sub-agents and Work cards); changed rows are asked about at once, one ask at a time.
671
+ var BRIEF_RESEND_MS = 5 * 60000, BRIEF_OFF_RECHECK_MS = 5 * 60000, BRIEF_TICK_MS = 60000;
672
+ function briefMinutes(v) { var d = toDate(v); return d ? Math.max(0, Math.round((Date.now() - d.getTime()) / 60000)) : 0; }
673
+ function briefInputs(m) {
674
+ var sessions = [];
675
+ (state.sessions || []).forEach(function (s) {
676
+ if (sxKeepWarm(s)) return;
677
+ var kind = sxState(s) === 'waiting' ? 'waiting' : s.running_active ? 'working' : s.running ? 'open' : '';
678
+ if (!kind) return; // only what Today counts: waiting, working and open
679
+ var k = splitSessionId(s.provider, s.session_id || s.id);
680
+ sessions.push({ title: sessionTitle(s), provider: k.provider, state: kind,
681
+ minutes: briefMinutes(kind === 'open' ? (s.last_activity_at || s.state_since) : (s.state_since || s.last_activity_at)),
682
+ line: String(s.discussion_summary || '').slice(0, 400), waiting: kind === 'waiting' ? askedWhy(s) : '',
683
+ // Only so the server can read this session's sub-agents (names, tasks, status); it never reaches the model.
684
+ key: k.provider + '|' + k.id,
685
+ agents: s.subagent_activity ? { active: s.subagent_activity.active, completed: s.subagent_activity.completed, total: s.subagent_activity.total } : undefined });
686
+ });
687
+ var needs = m.rows.filter(function (r) { return r.kind !== 'session'; }).slice(0, 12).map(function (r) {
688
+ if (r.kind === 'work') return { title: r.item.sessionTitle || r.item.title || 'Handoff', why: needWhy(r.item) };
689
+ return { title: r.title, why: r.why || '' };
690
+ });
691
+ // Today folds all but waiting sessions under "can wait until tomorrow" in quiet hours; the summary says the same.
692
+ var later = m.later.filter(function (r) { return r.kind !== 'session'; }).length;
693
+ return { sessions: sessions.slice(0, 40), needs: needs, later: Math.min(later, needs.length), sessionsRead: TODAY.sessionsState === 'answered', needsComplete: !!m.settled,
694
+ unread: m.failed.slice(), recording: !!TODAY.recording };
695
+ }
696
+ // Asked once every source answered or failed: at once when what Today read changed, else at most every five minutes
697
+ // (the server gates the spend; this only saves requests). When the switch is off, every five minutes.
698
+ function requestBrief(m) {
699
+ if (BRIEF.inflight || state.pane !== 'today') return; // renderStory already answered an offline Today
700
+ if (Object.keys(m.states).some(function (k) { return m.states[k] === 'pending'; })) return;
701
+ var body = briefInputs(m);
702
+ 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]);
703
+ var since = Date.now() - BRIEF.sentAt;
704
+ if (BRIEF.view && BRIEF.view.state === 'off' ? since < BRIEF_OFF_RECHECK_MS : key === BRIEF.sentKey && since < BRIEF_RESEND_MS) return;
705
+ BRIEF.inflight = true; BRIEF.sentKey = key; BRIEF.sentAt = Date.now();
706
+ api('/api/today/brief', { method: 'POST', body: body, timeoutMs: 45000 }).then(function (v) {
707
+ BRIEF.view = v && typeof v === 'object' ? v : null;
708
+ }, function () { /* keep what was shown: the counts line or the last summary */ }).then(function () {
709
+ BRIEF.inflight = false;
710
+ if (TODAY.model && state.pane === 'today') renderStory(TODAY.model);
711
+ });
712
+ }
713
+ /** The summary to show, or null: only with text, only over a session list that was read. */
714
+ function briefShown() {
715
+ var v = BRIEF.view;
716
+ if (!v || !v.text || (v.state !== 'fresh' && v.state !== 'stale') || TODAY.sessionsState !== 'answered') return null;
717
+ return v;
718
+ }
719
+ // Forget the summary (Settings turned it on or off): the next Today paint asks at once.
720
+ function resetBrief() { BRIEF.view = null; BRIEF.sentKey = ''; BRIEF.sentAt = 0; }
721
+ // Every minute while Today is on screen: "Updated N min ago" moves on, and an unchanged Today asks again once its
722
+ // five minutes are up (requestBrief's own throttle). Hidden or another pane: nothing.
723
+ setInterval(function () {
724
+ if (document.hidden || state.pane !== 'today' || !TODAY.model || TODAY.offline) return;
725
+ requestBrief(TODAY.model);
726
+ var v = briefShown(), when = document.querySelector('#today-story .story-when');
727
+ if (v && when) when.textContent = briefWhen(v, TODAY.model);
728
+ }, BRIEF_TICK_MS);
729
+ function briefWhen(v, m) {
730
+ var mins = briefMinutes(v.at);
731
+ var out = 'Updated ' + (mins < 1 ? 'just now' : span(v.at) + ' ago');
732
+ // Stale says so once it is five minutes old: a summary from a minute ago over one new reply is still the picture.
733
+ if (v.state === 'stale' && mins >= 5) out += '. Things have moved since';
734
+ if (m.failed.length) out += '. Couldn’t read ' + listWords(m.failed) + ', so this may not be everything';
735
+ return out + '.';
736
+ }
737
+
566
738
  // The headline: COS Control's brief (Models.swift TodayHome.brief) over the same rows Needs you lists.
567
739
  function renderStory(m) {
568
740
  var el = $('today-story');
569
741
  el.className = 'story';
570
742
  if (TODAY.offline) { el.textContent = 'COS on ' + state.serverName + ' is not answering right now. Today fills in when it does.'; return; }
743
+ // The model's summary when there is one (it never blocks: the counts line below shows until it lands). The test
744
+ // harnesses that run this block alone have no requestBrief, so it is looked up, never assumed.
745
+ if (typeof requestBrief === 'function') {
746
+ requestBrief(m);
747
+ var brief = briefShown();
748
+ if (brief) { el.innerHTML = esc(brief.text) + '<span class="story-when">' + esc(briefWhen(brief, m)) + '</span>'; return; }
749
+ }
571
750
  var first = '';
572
751
  if (m.sessionsWaiting > 0) first = countWord(m.sessionsWaiting) + ' ' + plural(m.sessionsWaiting, 'session is', 'sessions are') + ' waiting on you';
573
752
  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 +837,29 @@
658
837
  if (s.waiting_kind === 'question' && d) return d.length > 160 ? d.slice(0, 159) + '…' : d;
659
838
  return 'Waiting for you' + (sxProvider(s.provider) === 'claude' ? '. Paused until you answer.' : '');
660
839
  }
840
+ // 6.99.26: where a waiting session's question can be answered. 'here': this page holds it (panes/questions.js), as
841
+ // cards in its waiting sheet. 'mac': a Claude session's own prompt (or a held one that went back to the Mac), which
842
+ // only the Mac can answer, in person or through Screens. 'answered': answered on some screen in the last two minutes,
843
+ // the list not caught up yet. '': a reply answers it (a Codex or Cursor session).
844
+ function sessionQs() { var q = window.COSWeb && window.COSWeb.sessionQuestions; return q && q.itemsFor ? q : null; }
845
+ function askedHere(items) {
846
+ var it = items[0];
847
+ if (!it) return '';
848
+ if (it.kind === 'approval') return it.approval && it.approval.summary ? 'Wants to run ' + clipWords(it.approval.summary, 80) : '';
849
+ var q = (it.questions || [])[0];
850
+ return q && q.question ? clipWords(q.question, 160) : '';
851
+ }
852
+ function clipWords(text, n) { var t = String(text || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; }
853
+ function whereFor(provider, id) {
854
+ var q = sessionQs();
855
+ var held = q ? q.itemsFor(id) : [], mac = q && q.atMacFor ? q.atMacFor(id) : [];
856
+ if (held.length) return { where: 'here', ask: askedHere(held) };
857
+ if (q && q.answeredFor && q.answeredFor(id)) return { where: 'answered', ask: '' };
858
+ var words = mac.length && q.wordsOf ? q.wordsOf(mac[0].id) : null;
859
+ var ask = words ? clipWords(words.questions[0] || (words.summary ? 'Wants to run ' + words.summary : ''), 160) : '';
860
+ if (mac.length || sxProvider(provider) === 'claude') return { where: 'mac', ask: ask };
861
+ return { where: '', ask: '' };
862
+ }
661
863
  function needsModel() {
662
864
  var states = { sessions: TODAY.sessionsState, work: TODAY.workState, voices: TODAY.voices.state, memories: TODAY.memories.state };
663
865
  var rows = [], seen = {};
@@ -665,7 +867,19 @@
665
867
  if (sxKeepWarm(s) || sxState(s) !== 'waiting') return;
666
868
  var k = splitSessionId(s.provider, s.session_id || s.id);
667
869
  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 });
870
+ var w = whereFor(k.provider, k.id);
871
+ 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 });
872
+ });
873
+ // A question this page holds for a session the list has not shown waiting yet: its own row, so every held
874
+ // question is in front of you (two at once each get one).
875
+ var qs = sessionQs();
876
+ (qs ? qs.itemsFor('') : []).forEach(function (it) {
877
+ var k = splitSessionId(it.provider, it.sessionId), sk = k.provider + '|' + String(k.id).toLowerCase();
878
+ if (!k.id || seen[sk]) return;
879
+ seen[sk] = 1;
880
+ 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];
881
+ var title = s ? askedTitle(s) : it.kind === 'approval' ? 'A session wants your permission' : 'A session asked you a question';
882
+ 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
883
  });
670
884
  (TODAY.needs || []).forEach(function (it) {
671
885
  var k = splitSessionId(it.provider, it.sessionId);
@@ -721,9 +935,11 @@
721
935
  (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
936
  }
723
937
  if (r.kind === 'session') {
724
- // The session's question shows in the overlay's peek, with its cards to answer.
938
+ // The session's question shows in the overlay's peek, with its cards to answer. Under it, where it can be answered.
939
+ var q = sessionQs(), whereLine = r.where === 'here' ? 'Answer it here.' : r.where === 'mac' ? (q && q.macLine) || '' : r.where === 'answered' ? (q && q.answeredLine) || '' : '';
725
940
  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>' +
941
+ '<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>' +
942
+ (whereLine ? '<span class="need-foot need-where" data-where="' + esc(r.where) + '">' + esc(whereLine) + '</span>' : '') + '</button>' +
727
943
  '<div class="need-actions"><button type="button" class="btn small' + (i === 0 ? ' primary' : '') + '" data-session="' + esc(r.key) + '">Open session</button></div></div>';
728
944
  }
729
945
  var go2 = r.kind === 'voices' ? 'speakers' : 'memories/view/review', label = r.kind === 'voices' ? 'Name' : 'Review';
@@ -800,8 +1016,8 @@
800
1016
  }).join('') + '<p class="intake-foot">Moves to Work at midnight.</p>';
801
1017
  card.classList.remove('hidden');
802
1018
  }
803
- function openTodayAction(t) {
804
- var kind = t.getAttribute('data-today'), key = t.getAttribute('data-key') || '';
1019
+ function openTodayAction(t, kindIn, keyIn) {
1020
+ var kind = kindIn || t.getAttribute('data-today'), key = keyIn != null ? keyIn : t.getAttribute('data-key') || '';
805
1021
  ensurePane('todayActions', 4000).then(function (m) {
806
1022
  if (!m || !m.open) throw new Error('The Today overlay did not start.');
807
1023
  m.open(kind, key, t);
@@ -811,9 +1027,9 @@
811
1027
  // history entry, which the fallback replaces, so Back from there still lands on Today.
812
1028
  var s = String(kind === 'run' || kind === 'wait' ? key : t.getAttribute('data-session-key') || '').split('|');
813
1029
  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']);
1030
+ var dest = session ? ['sessions', s[0], s.slice(1).join('|')] : kind === 'pet' ? ['sessions'] : ['work'].concat(kind === 'need' ? [] : ['sort']);
815
1031
  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.');
1032
+ 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
1033
  if (history.state && history.state.cosTodayOverlay) location.replace('#/' + dest.map(encodeURIComponent).join('/'));
818
1034
  else go(dest[0], dest.slice(1));
819
1035
  });
@@ -1358,7 +1574,11 @@
1358
1574
  function writeStore(key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch (_e) {} }
1359
1575
  function saveHeld(r) {
1360
1576
  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 }; });
1577
+ var keep = r.held.filter(function (h) { return !h.cancelled; }).map(function (h) {
1578
+ var o = { text: h.text, key: h.key, since: h.since };
1579
+ if (h.ids && h.ids.length) { o.ids = h.ids; if (h.files && h.files.length) o.files = h.files; }
1580
+ return o;
1581
+ });
1362
1582
  if (keep.length) all[k] = keep; else delete all[k];
1363
1583
  writeStore(HELD_KEY, all);
1364
1584
  }
@@ -1393,6 +1613,7 @@
1393
1613
  function openSession(provider, id) {
1394
1614
  var reader = $('session-reader');
1395
1615
  if (!provider || !id) return;
1616
+ readerImagesStop($('session-turns'));
1396
1617
  if (state.stream) { state.stream.close(); state.stream = null; }
1397
1618
  clearInterval(state.turnPoll);
1398
1619
  if (state.reading) { clearInterval(state.reading.heldTimer); clearInterval(state.reading.feedTimer); }
@@ -1422,15 +1643,21 @@
1422
1643
  '<p class="sx-copy-note hidden" id="session-copy-note" role="status"></p>' +
1423
1644
  '<div class="sx-chat">' +
1424
1645
  '<div id="session-verdict"></div>' +
1425
- '<form class="composer" id="session-form" data-provider="' + esc(provider) + '" data-id="' + esc(id) + '">' +
1646
+ '<form class="composer att-composer" id="session-form" data-provider="' + esc(provider) + '" data-id="' + esc(id) + '">' +
1647
+ '<div class="cc-chips att-files hidden" id="session-files"></div>' +
1426
1648
  '<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>' +
1649
+ '<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>' +
1650
+ '<button type="submit" class="btn primary small" id="session-send">Send</button></div>' +
1429
1651
  '</form><div class="status-line" id="session-status"></div>' +
1430
1652
  '<p class="sx-caution hidden" id="session-caution"></p>' +
1431
1653
  '<div id="session-queue"></div>' +
1432
1654
  '</div>';
1433
- setLive({ working: !!d.running_active });
1655
+ readerImagesMount($('session-turns'), r);
1656
+ sxNoteList(r, d);
1657
+ setLive({});
1658
+ paintReaderFiles();
1659
+ // The Mac's answer about files may not be in yet: ask once, then draw the clip.
1660
+ var kitR = readerKit(); if (kitR && !kitR.turnFiles()) kitR.ensureCaps().then(paintReaderFiles, function () {});
1434
1661
  renderHeld(state.reading);
1435
1662
  loadQueue(provider, id);
1436
1663
  sxProbeAttach(r);
@@ -1449,20 +1676,30 @@
1449
1676
  // ACTIVITY (ActivityWindow.swift SessionActivityFeed 6800-6890, SessionLiveFeed.swift): the state
1450
1677
  // word with its clock, the current prompt, the last eight tool lines, and the honest counters.
1451
1678
  // 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 }; }
1679
+ 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
1680
  function applyFeed(r, e) {
1454
1681
  if (typeof e.seq !== 'number') return;
1455
1682
  var f = r.feed;
1456
- if (e.seq === 1 && f.lastSeq > 0) { var keep = f.connected; f = r.feed = newFeed(); f.connected = keep; }
1683
+ 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
1684
  else if (f.lastSeq > 0 && e.seq > f.lastSeq + 1) f.missed += e.seq - f.lastSeq - 1;
1458
1685
  f.lastSeq = e.seq;
1459
1686
  f.lastEventAt = typeof e.at === 'number' ? new Date(e.at) : new Date();
1460
1687
  f.connected = true;
1461
1688
  if (f.fallback === 'reconnecting') f.fallback = null;
1462
1689
  if (e.kind === 'status') {
1463
- if (e.state) f.transport = e.state;
1690
+ f.statusAt = f.lastEventAt;
1691
+ // When this page first saw each state (its own clock): the Stop's echo window is measured by it. The
1692
+ // memo outlives a reseed, so a reconnect that repeats a state seen before a Stop is not news.
1693
+ var memo = f.seenMemo || (f.seenMemo = {});
1694
+ if (e.state) {
1695
+ if (!memo.stream || memo.stream.key !== e.state) memo.stream = { key: e.state, seen: Date.now() };
1696
+ f.transport = e.state; f.transportSeenAt = memo.stream.seen;
1697
+ }
1464
1698
  if (e.agent_state) {
1465
- f.agentState = e.agent_state; f.stateSince = toDate(e.state_since);
1699
+ var since = toDate(e.state_since), hookKey = e.agent_state + '|' + sxMs(since);
1700
+ if (!memo.hook || memo.hook.key !== hookKey) memo.hook = { key: hookKey, seen: Date.now() };
1701
+ f.stateSeenAt = memo.hook.seen;
1702
+ f.agentState = e.agent_state; f.stateSince = since;
1466
1703
  f.waitingKind = e.waiting_kind || null; f.waitingDetail = e.waiting_detail || null; f.failure = e.failure || null;
1467
1704
  if (e.last_reply) f.lastReply = e.last_reply;
1468
1705
  }
@@ -1476,16 +1713,105 @@
1476
1713
  // `other` is the stream's verb for every tool it has no verb for; its target names the tool ("Used SendMessage").
1477
1714
  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
1715
  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.
1716
+ // ONE STATE FOR THE SESSION CARD (6.99.29). Miles, 11/07 11:01: Today's session overlay showed
1717
+ // "Idle 40s" over "Working on: Yes install and proceed", beside "Working right now" and "The
1718
+ // session is busy" while the session ran its turn. Each piece read its own source: the badge the
1719
+ // hooks' derived state (which a turn delivered through the peer inbox does not move), the composer
1720
+ // the server's write gate, the send note the park answer, and a delivered live turn was read as a
1721
+ // finished one. Now every piece of the card (the badge word and clock, the prompt line's label,
1722
+ // Stop, the composer's park note and the reader's state line) reads sxTruth, and each signal says
1723
+ // what it saw and WHEN that became true; the freshest wins:
1724
+ // hook the stream's derived agent_state, since its state_since
1725
+ // stream the stream's own state, when no derived state rides it, as of that status line
1726
+ // list the session row or detail: running_active as of its last activity, else since state_since
1727
+ // gate the server's write gate: native_thread_working on the attachability probe, a refused
1728
+ // turn or a parked send, or a turn delivered live into the open session, as of the answer;
1729
+ // a later free answer, or a COS child's turn that finished, clears it
1730
+ // stop this page's Stop (and a finished COS child turn): idle as of then, and no working signal
1731
+ // that ARRIVED in the next SX_STOP_ECHO_MS counts (the stream's status trails a Stop). The
1732
+ // window is the page's own clock (`seen`, when this page observed each signal), never the
1733
+ // signal's server time, and a turn this page sends ends it (continueSession clears stoppedAt).
1734
+ // Waiting, failed and ended, from the hook or the session row, are the session's own words and stay:
1735
+ // a gate or stream that sees a busy file does not unseat a question (QA 6.99.29: the row and the
1736
+ // hook carry the same state_since, so the row's Waiting must hold as the hook's does). Signals with
1737
+ // the same time are ordered gate, stream, list, hook, stop: the hook's word wins a tie. Idle yields
1738
+ // only to working seen more than SX_IDLE_GRACE_MS after it, the server's rule for the flush that
1739
+ // trails a Stop (TRANSCRIPT_AFTER_IDLE_GRACE_MS).
1740
+ var SX_IDLE_GRACE_MS = 5000, SX_STOP_ECHO_MS = 8000, SX_GATE_RECHECK_MS = 20000;
1741
+ var SX_TIE_RANK = { gate: 0, stream: 1, list: 2, hook: 3, stop: 4 };
1742
+ function sxMs(v) {
1743
+ var t = v instanceof Date ? v.getTime() : typeof v === 'number' ? v : typeof v === 'string' && v ? Date.parse(v) : NaN;
1744
+ return isFinite(t) ? t : null;
1745
+ }
1746
+ var SX_HOOK_WORD = { running: 'Working', waiting: 'Waiting', failed: 'Failed', ended: 'Done', idle: 'Idle' };
1747
+ function sxSignals(r) {
1748
+ var f = r.feed || {}, out = [];
1749
+ // `seen`: when this page observed the signal (its own clock), for the Stop's echo window.
1750
+ 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) });
1751
+ 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) });
1752
+ if (r.list && r.list.at != null) out.push({ word: r.list.word, at: r.list.at, src: 'list', seen: sxMs(r.list.seen) });
1753
+ if (r.gate && r.gate.busy && r.gate.at != null) out.push({ word: 'Working', at: r.gate.at, src: 'gate', seen: r.gate.at });
1754
+ var stop = sxMs(r.stoppedAt);
1755
+ if (stop !== null) out.push({ word: 'Idle', at: stop, src: 'stop', seen: stop });
1756
+ return out;
1757
+ }
1758
+ /** { word, at, src }: the one state the whole card shows. Null before any signal. */
1759
+ function sxTruth(r) {
1760
+ var stop = sxMs(r.stoppedAt);
1761
+ var all = sxSignals(r).sort(function (a, b) { return a.at - b.at || SX_TIE_RANK[a.src] - SX_TIE_RANK[b.src]; });
1762
+ var best = null;
1763
+ all.forEach(function (c) {
1764
+ if (!best) { best = c; return; }
1765
+ if (c.word === 'Working') {
1766
+ // The Stop's echo, judged by when this page saw the signal (a signal with no arrival time: by its own).
1767
+ var seen = c.seen != null ? c.seen : c.at;
1768
+ if (stop !== null && seen >= stop && seen - stop < SX_STOP_ECHO_MS) return;
1769
+ if (stop !== null && seen < stop) return;
1770
+ if ((best.src === 'hook' || best.src === 'list') && (c.src === 'gate' || c.src === 'stream') && /^(Waiting|Failed|Done)$/.test(best.word)) return;
1771
+ if (best.word === 'Idle' && best.src !== 'stop' && c.src !== best.src && c.at <= best.at + SX_IDLE_GRACE_MS) return;
1772
+ }
1773
+ best = c;
1774
+ });
1775
+ return best;
1776
+ }
1777
+ /** Settle the page's working flag from the one state; true when it changed. */
1778
+ function sxSettle(r) {
1779
+ var t = sxTruth(r), working = !!(t && t.word === 'Working');
1780
+ r.live = r.live || {};
1781
+ var was = !!r.live.working;
1782
+ r.live.working = working;
1783
+ r.live.waiting = !!(t && t.word === 'Waiting');
1784
+ if (!working) { r.live.step = null; r.live.prose = null; }
1785
+ return was !== working;
1786
+ }
1787
+ /** What the server's write gate said, and when. A free answer clears an earlier busy one. */
1788
+ function sxNoteGate(r, busy) { r.gate = { busy: !!busy, at: Date.now() }; }
1789
+ /** The list or detail row's view, timed by its own evidence. */
1790
+ function sxNoteList(r, row) {
1791
+ if (!row) return;
1792
+ var waiting = sxState(row) === 'waiting', working = !!row.running_active && !waiting;
1793
+ var at = working ? (sxMs(row.last_activity_at) || sxMs(row.state_since)) : (sxMs(row.state_since) || sxMs(row.last_activity_at));
1794
+ // A row with no time of its own is the oldest word: the live feed outranks it the moment it speaks.
1795
+ var word = working ? 'Working' : waiting ? 'Waiting' : 'Idle', was = r.list;
1796
+ // Arrival is the first time this page saw THIS word at THIS time: a re-read of the same row is not news.
1797
+ 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() };
1798
+ }
1799
+ /** A gate busy answer that decides the card is re-asked after a while, so it cannot stand forever. Every
1800
+ * attempt is stamped (gate.triedAt), and each failed one doubles the wait, up to 16 times (QA 6.99.29: a failing
1801
+ * probe was re-asked every second). `at` stays the time of the last ANSWER: a failure is no evidence of busy. */
1802
+ function sxGateStale(r) {
1803
+ var t = sxTruth(r);
1804
+ if (!(t && t.src === 'gate' && r.gate) || r.probing) return false;
1805
+ var last = Math.max(r.gate.at || 0, r.gate.triedAt || 0), fails = r.gate.failures || 0;
1806
+ return Date.now() - last > SX_GATE_RECHECK_MS * Math.min(Math.pow(2, fails), 16);
1807
+ }
1481
1808
  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';
1809
+ var t = sxTruth(r);
1810
+ return t ? t.word : 'Idle';
1811
+ }
1812
+ /** The prompt line: what the session works on, or what it was last asked. */
1813
+ function sxPromptLine(r, text) {
1814
+ return (sxFeedWord(r) === 'Working' ? 'Working on: ' : 'Last asked: ') + text;
1489
1815
  }
1490
1816
  function sxFeedDetail(f, word) {
1491
1817
  if (word === 'Waiting' && f.agentState === 'waiting') {
@@ -1500,8 +1826,9 @@
1500
1826
  if (word === 'Idle' && f.agentState === 'idle') { var reply = String(f.lastReply || '').trim(); return reply ? 'last reply: ' + reply : ''; }
1501
1827
  return '';
1502
1828
  }
1503
- function sxElapsed(f) {
1504
- var since = f.stateSince || f.lastEventAt;
1829
+ function sxElapsed(f, r) {
1830
+ var t = r ? sxTruth(r) : null;
1831
+ var since = t && t.at ? new Date(t.at) : f.stateSince || f.lastEventAt;
1505
1832
  if (!since) return '';
1506
1833
  var s = Math.max(0, Math.floor((Date.now() - since.getTime()) / 1000));
1507
1834
  if (s < 60) return s + 's';
@@ -1533,7 +1860,7 @@
1533
1860
  var word = sxFeedWord(r), detail = sxFeedDetail(f, word);
1534
1861
  html += '<div class="sx-act-state"><span class="sx-dot ' + word.toLowerCase() + '" aria-hidden="true"></span><strong>' + word + '</strong>' +
1535
1862
  '<span class="sx-act-detail">' + esc(detail) + '</span>' +
1536
- '<span class="sx-act-clock" id="session-elapsed">' + esc(sxElapsed(f)) + '</span>' +
1863
+ '<span class="sx-act-clock" id="session-elapsed">' + esc(sxElapsed(f, r)) + '</span>' +
1537
1864
  (live.working ? '<button type="button" class="cx-btn quiet sx-act-stop" data-action="stop-session">Stop</button>' : '') + '</div>' +
1538
1865
  '<p class="sx-act-prompt">' + esc(f.prompt) + '</p>' +
1539
1866
  '<div class="sx-act-tail">' + f.tools.map(function (t) { return '<div class="sx-act-tool">' + esc(sxToolLine(t)) + '</div>'; }).join('') +
@@ -1542,13 +1869,15 @@
1542
1869
  html += '<p class="sx-act-fallback">' + esc(f.fallback ? sxFallbackLine(f.fallback) : 'Connecting to the live feed…') + '</p>';
1543
1870
  }
1544
1871
  box.innerHTML = html;
1545
- // The clock ticks only while the session works or waits (Control 0.5.337).
1872
+ // The clock ticks only while the session works or waits (Control 0.5.337). A Working that only the write gate
1873
+ // says is re-asked even with no live feed (QA 6.99.29), so it cannot stand forever once the feed is lost.
1546
1874
  clearInterval(r.feedTimer); r.feedTimer = null;
1547
- var w = sxFeedWord(r);
1548
- if ((f.connected || f.lastSeq > 0) && (w === 'Working' || w === 'Waiting')) {
1875
+ var t = sxTruth(r), w = t ? t.word : 'Idle';
1876
+ if (((f.connected || f.lastSeq > 0) && (w === 'Working' || w === 'Waiting')) || (t && t.src === 'gate' && w === 'Working')) {
1549
1877
  r.feedTimer = setInterval(function () {
1550
1878
  if (state.reading !== r) { clearInterval(r.feedTimer); return; }
1551
- var el = $('session-elapsed'); if (el) el.textContent = sxElapsed(r.feed);
1879
+ var el = $('session-elapsed'); if (el) el.textContent = sxElapsed(r.feed, r);
1880
+ if (sxGateStale(r)) sxProbeAttach(r);
1552
1881
  }, 1000);
1553
1882
  }
1554
1883
  }
@@ -1571,7 +1900,7 @@
1571
1900
  var es;
1572
1901
  try { es = new EventSource(sessionPath(provider, id, '/stream?seed=turn')); } catch (_e) { if (r0) { r0.feed.fallback = 'unsupported'; paintActivity(r0); } return; }
1573
1902
  state.stream = es;
1574
- var wasWorking = false;
1903
+ var wasWorking = false, wasWaiting = false;
1575
1904
  es.onerror = function () {
1576
1905
  var r = state.reading;
1577
1906
  if (!r || r.id !== id || state.stream !== es) return;
@@ -1586,15 +1915,17 @@
1586
1915
  if (!r || r.id !== id) return;
1587
1916
  applyFeed(r, e);
1588
1917
  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';
1918
+ // 6.99.29: the one state (sxTruth), never this line's own reading of itself.
1919
+ setLive({});
1920
+ var working = !!(r.live && r.live.working);
1921
+ var waiting = !working && (e.state === 'waiting' || e.state === 'needs_input' || e.agent_state === 'waiting');
1593
1922
  var el = $('session-state');
1594
1923
  if (el) el.textContent = working ? 'Working now' : waiting ? 'Waiting for you' : 'Open';
1595
- setLive({ working: working });
1596
1924
  if (wasWorking && !working) { refreshTurns(provider, id); replyEnded(r); }
1597
1925
  wasWorking = working;
1926
+ // Gone to waiting: its question may be parked for this page now (6.99.27).
1927
+ if (waiting && !working && !wasWaiting) questionsNudge();
1928
+ wasWaiting = waiting && !working;
1598
1929
  } else if (e.kind === 'tool') {
1599
1930
  setLive({ step: [e.verb, e.target].filter(Boolean).join(' ') });
1600
1931
  } else if (e.kind === 'prose' && e.text) {
@@ -1606,16 +1937,20 @@
1606
1937
  }
1607
1938
  function setLive(patch) {
1608
1939
  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; }
1940
+ // The working flag is never set here: it is the one state's (sxSettle).
1941
+ var p = Object.assign({}, patch); delete p.working;
1942
+ r.live = Object.assign(r.live || {}, p);
1943
+ var moved = sxSettle(r);
1611
1944
  paintActivity(r);
1945
+ if (moved) renderVerdict(r);
1612
1946
  }
1613
1947
  function refreshTurns(provider, id) {
1948
+ var reading = state.reading;
1614
1949
  api(sessionPath(provider, id, '?turns=' + SX_TURNS)).then(function (d) {
1615
1950
  var r = state.reading;
1616
- if (!r || r.id !== id) return;
1951
+ if (!r || r !== reading || r.id !== id || r.provider !== provider) return;
1617
1952
  var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : [];
1618
- var box = $('session-turns'); if (box) sxMergeTurns(box, r.turns || [], turns, d);
1953
+ var box = $('session-turns'); if (box) { sxMergeTurns(box, r.turns || [], turns, d); readerImagesMount(box, r); }
1619
1954
  // The header keeps the subtitle it opened with: a rewrap above the turns would move them.
1620
1955
  r.detail = d; r.turns = turns; r.more = d.recent_turns_more === true;
1621
1956
  renderHeld(r);
@@ -1638,13 +1973,20 @@
1638
1973
  var box = $('session-queue'); if (!box || !r || r.id !== id) return;
1639
1974
  var rows = (q.status === 200 && q.body && q.body.turns) || [];
1640
1975
  var waiting = rows.filter(function (t) { return t.status === 'waiting' || t.status === 'queued' || t.status === 'pending'; });
1976
+ // QA 6.99.29: a queued message the server refused (or let expire) stays in view with the server's reason,
1977
+ // instead of vanishing from the line.
1978
+ var notSent = rows.filter(function (t) { return t.status === 'refused' || t.status === 'expired'; });
1641
1979
  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>' +
1980
+ if (!waiting.length && !notSent.length) { box.innerHTML = ''; return; }
1981
+ 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
1982
  waiting.map(function (t) {
1645
1983
  var mine = t.owner !== 'codex' && parkedByMe(r, t.clientTurnId);
1646
1984
  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
1985
  (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>';
1986
+ }).join('') +
1987
+ notSent.map(function (t) {
1988
+ 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') +
1989
+ '<span class="sx-queue-reason">' + esc(t.reasonCopy || (t.status === 'expired' ? 'Not sent: the thread stayed busy too long.' : 'Not sent: the session refused it.')) + '</span></span></div>';
1648
1990
  }).join('') + '</div>';
1649
1991
  }).catch(function () {});
1650
1992
  }
@@ -1654,21 +1996,33 @@
1654
1996
  // and anything else is the server's refusal, word for word.
1655
1997
  function sxProbeAttach(r) {
1656
1998
  // Today's overlay probes its own session object (r.view): the same verdict, painted by its view.
1999
+ r.probing = true;
2000
+ // Every attempt is stamped; a failed one backs the next off (sxGateStale). A gate answer resets both.
2001
+ if (r.gate) r.gate.triedAt = Date.now();
2002
+ var failed = function () { if (r.gate) r.gate.failures = (r.gate.failures || 0) + 1; };
1657
2003
  return apiAnswer(sessionPath(r.provider, r.id, '/attachability')).then(function (a) {
1658
2004
  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; }
2005
+ 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
2006
  var b = a.body || {};
1661
- if (a.status !== 200 || typeof b.attachable !== 'boolean') return;
2007
+ if (a.status !== 200 || typeof b.attachable !== 'boolean') { failed(); return; }
1662
2008
  var owners = Number(b.ownerCount) || 0;
1663
2009
  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 () {});
2010
+ // 6.99.29: the gate's answer is a signal of the one state, as of now.
2011
+ sxNoteGate(r, b.attachable === false && b.reason === 'native_thread_working');
2012
+ sxRepaint(r);
2013
+ }).catch(failed).finally(function () { r.probing = false; });
1666
2014
  }
1667
2015
  var SX_PARK = {
1668
2016
  cursor: 'Working right now. A message you send goes into this Cursor chat when its turn ends.',
1669
2017
  codex: 'Working right now. A message you send joins the Codex app’s queue for this thread.',
1670
2018
  claude: 'Working right now. A message you send waits and lands when this turn ends.'
1671
2019
  };
2020
+ /** The composer's "Working right now" note: a busy gate verdict, and the one state agrees. */
2021
+ function sxParkNoteShows(r) {
2022
+ var v = r.verdict;
2023
+ if (!v || v.attachable !== false || !/^(native_thread_working|native_target_busy)$/.test(v.reason)) return false;
2024
+ return sxFeedWord(r) === 'Working' || v.reason === 'native_target_busy';
2025
+ }
1672
2026
  function sxSupplement(reason) {
1673
2027
  if (reason === 'native_target_busy') return 'It frees itself within 30 minutes. Detaching is not available in Control yet.';
1674
2028
  if (reason === 'native_target_fenced') return 'Fences can be released from the Fences card in this panel.';
@@ -1694,7 +2048,8 @@
1694
2048
  (f.retry ? '<button type="button" class="cx-btn quiet" data-sx="retry">Retry</button>' : '') +
1695
2049
  (f.fork ? forkBtn : ''));
1696
2050
  } 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>';
2051
+ // 6.99.29: "Working right now" only while the one state says so.
2052
+ 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
2053
  } else if (v && v.attachable === false && v.reasonCopy) {
1699
2054
  html = refusalBox(v.reasonCopy, sxSupplement(v.reason), /fork/i.test(v.reasonCopy) ? forkBtn : '');
1700
2055
  }
@@ -1707,14 +2062,23 @@
1707
2062
  }
1708
2063
  }
1709
2064
 
2065
+ /** Repaint whichever card shows this session after its one state moved (the reader or a view). */
2066
+ function sxRepaint(r) {
2067
+ sxSettle(r);
2068
+ if (r.view) { if (typeof r.view.stateChanged === 'function') r.view.stateChanged(r); else r.view.verdict(r); return; }
2069
+ if (state.reading === r) { paintActivity(r); renderVerdict(r); }
2070
+ }
1710
2071
  function sessionStatus(text, kind, actionHtml) {
1711
2072
  var el = $('session-status'); if (!el) return;
1712
2073
  el.className = 'status-line' + (kind === 'error' ? ' error' : '');
1713
2074
  el.innerHTML = esc(text || '') + (actionHtml || '');
1714
2075
  }
1715
- function draftFor(text, r) {
2076
+ // 11/07: the files sent with the words (media ids) are part of the draft: other files are another message, with a
2077
+ // key of its own, so the server never answers a new message with an old one's replay.
2078
+ function draftFor(text, r, ids) {
1716
2079
  r = r || state.reading;
1717
- if (!r.draft || r.draft.text !== text) r.draft = { text: text, key: newTurnKey() };
2080
+ var files = Array.isArray(ids) ? ids.slice() : [];
2081
+ if (!r.draft || r.draft.text !== text || (r.draft.ids || []).join(',') !== files.join(',')) r.draft = { text: text, key: newTurnKey(), ids: files };
1718
2082
  return r.draft;
1719
2083
  }
1720
2084
  // The box is cleared only of the very words that were sent; anything typed since stays.
@@ -1723,11 +2087,12 @@
1723
2087
  if (input && input.value.trim() === String(text).trim()) { input.value = ''; autoGrow(input); }
1724
2088
  if (state.reading && state.reading.draft && state.reading.draft.text === text) state.reading.draft = null;
1725
2089
  }
1726
- // Words go back in the box only if the box is empty; otherwise the status line keeps them.
1727
- function returnToBox(text, why) {
2090
+ // Words go back in the box only if the box is empty; otherwise the status line keeps them. `files` says what became
2091
+ // of the message's files (heldFilesBack).
2092
+ function returnToBox(text, why, files) {
1728
2093
  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');
2094
+ if (input && !input.value.trim()) { input.value = text; autoGrow(input); sessionStatus(why + ' Your message is back in the box.' + (files || ''), 'error'); }
2095
+ else sessionStatus(why + ' Not sent: “' + String(text).slice(0, 80) + (text.length > 80 ? '…' : '') + '”' + (files || ''), 'error');
1731
2096
  }
1732
2097
  // Your words show in the conversation the moment the Mac accepts them; the next refresh of
1733
2098
  // the transcript replaces the bubble with the real turn.
@@ -1761,11 +2126,96 @@
1761
2126
  };
1762
2127
  function viewOf(r) { return (r && r.view) || READER_VIEW; }
1763
2128
 
2129
+ // ── files with a message to a session (6.99.27, Miles: "We still don't have the ability to take picture, or add photo
2130
+ // or files from the web") ──
2131
+ // The COS chat's attach kit (panes/cos-chat.js, W.cosChat.attach), as on Today's reply box: Take photo and the clip,
2132
+ // 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
2133
+ // with the words; the server writes where each file is on the Mac under them. One box per session, kept in this tab.
2134
+ var READER_FILES = {};
2135
+ function readerKit() { var c = window.COSWeb && window.COSWeb.cosChat; return c && c.attach ? c.attach : null; }
2136
+ function readerFilesOn() { var k = readerKit(); return !!(k && k.turnFiles() > 0 && state.reading); }
2137
+ function readerBox(r) {
2138
+ var kit = readerKit(); if (!kit || !r) return null;
2139
+ var key = r.provider + '|' + r.id;
2140
+ if (!READER_FILES[key]) READER_FILES[key] = kit.box({
2141
+ changed: function (box) { if (state.reading && READER_FILES[state.reading.provider + '|' + state.reading.id] === box) paintReaderFiles(); },
2142
+ removed: function () { sessionStatus(''); },
2143
+ signedOut: function () { sessionStatus('This browser was signed out. Your message and files are kept. Sign in, then tap Retry on each file.', 'error'); }
2144
+ }, { anyBytes: kit.turnFileBytes() });
2145
+ if (!READER_FILES[key].anyBytes && !READER_FILES[key].atts.length && kit.turnFileBytes()) READER_FILES[key].anyBytes = kit.turnFileBytes();
2146
+ return READER_FILES[key];
2147
+ }
2148
+ function readerFilesState(box) {
2149
+ var kit = readerKit();
2150
+ if (!kit || !box || !box.atts.length) return 'none';
2151
+ return kit.pending(box) ? 'waiting' : kit.failed(box) ? 'failed' : 'ready';
2152
+ }
2153
+ function paintReaderFiles() {
2154
+ var kit = readerKit(), r = state.reading, slot = $('session-clip'), list = $('session-files');
2155
+ if (!slot || !list) return;
2156
+ if (!readerFilesOn()) { slot.innerHTML = ''; list.innerHTML = ''; list.classList.add('hidden'); return; }
2157
+ var box = readerBox(r), full = box.atts.length >= kit.max, mode = String(box.anyBytes || 0);
2158
+ 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); }
2159
+ Array.prototype.forEach.call(slot.querySelectorAll('.cc-attach'), function (l) { l.classList.toggle('full', full); });
2160
+ Array.prototype.forEach.call(slot.querySelectorAll('[data-sx-file]'), function (i) { i.disabled = full || !!r.sending; });
2161
+ list.innerHTML = kit.chipsHtml(box, 'data-action') + (box.atts.length ? '<p class="att-limit">' + esc(kit.limitLine(box)) + '</p>' : '');
2162
+ list.classList.toggle('hidden', !box.atts.length);
2163
+ // Send and Fork wait while a file uploads. A failed file never greys them out (QA 6.99.27): a tap says why.
2164
+ var st = readerFilesState(box), form = $('session-form');
2165
+ if (!form || r.sending) return;
2166
+ var send = $('session-send'), forkBtn = form.querySelector('[data-action="fork-session"]');
2167
+ if (send) { send.disabled = st === 'waiting'; send.textContent = st === 'waiting' ? 'Uploading' : 'Send'; }
2168
+ if (forkBtn) forkBtn.disabled = st === 'waiting';
2169
+ }
2170
+ function addReaderFiles(list) {
2171
+ var kit = readerKit(), r = state.reading; if (!kit || !r || !readerFilesOn() || !list || !list.length) return;
2172
+ var refusals = kit.add(readerBox(r), list);
2173
+ if (refusals.length) sessionStatus(refusals.join(' '), 'error');
2174
+ paintReaderFiles();
2175
+ }
2176
+ // The files that went with a message leave the box; any added since stay. Taken, not removed: the status line stays
2177
+ // (a held message's Don't send is on it). Answers what went, for a held message to keep.
2178
+ function readerFilesSent(box, ids) {
2179
+ var kit = readerKit(); if (!kit || !box || !ids || !ids.length) return [];
2180
+ return kit.take(box, ids);
2181
+ }
2182
+ // QA 6.99.27: a held message keeps what its files were, so if it does not go, it comes back whole (heldFilesBack).
2183
+ function keepHeldFiles(r, text, ids, went) {
2184
+ if (!went || !went.length) return;
2185
+ r.held.forEach(function (h) { if (!h.files && h.text === text && (h.ids || []).join(',') === ids.join(',')) h.files = went; });
2186
+ saveHeld(r);
2187
+ }
2188
+ // The Mac keeps a file no turn took for 4 hours (lib/turn-file-store.ts, and the media store's own rule); this
2189
+ // trusts a file for 5 minutes less. Past that it says the file has gone rather than offer one the send would refuse.
2190
+ var HELD_FILE_KEEP_MS = 4 * 60 * 60000 - 5 * 60000;
2191
+ function heldFilesBack(r, h) {
2192
+ var ids = h.ids || []; if (!ids.length) return '';
2193
+ var kit = readerKit(), box = readerFilesOn() ? readerBox(r) : null, now = Date.now();
2194
+ var known = (h.files || []).filter(function (f) { return f && ids.indexOf(f.mediaId) >= 0; });
2195
+ var kept = known.filter(function (f) { return f.keptAt > 0 && now - f.keptAt < HELD_FILE_KEEP_MS; });
2196
+ var back = box && kept.length ? kit.restore(box, kept) : 0;
2197
+ var lost = ids.length - back, expired = known.length - kept.length;
2198
+ var said = !back ? '' : back === ids.length ? (back === 1 ? ' Its file is attached again.' : ' Its ' + back + ' files are attached again.')
2199
+ : ' ' + back + ' of its files ' + (back === 1 ? 'is' : 'are') + ' attached again.';
2200
+ if (!lost) return said;
2201
+ var gone = lost === expired
2202
+ ? (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.')
2203
+ : (lost === 1 ? ' A file it carried did not come back.' : ' ' + lost + ' files it carried did not come back.');
2204
+ return said + gone + ' Attach ' + (lost === 1 ? 'it' : 'them') + ' again.';
2205
+ }
2206
+
1764
2207
  function sendSessionTurn(fork, opts) {
1765
2208
  var r = state.reading; var form = $('session-form');
1766
2209
  if (!r || !form) return;
1767
2210
  var input = $('session-input'), text = input.value.trim();
1768
2211
  if (!text) { sessionStatus(fork ? 'Write what the copy should do first.' : 'Write a message first.', 'error'); return; }
2212
+ // 6.99.27: files. Never before every upload has finished, never with one that failed. A fork carries them too: its
2213
+ // first turn is this message, so the copy gets the same files (the fork route writes their paths as a turn does).
2214
+ var fb = readerFilesOn() ? readerBox(r) : null, fst = readerFilesState(fb);
2215
+ if (fst === 'waiting') { sessionStatus('A file is still uploading. ' + (fork ? 'Fork' : 'Send') + ' when it finishes.'); return; }
2216
+ if (fst === 'failed') { sessionStatus('A file did not upload. Retry it or remove it.', 'error'); return; }
2217
+ var fileIds = fst === 'ready' ? readerKit().ids(fb) : [];
2218
+ if (fileIds.length && !fork) opts = Object.assign({}, opts || {}, { attachmentIds: fileIds });
1769
2219
  // Control's caution (SessionChatVerdict.caution): another app holds this session and looks
1770
2220
  // idle, so the first send asks before it writes. A fork writes to a copy and never asks.
1771
2221
  if (!fork && r.verdict && r.verdict.caution && !r.cautionAck) { r.cautionAsk = true; renderVerdict(r); return; }
@@ -1775,8 +2225,12 @@
1775
2225
  if (r.refusal || r.cautionAsk) { r.refusal = null; r.cautionAsk = false; renderVerdict(r); }
1776
2226
  r.sending = true;
1777
2227
  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) {
2228
+ var done = function () { r.sending = false; btns.forEach(function (b) { b.disabled = false; }); paintReaderFiles(); };
2229
+ (fork ? forkSession(r, text, fileIds) : continueSession(r, text, opts || {}).then(function (how) {
2230
+ // The Mac took the message (sent, waiting in line, or held until the reply ends): its files went with it.
2231
+ if (/^(sent|parked|held)$/.test(how)) { var went = readerFilesSent(fb, fileIds); if (how === 'held') keepHeldFiles(r, text, fileIds, went); }
2232
+ return how;
2233
+ })).then(done, function (err) {
1780
2234
  done();
1781
2235
  if (state.reading !== r) return;
1782
2236
  var why = err && err.name === 'AbortError'
@@ -1794,7 +2248,9 @@
1794
2248
  // Resolves to 'sent' | 'parked' | 'held' | 'refused' | 'changed'.
1795
2249
  function continueSession(r, text, opts) {
1796
2250
  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);
2251
+ var draft = opts.held ? { text: opts.held.text, key: opts.held.key, ids: opts.held.ids || [] } : draftFor(text, r, opts.attachmentIds), view = viewOf(r);
2252
+ // Files go only to a server that said it takes them (threadTurnAttachments); the caller shows no clip otherwise.
2253
+ var files = draft.ids && draft.ids.length ? { attachmentIds: draft.ids } : {};
1798
2254
  if (!opts.held) view.status(r, 'Sending');
1799
2255
  var bound = bindingFor(provider, id);
1800
2256
  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 +2279,12 @@
1823
2279
  }
1824
2280
  return 'refused';
1825
2281
  }
2282
+ // QA 6.99.29: a turn this page sends starts new work, so the last Stop (or finished COS turn) no longer
2283
+ // mutes the working signals that follow (a held follow-up goes 800 ms after the end, inside the echo window).
2284
+ r.stoppedAt = null;
1826
2285
  return apiAnswer('/api/agent-sessions/bindings/' + encodeURIComponent(at.binding.bindingId) + '/turns', {
1827
2286
  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 } : {}),
2287
+ body: Object.assign({ prompt: draft.text, epoch: at.binding.epoch, targetKey: targetKey(provider, id), clientTurnId: draft.key }, files, opts.acknowledgedRevision ? { acknowledgedRevision: opts.acknowledgedRevision } : {}),
1829
2288
  }).then(function (t) {
1830
2289
  var b = t.body || {};
1831
2290
  if (t.status >= 200 && t.status < 300 && (b.outcome === 'queued' || b.outcome === 'completed')) {
@@ -1833,6 +2292,8 @@
1833
2292
  else { view.pending(r, draft.text, b.replayed ? 'already sent' : 'sent'); view.clear(r, draft.text); }
1834
2293
  view.status(r, b.replayed ? 'Already sent. Watching for the reply.' : 'Sent. Watching for the reply.');
1835
2294
  view.watch(r, at.binding.bindingId, draft.key);
2295
+ // 6.99.29: a turn that went live is in the open session, which now works on it.
2296
+ if (b.outcome === 'completed' && b.via === 'live') { sxNoteGate(r, true); try { sxRepaint(r); } catch (_e) {} }
1836
2297
  return 'sent';
1837
2298
  }
1838
2299
  var reason = b.reason || '';
@@ -1867,7 +2328,7 @@
1867
2328
  var HELD_MAX_MS = 5 * 60000, HELD_RETRY_MS = 8000;
1868
2329
  function holdUntilReplyEnds(r, draft) {
1869
2330
  if (!r.held.some(function (h) { return h.key === draft.key; })) {
1870
- r.held.push({ text: draft.text, key: draft.key, since: Date.now() });
2331
+ r.held.push({ text: draft.text, key: draft.key, since: Date.now(), ids: draft.ids || [] });
1871
2332
  showPendingTurn(draft.text, 'sends after this reply', draft.key);
1872
2333
  saveHeld(r);
1873
2334
  }
@@ -1903,8 +2364,8 @@
1903
2364
  if (bubble) { bubble.removeAttribute('data-held-key'); bubble.querySelector('.who').textContent = 'You, waiting in line'; }
1904
2365
  } else {
1905
2366
  if (bubble) bubble.remove();
1906
- if (how === 'cancelled') returnToBox(h.text, 'Not sent.');
1907
- else returnToBox(h.text, copy || 'Not sent.');
2367
+ // The whole message comes back: its words, and its files where the Mac still keeps them.
2368
+ returnToBox(h.text, how === 'cancelled' ? 'Not sent.' : copy || 'Not sent.', heldFilesBack(r, h));
1908
2369
  }
1909
2370
  if (!r.held.some(function (x) { return !x.cancelled; })) clearInterval(r.heldTimer);
1910
2371
  }
@@ -1921,7 +2382,7 @@
1921
2382
  var view = viewOf(r);
1922
2383
  return apiAnswer(sessionPath(r.provider, r.id, '/queued-turns'), {
1923
2384
  method: 'POST', timeoutMs: 15000,
1924
- body: { cosSessionId: cosSessionId(), clientTurnId: draft.key, prompt: draft.text },
2385
+ body: Object.assign({ cosSessionId: cosSessionId(), clientTurnId: draft.key, prompt: draft.text }, draft.ids && draft.ids.length ? { attachmentIds: draft.ids } : {}),
1925
2386
  }).then(function (q) {
1926
2387
  var b = q.body || {};
1927
2388
  if (b.queued === true || b.error === 'duplicate_turn') {
@@ -1930,10 +2391,14 @@
1930
2391
  else { view.pending(r, draft.text, 'waiting in line'); view.clear(r, draft.text); }
1931
2392
  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
2393
  view.queue(r);
2394
+ // 6.99.29: "The session is busy" is the gate's word as of now; the card shows the same. Last, and
2395
+ // never allowed to cost the parked message its settlement.
2396
+ if (b.queued === true && refusal && refusal.reason === 'native_thread_working') { sxNoteGate(r, true); try { sxRepaint(r); } catch (_e) {} }
1933
2397
  return 'parked';
1934
2398
  }
1935
2399
  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.');
2400
+ // 11/07: a file that could not be kept refuses the park with its own words, which say which step to take.
2401
+ 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
2402
  if (opts.held) settleHeld(r, opts.held, 'refused', copy); else view.status(r, copy + ' It is still in the box.', 'error');
1938
2403
  return 'refused';
1939
2404
  });
@@ -1949,10 +2414,13 @@
1949
2414
  apiAnswer('/api/agent-sessions/bindings/' + encodeURIComponent(bindingId) + '/turns/' + encodeURIComponent(key)).then(function (s) {
1950
2415
  var o = s.body && s.body.outcome;
1951
2416
  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);
2417
+ clearInterval(state.turnPoll); refreshTurns(provider, id);
2418
+ // 6.99.29: a turn delivered LIVE is in the open session, which is now working on it; only a
2419
+ // COS child's finished turn is the end of the work. Either way the one state settles it.
2420
+ if (s.body.via === 'live') { sxNoteGate(r, true); sessionStatus('Sent into the open session.'); }
2421
+ else { r.stoppedAt = Date.now(); sessionStatus('Done.'); }
2422
+ setLive({}); var st = $('session-state'); if (st) st.textContent = r.live && r.live.working ? 'Working now' : 'Open';
2423
+ if (s.body.via !== 'live') replyEnded(r);
1956
2424
  } else if (o === 'refused') { clearInterval(state.turnPoll); sessionStatus(s.body.reasonCopy || 'The session refused that message.', 'error'); replyEnded(r); }
1957
2425
  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
2426
  }).catch(function () {});
@@ -1961,13 +2429,15 @@
1961
2429
 
1962
2430
  // Fork copies the session and runs the message there. One key per draft, so a second tap or
1963
2431
  // 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) };
2432
+ function forkSession(r, text, ids) {
2433
+ ids = Array.isArray(ids) ? ids : [];
2434
+ // Other files are another fork, with its own key.
2435
+ 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
2436
  var forkId = r.forkDraft.key;
1967
2437
  sessionStatus('Copying the session. This can take up to a minute.');
1968
2438
  var settle = function (b) {
1969
2439
  if (b.forked === true) {
1970
- r.forkDraft = null; clearBoxOf(text);
2440
+ r.forkDraft = null; clearBoxOf(text); readerFilesSent(readerFilesOn() ? readerBox(r) : null, ids);
1971
2441
  sessionStatus('Forked. The copy has your message and shows in Sessions in a moment.');
1972
2442
  setTimeout(function () { if (state.pane === 'sessions') loadSessions(true); }, 4000);
1973
2443
  return true;
@@ -1975,7 +2445,7 @@
1975
2445
  if (b.forked === false || b.state === 'failed' || b.state === 'refused') { sessionStatus((b.reasonCopy || 'COS could not copy that session.'), 'error'); return true; }
1976
2446
  return false;
1977
2447
  };
1978
- return apiAnswer(sessionPath(r.provider, r.id, '/fork'), { method: 'POST', timeoutMs: 65000, body: { prompt: text, cosSessionId: cosSessionId(), clientForkId: forkId } }).then(function (f) {
2448
+ 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
2449
  if (settle(f.body || {})) return;
1980
2450
  if (f.status !== 202) { sessionStatus('COS could not copy that session.', 'error'); return; }
1981
2451
  // 202: running in the background. Follow its job for up to three minutes.
@@ -2000,7 +2470,7 @@
2000
2470
  if (b.cancelled) {
2001
2471
  r.stoppedAt = Date.now();
2002
2472
  clearInterval(state.turnPoll);
2003
- setLive({ working: false }); var st = $('session-state'); if (st) st.textContent = 'Open';
2473
+ setLive({}); var st = $('session-state'); if (st) st.textContent = 'Open';
2004
2474
  setTimeout(function () { if (state.reading === r) refreshTurns(r.provider, r.id); }, 1500);
2005
2475
  }
2006
2476
  }).catch(function (err) { sessionStatus(err.message, 'error'); });
@@ -2014,6 +2484,36 @@
2014
2484
  });
2015
2485
  }
2016
2486
 
2487
+ // Output previews are separate from composer attachments. Only the DTO fields reach the lazy viewer.
2488
+ function readerImagesHtml(turn) {
2489
+ var images = turn && Array.isArray(turn.outputImages) ? turn.outputImages.filter(function (x) {
2490
+ return x && /^ai_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable');
2491
+ }).map(function (x) { return { id: x.id, label: String(x.label || 'Output image').slice(0, 180), state: x.state, reason: x.reason }; }) : [];
2492
+ return images.length ? '<div data-reader-images="' + esc(JSON.stringify(images)) + '"><p class="rm-status" role="status">Loading image previews…</p></div>' : '';
2493
+ }
2494
+ function readerImagesStop(host) {
2495
+ if (!host) return;
2496
+ host.__cosImageOwner = null;
2497
+ if (window.COSWeb.readerMedia) window.COSWeb.readerMedia.unmount(host);
2498
+ }
2499
+ function readerImagesMount(host, r) {
2500
+ if (!host || !r) return;
2501
+ if (!host.querySelectorAll('[data-reader-images]').length) { readerImagesStop(host); return; }
2502
+ var owner = host.__cosImageOwner = {};
2503
+ ensurePane('readerMedia', 4000).then(function (kit) {
2504
+ if (host.__cosImageOwner !== owner || host.isConnected === false) return;
2505
+ if (!kit) throw new Error('Image previews could not load.');
2506
+ kit.mount(host, r.provider, r.id);
2507
+ }).catch(function () {
2508
+ if (host.__cosImageOwner !== owner || host.isConnected === false) return;
2509
+ host.querySelectorAll('[data-reader-images]').forEach(function (slot) {
2510
+ slot.textContent = 'Image previews could not load. ';
2511
+ var retry = document.createElement('button'); retry.type = 'button'; retry.className = 'link-btn'; retry.textContent = 'Retry images';
2512
+ retry.addEventListener('click', function () { readerImagesMount(host, r); }); slot.appendChild(retry);
2513
+ });
2514
+ });
2515
+ }
2516
+
2017
2517
  // Copy session (Control's Kickstart brief, HelperSources/main.swift claudeKickstartCopy). Control
2018
2518
  // builds it from the Mac's transcript; the web builds it from the turns the server serves and
2019
2519
  // the session's first prompt, so it keeps the original request and the newest context.
@@ -2146,7 +2646,7 @@
2146
2646
  // One turn's identity, for telling which turns a refresh brought that the page already shows.
2147
2647
  function sxTurnSig(t) {
2148
2648
  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);
2649
+ return (String(t.role || '').toLowerCase() === 'user' ? 'u' : 'a') + '|' + (t.at || '') + '|' + text.length + '|' + text.slice(0, 48) + '|' + readerImagesHtml(t);
2150
2650
  }
2151
2651
  function sxTurnHtml(t) {
2152
2652
  var role = String(t.role || '').toLowerCase() === 'user' ? 'user' : 'assistant';
@@ -2155,7 +2655,7 @@
2155
2655
  var when = at ? (Date.now() - at.getTime() < 86400000 ? clock(at) : sxShortDate(at)) : '';
2156
2656
  return '<div class="turn ' + role + ' sx-turn" data-sig="' + esc(sxTurnSig(t)) + '"><span class="who"><span>' + (role === 'user' ? 'YOU' : 'ASSISTANT') + '</span>' +
2157
2657
  (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>';
2658
+ (role === 'user' ? '<div class="sx-plain">' + esc(text) + '</div>' : '<div class="sx-md">' + sxMd(text) + '</div>' + readerImagesHtml(t)) + '</div>';
2159
2659
  }
2160
2660
  // A refresh never rebuilds what you are reading (Miles, /qa: "start to scroll and then it reloads
2161
2661
  // the section"). The server's window slides as a session grows, so the turns the page already
@@ -2200,7 +2700,8 @@
2200
2700
  return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20);
2201
2701
  }
2202
2702
  var TODAY_REFERENCE = 'What Today in COS Control shows right now';
2203
- function askCos(prompt, statusEl) {
2703
+ // files: { attachmentIds, atts } from the Ask sheet's clip (askSheetFiles), or nothing.
2704
+ function askCos(prompt, statusEl, files) {
2204
2705
  // 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
2706
  // that composer (panes/cos-chat.js submitAsk), so the answer streams on screen and lands as a numbered turn. The
2206
2707
  // request below is the fallback for a page without it; it resolves to the Messages route to open.
@@ -2209,17 +2710,27 @@
2209
2710
  // quoted reference, the query-jobs field for context. Asked from any other pane, nothing extra is sent.
2210
2711
  var context = state.pane === 'today' ? ensurePane('todayActions', 1500).then(function (m) {
2211
2712
  // 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 ''; }
2713
+ var ctx;
2714
+ try { ctx = m && m.summary ? m.summary() : ''; } catch (err) { console.error('[today] the Today summary failed; asking without it.', err); return ''; }
2715
+ // The model's summary of the sessions leads it when there is one, inside the same cap; past the cap it is left out.
2716
+ var b = typeof briefShown === 'function' ? briefShown() : null;
2717
+ 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 : '';
2718
+ return ctx && lead && lead.length + 1 + ctx.length <= ((m && m.summaryMax) || 4000) ? lead + '\n' + ctx : ctx;
2213
2719
  }, function () { return ''; }) : Promise.resolve('');
2214
2720
  if (chat && chat.askFromSheet) {
2215
2721
  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) {
2722
+ return context.then(function (ctx) {
2723
+ var extra = files ? { attachmentIds: files.attachmentIds, atts: files.atts } : {};
2724
+ if (ctx) extra.context = ctx;
2725
+ return chat.askFromSheet(prompt, ctx || files ? extra : undefined);
2726
+ }).then(function (where) { setStatus(statusEl, ''); return where; }, function (err) {
2217
2727
  setStatus(statusEl, err.message, true);
2218
2728
  throw err;
2219
2729
  });
2220
2730
  }
2221
2731
  setStatus(statusEl, 'Sent. COS is answering.');
2222
2732
  var body = { clientJobId: webJobId(), generation: 1, query: prompt, source: 'web', activityToolMode: 'status' };
2733
+ if (files && files.attachmentIds && files.attachmentIds.length) body.attachmentIds = files.attachmentIds;
2223
2734
  return context.then(function (ctx) {
2224
2735
  if (ctx) body.reference = { query: TODAY_REFERENCE, response: ctx };
2225
2736
  return api('/api/query-jobs', { method: 'POST', body: body, timeoutMs: 30000 });
@@ -3901,12 +4412,87 @@
3901
4412
  var q = ($('ask-q') && $('ask-q').value.trim()) || '';
3902
4413
  g.innerHTML = esc(ASK_FILE.busy ? 'Filing\u2026' : 'File to Work inbox') + fileNoteHtml(q);
3903
4414
  }
4415
+ // ── files with Ask COS (11/07, Miles: "Sometimes I need to pass context via a screenshot, photo or file") ──
4416
+ // The COS chat's own clip, chips, limits and upload (panes/cos-chat.js, W.cosChat.attach): photos, PDF, TXT, MD, CSV,
4417
+ // JSON, XLSX, MP4 and MOV, up to 5, pasted, dropped or picked. They go with Ask COS only; the sheet's other rows take
4418
+ // words. Ask COS waits for every upload, and a file that failed has to be retried or removed first. Closing the sheet
4419
+ // keeps them for the next Ask in this tab.
4420
+ var ASK_FILES = null, ASK_SENDING = false;
4421
+ function askKit() { var c = window.COSWeb && window.COSWeb.cosChat; return c && c.attach ? c.attach : null; }
4422
+ function askFiles() {
4423
+ var kit = askKit(); if (!kit) return null;
4424
+ if (!ASK_FILES) ASK_FILES = kit.box({
4425
+ changed: paintAskFiles,
4426
+ removed: function () { setStatus($('ask-status'), ''); },
4427
+ signedOut: function () { setStatus($('ask-status'), 'This browser was signed out. Your files are kept. Sign in, then tap Retry on each file.', true); }
4428
+ });
4429
+ return ASK_FILES;
4430
+ }
4431
+ function askFilesState() {
4432
+ var kit = askKit(), b = ASK_FILES;
4433
+ if (!kit || !b || !b.atts.length) return 'none';
4434
+ return kit.pending(b) ? 'waiting' : kit.failed(b) ? 'failed' : 'ready';
4435
+ }
4436
+ function paintAskClip() {
4437
+ var slot = $('ask-clip'), kit = askKit(); if (!slot) return;
4438
+ var on = !!(kit && kit.askOn());
4439
+ if (!on) { slot.innerHTML = ''; return; }
4440
+ // 6.99.27: Take photo beside the clip (a phone or tablet only; kit.cameraHtml).
4441
+ if (!slot.querySelector('[data-ask-file]')) slot.innerHTML = kit.cameraHtml('data-ask-file') + kit.clipHtml('data-ask-file');
4442
+ var full = !!(ASK_FILES && ASK_FILES.atts.length >= kit.max);
4443
+ Array.prototype.forEach.call(slot.querySelectorAll ? slot.querySelectorAll('.cc-attach') : [], function (l) { l.classList.toggle('full', full); });
4444
+ Array.prototype.forEach.call(slot.querySelectorAll ? slot.querySelectorAll('[data-ask-file]') : [], function (i) { i.disabled = full || ASK_SENDING; });
4445
+ }
4446
+ function paintAskFiles() {
4447
+ var kit = askKit(), box = $('ask-files'), b = ASK_FILES; if (!box || !kit) return;
4448
+ var st = askFilesState();
4449
+ box.innerHTML = st === 'none' ? '' : kit.chipsHtml(b, 'data-action') + '<p class="ask-files-note">' + esc(
4450
+ st === 'waiting' ? 'Uploading. Ask COS waits for it.' : st === 'failed' ? 'A file did not upload. Retry it or remove it, then Ask COS.'
4451
+ : (b.atts.length === 1 ? 'This file goes' : 'These files go') + ' with Ask COS. Write the question above.') + '</p>';
4452
+ box.classList.toggle('hidden', st === 'none');
4453
+ paintAskClip(); paintAskRow();
4454
+ }
4455
+ function addAskFiles(list) {
4456
+ var kit = askKit(), b = askFiles(); if (!kit || !b || !list || !list.length) return;
4457
+ var refusals = kit.add(b, list);
4458
+ setStatus($('ask-status'), refusals.join(' '), !!refusals.length);
4459
+ paintAskFiles();
4460
+ }
4461
+ // The Ask COS row says what it sends, and waits while a file uploads.
4462
+ function askRowNote(q) {
4463
+ var n = ASK_FILES ? ASK_FILES.atts.length : 0;
4464
+ // The files first: on the folded phone the line is cut, and they are what changed.
4465
+ return '“' + q + '”' + (n ? ' with ' + (n === 1 ? '1 file' : n + ' files') : '') + (state.pane === 'today' ? (n ? ' and' : ' with') + ' what Today shows' : '');
4466
+ }
4467
+ function paintAskRow() {
4468
+ var row = document.querySelector('#ask-results [data-action="ask-cos"]'); if (!row) return;
4469
+ var st = askFilesState();
4470
+ row.disabled = ASK_SENDING || st === 'waiting' || st === 'failed';
4471
+ var note = row.querySelector('.note'), q = ($('ask-q') && $('ask-q').value.trim()) || '';
4472
+ if (note) note.textContent = ASK_SENDING ? 'Sending…' : st === 'waiting' ? 'Waiting for the upload' : askRowNote(q);
4473
+ }
4474
+ function askSheetSend(q) {
4475
+ if (ASK_SENDING) return;
4476
+ var kit = askKit(), b = ASK_FILES, st = askFilesState();
4477
+ if (st === 'waiting') { setStatus($('ask-status'), 'A file is still uploading. Ask COS when it finishes.'); return; }
4478
+ if (st === 'failed') { setStatus($('ask-status'), 'A file did not upload. Retry it or remove it, then Ask COS.', true); return; }
4479
+ var files = st === 'ready' ? { attachmentIds: kit.ids(b), atts: kit.sent(b) } : null;
4480
+ ASK_SENDING = true; paintAskRow(); paintAskClip();
4481
+ var done = function () { ASK_SENDING = false; paintAskRow(); paintAskClip(); };
4482
+ askCos(q, $('ask-status'), files).then(function (where) {
4483
+ done();
4484
+ // Sent: these files went with it. Anything added while it was sending stays for the next Ask.
4485
+ if (files && b) b.atts.filter(function (t) { return files.attachmentIds.indexOf(t.mediaId) >= 0; }).forEach(function (t) { kit.remove(b, t.id); });
4486
+ closeAsk(); go('messages', Array.isArray(where) ? where : []);
4487
+ }).catch(done);
4488
+ }
3904
4489
  function openAsk(prefill) {
3905
4490
  loadFileDomains().then(function () { if (!$('ask-sheet').classList.contains('hidden') && $('ask-q').value.trim()) renderAsk(); });
3906
4491
  $('ask-sheet').classList.remove('hidden');
3907
4492
  document.body.style.overflow = 'hidden';
3908
4493
  var input = $('ask-q'); input.value = prefill || '';
3909
- renderAsk();
4494
+ renderAsk(); paintAskFiles(); paintAskClip();
4495
+ var kit = askKit(); if (kit && !kit.askOn()) kit.ensureCaps().then(paintAskClip, function () {});
3910
4496
  setTimeout(function () { input.focus(); }, 30);
3911
4497
  }
3912
4498
  function closeAsk() { $('ask-sheet').classList.add('hidden'); document.body.style.overflow = ''; }
@@ -3923,12 +4509,13 @@
3923
4509
  }
3924
4510
  var jumps = JUMPS.filter(function (j) { return j[1].toLowerCase().indexOf(q.toLowerCase()) === 0; });
3925
4511
  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>' +
4512
+ '<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
4513
  '<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
4514
  '<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
4515
  '<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
4516
  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
4517
  '</div><div id="ask-hits"></div><p class="status-line" id="ask-status"></p>';
4518
+ paintAskRow();
3932
4519
  clearTimeout(renderAsk.t);
3933
4520
  if (q.length >= 2) renderAsk.t = setTimeout(function () {
3934
4521
  api('/api/agent-sessions/search?q=' + encodeURIComponent(q) + '&limit=6').then(function (d) {
@@ -3972,7 +4559,7 @@
3972
4559
  return;
3973
4560
  }
3974
4561
  if (t.hasAttribute('data-open-ask')) return openAsk();
3975
- if (t.hasAttribute('data-today')) return openTodayAction(t);
4562
+ if (t.hasAttribute('data-today')) return t.getAttribute('data-today') === 'pet' ? petTap(t) : openTodayAction(t);
3976
4563
  if (t.hasAttribute('data-today-next')) return prepNext();
3977
4564
  if (t.hasAttribute('data-today-needs')) {
3978
4565
  var which = t.getAttribute('data-today-needs');
@@ -4004,7 +4591,18 @@
4004
4591
  }
4005
4592
  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
4593
  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; }
4594
+ if ((action === 'att-remove' || action === 'att-retry') && t.closest('#session-form') && state.reading) {
4595
+ var kitR = readerKit(), boxR = kitR && readerBox(state.reading); if (!boxR) return;
4596
+ sessionStatus('');
4597
+ if (action === 'att-remove') kitR.remove(boxR, t.getAttribute('data-att')); else kitR.retry(boxR, t.getAttribute('data-att'));
4598
+ return;
4599
+ }
4600
+ if (inSheet && (action === 'att-remove' || action === 'att-retry') && ASK_FILES) {
4601
+ var kitA = askKit(); setStatus($('ask-status'), '');
4602
+ if (action === 'att-remove') kitA.remove(ASK_FILES, t.getAttribute('data-att')); else kitA.retry(ASK_FILES, t.getAttribute('data-att'));
4603
+ return;
4604
+ }
4605
+ if (action === 'ask-cos' && q) return askSheetSend(q);
4008
4606
  if (action === 'ask-file' && q) return fileToInbox(q);
4009
4607
  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
4608
  if (action === 'ask-work' && q) {
@@ -4035,12 +4633,43 @@
4035
4633
  }
4036
4634
  });
4037
4635
 
4636
+ // The Ask sheet's clip, a pasted screenshot in its box, and files dropped on it (askFiles above).
4637
+ document.addEventListener('change', function (e) {
4638
+ if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-ask-file')) { addAskFiles(e.target.files); e.target.value = ''; }
4639
+ if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-sx-file')) { addReaderFiles(e.target.files); e.target.value = ''; }
4640
+ });
4641
+ // The Sessions reader's box: a pasted screenshot, and files dropped on it.
4642
+ document.addEventListener('paste', function (e) {
4643
+ if (!e.target || e.target.id !== 'session-input') return;
4644
+ var kit = readerKit(), files = kit && readerFilesOn() ? kit.pasted(e) : null;
4645
+ if (files) { e.preventDefault(); addReaderFiles(files); }
4646
+ });
4647
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (type) {
4648
+ document.addEventListener(type, function (e) {
4649
+ var form = e.target && e.target.closest ? e.target.closest('#session-form') : null, kit = readerKit();
4650
+ if (!form || !kit || !readerFilesOn()) return;
4651
+ var files = kit.dragged(e, form); if (files) addReaderFiles(files);
4652
+ });
4653
+ });
4654
+ document.addEventListener('paste', function (e) {
4655
+ if (!e.target || e.target.id !== 'ask-q') return;
4656
+ var kit = askKit(), files = kit && kit.askOn() ? kit.pasted(e) : null;
4657
+ if (files) { e.preventDefault(); addAskFiles(files); }
4658
+ });
4659
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (type) {
4660
+ var sheet = $('ask-sheet'); if (!sheet) return;
4661
+ sheet.addEventListener(type, function (e) {
4662
+ var kit = askKit(), zone = sheet.querySelector('.sheet'); if (!kit || !zone || !kit.askOn()) return;
4663
+ var files = kit.dragged(e, zone); if (files) addAskFiles(files);
4664
+ });
4665
+ });
4038
4666
  document.addEventListener('input', function (e) {
4039
4667
  if (e.target.tagName === 'TEXTAREA') autoGrow(e.target);
4040
4668
  if (e.target.id === 'ask-q') renderAsk();
4041
4669
  if (e.target.id === 'sessions-q' && !e.target.value.trim()) renderSessionGroups(state.sessions);
4042
4670
  });
4043
4671
  document.addEventListener('keydown', function (e) {
4672
+ if (window.COSWeb.readerMedia && window.COSWeb.readerMedia.isOpen()) return;
4044
4673
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openAsk(); return; }
4045
4674
  if (e.key === 'Escape' && !$('ask-sheet').classList.contains('hidden')) { closeAsk(); return; }
4046
4675
  if (e.key === 'Escape' && !$('section-menu').classList.contains('hidden')) { closeMenu(); $('section-btn').focus(); return; }
@@ -4106,6 +4735,7 @@
4106
4735
  sessionList: sessionList,
4107
4736
  petPrefs: petPrefs,
4108
4737
  setPetPrefs: setPetPrefs,
4738
+ resetTodayBrief: resetBrief,
4109
4739
  onRoute: function (fn) { routeHooks.push(fn); },
4110
4740
  onShell: function (fn) { shellHooks.push(fn); },
4111
4741
  // The detail title after "COS Control > Section" (Control's breadcrumb). Pane modules set it
@@ -4139,7 +4769,11 @@
4139
4769
  railLine: function () { return TODAY.model ? { text: todayRailLine(TODAY.model), hot: TODAY.model.now.length > 0 } : null; },
4140
4770
  markGone: function (id, gone) { if (gone) TODAY.gone[id] = 1; else delete TODAY.gone[id]; paintTodayIntake(); },
4141
4771
  reloadIntake: function () { return loadTodayIntake(); },
4142
- refresh: function () { if (state.pane === 'today') loadToday(); }
4772
+ refresh: function () { if (state.pane === 'today') loadToday(); },
4773
+ // 6.99.26: the pet's sessions (petPlay), and a repaint of Needs you when the held questions change.
4774
+ petPlay: function () { return petPlay(state.sessions || []); },
4775
+ // 6.99.27: the pet's caption and pose follow the held questions too.
4776
+ questionsChanged: function () { if (TODAY.at) { paintNeeds(); if (state.pane === 'today' && !TODAY.offline) renderPet(state.sessions || []); } }
4143
4777
  };
4144
4778
  window.COSWeb.sessionKit = {
4145
4779
  // A session object for continueSession, with the overlay's own view (READER_VIEW's methods).
@@ -4150,7 +4784,9 @@
4150
4784
  newKey: newTurnKey,
4151
4785
  probe: function (r) { return sxProbeAttach(r); },
4152
4786
  path: sessionPath, answer: apiAnswer,
4153
- feed: { fresh: newFeed, apply: applyFeed, word: sxFeedWord, detail: sxFeedDetail, tool: sxToolLine, elapsed: sxElapsed, fallback: sxFallbackLine },
4787
+ imageHtml: readerImagesHtml, imagesMount: readerImagesMount, imagesStop: readerImagesStop,
4788
+ feed: { fresh: newFeed, apply: applyFeed, word: sxFeedWord, detail: sxFeedDetail, tool: sxToolLine, elapsed: sxElapsed, fallback: sxFallbackLine,
4789
+ truth: sxTruth, settle: sxSettle, noteList: sxNoteList, noteGate: sxNoteGate, gateStale: sxGateStale, promptLine: sxPromptLine, parkNoteShows: sxParkNoteShows },
4154
4790
  title: sxTitle, stateOf: sxState, stateLabel: function (s) { return sxStateLabel(s, sxState(s)); },
4155
4791
  parkCopy: function (p) { return SX_PARK[sxProvider(p)]; }, providerLabel: sxProviderLabel, markdown: sxMd
4156
4792
  };