@gotcos/glasses-server 6.99.20 → 6.99.25

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 (33) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/docs/work-orchestrator.md +20 -0
  3. package/package.json +1 -1
  4. package/server/index.ts +2 -1
  5. package/server/lib/web-control-static.ts +226 -0
  6. package/server/lib/web-memories-bridge.ts +9 -8
  7. package/server/lib/web-pet-choice.ts +225 -0
  8. package/server/lib/web-pet.ts +226 -0
  9. package/server/lib/work-lens-rounds.ts +29 -4
  10. package/server/routes/web-control.ts +110 -7
  11. package/server/routes/work-lens-rounds.ts +19 -5
  12. package/server/web-control/app.css +24 -5
  13. package/server/web-control/app.js +646 -108
  14. package/server/web-control/control.css +1 -1
  15. package/server/web-control/index.html +27 -13
  16. package/server/web-control/memories/memory-workspace.js +1 -1
  17. package/server/web-control/panes/cos-chat.css +4 -0
  18. package/server/web-control/panes/cos-chat.js +26 -4
  19. package/server/web-control/panes/meetings.css +15 -2
  20. package/server/web-control/panes/memories.css +291 -0
  21. package/server/web-control/panes/memories.js +2208 -0
  22. package/server/web-control/panes/questions.js +59 -21
  23. package/server/web-control/panes/settings.js +102 -4
  24. package/server/web-control/panes/today-actions.css +109 -0
  25. package/server/web-control/panes/today-actions.js +972 -0
  26. package/server/web-control/panes/work.css +54 -0
  27. package/server/web-control/panes/work.js +1037 -18
  28. package/server/web-control/shell.js +6 -2
  29. package/server/web-control/theme.js +0 -2
  30. package/server/web-control/memories/memories-app.js +0 -1561
  31. package/server/web-control/memories/memories-theme.css +0 -73
  32. package/server/web-control/memories/memories.html +0 -72
  33. package/server/web-control/memories/memory-stewardship.js +0 -63
@@ -133,7 +133,7 @@
133
133
  var state = {
134
134
  pane: 'today', serverName: '', tailnetUrl: '',
135
135
  sessions: [], sessionsMode: 'words', workTasks: [], workOpenStages: { complete: false },
136
- stream: null, memoriesLoaded: false, lastLoad: {}, livePoll: null
136
+ stream: null, lastLoad: {}, livePoll: null
137
137
  };
138
138
  // Control's order (ActivitySection.visibleSections): Today, then Messages to Dictation. Usage is a
139
139
  // view inside Sessions on the Mac; its route stays so old links work, and the rail marks Sessions.
@@ -142,6 +142,53 @@
142
142
  // Pane modules (panes/*.js) register here: { load(args, ctx), leave(), up(args) }. A registered
143
143
  // pane owns its section; route() only shows it and hands it the hash arguments.
144
144
  var PANES = {};
145
+ // Heavy panes load when first opened, then the rest load quietly once the page is idle.
146
+ // index.html lists them in #lazy-panes so the server can version each URL (?v=<hash>).
147
+ // Read on first use, not at parse time: #lazy-panes sits after this script in index.html.
148
+ var lazyList = null;
149
+ function LAZY() {
150
+ if (lazyList) return lazyList;
151
+ var el = document.getElementById('lazy-panes');
152
+ if (!el) return {};
153
+ try { lazyList = JSON.parse(el.textContent || '{}'); } catch (_e) { lazyList = {}; }
154
+ return lazyList;
155
+ }
156
+ var lazyLoads = {};
157
+ // ensurePane(name, waitMs): the pane's script, loaded once. With waitMs, this caller stops waiting after that
158
+ // long (the load itself carries on, and a later call reuses it), so a stalled download can never hang a tap.
159
+ function ensurePane(name, waitMs) {
160
+ var load = loadPane(name);
161
+ if (!waitMs) return load;
162
+ return new Promise(function (resolve, reject) {
163
+ var timer = setTimeout(function () {
164
+ var late = new Error('The ' + (TITLES[name] || name) + ' section is taking too long to load. Check the connection to your Mac.');
165
+ late.code = 'pane_timeout'; reject(late);
166
+ }, waitMs);
167
+ load.then(function (v) { clearTimeout(timer); resolve(v); }, function (e) { clearTimeout(timer); reject(e); });
168
+ });
169
+ }
170
+ function loadPane(name) {
171
+ if (PANES[name] || !LAZY()[name]) return Promise.resolve(PANES[name] || null);
172
+ if (lazyLoads[name]) return lazyLoads[name];
173
+ lazyLoads[name] = new Promise(function (resolve, reject) {
174
+ var s = document.createElement('script');
175
+ s.src = LAZY()[name];
176
+ s.async = true;
177
+ s.onload = function () { if (PANES[name]) resolve(PANES[name]); else { delete lazyLoads[name]; reject(new Error('The ' + (TITLES[name] || name) + ' section did not start.')); } };
178
+ s.onerror = function () { delete lazyLoads[name]; s.remove(); reject(new Error('Could not load the ' + (TITLES[name] || name) + ' section. Check the connection to your Mac.')); };
179
+ document.body.appendChild(s);
180
+ });
181
+ return lazyLoads[name];
182
+ }
183
+ function prefetchPanes() {
184
+ var names = Object.keys(LAZY()).filter(function (n) { return !PANES[n]; });
185
+ var idle = window.requestIdleCallback || function (fn) { return setTimeout(fn, 200); };
186
+ (function next() {
187
+ var n = names.shift();
188
+ if (!n) return;
189
+ idle(function () { ensurePane(n).then(next, next); });
190
+ })();
191
+ }
145
192
  var routeHooks = [];
146
193
  var shellHooks = [];
147
194
  var crumbDetail = '';
@@ -157,9 +204,9 @@
157
204
  if (location.hash === hash) route(); else location.hash = hash;
158
205
  }
159
206
  function isPhone() { return !window.matchMedia('(min-width: 600px)').matches; }
160
- function route() {
207
+ function route(forceChanged) {
161
208
  var r = parseRoute();
162
- var changedPane = r.pane !== state.pane || !document.querySelector('#pane-' + r.pane + ':not(.hidden)');
209
+ var changedPane = forceChanged === true || r.pane !== state.pane || !document.querySelector('#pane-' + r.pane + ':not(.hidden)');
163
210
  if (state.pane !== r.pane && PANES[state.pane] && PANES[state.pane].leave) { try { PANES[state.pane].leave(); } catch (err) { console.error(err); } }
164
211
  state.pane = r.pane;
165
212
  crumbDetail = '';
@@ -171,7 +218,7 @@
171
218
  if (!(r.pane === 'sessions' && r.args.length === 2)) { clearInterval(state.turnPoll); if (state.reading) clearInterval(state.reading.heldTimer); state.reading = null; }
172
219
  if (r.pane !== 'meetings' && state.livePoll) { clearInterval(state.livePoll); state.livePoll = null; }
173
220
 
174
- var reading = r.args.length > 0;
221
+ var reading = r.args.length > 0 && !isTabRoute(r.args);
175
222
  var paneEl = $('pane-' + r.pane);
176
223
  if (paneEl) paneEl.classList.toggle('reading', reading);
177
224
  var split = paneEl && paneEl.querySelector('.split');
@@ -187,10 +234,31 @@
187
234
  $('topbar').classList.remove('tucked');
188
235
  onScroll();
189
236
 
237
+ if (!PANES[r.pane] && LAZY()[r.pane]) {
238
+ var lazyPane = r.pane, lazyEl = $('pane-' + lazyPane), wasEmpty = lazyEl && !lazyEl.getAttribute('data-lazy-ready');
239
+ if (wasEmpty && lazyEl && !lazyEl.querySelector('.lazy-wait')) lazyEl.insertAdjacentHTML('afterbegin', '<div class="lazy-wait">' + skeletonRows(4) + '</div>');
240
+ ensurePane(lazyPane, 15000).then(function () {
241
+ if (lazyEl) { lazyEl.setAttribute('data-lazy-ready', '1'); lazyEl.querySelectorAll('.lazy-wait').forEach(function (n) { n.remove(); }); }
242
+ // Pass changed=true, the value this pane got on its first open when it loaded at boot. Headless Chrome
243
+ // probes on 11/06 rendered Work, Meetings, Settings and Speakers with either value; this keeps the old contract.
244
+ if (state.pane === lazyPane) route(true);
245
+ }, function (err) {
246
+ if (!lazyEl) return;
247
+ lazyEl.querySelectorAll('.lazy-wait').forEach(function (n) { n.remove(); });
248
+ lazyEl.insertAdjacentHTML('afterbegin', '<div class="lazy-wait"><div class="list"><p class="empty"><strong>' + esc(err.message) + '</strong></p><button type="button" class="btn" data-lazy-retry="' + esc(lazyPane) + '">Try again</button></div></div>');
249
+ // Only the wait ran out: if the load still finishes, the card clears itself and the pane shows.
250
+ if (err && err.code === 'pane_timeout') loadPane(lazyPane).then(function () {
251
+ lazyEl.querySelectorAll('.lazy-wait').forEach(function (n) { n.remove(); });
252
+ lazyEl.setAttribute('data-lazy-ready', '1');
253
+ if (state.pane === lazyPane) route(true);
254
+ }, function () {});
255
+ });
256
+ runHooks(routeHooks, r);
257
+ return;
258
+ }
190
259
  if (PANES[r.pane]) { try { PANES[r.pane].load(r.args, { changed: changedPane }); } catch (err) { console.error(err); } }
191
260
  else if (r.pane === 'today') loadToday();
192
261
  else if (r.pane === 'sessions') { if (!reading || !state.sessions.length || changedPane) loadSessions(); if (reading) openSession(r.args[0], r.args[1]); }
193
- else if (r.pane === 'memories') loadMemories();
194
262
  else if (r.pane === 'usage') loadUsage(false);
195
263
  state.lastLoad[r.pane] = Date.now();
196
264
  runHooks(routeHooks, r);
@@ -202,9 +270,12 @@
202
270
  var r = parseRoute();
203
271
  var up = PANES[r.pane] && PANES[r.pane].up ? PANES[r.pane].up(r.args) : null;
204
272
  if (up) go(r.pane, up);
205
- else if (r.args.length) go(r.pane);
273
+ else if (r.args.length && !isTabRoute(r.args)) go(r.pane);
206
274
  else go('today');
207
275
  }
276
+ // '#/<pane>/t/<tab>' is the pane's list on one of its tabs, not a reader: the phone keeps the section switcher and Back
277
+ // leaves the pane. Memories keeps its tab in the address this way, so a reload or a new browser lands on the same tab.
278
+ function isTabRoute(args) { return args.length === 2 && args[0] === 't'; }
208
279
  // On the closed Duo every vertical pixel counts: the top bar slides away while you scroll
209
280
  // down and comes back the moment you scroll up.
210
281
  var lastY = 0;
@@ -238,18 +309,209 @@
238
309
  loadTailnetUrl();
239
310
  runHooks(shellHooks, info);
240
311
  route();
312
+ // Reloaded with Today's overlay open: its history entry is still current. The overlay opens the same thing again
313
+ // (panes/today-actions.js restore), so Back closes it; if it cannot load, the entry is unmarked.
314
+ var held = history.state;
315
+ if (held && held.cosTodayOverlay) {
316
+ ensurePane('todayActions', 4000).then(function (m) { if (m && m.restore) m.restore(held); },
317
+ function () { try { history.replaceState(null, ''); } catch (_e) {} });
318
+ }
319
+ setTimeout(prefetchPanes, 1500);
320
+ }
321
+ document.addEventListener('click', function (e) {
322
+ var b = e.target && e.target.closest && e.target.closest('[data-lazy-retry]');
323
+ if (!b) return;
324
+ var el = $('pane-' + b.getAttribute('data-lazy-retry'));
325
+ if (el) el.querySelectorAll('.lazy-wait').forEach(function (n) { n.remove(); });
326
+ route(true);
327
+ });
328
+
329
+ // ── one session list for every pane ────────────────────
330
+ // /api/agent-sessions walks every session folder and runs a 2-3 s lock probe on the Mac (measured
331
+ // 11/06 in the browser: 5.1 s on Sessions, 2.1 s on Work). Today, Sessions, Work and the section
332
+ // stories all asked for it separately. Now there is one copy: a request younger than maxAgeMs is
333
+ // answered from it, a request already on the wire is shared, and panes can paint the last copy at
334
+ // once while a fresh one loads (sessionListCached).
335
+ var SL = { data: null, at: 0, inflight: null };
336
+ function sessionList(maxAgeMs) {
337
+ if (SL.data && Date.now() - SL.at < (maxAgeMs || 0)) return Promise.resolve(SL.data);
338
+ if (SL.inflight) return SL.inflight;
339
+ 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;
341
+ }, function (err) { SL.inflight = null; throw err; });
342
+ return SL.inflight;
343
+ }
344
+ function sessionListCached() { return SL.data ? { data: SL.data, at: SL.at } : null; }
345
+
346
+ // ── the session pet (Today) ─────────────────────────────
347
+ // Same pet and the same pose rule as COS Control (PetSpritePose.resolve): waiting with nothing running
348
+ // is "waiting", then one, two, three, four or more sessions in play are working, duel, trio, swarm, an open
349
+ // quiet session is "patrol", none is "idle". Strips come from /api/web-pet, resized and cut for a phone.
350
+ var PET = { manifest: null, fetchedAt: 0, pose: null, timer: null, frame: 0, token: 0 };
351
+ // This browser's pet settings (Settings > Pet): which pet, and whether Today shows it. Per browser, like the theme.
352
+ var PET_KEY = 'cos-web-pet';
353
+ function petPrefs() {
354
+ var p = readStore(PET_KEY);
355
+ var pet = typeof p.pet === 'string' && /^(mac|(shipped|gallery):[a-z0-9][a-z0-9-]{0,80})$/.test(p.pet) ? p.pet : 'mac';
356
+ return { pet: pet, show: p.show !== false };
357
+ }
358
+ function setPetPrefs(next) {
359
+ var cur = petPrefs();
360
+ var merged = { pet: next.pet != null ? next.pet : cur.pet, show: next.show != null ? !!next.show : cur.show };
361
+ writeStore(PET_KEY, merged);
362
+ // Forget the old pet's manifest and loop; Today repaints with the new choice next time it shows.
363
+ PET.manifest = null; PET.fetchedAt = 0; PET.pose = null; PET.token++; petStop();
364
+ var box = $('today-pet');
365
+ if (box) { box.innerHTML = ''; box.classList.add('hidden'); }
366
+ if (state.pane === 'today' && state.sessions) renderPet(state.sessions);
367
+ return merged;
368
+ }
369
+ var PET_HEIGHT = 140;
370
+ function petPose(sessions) {
371
+ var working = 0, waiting = 0, alive = 0;
372
+ sessions.forEach(function (s) {
373
+ var st = sxState(s);
374
+ // Working is the Running card's own test (running_active), so the caption under the pet never
375
+ // disagrees with the Running count beside it (11/06: the pet said 3 while the card said 2).
376
+ if (s.running_active) working++;
377
+ else if (st === 'waiting') waiting++;
378
+ if (s.running || s.running_active) alive++;
379
+ });
380
+ var pose;
381
+ if (waiting > 0 && working === 0) pose = 'waiting';
382
+ else if (working + waiting >= 4) pose = 'swarm';
383
+ else if (working + waiting === 3) pose = 'trio';
384
+ else if (working + waiting === 2) pose = 'duel';
385
+ else if (working + waiting === 1) pose = 'working';
386
+ else pose = alive > 0 ? 'patrol' : 'idle';
387
+ return { pose: pose, working: working, waiting: waiting };
388
+ }
389
+ function petCaption(c) {
390
+ var parts = [];
391
+ if (c.working) parts.push(c.working + ' running');
392
+ if (c.waiting) parts.push(c.waiting + ' waiting on you');
393
+ return parts.length ? parts.join(', ') : 'Nothing running';
394
+ }
395
+ function petStop() { clearTimeout(PET.timer); PET.timer = null; }
396
+ function petPaint(el, spec, frame, scale) {
397
+ var chunk = Math.floor(frame / spec.perChunk), inChunk = frame % spec.perChunk;
398
+ var count = Math.min(spec.perChunk, spec.frames - chunk * spec.perChunk);
399
+ el.style.backgroundImage = 'url("' + spec.chunks[chunk] + '")';
400
+ el.style.backgroundSize = Math.round(count * spec.frameWidth * scale) + 'px ' + PET_HEIGHT + 'px';
401
+ el.style.backgroundPosition = '-' + Math.round(inChunk * spec.frameWidth * scale) + 'px 0';
402
+ }
403
+ function petLoadImages(spec) {
404
+ return Promise.all(spec.chunks.map(function (url) {
405
+ return new Promise(function (resolve, reject) { var im = new Image(); im.onload = function () { resolve(im); }; im.onerror = reject; im.src = url; });
406
+ }));
407
+ }
408
+ // Folded (one column) the pet sits above the story. Unfolded (two columns, the same 760px line as
409
+ // .today-cols in app.css) it moves to the top of the right-hand column, above Needs you (Miles 11/06).
410
+ var PET_WIDE = window.matchMedia ? window.matchMedia('(min-width: 760px)') : null;
411
+ function placePet() {
412
+ var box = $('today-pet'), side = document.querySelector('#pane-today .today-side'), rec = $('today-rec');
413
+ if (!box || !side || !rec) return;
414
+ if (PET_WIDE && PET_WIDE.matches) { if (side.firstElementChild !== box) side.insertBefore(box, side.firstElementChild); }
415
+ else if (rec.previousElementSibling !== box) rec.parentNode.insertBefore(box, rec);
416
+ }
417
+ if (PET_WIDE) { if (PET_WIDE.addEventListener) PET_WIDE.addEventListener('change', placePet); else if (PET_WIDE.addListener) PET_WIDE.addListener(placePet); }
418
+ var PET_CHANGE = '<button type="button" class="pet-change" data-goto="settings/pet">Change pet</button>';
419
+ // A still pet (a gallery pick, Cloud Puff, or a single image on the Mac): one picture for every state.
420
+ function renderPetStill(box, m, sessions) {
421
+ var c = petPose(sessions), label = petCaption(c);
422
+ petStop(); PET.pose = 'still:' + m.still;
423
+ var img = box.querySelector('.pet-still');
424
+ 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;
426
+ img = box.querySelector('.pet-still');
427
+ img.addEventListener('error', function () { box.classList.add('hidden'); });
428
+ img.addEventListener('load', function () { img.classList.toggle('pixel', img.naturalHeight > 0 && img.naturalHeight < 140); });
429
+ }
430
+ box.querySelector('.pet-caption').textContent = label;
431
+ box.querySelector('.pet-btn').setAttribute('aria-label', 'Sessions: ' + label);
432
+ box.classList.remove('hidden');
433
+ }
434
+ function renderPet(sessions) {
435
+ var box = $('today-pet');
436
+ if (!box) return;
437
+ placePet();
438
+ var prefs = petPrefs();
439
+ if (!prefs.show) { box.classList.add('hidden'); box.innerHTML = ''; petStop(); PET.pose = null; return; }
440
+ // The session list could not be read: the pet would say "Nothing running" about sessions nobody saw. Hide it.
441
+ if (state.sessionsUnread) { box.classList.add('hidden'); petStop(); PET.pose = null; return; }
442
+ var fresh = PET.manifest && PET.manifest.pet === prefs.pet && Date.now() - PET.fetchedAt < 10 * 60000;
443
+ (fresh ? Promise.resolve(PET.manifest) : api('/api/web-pet?pet=' + encodeURIComponent(prefs.pet)).then(function (m) { PET.manifest = m; PET.fetchedAt = Date.now(); return m; }))
444
+ .catch(function () { return PET.manifest && PET.manifest.pet === prefs.pet ? PET.manifest : null; })
445
+ .then(function (m) {
446
+ // A slow answer for a pet that is no longer chosen (or a pet now switched off) paints nothing.
447
+ var now = petPrefs();
448
+ if (!now.show || now.pet !== prefs.pet) return;
449
+ if (!m || !m.available) { box.classList.add('hidden'); petStop(); return; }
450
+ 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]);
453
+ var spec = m.poses[pose];
454
+ var label = petCaption(c);
455
+ box.classList.remove('hidden');
456
+ var cap = box.querySelector('.pet-caption');
457
+ if (cap) cap.textContent = label;
458
+ var btn = box.querySelector('.pet-btn');
459
+ if (btn) btn.setAttribute('aria-label', 'Sessions: ' + label);
460
+ if (PET.pose === pose && PET.timer) return;
461
+ var token = ++PET.token;
462
+ petLoadImages(spec).then(function () {
463
+ if (token !== PET.token) return;
464
+ petStop();
465
+ PET.pose = pose; PET.frame = 0;
466
+ 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;
468
+ }
469
+ box.querySelector('.pet-caption').textContent = label;
470
+ box.querySelector('.pet-btn').setAttribute('aria-label', 'Sessions: ' + label);
471
+ var el = box.querySelector('.pet-sprite'), scale = PET_HEIGHT / spec.height;
472
+ el.style.width = Math.round(spec.frameWidth * scale) + 'px';
473
+ petPaint(el, spec, 0, scale);
474
+ var still = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
475
+ if (still || spec.frames < 2) return;
476
+ var step = function () {
477
+ // Runs only while Today is on screen: leaving the pane or hiding the page stops the loop.
478
+ if (token !== PET.token || document.hidden || state.pane !== 'today') { PET.timer = null; return; }
479
+ PET.frame = (PET.frame + 1) % spec.frames;
480
+ petPaint(el, spec, PET.frame, scale);
481
+ PET.timer = setTimeout(step, Math.max(20, spec.interval * 1000));
482
+ };
483
+ PET.timer = setTimeout(step, Math.max(20, spec.interval * 1000));
484
+ }, function () {
485
+ if (token === PET.token && !box.querySelector('.pet-sprite')) box.classList.add('hidden');
486
+ });
487
+ });
241
488
  }
242
489
 
243
490
  // ── Today ───────────────────────────────────────────────
244
491
  function loadToday() {
245
492
  var now = new Date();
246
493
  $('today-date').textContent = now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) + ', ' + clock(now);
494
+ var gen = ++TODAY.gen;
495
+ // Voices and memories answer on their own time (the memories status runs a CLI on the Mac): Today paints what it
496
+ // has and says it is still reading, then repaints as each one lands.
497
+ if (Date.now() - TODAY.extraAt > 60000 || TODAY.voices.state === 'failed' || TODAY.memories.state === 'failed') {
498
+ TODAY.extraAt = Date.now();
499
+ TODAY.voices = { state: 'pending', groups: [] }; TODAY.memories = { state: 'pending', n: 0 };
500
+ api('/api/voice/held-groups').then(function (d) {
501
+ TODAY.voices = { state: 'answered', groups: (d && Array.isArray(d.groups)) ? d.groups : [] };
502
+ }, function () { TODAY.voices = { state: 'failed', groups: [] }; }).then(function () { paintNeeds(); });
503
+ api('/api/web-bridge/memories', { method: 'POST', body: { id: 'today-status', op: 'status', args: {} } }).then(function (d) {
504
+ var n = d && d.details ? d.details.learningToReview : null;
505
+ TODAY.memories = typeof n === 'number' ? { state: 'answered', n: Math.max(0, n) } : { state: 'failed', n: 0 };
506
+ }, function () { TODAY.memories = { state: 'failed', n: 0 }; }).then(function () { paintNeeds(); });
507
+ }
247
508
  Promise.all([
248
- api('/api/agent-sessions?limit=40&sort=updated').catch(function () { return null; }),
509
+ sessionList(15000).catch(function () { return null; }),
249
510
  api('/api/work-board/your-move').catch(function () { return null; }),
250
511
  api('/api/welcome-context').catch(function () { return null; }),
251
512
  api('/api/meetings/live-chunks').catch(function () { return null; })
252
513
  ]).then(function (res) {
514
+ if (gen !== TODAY.gen) return;
253
515
  var sessions = (res[0] && res[0].sessions) || [];
254
516
  state.sessions = sessions;
255
517
  var needs = (res[1] && res[1].items) || [];
@@ -259,18 +521,24 @@
259
521
  var open = sessions.filter(function (s) { return s.running && !s.running_active; });
260
522
  var recent = live.filter(function (s) { return Date.now() - (s.mtimeMs || 0) < LIVE_FRESH_MS; });
261
523
  var offline = !res[0] && !res[1];
524
+ TODAY.needs = needs; TODAY.working = working; TODAY.open = open; TODAY.welcome = welcome; TODAY.offline = offline; TODAY.at = Date.now();
525
+ // your-move answers {available:false} when the board cannot be read: that is a failed read, not "nothing".
526
+ TODAY.sessionsState = res[0] ? 'answered' : 'failed';
527
+ state.sessionsUnread = !res[0]; // the pet reads this (renderPet)
528
+ TODAY.workState = res[1] && res[1].available !== false ? 'answered' : 'failed';
529
+ TODAY.recording = false;
262
530
 
263
- renderStory(needs.length, working.length, open.length, welcome.nextEvent, false, offline);
264
531
  renderRunning(working, open);
265
- renderNeeds(needs);
532
+ if (!offline) renderPet(sessions);
533
+ paintNeeds();
266
534
  renderNext(welcome);
267
535
  loadTodayIntake();
268
- setBadge('today', needs.length > 0);
269
536
  setBadge('sessions', working.length > 0);
270
537
  liveSession(recent).then(function (rec) {
538
+ if (gen !== TODAY.gen) return;
271
539
  renderRecording(rec);
272
540
  setBadge('meetings', !!rec);
273
- if (rec) renderStory(needs.length, working.length, open.length, welcome.nextEvent, true, offline);
541
+ if (rec) { TODAY.recording = true; paintNeeds(); }
274
542
  });
275
543
  });
276
544
  }
@@ -295,37 +563,44 @@
295
563
  '<span class="rec-copy"><strong>Recording now</strong><span>' + esc(rec.source + (rec.startTime ? ', ' + span(rec.startTime) + ' so far' : '')) + '</span></span>' +
296
564
  '<span class="btn small">Live view</span></button>';
297
565
  }
298
- function renderStory(needs, working, open, next, recording, offline) {
566
+ // The headline: COS Control's brief (Models.swift TodayHome.brief) over the same rows Needs you lists.
567
+ function renderStory(m) {
299
568
  var el = $('today-story');
300
569
  el.className = 'story';
301
- if (offline) { el.textContent = 'COS on ' + state.serverName + ' is not answering right now. Today fills in when it does.'; return; }
302
- // Quiet hours (Control 0.5.361, and the rail's line): after 6 PM and at weekends what is
303
- // waiting can wait until tomorrow, so Today says so instead of "waiting on you".
304
- var nowD = new Date(), quiet = nowD.getDay() === 0 || nowD.getDay() === 6 || nowD.getHours() >= 18;
305
- var first = needs > 0
306
- ? (quiet ? countWord(needs) + ' ' + plural(needs, 'handoff', 'handoffs') + ' can wait until tomorrow'
307
- : countWord(needs) + ' ' + plural(needs, 'handoff is', 'handoffs are') + ' waiting on you')
308
- : 'Nothing is waiting on you';
309
- var nextBit = '';
310
- var nd = next && toDate(next.time);
570
+ if (TODAY.offline) { el.textContent = 'COS on ' + state.serverName + ' is not answering right now. Today fills in when it does.'; return; }
571
+ var first = '';
572
+ if (m.sessionsWaiting > 0) first = countWord(m.sessionsWaiting) + ' ' + plural(m.sessionsWaiting, 'session is', 'sessions are') + ' waiting on you';
573
+ else if (m.now.length > 0) first = countWord(m.now.length) + ' ' + plural(m.now.length, 'thing needs', 'things need') + ' you' + (m.settled ? '' : ' so far');
574
+ else if (m.later.length > 0) first = countWord(m.later.length) + ' ' + plural(m.later.length, 'thing', 'things') + ' can wait until tomorrow' + (m.settled ? '' : ' so far');
575
+ else if (m.settled) first = 'Nothing is waiting on you';
576
+ var next = TODAY.welcome && TODAY.welcome.nextEvent, nd = next && toDate(next.time);
311
577
  if (nd && next.title && (nd.getTime() - Date.now()) < 18 * 3600000 && nd.getTime() > Date.now() - 600000) {
312
- var dw = dayWord(nd);
313
- nextBit = ', and ' + next.title + ' is at ' + clock(nd) + (dw === 'today' ? '' : ' ' + dw);
578
+ var dw = dayWord(nd), words = next.title + ' is at ' + clock(nd) + (dw === 'today' ? '' : ' ' + dw);
579
+ first = first ? first + ', and ' + words : words.charAt(0).toUpperCase() + words.slice(1);
314
580
  }
315
- var second;
316
- if (working > 0) second = countWord(working) + ' ' + plural(working, 'agent is', 'agents are') + ' working' + (open > 0 ? ', with ' + countWord(open, true) + ' more ' + plural(open, 'session', 'sessions') + ' open' : '');
581
+ var working = TODAY.working.length, open = TODAY.open.length, second;
582
+ var unread = TODAY.sessionsState === 'failed';
583
+ // The sessions sentence speaks only about a list that was read (W3-r2: "No agents are running" while two worked).
584
+ if (unread) second = 'Couldn\u2019t read your sessions';
585
+ else if (working > 0) second = countWord(working) + ' ' + plural(working, 'agent is', 'agents are') + ' working' + (open > 0 ? ', with ' + countWord(open, true) + ' more ' + plural(open, 'session', 'sessions') + ' open' : '');
317
586
  else if (open > 0) second = countWord(open) + ' ' + plural(open, 'session is', 'sessions are') + ' open and quiet';
318
587
  else second = 'No agents are running';
319
- if (recording) second += (working > 0 || open > 0 ? ', and ' : '. ') + 'COS is recording a meeting';
320
- el.textContent = first + nextBit + '. ' + second + '.';
321
- }
588
+ if (TODAY.recording) second += (unread || working > 0 || open > 0 ? ', and ' : '. ') + 'COS is recording a meeting';
589
+ var out = (first ? first + '. ' : '') + second + '.';
590
+ var others = m.failed.filter(function (n) { return n !== 'sessions'; });
591
+ if (!m.settled && !m.now.length && !m.later.length) out += (unread ? others : m.failed).length ? ' Couldn\u2019t read ' + listWords(unread ? others : m.failed) + ', so this may not be everything.' : unread ? ' This may not be everything that needs you.' : ' Still reading what needs you.';
592
+ else if (unread) out += ' This may not be everything that needs you.';
593
+ el.textContent = out;
594
+ }
595
+ function listWords(names) { return names.length < 2 ? names.join('') : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1]; }
322
596
  function runRow(s, activeNow) {
323
597
  var key = splitSessionId(s.provider, s.session_id);
324
598
  var meta = activeNow
325
599
  ? 'Working for ' + span(s.state_since || s.last_activity_at)
326
600
  : 'Open, quiet for ' + span(s.last_activity_at || s.state_since);
327
601
  if (s.project && s.project !== usualProject()) meta += ', in ' + String(s.project).replace(/^-Users-[^-]+-/, '');
328
- return '<button type="button" class="run-row" data-session="' + esc(key.provider) + '|' + esc(key.id) + '">' +
602
+ // A tap opens the peek on Today (panes/today-actions.js); it falls back to the reader if the peek cannot load.
603
+ return '<button type="button" class="run-row" data-today="run" data-key="' + esc(key.provider) + '|' + esc(key.id) + '">' +
329
604
  '<span class="run-who">' + badge(key.provider) + (activeNow ? '<span class="pulse" aria-label="working"></span>' : '') + '</span>' +
330
605
  '<span class="run-title">' + esc(sessionTitle(s)) + '</span>' +
331
606
  '<span class="run-meta">' + esc(meta) + '</span></button>';
@@ -333,6 +608,8 @@
333
608
  function renderRunning(working, open) {
334
609
  $('run-count').textContent = working.length ? String(working.length) : '';
335
610
  var list = $('run-list');
611
+ // Never "Nothing running" about a list that was not read.
612
+ if (TODAY.sessionsState === 'failed') { list.innerHTML = '<button type="button" class="run-more run-failed" data-today-needs="retry">Couldn\u2019t read sessions. Try again</button>'; return; }
336
613
  var shown = working.slice(0, 3);
337
614
  var html = shown.map(function (s) { return runRow(s, true); }).join('');
338
615
  if (!shown.length) {
@@ -350,21 +627,121 @@
350
627
  if (item.status === 'delivered') return 'Handed to you';
351
628
  return 'Needs a look';
352
629
  }
353
- function renderNeeds(items) {
354
- $('needs-count').textContent = items.length ? String(items.length) : '';
355
- var list = $('needs-list');
356
- if (!items.length) { list.innerHTML = '<p class="card-empty">You are clear. New handoffs from your agents land here.</p>'; return; }
357
- // One primary action, on the item that has waited longest for a reply; the rest stay quiet.
358
- list.innerHTML = items.slice(0, 6).map(function (it, i) {
359
- var key = splitSessionId(it.provider, it.sessionId);
360
- var title = it.sessionTitle || it.title || 'Handoff';
630
+ // ── Needs you (COS Control's TodayHome.things, sourceStates and fold; Models.swift 14291, 14512, 14474) ──
631
+ // Every source Control's Today reads that the web can: sessions waiting on you (the session list), Work handoffs
632
+ // (your-move), voices to name (/api/voice/held-groups) and memories to review (the memories bridge's status). One row
633
+ // per thing. The card's count, the headline and the rail say the same number, from the same rows. "Nothing is
634
+ // waiting on you" only once every source answered; a source that failed is named instead. Under quiet hours (after
635
+ // 6 PM, before 6 AM, weekends) everything but a waiting session folds under "can wait until tomorrow".
636
+ var NEEDS_SHOWN = 6;
637
+ var NEED_NAMES = { sessions: 'sessions', work: 'Work', voices: 'voices', memories: 'memories' };
638
+ function quietNowToday(d) { var h = d.getHours(), day = d.getDay(); return day === 0 || day === 6 || h >= 18 || h < 6; }
639
+ // Control's askedTitle and askedWhy for a session waiting on you.
640
+ // Control's TodayHome: guarded(title) ?? "A session". Never the workspace path or the session id: this line is shown
641
+ // on Today and goes into the Today summary sent to the model, which carries no ids.
642
+ function askedTitle(s) {
643
+ var t = plain(s.display_label || s.custom_title || '').replace(/\s+/g, ' ').trim() || 'A session';
644
+ if (sxProvider(s.provider) !== 'claude') return t + ' is waiting for your reply';
645
+ if (s.waiting_kind === 'plan') return t + ' has a plan for you to approve';
646
+ if (s.waiting_kind === 'permission') return t + ' wants your permission';
647
+ if (s.waiting_kind === 'mcp_input') return t + ' needs your input';
648
+ return t + ' asked you a question';
649
+ }
650
+ // Control never shows a bare tool name (TodayHome.toolNames): "AskUserQuestion" says nothing to a person.
651
+ var TOOL_NAMES = ['AskUserQuestion', 'ExitPlanMode', 'Bash', 'BashOutput', 'KillShell', 'Edit', 'Write', 'Read', 'Grep', 'Glob', 'LS', 'Task', 'Agent',
652
+ 'WebFetch', 'WebSearch', 'TodoWrite', 'NotebookEdit', 'MultiEdit', 'SlashCommand', 'Skill', 'ToolSearch'];
653
+ function askedWhy(s) {
654
+ if (s.waiting_kind === 'plan') return 'Has a plan for you to approve';
655
+ var d = String(s.waiting_detail || '').trim();
656
+ if (TOOL_NAMES.indexOf(d) >= 0) d = '';
657
+ if (s.waiting_kind === 'permission' && d) return 'Wants to run ' + (d.length > 80 ? d.slice(0, 79) + '…' : d);
658
+ if (s.waiting_kind === 'question' && d) return d.length > 160 ? d.slice(0, 159) + '…' : d;
659
+ return 'Waiting for you' + (sxProvider(s.provider) === 'claude' ? '. Paused until you answer.' : '');
660
+ }
661
+ function needsModel() {
662
+ var states = { sessions: TODAY.sessionsState, work: TODAY.workState, voices: TODAY.voices.state, memories: TODAY.memories.state };
663
+ var rows = [], seen = {};
664
+ (state.sessions || []).forEach(function (s) {
665
+ if (sxKeepWarm(s) || sxState(s) !== 'waiting') return;
666
+ var k = splitSessionId(s.provider, s.session_id || s.id);
667
+ 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 });
669
+ });
670
+ (TODAY.needs || []).forEach(function (it) {
671
+ var k = splitSessionId(it.provider, it.sessionId);
672
+ if (k.id && seen[k.provider + '|' + String(k.id).toLowerCase()]) return; // Control: a Work row for a waiting session is the session's row
673
+ rows.push({ kind: 'work', tier: 3, item: it, key: k.id ? k.provider + '|' + k.id : '' });
674
+ });
675
+ var groups = TODAY.voices.groups || [];
676
+ if (groups.length) {
677
+ var named = groups.filter(function (g) { return g && g.suggestion && g.suggestion.name; })[0];
678
+ rows.push({ kind: 'voices', tier: 2, title: groups.length === 1 ? 'Name a held voice' : 'Name ' + groups.length + ' held voices',
679
+ why: named ? 'One sounds like ' + named.suggestion.name + '.' : 'Held from your recordings until they have a name.' });
680
+ }
681
+ if (TODAY.memories.n > 0) {
682
+ var n = TODAY.memories.n;
683
+ rows.push({ kind: 'memories', tier: 4, title: n === 1 ? 'Review a memory' : 'Review ' + n + ' memories', why: 'Learned from your meetings and sessions, waiting for a yes or no.' });
684
+ }
685
+ var quiet = quietNowToday(new Date());
686
+ var nowRows = rows.filter(function (r) { return !quiet || r.tier === 1; }), later = rows.filter(function (r) { return quiet && r.tier !== 1; });
687
+ var failed = Object.keys(states).filter(function (k) { return states[k] === 'failed'; }).map(function (k) { return NEED_NAMES[k]; });
688
+ var settled = Object.keys(states).every(function (k) { return states[k] === 'answered'; });
689
+ return { rows: rows, now: nowRows, later: later, failed: failed, settled: settled, states: states,
690
+ sessionsWaiting: rows.filter(function (r) { return r.kind === 'session'; }).length };
691
+ }
692
+ function paintNeeds() {
693
+ if (!TODAY.at) return;
694
+ var m = TODAY.model = needsModel();
695
+ renderStory(m);
696
+ renderNeeds(m);
697
+ setBadge('today', m.now.length > 0);
698
+ if (window.COSWeb && window.COSWeb.setStory) window.COSWeb.setStory('today', todayRailLine(m), m.now.length > 0);
699
+ }
700
+ // The rail's Today line, from the same rows (shell.js keeps its own count off Today).
701
+ function todayRailLine(m) {
702
+ var n = m.now.length, bits = [];
703
+ if (n > 0) bits.push(n === 1 ? '1 needs you' : n + ' need you');
704
+ else if (m.later.length) bits.push(m.later.length + ' can wait until tomorrow');
705
+ else if (m.settled) bits.push('Nothing needs you');
706
+ else if (m.failed.length) bits.push('Couldn’t read ' + listWords(m.failed));
707
+ else bits.push('Reading');
708
+ if (TODAY.working.length) bits.push(TODAY.working.length + ' running');
709
+ return bits.join(', ');
710
+ }
711
+ function needRowHtml(r, i) {
712
+ var age = r.since ? ago(r.since) : r.item ? ago(r.item.updatedAt || r.item.createdAt) : '';
713
+ var top = function (title) { return '<span class="need-top"><span class="need-title">' + esc(title) + '</span>' + (age ? '<span class="need-age">' + esc(age) + '</span>' : '') + '</span>'; };
714
+ if (r.kind === 'work') {
715
+ var it = r.item, k = splitSessionId(it.provider, it.sessionId);
361
716
  var why = needWhy(it) + (it.domain ? ' in ' + domainLabel(it.domain) : '');
717
+ // The row opens it in an overlay on Today (panes/today-actions.js); Open session still goes to the reader.
362
718
  return '<div class="need-row">' +
363
- '<div class="need-top"><span class="need-title">' + esc(title) + '</span><span class="need-age">' + esc(ago(it.updatedAt || it.createdAt)) + '</span></div>' +
364
- '<div class="need-foot">' + esc(why) + '</div>' +
365
- (key.id ? '<div class="need-actions"><button type="button" class="btn small' + (i === 0 ? ' primary' : '') + '" data-session="' + esc(key.provider) + '|' + esc(key.id) + '">Open session</button></div>' : '') +
366
- '</div>';
367
- }).join('');
719
+ '<button type="button" class="need-open" data-today="need" data-key="' + esc(it.id) + '"' + (k.id ? ' data-session-key="' + esc(k.provider) + '|' + esc(k.id) + '"' : '') + '>' +
720
+ top(it.sessionTitle || it.title || 'Handoff') + '<span class="need-foot">' + esc(why) + '</span></button>' +
721
+ (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
+ }
723
+ if (r.kind === 'session') {
724
+ // The session's question shows in the overlay's peek, with its cards to answer.
725
+ 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>' +
727
+ '<div class="need-actions"><button type="button" class="btn small' + (i === 0 ? ' primary' : '') + '" data-session="' + esc(r.key) + '">Open session</button></div></div>';
728
+ }
729
+ var go2 = r.kind === 'voices' ? 'speakers' : 'memories/view/review', label = r.kind === 'voices' ? 'Name' : 'Review';
730
+ return '<div class="need-row">' +
731
+ '<button type="button" class="need-open" data-goto="' + go2 + '">' + top(r.title) + '<span class="need-foot">' + esc(r.why) + '</span></button>' +
732
+ '<div class="need-actions"><button type="button" class="btn small' + (i === 0 ? ' primary' : '') + '" data-goto="' + go2 + '">' + label + '</button></div></div>';
733
+ }
734
+ function renderNeeds(m) {
735
+ var shown = TODAY.showLater ? m.now.concat(m.later) : m.now;
736
+ $('needs-count').textContent = m.now.length ? String(m.now.length) : '';
737
+ var list = $('needs-list');
738
+ var visible = TODAY.showAllNeeds ? shown : shown.slice(0, NEEDS_SHOWN);
739
+ var html = visible.map(needRowHtml).join('');
740
+ if (shown.length > visible.length) html += '<button type="button" class="need-more" data-today-needs="all">Show ' + (shown.length - visible.length) + ' more</button>';
741
+ if (m.later.length && !TODAY.showLater) html += '<button type="button" class="need-more" data-today-needs="later">' + esc(m.later.length + (m.later.length === 1 ? ' thing can' : ' things can') + ' wait until tomorrow. Show') + '</button>';
742
+ if (!m.rows.length) html = '<p class="card-empty">' + esc(m.settled ? 'Nothing is waiting on you.' : m.failed.length ? '' : 'Reading sessions, Work, voices and memories.') + '</p>';
743
+ m.failed.forEach(function (name) { html += '<button type="button" class="need-more need-failed" data-today-needs="retry">' + esc('Couldn’t read ' + name + '. Try again') + '</button>'; });
744
+ list.innerHTML = html;
368
745
  }
369
746
  function renderNext(w) {
370
747
  var card = $('next-card');
@@ -374,30 +751,73 @@
374
751
  var dw = dayWord(d);
375
752
  $('next-body').innerHTML = '<span class="when">' + esc(clock(d)) + '</span><span><span class="what">' + esc(n.title) + '</span>' +
376
753
  '<span class="sub" style="display:block">' + esc((dw.charAt(0).toUpperCase() + dw.slice(1)) + (weather ? '. Now ' + weather : '')) + '</span></span>';
754
+ // Control's Next up (todayPrepButtons): Prep me opens the Prep composer in the COS chat (panes/cos-chat.js prepFor),
755
+ // Open meeting opens Meetings. Without a server to answer, Prep me is not offered.
756
+ var prep = !TODAY.offline && window.COSWeb && window.COSWeb.cosChat && window.COSWeb.cosChat.prepFor;
757
+ $('next-actions').innerHTML = (prep ? '<button type="button" class="btn small primary" data-today-next="prep">Prep me</button>' : '') +
758
+ '<button type="button" class="link-btn" data-goto="meetings">Open meeting</button>';
377
759
  card.classList.remove('hidden');
378
760
  }
761
+ function prepNext() {
762
+ var n = TODAY.welcome && TODAY.welcome.nextEvent, d = n && toDate(n.time), chat = window.COSWeb && window.COSWeb.cosChat;
763
+ if (!n || !n.title || !chat || !chat.prepFor) return;
764
+ go('messages', ['cos']);
765
+ chat.prepFor(n.title, d ? d.toISOString() : null).catch(function (err) { toast((err && err.message) || 'Prep me did not start.'); });
766
+ }
379
767
  // From today's meetings: Work's suggestions from meetings held today. They stay here until
380
768
  // midnight, then join Sort in Work; Review opens them in Work's Sort.
381
769
  function localDay(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); }
382
770
  function loadTodayIntake() {
383
- var card = $('intake-card'); if (!card) return;
384
- api('/api/work-intake').then(function (d) {
771
+ var card = $('intake-card'); if (!card) return Promise.resolve();
772
+ return api('/api/work-intake').then(function (d) {
385
773
  var today = localDay(new Date());
386
774
  var items = ((d && d.items) || []).filter(function (it) {
387
775
  if (['suggested', 'review', 'ask'].indexOf(it.status) < 0) return false;
388
776
  var day = String((it.meeting && (it.meeting.filename || it.meeting.date)) || '').match(/\d{4}-\d{2}-\d{2}/);
389
777
  return day && day[0] === today;
390
778
  });
391
- if (!items.length) { card.classList.add('hidden'); return; }
392
- var meetings = {};
393
- items.forEach(function (it) { var t = (it.meeting && it.meeting.title) || 'A meeting'; meetings[t] = (meetings[t] || 0) + 1; });
394
- $('intake-count').textContent = String(items.length);
395
- $('intake-body').innerHTML = Object.keys(meetings).slice(0, 4).map(function (t) {
396
- return '<div class="intake-row"><span class="intake-meeting">' + esc(t) + '</span><span class="intake-n">' + meetings[t] + '</span></div>';
397
- }).join('') + '<p class="intake-foot">Moves to Work at midnight.</p>';
398
- card.classList.remove('hidden');
779
+ TODAY.intake = items; TODAY.intakeCaps = (d && d.capabilities) || {};
780
+ // A decision made on Today hides its row at once; a read that no longer lists it ends the hiding.
781
+ Object.keys(TODAY.gone).forEach(function (id) { if (!items.some(function (it) { return it.id === id; })) delete TODAY.gone[id]; });
782
+ paintTodayIntake();
399
783
  }, function () { card.classList.add('hidden'); });
400
784
  }
785
+ // Today's overlay (panes/today-actions.js) reads what the cards show from here and repaints the intake card after a
786
+ // decision, so the counts move the moment Work takes it.
787
+ var TODAY = { needs: [], working: [], open: [], welcome: {}, offline: false, at: 0, intake: null, intakeCaps: {}, gone: {},
788
+ gen: 0, sessionsState: 'pending', workState: 'pending', voices: { state: 'pending', groups: [] }, memories: { state: 'pending', n: 0 }, extraAt: 0,
789
+ recording: false, model: null, showAllNeeds: false, showLater: false };
790
+ function todayIntakeOpen() { return (TODAY.intake || []).filter(function (it) { return !TODAY.gone[it.id]; }); }
791
+ function paintTodayIntake() {
792
+ var card = $('intake-card'); if (!card) return;
793
+ var items = todayIntakeOpen();
794
+ if (!items.length) { card.classList.add('hidden'); return; }
795
+ var meetings = {};
796
+ items.forEach(function (it) { var t = (it.meeting && it.meeting.title) || 'A meeting'; meetings[t] = (meetings[t] || 0) + 1; });
797
+ $('intake-count').textContent = String(items.length);
798
+ $('intake-body').innerHTML = Object.keys(meetings).slice(0, 4).map(function (t) {
799
+ return '<button type="button" class="intake-row" data-today="meeting" data-key="' + esc(t) + '"><span class="intake-meeting">' + esc(t) + '</span><span class="intake-n">' + meetings[t] + '</span></button>';
800
+ }).join('') + '<p class="intake-foot">Moves to Work at midnight.</p>';
801
+ card.classList.remove('hidden');
802
+ }
803
+ function openTodayAction(t) {
804
+ var kind = t.getAttribute('data-today'), key = t.getAttribute('data-key') || '';
805
+ ensurePane('todayActions', 4000).then(function (m) {
806
+ if (!m || !m.open) throw new Error('The Today overlay did not start.');
807
+ m.open(kind, key, t);
808
+ }).catch(function (err) {
809
+ // Without the overlay, the tap goes where it went before: the session reader, or Sort in Work. Web has no client
810
+ // log route, so the failure is said on screen and in the console first. An overlay that half opened leaves its
811
+ // history entry, which the fallback replaces, so Back from there still lands on Today.
812
+ var s = String(kind === 'run' || kind === 'wait' ? key : t.getAttribute('data-session-key') || '').split('|');
813
+ 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']);
815
+ 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.');
817
+ if (history.state && history.state.cosTodayOverlay) location.replace('#/' + dest.map(encodeURIComponent).join('/'));
818
+ else go(dest[0], dest.slice(1));
819
+ });
820
+ }
401
821
  function setBadge(name, on) { document.querySelectorAll('[data-badge="' + name + '"]').forEach(function (b) { b.classList.toggle('hidden', !on); }); }
402
822
 
403
823
  // ── Sessions ────────────────────────────────────────────
@@ -583,9 +1003,21 @@
583
1003
  if (SX.view !== 'sessions') { renderUsageView(force === true); return Promise.resolve(); }
584
1004
  loadSessionHooks();
585
1005
  if (force !== true && SX.rows && Date.now() - SX.loadedAt < 15000) { renderSessionGroups(); return Promise.resolve(); }
586
- SX.loading = true;
587
- renderSessionGroups();
588
- return api('/api/agent-sessions?limit=80&sort=updated').then(function (d) {
1006
+ // Paint the shared copy at once (Today usually fetched it) and refresh in place behind it.
1007
+ var cached = !SX.rows && sessionListCached();
1008
+ if (cached && cached.data && Array.isArray(cached.data.sessions)) {
1009
+ SX.rows = cached.data.sessions; SX.dropped = cached.data.dropped || null; SX.enabled = cached.data.enabled !== false;
1010
+ SX.loadedAt = cached.at;
1011
+ if (sxQueryActive()) SX.instant = sxInstant(SX.q);
1012
+ var fresh = force !== true && Date.now() - cached.at < 15000;
1013
+ SX.loading = !fresh; // rows stay on screen; the header reads "Refreshing…" until the new list lands
1014
+ renderSessionGroups();
1015
+ if (fresh) return Promise.resolve();
1016
+ } else {
1017
+ SX.loading = true;
1018
+ renderSessionGroups();
1019
+ }
1020
+ return sessionList(force === true ? 0 : 15000).then(function (d) {
589
1021
  SX.rows = (d && d.sessions) || [];
590
1022
  SX.dropped = (d && d.dropped) || null;
591
1023
  SX.enabled = !(d && d.enabled === false);
@@ -1041,7 +1473,8 @@
1041
1473
  if (f.tools.length > 8) f.tools.splice(0, f.tools.length - 8);
1042
1474
  }
1043
1475
  }
1044
- 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' };
1476
+ // `other` is the stream's verb for every tool it has no verb for; its target names the tool ("Used SendMessage").
1477
+ 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' };
1045
1478
  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; }
1046
1479
  // The word follows the page's own working flag, which a Stop or a finished turn settles before
1047
1480
  // the stream's status catches up; otherwise the server's derived state, as Control reads it.
@@ -1220,14 +1653,15 @@
1220
1653
  // probeChatAttachability): a busy thread parks a send, another app holding it idle asks first,
1221
1654
  // and anything else is the server's refusal, word for word.
1222
1655
  function sxProbeAttach(r) {
1223
- apiAnswer(sessionPath(r.provider, r.id, '/attachability')).then(function (a) {
1224
- if (state.reading !== r) return;
1225
- if (a.status === 404) { r.verdict = { attachable: false, reason: 'route_absent', reasonCopy: 'Update the COS server to continue a thread from here.', ownerCount: 0 }; renderVerdict(r); return; }
1656
+ // Today's overlay probes its own session object (r.view): the same verdict, painted by its view.
1657
+ return apiAnswer(sessionPath(r.provider, r.id, '/attachability')).then(function (a) {
1658
+ 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; }
1226
1660
  var b = a.body || {};
1227
1661
  if (a.status !== 200 || typeof b.attachable !== 'boolean') return;
1228
1662
  var owners = Number(b.ownerCount) || 0;
1229
1663
  r.verdict = { attachable: b.attachable, reason: String(b.reason || ''), reasonCopy: String(b.reasonCopy || ''), ownerCount: owners, caution: b.attachable === true && owners > 0 };
1230
- renderVerdict(r);
1664
+ viewOf(r).verdict(r);
1231
1665
  }).catch(function () {});
1232
1666
  }
1233
1667
  var SX_PARK = {
@@ -1278,8 +1712,8 @@
1278
1712
  el.className = 'status-line' + (kind === 'error' ? ' error' : '');
1279
1713
  el.innerHTML = esc(text || '') + (actionHtml || '');
1280
1714
  }
1281
- function draftFor(text) {
1282
- var r = state.reading;
1715
+ function draftFor(text, r) {
1716
+ r = r || state.reading;
1283
1717
  if (!r.draft || r.draft.text !== text) r.draft = { text: text, key: newTurnKey() };
1284
1718
  return r.draft;
1285
1719
  }
@@ -1312,6 +1746,21 @@
1312
1746
  r.held.forEach(function (h) { if (!h.cancelled && !heldBubble(h.key)) showPendingTurn(h.text, 'sends after this reply', h.key); });
1313
1747
  }
1314
1748
 
1749
+ // Where the send path below reports: the reader's own page by default. Today's overlay (panes/today-actions.js)
1750
+ // hands continueSession a session object with its own view, so a reply or a steer from Today runs this same attach,
1751
+ // turn and queue path (one binding store, one key per draft, the same refusals). The overlay keeps its own copies of
1752
+ // the gates around it (sendSessionTurn), the turn watch (watchTurn) and the refusal box (renderVerdict).
1753
+ var READER_VIEW = {
1754
+ status: function (r, text, kind, actionHtml) { sessionStatus(text, kind, actionHtml); },
1755
+ pending: function (r, text, note) { showPendingTurn(text, note); },
1756
+ clear: function (r, text) { clearBoxOf(text); },
1757
+ verdict: function (r) { renderVerdict(r); },
1758
+ hold: function (r, draft) { holdUntilReplyEnds(r, draft); },
1759
+ watch: function (r, bindingId, key) { watchTurn(bindingId, key, r.provider, r.id); },
1760
+ queue: function (r) { loadQueue(r.provider, r.id); }
1761
+ };
1762
+ function viewOf(r) { return (r && r.view) || READER_VIEW; }
1763
+
1315
1764
  function sendSessionTurn(fork, opts) {
1316
1765
  var r = state.reading; var form = $('session-form');
1317
1766
  if (!r || !form) return;
@@ -1345,8 +1794,8 @@
1345
1794
  // Resolves to 'sent' | 'parked' | 'held' | 'refused' | 'changed'.
1346
1795
  function continueSession(r, text, opts) {
1347
1796
  var provider = r.provider, id = r.id;
1348
- var draft = opts.held ? { text: opts.held.text, key: opts.held.key } : draftFor(text);
1349
- if (!opts.held) sessionStatus('Sending');
1797
+ var draft = opts.held ? { text: opts.held.text, key: opts.held.key } : draftFor(text, r), view = viewOf(r);
1798
+ if (!opts.held) view.status(r, 'Sending');
1350
1799
  var bound = bindingFor(provider, id);
1351
1800
  var attach = bound ? Promise.resolve({ ok: true, binding: bound }) : apiAnswer(sessionPath(provider, id, '/attach'), { method: 'POST', body: { cosSessionId: cosSessionId() }, timeoutMs: 20000 }).then(function (a) {
1352
1801
  // 201 Created on success; any 2xx that says attached counts.
@@ -1368,9 +1817,9 @@
1368
1817
  if (opts.held) settleHeld(r, opts.held, 'refused', copy);
1369
1818
  else {
1370
1819
  // Control's refusal box: the server's words, then Fork with this message.
1371
- sessionStatus('');
1820
+ view.status(r, '');
1372
1821
  r.refusal = { copy: (at.refusal && at.refusal.reasonCopy) || 'COS cannot write to this session right now.', supplement: sxSupplement(why), fork: true };
1373
- renderVerdict(r);
1822
+ view.verdict(r);
1374
1823
  }
1375
1824
  return 'refused';
1376
1825
  }
@@ -1381,9 +1830,9 @@
1381
1830
  var b = t.body || {};
1382
1831
  if (t.status >= 200 && t.status < 300 && (b.outcome === 'queued' || b.outcome === 'completed')) {
1383
1832
  if (opts.held) settleHeld(r, opts.held, 'sent');
1384
- else { showPendingTurn(draft.text, b.replayed ? 'already sent' : 'sent'); clearBoxOf(draft.text); }
1385
- sessionStatus(b.replayed ? 'Already sent. Watching for the reply.' : 'Sent. Watching for the reply.');
1386
- watchTurn(at.binding.bindingId, draft.key, provider, id);
1833
+ else { view.pending(r, draft.text, b.replayed ? 'already sent' : 'sent'); view.clear(r, draft.text); }
1834
+ view.status(r, b.replayed ? 'Already sent. Watching for the reply.' : 'Sent. Watching for the reply.');
1835
+ view.watch(r, at.binding.bindingId, draft.key);
1387
1836
  return 'sent';
1388
1837
  }
1389
1838
  var reason = b.reason || '';
@@ -1391,15 +1840,15 @@
1391
1840
  // COS's own reply is still running on this thread. The server's queue counts that
1392
1841
  // thread as free (the running child is COS's), so it cannot park this; the page holds
1393
1842
  // it under the same key and sends it the moment the reply ends.
1394
- if (reason === 'native_turn_in_progress') { if (!opts.held) holdUntilReplyEnds(r, draft); return 'held'; }
1843
+ if (reason === 'native_turn_in_progress') { if (!opts.held) view.hold(r, draft); return 'held'; }
1395
1844
  if (reason === 'native_thread_changed' && b.revision) {
1396
1845
  if (opts.held) settleHeld(r, opts.held, 'refused', 'This session changed on your Mac while your message waited.');
1397
1846
  else {
1398
1847
  // Control's refusal box for a changed thread: Refresh, Continue anyway, Fork with this message.
1399
1848
  r.sendAnyway = b.revision;
1400
- sessionStatus('');
1849
+ view.status(r, '');
1401
1850
  r.refusal = { copy: b.reasonCopy || 'This session changed on your Mac since you opened it.', changed: true, fork: true };
1402
- renderVerdict(r);
1851
+ view.verdict(r);
1403
1852
  }
1404
1853
  return 'changed';
1405
1854
  }
@@ -1409,7 +1858,7 @@
1409
1858
  }
1410
1859
  var refusedCopy = b.reasonCopy || 'The session did not take that message.';
1411
1860
  if (opts.held) settleHeld(r, opts.held, 'refused', refusedCopy);
1412
- else sessionStatus(refusedCopy + ' It is still in the box.', 'error');
1861
+ else view.status(r, refusedCopy + ' It is still in the box.', 'error');
1413
1862
  return 'refused';
1414
1863
  });
1415
1864
  });
@@ -1469,6 +1918,7 @@
1469
1918
  }
1470
1919
 
1471
1920
  function parkTurn(r, draft, refusal, opts) {
1921
+ var view = viewOf(r);
1472
1922
  return apiAnswer(sessionPath(r.provider, r.id, '/queued-turns'), {
1473
1923
  method: 'POST', timeoutMs: 15000,
1474
1924
  body: { cosSessionId: cosSessionId(), clientTurnId: draft.key, prompt: draft.text },
@@ -1477,14 +1927,14 @@
1477
1927
  if (b.queued === true || b.error === 'duplicate_turn') {
1478
1928
  rememberParked(r, draft.key);
1479
1929
  if (opts.held) settleHeld(r, opts.held, 'parked');
1480
- else { showPendingTurn(draft.text, 'waiting in line'); clearBoxOf(draft.text); }
1481
- sessionStatus(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.'));
1482
- loadQueue(r.provider, r.id);
1930
+ else { view.pending(r, draft.text, 'waiting in line'); view.clear(r, draft.text); }
1931
+ 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
+ view.queue(r);
1483
1933
  return 'parked';
1484
1934
  }
1485
1935
  if (b.error === 'thread_free' && !opts.freeRetry) return continueSession(r, draft.text, Object.assign({}, opts, { freeRetry: true }));
1486
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.');
1487
- if (opts.held) settleHeld(r, opts.held, 'refused', copy); else sessionStatus(copy + ' It is still in the box.', 'error');
1937
+ if (opts.held) settleHeld(r, opts.held, 'refused', copy); else view.status(r, copy + ' It is still in the box.', 'error');
1488
1938
  return 'refused';
1489
1939
  });
1490
1940
  }
@@ -1749,21 +2199,31 @@
1749
2199
  var h = Array.prototype.map.call(b, function (x) { return (x + 0x100).toString(16).slice(1); }).join('');
1750
2200
  return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20);
1751
2201
  }
2202
+ var TODAY_REFERENCE = 'What Today in COS Control shows right now';
1752
2203
  function askCos(prompt, statusEl) {
1753
2204
  // 6.99.12: "it shows Sent but never shows up" (Miles, 11/04). With Ask COS in Messages, the sheet hands its words to
1754
2205
  // that composer (panes/cos-chat.js submitAsk), so the answer streams on screen and lands as a numbered turn. The
1755
2206
  // request below is the fallback for a page without it; it resolves to the Messages route to open.
1756
2207
  var chat = window.COSWeb && window.COSWeb.cosChat;
2208
+ // Asked from Today: COS gets a short summary of what Today shows (panes/today-actions.js summary), as the Ask's
2209
+ // quoted reference, the query-jobs field for context. Asked from any other pane, nothing extra is sent.
2210
+ var context = state.pane === 'today' ? ensurePane('todayActions', 1500).then(function (m) {
2211
+ // 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 ''; }
2213
+ }, function () { return ''; }) : Promise.resolve('');
1757
2214
  if (chat && chat.askFromSheet) {
1758
2215
  setStatus(statusEl, 'Sending…');
1759
- return chat.askFromSheet(prompt).then(function (where) { setStatus(statusEl, ''); return where; }, function (err) {
2216
+ return context.then(function (ctx) { return chat.askFromSheet(prompt, ctx ? { context: ctx } : undefined); }).then(function (where) { setStatus(statusEl, ''); return where; }, function (err) {
1760
2217
  setStatus(statusEl, err.message, true);
1761
2218
  throw err;
1762
2219
  });
1763
2220
  }
1764
2221
  setStatus(statusEl, 'Sent. COS is answering.');
1765
2222
  var body = { clientJobId: webJobId(), generation: 1, query: prompt, source: 'web', activityToolMode: 'status' };
1766
- return api('/api/query-jobs', { method: 'POST', body: body, timeoutMs: 30000 }).then(function () {
2223
+ return context.then(function (ctx) {
2224
+ if (ctx) body.reference = { query: TODAY_REFERENCE, response: ctx };
2225
+ return api('/api/query-jobs', { method: 'POST', body: body, timeoutMs: 30000 });
2226
+ }).then(function () {
1767
2227
  // The answer lands in Messages; refresh it there if it is open.
1768
2228
  var again = function () { if (state.pane === 'messages' && PANES.messages && PANES.messages.refresh) PANES.messages.refresh(); };
1769
2229
  setTimeout(again, 4000);
@@ -1774,27 +2234,6 @@
1774
2234
  throw err;
1775
2235
  });
1776
2236
  }
1777
- // ── Memories (the native page, hosted) ──────────────────
1778
- function syncFrameTheme() {
1779
- var frame = $('memories-frame'); if (!frame) return;
1780
- var mode = window.CosTheme ? window.CosTheme.mode() : 'light';
1781
- frame.style.colorScheme = mode;
1782
- try {
1783
- var root = frame.contentDocument && frame.contentDocument.documentElement;
1784
- if (root) {
1785
- root.setAttribute('data-theme', mode);
1786
- if (isPhone()) root.setAttribute('data-host', 'web-phone'); else root.removeAttribute('data-host');
1787
- }
1788
- } catch (_e) {}
1789
- }
1790
- function loadMemories() {
1791
- var frame = $('memories-frame');
1792
- if (state.memoriesLoaded) { syncFrameTheme(); return; }
1793
- state.memoriesLoaded = true;
1794
- frame.addEventListener('load', syncFrameTheme);
1795
- frame.src = '/control/memories/memories.html';
1796
- }
1797
-
1798
2237
  // ── Usage ───────────────────────────────────────────────
1799
2238
  // Usage and COS cost are views inside Sessions, as on the Mac (ActivityWindow.swift 2306-2325);
1800
2239
  // an old #/usage link opens the Usage view there. Data: GET /api/usage/summary, the helper's
@@ -3392,7 +3831,7 @@
3392
3831
  var left = exp ? Math.round((exp.getTime() - Date.now()) / 1000) : 0;
3393
3832
  var el = $('device-code-left');
3394
3833
  if (!el) { clearInterval(codeTimer); return; }
3395
- if (left <= 0) { clearInterval(codeTimer); panel.innerHTML = '<p>That code expired. Choose Sign in another device for a new one.</p>'; return; }
3834
+ if (left <= 0) { clearInterval(codeTimer); panel.innerHTML = '<p>That code expired. Choose Use COS on another phone or browser for a new one.</p>'; return; }
3396
3835
  el.textContent = 'Works once, for ' + (left >= 60 ? Math.ceil(left / 60) + ' more min' : left + ' more sec') + '.';
3397
3836
  };
3398
3837
  tick(); codeTimer = setInterval(tick, 1000);
@@ -3413,7 +3852,57 @@
3413
3852
  ['today', 'Today'], ['messages', 'Messages'], ['speakers', 'Speakers'], ['meetings', 'Meetings'], ['memories', 'Memories'],
3414
3853
  ['threads', 'Threads'], ['sessions', 'Sessions'], ['work', 'Work'], ['dictation', 'Dictation'], ['usage', 'Usage'], ['settings', 'Settings']
3415
3854
  ];
3855
+ // ⌘K's File to Work inbox (Control's todaySubmitAsk, ActivityWindow.swift 8810): the words become a task in the Work
3856
+ // inbox, in Control's taskDomain (quilt when the server has it, else the first domain it lists: reconcileTaskDomain),
3857
+ // through POST /api/tasks/capture,
3858
+ // the route Work's Add task uses. One captureId per text: a second tap, or a retry after a lost answer, files it once.
3859
+ var ASK_FILE = { text: '', id: '', busy: false, domains: null, note: null };
3860
+ // The result shows on the row itself, so it is in view wherever the sheet is (W5-r2: a status line under the sheet
3861
+ // at 1000x703 hid a refusal), and a toast says it too.
3862
+ function fileNoteHtml(q) {
3863
+ var n = ASK_FILE.note && ASK_FILE.note.text === q ? ASK_FILE.note : null;
3864
+ if (n) return '<span class="note' + (n.error ? ' file-error' : '') + '" role="' + (n.error ? 'alert' : 'status') + '">' + esc(n.line) + '</span>';
3865
+ return '<span class="note">' + esc('As a task in ' + fileDomainLabel()) + '</span>';
3866
+ }
3867
+ function fileDomainEntry() {
3868
+ var d = ASK_FILE.domains || [];
3869
+ return d.filter(function (x) { return x && x.name === 'quilt'; })[0] || d.filter(function (x) { return x && x.name; })[0] || null;
3870
+ }
3871
+ function fileDomain() { var e = fileDomainEntry(); return e ? e.name : 'quilt'; }
3872
+ function fileDomainLabel() { var e = fileDomainEntry(); return (e && e.label) || domainLabel(fileDomain()); }
3873
+ function loadFileDomains() {
3874
+ if (ASK_FILE.domains) return Promise.resolve();
3875
+ return api('/api/domains').then(function (d) { ASK_FILE.domains = (d && Array.isArray(d.domains)) ? d.domains : []; }, function () { ASK_FILE.domains = null; });
3876
+ }
3877
+ function fileToInbox(text) {
3878
+ if (ASK_FILE.busy || !text) return;
3879
+ if (ASK_FILE.text !== text) { ASK_FILE.text = text; ASK_FILE.id = webJobId(); }
3880
+ ASK_FILE.busy = true; ASK_FILE.note = null; renderAskFile();
3881
+ loadFileDomains().then(function () {
3882
+ return api('/api/tasks/capture', { method: 'POST', body: { domain: fileDomain(), text: text, section: 'inbox', captureId: ASK_FILE.id }, timeoutMs: 20000 });
3883
+ }).then(function () {
3884
+ ASK_FILE.busy = false; ASK_FILE.text = ''; ASK_FILE.note = null;
3885
+ var box = $('ask-q'); if (box && box.value.trim() === text) { box.value = ''; renderAsk(); }
3886
+ renderAskFile();
3887
+ toast('Filed to the Work inbox.');
3888
+ if (PANES.work && PANES.work.intakeChanged) PANES.work.intakeChanged(); // Work reads its board again when it opens
3889
+ }, function (err) {
3890
+ ASK_FILE.busy = false;
3891
+ var line = 'Not filed: ' + ((err && err.message) || 'your Mac did not answer') + ' Filing again is safe.';
3892
+ ASK_FILE.note = { text: text, line: line, error: true };
3893
+ renderAskFile();
3894
+ toast(line);
3895
+ });
3896
+ }
3897
+ function renderAskFile() {
3898
+ var b = document.querySelector('#ask-results [data-action="ask-file"]'); if (!b) return;
3899
+ b.disabled = ASK_FILE.busy;
3900
+ var g = b.querySelector('.grow'); if (!g) return;
3901
+ var q = ($('ask-q') && $('ask-q').value.trim()) || '';
3902
+ g.innerHTML = esc(ASK_FILE.busy ? 'Filing\u2026' : 'File to Work inbox') + fileNoteHtml(q);
3903
+ }
3416
3904
  function openAsk(prefill) {
3905
+ loadFileDomains().then(function () { if (!$('ask-sheet').classList.contains('hidden') && $('ask-q').value.trim()) renderAsk(); });
3417
3906
  $('ask-sheet').classList.remove('hidden');
3418
3907
  document.body.style.overflow = 'hidden';
3419
3908
  var input = $('ask-q'); input.value = prefill || '';
@@ -3434,7 +3923,8 @@
3434
3923
  }
3435
3924
  var jumps = JUMPS.filter(function (j) { return j[1].toLowerCase().indexOf(q.toLowerCase()) === 0; });
3436
3925
  out.innerHTML = '<div class="list" style="margin-top:14px">' +
3437
- '<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) + '”</span></span></button>' +
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>' +
3927
+ '<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>' +
3438
3928
  '<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>' +
3439
3929
  '<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>' +
3440
3930
  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('') +
@@ -3451,7 +3941,7 @@
3451
3941
 
3452
3942
  // ── events (one delegated listener) ─────────────────────
3453
3943
  document.addEventListener('click', function (e) {
3454
- var t = e.target.closest('[data-goto],[data-pane],[data-session],[data-meeting],[data-speakers],[data-action],[data-open-ask],[data-theme-choice],#topbar-back,#section-btn,#sign-out,#copy-link,#ask-close,#auth-switch,#auth-token-show,#device-code-btn');
3944
+ var t = e.target.closest('[data-today-next],[data-today-needs],[data-today],[data-goto],[data-pane],[data-session],[data-meeting],[data-speakers],[data-action],[data-open-ask],[data-theme-choice],#topbar-back,#section-btn,#sign-out,#copy-link,#ask-close,#auth-switch,#auth-token-show,#device-code-btn');
3455
3945
  if (!t) { if (e.target.id === 'ask-sheet') closeAsk(); if (e.target.id === 'section-menu') closeMenu(); return; }
3456
3946
  var inSheet = !!t.closest('#ask-sheet');
3457
3947
  if (t.id === 'topbar-back') return goBack();
@@ -3482,7 +3972,16 @@
3482
3972
  return;
3483
3973
  }
3484
3974
  if (t.hasAttribute('data-open-ask')) return openAsk();
3485
- if (t.hasAttribute('data-theme-choice')) { window.CosTheme.set(t.getAttribute('data-theme-choice')); paintAppearance(); syncFrameTheme(); return; }
3975
+ if (t.hasAttribute('data-today')) return openTodayAction(t);
3976
+ if (t.hasAttribute('data-today-next')) return prepNext();
3977
+ if (t.hasAttribute('data-today-needs')) {
3978
+ var which = t.getAttribute('data-today-needs');
3979
+ if (which === 'all') TODAY.showAllNeeds = true;
3980
+ else if (which === 'later') TODAY.showLater = true;
3981
+ else { TODAY.extraAt = 0; return loadToday(); }
3982
+ return paintNeeds();
3983
+ }
3984
+ if (t.hasAttribute('data-theme-choice')) { window.CosTheme.set(t.getAttribute('data-theme-choice')); paintAppearance(); return; }
3486
3985
  if (t.hasAttribute('data-goto') || t.hasAttribute('data-pane')) {
3487
3986
  if (inSheet) closeAsk(); closeMenu();
3488
3987
  // "work/sort" is a pane and its arguments, not one name.
@@ -3506,11 +4005,12 @@
3506
4005
  if (action === 'expand-turn') { var tr = document.querySelector('.turn[data-turn="' + t.getAttribute('data-turn') + '"]'); if (tr) tr.classList.remove('clamped'); t.remove(); return; }
3507
4006
  var q = $('ask-q').value.trim();
3508
4007
  if (action === 'ask-cos' && q) { askCos(q, $('ask-status')).then(function (where) { closeAsk(); go('messages', Array.isArray(where) ? where : []); }).catch(function () {}); return; }
4008
+ if (action === 'ask-file' && q) return fileToInbox(q);
3509
4009
  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; }
3510
4010
  if (action === 'ask-work' && q) {
3511
4011
  closeAsk(); go('work');
3512
4012
  // The Work pane (panes/work.js) owns its search box; hand it the query.
3513
- if (PANES.work && PANES.work.search) PANES.work.search(q);
4013
+ ensurePane('work').then(function () { if (PANES.work && PANES.work.search) PANES.work.search(q); }, function () {});
3514
4014
  }
3515
4015
  });
3516
4016
 
@@ -3554,14 +4054,14 @@
3554
4054
  });
3555
4055
  });
3556
4056
  window.addEventListener('hashchange', route);
3557
- // Folding or unfolding the Duo crosses the 600px line: keep the hosted Memories page in step.
3558
- window.matchMedia('(min-width: 600px)').addEventListener('change', syncFrameTheme);
3559
4057
  window.addEventListener('scroll', onScroll, { passive: true });
3560
4058
  document.addEventListener('visibilitychange', function () {
3561
4059
  if (document.hidden || $('app-shell').classList.contains('hidden')) return;
3562
4060
  // Coming back to the page refreshes Today in place. Other panes keep what is on screen:
3563
4061
  // rebuilding them here moved the page under Miles's finger ("it reloads the section").
3564
4062
  if (state.pane === 'today' && Date.now() - (state.lastLoad.today || 0) > 60000) { state.lastLoad.today = Date.now(); loadToday(); }
4063
+ // Back within the minute: Today keeps its data, but the pet's loop stopped while hidden, so restart it.
4064
+ else if (state.pane === 'today' && PET.pose && !PET.timer) renderPet(state.sessions || []);
3565
4065
  });
3566
4066
  window.addEventListener('message', function (e) {
3567
4067
  if (e.origin !== location.origin) return;
@@ -3603,6 +4103,9 @@
3603
4103
  readStore: readStore, writeStore: writeStore, copyText: copyText, isPhone: isPhone,
3604
4104
  state: state, titles: TITLES, go: go, parseRoute: parseRoute, goBack: goBack,
3605
4105
  registerPane: function (name, def) { PANES[name] = def; },
4106
+ sessionList: sessionList,
4107
+ petPrefs: petPrefs,
4108
+ setPetPrefs: setPetPrefs,
3606
4109
  onRoute: function (fn) { routeHooks.push(fn); },
3607
4110
  onShell: function (fn) { shellHooks.push(fn); },
3608
4111
  // The detail title after "COS Control > Section" (Control's breadcrumb). Pane modules set it
@@ -3616,6 +4119,41 @@
3616
4119
  sessionState: function (s) { return sxState(s); },
3617
4120
  isKeepWarm: function (s) { return sxKeepWarm(s); }
3618
4121
  };
4122
+ // Today as a place to act (panes/today-actions.js, Miles 11/06): what the Today cards show, a repaint after a
4123
+ // decision, and the Sessions reader's own pieces, so the overlay sends and reads through the reader's code.
4124
+ window.COSWeb.pane = function (name) { return PANES[name] || null; };
4125
+ // Tap, then tap again (Today's Dismiss, Stop and Send anyway; a question card's Allow, Deny and Send): the second tap
4126
+ // counts only once the first has been on screen ARM_CONFIRM_MS, so a double tap or a finger bounce arms and never
4127
+ // confirms (W2-r2). The one rule both panes/today-actions.js and panes/questions.js read.
4128
+ var ARM_CONFIRM_MS = 400;
4129
+ window.COSWeb.armConfirmMs = ARM_CONFIRM_MS;
4130
+ window.COSWeb.confirmReady = function (armedAt) { return typeof armedAt === 'number' && Date.now() - armedAt >= ARM_CONFIRM_MS; };
4131
+ window.COSWeb.today = {
4132
+ data: function () {
4133
+ return { needs: TODAY.needs, working: TODAY.working, open: TODAY.open, welcome: TODAY.welcome, offline: TODAY.offline, at: TODAY.at,
4134
+ sessions: state.sessions || [], intake: todayIntakeOpen(), intakeCaps: TODAY.intakeCaps, intakeLoaded: TODAY.intake !== null };
4135
+ },
4136
+ needWhy: needWhy, sessionTitle: sessionTitle, badge: badge, span: span, splitSessionId: splitSessionId,
4137
+ // Needs you as Today lists it (all four sources), for the overlay's summary and the rail.
4138
+ needs: function () { return TODAY.model; },
4139
+ railLine: function () { return TODAY.model ? { text: todayRailLine(TODAY.model), hot: TODAY.model.now.length > 0 } : null; },
4140
+ markGone: function (id, gone) { if (gone) TODAY.gone[id] = 1; else delete TODAY.gone[id]; paintTodayIntake(); },
4141
+ reloadIntake: function () { return loadTodayIntake(); },
4142
+ refresh: function () { if (state.pane === 'today') loadToday(); }
4143
+ };
4144
+ window.COSWeb.sessionKit = {
4145
+ // A session object for continueSession, with the overlay's own view (READER_VIEW's methods).
4146
+ session: function (provider, id, view) {
4147
+ return { provider: provider, id: id, draft: null, held: [], view: view, verdict: null, refusal: null, cautionAck: false, feed: newFeed(), live: null, queued: 0 };
4148
+ },
4149
+ send: function (r, text, opts) { return continueSession(r, text, opts || {}); },
4150
+ newKey: newTurnKey,
4151
+ probe: function (r) { return sxProbeAttach(r); },
4152
+ path: sessionPath, answer: apiAnswer,
4153
+ feed: { fresh: newFeed, apply: applyFeed, word: sxFeedWord, detail: sxFeedDetail, tool: sxToolLine, elapsed: sxElapsed, fallback: sxFallbackLine },
4154
+ title: sxTitle, stateOf: sxState, stateLabel: function (s) { return sxStateLabel(s, sxState(s)); },
4155
+ parkCopy: function (p) { return SX_PARK[sxProvider(p)]; }, providerLabel: sxProviderLabel, markdown: sxMd
4156
+ };
3619
4157
 
3620
4158
  // Every pane module is a plain script after this one; boot waits for all of them.
3621
4159
  function boot() {