@gotcos/glasses-server 6.99.20 → 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 (65) hide show
  1. package/CHANGELOG.md +257 -0
  2. package/docs/work-orchestrator.md +20 -0
  3. package/package.json +1 -1
  4. package/server/index.ts +48 -5
  5. package/server/lib/agent-session-output-images.ts +280 -0
  6. package/server/lib/agent-session-store.ts +3 -1
  7. package/server/lib/agent-session-turns.ts +18 -7
  8. package/server/lib/client-liveness.ts +38 -3
  9. package/server/lib/codex-subagent-activity.ts +36 -0
  10. package/server/lib/g2-turn-provenance.ts +679 -0
  11. package/server/lib/media-store.ts +41 -2
  12. package/server/lib/permission-broker.ts +94 -17
  13. package/server/lib/queue-turn-evidence.ts +19 -2
  14. package/server/lib/session-halt.ts +15 -1
  15. package/server/lib/session-stream-events.ts +4 -0
  16. package/server/lib/session-subagents.ts +155 -0
  17. package/server/lib/session-turn-attachments.ts +245 -0
  18. package/server/lib/thread-turn-queue-deliver.ts +17 -2
  19. package/server/lib/thread-turn-queue.ts +10 -0
  20. package/server/lib/today-brief.ts +731 -0
  21. package/server/lib/turn-file-store.ts +202 -0
  22. package/server/lib/turn-footer.ts +81 -0
  23. package/server/lib/web-control-static.ts +226 -0
  24. package/server/lib/web-memories-bridge.ts +9 -8
  25. package/server/lib/web-pet-choice.ts +225 -0
  26. package/server/lib/web-pet.ts +226 -0
  27. package/server/lib/work-lens-rounds.ts +29 -4
  28. package/server/routes/agent-session-bindings.ts +164 -16
  29. package/server/routes/agent-sessions.ts +77 -8
  30. package/server/routes/client-instance.ts +12 -1
  31. package/server/routes/cursor-stop-followup.ts +30 -1
  32. package/server/routes/g2-turns.ts +46 -0
  33. package/server/routes/health.ts +9 -0
  34. package/server/routes/media.ts +32 -8
  35. package/server/routes/permission-broker.ts +47 -4
  36. package/server/routes/thread-turn-queue.ts +148 -8
  37. package/server/routes/today-brief.ts +148 -0
  38. package/server/routes/web-control.ts +110 -7
  39. package/server/routes/work-lens-rounds.ts +19 -5
  40. package/server/web-control/app.css +26 -5
  41. package/server/web-control/app.js +1351 -177
  42. package/server/web-control/control.css +1 -1
  43. package/server/web-control/index.html +31 -13
  44. package/server/web-control/memories/memory-workspace.js +1 -1
  45. package/server/web-control/panes/cos-chat.css +22 -0
  46. package/server/web-control/panes/cos-chat.js +264 -48
  47. package/server/web-control/panes/meetings.css +15 -2
  48. package/server/web-control/panes/meetings.js +20 -9
  49. package/server/web-control/panes/memories.css +291 -0
  50. package/server/web-control/panes/memories.js +2208 -0
  51. package/server/web-control/panes/questions.css +39 -1
  52. package/server/web-control/panes/questions.js +419 -64
  53. package/server/web-control/panes/reader-media.js +154 -0
  54. package/server/web-control/panes/sessions.css +2 -0
  55. package/server/web-control/panes/settings.js +136 -6
  56. package/server/web-control/panes/today-actions.css +167 -0
  57. package/server/web-control/panes/today-actions.js +1303 -0
  58. package/server/web-control/panes/work.css +54 -0
  59. package/server/web-control/panes/work.js +1037 -18
  60. package/server/web-control/shell.js +6 -2
  61. package/server/web-control/theme.js +0 -2
  62. package/server/web-control/memories/memories-app.js +0 -1561
  63. package/server/web-control/memories/memories-theme.css +0 -73
  64. package/server/web-control/memories/memories.html +0 -72
  65. package/server/web-control/memories/memory-stewardship.js +0 -63
@@ -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) {
@@ -133,7 +138,7 @@
133
138
  var state = {
134
139
  pane: 'today', serverName: '', tailnetUrl: '',
135
140
  sessions: [], sessionsMode: 'words', workTasks: [], workOpenStages: { complete: false },
136
- stream: null, memoriesLoaded: false, lastLoad: {}, livePoll: null
141
+ stream: null, lastLoad: {}, livePoll: null
137
142
  };
138
143
  // Control's order (ActivitySection.visibleSections): Today, then Messages to Dictation. Usage is a
139
144
  // view inside Sessions on the Mac; its route stays so old links work, and the rail marks Sessions.
@@ -142,6 +147,53 @@
142
147
  // Pane modules (panes/*.js) register here: { load(args, ctx), leave(), up(args) }. A registered
143
148
  // pane owns its section; route() only shows it and hands it the hash arguments.
144
149
  var PANES = {};
150
+ // Heavy panes load when first opened, then the rest load quietly once the page is idle.
151
+ // index.html lists them in #lazy-panes so the server can version each URL (?v=<hash>).
152
+ // Read on first use, not at parse time: #lazy-panes sits after this script in index.html.
153
+ var lazyList = null;
154
+ function LAZY() {
155
+ if (lazyList) return lazyList;
156
+ var el = document.getElementById('lazy-panes');
157
+ if (!el) return {};
158
+ try { lazyList = JSON.parse(el.textContent || '{}'); } catch (_e) { lazyList = {}; }
159
+ return lazyList;
160
+ }
161
+ var lazyLoads = {};
162
+ // ensurePane(name, waitMs): the pane's script, loaded once. With waitMs, this caller stops waiting after that
163
+ // long (the load itself carries on, and a later call reuses it), so a stalled download can never hang a tap.
164
+ function ensurePane(name, waitMs) {
165
+ var load = loadPane(name);
166
+ if (!waitMs) return load;
167
+ return new Promise(function (resolve, reject) {
168
+ var timer = setTimeout(function () {
169
+ var late = new Error('The ' + (TITLES[name] || name) + ' section is taking too long to load. Check the connection to your Mac.');
170
+ late.code = 'pane_timeout'; reject(late);
171
+ }, waitMs);
172
+ load.then(function (v) { clearTimeout(timer); resolve(v); }, function (e) { clearTimeout(timer); reject(e); });
173
+ });
174
+ }
175
+ function loadPane(name) {
176
+ if (PANES[name] || !LAZY()[name]) return Promise.resolve(PANES[name] || null);
177
+ if (lazyLoads[name]) return lazyLoads[name];
178
+ lazyLoads[name] = new Promise(function (resolve, reject) {
179
+ var s = document.createElement('script');
180
+ s.src = LAZY()[name];
181
+ s.async = true;
182
+ s.onload = function () { if (PANES[name]) resolve(PANES[name]); else { delete lazyLoads[name]; reject(new Error('The ' + (TITLES[name] || name) + ' section did not start.')); } };
183
+ 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.')); };
184
+ document.body.appendChild(s);
185
+ });
186
+ return lazyLoads[name];
187
+ }
188
+ function prefetchPanes() {
189
+ var names = Object.keys(LAZY()).filter(function (n) { return !PANES[n]; });
190
+ var idle = window.requestIdleCallback || function (fn) { return setTimeout(fn, 200); };
191
+ (function next() {
192
+ var n = names.shift();
193
+ if (!n) return;
194
+ idle(function () { ensurePane(n).then(next, next); });
195
+ })();
196
+ }
145
197
  var routeHooks = [];
146
198
  var shellHooks = [];
147
199
  var crumbDetail = '';
@@ -157,9 +209,9 @@
157
209
  if (location.hash === hash) route(); else location.hash = hash;
158
210
  }
159
211
  function isPhone() { return !window.matchMedia('(min-width: 600px)').matches; }
160
- function route() {
212
+ function route(forceChanged) {
161
213
  var r = parseRoute();
162
- var changedPane = r.pane !== state.pane || !document.querySelector('#pane-' + r.pane + ':not(.hidden)');
214
+ var changedPane = forceChanged === true || r.pane !== state.pane || !document.querySelector('#pane-' + r.pane + ':not(.hidden)');
163
215
  if (state.pane !== r.pane && PANES[state.pane] && PANES[state.pane].leave) { try { PANES[state.pane].leave(); } catch (err) { console.error(err); } }
164
216
  state.pane = r.pane;
165
217
  crumbDetail = '';
@@ -168,10 +220,10 @@
168
220
  var railPane = r.pane === 'usage' ? 'sessions' : r.pane;
169
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'); });
170
222
  if (state.stream && !(r.pane === 'sessions' && r.args.length === 2)) { state.stream.close(); state.stream = null; }
171
- 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; }
172
224
  if (r.pane !== 'meetings' && state.livePoll) { clearInterval(state.livePoll); state.livePoll = null; }
173
225
 
174
- var reading = r.args.length > 0;
226
+ var reading = r.args.length > 0 && !isTabRoute(r.args);
175
227
  var paneEl = $('pane-' + r.pane);
176
228
  if (paneEl) paneEl.classList.toggle('reading', reading);
177
229
  var split = paneEl && paneEl.querySelector('.split');
@@ -187,10 +239,31 @@
187
239
  $('topbar').classList.remove('tucked');
188
240
  onScroll();
189
241
 
242
+ if (!PANES[r.pane] && LAZY()[r.pane]) {
243
+ var lazyPane = r.pane, lazyEl = $('pane-' + lazyPane), wasEmpty = lazyEl && !lazyEl.getAttribute('data-lazy-ready');
244
+ if (wasEmpty && lazyEl && !lazyEl.querySelector('.lazy-wait')) lazyEl.insertAdjacentHTML('afterbegin', '<div class="lazy-wait">' + skeletonRows(4) + '</div>');
245
+ ensurePane(lazyPane, 15000).then(function () {
246
+ if (lazyEl) { lazyEl.setAttribute('data-lazy-ready', '1'); lazyEl.querySelectorAll('.lazy-wait').forEach(function (n) { n.remove(); }); }
247
+ // Pass changed=true, the value this pane got on its first open when it loaded at boot. Headless Chrome
248
+ // probes on 11/06 rendered Work, Meetings, Settings and Speakers with either value; this keeps the old contract.
249
+ if (state.pane === lazyPane) route(true);
250
+ }, function (err) {
251
+ if (!lazyEl) return;
252
+ lazyEl.querySelectorAll('.lazy-wait').forEach(function (n) { n.remove(); });
253
+ 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>');
254
+ // Only the wait ran out: if the load still finishes, the card clears itself and the pane shows.
255
+ if (err && err.code === 'pane_timeout') loadPane(lazyPane).then(function () {
256
+ lazyEl.querySelectorAll('.lazy-wait').forEach(function (n) { n.remove(); });
257
+ lazyEl.setAttribute('data-lazy-ready', '1');
258
+ if (state.pane === lazyPane) route(true);
259
+ }, function () {});
260
+ });
261
+ runHooks(routeHooks, r);
262
+ return;
263
+ }
190
264
  if (PANES[r.pane]) { try { PANES[r.pane].load(r.args, { changed: changedPane }); } catch (err) { console.error(err); } }
191
265
  else if (r.pane === 'today') loadToday();
192
266
  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
267
  else if (r.pane === 'usage') loadUsage(false);
195
268
  state.lastLoad[r.pane] = Date.now();
196
269
  runHooks(routeHooks, r);
@@ -202,9 +275,12 @@
202
275
  var r = parseRoute();
203
276
  var up = PANES[r.pane] && PANES[r.pane].up ? PANES[r.pane].up(r.args) : null;
204
277
  if (up) go(r.pane, up);
205
- else if (r.args.length) go(r.pane);
278
+ else if (r.args.length && !isTabRoute(r.args)) go(r.pane);
206
279
  else go('today');
207
280
  }
281
+ // '#/<pane>/t/<tab>' is the pane's list on one of its tabs, not a reader: the phone keeps the section switcher and Back
282
+ // leaves the pane. Memories keeps its tab in the address this way, so a reload or a new browser lands on the same tab.
283
+ function isTabRoute(args) { return args.length === 2 && args[0] === 't'; }
208
284
  // On the closed Duo every vertical pixel counts: the top bar slides away while you scroll
209
285
  // down and comes back the moment you scroll up.
210
286
  var lastY = 0;
@@ -221,6 +297,7 @@
221
297
 
222
298
  // ── auth ────────────────────────────────────────────────
223
299
  function showAuth(message) {
300
+ readerImagesStop($('session-turns')); readerImagesStop($('ta-last'));
224
301
  var alreadyOpen = !$('auth-gate').classList.contains('hidden');
225
302
  $('app-shell').classList.add('hidden');
226
303
  $('auth-gate').classList.remove('hidden');
@@ -238,18 +315,299 @@
238
315
  loadTailnetUrl();
239
316
  runHooks(shellHooks, info);
240
317
  route();
318
+ // Reloaded with Today's overlay open: its history entry is still current. The overlay opens the same thing again
319
+ // (panes/today-actions.js restore), so Back closes it; if it cannot load, the entry is unmarked.
320
+ var held = history.state;
321
+ if (held && held.cosTodayOverlay) {
322
+ ensurePane('todayActions', 4000).then(function (m) { if (m && m.restore) m.restore(held); },
323
+ function () { try { history.replaceState(null, ''); } catch (_e) {} });
324
+ }
325
+ setTimeout(prefetchPanes, 1500);
326
+ }
327
+ document.addEventListener('click', function (e) {
328
+ var b = e.target && e.target.closest && e.target.closest('[data-lazy-retry]');
329
+ if (!b) return;
330
+ var el = $('pane-' + b.getAttribute('data-lazy-retry'));
331
+ if (el) el.querySelectorAll('.lazy-wait').forEach(function (n) { n.remove(); });
332
+ route(true);
333
+ });
334
+
335
+ // ── one session list for every pane ────────────────────
336
+ // /api/agent-sessions walks every session folder and runs a 2-3 s lock probe on the Mac (measured
337
+ // 11/06 in the browser: 5.1 s on Sessions, 2.1 s on Work). Today, Sessions, Work and the section
338
+ // stories all asked for it separately. Now there is one copy: a request younger than maxAgeMs is
339
+ // answered from it, a request already on the wire is shared, and panes can paint the last copy at
340
+ // once while a fresh one loads (sessionListCached).
341
+ var SL = { data: null, at: 0, inflight: null, waiting: null };
342
+ function sessionList(maxAgeMs) {
343
+ if (SL.data && Date.now() - SL.at < (maxAgeMs || 0)) return Promise.resolve(SL.data);
344
+ if (SL.inflight) return SL.inflight;
345
+ SL.inflight = api('/api/agent-sessions?limit=80&sort=updated').then(function (d) {
346
+ SL.data = d; SL.at = Date.now(); SL.inflight = null;
347
+ noteWaiting(d);
348
+ return d;
349
+ }, function (err) { SL.inflight = null; throw err; });
350
+ return SL.inflight;
351
+ }
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
+ }
370
+
371
+ // ── the session pet (Today) ─────────────────────────────
372
+ // Same pet and the same pose rule as COS Control (PetSpritePose.resolve): waiting with nothing running
373
+ // is "waiting", then one, two, three, four or more sessions in play are working, duel, trio, swarm, an open
374
+ // quiet session is "patrol", none is "idle". Strips come from /api/web-pet, resized and cut for a phone.
375
+ var PET = { manifest: null, fetchedAt: 0, pose: null, timer: null, frame: 0, token: 0 };
376
+ // This browser's pet settings (Settings > Pet): which pet, and whether Today shows it. Per browser, like the theme.
377
+ var PET_KEY = 'cos-web-pet';
378
+ function petPrefs() {
379
+ var p = readStore(PET_KEY);
380
+ var pet = typeof p.pet === 'string' && /^(mac|(shipped|gallery):[a-z0-9][a-z0-9-]{0,80})$/.test(p.pet) ? p.pet : 'mac';
381
+ return { pet: pet, show: p.show !== false };
382
+ }
383
+ function setPetPrefs(next) {
384
+ var cur = petPrefs();
385
+ var merged = { pet: next.pet != null ? next.pet : cur.pet, show: next.show != null ? !!next.show : cur.show };
386
+ writeStore(PET_KEY, merged);
387
+ // Forget the old pet's manifest and loop; Today repaints with the new choice next time it shows.
388
+ PET.manifest = null; PET.fetchedAt = 0; PET.pose = null; PET.token++; petStop();
389
+ var box = $('today-pet');
390
+ if (box) { box.innerHTML = ''; box.classList.add('hidden'); }
391
+ if (state.pane === 'today' && state.sessions) renderPet(state.sessions);
392
+ return merged;
393
+ }
394
+ var PET_HEIGHT = 140;
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; });
408
+ sessions.forEach(function (s) {
409
+ var st = sxState(s);
410
+ // Working is the Running card's own test (running_active), so the caption under the pet never
411
+ // disagrees with the Running count beside it (11/06: the pet said 3 while the card said 2).
412
+ if (s.running_active) working++;
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++;
415
+ if (s.running || s.running_active) alive++;
416
+ });
417
+ var pose;
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';
422
+ else if (working + waiting >= 4) pose = 'swarm';
423
+ else if (working + waiting === 3) pose = 'trio';
424
+ else if (working + waiting === 2) pose = 'duel';
425
+ else if (working + waiting === 1) pose = 'working';
426
+ else pose = alive > 0 ? 'patrol' : 'idle';
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');
479
+ }
480
+ function petCaption(c) {
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');
488
+ if (c.working) parts.push(c.working + ' running');
489
+ if (c.waiting) parts.push(c.waiting + ' waiting on you');
490
+ return parts.length ? parts.join(', ') : 'Nothing running';
491
+ }
492
+ function petStop() { clearTimeout(PET.timer); PET.timer = null; }
493
+ function petPaint(el, spec, frame, scale) {
494
+ var chunk = Math.floor(frame / spec.perChunk), inChunk = frame % spec.perChunk;
495
+ var count = Math.min(spec.perChunk, spec.frames - chunk * spec.perChunk);
496
+ el.style.backgroundImage = 'url("' + spec.chunks[chunk] + '")';
497
+ el.style.backgroundSize = Math.round(count * spec.frameWidth * scale) + 'px ' + PET_HEIGHT + 'px';
498
+ el.style.backgroundPosition = '-' + Math.round(inChunk * spec.frameWidth * scale) + 'px 0';
499
+ }
500
+ function petLoadImages(spec) {
501
+ return Promise.all(spec.chunks.map(function (url) {
502
+ return new Promise(function (resolve, reject) { var im = new Image(); im.onload = function () { resolve(im); }; im.onerror = reject; im.src = url; });
503
+ }));
504
+ }
505
+ // Folded (one column) the pet sits above the story. Unfolded (two columns, the same 760px line as
506
+ // .today-cols in app.css) it moves to the top of the right-hand column, above Needs you (Miles 11/06).
507
+ var PET_WIDE = window.matchMedia ? window.matchMedia('(min-width: 760px)') : null;
508
+ function placePet() {
509
+ var box = $('today-pet'), side = document.querySelector('#pane-today .today-side'), rec = $('today-rec');
510
+ if (!box || !side || !rec) return;
511
+ if (PET_WIDE && PET_WIDE.matches) { if (side.firstElementChild !== box) side.insertBefore(box, side.firstElementChild); }
512
+ else if (rec.previousElementSibling !== box) rec.parentNode.insertBefore(box, rec);
513
+ }
514
+ if (PET_WIDE) { if (PET_WIDE.addEventListener) PET_WIDE.addEventListener('change', placePet); else if (PET_WIDE.addListener) PET_WIDE.addListener(placePet); }
515
+ var PET_CHANGE = '<button type="button" class="pet-change" data-goto="settings/pet">Change pet</button>';
516
+ // A still pet (a gallery pick, Cloud Puff, or a single image on the Mac): one picture for every state.
517
+ function renderPetStill(box, m, sessions) {
518
+ var c = petPose(sessions, petQuestions()), label = petCaption(c);
519
+ petStop(); PET.pose = 'still:' + m.still;
520
+ var img = box.querySelector('.pet-still');
521
+ if (!img || img.getAttribute('src') !== m.still) {
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;
523
+ img = box.querySelector('.pet-still');
524
+ img.addEventListener('error', function () { box.classList.add('hidden'); });
525
+ img.addEventListener('load', function () { img.classList.toggle('pixel', img.naturalHeight > 0 && img.naturalHeight < 140); });
526
+ }
527
+ box.querySelector('.pet-caption').textContent = label;
528
+ petButtonState(box, c, label);
529
+ box.classList.remove('hidden');
530
+ }
531
+ function renderPet(sessions) {
532
+ var box = $('today-pet');
533
+ if (!box) return;
534
+ placePet();
535
+ var prefs = petPrefs();
536
+ if (!prefs.show) { box.classList.add('hidden'); box.innerHTML = ''; petStop(); PET.pose = null; return; }
537
+ // The session list could not be read: the pet would say "Nothing running" about sessions nobody saw. Hide it.
538
+ if (state.sessionsUnread) { box.classList.add('hidden'); petStop(); PET.pose = null; return; }
539
+ var fresh = PET.manifest && PET.manifest.pet === prefs.pet && Date.now() - PET.fetchedAt < 10 * 60000;
540
+ (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; }))
541
+ .catch(function () { return PET.manifest && PET.manifest.pet === prefs.pet ? PET.manifest : null; })
542
+ .then(function (m) {
543
+ // A slow answer for a pet that is no longer chosen (or a pet now switched off) paints nothing.
544
+ var now = petPrefs();
545
+ if (!now.show || now.pet !== prefs.pet) return;
546
+ if (!m || !m.available) { box.classList.add('hidden'); petStop(); return; }
547
+ if (m.kind === 'still') { renderPetStill(box, m, sessions); return; }
548
+ var c = petPose(sessions, petQuestions());
549
+ var pose = petPoseIn(m.poses, c.pose);
550
+ var spec = m.poses[pose];
551
+ var label = petCaption(c);
552
+ box.classList.remove('hidden');
553
+ var cap = box.querySelector('.pet-caption');
554
+ if (cap) cap.textContent = label;
555
+ petButtonState(box, c, label);
556
+ if (PET.pose === pose && PET.timer) return;
557
+ var token = ++PET.token;
558
+ petLoadImages(spec).then(function () {
559
+ if (token !== PET.token) return;
560
+ petStop();
561
+ PET.pose = pose; PET.frame = 0;
562
+ if (!box.querySelector('.pet-sprite')) {
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;
564
+ }
565
+ box.querySelector('.pet-caption').textContent = label;
566
+ petButtonState(box, c, label);
567
+ var el = box.querySelector('.pet-sprite'), scale = PET_HEIGHT / spec.height;
568
+ el.style.width = Math.round(spec.frameWidth * scale) + 'px';
569
+ petPaint(el, spec, 0, scale);
570
+ var still = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
571
+ if (still || spec.frames < 2) return;
572
+ var step = function () {
573
+ // Runs only while Today is on screen: leaving the pane or hiding the page stops the loop.
574
+ if (token !== PET.token || document.hidden || state.pane !== 'today') { PET.timer = null; return; }
575
+ PET.frame = (PET.frame + 1) % spec.frames;
576
+ petPaint(el, spec, PET.frame, scale);
577
+ PET.timer = setTimeout(step, Math.max(20, spec.interval * 1000));
578
+ };
579
+ PET.timer = setTimeout(step, Math.max(20, spec.interval * 1000));
580
+ }, function () {
581
+ if (token === PET.token && !box.querySelector('.pet-sprite')) box.classList.add('hidden');
582
+ });
583
+ });
241
584
  }
242
585
 
243
586
  // ── Today ───────────────────────────────────────────────
244
587
  function loadToday() {
245
588
  var now = new Date();
246
589
  $('today-date').textContent = now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) + ', ' + clock(now);
590
+ var gen = ++TODAY.gen;
591
+ // Voices and memories answer on their own time (the memories status runs a CLI on the Mac): Today paints what it
592
+ // has and says it is still reading, then repaints as each one lands.
593
+ if (Date.now() - TODAY.extraAt > 60000 || TODAY.voices.state === 'failed' || TODAY.memories.state === 'failed') {
594
+ TODAY.extraAt = Date.now();
595
+ TODAY.voices = { state: 'pending', groups: [] }; TODAY.memories = { state: 'pending', n: 0 };
596
+ api('/api/voice/held-groups').then(function (d) {
597
+ TODAY.voices = { state: 'answered', groups: (d && Array.isArray(d.groups)) ? d.groups : [] };
598
+ }, function () { TODAY.voices = { state: 'failed', groups: [] }; }).then(function () { paintNeeds(); });
599
+ api('/api/web-bridge/memories', { method: 'POST', body: { id: 'today-status', op: 'status', args: {} } }).then(function (d) {
600
+ var n = d && d.details ? d.details.learningToReview : null;
601
+ TODAY.memories = typeof n === 'number' ? { state: 'answered', n: Math.max(0, n) } : { state: 'failed', n: 0 };
602
+ }, function () { TODAY.memories = { state: 'failed', n: 0 }; }).then(function () { paintNeeds(); });
603
+ }
247
604
  Promise.all([
248
- api('/api/agent-sessions?limit=40&sort=updated').catch(function () { return null; }),
605
+ sessionList(15000).catch(function () { return null; }),
249
606
  api('/api/work-board/your-move').catch(function () { return null; }),
250
607
  api('/api/welcome-context').catch(function () { return null; }),
251
608
  api('/api/meetings/live-chunks').catch(function () { return null; })
252
609
  ]).then(function (res) {
610
+ if (gen !== TODAY.gen) return;
253
611
  var sessions = (res[0] && res[0].sessions) || [];
254
612
  state.sessions = sessions;
255
613
  var needs = (res[1] && res[1].items) || [];
@@ -259,18 +617,24 @@
259
617
  var open = sessions.filter(function (s) { return s.running && !s.running_active; });
260
618
  var recent = live.filter(function (s) { return Date.now() - (s.mtimeMs || 0) < LIVE_FRESH_MS; });
261
619
  var offline = !res[0] && !res[1];
620
+ TODAY.needs = needs; TODAY.working = working; TODAY.open = open; TODAY.welcome = welcome; TODAY.offline = offline; TODAY.at = Date.now();
621
+ // your-move answers {available:false} when the board cannot be read: that is a failed read, not "nothing".
622
+ TODAY.sessionsState = res[0] ? 'answered' : 'failed';
623
+ state.sessionsUnread = !res[0]; // the pet reads this (renderPet)
624
+ TODAY.workState = res[1] && res[1].available !== false ? 'answered' : 'failed';
625
+ TODAY.recording = false;
262
626
 
263
- renderStory(needs.length, working.length, open.length, welcome.nextEvent, false, offline);
264
627
  renderRunning(working, open);
265
- renderNeeds(needs);
628
+ if (!offline) renderPet(sessions);
629
+ paintNeeds();
266
630
  renderNext(welcome);
267
631
  loadTodayIntake();
268
- setBadge('today', needs.length > 0);
269
632
  setBadge('sessions', working.length > 0);
270
633
  liveSession(recent).then(function (rec) {
634
+ if (gen !== TODAY.gen) return;
271
635
  renderRecording(rec);
272
636
  setBadge('meetings', !!rec);
273
- if (rec) renderStory(needs.length, working.length, open.length, welcome.nextEvent, true, offline);
637
+ if (rec) { TODAY.recording = true; paintNeeds(); }
274
638
  });
275
639
  });
276
640
  }
@@ -295,37 +659,127 @@
295
659
  '<span class="rec-copy"><strong>Recording now</strong><span>' + esc(rec.source + (rec.startTime ? ', ' + span(rec.startTime) + ' so far' : '')) + '</span></span>' +
296
660
  '<span class="btn small">Live view</span></button>';
297
661
  }
298
- function renderStory(needs, working, open, next, recording, offline) {
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
+
738
+ // The headline: COS Control's brief (Models.swift TodayHome.brief) over the same rows Needs you lists.
739
+ function renderStory(m) {
299
740
  var el = $('today-story');
300
741
  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);
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
+ }
750
+ var first = '';
751
+ if (m.sessionsWaiting > 0) first = countWord(m.sessionsWaiting) + ' ' + plural(m.sessionsWaiting, 'session is', 'sessions are') + ' waiting on you';
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');
753
+ 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');
754
+ else if (m.settled) first = 'Nothing is waiting on you';
755
+ var next = TODAY.welcome && TODAY.welcome.nextEvent, nd = next && toDate(next.time);
311
756
  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);
757
+ var dw = dayWord(nd), words = next.title + ' is at ' + clock(nd) + (dw === 'today' ? '' : ' ' + dw);
758
+ first = first ? first + ', and ' + words : words.charAt(0).toUpperCase() + words.slice(1);
314
759
  }
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' : '');
760
+ var working = TODAY.working.length, open = TODAY.open.length, second;
761
+ var unread = TODAY.sessionsState === 'failed';
762
+ // The sessions sentence speaks only about a list that was read (W3-r2: "No agents are running" while two worked).
763
+ if (unread) second = 'Couldn\u2019t read your sessions';
764
+ 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
765
  else if (open > 0) second = countWord(open) + ' ' + plural(open, 'session is', 'sessions are') + ' open and quiet';
318
766
  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
- }
767
+ if (TODAY.recording) second += (unread || working > 0 || open > 0 ? ', and ' : '. ') + 'COS is recording a meeting';
768
+ var out = (first ? first + '. ' : '') + second + '.';
769
+ var others = m.failed.filter(function (n) { return n !== 'sessions'; });
770
+ 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.';
771
+ else if (unread) out += ' This may not be everything that needs you.';
772
+ el.textContent = out;
773
+ }
774
+ function listWords(names) { return names.length < 2 ? names.join('') : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1]; }
322
775
  function runRow(s, activeNow) {
323
776
  var key = splitSessionId(s.provider, s.session_id);
324
777
  var meta = activeNow
325
778
  ? 'Working for ' + span(s.state_since || s.last_activity_at)
326
779
  : 'Open, quiet for ' + span(s.last_activity_at || s.state_since);
327
780
  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) + '">' +
781
+ // A tap opens the peek on Today (panes/today-actions.js); it falls back to the reader if the peek cannot load.
782
+ return '<button type="button" class="run-row" data-today="run" data-key="' + esc(key.provider) + '|' + esc(key.id) + '">' +
329
783
  '<span class="run-who">' + badge(key.provider) + (activeNow ? '<span class="pulse" aria-label="working"></span>' : '') + '</span>' +
330
784
  '<span class="run-title">' + esc(sessionTitle(s)) + '</span>' +
331
785
  '<span class="run-meta">' + esc(meta) + '</span></button>';
@@ -333,6 +787,8 @@
333
787
  function renderRunning(working, open) {
334
788
  $('run-count').textContent = working.length ? String(working.length) : '';
335
789
  var list = $('run-list');
790
+ // Never "Nothing running" about a list that was not read.
791
+ 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
792
  var shown = working.slice(0, 3);
337
793
  var html = shown.map(function (s) { return runRow(s, true); }).join('');
338
794
  if (!shown.length) {
@@ -350,21 +806,158 @@
350
806
  if (item.status === 'delivered') return 'Handed to you';
351
807
  return 'Needs a look';
352
808
  }
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';
809
+ // ── Needs you (COS Control's TodayHome.things, sourceStates and fold; Models.swift 14291, 14512, 14474) ──
810
+ // Every source Control's Today reads that the web can: sessions waiting on you (the session list), Work handoffs
811
+ // (your-move), voices to name (/api/voice/held-groups) and memories to review (the memories bridge's status). One row
812
+ // per thing. The card's count, the headline and the rail say the same number, from the same rows. "Nothing is
813
+ // waiting on you" only once every source answered; a source that failed is named instead. Under quiet hours (after
814
+ // 6 PM, before 6 AM, weekends) everything but a waiting session folds under "can wait until tomorrow".
815
+ var NEEDS_SHOWN = 6;
816
+ var NEED_NAMES = { sessions: 'sessions', work: 'Work', voices: 'voices', memories: 'memories' };
817
+ function quietNowToday(d) { var h = d.getHours(), day = d.getDay(); return day === 0 || day === 6 || h >= 18 || h < 6; }
818
+ // Control's askedTitle and askedWhy for a session waiting on you.
819
+ // Control's TodayHome: guarded(title) ?? "A session". Never the workspace path or the session id: this line is shown
820
+ // on Today and goes into the Today summary sent to the model, which carries no ids.
821
+ function askedTitle(s) {
822
+ var t = plain(s.display_label || s.custom_title || '').replace(/\s+/g, ' ').trim() || 'A session';
823
+ if (sxProvider(s.provider) !== 'claude') return t + ' is waiting for your reply';
824
+ if (s.waiting_kind === 'plan') return t + ' has a plan for you to approve';
825
+ if (s.waiting_kind === 'permission') return t + ' wants your permission';
826
+ if (s.waiting_kind === 'mcp_input') return t + ' needs your input';
827
+ return t + ' asked you a question';
828
+ }
829
+ // Control never shows a bare tool name (TodayHome.toolNames): "AskUserQuestion" says nothing to a person.
830
+ var TOOL_NAMES = ['AskUserQuestion', 'ExitPlanMode', 'Bash', 'BashOutput', 'KillShell', 'Edit', 'Write', 'Read', 'Grep', 'Glob', 'LS', 'Task', 'Agent',
831
+ 'WebFetch', 'WebSearch', 'TodoWrite', 'NotebookEdit', 'MultiEdit', 'SlashCommand', 'Skill', 'ToolSearch'];
832
+ function askedWhy(s) {
833
+ if (s.waiting_kind === 'plan') return 'Has a plan for you to approve';
834
+ var d = String(s.waiting_detail || '').trim();
835
+ if (TOOL_NAMES.indexOf(d) >= 0) d = '';
836
+ if (s.waiting_kind === 'permission' && d) return 'Wants to run ' + (d.length > 80 ? d.slice(0, 79) + '…' : d);
837
+ if (s.waiting_kind === 'question' && d) return d.length > 160 ? d.slice(0, 159) + '…' : d;
838
+ return 'Waiting for you' + (sxProvider(s.provider) === 'claude' ? '. Paused until you answer.' : '');
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
+ }
863
+ function needsModel() {
864
+ var states = { sessions: TODAY.sessionsState, work: TODAY.workState, voices: TODAY.voices.state, memories: TODAY.memories.state };
865
+ var rows = [], seen = {};
866
+ (state.sessions || []).forEach(function (s) {
867
+ if (sxKeepWarm(s) || sxState(s) !== 'waiting') return;
868
+ var k = splitSessionId(s.provider, s.session_id || s.id);
869
+ seen[k.provider + '|' + String(k.id).toLowerCase()] = 1;
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]) });
883
+ });
884
+ (TODAY.needs || []).forEach(function (it) {
885
+ var k = splitSessionId(it.provider, it.sessionId);
886
+ if (k.id && seen[k.provider + '|' + String(k.id).toLowerCase()]) return; // Control: a Work row for a waiting session is the session's row
887
+ rows.push({ kind: 'work', tier: 3, item: it, key: k.id ? k.provider + '|' + k.id : '' });
888
+ });
889
+ var groups = TODAY.voices.groups || [];
890
+ if (groups.length) {
891
+ var named = groups.filter(function (g) { return g && g.suggestion && g.suggestion.name; })[0];
892
+ rows.push({ kind: 'voices', tier: 2, title: groups.length === 1 ? 'Name a held voice' : 'Name ' + groups.length + ' held voices',
893
+ why: named ? 'One sounds like ' + named.suggestion.name + '.' : 'Held from your recordings until they have a name.' });
894
+ }
895
+ if (TODAY.memories.n > 0) {
896
+ var n = TODAY.memories.n;
897
+ 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.' });
898
+ }
899
+ var quiet = quietNowToday(new Date());
900
+ var nowRows = rows.filter(function (r) { return !quiet || r.tier === 1; }), later = rows.filter(function (r) { return quiet && r.tier !== 1; });
901
+ var failed = Object.keys(states).filter(function (k) { return states[k] === 'failed'; }).map(function (k) { return NEED_NAMES[k]; });
902
+ var settled = Object.keys(states).every(function (k) { return states[k] === 'answered'; });
903
+ return { rows: rows, now: nowRows, later: later, failed: failed, settled: settled, states: states,
904
+ sessionsWaiting: rows.filter(function (r) { return r.kind === 'session'; }).length };
905
+ }
906
+ function paintNeeds() {
907
+ if (!TODAY.at) return;
908
+ var m = TODAY.model = needsModel();
909
+ renderStory(m);
910
+ renderNeeds(m);
911
+ setBadge('today', m.now.length > 0);
912
+ if (window.COSWeb && window.COSWeb.setStory) window.COSWeb.setStory('today', todayRailLine(m), m.now.length > 0);
913
+ }
914
+ // The rail's Today line, from the same rows (shell.js keeps its own count off Today).
915
+ function todayRailLine(m) {
916
+ var n = m.now.length, bits = [];
917
+ if (n > 0) bits.push(n === 1 ? '1 needs you' : n + ' need you');
918
+ else if (m.later.length) bits.push(m.later.length + ' can wait until tomorrow');
919
+ else if (m.settled) bits.push('Nothing needs you');
920
+ else if (m.failed.length) bits.push('Couldn’t read ' + listWords(m.failed));
921
+ else bits.push('Reading');
922
+ if (TODAY.working.length) bits.push(TODAY.working.length + ' running');
923
+ return bits.join(', ');
924
+ }
925
+ function needRowHtml(r, i) {
926
+ var age = r.since ? ago(r.since) : r.item ? ago(r.item.updatedAt || r.item.createdAt) : '';
927
+ 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>'; };
928
+ if (r.kind === 'work') {
929
+ var it = r.item, k = splitSessionId(it.provider, it.sessionId);
361
930
  var why = needWhy(it) + (it.domain ? ' in ' + domainLabel(it.domain) : '');
931
+ // The row opens it in an overlay on Today (panes/today-actions.js); Open session still goes to the reader.
362
932
  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('');
933
+ '<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) + '"' : '') + '>' +
934
+ top(it.sessionTitle || it.title || 'Handoff') + '<span class="need-foot">' + esc(why) + '</span></button>' +
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>';
936
+ }
937
+ if (r.kind === 'session') {
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) || '' : '';
940
+ return '<div class="need-row">' +
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>' +
943
+ '<div class="need-actions"><button type="button" class="btn small' + (i === 0 ? ' primary' : '') + '" data-session="' + esc(r.key) + '">Open session</button></div></div>';
944
+ }
945
+ var go2 = r.kind === 'voices' ? 'speakers' : 'memories/view/review', label = r.kind === 'voices' ? 'Name' : 'Review';
946
+ return '<div class="need-row">' +
947
+ '<button type="button" class="need-open" data-goto="' + go2 + '">' + top(r.title) + '<span class="need-foot">' + esc(r.why) + '</span></button>' +
948
+ '<div class="need-actions"><button type="button" class="btn small' + (i === 0 ? ' primary' : '') + '" data-goto="' + go2 + '">' + label + '</button></div></div>';
949
+ }
950
+ function renderNeeds(m) {
951
+ var shown = TODAY.showLater ? m.now.concat(m.later) : m.now;
952
+ $('needs-count').textContent = m.now.length ? String(m.now.length) : '';
953
+ var list = $('needs-list');
954
+ var visible = TODAY.showAllNeeds ? shown : shown.slice(0, NEEDS_SHOWN);
955
+ var html = visible.map(needRowHtml).join('');
956
+ if (shown.length > visible.length) html += '<button type="button" class="need-more" data-today-needs="all">Show ' + (shown.length - visible.length) + ' more</button>';
957
+ 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>';
958
+ 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>';
959
+ 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>'; });
960
+ list.innerHTML = html;
368
961
  }
369
962
  function renderNext(w) {
370
963
  var card = $('next-card');
@@ -374,30 +967,73 @@
374
967
  var dw = dayWord(d);
375
968
  $('next-body').innerHTML = '<span class="when">' + esc(clock(d)) + '</span><span><span class="what">' + esc(n.title) + '</span>' +
376
969
  '<span class="sub" style="display:block">' + esc((dw.charAt(0).toUpperCase() + dw.slice(1)) + (weather ? '. Now ' + weather : '')) + '</span></span>';
970
+ // Control's Next up (todayPrepButtons): Prep me opens the Prep composer in the COS chat (panes/cos-chat.js prepFor),
971
+ // Open meeting opens Meetings. Without a server to answer, Prep me is not offered.
972
+ var prep = !TODAY.offline && window.COSWeb && window.COSWeb.cosChat && window.COSWeb.cosChat.prepFor;
973
+ $('next-actions').innerHTML = (prep ? '<button type="button" class="btn small primary" data-today-next="prep">Prep me</button>' : '') +
974
+ '<button type="button" class="link-btn" data-goto="meetings">Open meeting</button>';
377
975
  card.classList.remove('hidden');
378
976
  }
977
+ function prepNext() {
978
+ var n = TODAY.welcome && TODAY.welcome.nextEvent, d = n && toDate(n.time), chat = window.COSWeb && window.COSWeb.cosChat;
979
+ if (!n || !n.title || !chat || !chat.prepFor) return;
980
+ go('messages', ['cos']);
981
+ chat.prepFor(n.title, d ? d.toISOString() : null).catch(function (err) { toast((err && err.message) || 'Prep me did not start.'); });
982
+ }
379
983
  // From today's meetings: Work's suggestions from meetings held today. They stay here until
380
984
  // midnight, then join Sort in Work; Review opens them in Work's Sort.
381
985
  function localDay(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); }
382
986
  function loadTodayIntake() {
383
- var card = $('intake-card'); if (!card) return;
384
- api('/api/work-intake').then(function (d) {
987
+ var card = $('intake-card'); if (!card) return Promise.resolve();
988
+ return api('/api/work-intake').then(function (d) {
385
989
  var today = localDay(new Date());
386
990
  var items = ((d && d.items) || []).filter(function (it) {
387
991
  if (['suggested', 'review', 'ask'].indexOf(it.status) < 0) return false;
388
992
  var day = String((it.meeting && (it.meeting.filename || it.meeting.date)) || '').match(/\d{4}-\d{2}-\d{2}/);
389
993
  return day && day[0] === today;
390
994
  });
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');
995
+ TODAY.intake = items; TODAY.intakeCaps = (d && d.capabilities) || {};
996
+ // A decision made on Today hides its row at once; a read that no longer lists it ends the hiding.
997
+ Object.keys(TODAY.gone).forEach(function (id) { if (!items.some(function (it) { return it.id === id; })) delete TODAY.gone[id]; });
998
+ paintTodayIntake();
399
999
  }, function () { card.classList.add('hidden'); });
400
1000
  }
1001
+ // Today's overlay (panes/today-actions.js) reads what the cards show from here and repaints the intake card after a
1002
+ // decision, so the counts move the moment Work takes it.
1003
+ var TODAY = { needs: [], working: [], open: [], welcome: {}, offline: false, at: 0, intake: null, intakeCaps: {}, gone: {},
1004
+ gen: 0, sessionsState: 'pending', workState: 'pending', voices: { state: 'pending', groups: [] }, memories: { state: 'pending', n: 0 }, extraAt: 0,
1005
+ recording: false, model: null, showAllNeeds: false, showLater: false };
1006
+ function todayIntakeOpen() { return (TODAY.intake || []).filter(function (it) { return !TODAY.gone[it.id]; }); }
1007
+ function paintTodayIntake() {
1008
+ var card = $('intake-card'); if (!card) return;
1009
+ var items = todayIntakeOpen();
1010
+ if (!items.length) { card.classList.add('hidden'); return; }
1011
+ var meetings = {};
1012
+ items.forEach(function (it) { var t = (it.meeting && it.meeting.title) || 'A meeting'; meetings[t] = (meetings[t] || 0) + 1; });
1013
+ $('intake-count').textContent = String(items.length);
1014
+ $('intake-body').innerHTML = Object.keys(meetings).slice(0, 4).map(function (t) {
1015
+ 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>';
1016
+ }).join('') + '<p class="intake-foot">Moves to Work at midnight.</p>';
1017
+ card.classList.remove('hidden');
1018
+ }
1019
+ function openTodayAction(t, kindIn, keyIn) {
1020
+ var kind = kindIn || t.getAttribute('data-today'), key = keyIn != null ? keyIn : t.getAttribute('data-key') || '';
1021
+ ensurePane('todayActions', 4000).then(function (m) {
1022
+ if (!m || !m.open) throw new Error('The Today overlay did not start.');
1023
+ m.open(kind, key, t);
1024
+ }).catch(function (err) {
1025
+ // Without the overlay, the tap goes where it went before: the session reader, or Sort in Work. Web has no client
1026
+ // log route, so the failure is said on screen and in the console first. An overlay that half opened leaves its
1027
+ // history entry, which the fallback replaces, so Back from there still lands on Today.
1028
+ var s = String(kind === 'run' || kind === 'wait' ? key : t.getAttribute('data-session-key') || '').split('|');
1029
+ var session = (kind === 'run' || kind === 'wait' || kind === 'need') && !!s[1];
1030
+ var dest = session ? ['sessions', s[0], s.slice(1).join('|')] : kind === 'pet' ? ['sessions'] : ['work'].concat(kind === 'need' ? [] : ['sort']);
1031
+ console.error('[today] the overlay did not open (' + kind + '); opening ' + dest.join('/') + ' instead.', err);
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.');
1033
+ if (history.state && history.state.cosTodayOverlay) location.replace('#/' + dest.map(encodeURIComponent).join('/'));
1034
+ else go(dest[0], dest.slice(1));
1035
+ });
1036
+ }
401
1037
  function setBadge(name, on) { document.querySelectorAll('[data-badge="' + name + '"]').forEach(function (b) { b.classList.toggle('hidden', !on); }); }
402
1038
 
403
1039
  // ── Sessions ────────────────────────────────────────────
@@ -583,9 +1219,21 @@
583
1219
  if (SX.view !== 'sessions') { renderUsageView(force === true); return Promise.resolve(); }
584
1220
  loadSessionHooks();
585
1221
  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) {
1222
+ // Paint the shared copy at once (Today usually fetched it) and refresh in place behind it.
1223
+ var cached = !SX.rows && sessionListCached();
1224
+ if (cached && cached.data && Array.isArray(cached.data.sessions)) {
1225
+ SX.rows = cached.data.sessions; SX.dropped = cached.data.dropped || null; SX.enabled = cached.data.enabled !== false;
1226
+ SX.loadedAt = cached.at;
1227
+ if (sxQueryActive()) SX.instant = sxInstant(SX.q);
1228
+ var fresh = force !== true && Date.now() - cached.at < 15000;
1229
+ SX.loading = !fresh; // rows stay on screen; the header reads "Refreshing…" until the new list lands
1230
+ renderSessionGroups();
1231
+ if (fresh) return Promise.resolve();
1232
+ } else {
1233
+ SX.loading = true;
1234
+ renderSessionGroups();
1235
+ }
1236
+ return sessionList(force === true ? 0 : 15000).then(function (d) {
589
1237
  SX.rows = (d && d.sessions) || [];
590
1238
  SX.dropped = (d && d.dropped) || null;
591
1239
  SX.enabled = !(d && d.enabled === false);
@@ -926,7 +1574,11 @@
926
1574
  function writeStore(key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch (_e) {} }
927
1575
  function saveHeld(r) {
928
1576
  var all = readStore(HELD_KEY), k = r.provider + '|' + r.id;
929
- 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
+ });
930
1582
  if (keep.length) all[k] = keep; else delete all[k];
931
1583
  writeStore(HELD_KEY, all);
932
1584
  }
@@ -961,6 +1613,7 @@
961
1613
  function openSession(provider, id) {
962
1614
  var reader = $('session-reader');
963
1615
  if (!provider || !id) return;
1616
+ readerImagesStop($('session-turns'));
964
1617
  if (state.stream) { state.stream.close(); state.stream = null; }
965
1618
  clearInterval(state.turnPoll);
966
1619
  if (state.reading) { clearInterval(state.reading.heldTimer); clearInterval(state.reading.feedTimer); }
@@ -990,15 +1643,21 @@
990
1643
  '<p class="sx-copy-note hidden" id="session-copy-note" role="status"></p>' +
991
1644
  '<div class="sx-chat">' +
992
1645
  '<div id="session-verdict"></div>' +
993
- '<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>' +
994
1648
  '<textarea id="session-input" rows="1" placeholder="Continue this session…" enterkeyhint="send" aria-label="Continue this session"></textarea>' +
995
- '<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>' +
996
- '<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>' +
997
1651
  '</form><div class="status-line" id="session-status"></div>' +
998
1652
  '<p class="sx-caution hidden" id="session-caution"></p>' +
999
1653
  '<div id="session-queue"></div>' +
1000
1654
  '</div>';
1001
- 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 () {});
1002
1661
  renderHeld(state.reading);
1003
1662
  loadQueue(provider, id);
1004
1663
  sxProbeAttach(r);
@@ -1017,20 +1676,30 @@
1017
1676
  // ACTIVITY (ActivityWindow.swift SessionActivityFeed 6800-6890, SessionLiveFeed.swift): the state
1018
1677
  // word with its clock, the current prompt, the last eight tool lines, and the honest counters.
1019
1678
  // The reducer follows SessionLiveFeed.applying: a reseed (seq 1 after anything) starts over.
1020
- 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 }; }
1021
1680
  function applyFeed(r, e) {
1022
1681
  if (typeof e.seq !== 'number') return;
1023
1682
  var f = r.feed;
1024
- 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; }
1025
1684
  else if (f.lastSeq > 0 && e.seq > f.lastSeq + 1) f.missed += e.seq - f.lastSeq - 1;
1026
1685
  f.lastSeq = e.seq;
1027
1686
  f.lastEventAt = typeof e.at === 'number' ? new Date(e.at) : new Date();
1028
1687
  f.connected = true;
1029
1688
  if (f.fallback === 'reconnecting') f.fallback = null;
1030
1689
  if (e.kind === 'status') {
1031
- 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
+ }
1032
1698
  if (e.agent_state) {
1033
- 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;
1034
1703
  f.waitingKind = e.waiting_kind || null; f.waitingDetail = e.waiting_detail || null; f.failure = e.failure || null;
1035
1704
  if (e.last_reply) f.lastReply = e.last_reply;
1036
1705
  }
@@ -1041,18 +1710,108 @@
1041
1710
  if (f.tools.length > 8) f.tools.splice(0, f.tools.length - 8);
1042
1711
  }
1043
1712
  }
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' };
1713
+ // `other` is the stream's verb for every tool it has no verb for; its target names the tool ("Used SendMessage").
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' };
1045
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; }
1046
- // The word follows the page's own working flag, which a Stop or a finished turn settles before
1047
- // 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
+ }
1048
1808
  function sxFeedWord(r) {
1049
- var f = r.feed;
1050
- if (r.live && r.live.working) return 'Working';
1051
- var s = f.agentState || f.transport;
1052
- if (s === 'waiting') return 'Waiting';
1053
- if (s === 'failed') return 'Failed';
1054
- if (s === 'ended' || s === 'done') return 'Done';
1055
- 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;
1056
1815
  }
1057
1816
  function sxFeedDetail(f, word) {
1058
1817
  if (word === 'Waiting' && f.agentState === 'waiting') {
@@ -1067,8 +1826,9 @@
1067
1826
  if (word === 'Idle' && f.agentState === 'idle') { var reply = String(f.lastReply || '').trim(); return reply ? 'last reply: ' + reply : ''; }
1068
1827
  return '';
1069
1828
  }
1070
- function sxElapsed(f) {
1071
- 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;
1072
1832
  if (!since) return '';
1073
1833
  var s = Math.max(0, Math.floor((Date.now() - since.getTime()) / 1000));
1074
1834
  if (s < 60) return s + 's';
@@ -1100,7 +1860,7 @@
1100
1860
  var word = sxFeedWord(r), detail = sxFeedDetail(f, word);
1101
1861
  html += '<div class="sx-act-state"><span class="sx-dot ' + word.toLowerCase() + '" aria-hidden="true"></span><strong>' + word + '</strong>' +
1102
1862
  '<span class="sx-act-detail">' + esc(detail) + '</span>' +
1103
- '<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>' +
1104
1864
  (live.working ? '<button type="button" class="cx-btn quiet sx-act-stop" data-action="stop-session">Stop</button>' : '') + '</div>' +
1105
1865
  '<p class="sx-act-prompt">' + esc(f.prompt) + '</p>' +
1106
1866
  '<div class="sx-act-tail">' + f.tools.map(function (t) { return '<div class="sx-act-tool">' + esc(sxToolLine(t)) + '</div>'; }).join('') +
@@ -1109,13 +1869,15 @@
1109
1869
  html += '<p class="sx-act-fallback">' + esc(f.fallback ? sxFallbackLine(f.fallback) : 'Connecting to the live feed…') + '</p>';
1110
1870
  }
1111
1871
  box.innerHTML = html;
1112
- // 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.
1113
1874
  clearInterval(r.feedTimer); r.feedTimer = null;
1114
- var w = sxFeedWord(r);
1115
- 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')) {
1116
1877
  r.feedTimer = setInterval(function () {
1117
1878
  if (state.reading !== r) { clearInterval(r.feedTimer); return; }
1118
- 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);
1119
1881
  }, 1000);
1120
1882
  }
1121
1883
  }
@@ -1138,7 +1900,7 @@
1138
1900
  var es;
1139
1901
  try { es = new EventSource(sessionPath(provider, id, '/stream?seed=turn')); } catch (_e) { if (r0) { r0.feed.fallback = 'unsupported'; paintActivity(r0); } return; }
1140
1902
  state.stream = es;
1141
- var wasWorking = false;
1903
+ var wasWorking = false, wasWaiting = false;
1142
1904
  es.onerror = function () {
1143
1905
  var r = state.reading;
1144
1906
  if (!r || r.id !== id || state.stream !== es) return;
@@ -1153,15 +1915,17 @@
1153
1915
  if (!r || r.id !== id) return;
1154
1916
  applyFeed(r, e);
1155
1917
  if (e.kind === 'status') {
1156
- var working = e.state === 'working' || e.agent_state === 'running';
1157
- // The stream's status trails a Stop by a few seconds; do not let that echo reopen it.
1158
- if (working && r.stoppedAt && Date.now() - r.stoppedAt < 8000) working = false;
1159
- 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');
1160
1922
  var el = $('session-state');
1161
1923
  if (el) el.textContent = working ? 'Working now' : waiting ? 'Waiting for you' : 'Open';
1162
- setLive({ working: working });
1163
1924
  if (wasWorking && !working) { refreshTurns(provider, id); replyEnded(r); }
1164
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;
1165
1929
  } else if (e.kind === 'tool') {
1166
1930
  setLive({ step: [e.verb, e.target].filter(Boolean).join(' ') });
1167
1931
  } else if (e.kind === 'prose' && e.text) {
@@ -1173,16 +1937,20 @@
1173
1937
  }
1174
1938
  function setLive(patch) {
1175
1939
  var r = state.reading; if (!r) return;
1176
- r.live = Object.assign(r.live || {}, patch);
1177
- 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);
1178
1944
  paintActivity(r);
1945
+ if (moved) renderVerdict(r);
1179
1946
  }
1180
1947
  function refreshTurns(provider, id) {
1948
+ var reading = state.reading;
1181
1949
  api(sessionPath(provider, id, '?turns=' + SX_TURNS)).then(function (d) {
1182
1950
  var r = state.reading;
1183
- if (!r || r.id !== id) return;
1951
+ if (!r || r !== reading || r.id !== id || r.provider !== provider) return;
1184
1952
  var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : [];
1185
- 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); }
1186
1954
  // The header keeps the subtitle it opened with: a rewrap above the turns would move them.
1187
1955
  r.detail = d; r.turns = turns; r.more = d.recent_turns_more === true;
1188
1956
  renderHeld(r);
@@ -1205,13 +1973,20 @@
1205
1973
  var box = $('session-queue'); if (!box || !r || r.id !== id) return;
1206
1974
  var rows = (q.status === 200 && q.body && q.body.turns) || [];
1207
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'; });
1208
1979
  if (q.status === 200 && r.queued !== waiting.length) { r.queued = waiting.length; paintActivity(r); }
1209
- if (!waiting.length) { box.innerHTML = ''; return; }
1210
- 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>' : '') +
1211
1982
  waiting.map(function (t) {
1212
1983
  var mine = t.owner !== 'codex' && parkedByMe(r, t.clientTurnId);
1213
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>' +
1214
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>';
1215
1990
  }).join('') + '</div>';
1216
1991
  }).catch(function () {});
1217
1992
  }
@@ -1220,21 +1995,34 @@
1220
1995
  // probeChatAttachability): a busy thread parks a send, another app holding it idle asks first,
1221
1996
  // and anything else is the server's refusal, word for word.
1222
1997
  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; }
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; };
2003
+ return apiAnswer(sessionPath(r.provider, r.id, '/attachability')).then(function (a) {
2004
+ if (state.reading !== r && !r.view) 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; }
1226
2006
  var b = a.body || {};
1227
- if (a.status !== 200 || typeof b.attachable !== 'boolean') return;
2007
+ if (a.status !== 200 || typeof b.attachable !== 'boolean') { failed(); return; }
1228
2008
  var owners = Number(b.ownerCount) || 0;
1229
2009
  r.verdict = { attachable: b.attachable, reason: String(b.reason || ''), reasonCopy: String(b.reasonCopy || ''), ownerCount: owners, caution: b.attachable === true && owners > 0 };
1230
- renderVerdict(r);
1231
- }).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; });
1232
2014
  }
1233
2015
  var SX_PARK = {
1234
2016
  cursor: 'Working right now. A message you send goes into this Cursor chat when its turn ends.',
1235
2017
  codex: 'Working right now. A message you send joins the Codex app’s queue for this thread.',
1236
2018
  claude: 'Working right now. A message you send waits and lands when this turn ends.'
1237
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
+ }
1238
2026
  function sxSupplement(reason) {
1239
2027
  if (reason === 'native_target_busy') return 'It frees itself within 30 minutes. Detaching is not available in Control yet.';
1240
2028
  if (reason === 'native_target_fenced') return 'Fences can be released from the Fences card in this panel.';
@@ -1260,7 +2048,8 @@
1260
2048
  (f.retry ? '<button type="button" class="cx-btn quiet" data-sx="retry">Retry</button>' : '') +
1261
2049
  (f.fork ? forkBtn : ''));
1262
2050
  } else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
1263
- 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>';
1264
2053
  } else if (v && v.attachable === false && v.reasonCopy) {
1265
2054
  html = refusalBox(v.reasonCopy, sxSupplement(v.reason), /fork/i.test(v.reasonCopy) ? forkBtn : '');
1266
2055
  }
@@ -1273,14 +2062,23 @@
1273
2062
  }
1274
2063
  }
1275
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
+ }
1276
2071
  function sessionStatus(text, kind, actionHtml) {
1277
2072
  var el = $('session-status'); if (!el) return;
1278
2073
  el.className = 'status-line' + (kind === 'error' ? ' error' : '');
1279
2074
  el.innerHTML = esc(text || '') + (actionHtml || '');
1280
2075
  }
1281
- function draftFor(text) {
1282
- var r = state.reading;
1283
- if (!r.draft || r.draft.text !== text) r.draft = { text: text, key: newTurnKey() };
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) {
2079
+ r = r || state.reading;
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 };
1284
2082
  return r.draft;
1285
2083
  }
1286
2084
  // The box is cleared only of the very words that were sent; anything typed since stays.
@@ -1289,11 +2087,12 @@
1289
2087
  if (input && input.value.trim() === String(text).trim()) { input.value = ''; autoGrow(input); }
1290
2088
  if (state.reading && state.reading.draft && state.reading.draft.text === text) state.reading.draft = null;
1291
2089
  }
1292
- // Words go back in the box only if the box is empty; otherwise the status line keeps them.
1293
- 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) {
1294
2093
  var input = $('session-input');
1295
- if (input && !input.value.trim()) { input.value = text; autoGrow(input); sessionStatus(why + ' Your message is back in the box.', 'error'); }
1296
- 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');
1297
2096
  }
1298
2097
  // Your words show in the conversation the moment the Mac accepts them; the next refresh of
1299
2098
  // the transcript replaces the bubble with the real turn.
@@ -1312,11 +2111,111 @@
1312
2111
  r.held.forEach(function (h) { if (!h.cancelled && !heldBubble(h.key)) showPendingTurn(h.text, 'sends after this reply', h.key); });
1313
2112
  }
1314
2113
 
2114
+ // Where the send path below reports: the reader's own page by default. Today's overlay (panes/today-actions.js)
2115
+ // hands continueSession a session object with its own view, so a reply or a steer from Today runs this same attach,
2116
+ // turn and queue path (one binding store, one key per draft, the same refusals). The overlay keeps its own copies of
2117
+ // the gates around it (sendSessionTurn), the turn watch (watchTurn) and the refusal box (renderVerdict).
2118
+ var READER_VIEW = {
2119
+ status: function (r, text, kind, actionHtml) { sessionStatus(text, kind, actionHtml); },
2120
+ pending: function (r, text, note) { showPendingTurn(text, note); },
2121
+ clear: function (r, text) { clearBoxOf(text); },
2122
+ verdict: function (r) { renderVerdict(r); },
2123
+ hold: function (r, draft) { holdUntilReplyEnds(r, draft); },
2124
+ watch: function (r, bindingId, key) { watchTurn(bindingId, key, r.provider, r.id); },
2125
+ queue: function (r) { loadQueue(r.provider, r.id); }
2126
+ };
2127
+ function viewOf(r) { return (r && r.view) || READER_VIEW; }
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
+
1315
2207
  function sendSessionTurn(fork, opts) {
1316
2208
  var r = state.reading; var form = $('session-form');
1317
2209
  if (!r || !form) return;
1318
2210
  var input = $('session-input'), text = input.value.trim();
1319
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 });
1320
2219
  // Control's caution (SessionChatVerdict.caution): another app holds this session and looks
1321
2220
  // idle, so the first send asks before it writes. A fork writes to a copy and never asks.
1322
2221
  if (!fork && r.verdict && r.verdict.caution && !r.cautionAck) { r.cautionAsk = true; renderVerdict(r); return; }
@@ -1326,8 +2225,12 @@
1326
2225
  if (r.refusal || r.cautionAsk) { r.refusal = null; r.cautionAsk = false; renderVerdict(r); }
1327
2226
  r.sending = true;
1328
2227
  var btns = form.querySelectorAll('button'); btns.forEach(function (b) { b.disabled = true; });
1329
- var done = function () { r.sending = false; btns.forEach(function (b) { b.disabled = false; }); };
1330
- (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) {
1331
2234
  done();
1332
2235
  if (state.reading !== r) return;
1333
2236
  var why = err && err.name === 'AbortError'
@@ -1345,8 +2248,10 @@
1345
2248
  // Resolves to 'sent' | 'parked' | 'held' | 'refused' | 'changed'.
1346
2249
  function continueSession(r, text, opts) {
1347
2250
  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');
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 } : {};
2254
+ if (!opts.held) view.status(r, 'Sending');
1350
2255
  var bound = bindingFor(provider, id);
1351
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) {
1352
2257
  // 201 Created on success; any 2xx that says attached counts.
@@ -1368,22 +2273,27 @@
1368
2273
  if (opts.held) settleHeld(r, opts.held, 'refused', copy);
1369
2274
  else {
1370
2275
  // Control's refusal box: the server's words, then Fork with this message.
1371
- sessionStatus('');
2276
+ view.status(r, '');
1372
2277
  r.refusal = { copy: (at.refusal && at.refusal.reasonCopy) || 'COS cannot write to this session right now.', supplement: sxSupplement(why), fork: true };
1373
- renderVerdict(r);
2278
+ view.verdict(r);
1374
2279
  }
1375
2280
  return 'refused';
1376
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;
1377
2285
  return apiAnswer('/api/agent-sessions/bindings/' + encodeURIComponent(at.binding.bindingId) + '/turns', {
1378
2286
  method: 'POST', timeoutMs: 25000,
1379
- 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 } : {}),
1380
2288
  }).then(function (t) {
1381
2289
  var b = t.body || {};
1382
2290
  if (t.status >= 200 && t.status < 300 && (b.outcome === 'queued' || b.outcome === 'completed')) {
1383
2291
  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);
2292
+ else { view.pending(r, draft.text, b.replayed ? 'already sent' : 'sent'); view.clear(r, draft.text); }
2293
+ view.status(r, b.replayed ? 'Already sent. Watching for the reply.' : 'Sent. Watching for the reply.');
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) {} }
1387
2297
  return 'sent';
1388
2298
  }
1389
2299
  var reason = b.reason || '';
@@ -1391,15 +2301,15 @@
1391
2301
  // COS's own reply is still running on this thread. The server's queue counts that
1392
2302
  // thread as free (the running child is COS's), so it cannot park this; the page holds
1393
2303
  // 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'; }
2304
+ if (reason === 'native_turn_in_progress') { if (!opts.held) view.hold(r, draft); return 'held'; }
1395
2305
  if (reason === 'native_thread_changed' && b.revision) {
1396
2306
  if (opts.held) settleHeld(r, opts.held, 'refused', 'This session changed on your Mac while your message waited.');
1397
2307
  else {
1398
2308
  // Control's refusal box for a changed thread: Refresh, Continue anyway, Fork with this message.
1399
2309
  r.sendAnyway = b.revision;
1400
- sessionStatus('');
2310
+ view.status(r, '');
1401
2311
  r.refusal = { copy: b.reasonCopy || 'This session changed on your Mac since you opened it.', changed: true, fork: true };
1402
- renderVerdict(r);
2312
+ view.verdict(r);
1403
2313
  }
1404
2314
  return 'changed';
1405
2315
  }
@@ -1409,7 +2319,7 @@
1409
2319
  }
1410
2320
  var refusedCopy = b.reasonCopy || 'The session did not take that message.';
1411
2321
  if (opts.held) settleHeld(r, opts.held, 'refused', refusedCopy);
1412
- else sessionStatus(refusedCopy + ' It is still in the box.', 'error');
2322
+ else view.status(r, refusedCopy + ' It is still in the box.', 'error');
1413
2323
  return 'refused';
1414
2324
  });
1415
2325
  });
@@ -1418,7 +2328,7 @@
1418
2328
  var HELD_MAX_MS = 5 * 60000, HELD_RETRY_MS = 8000;
1419
2329
  function holdUntilReplyEnds(r, draft) {
1420
2330
  if (!r.held.some(function (h) { return h.key === draft.key; })) {
1421
- 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 || [] });
1422
2332
  showPendingTurn(draft.text, 'sends after this reply', draft.key);
1423
2333
  saveHeld(r);
1424
2334
  }
@@ -1454,8 +2364,8 @@
1454
2364
  if (bubble) { bubble.removeAttribute('data-held-key'); bubble.querySelector('.who').textContent = 'You, waiting in line'; }
1455
2365
  } else {
1456
2366
  if (bubble) bubble.remove();
1457
- if (how === 'cancelled') returnToBox(h.text, 'Not sent.');
1458
- 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));
1459
2369
  }
1460
2370
  if (!r.held.some(function (x) { return !x.cancelled; })) clearInterval(r.heldTimer);
1461
2371
  }
@@ -1469,22 +2379,27 @@
1469
2379
  }
1470
2380
 
1471
2381
  function parkTurn(r, draft, refusal, opts) {
2382
+ var view = viewOf(r);
1472
2383
  return apiAnswer(sessionPath(r.provider, r.id, '/queued-turns'), {
1473
2384
  method: 'POST', timeoutMs: 15000,
1474
- 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 } : {}),
1475
2386
  }).then(function (q) {
1476
2387
  var b = q.body || {};
1477
2388
  if (b.queued === true || b.error === 'duplicate_turn') {
1478
2389
  rememberParked(r, draft.key);
1479
2390
  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);
2391
+ else { view.pending(r, draft.text, 'waiting in line'); view.clear(r, draft.text); }
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.'));
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) {} }
1483
2397
  return 'parked';
1484
2398
  }
1485
2399
  if (b.error === 'thread_free' && !opts.freeRetry) return continueSession(r, draft.text, Object.assign({}, opts, { freeRetry: true }));
1486
- 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');
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.');
2402
+ if (opts.held) settleHeld(r, opts.held, 'refused', copy); else view.status(r, copy + ' It is still in the box.', 'error');
1488
2403
  return 'refused';
1489
2404
  });
1490
2405
  }
@@ -1499,10 +2414,13 @@
1499
2414
  apiAnswer('/api/agent-sessions/bindings/' + encodeURIComponent(bindingId) + '/turns/' + encodeURIComponent(key)).then(function (s) {
1500
2415
  var o = s.body && s.body.outcome;
1501
2416
  if (o === 'completed') {
1502
- clearInterval(state.turnPoll); sessionStatus('Done.'); refreshTurns(provider, id);
1503
- // The finished turn is the authority; the stream's idle status can trail it.
1504
- setLive({ working: false }); var st = $('session-state'); if (st) st.textContent = 'Open';
1505
- 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);
1506
2424
  } else if (o === 'refused') { clearInterval(state.turnPoll); sessionStatus(s.body.reasonCopy || 'The session refused that message.', 'error'); replyEnded(r); }
1507
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'); }
1508
2426
  }).catch(function () {});
@@ -1511,13 +2429,15 @@
1511
2429
 
1512
2430
  // Fork copies the session and runs the message there. One key per draft, so a second tap or
1513
2431
  // a resend after a timeout finds the same job instead of making a second copy.
1514
- function forkSession(r, text) {
1515
- 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() };
1516
2436
  var forkId = r.forkDraft.key;
1517
2437
  sessionStatus('Copying the session. This can take up to a minute.');
1518
2438
  var settle = function (b) {
1519
2439
  if (b.forked === true) {
1520
- r.forkDraft = null; clearBoxOf(text);
2440
+ r.forkDraft = null; clearBoxOf(text); readerFilesSent(readerFilesOn() ? readerBox(r) : null, ids);
1521
2441
  sessionStatus('Forked. The copy has your message and shows in Sessions in a moment.');
1522
2442
  setTimeout(function () { if (state.pane === 'sessions') loadSessions(true); }, 4000);
1523
2443
  return true;
@@ -1525,7 +2445,7 @@
1525
2445
  if (b.forked === false || b.state === 'failed' || b.state === 'refused') { sessionStatus((b.reasonCopy || 'COS could not copy that session.'), 'error'); return true; }
1526
2446
  return false;
1527
2447
  };
1528
- 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) {
1529
2449
  if (settle(f.body || {})) return;
1530
2450
  if (f.status !== 202) { sessionStatus('COS could not copy that session.', 'error'); return; }
1531
2451
  // 202: running in the background. Follow its job for up to three minutes.
@@ -1550,7 +2470,7 @@
1550
2470
  if (b.cancelled) {
1551
2471
  r.stoppedAt = Date.now();
1552
2472
  clearInterval(state.turnPoll);
1553
- setLive({ working: false }); var st = $('session-state'); if (st) st.textContent = 'Open';
2473
+ setLive({}); var st = $('session-state'); if (st) st.textContent = 'Open';
1554
2474
  setTimeout(function () { if (state.reading === r) refreshTurns(r.provider, r.id); }, 1500);
1555
2475
  }
1556
2476
  }).catch(function (err) { sessionStatus(err.message, 'error'); });
@@ -1564,6 +2484,36 @@
1564
2484
  });
1565
2485
  }
1566
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
+
1567
2517
  // Copy session (Control's Kickstart brief, HelperSources/main.swift claudeKickstartCopy). Control
1568
2518
  // builds it from the Mac's transcript; the web builds it from the turns the server serves and
1569
2519
  // the session's first prompt, so it keeps the original request and the newest context.
@@ -1696,7 +2646,7 @@
1696
2646
  // One turn's identity, for telling which turns a refresh brought that the page already shows.
1697
2647
  function sxTurnSig(t) {
1698
2648
  var text = String(t.text || t.content || '').trim();
1699
- 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);
1700
2650
  }
1701
2651
  function sxTurnHtml(t) {
1702
2652
  var role = String(t.role || '').toLowerCase() === 'user' ? 'user' : 'assistant';
@@ -1705,7 +2655,7 @@
1705
2655
  var when = at ? (Date.now() - at.getTime() < 86400000 ? clock(at) : sxShortDate(at)) : '';
1706
2656
  return '<div class="turn ' + role + ' sx-turn" data-sig="' + esc(sxTurnSig(t)) + '"><span class="who"><span>' + (role === 'user' ? 'YOU' : 'ASSISTANT') + '</span>' +
1707
2657
  (when ? '<span class="sx-turn-when">' + esc(when) + '</span>' : '') + '</span>' +
1708
- (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>';
1709
2659
  }
1710
2660
  // A refresh never rebuilds what you are reading (Miles, /qa: "start to scroll and then it reloads
1711
2661
  // the section"). The server's window slides as a session grows, so the turns the page already
@@ -1749,21 +2699,42 @@
1749
2699
  var h = Array.prototype.map.call(b, function (x) { return (x + 0x100).toString(16).slice(1); }).join('');
1750
2700
  return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20);
1751
2701
  }
1752
- function askCos(prompt, statusEl) {
2702
+ var TODAY_REFERENCE = 'What Today in COS Control shows right now';
2703
+ // files: { attachmentIds, atts } from the Ask sheet's clip (askSheetFiles), or nothing.
2704
+ function askCos(prompt, statusEl, files) {
1753
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
1754
2706
  // that composer (panes/cos-chat.js submitAsk), so the answer streams on screen and lands as a numbered turn. The
1755
2707
  // request below is the fallback for a page without it; it resolves to the Messages route to open.
1756
2708
  var chat = window.COSWeb && window.COSWeb.cosChat;
2709
+ // Asked from Today: COS gets a short summary of what Today shows (panes/today-actions.js summary), as the Ask's
2710
+ // quoted reference, the query-jobs field for context. Asked from any other pane, nothing extra is sent.
2711
+ var context = state.pane === 'today' ? ensurePane('todayActions', 1500).then(function (m) {
2712
+ // A broken summary never costs the Ask: it goes without it, and the console says why.
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;
2719
+ }, function () { return ''; }) : Promise.resolve('');
1757
2720
  if (chat && chat.askFromSheet) {
1758
2721
  setStatus(statusEl, 'Sending…');
1759
- return chat.askFromSheet(prompt).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) {
1760
2727
  setStatus(statusEl, err.message, true);
1761
2728
  throw err;
1762
2729
  });
1763
2730
  }
1764
2731
  setStatus(statusEl, 'Sent. COS is answering.');
1765
2732
  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 () {
2733
+ if (files && files.attachmentIds && files.attachmentIds.length) body.attachmentIds = files.attachmentIds;
2734
+ return context.then(function (ctx) {
2735
+ if (ctx) body.reference = { query: TODAY_REFERENCE, response: ctx };
2736
+ return api('/api/query-jobs', { method: 'POST', body: body, timeoutMs: 30000 });
2737
+ }).then(function () {
1767
2738
  // The answer lands in Messages; refresh it there if it is open.
1768
2739
  var again = function () { if (state.pane === 'messages' && PANES.messages && PANES.messages.refresh) PANES.messages.refresh(); };
1769
2740
  setTimeout(again, 4000);
@@ -1774,27 +2745,6 @@
1774
2745
  throw err;
1775
2746
  });
1776
2747
  }
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
2748
  // ── Usage ───────────────────────────────────────────────
1799
2749
  // Usage and COS cost are views inside Sessions, as on the Mac (ActivityWindow.swift 2306-2325);
1800
2750
  // an old #/usage link opens the Usage view there. Data: GET /api/usage/summary, the helper's
@@ -3392,7 +4342,7 @@
3392
4342
  var left = exp ? Math.round((exp.getTime() - Date.now()) / 1000) : 0;
3393
4343
  var el = $('device-code-left');
3394
4344
  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; }
4345
+ 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
4346
  el.textContent = 'Works once, for ' + (left >= 60 ? Math.ceil(left / 60) + ' more min' : left + ' more sec') + '.';
3397
4347
  };
3398
4348
  tick(); codeTimer = setInterval(tick, 1000);
@@ -3413,11 +4363,136 @@
3413
4363
  ['today', 'Today'], ['messages', 'Messages'], ['speakers', 'Speakers'], ['meetings', 'Meetings'], ['memories', 'Memories'],
3414
4364
  ['threads', 'Threads'], ['sessions', 'Sessions'], ['work', 'Work'], ['dictation', 'Dictation'], ['usage', 'Usage'], ['settings', 'Settings']
3415
4365
  ];
4366
+ // ⌘K's File to Work inbox (Control's todaySubmitAsk, ActivityWindow.swift 8810): the words become a task in the Work
4367
+ // inbox, in Control's taskDomain (quilt when the server has it, else the first domain it lists: reconcileTaskDomain),
4368
+ // through POST /api/tasks/capture,
4369
+ // the route Work's Add task uses. One captureId per text: a second tap, or a retry after a lost answer, files it once.
4370
+ var ASK_FILE = { text: '', id: '', busy: false, domains: null, note: null };
4371
+ // The result shows on the row itself, so it is in view wherever the sheet is (W5-r2: a status line under the sheet
4372
+ // at 1000x703 hid a refusal), and a toast says it too.
4373
+ function fileNoteHtml(q) {
4374
+ var n = ASK_FILE.note && ASK_FILE.note.text === q ? ASK_FILE.note : null;
4375
+ if (n) return '<span class="note' + (n.error ? ' file-error' : '') + '" role="' + (n.error ? 'alert' : 'status') + '">' + esc(n.line) + '</span>';
4376
+ return '<span class="note">' + esc('As a task in ' + fileDomainLabel()) + '</span>';
4377
+ }
4378
+ function fileDomainEntry() {
4379
+ var d = ASK_FILE.domains || [];
4380
+ return d.filter(function (x) { return x && x.name === 'quilt'; })[0] || d.filter(function (x) { return x && x.name; })[0] || null;
4381
+ }
4382
+ function fileDomain() { var e = fileDomainEntry(); return e ? e.name : 'quilt'; }
4383
+ function fileDomainLabel() { var e = fileDomainEntry(); return (e && e.label) || domainLabel(fileDomain()); }
4384
+ function loadFileDomains() {
4385
+ if (ASK_FILE.domains) return Promise.resolve();
4386
+ return api('/api/domains').then(function (d) { ASK_FILE.domains = (d && Array.isArray(d.domains)) ? d.domains : []; }, function () { ASK_FILE.domains = null; });
4387
+ }
4388
+ function fileToInbox(text) {
4389
+ if (ASK_FILE.busy || !text) return;
4390
+ if (ASK_FILE.text !== text) { ASK_FILE.text = text; ASK_FILE.id = webJobId(); }
4391
+ ASK_FILE.busy = true; ASK_FILE.note = null; renderAskFile();
4392
+ loadFileDomains().then(function () {
4393
+ return api('/api/tasks/capture', { method: 'POST', body: { domain: fileDomain(), text: text, section: 'inbox', captureId: ASK_FILE.id }, timeoutMs: 20000 });
4394
+ }).then(function () {
4395
+ ASK_FILE.busy = false; ASK_FILE.text = ''; ASK_FILE.note = null;
4396
+ var box = $('ask-q'); if (box && box.value.trim() === text) { box.value = ''; renderAsk(); }
4397
+ renderAskFile();
4398
+ toast('Filed to the Work inbox.');
4399
+ if (PANES.work && PANES.work.intakeChanged) PANES.work.intakeChanged(); // Work reads its board again when it opens
4400
+ }, function (err) {
4401
+ ASK_FILE.busy = false;
4402
+ var line = 'Not filed: ' + ((err && err.message) || 'your Mac did not answer') + ' Filing again is safe.';
4403
+ ASK_FILE.note = { text: text, line: line, error: true };
4404
+ renderAskFile();
4405
+ toast(line);
4406
+ });
4407
+ }
4408
+ function renderAskFile() {
4409
+ var b = document.querySelector('#ask-results [data-action="ask-file"]'); if (!b) return;
4410
+ b.disabled = ASK_FILE.busy;
4411
+ var g = b.querySelector('.grow'); if (!g) return;
4412
+ var q = ($('ask-q') && $('ask-q').value.trim()) || '';
4413
+ g.innerHTML = esc(ASK_FILE.busy ? 'Filing\u2026' : 'File to Work inbox') + fileNoteHtml(q);
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
+ }
3416
4489
  function openAsk(prefill) {
4490
+ loadFileDomains().then(function () { if (!$('ask-sheet').classList.contains('hidden') && $('ask-q').value.trim()) renderAsk(); });
3417
4491
  $('ask-sheet').classList.remove('hidden');
3418
4492
  document.body.style.overflow = 'hidden';
3419
4493
  var input = $('ask-q'); input.value = prefill || '';
3420
- renderAsk();
4494
+ renderAsk(); paintAskFiles(); paintAskClip();
4495
+ var kit = askKit(); if (kit && !kit.askOn()) kit.ensureCaps().then(paintAskClip, function () {});
3421
4496
  setTimeout(function () { input.focus(); }, 30);
3422
4497
  }
3423
4498
  function closeAsk() { $('ask-sheet').classList.add('hidden'); document.body.style.overflow = ''; }
@@ -3434,11 +4509,13 @@
3434
4509
  }
3435
4510
  var jumps = JUMPS.filter(function (j) { return j[1].toLowerCase().indexOf(q.toLowerCase()) === 0; });
3436
4511
  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>' +
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>' +
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>' +
3438
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>' +
3439
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>' +
3440
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('') +
3441
4517
  '</div><div id="ask-hits"></div><p class="status-line" id="ask-status"></p>';
4518
+ paintAskRow();
3442
4519
  clearTimeout(renderAsk.t);
3443
4520
  if (q.length >= 2) renderAsk.t = setTimeout(function () {
3444
4521
  api('/api/agent-sessions/search?q=' + encodeURIComponent(q) + '&limit=6').then(function (d) {
@@ -3451,7 +4528,7 @@
3451
4528
 
3452
4529
  // ── events (one delegated listener) ─────────────────────
3453
4530
  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');
4531
+ 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
4532
  if (!t) { if (e.target.id === 'ask-sheet') closeAsk(); if (e.target.id === 'section-menu') closeMenu(); return; }
3456
4533
  var inSheet = !!t.closest('#ask-sheet');
3457
4534
  if (t.id === 'topbar-back') return goBack();
@@ -3482,7 +4559,16 @@
3482
4559
  return;
3483
4560
  }
3484
4561
  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; }
4562
+ if (t.hasAttribute('data-today')) return t.getAttribute('data-today') === 'pet' ? petTap(t) : openTodayAction(t);
4563
+ if (t.hasAttribute('data-today-next')) return prepNext();
4564
+ if (t.hasAttribute('data-today-needs')) {
4565
+ var which = t.getAttribute('data-today-needs');
4566
+ if (which === 'all') TODAY.showAllNeeds = true;
4567
+ else if (which === 'later') TODAY.showLater = true;
4568
+ else { TODAY.extraAt = 0; return loadToday(); }
4569
+ return paintNeeds();
4570
+ }
4571
+ if (t.hasAttribute('data-theme-choice')) { window.CosTheme.set(t.getAttribute('data-theme-choice')); paintAppearance(); return; }
3486
4572
  if (t.hasAttribute('data-goto') || t.hasAttribute('data-pane')) {
3487
4573
  if (inSheet) closeAsk(); closeMenu();
3488
4574
  // "work/sort" is a pane and its arguments, not one name.
@@ -3505,12 +4591,24 @@
3505
4591
  }
3506
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; }
3507
4593
  var q = $('ask-q').value.trim();
3508
- 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);
4606
+ if (action === 'ask-file' && q) return fileToInbox(q);
3509
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; }
3510
4608
  if (action === 'ask-work' && q) {
3511
4609
  closeAsk(); go('work');
3512
4610
  // 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);
4611
+ ensurePane('work').then(function () { if (PANES.work && PANES.work.search) PANES.work.search(q); }, function () {});
3514
4612
  }
3515
4613
  });
3516
4614
 
@@ -3535,12 +4633,43 @@
3535
4633
  }
3536
4634
  });
3537
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
+ });
3538
4666
  document.addEventListener('input', function (e) {
3539
4667
  if (e.target.tagName === 'TEXTAREA') autoGrow(e.target);
3540
4668
  if (e.target.id === 'ask-q') renderAsk();
3541
4669
  if (e.target.id === 'sessions-q' && !e.target.value.trim()) renderSessionGroups(state.sessions);
3542
4670
  });
3543
4671
  document.addEventListener('keydown', function (e) {
4672
+ if (window.COSWeb.readerMedia && window.COSWeb.readerMedia.isOpen()) return;
3544
4673
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openAsk(); return; }
3545
4674
  if (e.key === 'Escape' && !$('ask-sheet').classList.contains('hidden')) { closeAsk(); return; }
3546
4675
  if (e.key === 'Escape' && !$('section-menu').classList.contains('hidden')) { closeMenu(); $('section-btn').focus(); return; }
@@ -3554,14 +4683,14 @@
3554
4683
  });
3555
4684
  });
3556
4685
  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
4686
  window.addEventListener('scroll', onScroll, { passive: true });
3560
4687
  document.addEventListener('visibilitychange', function () {
3561
4688
  if (document.hidden || $('app-shell').classList.contains('hidden')) return;
3562
4689
  // Coming back to the page refreshes Today in place. Other panes keep what is on screen:
3563
4690
  // rebuilding them here moved the page under Miles's finger ("it reloads the section").
3564
4691
  if (state.pane === 'today' && Date.now() - (state.lastLoad.today || 0) > 60000) { state.lastLoad.today = Date.now(); loadToday(); }
4692
+ // Back within the minute: Today keeps its data, but the pet's loop stopped while hidden, so restart it.
4693
+ else if (state.pane === 'today' && PET.pose && !PET.timer) renderPet(state.sessions || []);
3565
4694
  });
3566
4695
  window.addEventListener('message', function (e) {
3567
4696
  if (e.origin !== location.origin) return;
@@ -3603,6 +4732,10 @@
3603
4732
  readStore: readStore, writeStore: writeStore, copyText: copyText, isPhone: isPhone,
3604
4733
  state: state, titles: TITLES, go: go, parseRoute: parseRoute, goBack: goBack,
3605
4734
  registerPane: function (name, def) { PANES[name] = def; },
4735
+ sessionList: sessionList,
4736
+ petPrefs: petPrefs,
4737
+ setPetPrefs: setPetPrefs,
4738
+ resetTodayBrief: resetBrief,
3606
4739
  onRoute: function (fn) { routeHooks.push(fn); },
3607
4740
  onShell: function (fn) { shellHooks.push(fn); },
3608
4741
  // The detail title after "COS Control > Section" (Control's breadcrumb). Pane modules set it
@@ -3616,6 +4749,47 @@
3616
4749
  sessionState: function (s) { return sxState(s); },
3617
4750
  isKeepWarm: function (s) { return sxKeepWarm(s); }
3618
4751
  };
4752
+ // Today as a place to act (panes/today-actions.js, Miles 11/06): what the Today cards show, a repaint after a
4753
+ // decision, and the Sessions reader's own pieces, so the overlay sends and reads through the reader's code.
4754
+ window.COSWeb.pane = function (name) { return PANES[name] || null; };
4755
+ // Tap, then tap again (Today's Dismiss, Stop and Send anyway; a question card's Allow, Deny and Send): the second tap
4756
+ // counts only once the first has been on screen ARM_CONFIRM_MS, so a double tap or a finger bounce arms and never
4757
+ // confirms (W2-r2). The one rule both panes/today-actions.js and panes/questions.js read.
4758
+ var ARM_CONFIRM_MS = 400;
4759
+ window.COSWeb.armConfirmMs = ARM_CONFIRM_MS;
4760
+ window.COSWeb.confirmReady = function (armedAt) { return typeof armedAt === 'number' && Date.now() - armedAt >= ARM_CONFIRM_MS; };
4761
+ window.COSWeb.today = {
4762
+ data: function () {
4763
+ return { needs: TODAY.needs, working: TODAY.working, open: TODAY.open, welcome: TODAY.welcome, offline: TODAY.offline, at: TODAY.at,
4764
+ sessions: state.sessions || [], intake: todayIntakeOpen(), intakeCaps: TODAY.intakeCaps, intakeLoaded: TODAY.intake !== null };
4765
+ },
4766
+ needWhy: needWhy, sessionTitle: sessionTitle, badge: badge, span: span, splitSessionId: splitSessionId,
4767
+ // Needs you as Today lists it (all four sources), for the overlay's summary and the rail.
4768
+ needs: function () { return TODAY.model; },
4769
+ railLine: function () { return TODAY.model ? { text: todayRailLine(TODAY.model), hot: TODAY.model.now.length > 0 } : null; },
4770
+ markGone: function (id, gone) { if (gone) TODAY.gone[id] = 1; else delete TODAY.gone[id]; paintTodayIntake(); },
4771
+ reloadIntake: function () { return loadTodayIntake(); },
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 || []); } }
4777
+ };
4778
+ window.COSWeb.sessionKit = {
4779
+ // A session object for continueSession, with the overlay's own view (READER_VIEW's methods).
4780
+ session: function (provider, id, view) {
4781
+ return { provider: provider, id: id, draft: null, held: [], view: view, verdict: null, refusal: null, cautionAck: false, feed: newFeed(), live: null, queued: 0 };
4782
+ },
4783
+ send: function (r, text, opts) { return continueSession(r, text, opts || {}); },
4784
+ newKey: newTurnKey,
4785
+ probe: function (r) { return sxProbeAttach(r); },
4786
+ path: sessionPath, answer: apiAnswer,
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 },
4790
+ title: sxTitle, stateOf: sxState, stateLabel: function (s) { return sxStateLabel(s, sxState(s)); },
4791
+ parkCopy: function (p) { return SX_PARK[sxProvider(p)]; }, providerLabel: sxProviderLabel, markdown: sxMd
4792
+ };
3619
4793
 
3620
4794
  // Every pane module is a plain script after this one; boot waits for all of them.
3621
4795
  function boot() {