@gotcos/glasses-server 6.99.29 → 7.0.7

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 (80) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +475 -0
  3. package/README.md +3 -1
  4. package/managed-runtime-contract.json +3 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +29 -7
  7. package/server/lib/agent-session-output-images.ts +627 -49
  8. package/server/lib/agent-session-store.ts +24 -11
  9. package/server/lib/agent-session-tool-images.ts +224 -0
  10. package/server/lib/agent-session-turns.ts +19 -2
  11. package/server/lib/api-auth.ts +6 -0
  12. package/server/lib/ask-work-plan.ts +3 -1
  13. package/server/lib/calendar-source.ts +491 -0
  14. package/server/lib/codex-bridge.ts +4 -1
  15. package/server/lib/codex-run-ledger.ts +29 -0
  16. package/server/lib/context-builder.ts +2 -1
  17. package/server/lib/drop-p2-runtime.ts +1 -1
  18. package/server/lib/drop-store.ts +10 -4
  19. package/server/lib/learning-needs-you.ts +24 -7
  20. package/server/lib/live-events.ts +533 -0
  21. package/server/lib/local-path-redact.ts +272 -0
  22. package/server/lib/meeting-ask-orientation.ts +40 -6
  23. package/server/lib/meeting-ask-runner.ts +30 -10
  24. package/server/lib/morning-brief-runtime.ts +4 -2
  25. package/server/lib/native-mac-caller.ts +29 -0
  26. package/server/lib/ping-engine.ts +1 -1
  27. package/server/lib/ping-sources.ts +11 -6
  28. package/server/lib/preview-content.ts +172 -0
  29. package/server/lib/preview-error-code.ts +10 -0
  30. package/server/lib/preview-folders-setting.ts +133 -0
  31. package/server/lib/preview-log-throttle.ts +18 -0
  32. package/server/lib/preview-thumbs.ts +139 -0
  33. package/server/lib/secret-path-guard.ts +80 -0
  34. package/server/lib/session-files-setting.ts +88 -0
  35. package/server/lib/session-links.ts +280 -0
  36. package/server/lib/thread-turn-queue.ts +10 -0
  37. package/server/lib/today-brief.ts +119 -28
  38. package/server/lib/web-memories-bridge.ts +3 -1
  39. package/server/lib/work-activity.ts +56 -6
  40. package/server/lib/work-session-activity.ts +260 -0
  41. package/server/lib/your-move-count.ts +9 -3
  42. package/server/routes/agent-session-bindings.ts +13 -0
  43. package/server/routes/agent-session-stream.ts +16 -0
  44. package/server/routes/agent-sessions.ts +146 -11
  45. package/server/routes/ask-work-plan.ts +4 -1
  46. package/server/routes/claude-sessions.ts +5 -1
  47. package/server/routes/health.ts +35 -0
  48. package/server/routes/live-events.ts +329 -0
  49. package/server/routes/permission-broker.ts +69 -5
  50. package/server/routes/session-preview.ts +200 -0
  51. package/server/routes/thread-turn-queue.ts +207 -3
  52. package/server/routes/today-brief.ts +44 -9
  53. package/server/routes/welcome-context.ts +23 -3
  54. package/server/routes/work-board.ts +9 -2
  55. package/server/web-control/app.css +79 -9
  56. package/server/web-control/app.js +1543 -65
  57. package/server/web-control/control.css +37 -6
  58. package/server/web-control/index.html +8 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +51 -6
  61. package/server/web-control/panes/cos-chat.js +268 -16
  62. package/server/web-control/panes/meetings.css +23 -4
  63. package/server/web-control/panes/meetings.js +25 -9
  64. package/server/web-control/panes/memories.css +18 -3
  65. package/server/web-control/panes/memories.js +130 -50
  66. package/server/web-control/panes/messages.js +21 -2
  67. package/server/web-control/panes/preview.js +318 -0
  68. package/server/web-control/panes/questions.css +20 -4
  69. package/server/web-control/panes/questions.js +127 -26
  70. package/server/web-control/panes/reader-media.js +409 -23
  71. package/server/web-control/panes/sessions.css +50 -10
  72. package/server/web-control/panes/settings.js +96 -5
  73. package/server/web-control/panes/speakers.css +3 -5
  74. package/server/web-control/panes/speakers.js +7 -4
  75. package/server/web-control/panes/threads.js +3 -2
  76. package/server/web-control/panes/today-actions.css +49 -18
  77. package/server/web-control/panes/today-actions.js +217 -50
  78. package/server/web-control/panes/work.css +11 -1
  79. package/server/web-control/panes/work.js +10 -2
  80. package/server/web-control/shell.js +20 -14
@@ -26,6 +26,153 @@
26
26
  }).join('\n');
27
27
  }
28
28
  function plain(text) { return String(text == null ? '' : text).replace(/\*\*|`/g, '').replace(/^#{1,4}\s+/, ''); }
29
+ // 7.0.5: a COS answer is written for the G2 lens as plain text, so a numbered list arrives inline ("...for me: 1. Prep
30
+ // before each meeting. 2. Catching conflicts early. 3. ..."), one long paragraph on the phone. Before md() draws a COS
31
+ // answer (the chat card, the Messages reader), each run of inline items 1, 2, 3 in order goes on its own line under
32
+ // its lead-in. Only whitespace changes: every character of the answer stays. The lens and the stored text never see it.
33
+ // Conservative: text with any Markdown structure is left alone; a marker counts only after a sentence end (". " ": "
34
+ // "? " "! ") and never inside a code, bold or italic span; fewer than two items, or numbers out of order, are not a list;
35
+ // words after the last item stay in it, except a short "Label:" sentence followed by a new 1, which starts the next list.
36
+ // 7.0.7 (final QA W1): a new line never starts with what a renderer reads as a block. A "Label:" that starts with one
37
+ // stays on the line before it, and a list whose item starts with one is left as it is (never half a list).
38
+ var LA_STRUCTURED = /^[ \t]*(?:[-*+][ \t]|\d{1,3}[.)][ \t]|#|>|\|)|\n[ \t]*\n|```|~~~/m;
39
+ var LA_MARK = /([.:?!]["'”’)]?)([ \t]+)([1-9]\d?)\.(?=[ \t]+\S)/g;
40
+ var LA_END = /[.?!]["'”’)]?([ \t]+)/g;
41
+ // Spans no cut may enter. Each kind is found on its own, as its renderer finds it, and a cut is refused inside any of
42
+ // them: md()'s own `code` and **bold** (never empty, no star inside bold), and for the Mac's inline Markdown *italic*,
43
+ // __bold__ and _italic_, each opening on a non-space and closing after one, on one line ("2 * 3" and a lone "*" are
44
+ // not spans). A word's own underscores (max_tokens, top_p) are matched first, so they never open a span. A link holds
45
+ // no whitespace, and only whitespace is ever replaced, so a link is never cut.
46
+ var LA_GUARD = /`[^`\n]+`/g;
47
+ var LA_BOLD = /\*\*[^*\n]+\*\*/g;
48
+ var LA_STAR = /\*(?!\*)\S(?:[^*\n]*(?!\*)\S)?\*/g;
49
+ var LA_UNDER = /[A-Za-z0-9]_+|__(?!_)\S(?:[^_\n]*(?!_)\S)?__(?![A-Za-z0-9])|_(?!_)\S(?:[^_\n]*(?!_)\S)?_(?![A-Za-z0-9])/g;
50
+ var LA_SPANS = [LA_GUARD, LA_BOLD, LA_STAR, LA_UNDER];
51
+ // What a line may not start with when a cut makes it start (md() here; the Mac's COSMarkdownParser also reads
52
+ // "[Speaker]:" lines and "<details>"): a heading, quote, bullet or table row, a "[" or "<", or a number list marker.
53
+ var LA_BLOCK_START = /[ \t]*(?:[\-#>*+|<\[]|\d+[.)])/y;
54
+ // What an item's own words may not start with: a heading, a quote, or a Mac task box ("1. [x] done").
55
+ var LA_ITEM_START = /[ \t]*(?:[#>]|\[[ xX]\])/y;
56
+ var LA_LABEL_MAX = 100;
57
+ // Does `re` (sticky) match at `at` in `line`?
58
+ function laAt(re, line, at) { re.lastIndex = at; return re.test(line); }
59
+ function structureLensAnswer(text) {
60
+ var s = text == null ? '' : String(text);
61
+ if (s.indexOf('. ') < 0 || LA_STRUCTURED.test(s)) return s;
62
+ var lines = s.split('\n'), out = [], changed = false;
63
+ for (var i = 0; i < lines.length; i++) {
64
+ var r = laLine(lines[i]);
65
+ if (r !== lines[i]) changed = true;
66
+ // A line that became a list ends with a blank line before whatever follows it.
67
+ out.push(r !== lines[i] && i < lines.length - 1 ? r + '\n' : r);
68
+ }
69
+ return changed ? out.join('\n') : s;
70
+ }
71
+ function laLine(line) {
72
+ var kinds = [], m;
73
+ for (var g = 0; g < LA_SPANS.length; g++) {
74
+ var re = LA_SPANS[g], starts = [], ends = [];
75
+ re.lastIndex = 0;
76
+ while ((m = re.exec(line))) { starts.push(m.index); ends.push(m.index + m[0].length); }
77
+ if (starts.length) kinds.push([starts, ends]);
78
+ }
79
+ // Inside a span of any kind? One kind's spans are in order and never overlap: binary search each.
80
+ var guarded = function (at) {
81
+ for (var k = 0; k < kinds.length; k++) {
82
+ var st = kinds[k][0], en = kinds[k][1], lo = 0, hi = st.length - 1;
83
+ while (lo <= hi) { var mid = (lo + hi) >> 1; if (st[mid] > at) hi = mid - 1; else if (en[mid] <= at) lo = mid + 1; else return true; }
84
+ }
85
+ return false;
86
+ };
87
+ var marks = [];
88
+ LA_MARK.lastIndex = 0;
89
+ while ((m = LA_MARK.exec(line))) {
90
+ var ws = m.index + m[1].length, at = ws + m[2].length;
91
+ if (guarded(m.index) || guarded(at)) continue;
92
+ marks.push({ colon: m[1].charAt(0) === ':', ws: ws, at: at, body: at + m[3].length + 1, n: Number(m[3]) });
93
+ }
94
+ // Runs of 1, 2, 3 in order; two items or more make a list.
95
+ var runs = [], cur = [];
96
+ for (var k = 0; k < marks.length; k++) {
97
+ var c = marks[k];
98
+ if (cur.length && c.n === cur[cur.length - 1].n + 1) { cur.push(c); continue; }
99
+ if (cur.length >= 2) runs.push(cur);
100
+ cur = c.n === 1 ? [c] : [];
101
+ }
102
+ if (cur.length >= 2) runs.push(cur);
103
+ var cuts = [], prevEnd = 0, lists = 0;
104
+ for (var q = 0; q < runs.length; q++) {
105
+ var run = runs[q], first = run[0];
106
+ // After a list, only a "Label:" lead-in starts another; anything else stays in the last item.
107
+ if (lists && !first.colon) continue;
108
+ // An item whose words start a block leaves its whole list as it is; it still counts as a list, so the words after
109
+ // it stay in its last item.
110
+ var clean = true;
111
+ for (var j = 0; j < run.length; j++) if (laAt(LA_ITEM_START, line, run[j].body)) { clean = false; break; }
112
+ if (clean) {
113
+ if (first.colon) {
114
+ // The lead-in is the "Label:" sentence: it gets its own line when it is short and does not start a block.
115
+ var from = -1, to = -1, e;
116
+ LA_END.lastIndex = prevEnd;
117
+ while ((e = LA_END.exec(line)) && e.index + e[0].length < first.ws) {
118
+ if (!guarded(e.index)) { to = e.index + e[0].length; from = to - e[1].length; }
119
+ }
120
+ if (from >= 0 && first.ws - to <= LA_LABEL_MAX && !laAt(LA_BLOCK_START, line, to)) cuts.push([from, to, '\n\n']);
121
+ }
122
+ cuts.push([first.ws, first.at, '\n\n']);
123
+ for (var r2 = 1; r2 < run.length; r2++) cuts.push([run[r2].ws, run[r2].at, '\n']);
124
+ }
125
+ prevEnd = run[run.length - 1].body; // after the last item's own "5.", which is not a sentence end
126
+ lists++;
127
+ }
128
+ if (!cuts.length) return line;
129
+ var res = '', pos = 0;
130
+ for (var x = 0; x < cuts.length; x++) { res += line.slice(pos, cuts[x][0]) + cuts[x][2]; pos = cuts[x][1]; }
131
+ return res + line.slice(pos);
132
+ }
133
+ // Markdown pipe tables (Duo tables). Every pane that draws one asks tableFit how far each column may shrink. Cells
134
+ // wrap only at spaces (CSS: overflow-wrap break-word, so a column is never narrower than its longest word), a column
135
+ // whose text is longer than TF_MIN_CH keeps at least that many characters, a first column of short labels stays on
136
+ // one line, and a long first column gets a fixed width and wraps at words. Only a token of TF_LONG or more characters
137
+ // (a URL, a hash) may break inside. A table wider than its box scrolls inside it (CSS .tf-wrap), first column sticky.
138
+ var TF_LONG = 25, TF_MIN_CH = 10, TF_KEY_WORDS = 3, TF_KEY_CHARS = 28, TF_FIRST_MIN = 14, TF_FIRST_MAX = 26;
139
+ var TF_LONG_RE = new RegExp('[^\\s<>]{' + TF_LONG + ',}', 'g');
140
+ function tfText(cell) {
141
+ return String(cell == null ? '' : cell).replace(/<[^>]*>/g, '').replace(/&(?:amp|lt|gt|quot|#39);/g, '_')
142
+ .replace(/\[([^\]\n]*)\]\([^)\s]*\)/g, '$1').replace(/\*\*|__|~~|`/g, '').replace(/\s+/g, ' ').trim();
143
+ }
144
+ function tfLong(html) {
145
+ return String(html).split(/(<[^>]*>)/).map(function (part) {
146
+ return part.charAt(0) === '<' ? part : part.replace(TF_LONG_RE, function (t) { return '<span class="tf-long">' + t + '</span>'; });
147
+ }).join('');
148
+ }
149
+ // rows: every row of raw cell text, the header row first when there is one.
150
+ function tableFit(rows) {
151
+ rows = (rows || []).filter(Array.isArray);
152
+ var n = rows.reduce(function (m, r) { return Math.max(m, r.length); }, 0);
153
+ var key = n > 1 && rows.length > 0 && rows.every(function (r) {
154
+ var t = tfText(r[0]);
155
+ return t.length <= TF_KEY_CHARS && (t ? t.split(' ').length : 0) <= TF_KEY_WORDS;
156
+ });
157
+ var cols = [];
158
+ for (var k = 0; k < n; k++) {
159
+ var len = rows.reduce(function (m, r) { return Math.max(m, tfText(r[k]).length); }, 0);
160
+ if (k === 0 && key) cols.push({ min: 0, width: 0 });
161
+ else if (k === 0 && n > 1) cols.push({ min: Math.min(len, TF_FIRST_MIN), width: Math.min(len, TF_FIRST_MAX) });
162
+ else cols.push({ min: Math.min(len, TF_MIN_CH), width: 0 });
163
+ }
164
+ return {
165
+ key: key, cols: cols,
166
+ tableClass: 'tf' + (key ? ' tf-key' : ''),
167
+ // The first row's cells carry each column's floor (an inner block's min-width counts in every engine's table
168
+ // sizing) and the first column's width; later rows only get their long tokens marked.
169
+ width: function (k, first) { var c = cols[k]; return first && c && c.width ? 'width:' + c.width + 'ch' : ''; },
170
+ cell: function (k, html, first) {
171
+ var c = cols[k], body = tfLong(html);
172
+ return first && c && c.min >= 3 ? '<span class="tf-min" style="min-width:' + c.min + 'ch">' + body + '</span>' : body;
173
+ }
174
+ };
175
+ }
29
176
  var WORDS = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten'];
30
177
  function countWord(n, lower) { var w = n >= 0 && n <= 10 ? WORDS[n] : String(n); return lower ? w.toLowerCase() : w; }
31
178
  function plural(n, one, many) { return n === 1 ? one : many; }
@@ -58,6 +205,19 @@
58
205
  if (diff === 1) return 'tomorrow';
59
206
  return d.toLocaleDateString(undefined, { weekday: 'long' });
60
207
  }
208
+ // 6.99.30 (design QA): a day in a row, in plain words: "Today", else "Nov 7" ("Nov 7, 2025" in another year). Takes
209
+ // a YYYY-MM-DD (a meeting's date, read as that calendar day, never shifted by time zone) or a Date. Anything else comes
210
+ // back as it was.
211
+ var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
212
+ function plainDay(v, now) {
213
+ var y, m, d;
214
+ if (v instanceof Date && isFinite(v.getTime())) { y = v.getFullYear(); m = v.getMonth(); d = v.getDate(); }
215
+ else { var p = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v == null ? '' : v)); if (!p) return v == null ? '' : String(v); y = +p[1]; m = +p[2] - 1; d = +p[3]; }
216
+ if (m < 0 || m > 11 || d < 1 || d > 31) return String(v);
217
+ var n = now || new Date();
218
+ if (y === n.getFullYear() && m === n.getMonth() && d === n.getDate()) return 'Today';
219
+ return MONTHS[m] + ' ' + d + (y === n.getFullYear() ? '' : ', ' + y);
220
+ }
61
221
  var PROVIDERS = { claude: 'Claude Code', codex: 'Codex', cursor: 'Cursor', glasses: 'Glasses', cos: 'COS' };
62
222
  function providerLabel(p) { return PROVIDERS[p] || (p ? p.charAt(0).toUpperCase() + p.slice(1) : 'Session'); }
63
223
  function badge(p) { var cls = PROVIDERS[p] ? (p === 'cos' ? 'glasses' : p) : 'work'; return '<span class="badge ' + esc(cls) + '">' + esc(providerLabel(p)) + '</span>'; }
@@ -100,9 +260,12 @@
100
260
  var limit = o.timeoutMs != null ? o.timeoutMs : (method === 'GET' ? API_READ_TIMEOUT_MS : 0);
101
261
  var ctrl = limit && window.AbortController ? new AbortController() : null;
102
262
  var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, limit) : null;
263
+ var sentAt = Date.now();
103
264
  return fetch(path, { method: method, headers: headers, credentials: 'same-origin', body: body, signal: ctrl ? ctrl.signal : undefined }).then(function (res) {
104
265
  // Blob bodies keep their deadline after headers: a stalled download must release its queue slot.
105
266
  if (timer && o.responseType !== 'blob') clearTimeout(timer);
267
+ sxNoteClock(res, sentAt);
268
+ noteServer(true);
106
269
  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
270
  if (o.responseType === 'blob' && res.ok) return res.blob();
108
271
  return res.text().then(function (text) {
@@ -126,14 +289,68 @@
126
289
  }, function (err) {
127
290
  if (timer) clearTimeout(timer);
128
291
  if (err && err.name === 'AbortError' && ctrl && ctrl.signal.aborted) {
292
+ // 6.99.32 (QA 6.99.30 canary W1): a read the Mac never answered in time means it is not answering. A write has
293
+ // no deadline unless its caller set one, and a slow save is not a Mac that went away, so only reads say so.
294
+ if (method === 'GET' && !(SERVER.lastUpAt >= sentAt)) noteServer(false);
129
295
  var late = new Error('Your Mac did not answer in ' + Math.round(limit / 1000) + ' seconds. Check that it is awake, then try again.');
130
296
  late.code = 'timeout'; throw late;
131
297
  }
298
+ // 6.99.30: the browser's own words for a request that never reached the Mac ("Failed to fetch" in Chrome and
299
+ // Comet, "Load failed" in Safari, "NetworkError..." in Firefox) never reach the page. Anything with no status
300
+ // that is not a timeout or an abort the caller asked for is the Mac out of reach, said once, here.
301
+ if (err && err.status == null && !err.code && err.name !== 'AbortError') { noteServer(false); throw unreachableError(err); }
132
302
  throw err;
133
303
  });
134
304
  }
135
305
  var API_READ_TIMEOUT_MS = 20000;
136
306
 
307
+ // ── what every request learns about the Mac (6.99.30) ──
308
+ // api() above and apiAnswer() (the Sessions reader's transport) share these; they sit here, between api() and the
309
+ // state, so a test that loads api() alone has them.
310
+ // 6.99.30: the one sentence for a request that never reached the Mac (api() and apiAnswer() below, and the panes with
311
+ // their own fetch: COSWeb.unreachableCopy). Never the browser's own "Failed to fetch" or "Load failed".
312
+ var UNREACHABLE_COPY = 'Could not reach COS on your Mac. Check that it is awake and on Tailscale, then try again.';
313
+ function unreachableError(cause) { var e = new Error(UNREACHABLE_COPY); e.code = 'unreachable'; e.cause = cause; return e; }
314
+
315
+ // 6.99.30 (QA: clock skew): the Mac's clock, as this page's offset from it. The session card's one state (sxTruth)
316
+ // weighs the Mac's own times (state_since, a row's activity) against moments this page stamped with its own clock (a
317
+ // gate answer, a Stop). A phone a minute behind made every Mac time look a minute newer, so a stale Idle beat a
318
+ // Working the page had just been told about. Each answer's Date header (whole seconds, read at the midpoint of the
319
+ // request) gives the offset; the answer with the shortest round trip in the last ten minutes wins, so one slow answer
320
+ // never moves it. Zero until the first answer. A missing or unreadable header changes nothing.
321
+ var SX_CLOCK = { skew: 0, rtt: Infinity, at: 0 };
322
+ var SX_CLOCK_KEEP_MS = 600000;
323
+ function sxNoteClock(res, sentAt) {
324
+ var got = Date.now(), h = null;
325
+ try { h = res && res.headers && typeof res.headers.get === 'function' ? res.headers.get('date') : null; } catch (_e) { h = null; }
326
+ var server = h ? Date.parse(h) : NaN;
327
+ if (!isFinite(server) || typeof sentAt !== 'number') return;
328
+ var rtt = Math.max(0, got - sentAt);
329
+ // The header drops the milliseconds: on average the Mac's moment is half a second after the stamped second.
330
+ var skew = Math.round(server + 500 - (sentAt + rtt / 2));
331
+ if (rtt <= SX_CLOCK.rtt || got - SX_CLOCK.at > SX_CLOCK_KEEP_MS) { SX_CLOCK.skew = skew; SX_CLOCK.rtt = rtt; SX_CLOCK.at = got; }
332
+ }
333
+ // 6.99.30 (QA: "Server connected" stayed green for five minutes after the Mac went away): every request says whether
334
+ // the Mac answered. A request that never reached it marks it down; any HTTP answer, whatever its status, marks it up.
335
+ // QA 6.99.30 (Skeptic): never a 502. Nothing sits in front of the Mac (direct HTTPS over Tailscale) and COS sends its
336
+ // own 502s (an unsupported Memories workspace, a bad Meetings Ask answer), so a 502 flapped the rail, the question
337
+ // cards and an open sheet down and up again. The rail's server line, Today's sheet and the question cards hear each change at once
338
+ // (COSWeb.onServer). null until the first answer.
339
+ // lastUpAt (6.99.32, QA Skeptic W-2): when the Mac last answered anything, on this page's clock. A read that timed out
340
+ // marks it down only if nothing has answered since that read went out (a slow image whose headers already came, a busy
341
+ // Mac whose question poll keeps answering), so a slow but alive Mac never flaps.
342
+ var SERVER = { up: null, hooks: [], lastUpAt: 0 };
343
+ function noteServer(up) {
344
+ up = !!up;
345
+ if (up) SERVER.lastUpAt = Date.now();
346
+ if (SERVER.up === up) return;
347
+ SERVER.up = up;
348
+ SERVER.hooks.slice().forEach(function (fn) { try { fn(up); } catch (err) { console.error(err); } });
349
+ }
350
+
351
+ /** This page's moment `t` (its own Date.now()) on the Mac's clock. */
352
+ function sxServerTime(t) { return t == null ? t : t + SX_CLOCK.skew; }
353
+
137
354
  // ── state ───────────────────────────────────────────────
138
355
  var state = {
139
356
  pane: 'today', serverName: '', tailnetUrl: '',
@@ -208,7 +425,21 @@
208
425
  var hash = '#/' + [pane].concat(args || []).map(encodeURIComponent).join('/');
209
426
  if (location.hash === hash) route(); else location.hash = hash;
210
427
  }
211
- function isPhone() { return !window.matchMedia('(min-width: 600px)').matches; }
428
+ // 7.0.2 (A8): a phone is under 600 px wide, or a short touch screen at any width (the unfolded Duo is 951x539 in
429
+ // Comet): height is its scarce axis, so it gets the phone Ask sheet and the 44 px tap floor (app.css), as folded does.
430
+ var SHORT_TOUCH_MQ = '(pointer: coarse) and (max-height: 720px)';
431
+ function isPhone() { return !window.matchMedia('(min-width: 600px)').matches || window.matchMedia(SHORT_TOUCH_MQ).matches; }
432
+ /** A keyboard and a fine pointer are the way in (a Mac, a PC): Return sends. Touch screens type a new line. */
433
+ function keyboardFirst() { return !!(window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches); }
434
+ // Return in a text box sends only where a keyboard and a pointer are the way in (cos-chat.js keyboardFirst): never on
435
+ // a touch screen, whatever its width (6.99.30: the unfolded Duo's on-screen keyboard sent on Return), and never
436
+ // mid-composition (an IME's Return picks the word; keyCode 229 is Safari's composing key). Shift-Return is a new line.
437
+ // QA 6.99.30 (Skeptic): Cmd-Return or Ctrl-Return sends on every device, so a Bluetooth keyboard on the Duo or an iPad
438
+ // (a coarse pointer, so plain Return is a new line there) still has a send key.
439
+ function returnSends(e) {
440
+ if (!e || e.key !== 'Enter' || e.shiftKey || e.isComposing || e.keyCode === 229 || !e.target || e.target.tagName !== 'TEXTAREA') return false;
441
+ return !!(e.metaKey || e.ctrlKey || keyboardFirst());
442
+ }
212
443
  function route(forceChanged) {
213
444
  var r = parseRoute();
214
445
  var changedPane = forceChanged === true || r.pane !== state.pane || !document.querySelector('#pane-' + r.pane + ':not(.hidden)');
@@ -298,6 +529,7 @@
298
529
  // ── auth ────────────────────────────────────────────────
299
530
  function showAuth(message) {
300
531
  readerImagesStop($('session-turns')); readerImagesStop($('ta-last'));
532
+ if (typeof liveHide === 'function') { liveHide(); liveStopWatch(); LIVE.mode = 'idle'; }
301
533
  var alreadyOpen = !$('auth-gate').classList.contains('hidden');
302
534
  $('app-shell').classList.add('hidden');
303
535
  $('auth-gate').classList.remove('hidden');
@@ -315,6 +547,7 @@
315
547
  loadTailnetUrl();
316
548
  runHooks(shellHooks, info);
317
549
  route();
550
+ if (typeof liveStart === 'function') liveStart(); // 7.0.1: the one live stream for this page (a no-op if it is already open)
318
551
  // Reloaded with Today's overlay open: its history entry is still current. The overlay opens the same thing again
319
552
  // (panes/today-actions.js restore), so Back closes it; if it cannot load, the entry is unmarked.
320
553
  var held = history.state;
@@ -338,15 +571,24 @@
338
571
  // stories all asked for it separately. Now there is one copy: a request younger than maxAgeMs is
339
572
  // answered from it, a request already on the wire is shared, and panes can paint the last copy at
340
573
  // once while a fresh one loads (sessionListCached).
341
- var SL = { data: null, at: 0, inflight: null, waiting: null };
574
+ var SL = { data: null, at: 0, inflight: null, waiting: null, failed: false };
342
575
  function sessionList(maxAgeMs) {
343
576
  if (SL.data && Date.now() - SL.at < (maxAgeMs || 0)) return Promise.resolve(SL.data);
344
577
  if (SL.inflight) return SL.inflight;
345
578
  SL.inflight = api('/api/agent-sessions?limit=80&sort=updated').then(function (d) {
579
+ // 7.0.5: a sessions read (Sessions pane or Today) that follows a failed one is a reconnect: Today's next summary
580
+ // ask says so (requestBrief), once.
581
+ if (SL.failed) { SL.failed = false; if (typeof BRIEF === 'object' && BRIEF) BRIEF.reconnect = true; }
346
582
  SL.data = d; SL.at = Date.now(); SL.inflight = null;
583
+ // 7.0.1: a fresh list is corrected by the live model at once, so it never paints a state the stream already moved.
584
+ // Only while a stream is open and live (review W1): reconnecting, busy or hidden, the list is the newest truth.
585
+ if (typeof liveCanOverlay === 'function' && d && Array.isArray(d.sessions)) {
586
+ if (liveCanOverlay()) liveOverlay(d.sessions, { placeholders: true, unknown: true });
587
+ else if (liveOn()) LIVE.freshAt = SL.at;
588
+ }
347
589
  noteWaiting(d);
348
590
  return d;
349
- }, function (err) { SL.inflight = null; throw err; });
591
+ }, function (err) { SL.inflight = null; SL.failed = true; throw err; });
350
592
  return SL.inflight;
351
593
  }
352
594
  function sessionListCached() { return SL.data ? { data: SL.data, at: SL.at } : null; }
@@ -368,6 +610,438 @@
368
610
  if (fresh) questionsNudge();
369
611
  }
370
612
 
613
+ // ── live state (7.0.1) ──────────────────────────────────
614
+ // Miles 11/07: the pet, the session rows, Today's counts and the running clocks showed the last fetch, not now. One
615
+ // EventSource('/api/events') per page (docs/live-events-contract.md) tells the page when the server's own session
616
+ // state moves: ids, states, counts and times only. The list (/api/agent-sessions) still carries every word; the
617
+ // stream corrects its rows in place and asks for a fresh list when a session appears, leaves or replies.
618
+ // mode 'idle' not started (signed out)
619
+ // 'connecting' a stream is opening; nothing painted differently yet
620
+ // 'live' a frame arrived inside the last 30 s: counts are live
621
+ // 'reconnecting' the stream dropped or went quiet: "Reconnecting", the list read every 5 s until it is back
622
+ // 'busy' the server has no room for another stream (503): retried in 30 s, no fast polling
623
+ // 'absent' no /api/events (an older server, or COS_WEB_LIVE_EVENTS=0): exactly what 7.0.0 did
624
+ // A hidden page holds no stream and runs no timer; coming back re-reads the list at once and resumes from the last
625
+ // id (`?after=<epoch>.<seq>`, the contract's manual resume, since new EventSource cannot set Last-Event-ID).
626
+ var LIVE_SILENT_MS = 30000, LIVE_POLL_MS = 5000, LIVE_WATCH_MS = 5000, LIVE_RETRY_MS = 3000, LIVE_RETRY_MAX_MS = 30000;
627
+ var LIVE_BUSY_MS = 30000, LIVE_ABSENT_RECHECK_MS = 10 * 60000, LIVE_LIST_DEBOUNCE_MS = 1000, LIVE_STALE_MS = 30000, LIVE_REPLY_LIST_GAP_MS = 12000;
628
+ var LIVE = {
629
+ mode: 'idle', es: null, lastId: '', epoch: null, seq: -1, frameAt: 0, skew: 0, gotFrame: false,
630
+ sessions: {}, // key (provider:sessionid, lowercase) -> LiveSession
631
+ questions: null, queue: null, degraded: {},
632
+ freshAt: 0, // when the counts on screen were last confirmed: a frame on a live stream, or a list read
633
+ retryMs: LIVE_RETRY_MS, retry: null, poll: null, watch: null, listTimer: null, paintTimer: null,
634
+ absentAt: 0, hooks: [], gen: 0, pendingRows: [], pendingStructural: false, listAt: 0, listDue: 0, noteSaid: null
635
+ };
636
+ function liveOnMode(m) { return m === 'live' || m === 'reconnecting' || m === 'busy'; }
637
+ function liveOn() { return liveOnMode(LIVE.mode); }
638
+ /** The live model corrects list rows only while a stream is open and live. Reconnecting, busy or hidden, the list
639
+ * read is newer than the model (review W1). The model is kept only as the base a resume applies to. */
640
+ function liveCanOverlay() { return LIVE.mode === 'live' && !!LIVE.es && LIVE.gotFrame; }
641
+ function liveKey(provider, id) { return String(provider || 'claude').toLowerCase() + ':' + String(id || '').toLowerCase(); }
642
+ function liveRowKey(s) { var k = splitSessionId(s.provider, s.session_id || s.id); return liveKey(k.provider, k.id); }
643
+ // The contract's join (review B1): the full id, lowercase. The 8-character prefix joins only when one side is
644
+ // exactly 8 characters (the list may carry a Claude tab under the registry's short id until it finds the
645
+ // transcript) and only one candidate matches, the rule countFor uses on the server. Codex ids are UUIDv7: their
646
+ // first 8 characters are a timestamp and stay the same for about 65 s, so a prefix alone joins parallel sessions.
647
+ function liveSame(a, b) {
648
+ if (a === b) return true;
649
+ var ia = a.slice(a.indexOf(':') + 1), ib = b.slice(b.indexOf(':') + 1);
650
+ if (a.slice(0, a.length - ia.length) !== b.slice(0, b.length - ib.length)) return false;
651
+ // One side must BE the other's first 8 characters, so it is exactly 8 long: two full ids never join here.
652
+ return ia.length === 8 ? ib.slice(0, 8) === ia : ia.slice(0, 8) === ib;
653
+ }
654
+ /** The one key in `keys` this key joins, or '' (none, or more than one by prefix). */
655
+ function liveMatch(key, keys) {
656
+ if (keys.indexOf(key) >= 0) return key;
657
+ var hits = keys.filter(function (k) { return liveSame(key, k); });
658
+ return hits.length === 1 ? hits[0] : '';
659
+ }
660
+ function liveIso(ms) { return typeof ms === 'number' && isFinite(ms) ? new Date(ms).toISOString() : null; }
661
+ /** The list arrays on this page (usually one array, shared): every row a live frame must correct. */
662
+ function liveLists() {
663
+ var out = [];
664
+ [SL.data && SL.data.sessions, state.sessions, typeof SX !== 'undefined' && SX ? SX.rows : null].forEach(function (a) { if (Array.isArray(a) && out.indexOf(a) < 0) out.push(a); });
665
+ return out;
666
+ }
667
+ /** Write one LiveSession onto a list row, in the list's own fields (sxState, running_active, running). */
668
+ // Claude: the registry and the hooks know whether the tab is open, so the stream decides open or closed too. Codex
669
+ // and Cursor (review W2): the stream's "open" is only a 30-minute hook window, while the list reads the process
670
+ // itself, so the list decides open or closed and alive; the stream moves working, waiting or idle and the times.
671
+ function liveOwnsOpen(provider) { return String(provider || 'claude').toLowerCase() === 'claude'; }
672
+ function liveWrite(row, ls) {
673
+ var st = ls.state;
674
+ row._live = true;
675
+ row._liveTurnAt = (st === 'working' || st === 'waiting') && typeof ls.turnStartedAt === 'number' ? ls.turnStartedAt : null;
676
+ row.state_source = ls.source === 'registry' ? 'registry' : 'hook';
677
+ row.running_active = st === 'working';
678
+ if (liveOwnsOpen(ls.provider)) {
679
+ row.running = st !== 'ended';
680
+ row.alive = st !== 'ended';
681
+ row.agent_state = st === 'working' ? 'running' : st === 'waiting' ? 'waiting' : st === 'ended' ? 'ended' : ls.failed ? 'failed' : 'idle';
682
+ } else if (st === 'working' || st === 'waiting') {
683
+ row.agent_state = st === 'working' ? 'running' : 'waiting';
684
+ } else if (row.agent_state === 'running' || row.agent_state === 'waiting' || row.agent_state === 'idle' || row.agent_state === 'failed' || !row.agent_state) {
685
+ row.agent_state = row.running === false ? 'ended' : ls.failed && st !== 'ended' ? 'failed' : 'idle';
686
+ }
687
+ if (row._liveTurnAt != null) row.state_since = liveIso(row._liveTurnAt);
688
+ else if (typeof ls.updatedAt === 'number' && row._liveState !== st) row.state_since = liveIso(ls.updatedAt);
689
+ if (typeof ls.updatedAt === 'number') row.last_activity_at = liveIso(ls.updatedAt);
690
+ if (typeof ls.queued === 'number') row.queued_turns = ls.queued;
691
+ row._liveState = st;
692
+ }
693
+ /** A session the stream says left the live model: no longer working or open (it may stay in the list as recent). */
694
+ function liveForget(row) {
695
+ row._live = false; row._liveTurnAt = null; row._liveState = '';
696
+ row.running_active = false;
697
+ if (!liveOwnsOpen(splitSessionId(row.provider, row.session_id || row.id).provider)) {
698
+ // Codex and Cursor: still open or closed as the list says; only no longer working or waiting.
699
+ if (row.agent_state === 'running' || row.agent_state === 'waiting') row.agent_state = row.running === false ? 'ended' : 'idle';
700
+ return;
701
+ }
702
+ row.running = false;
703
+ if (row.agent_state === 'running' || row.agent_state === 'waiting' || row.agent_state === 'idle') row.agent_state = 'ended';
704
+ }
705
+ /** Overlay the live model on the list rows. Returns what moved: { changed: [rows], added: n, unknown: n }. */
706
+ function liveOverlay(rows, opts) {
707
+ var o = opts || {}, out = { changed: [], added: 0, unknown: [] }, byJoin = {};
708
+ Object.keys(LIVE.sessions).forEach(function (k) { var ls = LIVE.sessions[k]; byJoin[liveKey(ls.provider, ls.sessionId)] = ls; });
709
+ var keys = Object.keys(byJoin), seen = {};
710
+ (rows || []).forEach(function (row) {
711
+ if (!row) return;
712
+ var j = liveMatch(liveRowKey(row), keys), ls = j ? byJoin[j] : null;
713
+ if (ls) { seen[j] = 1; var before = row._liveState + '|' + row._liveTurnAt + '|' + row.queued_turns; liveWrite(row, ls); if (before !== row._liveState + '|' + row._liveTurnAt + '|' + row.queued_turns) out.changed.push(row); }
714
+ // A row this page corrected before, now missing from a whole snapshot: it left. Never a row the stream never
715
+ // spoke for (a Codex or Cursor session whose hooks never reach the server stays as the list says), and never
716
+ // while the server says it could not read its registry (degraded: absence is not "nothing running").
717
+ else if (o.whole && row._live && !LIVE.degraded.registry) { liveForget(row); out.changed.push(row); }
718
+ });
719
+ // A session the list has not caught up with yet: a placeholder row while it works or waits, so the pet and the
720
+ // Running count move now, and a fresh list (debounced) brings its title.
721
+ if (o.placeholders || o.unknown) {
722
+ Object.keys(byJoin).forEach(function (j) {
723
+ if (seen[j]) return;
724
+ var ls = byJoin[j];
725
+ out.unknown.push(j);
726
+ if (!o.placeholders || (ls.state !== 'working' && ls.state !== 'waiting')) return;
727
+ var row = { provider: ls.provider, session_id: ls.sessionId, id: ls.sessionId, title: 'New ' + providerLabel(ls.provider) + ' session', _placeholder: true, running: true, alive: true };
728
+ liveWrite(row, ls);
729
+ rows.unshift(row); out.added++; out.changed.push(row);
730
+ });
731
+ }
732
+ return out;
733
+ }
734
+ /** Apply the live model to every list on the page, then repaint what shows. */
735
+ function liveApply(whole) {
736
+ if (!liveCanOverlay()) return { changed: 0, added: 0, unknown: 0 };
737
+ var lists = liveLists(), changed = [], added = 0, unknown = [];
738
+ lists.forEach(function (rows, i) {
739
+ var r = liveOverlay(rows, { whole: whole, placeholders: i === 0, unknown: i === 0 });
740
+ changed = changed.concat(r.changed); added += r.added; if (i === 0) unknown = r.unknown;
741
+ });
742
+ // A session the list has not shown yet: the snapshot and each new session already ask for one fresh list
743
+ // (liveListSoon in liveFrame), so nothing more here.
744
+ if (changed.length || added) livePaint(changed, added > 0);
745
+ return { changed: changed.length, added: added, unknown: unknown.length };
746
+ }
747
+ function liveRunHooks(ev) { LIVE.hooks.slice().forEach(function (fn) { try { fn(ev); } catch (err) { console.error(err); } }); }
748
+ // One frame. Returns false when the stream must be reopened (a gap or a server restart).
749
+ function liveFrame(f) {
750
+ if (!f || typeof f !== 'object' || typeof f.kind !== 'string') return true;
751
+ var now = Date.now(), couldOverlay = liveCanOverlay();
752
+ LIVE.frameAt = now;
753
+ if (typeof f.at === 'number' && isFinite(f.at)) LIVE.skew = now - f.at;
754
+ var stateFrame = f.kind === 'snapshot' || f.kind === 'session' || f.kind === 'session.removed' || f.kind === 'questions' || f.kind === 'queue';
755
+ if (f.kind === 'snapshot') {
756
+ LIVE.epoch = f.epoch; LIVE.seq = typeof f.seq === 'number' ? f.seq : -1;
757
+ LIVE.sessions = {};
758
+ (Array.isArray(f.sessions) ? f.sessions : []).forEach(function (ls) { if (ls && ls.sessionId) LIVE.sessions[liveKey(ls.provider, ls.sessionId)] = ls; });
759
+ LIVE.questions = f.questions || null; LIVE.queue = f.queue || null;
760
+ LIVE.degraded = f.degraded && typeof f.degraded === 'object' ? f.degraded : {};
761
+ } else if (typeof f.seq === 'number') {
762
+ if (LIVE.epoch !== null && f.epoch != null && f.epoch !== LIVE.epoch) return false; // a new server life
763
+ if (stateFrame ? f.seq > LIVE.seq + 1 : f.seq > LIVE.seq) return false; // missed a frame
764
+ if (stateFrame && f.seq <= LIVE.seq) return true; // already applied
765
+ }
766
+ if (stateFrame && typeof f.seq === 'number') { LIVE.seq = f.seq; if (f.epoch != null) LIVE.epoch = f.epoch; LIVE.lastId = LIVE.epoch + '.' + LIVE.seq; }
767
+ if (!LIVE.gotFrame) { LIVE.gotFrame = true; LIVE.retryMs = LIVE_RETRY_MS; }
768
+ var wasMode = LIVE.mode;
769
+ LIVE.mode = 'live';
770
+ if (!LIVE.degraded.registry) LIVE.freshAt = now;
771
+ liveStopPoll();
772
+ // The first frame on a (re)opened stream, after a list read won (Reconnecting, busy, or hidden and back): the
773
+ // resumed model corrects the rows again, including a resume that replayed nothing.
774
+ if (!couldOverlay && f.kind !== 'snapshot') liveApply(false);
775
+ if (f.kind === 'snapshot') {
776
+ liveApply(true);
777
+ liveListSoon();
778
+ questionsNudge();
779
+ liveRunHooks({ kind: 'snapshot' });
780
+ } else if (f.kind === 'session' && f.session && f.session.sessionId) {
781
+ var k = liveKey(f.session.provider, f.session.sessionId), was = LIVE.sessions[k];
782
+ LIVE.sessions[k] = f.session;
783
+ var r = liveApply(false);
784
+ // New: the words come from the list now. A reply landed (`replies` went up): the words come from the list, at
785
+ // most one read per LIVE_REPLY_LIST_GAP_MS (ghost W7: dozens of sessions ending turns would poll the list).
786
+ if (f.change === 'added' || !was) liveListSoon();
787
+ else if (typeof f.session.replies === 'number' && f.session.replies > (was.replies || 0)) liveListSoon(LIVE_REPLY_LIST_GAP_MS);
788
+ if (f.session.state === 'waiting' && (!was || was.state !== 'waiting')) questionsNudge();
789
+ liveRunHooks({ kind: 'session', key: k, session: f.session, previous: was || null, moved: r.changed });
790
+ } else if (f.kind === 'session.removed') {
791
+ var j = liveKey(f.provider, f.sessionId), gone = [];
792
+ delete LIVE.sessions[j];
793
+ var others = Object.keys(LIVE.sessions).map(function (k) { return liveKey(LIVE.sessions[k].provider, LIVE.sessions[k].sessionId); });
794
+ liveLists().forEach(function (rows) {
795
+ for (var i = rows.length - 1; i >= 0; i--) {
796
+ var row = rows[i]; if (!row) continue;
797
+ var rk = liveRowKey(row);
798
+ // This row is the removed session's (by the same join), and no remaining live session claims it.
799
+ if (liveMatch(rk, others.concat([j])) !== j) continue;
800
+ if (row._placeholder) { rows.splice(i, 1); gone.push(row); } else if (row._live) { liveForget(row); gone.push(row); }
801
+ }
802
+ });
803
+ if (gone.length) livePaint(gone, true);
804
+ liveListSoon();
805
+ liveRunHooks({ kind: 'session.removed', key: liveKey(f.provider, f.sessionId) });
806
+ } else if (f.kind === 'questions') {
807
+ LIVE.questions = { total: f.total, approvals: f.approvals, bySession: f.bySession };
808
+ questionsNudge(); // the cards come from /api/session-questions (questions.js keeps its own poll as the fallback)
809
+ liveRunHooks({ kind: 'questions' });
810
+ } else if (f.kind === 'queue') {
811
+ LIVE.queue = { total: f.total, bySession: f.bySession };
812
+ liveRunHooks({ kind: 'queue' });
813
+ }
814
+ if (wasMode !== 'live') livePaint([], true);
815
+ return true;
816
+ }
817
+ function liveUrl() { return '/api/events' + (LIVE.lastId ? '?after=' + encodeURIComponent(LIVE.lastId) : ''); }
818
+ function liveVisible() { return !document.hidden; }
819
+ function liveSignedIn() { var sh = $('app-shell'); return !!(sh && !sh.classList.contains('hidden')); }
820
+ /** Open the one stream (never a second while one is open). */
821
+ function liveOpen() {
822
+ clearTimeout(LIVE.retry); LIVE.retry = null;
823
+ if (LIVE.es || !liveVisible() || !liveSignedIn()) return;
824
+ if (typeof EventSource !== 'function') { liveSetMode('absent'); return; }
825
+ var es, gen = ++LIVE.gen;
826
+ try { es = new EventSource(liveUrl()); } catch (_e) { liveSetMode('absent'); return; }
827
+ LIVE.es = es; LIVE.gotFrame = false;
828
+ if (LIVE.mode !== 'live' && LIVE.mode !== 'reconnecting' && LIVE.mode !== 'busy') LIVE.mode = 'connecting';
829
+ LIVE.frameAt = Date.now();
830
+ es.onmessage = function (ev) {
831
+ if (LIVE.es !== es || gen !== LIVE.gen) return;
832
+ var f; try { f = JSON.parse(ev.data); } catch (_e) { return; }
833
+ if (liveFrame(f) === false) {
834
+ // A gap or a new server life: reopen. A gap resumes from the last applied id; a new life starts fresh.
835
+ if (f && f.epoch != null && LIVE.epoch !== null && f.epoch !== LIVE.epoch) { LIVE.lastId = ''; LIVE.epoch = null; LIVE.seq = -1; }
836
+ liveClose(); liveOpen();
837
+ }
838
+ };
839
+ es.onerror = function () {
840
+ if (LIVE.es !== es || gen !== LIVE.gen) return;
841
+ if (!LIVE.gotFrame) {
842
+ // Never got a frame: EventSource hides the status, so one GET reads it (the readers' fallback-status pattern).
843
+ liveClose();
844
+ liveProbe(gen);
845
+ return;
846
+ }
847
+ // The stream had been live. The browser reconnects on its own with Last-Event-ID while readyState is 0; a closed
848
+ // stream (2) is reopened here with ?after. Either way the page says so and reads the list until a frame lands.
849
+ LIVE.gotFrame = false;
850
+ if (es.readyState === 2) { liveClose(); liveReconnect(); }
851
+ else liveSetMode('reconnecting');
852
+ };
853
+ liveStartWatch();
854
+ }
855
+ function liveClose() {
856
+ if (LIVE.es) { try { LIVE.es.close(); } catch (_e) {} }
857
+ LIVE.es = null; LIVE.gen++;
858
+ }
859
+ // What the route answered, read once without signing the page out: 404 (no route, or the switch off) and 401 or 403
860
+ // mean no live stream here, so the page does exactly what 7.0.0 did. 503 is a full server. Anything else (a
861
+ // network failure, a 200 that dropped) is a stream to reopen.
862
+ function liveProbe(gen) {
863
+ var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
864
+ var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, 10000) : null;
865
+ fetch(liveUrl(), { headers: { 'X-COS-Web': '1', Accept: 'text/event-stream' }, credentials: 'same-origin', signal: ctrl ? ctrl.signal : undefined })
866
+ .then(function (res) {
867
+ var code = res.status, type = '';
868
+ try { type = String((res.headers && res.headers.get && res.headers.get('content-type')) || ''); } catch (_e) { type = ''; }
869
+ if (ctrl) ctrl.abort();
870
+ return { code: code, stream: type.indexOf('text/event-stream') === 0 };
871
+ }, function () { return { code: 0, stream: false }; })
872
+ .then(function (r) {
873
+ if (timer) clearTimeout(timer);
874
+ if (LIVE.es || LIVE.mode === 'idle') return;
875
+ if (r.code === 503) { liveSetMode('busy'); LIVE.retry = setTimeout(liveOpen, LIVE_BUSY_MS); return; }
876
+ if (r.code === 0 || (r.code === 200 && r.stream)) { liveReconnect(); return; }
877
+ // 404, 401, 403, any other status, or a 200 that is not a stream: polling as today.
878
+ liveSetMode('absent');
879
+ });
880
+ }
881
+ function liveReconnect() {
882
+ liveSetMode('reconnecting');
883
+ clearTimeout(LIVE.retry);
884
+ var wait = LIVE.retryMs;
885
+ LIVE.retryMs = Math.min(LIVE.retryMs * 2, LIVE_RETRY_MAX_MS);
886
+ LIVE.retry = setTimeout(liveOpen, wait);
887
+ }
888
+ function liveSetMode(m) {
889
+ var was = LIVE.mode;
890
+ LIVE.mode = m;
891
+ if (m === 'absent') {
892
+ // Exactly 7.0.0: no stream, no extra timer, no label. Rows this page corrected keep their last values until the
893
+ // next list read replaces them, as 7.0.0's rows did.
894
+ liveClose(); liveStopPoll(); liveStopWatch(); clearTimeout(LIVE.retry); LIVE.retry = null;
895
+ LIVE.absentAt = Date.now(); LIVE.sessions = {};
896
+ }
897
+ if (m === 'reconnecting') liveStartPoll(); else if (m !== 'live') liveStopPoll();
898
+ if (was === 'live' && m !== 'live') liveLetGo();
899
+ if (was !== m && (liveOnMode(was) || liveOnMode(m))) livePaint([], true);
900
+ }
901
+ /** Leaving live (review W1): placeholder rows go, and no row keeps a live mark the next list read would not
902
+ * replace. The model stays only as the base a resume applies to; nothing overlays it until a frame lands. */
903
+ function liveLetGo() {
904
+ liveLists().forEach(function (rows) {
905
+ for (var i = rows.length - 1; i >= 0; i--) {
906
+ var row = rows[i]; if (!row) continue;
907
+ if (row._placeholder) rows.splice(i, 1); else if (row._live) { row._live = false; row._liveTurnAt = null; row._liveState = ''; }
908
+ }
909
+ });
910
+ }
911
+ // ── the fallbacks: a watchdog for a quiet stream, and the 5 s list read while reconnecting ──
912
+ function liveStartWatch() {
913
+ if (LIVE.watch) return;
914
+ LIVE.watch = setInterval(function () {
915
+ if (!liveVisible()) return;
916
+ if ((LIVE.mode === 'live' || LIVE.mode === 'connecting' || LIVE.mode === 'reconnecting') && LIVE.es && Date.now() - LIVE.frameAt > LIVE_SILENT_MS) {
917
+ // No frame of any kind for 30 s (the heartbeat is every 15 s): the stream is dead whatever readyState says.
918
+ liveClose(); liveReconnect();
919
+ }
920
+ if (liveOn() || LIVE.mode === 'connecting') liveNoteTick();
921
+ }, LIVE_WATCH_MS);
922
+ }
923
+ function liveStopWatch() { clearInterval(LIVE.watch); LIVE.watch = null; }
924
+ function liveStartPoll() {
925
+ if (LIVE.poll) return;
926
+ LIVE.poll = setInterval(function () { if (liveVisible() && LIVE.mode === 'reconnecting') livePollOnce(); }, LIVE_POLL_MS);
927
+ }
928
+ function liveStopPoll() { clearInterval(LIVE.poll); LIVE.poll = null; }
929
+ /** Read the list now, and swap it in on every pane that shows it. Resolves when it landed (or failed). */
930
+ function livePollOnce() {
931
+ var before = SL.data && SL.data.sessions;
932
+ LIVE.listAt = Date.now();
933
+ var keysOf = function (a) { return (a || []).map(function (s) { return s ? liveRowKey(s) : ''; }).join(','); };
934
+ return sessionList(0).then(function (d) {
935
+ var rows = (d && d.sessions) || [];
936
+ var moved = keysOf(before) !== keysOf(rows);
937
+ if (state.sessions === before || !state.sessions || !state.sessions.length) state.sessions = rows;
938
+ if (typeof SX !== 'undefined' && SX && SX.rows === before) { SX.rows = rows; SX.loadedAt = Date.now(); }
939
+ LIVE.freshAt = Date.now();
940
+ if (liveOn()) livePaint(rows, moved);
941
+ return true;
942
+ }, function () { if (liveOn()) livePaint([], false); return false; });
943
+ }
944
+ /** A fresh list soon (debounced), for the words of a session that appeared, left or replied. With `gapMs` (a reply),
945
+ * no sooner than gapMs after the last list read, and never later than a read already due. */
946
+ function liveListSoon(gapMs) {
947
+ var now = Date.now(), due = now + LIVE_LIST_DEBOUNCE_MS;
948
+ if (gapMs) {
949
+ if (LIVE.listTimer) return; // a read is already due; it brings this reply's words too
950
+ due = Math.max(due, (LIVE.listAt || 0) + gapMs);
951
+ }
952
+ clearTimeout(LIVE.listTimer);
953
+ LIVE.listDue = due;
954
+ LIVE.listTimer = setTimeout(function () { LIVE.listTimer = null; if (liveVisible()) livePollOnce(); }, due - now);
955
+ }
956
+ // ── coming back: visible, back from the back-forward cache, back online ──
957
+ function liveResume() {
958
+ if (!liveSignedIn()) return;
959
+ var wasOn = liveOn();
960
+ if (LIVE.mode === 'absent') {
961
+ // 7.0.0 behaviour; look again only after a long while (a server updated or switched on since). That look is one
962
+ // stream attempt, nothing else: the panes read what 7.0.0 read.
963
+ if (Date.now() - LIVE.absentAt < LIVE_ABSENT_RECHECK_MS) return;
964
+ LIVE.mode = 'connecting';
965
+ }
966
+ if (LIVE.mode === 'idle') return;
967
+ if (wasOn) livePollOnce();
968
+ if (!LIVE.es) { clearTimeout(LIVE.retry); LIVE.retry = null; LIVE.retryMs = LIVE_RETRY_MS; liveOpen(); }
969
+ liveStartWatch();
970
+ liveTickStart(); // stops itself at once when nothing on screen ticks
971
+ liveNoteTick();
972
+ }
973
+ function liveHide() {
974
+ // A hidden page holds no stream (a locked phone drops it anyway) and runs no live timer: not the stream, the list
975
+ // read, the retry, the watchdog or the 1 s clock (ghost note 1).
976
+ liveClose(); liveStopPoll(); liveStopWatch(); liveTickStop(); clearTimeout(LIVE.retry); LIVE.retry = null; clearTimeout(LIVE.listTimer); LIVE.listTimer = null;
977
+ }
978
+ function liveStart() {
979
+ if (LIVE.mode !== 'idle') return;
980
+ LIVE.mode = 'connecting';
981
+ liveOpen();
982
+ }
983
+ // ── honesty: how old the counts on screen are ──
984
+ /** "As of 2 min ago" when the counts are older than 30 s and the page is not live; '' when live, or with no stream
985
+ * at all (7.0.0 showed none). */
986
+ function liveAge(now) {
987
+ if (LIVE.mode === 'idle' || LIVE.mode === 'absent' || LIVE.mode === 'connecting') return '';
988
+ var at = Math.max(LIVE.freshAt || 0, LIVE.mode === 'live' && !LIVE.degraded.registry ? LIVE.frameAt : 0);
989
+ if (!at) return '';
990
+ var s = Math.max(0, ((now || Date.now()) - at) / 1000);
991
+ if (s * 1000 < LIVE_STALE_MS) return '';
992
+ if (s < 60) return 'As of ' + Math.floor(s) + ' s ago';
993
+ if (s < 3600) return 'As of ' + Math.floor(s / 60) + ' min ago';
994
+ return 'As of ' + Math.floor(s / 3600) + ' h ago';
995
+ }
996
+ /** The running clock: "12s", "4m 12s", "1h 5m". */
997
+ function liveClock(sec) {
998
+ var s = Math.max(0, Math.floor(sec));
999
+ if (s < 60) return s + 's';
1000
+ if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
1001
+ return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
1002
+ }
1003
+ /** Seconds a working row has run: from the stream's turnStartedAt (skew from the frame's `at`), else the row's own
1004
+ * state_since on the Mac's clock. */
1005
+ function liveElapsed(row, now) {
1006
+ var t = now || Date.now();
1007
+ if (row && typeof row._liveTurnAt === 'number') return (t - LIVE.skew - row._liveTurnAt) / 1000;
1008
+ var d = row ? toDate(row.state_since || row.last_activity_at) : null;
1009
+ return d ? (sxServerTime(t) - d.getTime()) / 1000 : 0;
1010
+ }
1011
+ /** Server ms a row's clock counts from, for the data attribute the 1 s tick reads. */
1012
+ function liveSince(row) {
1013
+ if (row && typeof row._liveTurnAt === 'number') return row._liveTurnAt;
1014
+ var d = row ? toDate(row.state_since || row.last_activity_at) : null;
1015
+ return d ? d.getTime() - SX_CLOCK.skew - LIVE.skew : null;
1016
+ }
1017
+ // ── the 1 s tick: every running clock on screen, never on a hidden page ──
1018
+ var LIVE_TICK = { timer: null };
1019
+ function liveTickStart() {
1020
+ if (LIVE_TICK.timer) return;
1021
+ LIVE_TICK.timer = setInterval(liveTickOnce, 1000);
1022
+ }
1023
+ function liveTickStop() { clearInterval(LIVE_TICK.timer); LIVE_TICK.timer = null; }
1024
+ function liveTickOnce() {
1025
+ if (document.hidden) return; // paused while hidden; visible again paints at once (liveResume, liveNoteTick)
1026
+ var els = document.querySelectorAll('[data-live-since]'), now = Date.now();
1027
+ if (!els.length && !liveNoteBusy()) { liveTickStop(); return; }
1028
+ for (var i = 0; i < els.length; i++) {
1029
+ var since = Number(els[i].getAttribute('data-live-since'));
1030
+ if (isFinite(since)) els[i].textContent = liveClock((now - LIVE.skew - since) / 1000);
1031
+ }
1032
+ liveNoteTick();
1033
+ }
1034
+ // Repaints are batched: a burst of frames paints once.
1035
+ function livePaint(rows, structural) {
1036
+ LIVE.pendingRows = (LIVE.pendingRows || []).concat(rows || []);
1037
+ LIVE.pendingStructural = LIVE.pendingStructural || !!structural;
1038
+ if (LIVE.paintTimer) return;
1039
+ LIVE.paintTimer = setTimeout(function () {
1040
+ var r = LIVE.pendingRows || [], st = LIVE.pendingStructural;
1041
+ LIVE.paintTimer = null; LIVE.pendingRows = []; LIVE.pendingStructural = false;
1042
+ try { liveRepaint(r, st); } catch (err) { console.error(err); }
1043
+ }, 30);
1044
+ }
371
1045
  // ── the session pet (Today) ─────────────────────────────
372
1046
  // Same pet and the same pose rule as COS Control (PetSpritePose.resolve): waiting with nothing running
373
1047
  // is "waiting", then one, two, three, four or more sessions in play are working, duel, trio, swarm, an open
@@ -584,25 +1258,28 @@
584
1258
  }
585
1259
 
586
1260
  // ── Today ───────────────────────────────────────────────
587
- function loadToday() {
1261
+ // force (7.0.1, Today's Refresh): every source read again now, none from a cache. Resolves when the sessions, Work,
1262
+ // welcome and recording reads have painted.
1263
+ function loadToday(force) {
588
1264
  var now = new Date();
589
1265
  $('today-date').textContent = now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) + ', ' + clock(now);
590
1266
  var gen = ++TODAY.gen;
591
1267
  // Voices and memories answer on their own time (the memories status runs a CLI on the Mac): Today paints what it
592
1268
  // 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') {
1269
+ var extras = [];
1270
+ if (force === true || Date.now() - TODAY.extraAt > 60000 || TODAY.voices.state === 'failed' || TODAY.memories.state === 'failed') {
594
1271
  TODAY.extraAt = Date.now();
595
1272
  TODAY.voices = { state: 'pending', groups: [] }; TODAY.memories = { state: 'pending', n: 0 };
596
- api('/api/voice/held-groups').then(function (d) {
1273
+ extras.push(api('/api/voice/held-groups').then(function (d) {
597
1274
  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) {
1275
+ }, function () { TODAY.voices = { state: 'failed', groups: [] }; }).then(function () { paintNeeds(); }));
1276
+ extras.push(api('/api/web-bridge/memories', { method: 'POST', body: { id: 'today-status', op: 'status', args: {} } }).then(function (d) {
600
1277
  var n = d && d.details ? d.details.learningToReview : null;
601
1278
  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(); });
1279
+ }, function () { TODAY.memories = { state: 'failed', n: 0 }; }).then(function () { paintNeeds(); }));
603
1280
  }
604
- Promise.all([
605
- sessionList(15000).catch(function () { return null; }),
1281
+ var main = Promise.all([
1282
+ sessionList(force === true ? 0 : 15000).catch(function () { return null; }),
606
1283
  api('/api/work-board/your-move').catch(function () { return null; }),
607
1284
  api('/api/welcome-context').catch(function () { return null; }),
608
1285
  api('/api/meetings/live-chunks').catch(function () { return null; })
@@ -617,6 +1294,9 @@
617
1294
  var open = sessions.filter(function (s) { return s.running && !s.running_active; });
618
1295
  var recent = live.filter(function (s) { return Date.now() - (s.mtimeMs || 0) < LIVE_FRESH_MS; });
619
1296
  var offline = !res[0] && !res[1];
1297
+ // 7.0.5: the sessions or Today read recovered from a failed one, so the next summary ask says "reconnect" (the
1298
+ // server lets that one start skip its 10-minute spacing, at most once per 10 minutes).
1299
+ if ((TODAY.sessionsState === 'failed' || TODAY.offline === true) && res[0] && !offline) BRIEF.reconnect = true;
620
1300
  TODAY.needs = needs; TODAY.working = working; TODAY.open = open; TODAY.welcome = welcome; TODAY.offline = offline; TODAY.at = Date.now();
621
1301
  // your-move answers {available:false} when the board cannot be read: that is a failed read, not "nothing".
622
1302
  TODAY.sessionsState = res[0] ? 'answered' : 'failed';
@@ -627,16 +1307,22 @@
627
1307
  renderRunning(working, open);
628
1308
  if (!offline) renderPet(sessions);
629
1309
  paintNeeds();
1310
+ renderWeather(welcome);
630
1311
  renderNext(welcome);
1312
+ // PH plan P1: the rest of today under Next up. Kept out of renderNext so that stays a pure render.
1313
+ var nx = welcome && welcome.nextEvent;
1314
+ loadCalendarLater(nx, nx && toDate(nx.time));
631
1315
  loadTodayIntake();
632
1316
  setBadge('sessions', working.length > 0);
633
- liveSession(recent).then(function (rec) {
1317
+ return liveSession(recent).then(function (rec) {
634
1318
  if (gen !== TODAY.gen) return;
635
1319
  renderRecording(rec);
636
1320
  setBadge('meetings', !!rec);
637
1321
  if (rec) { TODAY.recording = true; paintNeeds(); }
638
1322
  });
639
1323
  });
1324
+ // A forced read waits for the voices and memories too, so the summary it asks for sees every source.
1325
+ return force === true ? Promise.all([main].concat(extras)) : main;
640
1326
  }
641
1327
  // A capture counts as live only while it is still being written: an abandoned
642
1328
  // active-sessions file must never read as "Recording now".
@@ -660,15 +1346,15 @@
660
1346
  '<span class="btn small">Live view</span></button>';
661
1347
  }
662
1348
  // ── 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
1349
+ // "The orchestrator's eyes" (Miles, 11/07): two or three sentences on what the sessions add up to, written by Sonnet on
664
1350
  // the server (lib/today-brief.ts). Today sends what it already read: session titles, states, how long, each session's
665
1351
  // 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
1352
+ // the inputs changed, 10 minutes apart, the Summaries per day cap, a breaker). Today never waits for it: the counts line paints at once
667
1353
  // 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 };
1354
+ var BRIEF = { view: null, inflight: false, sentKey: '', sentAt: 0, reconnect: false };
669
1355
  // 6.99.26 QA: the same rows are asked about again only after five minutes (a Today that sits open still learns what
670
1356
  // 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;
1357
+ var BRIEF_RESEND_MS = 5 * 60000, BRIEF_OFF_RECHECK_MS = 5 * 60000, BRIEF_TICK_MS = 60000, BRIEF_LIVE_GAP_MS = 60000;
672
1358
  function briefMinutes(v) { var d = toDate(v); return d ? Math.max(0, Math.round((Date.now() - d.getTime()) / 60000)) : 0; }
673
1359
  function briefInputs(m) {
674
1360
  var sessions = [];
@@ -701,9 +1387,25 @@
701
1387
  var body = briefInputs(m);
702
1388
  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
1389
  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;
1390
+ // 7.0.5: right after a read recovered, ask at once (the server still holds its own gates).
1391
+ var reconnect = BRIEF.reconnect && !(BRIEF.view && BRIEF.view.state === 'off');
1392
+ if (!reconnect) {
1393
+ if (BRIEF.view && BRIEF.view.state === 'off' ? since < BRIEF_OFF_RECHECK_MS : key === BRIEF.sentKey && since < BRIEF_RESEND_MS) return;
1394
+ // 7.0.1: with the live stream Today repaints on every session move, so a changed Today asks at most once a minute
1395
+ // (Refresh asks at once). Without it, 7.0.0's rule above is the only one.
1396
+ if (typeof liveOn === 'function' && liveOn() && since < BRIEF_LIVE_GAP_MS) return;
1397
+ }
1398
+ BRIEF.reconnect = false;
1399
+ if (reconnect) body.reason = 'reconnect';
705
1400
  BRIEF.inflight = true; BRIEF.sentKey = key; BRIEF.sentAt = Date.now();
706
- api('/api/today/brief', { method: 'POST', body: body, timeoutMs: 45000 }).then(function (v) {
1401
+ var ask = function (b) { return api('/api/today/brief', { method: 'POST', body: b, timeoutMs: 45000 }); };
1402
+ ask(body).catch(function (err) {
1403
+ // A server before 7.0.5 refuses the unknown `reason` (400 invalid_today_brief): ask it the ordinary way.
1404
+ if (!(reconnect && err && err.status === 400 && err.code === 'invalid_today_brief')) throw err;
1405
+ var again = {};
1406
+ Object.keys(body).forEach(function (k) { if (k !== 'reason') again[k] = body[k]; });
1407
+ return ask(again);
1408
+ }).then(function (v) {
707
1409
  BRIEF.view = v && typeof v === 'object' ? v : null;
708
1410
  }, function () { /* keep what was shown: the counts line or the last summary */ }).then(function () {
709
1411
  BRIEF.inflight = false;
@@ -774,15 +1476,21 @@
774
1476
  function listWords(names) { return names.length < 2 ? names.join('') : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1]; }
775
1477
  function runRow(s, activeNow) {
776
1478
  var key = splitSessionId(s.provider, s.session_id);
1479
+ // 7.0.1: with the live stream, a working row's clock ticks every second from the turn's start (liveTickOnce).
1480
+ // Without it (an older server, the switch off), the minute line 7.0.0 showed.
1481
+ var since = activeNow && typeof liveOn === 'function' && liveOn() ? liveSince(s) : null;
777
1482
  var meta = activeNow
778
- ? 'Working for ' + span(s.state_since || s.last_activity_at)
1483
+ ? 'Working for ' + (since != null ? '\u0000' : span(s.state_since || s.last_activity_at))
779
1484
  : 'Open, quiet for ' + span(s.last_activity_at || s.state_since);
780
1485
  if (s.project && s.project !== usualProject()) meta += ', in ' + String(s.project).replace(/^-Users-[^-]+-/, '');
1486
+ var metaHtml = since != null
1487
+ ? esc(meta).replace('\u0000', '<span class="run-clock" data-live-since="' + esc(String(since)) + '">' + esc(liveClock(liveElapsed(s))) + '</span>')
1488
+ : esc(meta);
781
1489
  // A tap opens the peek on Today (panes/today-actions.js); it falls back to the reader if the peek cannot load.
782
1490
  return '<button type="button" class="run-row" data-today="run" data-key="' + esc(key.provider) + '|' + esc(key.id) + '">' +
783
1491
  '<span class="run-who">' + badge(key.provider) + (activeNow ? '<span class="pulse" aria-label="working"></span>' : '') + '</span>' +
784
1492
  '<span class="run-title">' + esc(sessionTitle(s)) + '</span>' +
785
- '<span class="run-meta">' + esc(meta) + '</span></button>';
1493
+ '<span class="run-meta">' + metaHtml + '</span></button>';
786
1494
  }
787
1495
  function renderRunning(working, open) {
788
1496
  $('run-count').textContent = working.length ? String(working.length) : '';
@@ -798,6 +1506,150 @@
798
1506
  var more = (working.length - shown.length) + (shown.length ? open.length : Math.max(0, open.length - 2));
799
1507
  if (more > 0) html += '<button type="button" class="run-more" data-goto="sessions">' + countWord(more) + ' more open. All sessions</button>';
800
1508
  list.innerHTML = html;
1509
+ if (typeof liveTickStart === 'function' && list.querySelector && list.querySelector('[data-live-since]')) liveTickStart();
1510
+ }
1511
+ // ── live state on screen (7.0.1): Today's counts and rows, the pet, the Sessions rows, the header line ──
1512
+ // The live model (above) calls this, batched, after a frame or a list read moved something.
1513
+ function liveRepaint(rows, structural) {
1514
+ if (!liveSignedIn()) return;
1515
+ if (state.pane === 'today' && TODAY.at && !TODAY.offline && TODAY.sessionsState === 'answered') {
1516
+ var sessions = state.sessions || [];
1517
+ TODAY.working = sessions.filter(function (s) { return s.running_active; });
1518
+ TODAY.open = sessions.filter(function (s) { return s.running && !s.running_active; });
1519
+ renderRunning(TODAY.working, TODAY.open);
1520
+ renderPet(sessions);
1521
+ paintNeeds();
1522
+ setBadge('sessions', TODAY.working.length > 0);
1523
+ }
1524
+ if (state.pane === 'sessions' && SX.rows && SX.view === 'sessions') liveSessionRows(rows, structural);
1525
+ liveNoteTick();
1526
+ }
1527
+ // Sessions: a row whose state moved is redrawn where it stands (the list never jumps under a finger); a session that
1528
+ // appeared or left redraws the list. A search on screen keeps its hits; only the hero's counts move.
1529
+ function liveSessionRows(rows, structural) {
1530
+ var list = $('sessions-list');
1531
+ if (!list) return;
1532
+ if (sxQueryActive()) { paintSessionsHero(); return; }
1533
+ if (structural) { renderSessionGroups(); return; }
1534
+ var amb = sxAmbiguous(sxVisible());
1535
+ (rows || []).forEach(function (s) {
1536
+ if (!s || sxKeepWarm(s)) return;
1537
+ var k = splitSessionId(s.provider, s.session_id || s.id), want = k.provider + '|' + k.id;
1538
+ var els = list.querySelectorAll('[data-session]');
1539
+ for (var i = 0; i < els.length; i++) {
1540
+ if (els[i].getAttribute('data-session') !== want) continue;
1541
+ var html = sxRow(s, null, amb);
1542
+ if (els[i].__liveHtml !== html) {
1543
+ var tmp = document.createElement('div'); tmp.innerHTML = html;
1544
+ var next = tmp.firstElementChild || tmp.firstChild;
1545
+ if (next) { next.__liveHtml = html; els[i].parentNode.replaceChild(next, els[i]); }
1546
+ }
1547
+ break;
1548
+ }
1549
+ });
1550
+ paintSessionsHero();
1551
+ }
1552
+ // ── Today's header line and Refresh ──
1553
+ // Refresh (Miles 11/07: "a 'refresh' button to the home 'today' tab that allows the user to get the latest state and
1554
+ // to update the summary"): every Today source read again now, then a new summary asked for with force (the server
1555
+ // still holds its switch, its one-per-30-s refresh limit and its daily cap, and says which in `reason`).
1556
+ var TR = { busy: false, doneAt: 0, msg: '', retryAt: 0 };
1557
+ var TR_REASONS = {
1558
+ daily_cap: 'Daily summary limit reached',
1559
+ recording: 'No new summary while a meeting records',
1560
+ maintenance: 'No new summary while COS updates',
1561
+ paused: 'The summary is paused for now. Try again later',
1562
+ failed: 'The summary did not update',
1563
+ interval: 'The summary updated recently. Try again in a few minutes'
1564
+ };
1565
+ /** The words for a refresh answer, or '' when the summary updated (or is switched off). */
1566
+ function refreshWords(v) {
1567
+ if (!v || typeof v !== 'object') return '';
1568
+ if (typeof v.message === 'string' && v.message.trim()) return v.message.trim();
1569
+ if (v.state === 'off') return '';
1570
+ if (v.reason === 'refresh_throttled') return '';
1571
+ return v.state === 'fresh' && !v.reason ? '' : (TR_REASONS[v.reason] || '');
1572
+ }
1573
+ function todayRefresh() {
1574
+ if (TR.busy) return Promise.resolve(false);
1575
+ TR.busy = true; TR.msg = ''; TR.retryAt = 0;
1576
+ var btn = $('today-refresh'); if (btn) btn.disabled = true;
1577
+ liveNoteTick();
1578
+ var done = function (msg) {
1579
+ TR.busy = false; TR.doneAt = Date.now(); TR.msg = msg || '';
1580
+ var b = $('today-refresh'); if (b) b.disabled = false;
1581
+ liveNoteTick(); liveTickStart();
1582
+ return true;
1583
+ };
1584
+ return Promise.resolve(loadToday(true)).then(function () {
1585
+ if (typeof liveOn === 'function' && liveOn()) LIVE.freshAt = Date.now();
1586
+ if (!TODAY.model || TODAY.offline) return done('');
1587
+ // The summary switched off: the state only.
1588
+ if (BRIEF.view && (BRIEF.view.state === 'off' || BRIEF.view.enabled === false)) return done('');
1589
+ var body = briefInputs(TODAY.model);
1590
+ body.force = true;
1591
+ BRIEF.inflight = true;
1592
+ // A 7.0.0 server refuses the unknown `force` (400 invalid_today_brief): ask it the ordinary way instead.
1593
+ var ask = function (b) { return api('/api/today/brief', { method: 'POST', body: b, timeoutMs: 45000 }); };
1594
+ return ask(body).catch(function (err) {
1595
+ if (!(err && err.status === 400 && err.code === 'invalid_today_brief')) throw err;
1596
+ delete body.force;
1597
+ return ask(body);
1598
+ }).then(function (v) {
1599
+ BRIEF.inflight = false;
1600
+ if (v && typeof v === 'object') { BRIEF.view = v; BRIEF.sentAt = Date.now(); }
1601
+ if (v && v.reason === 'refresh_throttled') TR.retryAt = Date.now() + Math.max(1000, Number(v.retryAfterMs) || 30000);
1602
+ if (TODAY.model && state.pane === 'today') renderStory(TODAY.model);
1603
+ return done(refreshWords(v));
1604
+ }, function (err) {
1605
+ BRIEF.inflight = false;
1606
+ return done(err && err.code === 'unreachable' ? err.message : 'The summary did not update');
1607
+ });
1608
+ }, function () { return done('Could not read everything. Try again'); });
1609
+ }
1610
+ function liveNoteBusy() {
1611
+ var now = Date.now();
1612
+ return TR.busy || TR.retryAt > now || (TR.doneAt && now - TR.doneAt < 10 * 60000) || !!liveAge(now) || LIVE.mode === 'reconnecting';
1613
+ }
1614
+ /** The line beside Refresh: Updating, Reconnecting, the counts' age, when Today was refreshed, the server's limit. */
1615
+ function liveNoteText(now) {
1616
+ now = now || Date.now();
1617
+ if (TR.busy) return 'Updating';
1618
+ var parts = [];
1619
+ if (LIVE.mode === 'reconnecting') parts.push('Reconnecting');
1620
+ var age = liveAge(now);
1621
+ if (age) parts.push(age);
1622
+ if (TR.doneAt && !age) {
1623
+ var m = Math.floor((now - TR.doneAt) / 60000);
1624
+ if (m < 10) parts.push(m < 1 ? 'Updated just now' : 'Updated ' + m + ' min ago');
1625
+ }
1626
+ if (TR.retryAt > now) parts.push('Try again in ' + Math.ceil((TR.retryAt - now) / 1000) + ' s');
1627
+ else if (TR.msg && TR.doneAt && now - TR.doneAt < 10 * 60000) parts.push(TR.msg);
1628
+ return parts.join('. ');
1629
+ }
1630
+ /** What the line says, without its numbers: the polite status line speaks only when this changes (review N10). */
1631
+ function liveNoteState(now) {
1632
+ now = now || Date.now();
1633
+ var age = liveAge(now);
1634
+ return [TR.busy ? 'busy' : '', LIVE.mode === 'reconnecting' ? 'reconnecting' : '', age ? 'age' : '',
1635
+ TR.doneAt && !age && now - TR.doneAt < 10 * 60000 ? 'done' : '', TR.retryAt > now ? 'retry' : '',
1636
+ TR.msg && TR.doneAt && now - TR.doneAt < 10 * 60000 ? TR.msg : ''].join('|');
1637
+ }
1638
+ function liveNoteTick() {
1639
+ var el = $('today-live-note'), t = liveNoteText();
1640
+ if (el) { if (el.textContent !== t) el.textContent = t; el.classList.toggle('warn', LIVE.mode === 'reconnecting'); }
1641
+ var said = $('today-live-status'), key = liveNoteState();
1642
+ if (said && LIVE.noteSaid !== key) { LIVE.noteSaid = key; said.textContent = t; }
1643
+ var sx = document.querySelector('#sessions-hero .sx-live-note');
1644
+ if (sx) { var n = liveSessionsNote(); sx.textContent = n ? ' · ' + n : ''; }
1645
+ }
1646
+ /** The Sessions hero's honesty suffix: "Reconnecting" and the counts' age, or ''. */
1647
+ function liveSessionsNote() {
1648
+ var bits = [];
1649
+ if (LIVE.mode === 'reconnecting') bits.push('Reconnecting');
1650
+ var age = liveAge();
1651
+ if (age) bits.push(age.charAt(0).toLowerCase() + age.slice(1));
1652
+ return bits.join(' · ');
801
1653
  }
802
1654
  function needWhy(item) {
803
1655
  var p = item.progress || {};
@@ -833,7 +1685,12 @@
833
1685
  if (s.waiting_kind === 'plan') return 'Has a plan for you to approve';
834
1686
  var d = String(s.waiting_detail || '').trim();
835
1687
  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);
1688
+ // 7.0.4 W5: the server shows the home folder in this command as "[home]" (lib/local-path-redact.ts COMMAND_KEYS), so
1689
+ // the line is a display, labelled as one. The exact command is on the approval card, which is not redacted.
1690
+ if (s.waiting_kind === 'permission' && d) {
1691
+ var shown = d.length > 80 ? d.slice(0, 79) + '…' : d;
1692
+ return d.indexOf('[home]') >= 0 ? 'Wants to run (home folder hidden) ' + shown : 'Wants to run ' + shown;
1693
+ }
837
1694
  if (s.waiting_kind === 'question' && d) return d.length > 160 ? d.slice(0, 159) + '…' : d;
838
1695
  return 'Waiting for you' + (sxProvider(s.provider) === 'claude' ? '. Paused until you answer.' : '');
839
1696
  }
@@ -884,6 +1741,11 @@
884
1741
  (TODAY.needs || []).forEach(function (it) {
885
1742
  var k = splitSessionId(it.provider, it.sessionId);
886
1743
  if (k.id && seen[k.provider + '|' + String(k.id).toLowerCase()]) return; // Control: a Work row for a waiting session is the session's row
1744
+ // 7.0.1 (Miles 11/07): the server saw you start a turn in the session after its needs-input or blocked report
1745
+ // (work-activity.ts supersededBy). That report is history, not something waiting on you. Only the item that
1746
+ // regrouped to running leaves: a superseded completed handoff regroups to "Needs a look", which the Messages
1747
+ // badge counts, so it stays here too (review N6).
1748
+ if (it.supersededAt && it.group === 'running') return;
887
1749
  rows.push({ kind: 'work', tier: 3, item: it, key: k.id ? k.provider + '|' + k.id : '' });
888
1750
  });
889
1751
  var groups = TODAY.voices.groups || [];
@@ -923,7 +1785,8 @@
923
1785
  return bits.join(', ');
924
1786
  }
925
1787
  function needRowHtml(r, i) {
926
- var age = r.since ? ago(r.since) : r.item ? ago(r.item.updatedAt || r.item.createdAt) : '';
1788
+ // A Work row is dated by when it started waiting (7.0.1), not by the card's newest event.
1789
+ var age = r.since ? ago(r.since) : r.item ? ago(r.item.waitingSince || r.item.updatedAt || r.item.createdAt) : '';
927
1790
  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
1791
  if (r.kind === 'work') {
929
1792
  var it = r.item, k = splitSessionId(it.provider, it.sessionId);
@@ -955,18 +1818,32 @@
955
1818
  var html = visible.map(needRowHtml).join('');
956
1819
  if (shown.length > visible.length) html += '<button type="button" class="need-more" data-today-needs="all">Show ' + (shown.length - visible.length) + ' more</button>';
957
1820
  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>'; });
1821
+ // 6.99.30 (design QA): no empty line above a failed read (it left a 45 px gap), and one retry row, never one per
1822
+ // source: with the Mac not answering (sessions and Work both unread, or more than two sources) it says so once.
1823
+ if (!m.rows.length) html = m.failed.length ? '' : '<p class="card-empty">' + esc(m.settled ? 'Nothing is waiting on you.' : 'Reading sessions, Work, voices and memories.') + '</p>';
1824
+ if (m.failed.length) {
1825
+ var silent = TODAY.offline || m.failed.length > 2;
1826
+ html += '<button type="button" class="need-more need-failed" data-today-needs="retry">' + esc(silent ? 'Your Mac is not answering. Try again.' : 'Couldn’t read ' + listWords(m.failed) + '. Try again') + '</button>';
1827
+ }
960
1828
  list.innerHTML = html;
961
1829
  }
1830
+ // Weather sits under the date at the top left (Miles, 11/07), where the eye starts, not inside Next up.
1831
+ // It shows whether or not a meeting is next.
1832
+ function renderWeather(w) {
1833
+ var el = $('today-weather'); if (!el) return;
1834
+ var x = w && w.weather;
1835
+ if (!x || !x.temp) { el.classList.add('hidden'); el.textContent = ''; return; }
1836
+ var desc = String(x.desc || '').toLowerCase(), place = x.location ? String(x.location).split(',')[0] : '';
1837
+ el.textContent = x.temp + (desc ? ' and ' + desc : '') + (place ? ' in ' + place : '');
1838
+ el.classList.remove('hidden');
1839
+ }
962
1840
  function renderNext(w) {
963
1841
  var card = $('next-card');
964
1842
  var n = w && w.nextEvent; var d = n && toDate(n.time);
965
1843
  if (!d || !n.title) { card.classList.add('hidden'); return; }
966
- var weather = w.weather && w.weather.temp ? w.weather.temp + ' and ' + String(w.weather.desc || '').toLowerCase() + (w.weather.location ? ' in ' + w.weather.location.split(',')[0] : '') : '';
967
1844
  var dw = dayWord(d);
968
1845
  $('next-body').innerHTML = '<span class="when">' + esc(clock(d)) + '</span><span><span class="what">' + esc(n.title) + '</span>' +
969
- '<span class="sub" style="display:block">' + esc((dw.charAt(0).toUpperCase() + dw.slice(1)) + (weather ? '. Now ' + weather : '')) + '</span></span>';
1846
+ '<span class="sub" style="display:block">' + esc(dw.charAt(0).toUpperCase() + dw.slice(1)) + '</span></span>';
970
1847
  // Control's Next up (todayPrepButtons): Prep me opens the Prep composer in the COS chat (panes/cos-chat.js prepFor),
971
1848
  // Open meeting opens Meetings. Without a server to answer, Prep me is not offered.
972
1849
  var prep = !TODAY.offline && window.COSWeb && window.COSWeb.cosChat && window.COSWeb.cosChat.prepFor;
@@ -974,6 +1851,27 @@
974
1851
  '<button type="button" class="link-btn" data-goto="meetings">Open meeting</button>';
975
1852
  card.classList.remove('hidden');
976
1853
  }
1854
+ // PH plan P1: the rest of today from GET /api/calendar/today (selected calendars, next 3), under Next up.
1855
+ // An aging snapshot says when it was read. Not connected or nothing more: the block stays hidden.
1856
+ function loadCalendarLater(n, d) {
1857
+ var box = $('next-later'); if (!box) return;
1858
+ if (!n || !n.title || !d) { box.classList.add('hidden'); box.innerHTML = ''; return; }
1859
+ var gen = TODAY.gen;
1860
+ api('/api/calendar/today').then(function (c) {
1861
+ if (gen !== TODAY.gen) return;
1862
+ if (!c || !c.connected) { box.classList.add('hidden'); box.innerHTML = ''; return; }
1863
+ var same = function (r) { var rd = toDate(r.start); return r.title === n.title && rd && d && Math.abs(rd.getTime() - d.getTime()) < 60000; };
1864
+ var rows = (c.next || []).filter(function (r) { return !r.allDay && !same(r); }).slice(0, 2);
1865
+ var asOf = c.stale && c.asOf ? toDate(c.asOf) : null;
1866
+ var html = rows.map(function (r) {
1867
+ var rd = toDate(r.start);
1868
+ return '<div class="later-row"><span class="later-when">' + esc(rd ? clock(rd) : '') + '</span><span class="later-what">' + esc(r.title) + '</span></div>';
1869
+ }).join('');
1870
+ if (asOf) html += '<div class="later-asof">' + esc('Calendar as of ' + clock(asOf)) + '</div>';
1871
+ box.innerHTML = html;
1872
+ box.classList.toggle('hidden', !html);
1873
+ }, function () { box.classList.add('hidden'); box.innerHTML = ''; });
1874
+ }
977
1875
  function prepNext() {
978
1876
  var n = TODAY.welcome && TODAY.welcome.nextEvent, d = n && toDate(n.time), chat = window.COSWeb && window.COSWeb.cosChat;
979
1877
  if (!n || !n.title || !chat || !chat.prepFor) return;
@@ -1195,7 +2093,7 @@
1195
2093
  var stats = view === 'sessions' ? sxStats(vis) : [];
1196
2094
  var busy = view === 'sessions' ? SX.loading : SX.usageLoading;
1197
2095
  hero.innerHTML = '<span class="cx-glyph"><svg class="icon"><use href="#i-sessions"/></svg></span>' +
1198
- '<div class="cx-hero-text"><h1 class="cx-title">Sessions</h1><p class="cx-detail-line">' + esc(detail) + '</p></div>' +
2096
+ '<div class="cx-hero-text"><h1 class="cx-title">Sessions</h1><p class="cx-detail-line">' + esc(detail) + liveHeroNote(view) + '</p></div>' +
1199
2097
  '<div class="cx-actions"><button type="button" class="cx-btn quiet" data-sx="refresh"' + (busy ? ' disabled' : '') + '><svg class="icon"><use href="#i-refresh"/></svg>Refresh</button></div>' +
1200
2098
  (stats.length ? '<div class="cx-stats">' + stats.map(function (s) { return '<span class="cx-stat"><b>' + esc(typeof s[0] === 'number' ? s[0].toLocaleString() : s[0]) + '</b><span>' + s[1] + '</span></span>'; }).join('') + '</div>' : '');
1201
2099
  var clockOn = view === 'sessions' && !sxQueryActive();
@@ -1210,6 +2108,12 @@
1210
2108
  if (usage) usage.classList.toggle('hidden', view === 'sessions');
1211
2109
  }
1212
2110
 
2111
+ // 7.0.1: the hero's counts say when they are not live ("Reconnecting", "as of 2 min ago"); nothing without a stream.
2112
+ function liveHeroNote(view) {
2113
+ if (view !== 'sessions' || typeof liveSessionsNote !== 'function') return '';
2114
+ var n = liveSessionsNote();
2115
+ return '<span class="sx-live-note">' + (n ? ' · ' + esc(n) : '') + '</span>';
2116
+ }
1213
2117
  function loadSessions(force) {
1214
2118
  var r = parseRoute();
1215
2119
  var reading = r.pane === 'sessions' && r.args.length === 2;
@@ -1275,6 +2179,7 @@
1275
2179
  var meta = sxBadge(key.provider) +
1276
2180
  (s.pinned ? '<span class="sx-pinned">PINNED</span>' : '') +
1277
2181
  (label ? '<span class="sx-chip ' + st + '">' + esc(label) + '</span>' : '') +
2182
+ (notSentFor(key.provider, key.id) ? '<span class="sx-chip refused" title="' + esc(notSentFor(key.provider, key.id).line) + '">' + esc(notSentFor(key.provider, key.id).label || 'Not sent') + '</span>' : '') +
1278
2183
  (upd ? '<span class="sx-when">Updated ' + esc(sxShortDate(upd)) + '</span>' : '') +
1279
2184
  (ambiguous[title] && opened ? '<span class="sx-opened" title="Another session on screen has the same name. This one was opened ' + esc(sxShortDate(opened)) + '.">Opened ' + esc(sxShortDate(opened)) + '</span>' : '') +
1280
2185
  (s.project && String(s.project) !== title ? '<span class="sx-ws">' + esc(s.project) + '</span>' : '');
@@ -1480,6 +2385,7 @@
1480
2385
  }
1481
2386
  if (act === 'clear') { var input = $('sessions-q'); input.value = ''; searchSessions(); input.focus(); return; }
1482
2387
  if (act === 'copy-session') return copySessionBrief();
2388
+ if (act === 'full') { if (SX_SPLIT) SX_SPLIT.toggleFull(); return; }
1483
2389
  if (act === 'refresh-turns' && rd) { rd.refusal = null; renderVerdict(rd); refreshTurns(rd.provider, rd.id); return; }
1484
2390
  if (act === 'retry' && rd) { rd.refusal = null; renderVerdict(rd); return sendSessionTurn(!!rd.retryFork); }
1485
2391
  if (act === 'caution-send' && rd) { rd.cautionAck = true; rd.cautionAsk = false; renderVerdict(rd); return sendSessionTurn(false); }
@@ -1508,6 +2414,153 @@
1508
2414
  });
1509
2415
  }
1510
2416
 
2417
+ // Duo tables: the divider between two columns, where both show side by side. One kit (splitKit) for every pane that
2418
+ // has them: a list beside its reader in Sessions (760 px and up), Memories (760 px and up) and Messages with the COS
2419
+ // chat (860 px and up), and a reader beside its side panel in a meeting (Ask, 860 px and up) and a Work card (900 px).
2420
+ // Drag it with a finger, mouse or pen (pointer events, captured, so a table's own sideways scroll and text selection
2421
+ // in the panes never start a drag), or focus it and use the arrow keys (Shift for bigger steps, Home and End).
2422
+ // A double tap or double click goes back to the default width. The width is a share of the split (CSS clamps it so the
2423
+ // list keeps 240 px and the reader 380 px, also after the screen turns) and is remembered on this device, one key per
2424
+ // pane; "Full width" in the reader hides the list and "Show list" brings it back. Storage that throws (a private
2425
+ // window) only means the choice lasts this visit. Below a pane's own breakpoint the divider is hidden by CSS and the
2426
+ // kit does nothing (o.media), so a phone's layout never changes.
2427
+ var SX_SPLIT_KEY = 'cos_web_sessions_split', SX_LIST_MIN = 240, SX_READER_MIN = 380, SX_HANDLE_W = 18, SX_KEY_STEP = 16, SX_DOUBLE_MS = 350;
2428
+ // o: key (storage), prefix (class names: <prefix>-sized, -full and -dragging), cssVar (the share, default
2429
+ // --<prefix>-list-share), label (what the divider sizes, for screen readers), sized (the column the share sizes,
2430
+ // default the list), side ('end' when that column is on the right: a side panel grows as the line moves left),
2431
+ // fullSel (the Full width buttons), media (where the divider shows), listMin (the sized column's minimum), readerMin
2432
+ // (the other column's), handleW (px, the same numbers as the pane's CSS clamp), memo (this visit's choice, kept across
2433
+ // a pane's repaint when storage is off).
2434
+ function splitKit(split, handle, storage, o) {
2435
+ var KEY = o.key, PRE = o.prefix || 'split', LIST_MIN = o.listMin || SX_LIST_MIN, READER_MIN = o.readerMin || SX_READER_MIN,
2436
+ HANDLE_W = o.handleW || SX_HANDLE_W, LIST_SEL = o.sized || '.split-list', FULL_SEL = o.fullSel || '[data-split-full="' + KEY + '"]', memo = o.memo || null,
2437
+ VAR = o.cssVar || '--' + PRE + '-list-share', END = o.side === 'end';
2438
+ var saved = {};
2439
+ try { var raw = JSON.parse(storage.getItem(KEY) || 'null'); if (raw && typeof raw === 'object') saved = raw; } catch (_e) { saved = {}; }
2440
+ if (memo && memo.at) saved = memo;
2441
+ var share = typeof saved.share === 'number' && saved.share > 0 && saved.share < 100 ? saved.share : null, full = saved.full === true, lastTap = 0;
2442
+ function save() {
2443
+ if (memo) { memo.share = share; memo.full = full; memo.at = Date.now(); }
2444
+ try { storage.setItem(KEY, JSON.stringify({ share: share, full: full })); } catch (_e) { /* this visit only */ }
2445
+ }
2446
+ // Narrow (a phone, the folded Duo): the divider is not shown, and nothing here may move the layout.
2447
+ function narrow() { return !!o.media && typeof matchMedia === 'function' && !matchMedia(o.media).matches; }
2448
+ function total() { return split.getBoundingClientRect().width; }
2449
+ function listWidth() { var l = split.querySelector(LIST_SEL); return l ? l.getBoundingClientRect().width : 0; }
2450
+ function bounds(t) { return { min: LIST_MIN, max: Math.max(LIST_MIN, t - HANDLE_W - READER_MIN) }; }
2451
+ function aria() {
2452
+ var t = total(); if (!(t > 0)) return;
2453
+ var b = bounds(t), pct = function (px) { return Math.round(px / t * 100); }, now = pct(listWidth());
2454
+ handle.setAttribute('aria-valuemin', String(pct(b.min)));
2455
+ handle.setAttribute('aria-valuemax', String(pct(b.max)));
2456
+ handle.setAttribute('aria-valuenow', String(now));
2457
+ handle.setAttribute('aria-valuetext', (o.label || 'List') + ' ' + now + '% of the width');
2458
+ }
2459
+ function paint() {
2460
+ split.classList.toggle(PRE + '-sized', share != null);
2461
+ if (share != null) split.style.setProperty(VAR, share.toFixed(2) + '%'); else split.style.removeProperty(VAR);
2462
+ split.classList.toggle(PRE + '-full', full);
2463
+ document.querySelectorAll(FULL_SEL).forEach(function (b) { b.setAttribute('aria-pressed', String(full)); b.textContent = full ? 'Show list' : 'Full width'; });
2464
+ aria();
2465
+ }
2466
+ // px: the sized column's width asked for; kept inside both columns' minimums for the split's width now.
2467
+ function setWidth(px, persist) {
2468
+ if (narrow()) return;
2469
+ var t = total(); if (!(t > 0)) return;
2470
+ var b = bounds(t);
2471
+ share = Math.min(b.max, Math.max(b.min, px)) / t * 100;
2472
+ paint();
2473
+ if (persist) save();
2474
+ }
2475
+ function reset() { share = null; paint(); save(); }
2476
+ handle.addEventListener('pointerdown', function (e) {
2477
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
2478
+ if (narrow()) return;
2479
+ e.preventDefault();
2480
+ var x0 = e.clientX, start = listWidth(), moved = false, id = e.pointerId;
2481
+ try { handle.setPointerCapture(id); } catch (_e) {}
2482
+ split.classList.add(PRE + '-dragging');
2483
+ // A column on the right grows as the pointer moves left.
2484
+ function dx(ev) { return END ? x0 - ev.clientX : ev.clientX - x0; }
2485
+ function move(ev) {
2486
+ if (ev.pointerId !== id) return;
2487
+ if (!moved && Math.abs(ev.clientX - x0) < 4) return;
2488
+ moved = true;
2489
+ setWidth(start + dx(ev), false);
2490
+ }
2491
+ function end(ev) {
2492
+ if (ev.pointerId !== id) return;
2493
+ handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', end); handle.removeEventListener('pointercancel', end);
2494
+ split.classList.remove(PRE + '-dragging');
2495
+ // The lift's own position counts: a browser may fold the last moves into it.
2496
+ if (moved) { if (ev.type === 'pointerup') setWidth(start + dx(ev), false); save(); lastTap = 0; return; }
2497
+ if (ev.type !== 'pointerup') return;
2498
+ var now = Date.now();
2499
+ if (now - lastTap < SX_DOUBLE_MS) { lastTap = 0; reset(); } else lastTap = now;
2500
+ }
2501
+ handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', end); handle.addEventListener('pointercancel', end);
2502
+ });
2503
+ handle.addEventListener('dblclick', function (e) { if (narrow()) return; e.preventDefault(); reset(); });
2504
+ handle.addEventListener('keydown', function (e) {
2505
+ if (narrow()) return;
2506
+ var t = total(), b = bounds(t), w = listWidth(), step = e.shiftKey ? SX_KEY_STEP * 4 : SX_KEY_STEP;
2507
+ // The arrows move the line the way they point; Home and End take it to the left and right limits.
2508
+ var left = END ? 1 : -1;
2509
+ var to = e.key === 'ArrowLeft' ? w + left * step : e.key === 'ArrowRight' ? w - left * step : e.key === 'Home' ? (END ? b.max : b.min) : e.key === 'End' ? (END ? b.min : b.max) : null;
2510
+ if (to == null) return;
2511
+ e.preventDefault();
2512
+ setWidth(to, true);
2513
+ });
2514
+ paint();
2515
+ return {
2516
+ setWidth: setWidth, reset: reset, paint: paint,
2517
+ toggleFull: function () { full = !full; paint(); save(); },
2518
+ state: function () { return { share: share, full: full }; }
2519
+ };
2520
+ }
2521
+ // Sessions: the first pane with the divider, unchanged (its markup is in index.html, its CSS in panes/sessions.css).
2522
+ var SX_SPLIT_OPTS = { key: SX_SPLIT_KEY, prefix: 'sx', label: 'Session list', fullSel: '[data-sx="full"]', media: '(min-width: 760px)',
2523
+ listMin: SX_LIST_MIN, readerMin: SX_READER_MIN, handleW: SX_HANDLE_W };
2524
+ function sxSplitKit(split, handle, storage) { return splitKit(split, handle, storage, SX_SPLIT_OPTS); }
2525
+ // The other panes (W.splitKit): the divider's markup, a Full width button, and mount, which binds the kit to a split
2526
+ // the pane just drew. One live kit per key; a pane that draws its split again gets a fresh kit with this visit's choice.
2527
+ var SPLITS = {}, SPLIT_MEMO = {}, SPLIT_NO_STORE = { getItem: function () { return null; }, setItem: function () {} };
2528
+ function splitStorage() { try { return window.localStorage || null; } catch (_e) { return null; } }
2529
+ function splitHandleHtml(o) {
2530
+ return '<div class="split-handle" role="separator" aria-orientation="vertical" aria-controls="' + esc(o.controls) + '" aria-label="' + esc(o.label + ' width') +
2531
+ '" tabindex="0" title="Drag to resize. Double-tap or double-click to reset."' + (o.id ? ' id="' + esc(o.id) + '"' : '') + '></div>';
2532
+ }
2533
+ function splitFullButton(key, controls) {
2534
+ var live = SPLITS[key], full = !!(live && live.kit.state().full);
2535
+ return '<button type="button" class="cx-btn quiet split-full-btn" data-split-full="' + esc(key) + '" aria-controls="' + esc(controls || '') + '" aria-pressed="' + full + '">' + (full ? 'Show list' : 'Full width') + '</button>';
2536
+ }
2537
+ function mountSplit(split, handle, o) {
2538
+ if (!split || !handle || !o || !o.key) return null;
2539
+ var live = SPLITS[o.key];
2540
+ if (live && live.split === split && live.handle === handle) return live.kit;
2541
+ var memo = SPLIT_MEMO[o.key] || (SPLIT_MEMO[o.key] = {});
2542
+ var kit = splitKit(split, handle, splitStorage() || SPLIT_NO_STORE, Object.assign({}, o, { memo: memo }));
2543
+ SPLITS[o.key] = { kit: kit, split: split, handle: handle };
2544
+ return kit;
2545
+ }
2546
+ var SX_SPLIT = null;
2547
+ (function () {
2548
+ var split = $('sessions-split'), handle = $('sessions-handle');
2549
+ if (!split || !handle) return;
2550
+ var store = splitStorage();
2551
+ SX_SPLIT = sxSplitKit(split, handle, store || SPLIT_NO_STORE);
2552
+ })();
2553
+ // A turned screen or a fold changes the split's width: every divider on the page repaints its limits.
2554
+ window.addEventListener('resize', function () {
2555
+ if (SX_SPLIT) SX_SPLIT.paint();
2556
+ Object.keys(SPLITS).forEach(function (k) { var s = SPLITS[k]; if (s.split.isConnected) s.kit.paint(); });
2557
+ });
2558
+ document.addEventListener('click', function (e) {
2559
+ var b = e.target && e.target.closest ? e.target.closest('[data-split-full]') : null;
2560
+ var live = b ? SPLITS[b.getAttribute('data-split-full')] : null;
2561
+ if (live) live.kit.toggleFull();
2562
+ });
2563
+
1511
2564
  // ── Sessions: read, send, queue, fork, stop (the glasses' Continue, from a browser) ──
1512
2565
  // The server owns every safety rule (agent-session-bindings.ts): COS writes into a desktop
1513
2566
  // thread only through a binding, attaches only when no live process owns the thread, and
@@ -1551,18 +2604,54 @@
1551
2604
  }
1552
2605
  function sessionPath(provider, id, tail) { return '/api/agent-sessions/' + encodeURIComponent(provider) + '/' + encodeURIComponent(id) + (tail || ''); }
1553
2606
 
2607
+ // 6.99.30 (QA, held from Codex for .30): a queued message the server refused or let expire is said where the person
2608
+ // looks, not only in the Sessions reader: a "Not sent" line in Today's sheet for that session and a chip on its row in
2609
+ // the Sessions list. What the server's queue said last, per session, in this browser for a day; each read of the
2610
+ // queue replaces it, so a row the server drops clears the mark.
2611
+ var NOT_SENT_KEY = 'cos_web_not_sent', NOT_SENT_KEEP_MS = 86400000;
2612
+ function notSentRows(rows) { return (rows || []).filter(function (t) { return t && (t.status === 'refused' || t.status === 'expired'); }); }
2613
+ // A row COS cannot tell went in or not (deliveryState unknown, e.g. a steer it could not confirm) is not "Not sent":
2614
+ // it may be in the session already, so it has its own label and its own line, never a "Not sent:" prefix.
2615
+ function maybeSent(t) { return !!(t && t.deliveryState === 'unknown'); }
2616
+ function notSentLabel(t) { return maybeSent(t) ? 'May have been sent' : 'Not sent'; }
2617
+ function notSentLine(t) {
2618
+ if (maybeSent(t)) return String(t.reasonCopy || 'May not have been sent. Read it, then say it again.').trim();
2619
+ var why = String((t && t.reasonCopy) || (t && t.status === 'expired' ? 'the thread stayed busy too long.' : 'the session refused it.')).trim();
2620
+ return /^not sent\b/i.test(why) ? why : 'Not sent: ' + why.charAt(0).toLowerCase() + why.slice(1);
2621
+ }
2622
+ function noteNotSent(provider, id, rows) {
2623
+ var all = readStore(NOT_SENT_KEY), k = provider + '|' + id, now = Date.now(), list = notSentRows(rows);
2624
+ Object.keys(all).forEach(function (x) { if (!all[x] || now - (Number(all[x].at) || 0) > NOT_SENT_KEEP_MS) delete all[x]; });
2625
+ if (list.length) all[k] = { n: list.length, line: notSentLine(list[list.length - 1]).slice(0, 240), label: notSentLabel(list[list.length - 1]), at: now }; else delete all[k];
2626
+ writeStore(NOT_SENT_KEY, all);
2627
+ }
2628
+ function notSentFor(provider, id) {
2629
+ var v = readStore(NOT_SENT_KEY)[provider + '|' + id];
2630
+ return v && Date.now() - (Number(v.at) || 0) <= NOT_SENT_KEEP_MS ? v : null;
2631
+ }
2632
+
1554
2633
  // Like api(), but a 4xx refusal comes back as { status, body } instead of throwing, because
1555
2634
  // these routes answer "busy", "changed" or "not now" with a body the person should read.
2635
+ // A timeout here stays the caller's (an abort: "may have saved", a probe that gives up), never a down mark; api()
2636
+ // reads and the question poll are what say a hung Mac is not answering (6.99.32).
1556
2637
  function apiAnswer(path, opts) {
1557
2638
  var o = opts || {};
1558
2639
  var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
1559
2640
  var timer = ctrl && o.timeoutMs ? setTimeout(function () { ctrl.abort(); }, o.timeoutMs) : null;
1560
2641
  var headers = { 'X-COS-Web': '1' };
1561
2642
  if (o.body !== undefined) headers['Content-Type'] = 'application/json';
2643
+ var sentAt = Date.now();
1562
2644
  return fetch(path, { method: o.method || 'GET', headers: headers, credentials: 'same-origin', body: o.body !== undefined ? JSON.stringify(o.body) : undefined, signal: ctrl ? ctrl.signal : undefined })
1563
2645
  .then(function (res) {
2646
+ sxNoteClock(res, sentAt);
2647
+ noteServer(true);
1564
2648
  if (res.status === 401) { showAuth(); throw new Error('Signed out. Connect this browser again.'); }
1565
2649
  return res.text().then(function (t) { var b = {}; try { b = t ? JSON.parse(t) : {}; } catch (_e) { b = {}; } return { status: res.status, body: b || {} }; });
2650
+ }, function (err) {
2651
+ // A timeout this call asked for stays an abort (its callers say "may have saved"); the rest never reached the Mac.
2652
+ if (err && err.name === 'AbortError') throw err;
2653
+ noteServer(false);
2654
+ throw unreachableError(err);
1566
2655
  })
1567
2656
  .finally(function () { if (timer) clearTimeout(timer); });
1568
2657
  }
@@ -1610,6 +2699,11 @@
1610
2699
  return 'Showing the last ' + turns.length + (total > turns.length && !d.truncated ? ' of ' + total : '') + ' turns. Copy session keeps the original request plus the newest context.';
1611
2700
  }
1612
2701
 
2702
+ // Wide layouts: give the reader the whole width, or bring the list back (sxSplitKit). Hidden under 760 px by CSS.
2703
+ function sxFullButton() {
2704
+ var full = !!(SX_SPLIT && SX_SPLIT.state().full);
2705
+ return '<button type="button" class="cx-btn quiet sx-full-btn" data-sx="full" aria-controls="sessions-list-col" aria-pressed="' + full + '">' + (full ? 'Show list' : 'Full width') + '</button>';
2706
+ }
1613
2707
  function openSession(provider, id) {
1614
2708
  var reader = $('session-reader');
1615
2709
  if (!provider || !id) return;
@@ -1624,7 +2718,7 @@
1624
2718
  var listed = (SX.rows || []).filter(function (s) { return sxSame(sxKey(s), provider + ':' + String(id).toLowerCase()); })[0];
1625
2719
  if (listed) window.COSWeb.setCrumb(sxTitle(listed));
1626
2720
  reader.innerHTML = '<div class="sx-reader-head"><div class="skeleton" style="height:26px;width:70%;margin:6px 0 10px"></div><div class="skeleton" style="width:45%"></div></div>' + skeletonRows(3);
1627
- api(sessionPath(provider, id, '?turns=' + SX_TURNS)).then(function (d) {
2721
+ api(sessionPath(provider, id, '?turns=' + SX_TURNS + '&cards=1')).then(function (d) {
1628
2722
  if (state.reading !== r) return;
1629
2723
  var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : ((d && d.recent_turns && d.recent_turns.turns) || (d && d.turns) || []);
1630
2724
  r.detail = d; r.turns = turns; r.more = d.recent_turns_more === true;
@@ -1633,7 +2727,7 @@
1633
2727
  window.COSWeb.setCrumb(title);
1634
2728
  var trunc = sxTruncNote(d, turns, r.more);
1635
2729
  reader.innerHTML =
1636
- '<div class="sx-reader-head"><div class="sx-head-line">' + sxBadge(provider) + '<h2 class="sx-reader-title">' + esc(title) + '</h2></div>' +
2730
+ '<div class="sx-reader-head"><div class="sx-head-line">' + sxBadge(provider) + '<h2 class="sx-reader-title">' + esc(title) + '</h2>' + sxFullButton() + '</div>' +
1637
2731
  '<p class="sx-subtitle">' + esc(sxSubtitle(d, provider, turns, r.more)) + '</p></div>' +
1638
2732
  '<div class="sx-activity" id="session-live" aria-live="polite"></div>' +
1639
2733
  (trunc ? '<p class="sx-note">' + esc(trunc) + '</p>' : '') +
@@ -1737,7 +2831,7 @@
1737
2831
  // the same time are ordered gate, stream, list, hook, stop: the hook's word wins a tie. Idle yields
1738
2832
  // only to working seen more than SX_IDLE_GRACE_MS after it, the server's rule for the flush that
1739
2833
  // 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;
2834
+ var SX_IDLE_GRACE_MS = 5000, SX_STOP_ECHO_MS = 8000, SX_GATE_RECHECK_MS = 20000, SX_PROBE_TIMEOUT_MS = 15000;
1741
2835
  var SX_TIE_RANK = { gate: 0, stream: 1, list: 2, hook: 3, stop: 4 };
1742
2836
  function sxMs(v) {
1743
2837
  var t = v instanceof Date ? v.getTime() : typeof v === 'number' ? v : typeof v === 'string' && v ? Date.parse(v) : NaN;
@@ -1750,9 +2844,11 @@
1750
2844
  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
2845
  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
2846
  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 });
2847
+ // A gate answer and a Stop are this page's own moments: ranked on the Mac's clock (sxServerTime) against the Mac's
2848
+ // own times, and kept on this page's clock as `seen` for the echo window (6.99.30, QA: clock skew).
2849
+ if (r.gate && r.gate.busy && r.gate.at != null) out.push({ word: 'Working', at: sxServerTime(r.gate.at), src: 'gate', seen: r.gate.at });
1754
2850
  var stop = sxMs(r.stoppedAt);
1755
- if (stop !== null) out.push({ word: 'Idle', at: stop, src: 'stop', seen: stop });
2851
+ if (stop !== null) out.push({ word: 'Idle', at: sxServerTime(stop), src: 'stop', seen: stop });
1756
2852
  return out;
1757
2853
  }
1758
2854
  /** { word, at, src }: the one state the whole card shows. Null before any signal. */
@@ -1796,9 +2892,12 @@
1796
2892
  // Arrival is the first time this page saw THIS word at THIS time: a re-read of the same row is not news.
1797
2893
  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
2894
  }
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. */
2895
+ /** A gate busy answer that decides the card is re-asked while the card shows it: 20 s after the last attempt, and
2896
+ * each failed attempt doubles the wait, up to 16 times (QA 6.99.29: a failing probe was re-asked every second).
2897
+ * Every attempt is stamped (gate.triedAt) and ends within SX_PROBE_TIMEOUT_MS (6.99.30: before that a probe that
2898
+ * never answered left r.probing set, and the gate was never asked again). So a busy gate stands only while the
2899
+ * server keeps answering busy, or keeps not answering, and a fresher signal (a Stop, the hook going idle more than
2900
+ * the grace after it) still outranks it. `at` stays the time of the last ANSWER: a failure is no evidence of busy. */
1802
2901
  function sxGateStale(r) {
1803
2902
  var t = sxTruth(r);
1804
2903
  if (!(t && t.src === 'gate' && r.gate) || r.probing) return false;
@@ -1830,7 +2929,9 @@
1830
2929
  var t = r ? sxTruth(r) : null;
1831
2930
  var since = t && t.at ? new Date(t.at) : f.stateSince || f.lastEventAt;
1832
2931
  if (!since) return '';
1833
- var s = Math.max(0, Math.floor((Date.now() - since.getTime()) / 1000));
2932
+ // QA 6.99.30 (Skeptic): every moment here is on the Mac's clock (the truth's own time, or the Mac's state_since), so
2933
+ // now is too; a phone a minute behind read "0s" for a minute.
2934
+ var s = Math.max(0, Math.floor((sxServerTime(Date.now()) - since.getTime()) / 1000));
1834
2935
  if (s < 60) return s + 's';
1835
2936
  if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
1836
2937
  return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
@@ -1840,6 +2941,7 @@
1840
2941
  if (reason === 'http_503') return 'The server has no room for another live feed right now; showing the stored turns.';
1841
2942
  if (reason === 'reconnecting') return 'Reconnecting to the live feed…';
1842
2943
  if (reason === 'stream_lost') return 'The live feed stopped answering; showing the stored turns. Reopen the session to try again.';
2944
+ if (reason === 'unreachable') return 'Your Mac is not answering. This is the last state it sent.';
1843
2945
  return 'Live feed unavailable (' + reason + '); showing the stored turns.';
1844
2946
  }
1845
2947
  // The block keeps one size whatever it holds (/qa: the turns below moved while Miles read, as tool
@@ -1946,7 +3048,7 @@
1946
3048
  }
1947
3049
  function refreshTurns(provider, id) {
1948
3050
  var reading = state.reading;
1949
- api(sessionPath(provider, id, '?turns=' + SX_TURNS)).then(function (d) {
3051
+ api(sessionPath(provider, id, '?turns=' + SX_TURNS + '&cards=1')).then(function (d) {
1950
3052
  var r = state.reading;
1951
3053
  if (!r || r !== reading || r.id !== id || r.provider !== provider) return;
1952
3054
  var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : [];
@@ -1975,7 +3077,8 @@
1975
3077
  var waiting = rows.filter(function (t) { return t.status === 'waiting' || t.status === 'queued' || t.status === 'pending'; });
1976
3078
  // QA 6.99.29: a queued message the server refused (or let expire) stays in view with the server's reason,
1977
3079
  // instead of vanishing from the line.
1978
- var notSent = rows.filter(function (t) { return t.status === 'refused' || t.status === 'expired'; });
3080
+ var notSent = notSentRows(rows);
3081
+ if (q.status === 200) noteNotSent(provider, id, rows);
1979
3082
  if (q.status === 200 && r.queued !== waiting.length) { r.queued = waiting.length; paintActivity(r); }
1980
3083
  if (!waiting.length && !notSent.length) { box.innerHTML = ''; return; }
1981
3084
  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>' : '') +
@@ -1985,8 +3088,8 @@
1985
3088
  (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
3089
  }).join('') +
1987
3090
  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>';
3091
+ return '<div class="sx-queue-row sx-queue-refused"><span class="sx-queue-state">' + esc(notSentLabel(t)) + '</span><span class="sx-queue-text">' + esc(t.preview || t.prompt || 'Message') +
3092
+ '<span class="sx-queue-reason">' + esc(notSentLine(t)) + '</span></span></div>';
1990
3093
  }).join('') + '</div>';
1991
3094
  }).catch(function () {});
1992
3095
  }
@@ -2000,13 +3103,15 @@
2000
3103
  // Every attempt is stamped; a failed one backs the next off (sxGateStale). A gate answer resets both.
2001
3104
  if (r.gate) r.gate.triedAt = Date.now();
2002
3105
  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) {
3106
+ return apiAnswer(sessionPath(r.provider, r.id, '/attachability'), { timeoutMs: SX_PROBE_TIMEOUT_MS }).then(function (a) {
2004
3107
  if (state.reading !== r && !r.view) return;
2005
3108
  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; }
2006
3109
  var b = a.body || {};
2007
3110
  if (a.status !== 200 || typeof b.attachable !== 'boolean') { failed(); return; }
2008
3111
  var owners = Number(b.ownerCount) || 0;
2009
- r.verdict = { attachable: b.attachable, reason: String(b.reason || ''), reasonCopy: String(b.reasonCopy || ''), ownerCount: owners, caution: b.attachable === true && owners > 0 };
3112
+ r.verdict = { attachable: b.attachable, reason: String(b.reason || ''), reasonCopy: String(b.reasonCopy || ''), ownerCount: owners, caution: b.attachable === true && owners > 0,
3113
+ // Steer mid-turn: the server says whether a message sent now goes into the running turn.
3114
+ midTurn: !!(b.midTurn && b.midTurn.available === true) };
2010
3115
  // 6.99.29: the gate's answer is a signal of the one state, as of now.
2011
3116
  sxNoteGate(r, b.attachable === false && b.reason === 'native_thread_working');
2012
3117
  sxRepaint(r);
@@ -2017,6 +3122,17 @@
2017
3122
  codex: 'Working right now. A message you send joins the Codex app’s queue for this thread.',
2018
3123
  claude: 'Working right now. A message you send waits and lands when this turn ends.'
2019
3124
  };
3125
+ // Steer mid-turn: only when the server says this session takes a message inside its running turn.
3126
+ var SX_PARK_MIDTURN = 'Working right now. Your message lands at its next step.';
3127
+ // QA 7.0.0: a message with files never goes in mid-turn (the drain steers words only; a row with files waits for the
3128
+ // turn end, and the park answer says landsAt turn_end). With files in the box the note says so.
3129
+ var SX_PARK_FILES = 'Working right now. A message with files waits and lands when this turn ends.';
3130
+ // `hasFiles`: a view (Today's sheet) says whether its own box holds files; the reader's box is read here.
3131
+ function sxParkLine(r, hasFiles) {
3132
+ if (!(r.verdict && r.verdict.midTurn === true)) return SX_PARK[sxProvider(r.provider)];
3133
+ if (hasFiles === undefined) hasFiles = sxReaderHasFiles(r);
3134
+ return hasFiles ? SX_PARK_FILES : SX_PARK_MIDTURN;
3135
+ }
2020
3136
  /** The composer's "Working right now" note: a busy gate verdict, and the one state agrees. */
2021
3137
  function sxParkNoteShows(r) {
2022
3138
  var v = r.verdict;
@@ -2049,7 +3165,7 @@
2049
3165
  (f.fork ? forkBtn : ''));
2050
3166
  } else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
2051
3167
  // 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>';
3168
+ if (sxParkNoteShows(r)) html = '<div class="sx-park"><p>' + esc(sxParkLine(r)) + '</p>' + (/fork/i.test(v.reasonCopy) ? '<div class="sx-box-actions">' + forkBtn + '</div>' : '') + '</div>';
2053
3169
  } else if (v && v.attachable === false && v.reasonCopy) {
2054
3170
  html = refusalBox(v.reasonCopy, sxSupplement(v.reason), /fork/i.test(v.reasonCopy) ? forkBtn : '');
2055
3171
  }
@@ -2145,6 +3261,12 @@
2145
3261
  if (!READER_FILES[key].anyBytes && !READER_FILES[key].atts.length && kit.turnFileBytes()) READER_FILES[key].anyBytes = kit.turnFileBytes();
2146
3262
  return READER_FILES[key];
2147
3263
  }
3264
+ // Whether the reader's own box holds a file for this session (any state: a file still uploading goes with the message).
3265
+ function sxReaderHasFiles(r) {
3266
+ if (!r || r.view || state.reading !== r || !readerFilesOn()) return false;
3267
+ var box = readerBox(r);
3268
+ return !!(box && box.atts.length);
3269
+ }
2148
3270
  function readerFilesState(box) {
2149
3271
  var kit = readerKit();
2150
3272
  if (!kit || !box || !box.atts.length) return 'none';
@@ -2160,6 +3282,8 @@
2160
3282
  Array.prototype.forEach.call(slot.querySelectorAll('[data-sx-file]'), function (i) { i.disabled = full || !!r.sending; });
2161
3283
  list.innerHTML = kit.chipsHtml(box, 'data-action') + (box.atts.length ? '<p class="att-limit">' + esc(kit.limitLine(box)) + '</p>' : '');
2162
3284
  list.classList.toggle('hidden', !box.atts.length);
3285
+ // QA 7.0.0: the "Working right now" note depends on whether files go with the message (sxParkLine).
3286
+ if (r && r.verdict) renderVerdict(r);
2163
3287
  // Send and Fork wait while a file uploads. A failed file never greys them out (QA 6.99.27): a tap says why.
2164
3288
  var st = readerFilesState(box), form = $('session-form');
2165
3289
  if (!form || r.sending) return;
@@ -2389,7 +3513,7 @@
2389
3513
  rememberParked(r, draft.key);
2390
3514
  if (opts.held) settleHeld(r, opts.held, 'parked');
2391
3515
  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.'));
3516
+ view.status(r, b.error === 'duplicate_turn' ? 'Already waiting to send.' : (b.landsAt === 'next_step' ? 'The session is busy. Yours lands at its next step.' : 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
3517
  view.queue(r);
2394
3518
  // 6.99.29: "The session is busy" is the gate's word as of now; the card shows the same. Last, and
2395
3519
  // never allowed to cost the parked message its settlement.
@@ -2485,11 +3609,50 @@
2485
3609
  }
2486
3610
 
2487
3611
  // Output previews are separate from composer attachments. Only the DTO fields reach the lazy viewer.
3612
+ // opts.part (sxTurnHtml): 'before' is only the step that came before a reply's words, drawn above them; 'after' is
3613
+ // everything else, drawn below; 'user' is a YOU turn's attached images and never a reply's outputs. No opts: all of it.
2488
3614
  function readerImagesHtml(turn) {
3615
+ var opts = arguments[1], part = opts && opts.part;
3616
+ // 6.99.35: "Show files and images your sessions make" is off (Settings, Privacy). The server sent no names, only this mark.
3617
+ if (turn && turn.previewsOff === true) return part === 'before' ? '' : '<p class="rm-status rm-off">Previews are off. Turn them on in Settings, Privacy.</p>';
3618
+ if (part === 'before') return readerToolsHtml(turn, true);
3619
+ if (part === 'user') return readerToolsHtml(turn, false);
3620
+ var head = part === 'after' ? '' : readerToolsHtml(turn, true);
2489
3621
  var images = turn && Array.isArray(turn.outputImages) ? turn.outputImages.filter(function (x) {
2490
3622
  return x && /^ai_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable');
2491
3623
  }).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>' : '';
3624
+ return head + (images.length ? '<div data-reader-images="' + esc(JSON.stringify(images)) + '"><p class="rm-status" role="status">Loading image previews…</p></div>' : '') + readerFilesHtml(turn) + readerToolsHtml(turn, false);
3625
+ }
3626
+ // 11/08: images the session looked at (a Read of a PNG, a screenshot) and a prompt's attached images, as the Claude
3627
+ // app shows them under a step: "Read", "1 of 2". Only the checked DTO fields travel; the viewer loads them lazily by
3628
+ // opaque id. `before`: the step came before the turn's words. The count of more rides on the last strip shown.
3629
+ var READER_TOOLS = ['Read', 'Screenshot', 'Image', 'Attached'];
3630
+ function readerToolsHtml(turn, before) {
3631
+ var all = turn && Array.isArray(turn.toolImages) ? turn.toolImages.filter(function (x) {
3632
+ return x && /^ti_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable');
3633
+ }).slice(0, 12) : [];
3634
+ var mine = all.filter(function (x) { return (x.before === true) === before; }).map(function (x) {
3635
+ return { id: x.id, tool: READER_TOOLS.indexOf(x.tool) >= 0 ? x.tool : 'Image', index: Math.floor(Number(x.index)) || 1, total: Math.floor(Number(x.total)) || 1, state: x.state,
3636
+ reason: ['outside_workspace', 'unavailable', 'too_large', 'missing', 'secret_file'].indexOf(x.reason) >= 0 ? x.reason : undefined };
3637
+ });
3638
+ var hasAfter = all.some(function (x) { return x.before !== true; });
3639
+ var more = Math.max(0, Math.min(9999, Math.floor(Number(turn && turn.toolImagesMore)) || 0));
3640
+ var hasBefore = all.some(function (x) { return x.before === true; });
3641
+ var mineMore = more && (before ? hasBefore && !hasAfter : !(hasBefore && !hasAfter)) ? more : 0;
3642
+ if (!mine.length && !mineMore) return '';
3643
+ return '<div data-reader-tools="' + esc(JSON.stringify({ items: mine, more: mineMore, before: before })) + '"><p class="rm-status" role="status">Loading images…</p></div>';
3644
+ }
3645
+ // 6.99.35: files a Claude session sent with SendUserFile that are not images. Names, types and sizes only; the
3646
+ // lazy viewer opens them through the authenticated output-files route by opaque id.
3647
+ var READER_FILE_KINDS = ['pdf', 'text', 'html', 'image', 'file'];
3648
+ function readerFilesHtml(turn) {
3649
+ var files = turn && Array.isArray(turn.outputFiles) ? turn.outputFiles.filter(function (x) {
3650
+ return x && /^af_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable');
3651
+ }).map(function (x) {
3652
+ return { id: x.id, name: String(x.name || 'File').slice(0, 120), kind: READER_FILE_KINDS.indexOf(x.kind) >= 0 ? x.kind : 'file',
3653
+ type: String(x.type || 'File').slice(0, 40), size: typeof x.size === 'number' && x.size >= 0 ? x.size : null, state: x.state, reason: x.reason };
3654
+ }) : [];
3655
+ return files.length ? '<div data-reader-files="' + esc(JSON.stringify(files)) + '"><p class="rm-status" role="status">Loading files…</p></div>' : '';
2493
3656
  }
2494
3657
  function readerImagesStop(host) {
2495
3658
  if (!host) return;
@@ -2498,15 +3661,17 @@
2498
3661
  }
2499
3662
  function readerImagesMount(host, r) {
2500
3663
  if (!host || !r) return;
2501
- if (!host.querySelectorAll('[data-reader-images]').length) { readerImagesStop(host); return; }
3664
+ if (!host.querySelectorAll('[data-reader-images]').length && !host.querySelectorAll('[data-reader-files]').length && !host.querySelectorAll('[data-reader-tools]').length && !host.querySelectorAll('[data-reader-links]').length) { readerImagesStop(host); return; }
2502
3665
  var owner = host.__cosImageOwner = {};
2503
- ensurePane('readerMedia', 4000).then(function (kit) {
3666
+ // Preview engine phase 1: the shared viewer loads first, so link cards and sent files open in it. If it cannot load,
3667
+ // links stay their words and sent files keep the 6.99.35 viewer.
3668
+ ensurePane('preview', 4000).catch(function () { return null; }).then(function () { return ensurePane('readerMedia', 4000); }).then(function (kit) {
2504
3669
  if (host.__cosImageOwner !== owner || host.isConnected === false) return;
2505
3670
  if (!kit) throw new Error('Image previews could not load.');
2506
3671
  kit.mount(host, r.provider, r.id);
2507
3672
  }).catch(function () {
2508
3673
  if (host.__cosImageOwner !== owner || host.isConnected === false) return;
2509
- host.querySelectorAll('[data-reader-images]').forEach(function (slot) {
3674
+ Array.prototype.slice.call(host.querySelectorAll('[data-reader-images]')).concat(Array.prototype.slice.call(host.querySelectorAll('[data-reader-files]')), Array.prototype.slice.call(host.querySelectorAll('[data-reader-tools]'))).forEach(function (slot) {
2510
3675
  slot.textContent = 'Image previews could not load. ';
2511
3676
  var retry = document.createElement('button'); retry.type = 'button'; retry.className = 'link-btn'; retry.textContent = 'Retry images';
2512
3677
  retry.addEventListener('click', function () { readerImagesMount(host, r); }); slot.appendChild(retry);
@@ -2532,7 +3697,7 @@
2532
3697
  out += original ? '## Original request\n\n' + original + '\n\n## Conversation\n\n' : '## Conversation\n\n';
2533
3698
  if (r.more) out += '[Older turns omitted. COS serves the newest ' + turns.length + '. Original request kept.]\n\n';
2534
3699
  turns.forEach(function (t) {
2535
- var text = String(t.text).trim();
3700
+ var text = String(t.text).trim().replace(/\[([^\]\n]*)\]\(cos-file:\d{1,2}\)/g, '$1');
2536
3701
  if (original && t.role === 'user' && text === original) return;
2537
3702
  out += '**' + (t.role === 'user' ? 'You' : 'Assistant') + '**\n\n' + text + '\n\n';
2538
3703
  });
@@ -2550,9 +3715,16 @@
2550
3715
  // Markdown for an assistant turn (Control renders replies with COSMarkdownView): headings, lists,
2551
3716
  // code, quotes, tables, rules, links. Everything is escaped first, so a reply can never add
2552
3717
  // markup of its own; a link keeps only an http(s) address.
3718
+ // Preview engine phase 1: the server turns a reply's local link into `[words](cos-file:N)`, N its card in the turn's
3719
+ // outputLinks. While a turn with cards renders (sxInline.links), that becomes a slot panes/reader-media.js fills with the
3720
+ // card; anywhere else (a file shown in the viewer, previews off, an older turn) it is just the words.
2553
3721
  function sxInline(s) {
3722
+ var links = sxInline.links;
2554
3723
  var codes = [];
2555
3724
  s = s.replace(/`([^`\n]+)`/g, function (_m, c) { codes.push(c); return '\u0000' + (codes.length - 1) + '\u0000'; });
3725
+ s = s.replace(/\[([^\]\n]*)\]\(cos-file:(\d{1,2})\)/g, function (_m, words, n) {
3726
+ return links && Number(n) < links ? '<span class="rm-link-slot" data-reader-link="' + Number(n) + '">' + words + '</span>' : words;
3727
+ });
2556
3728
  s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
2557
3729
  s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>').replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
2558
3730
  s = s.replace(/(^|[^*\w])\*([^*\s][^*\n]*?)\*(?!\w)/g, '$1<em>$2</em>');
@@ -2563,7 +3735,13 @@
2563
3735
  var SX_RULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
2564
3736
  var SX_TABLE_SEP = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/;
2565
3737
  function sxCells(row) { return row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function (c) { return c.trim(); }); }
2566
- function sxMd(text) {
3738
+ function sxMd(text, links) {
3739
+ // links: the turn's card list. Only a real list turns markers into slots (the count bounds the indexes).
3740
+ var held = sxInline.links;
3741
+ sxInline.links = Array.isArray(links) && links.length ? Math.min(20, links.length) : null;
3742
+ try { return sxMdBody(text); } finally { sxInline.links = held; }
3743
+ }
3744
+ function sxMdBody(text) {
2567
3745
  var lines = esc(String(text || '')).replace(/\r\n?/g, '\n').split('\n');
2568
3746
  var out = [], para = [], i = 0;
2569
3747
  function flush() { if (para.length) { out.push('<p>' + para.map(sxInline).join('<br>') + '</p>'); para = []; } }
@@ -2585,12 +3763,16 @@
2585
3763
  if (line.indexOf('|') >= 0 && i + 1 < lines.length && SX_TABLE_SEP.test(lines[i + 1])) {
2586
3764
  flush();
2587
3765
  var head = sxCells(line), aligns = sxCells(lines[i + 1]).map(function (a) { return /^:-+:$/.test(a) ? 'center' : /-:$/.test(a) ? 'right' : ''; });
2588
- var cell = function (tag, c, k) { return '<' + tag + (aligns[k] ? ' style="text-align:' + aligns[k] + '"' : '') + '>' + sxInline(c) + '</' + tag + '>'; };
2589
3766
  i += 2;
2590
3767
  var body = [];
2591
3768
  while (i < lines.length && lines[i].indexOf('|') >= 0 && lines[i].trim()) { body.push(sxCells(lines[i])); i += 1; }
2592
- out.push('<div class="sx-table"><table><thead><tr>' + head.map(function (c, k) { return cell('th', c, k); }).join('') + '</tr></thead><tbody>' +
2593
- body.map(function (row) { return '<tr>' + head.map(function (_c, k) { return cell('td', row[k] || '', k); }).join('') + '</tr>'; }).join('') + '</tbody></table></div>');
3769
+ var fit = tableFit([head].concat(body));
3770
+ var cell = function (tag, c, k, first) {
3771
+ var st = [aligns[k] ? 'text-align:' + aligns[k] : '', fit.width(k, first)].filter(Boolean).join(';');
3772
+ return '<' + tag + (st ? ' style="' + st + '"' : '') + '>' + fit.cell(k, sxInline(c), first) + '</' + tag + '>';
3773
+ };
3774
+ out.push('<div class="sx-table tf-wrap"><table class="' + fit.tableClass + ' tf-grid"><thead><tr>' + head.map(function (c, k) { return cell('th', c, k, true); }).join('') + '</tr></thead><tbody>' +
3775
+ body.map(function (row) { return '<tr>' + head.map(function (_c, k) { return cell('td', row[k] || '', k, false); }).join('') + '</tr>'; }).join('') + '</tbody></table></div>');
2594
3776
  continue;
2595
3777
  }
2596
3778
  if (/^\s*&gt;/.test(line)) {
@@ -2611,6 +3793,17 @@
2611
3793
  sxMd.unescaped = function (lines) {
2612
3794
  return lines.join('\n').split(/\n\s*\n/).map(function (p) { return '<p>' + p.split('\n').map(sxInline).join('<br>') + '</p>'; }).join('');
2613
3795
  };
3796
+ // The card list a turn's Markdown box carries for panes/reader-media.js, checked here: ids and words only.
3797
+ sxMd.linksAttr = function (turn) {
3798
+ var list = turn && Array.isArray(turn.outputLinks) ? turn.outputLinks.slice(0, 20).map(function (x) {
3799
+ return x && /^al_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable') ? {
3800
+ id: x.id, name: String(x.name || 'File').slice(0, 120), label: String(x.label || '').slice(0, 120), kind: String(x.kind || 'file').slice(0, 20),
3801
+ type: String(x.type || 'File').slice(0, 40), size: typeof x.size === 'number' && x.size >= 0 ? x.size : undefined,
3802
+ line: typeof x.line === 'number' && x.line > 0 ? Math.floor(x.line) : undefined, state: x.state, reason: typeof x.reason === 'string' ? x.reason.slice(0, 30) : undefined
3803
+ } : { id: '', state: 'invalid' };
3804
+ }) : [];
3805
+ return list.length ? ' data-reader-links="' + esc(JSON.stringify(list)) + '"' : '';
3806
+ };
2614
3807
  // Nested lists by indent: a deeper item opens a list inside the open item, a shallower one closes back.
2615
3808
  function sxListBlock(lines, i) {
2616
3809
  var html = '', stack = [];
@@ -2646,7 +3839,7 @@
2646
3839
  // One turn's identity, for telling which turns a refresh brought that the page already shows.
2647
3840
  function sxTurnSig(t) {
2648
3841
  var text = String(t.text || t.content || '').trim();
2649
- return (String(t.role || '').toLowerCase() === 'user' ? 'u' : 'a') + '|' + (t.at || '') + '|' + text.length + '|' + text.slice(0, 48) + '|' + readerImagesHtml(t);
3842
+ return (String(t.role || '').toLowerCase() === 'user' ? 'u' : 'a') + '|' + (t.at || '') + '|' + text.length + '|' + text.slice(0, 48) + '|' + readerImagesHtml(t) + (sxMd.linksAttr ? sxMd.linksAttr(t) : '');
2650
3843
  }
2651
3844
  function sxTurnHtml(t) {
2652
3845
  var role = String(t.role || '').toLowerCase() === 'user' ? 'user' : 'assistant';
@@ -2655,7 +3848,9 @@
2655
3848
  var when = at ? (Date.now() - at.getTime() < 86400000 ? clock(at) : sxShortDate(at)) : '';
2656
3849
  return '<div class="turn ' + role + ' sx-turn" data-sig="' + esc(sxTurnSig(t)) + '"><span class="who"><span>' + (role === 'user' ? 'YOU' : 'ASSISTANT') + '</span>' +
2657
3850
  (when ? '<span class="sx-turn-when">' + esc(when) + '</span>' : '') + '</span>' +
2658
- (role === 'user' ? '<div class="sx-plain">' + esc(text) + '</div>' : '<div class="sx-md">' + sxMd(text) + '</div>' + readerImagesHtml(t)) + '</div>';
3851
+ // A YOU turn shows only its attached images (and the previews-off mark); never a reply's outputs.
3852
+ (role === 'user' ? '<div class="sx-plain">' + esc(text) + '</div>' + readerImagesHtml(t, { part: 'user' })
3853
+ : readerImagesHtml(t, { part: 'before' }) + '<div class="sx-md"' + (sxMd.linksAttr ? sxMd.linksAttr(t) : '') + '>' + sxMd(text, t.outputLinks) + '</div>' + readerImagesHtml(t, { part: 'after' })) + '</div>';
2659
3854
  }
2660
3855
  // A refresh never rebuilds what you are reading (Miles, /qa: "start to scroll and then it reloads
2661
3856
  // the section"). The server's window slides as a session grows, so the turns the page already
@@ -4624,7 +5819,7 @@
4624
5819
  .catch(function (err) {
4625
5820
  // The server names what failed (wrong token, wrong code, the glasses code, lockout).
4626
5821
  // No status means the request never reached COS (the browser's own "Load failed").
4627
- showAuth(err && err.status ? err.message : 'Could not reach COS on your Mac. Check that this phone is on Tailscale and the Mac is awake.');
5822
+ showAuth(err && err.status ? err.message : UNREACHABLE_COPY);
4628
5823
  });
4629
5824
  } else if (f.id === 'session-form') sendSessionTurn(false);
4630
5825
  else if (f.id === 'sessions-search-form') { $('sessions-q').blur(); searchSessions(); }
@@ -4673,7 +5868,7 @@
4673
5868
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openAsk(); return; }
4674
5869
  if (e.key === 'Escape' && !$('ask-sheet').classList.contains('hidden')) { closeAsk(); return; }
4675
5870
  if (e.key === 'Escape' && !$('section-menu').classList.contains('hidden')) { closeMenu(); $('section-btn').focus(); return; }
4676
- if (e.key === 'Enter' && !e.shiftKey && e.target.tagName === 'TEXTAREA' && !isPhone()) { e.preventDefault(); var form = e.target.closest('form'); if (form) form.requestSubmit(); }
5871
+ if (returnSends(e)) { e.preventDefault(); var form = e.target.closest('form'); if (form) form.requestSubmit(); }
4677
5872
  });
4678
5873
  document.querySelectorAll('[data-mode]').forEach(function (b) {
4679
5874
  b.addEventListener('click', function () {
@@ -4684,14 +5879,37 @@
4684
5879
  });
4685
5880
  window.addEventListener('hashchange', route);
4686
5881
  window.addEventListener('scroll', onScroll, { passive: true });
5882
+ // 6.99.30: back from the browser's back-forward cache (a swipe back, the app switcher) the open reader's live stream
5883
+ // is closed and the page's last reading is however old the visit was. Open the stream again and read the turns.
5884
+ window.addEventListener('pageshow', function (e) {
5885
+ if (!e || !e.persisted) return;
5886
+ liveResume(); // 7.0.1: re-read the list now, then resume the live stream from its last id
5887
+ var r = state.reading;
5888
+ if (r && r.provider && r.id) {
5889
+ if (state.stream) { try { state.stream.close(); } catch (_e) {} state.stream = null; }
5890
+ watchSession(r.provider, r.id); refreshTurns(r.provider, r.id);
5891
+ }
5892
+ if (state.pane === 'today' && !$('app-shell').classList.contains('hidden')) loadToday();
5893
+ });
4687
5894
  document.addEventListener('visibilitychange', function () {
4688
- if (document.hidden || $('app-shell').classList.contains('hidden')) return;
5895
+ if (document.hidden) { liveHide(); return; }
5896
+ if ($('app-shell').classList.contains('hidden')) return;
5897
+ liveResume(); // 7.0.1: re-read the list now, then resume the live stream from its last id
4689
5898
  // Coming back to the page refreshes Today in place. Other panes keep what is on screen:
4690
5899
  // rebuilding them here moved the page under Miles's finger ("it reloads the section").
4691
5900
  if (state.pane === 'today' && Date.now() - (state.lastLoad.today || 0) > 60000) { state.lastLoad.today = Date.now(); loadToday(); }
4692
5901
  // Back within the minute: Today keeps its data, but the pet's loop stopped while hidden, so restart it.
4693
5902
  else if (state.pane === 'today' && PET.pose && !PET.timer) renderPet(state.sessions || []);
4694
5903
  });
5904
+ // 7.0.1: back on the network, the same as coming back to the page.
5905
+ window.addEventListener('online', function () { if (!document.hidden) liveResume(); });
5906
+ // 7.0.1: Today's Refresh (todayRefresh).
5907
+ document.addEventListener('click', function (e) {
5908
+ var b = e.target && e.target.closest && e.target.closest('[data-today-refresh]');
5909
+ if (!b) return;
5910
+ e.preventDefault();
5911
+ todayRefresh();
5912
+ });
4695
5913
  window.addEventListener('message', function (e) {
4696
5914
  if (e.origin !== location.origin) return;
4697
5915
  var d = e.data;
@@ -4724,10 +5942,133 @@
4724
5942
  });
4725
5943
  }
4726
5944
 
5945
+ // ── the visible viewport, read one way (6.99.30) ──────
5946
+ // Today's session sheet (panes/today-actions.js) and the question card's footer on Today (panes/questions.js) both
5947
+ // place things above whatever covers the bottom of the page: an on-screen keyboard, a hardware keyboard's strip, or a
5948
+ // browser's own bottom bar (Comet, Chrome, Safari's, whichever: nothing here knows any browser's bar). Only what the
5949
+ // browser reports is used: window.visualViewport's height, offsetTop and scale against the layout viewport.
5950
+ // layoutH the layout viewport's height. Read whenever the page is not pinch-zoomed (then innerHeight is the layout
5951
+ // viewport everywhere) and kept while it is, since some browsers report the zoomed size there.
5952
+ // inset what covers the bottom, in unzoomed CSS px: layoutH less the visible height times the zoom. A pinch
5953
+ // zoom alone is no inset (half the height at 2x is all of the page).
5954
+ // top, height the box to fill, in layout px. Unzoomed it is the visible viewport itself (offsetTop is where a
5955
+ // browser panned the page to keep a focused field in view). Zoomed it is the layout viewport less the
5956
+ // inset, so a pinch zoom magnifies the sheet instead of shrinking its layout into the zoomed window.
5957
+ // below what of the layout viewport lies under that box: the backdrop alone reaches down over it.
5958
+ // No visualViewport (old browsers): the whole layout viewport, nothing covered.
5959
+ // 7.0.2 (A3, the Duo audit): some web views shrink innerHeight itself while the keyboard is up (WKWebView in Comet or
5960
+ // Chrome on iOS may), and re-reading it then measured a covered area of 0, so no keyboard was ever seen. So on a touch
5961
+ // screen, while a field that types has focus, layoutH is the tallest unzoomed innerHeight seen at this width (so this
5962
+ // orientation): the keyboard is the same inset whether the browser shrinks visualViewport only or the whole web view.
5963
+ // - It never goes past the last height read with no field focused (free) plus VP_SLACK_PX, so a browser bar that
5964
+ // comes back while typing is never an 80 px keyboard (QA W1). It never goes under innerHeight itself.
5965
+ // - It starts, on focus, from free: the full height, never a height read while a keyboard was still closing. For
5966
+ // VP_SETTLE_MS after a blur, free only grows (QA W2).
5967
+ // - With no field focused, or on a fine pointer (a Mac window resized or DevTools docked while typing, QA N3), it
5968
+ // follows innerHeight exactly as before 7.0.2. A select is not typing: its picker never counts as a keyboard.
5969
+ // - A new width (a turn) starts over. Pinch zoom keeps the height read before the zoom.
5970
+ // dock the CURRENT layout viewport under the visible box (innerHeight less top and height): what a sticky or
5971
+ // fixed composer clears to sit above the keyboard. 0 when the web view itself shrank.
5972
+ var VP = { layoutH: 0 };
5973
+ VP.maxH = 0; VP.w = -1; VP.free = 0; VP.typing = false; VP.blurAt = 0;
5974
+ var VP_SLACK_PX = 20, VP_SETTLE_MS = 1000;
5975
+ function vpTyping() {
5976
+ var a = document.activeElement;
5977
+ if (!a || !a.tagName) return false;
5978
+ var tag = String(a.tagName).toUpperCase();
5979
+ if (tag === 'TEXTAREA') return true;
5980
+ if (tag === 'INPUT') return !/^(checkbox|radio|button|submit|reset|range|color|file|image|hidden)$/i.test(String(a.type || 'text'));
5981
+ return !!a.isContentEditable;
5982
+ }
5983
+ function viewportFit() {
5984
+ var vv = window.visualViewport, de = document.documentElement;
5985
+ var inner = window.innerHeight || (de && de.clientHeight) || 0;
5986
+ var scale = vv && vv.scale > 0 ? vv.scale : 1, zoomed = scale > 1.01;
5987
+ // A turn always changes the width. The orientation type is not read: an engine that derives it from the viewport's
5988
+ // aspect flips it when the keyboard shrinks a web view wider than it is then tall (WebKit, 382x266), which would
5989
+ // throw away the very height the keyboard is measured against.
5990
+ var w = window.innerWidth || 0;
5991
+ if (w !== VP.w) { VP.w = w; VP.maxH = 0; VP.free = 0; }
5992
+ if (!zoomed) {
5993
+ var coarse = !window.matchMedia || !!window.matchMedia('(any-pointer: coarse)').matches;
5994
+ var typing = coarse && vpTyping(), now = Date.now();
5995
+ if (!typing) {
5996
+ if (VP.typing) VP.blurAt = now;
5997
+ VP.free = VP.free && now - VP.blurAt < VP_SETTLE_MS ? Math.max(VP.free, inner) : inner;
5998
+ VP.maxH = inner;
5999
+ VP.layoutH = inner;
6000
+ } else {
6001
+ if (!VP.free) VP.free = inner;
6002
+ VP.maxH = Math.max(VP.typing ? VP.maxH : VP.free, inner);
6003
+ VP.layoutH = Math.max(inner, Math.min(VP.maxH, VP.free + VP_SLACK_PX));
6004
+ }
6005
+ VP.typing = typing;
6006
+ }
6007
+ else if (!VP.layoutH) VP.layoutH = Math.max(inner, (de && de.clientHeight) || 0);
6008
+ var H = VP.layoutH;
6009
+ if (!vv || !(vv.height > 0)) return { layoutH: H, top: 0, height: H, inset: 0, below: 0, dock: 0, scale: 1, zoomed: false, vv: false };
6010
+ var inset = Math.max(0, Math.round(H - vv.height * scale));
6011
+ var top, height;
6012
+ if (!zoomed) { top = Math.max(0, Math.round(vv.offsetTop || 0)); height = Math.min(Math.round(vv.height), H); }
6013
+ else { height = H - inset; top = Math.min(Math.max(0, Math.round(vv.offsetTop || 0)), inset); }
6014
+ if (top + height > H) top = Math.max(0, H - height);
6015
+ var below = Math.max(0, H - top - height);
6016
+ return { layoutH: H, top: top, height: height, inset: inset, below: below, dock: zoomed ? below : Math.max(0, Math.round(inner) - top - height), scale: scale, zoomed: zoomed, vv: true };
6017
+ }
6018
+
6019
+ // ── layout breadcrumbs (6.99.30) ──────────────────────
6020
+ // When a sheet lands wrong on a phone nobody here holds, the numbers have to come back on their own. A breadcrumb
6021
+ // goes to the server's existing client diagnostics log (POST /api/diag/client, routes/diag.ts: 10 MB with one
6022
+ // rotation, 30 lines per 10 s per client) when an overlay opens and each time its keyboard state changes. Numbers and
6023
+ // a browser family only: no address, no text, no ids, never the user agent string itself. At most one identical
6024
+ // line per 2 s and LAYOUT_CRUMB_MAX a page load, fire and forget.
6025
+ // 7.0.2 (A1): every pane reports now (focus, keyboard open and close, the gap after it closes), so the cap is 80 a
6026
+ // page load, in two budgets: focus lines have LAYOUT_FOCUS_MAX of them, everything else the rest, so a page of typing
6027
+ // never starves the keyboard reports (QA W3). At most LAYOUT_CRUMB_RATE leave in any 10 s, under the server's 30
6028
+ // (routes/diag.ts), and a focus line only while that window is under half full.
6029
+ var LAYOUT_CRUMB_MAX = 80, LAYOUT_FOCUS_MAX = 20, LAYOUT_CRUMB_GAP_MS = 2000, LAYOUT_CRUMB_RATE = 20, LAYOUT_CRUMB_WINDOW_MS = 10000;
6030
+ var crumbs = { sent: 0, focus: 0, last: '', lastAt: 0, recent: [] };
6031
+ /** The browser family, from the UA string, for the breadcrumb. Comet first: it says Chrome or Safari too. */
6032
+ function uaClass(ua) {
6033
+ ua = String(ua == null ? (navigator && navigator.userAgent) || '' : ua);
6034
+ var ios = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && typeof navigator !== 'undefined' && navigator.maxTouchPoints > 1);
6035
+ var android = /Android/.test(ua);
6036
+ var os = ios ? 'ios' : android ? 'android' : /Mac OS X|Macintosh/.test(ua) ? 'mac' : /Windows/.test(ua) ? 'windows' : /Linux|CrOS/.test(ua) ? 'linux' : 'other';
6037
+ var hints = typeof navigator !== 'undefined' && navigator.userAgentData && navigator.userAgentData.brands ? navigator.userAgentData.brands.map(function (b) { return b && b.brand; }).join(' ') : '';
6038
+ var brand = /Comet\//i.test(ua) || /\bComet\b/i.test(ua) || /\bComet\b/i.test(hints) ? 'comet'
6039
+ : /CriOS\//.test(ua) ? 'crios' : /FxiOS\//.test(ua) ? 'firefox' : /EdgiOS\/|Edg\//.test(ua) ? 'edge'
6040
+ : /Firefox\//.test(ua) ? 'firefox' : /Chrome\/|Chromium\//.test(ua) ? 'chrome'
6041
+ : /Safari\//.test(ua) ? 'safari' : 'other';
6042
+ return { os: os, brand: brand };
6043
+ }
6044
+ /** One breadcrumb: `event` (open, kb), the viewport as the browser reports it, and the overlay's own state. */
6045
+ function layoutCrumb(event, extra) {
6046
+ try {
6047
+ var vv = window.visualViewport, de = document.documentElement, ua = uaClass();
6048
+ var r = function (n) { return typeof n === 'number' && isFinite(n) ? Math.round(n * 100) / 100 : null; };
6049
+ var o = (screen && screen.orientation && screen.orientation.type) || (typeof window.orientation === 'number' ? 'deg' + window.orientation : '');
6050
+ var data = { what: String(event || ''), ua: ua.brand, os: ua.os, iw: r(window.innerWidth), ih: r(window.innerHeight), ch: r(de && de.clientHeight),
6051
+ vvh: vv ? r(vv.height) : null, vvw: vv ? r(vv.width) : null, vvt: vv ? r(vv.offsetTop) : null, vvs: vv ? r(vv.scale) : null,
6052
+ orient: String(o).slice(0, 24), coarse: !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches) };
6053
+ var x = extra || {};
6054
+ Object.keys(x).forEach(function (k) { var v = x[k]; if (/^[a-z][a-zA-Z0-9]{0,15}$/.test(k) && (typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 24))) data[k] = typeof v === 'number' ? r(v) : v; });
6055
+ var key = JSON.stringify(data), now = Date.now();
6056
+ var focusLine = data.what === 'focus';
6057
+ if (focusLine ? crumbs.focus >= LAYOUT_FOCUS_MAX : crumbs.sent - crumbs.focus >= LAYOUT_CRUMB_MAX - LAYOUT_FOCUS_MAX) return false;
6058
+ if (key === crumbs.last && now - crumbs.lastAt < LAYOUT_CRUMB_GAP_MS) return false;
6059
+ crumbs.recent = crumbs.recent.filter(function (t) { return now - t < LAYOUT_CRUMB_WINDOW_MS; });
6060
+ if (crumbs.recent.length >= (focusLine ? LAYOUT_CRUMB_RATE / 2 : LAYOUT_CRUMB_RATE)) return false;
6061
+ crumbs.sent++; if (focusLine) crumbs.focus++;
6062
+ crumbs.last = key; crumbs.lastAt = now; crumbs.recent.push(now);
6063
+ api('/api/diag/client', { method: 'POST', body: { ts: now, level: 'info', event: 'web.layout', data: data }, timeoutMs: 5000 }).catch(function () {});
6064
+ return true;
6065
+ } catch (_e) { return false; }
6066
+ }
6067
+
4727
6068
  // ── the shared kit for pane modules (panes/*.js) ───────
4728
6069
  window.COSWeb = {
4729
- $: $, esc: esc, md: md, plain: plain, api: api, toast: toast, ago: ago, clock: clock, toDate: toDate,
4730
- plural: plural, countWord: countWord, dayWord: dayWord, providerLabel: providerLabel, domainLabel: domainLabel,
6070
+ $: $, esc: esc, md: md, plain: plain, structureLensAnswer: structureLensAnswer, tableFit: tableFit, api: api, toast: toast, ago: ago, clock: clock, toDate: toDate,
6071
+ plural: plural, countWord: countWord, dayWord: dayWord, plainDay: plainDay, providerLabel: providerLabel, domainLabel: domainLabel,
4731
6072
  skeletonRows: skeletonRows, emptyBox: emptyBox, setStatus: setStatus, autoGrow: autoGrow,
4732
6073
  readStore: readStore, writeStore: writeStore, copyText: copyText, isPhone: isPhone,
4733
6074
  state: state, titles: TITLES, go: go, parseRoute: parseRoute, goBack: goBack,
@@ -4747,7 +6088,31 @@
4747
6088
  // Control's session state (the Sessions pane's sxState) and warm-up test, so the rail's
4748
6089
  // "N waiting on you, N working" counts exactly what the pane lists.
4749
6090
  sessionState: function (s) { return sxState(s); },
4750
- isKeepWarm: function (s) { return sxKeepWarm(s); }
6091
+ isKeepWarm: function (s) { return sxKeepWarm(s); },
6092
+ // 6.99.30: the visible viewport read one way, layout breadcrumbs, the one unreachable sentence, Return-sends.
6093
+ viewportFit: viewportFit, layoutCrumb: layoutCrumb, uaClass: uaClass, unreachableCopy: UNREACHABLE_COPY, keyboardFirst: keyboardFirst,
6094
+ serverUp: function () { return SERVER.up; },
6095
+ // Panes with their own fetch (the questions poll) say whether the Mac answered, so a "down" mark never outlives it.
6096
+ noteServer: function (up) { noteServer(up); },
6097
+ onServer: function (fn) { SERVER.hooks.push(fn); },
6098
+ // Duo tables: the resizable divider between a list and its reader (splitKit above), for panes/*.js.
6099
+ splitKit: { mount: mountSplit, handle: splitHandleHtml, fullButton: splitFullButton },
6100
+ // 7.0.1: the one live stream (docs/live-events-contract.md). mode(): idle, connecting, live, reconnecting, busy or
6101
+ // absent. on(fn) hears each applied frame ({ kind, key, session, previous }). session(provider, id): the live
6102
+ // record, or null. age(): "As of 2 min ago" when the counts are not live, else ''.
6103
+ live: {
6104
+ mode: function () { return LIVE.mode; },
6105
+ on: function (fn) { if (typeof fn === 'function') LIVE.hooks.push(fn); },
6106
+ session: function (provider, id) {
6107
+ if (!liveCanOverlay()) return null; // outside live the list is the truth (review W1)
6108
+ var byKey = {};
6109
+ Object.keys(LIVE.sessions).forEach(function (k) { var ls = LIVE.sessions[k]; byKey[liveKey(ls.provider, ls.sessionId)] = ls; });
6110
+ var j = liveMatch(liveKey(provider, id), Object.keys(byKey));
6111
+ return j ? byKey[j] : null;
6112
+ },
6113
+ age: function () { return liveAge(); },
6114
+ clock: liveClock
6115
+ }
4751
6116
  };
4752
6117
  // Today as a place to act (panes/today-actions.js, Miles 11/06): what the Today cards show, a repaint after a
4753
6118
  // decision, and the Sessions reader's own pieces, so the overlay sends and reads through the reader's code.
@@ -4784,13 +6149,126 @@
4784
6149
  newKey: newTurnKey,
4785
6150
  probe: function (r) { return sxProbeAttach(r); },
4786
6151
  path: sessionPath, answer: apiAnswer,
4787
- imageHtml: readerImagesHtml, imagesMount: readerImagesMount, imagesStop: readerImagesStop,
6152
+ notSent: { rows: notSentRows, line: notSentLine, label: notSentLabel, note: noteNotSent, get: notSentFor },
6153
+ imageHtml: readerImagesHtml, imagesMount: readerImagesMount, imagesStop: readerImagesStop, linksAttr: function (t) { return sxMd.linksAttr(t); },
4788
6154
  feed: { fresh: newFeed, apply: applyFeed, word: sxFeedWord, detail: sxFeedDetail, tool: sxToolLine, elapsed: sxElapsed, fallback: sxFallbackLine,
4789
6155
  truth: sxTruth, settle: sxSettle, noteList: sxNoteList, noteGate: sxNoteGate, gateStale: sxGateStale, promptLine: sxPromptLine, parkNoteShows: sxParkNoteShows },
4790
6156
  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
6157
+ parkCopy: function (p, r, hasFiles) { return r ? sxParkLine(r, hasFiles) : SX_PARK[sxProvider(p)]; }, providerLabel: sxProviderLabel, markdown: sxMd
4792
6158
  };
4793
6159
 
6160
+ // ── layout reports and the visible box, every pane (7.0.2) ──
6161
+ // The Duo audit (11/07): only Today's session sheet ever sent a layout report, and only when its classes flipped, so
6162
+ // the COS chat composer, the front screen Miles types on most, never sent one, and a keyboard the code failed to see
6163
+ // sent nothing at all. Now, through layoutCrumb (numbers and fixed labels only, never text or ids):
6164
+ // device once per page load, after sign-in: the screen, the pixel ratio, safe areas, pointer kind
6165
+ // focus a field that types gets focus, on any pane, on a touch screen: its computed font size, the scroll
6166
+ // position and range, and innerWidth against scrollWidth, as scw (zoom vs pan vs a wider page)
6167
+ // kb-open, kb-close EVERY keyboard open and close, numbered, so a repeat is never dropped as a duplicate; with
6168
+ // innerHeight against the layout height (shrank: the web view itself got shorter)
6169
+ // kb-after KB_AFTER_MS after a close: the gap below the composer that had the keyboard, scrollY against its
6170
+ // range, and 100dvh, 100svh and 100lvh in px; measured before and after the panes settle
6171
+ // The same pass publishes the visible box for CSS on <html>: --vv-top, --vv-h, --vv-below (the layout viewport under
6172
+ // the visible box, viewportFit's dock) and html.kb-up while an on-screen keyboard is up (KB_UP_PX or more covered
6173
+ // with a typing field focused). Panes hear open, close, move (the visible box changed while it is up) and after
6174
+ // through COSWeb.onKeyboard; COSWeb.keyboardUp says whether it is up now. Which composer the keyboard is for is the
6175
+ // pane's to mark (panes/cos-chat.js sets html.kb-chat and .cc-kb).
6176
+ var KB_UP_PX = 120, KB_AFTER_MS = 300;
6177
+ var LR = { up: false, frame: 0, after: 0, cycle: 0, focusN: 0, device: false, field: null, hooks: [], box: '' };
6178
+ function lrPane() { var r = parseRoute(); return String(r.pane + (r.args[0] === 'cos' ? '.cos' : '')).slice(0, 24); }
6179
+ function lrKind(a) {
6180
+ var tag = String((a && a.tagName) || '').toLowerCase();
6181
+ return (tag === 'input' ? 'input.' + String(a.type || 'text').toLowerCase() : tag || 'other').slice(0, 24);
6182
+ }
6183
+ function lrScroll() {
6184
+ var se = document.scrollingElement || document.documentElement;
6185
+ var sy = Math.round(window.scrollY || window.pageYOffset || 0);
6186
+ var smax = se ? Math.max(0, Math.round((se.scrollHeight || 0) - (window.innerHeight || se.clientHeight || 0))) : 0;
6187
+ return { sy: sy, smax: smax, sw: se ? Math.round(se.scrollWidth || 0) : 0 };
6188
+ }
6189
+ // One hidden element, measured and removed: its height (h) or a computed padding in px. Null where it cannot be read.
6190
+ function lrProbe(css, prop) {
6191
+ var b = document.body;
6192
+ if (!b || !document.createElement) return null;
6193
+ var el = document.createElement('div'), v = null;
6194
+ el.setAttribute('aria-hidden', 'true');
6195
+ el.style.cssText = 'position:fixed;left:0;top:0;width:1px;visibility:hidden;pointer-events:none;' + css;
6196
+ b.appendChild(el);
6197
+ try { v = prop === 'h' ? el.offsetHeight : parseFloat(getComputedStyle(el)[prop]); } catch (_e) { v = null; }
6198
+ b.removeChild(el);
6199
+ return typeof v === 'number' && isFinite(v) ? v : null;
6200
+ }
6201
+ function lrMatch(q) { try { return !!window.matchMedia(q).matches; } catch (_e) { return false; } }
6202
+ function lrDevice() {
6203
+ if (LR.device) return;
6204
+ LR.device = true;
6205
+ var sc = window.screen || {};
6206
+ layoutCrumb('device', { sw: sc.width, sh: sc.height, sah: sc.availHeight, oh: window.outerHeight, ow: window.outerWidth, dpr: window.devicePixelRatio,
6207
+ sat: lrProbe('padding-top:env(safe-area-inset-top)', 'paddingTop'), sab: lrProbe('padding-bottom:env(safe-area-inset-bottom)', 'paddingBottom'),
6208
+ anyCoarse: lrMatch('(any-pointer: coarse)'), shortTouch: lrMatch(SHORT_TOUCH_MQ), phone: isPhone() });
6209
+ }
6210
+ // The focus line is for touch screens only (QA W3): on a Mac it would only spend the budget.
6211
+ function lrFocus() {
6212
+ if (!vpTyping()) return;
6213
+ var a = document.activeElement, fs = null;
6214
+ LR.field = a;
6215
+ if (!lrMatch('(pointer: coarse)')) return;
6216
+ try { fs = parseFloat(getComputedStyle(a).fontSize); } catch (_e) { fs = null; }
6217
+ var s = lrScroll();
6218
+ layoutCrumb('focus', { pane: lrPane(), field: lrKind(a), fs: fs, sy: s.sy, smax: s.smax, scw: s.sw, kb: LR.up, n: ++LR.focusN });
6219
+ }
6220
+ function lrHooks(what, v) { LR.hooks.forEach(function (fn) { try { fn(what, v); } catch (err) { console.error(err); } }); }
6221
+ function lrGap(v) {
6222
+ var f = LR.field, box = f && f.closest ? (f.closest('form') || f) : f;
6223
+ if (!box || box.isConnected === false || !box.getBoundingClientRect) return null;
6224
+ return Math.round(v.top + v.height - box.getBoundingClientRect().bottom);
6225
+ }
6226
+ function lrAfter() {
6227
+ LR.after = 0;
6228
+ if (LR.up) return;
6229
+ var v = viewportFit(), s = lrScroll(), gap = lrGap(v);
6230
+ lrHooks('after', v);
6231
+ var s2 = lrScroll();
6232
+ layoutCrumb('kb-after', { pane: lrPane(), n: LR.cycle, gap: gap, sy: s.sy, smax: s.smax, over: s.sy > s.smax, gapNow: lrGap(viewportFit()), syNow: s2.sy,
6233
+ dvh: lrProbe('height:100dvh', 'h'), svh: lrProbe('height:100svh', 'h'), lvh: lrProbe('height:100lvh', 'h'), lh: v.layoutH });
6234
+ }
6235
+ function lrApply() {
6236
+ LR.frame = 0;
6237
+ var v = viewportFit(), de = document.documentElement;
6238
+ // Written only when they change: a pinch or pan fires this every frame, and each write restyles the page.
6239
+ var box = v.top + '|' + v.height + '|' + v.dock, moved = box !== LR.box;
6240
+ if (moved && de && de.style && de.style.setProperty) {
6241
+ LR.box = box;
6242
+ de.style.setProperty('--vv-top', v.top + 'px'); de.style.setProperty('--vv-h', v.height + 'px'); de.style.setProperty('--vv-below', v.dock + 'px');
6243
+ }
6244
+ var up = !!(v.vv && v.inset >= KB_UP_PX && vpTyping());
6245
+ if (de && de.classList) de.classList.toggle('kb-up', up);
6246
+ if (up === LR.up) { if (up && moved) lrHooks('move', v); return v; }
6247
+ LR.up = up; LR.cycle++;
6248
+ layoutCrumb(up ? 'kb-open' : 'kb-close', { pane: lrPane(), n: LR.cycle, inset: v.inset, below: v.below, dock: v.dock, lh: v.layoutH, top: v.top, h: v.height,
6249
+ zoomed: v.zoomed, shrank: Math.round(window.innerHeight || 0) < v.layoutH });
6250
+ if (LR.after) { clearTimeout(LR.after); LR.after = 0; }
6251
+ if (!up) LR.after = setTimeout(lrAfter, KB_AFTER_MS);
6252
+ lrHooks(up ? 'open' : 'close', v);
6253
+ return v;
6254
+ }
6255
+ function lrFit() {
6256
+ if (LR.frame) return;
6257
+ if (!window.requestAnimationFrame) { lrApply(); return; }
6258
+ LR.frame = window.requestAnimationFrame(lrApply);
6259
+ }
6260
+ (function lrListen() {
6261
+ var vv = window.visualViewport;
6262
+ if (vv && vv.addEventListener) { vv.addEventListener('resize', lrFit); vv.addEventListener('scroll', lrFit); }
6263
+ window.addEventListener('resize', lrFit); window.addEventListener('orientationchange', lrFit); window.addEventListener('pageshow', lrFit);
6264
+ document.addEventListener('focusin', function () { lrFocus(); lrFit(); });
6265
+ document.addEventListener('focusout', function () { setTimeout(lrFit, 0); });
6266
+ shellHooks.push(function () { lrDevice(); lrFit(); });
6267
+ })();
6268
+ window.COSWeb.keyboardUp = function () { return LR.up; };
6269
+ window.COSWeb.onKeyboard = function (fn) { if (typeof fn === 'function') LR.hooks.push(fn); };
6270
+ // ── end layout reports
6271
+
4794
6272
  // Every pane module is a plain script after this one; boot waits for all of them.
4795
6273
  function boot() {
4796
6274
  var params = new URLSearchParams(location.search);
@@ -4799,7 +6277,7 @@
4799
6277
  history.replaceState({}, document.title, location.pathname + location.hash);
4800
6278
  api('/api/web-session/login', { method: 'POST', body: { code: linkCode } }).then(showShell)
4801
6279
  .catch(function (err) {
4802
- showAuth(err && err.status ? err.message : 'Could not reach COS on your Mac. Check that this phone is on Tailscale and the Mac is awake.');
6280
+ showAuth(err && err.status ? err.message : UNREACHABLE_COPY);
4803
6281
  var box = $('auth-code'); if (box) box.value = linkCode;
4804
6282
  });
4805
6283
  return;
@@ -4811,7 +6289,7 @@
4811
6289
  .catch(function (err) { showAuth(err && err.status ? err.message : 'That sign-in link did not work. Get a new one from COS Control on your Mac or the COS Glasses app, or use a code or the token.'); });
4812
6290
  return;
4813
6291
  }
4814
- api('/api/web-session/status').then(function (st) { if (st && st.authenticated) showShell(st); else showAuth(); }).catch(function () { showAuth('COS on your Mac is not answering. Check that the Mac is awake and on Tailscale.'); });
6292
+ api('/api/web-session/status').then(function (st) { if (st && st.authenticated) showShell(st); else showAuth(); }).catch(function () { showAuth(UNREACHABLE_COPY); });
4815
6293
  }
4816
6294
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
4817
6295
  })();