@gotcos/glasses-server 6.99.33 → 7.0.18

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 (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
@@ -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; }
@@ -119,7 +266,10 @@
119
266
  if (timer && o.responseType !== 'blob') clearTimeout(timer);
120
267
  sxNoteClock(res, sentAt);
121
268
  noteServer(true);
122
- if (res.status === 401 && path.indexOf('/api/web-session/') !== 0) { showAuth(); var gone = new Error('Signed out. Connect this browser again.'); gone.status = 401; throw gone; }
269
+ // A request that renewed this browser's sign-in says so (lib/api-auth.ts): `<new expiry> <30-day cap>`.
270
+ var renewedTo = res.headers && typeof res.headers.get === 'function' ? res.headers.get('X-COS-Web-Session') : null;
271
+ if (renewedTo && typeof signinRemember === 'function') { var rt = String(renewedTo).split(' '); signinRemember({ expiresAt: rt[0], renewUntil: rt[1] }); }
272
+ if (res.status === 401 && path.indexOf('/api/web-session/') !== 0) { showAuth(typeof signinLapsedCopy === 'function' && signedInOnce() ? signinLapsedCopy() : undefined); var gone = new Error('Signed out. Connect this browser again.'); gone.status = 401; throw gone; }
123
273
  if (o.responseType === 'blob' && res.ok) return res.blob();
124
274
  return res.text().then(function (text) {
125
275
  var data = {};
@@ -210,6 +360,103 @@
210
360
  sessions: [], sessionsMode: 'words', workTasks: [], workOpenStages: { complete: false },
211
361
  stream: null, lastLoad: {}, livePoll: null
212
362
  };
363
+ // ── Mac reachability and sign-in lifetime (W1, 7.0.x): audit 02 F2, F3, F23 ──
364
+ // F3: with no offline shell (that needs a service worker, so HTTPS), an open page says plainly when the Mac stops answering,
365
+ // when it last heard from it, and offers Retry, instead of spinners. Retry asks GET /api/health (public, no state) and then
366
+ // reloads the pane on screen. While the Mac is down it asks again every 30 s, only while the page is visible.
367
+ // F2: a sign-in lasts 7 days and, since 7.0.x, renews while in use up to 30 days (lib/web-session-auth.ts). Status and the
368
+ // login answer carry this browser's own expiresAt; within a day of it the page says when it ends and how to sign in again.
369
+ // Sign-in (7.0.x, Miles 2026-11-09 "Renew while in use, 30-day cap"): `ends` is this cookie's expiry, `cap` the latest a
370
+ // renewal can reach (30 days from the first sign-in). A server without renewal sends no cap: then the cookie's end is the end.
371
+ var CONN = { timer: null, retrying: false, signin: null, hideFor: 0, lapsed: false };
372
+ var CONN_RETRY_MS = 30000, SIGNIN_WARN_MS = 24 * 3600000, SIGNIN_STORE = 'cos.web.signinEnds';
373
+ // Names the row, never the Settings page: that page was renamed in COS Control 0.5.387, and a phone may pair with either build.
374
+ var SIGNIN_HOW = 'On your Mac, open COS Control Settings and choose Open COS on your phone. Or get a code from another signed-in browser, or from the COS Glasses app.';
375
+ function connWhen(ms) {
376
+ if (!ms) return '';
377
+ var d = new Date(ms), same = new Date().toDateString() === d.toDateString();
378
+ return same ? clock(d) : d.toLocaleDateString(undefined, { weekday: 'short' }) + ' ' + clock(d);
379
+ }
380
+ function signinRemember(info) {
381
+ var ends = info && info.expiresAt ? Date.parse(info.expiresAt) : NaN, cap = info && info.renewUntil ? Date.parse(info.renewUntil) : NaN;
382
+ // Q1: a signed-in answer with no expiry (the token, or an older server) leaves nothing to warn about.
383
+ if (info && info.authenticated === true && !info.expiresAt) {
384
+ CONN.signin = null;
385
+ try { localStorage.removeItem(SIGNIN_STORE); } catch (e) { /* nothing kept */ }
386
+ return;
387
+ }
388
+ if (!isFinite(ends) || ends <= 0) return;
389
+ CONN.signin = { ends: ends, cap: isFinite(cap) && cap >= ends ? cap : 0 };
390
+ try { localStorage.setItem(SIGNIN_STORE, JSON.stringify(CONN.signin)); } catch (e) { /* the notice still works for this page */ }
391
+ if (typeof connPaint === 'function') connPaint();
392
+ }
393
+ function signinKnown() {
394
+ if (CONN.signin) return CONN.signin;
395
+ try {
396
+ var raw = localStorage.getItem(SIGNIN_STORE), v = raw ? JSON.parse(raw) : null;
397
+ if (typeof v === 'number' && v > 0) return { ends: v, cap: 0 }; // the earlier page kept the expiry alone
398
+ if (v && typeof v.ends === 'number' && v.ends > 0) return { ends: v.ends, cap: typeof v.cap === 'number' ? v.cap : 0 };
399
+ } catch (e) { /* nothing known */ }
400
+ return null;
401
+ }
402
+ // When this sign-in really ends while it is in use: the 30-day cap where the server renews, else the cookie's own end.
403
+ function signinEndsAt() { var k = signinKnown(); return k ? (k.cap || k.ends) : 0; }
404
+ // Why the sign-in gate opened after this page had been signed in: the 30 days, no use in the cookie's last 2 days (its
405
+ // only renewal window, so a browser used on day 4 still ends on day 7), or a sign-out. Q1 R-3.
406
+ function signinLapsedCopy() {
407
+ var k = signinKnown(), soon = Date.now() + 60000;
408
+ if (k && k.cap && k.cap <= soon) return 'Your sign-in on this browser reached its 30 days. ' + SIGNIN_HOW;
409
+ if (k && k.cap && k.ends <= soon) return 'Your sign-in on this browser ended because COS was not used here in its last 2 days. ' + SIGNIN_HOW;
410
+ if (k && !k.cap && k.ends <= soon) return 'Your sign-in on this browser ended. Sign-ins last 7 days. ' + SIGNIN_HOW;
411
+ return 'This browser is signed out. ' + SIGNIN_HOW;
412
+ }
413
+ // Q1 M1: a request that never got an answer cannot tell a sleeping Mac from this device being offline or off
414
+ // Tailscale, so the line names both. Only the browser's own "offline" is certain enough to say alone.
415
+ function connOffline() { return typeof navigator !== 'undefined' && navigator && navigator.onLine === false; }
416
+ function connTitle() { return connOffline() ? 'This device is offline.' : 'COS on your Mac is not answering.'; }
417
+ function connWhy() {
418
+ return connOffline() ? 'Connect to the internet and Tailscale, then try again.'
419
+ : 'Your Mac may be asleep, or this device may be offline or off Tailscale.';
420
+ }
421
+ function connHtml() {
422
+ var html = '';
423
+ if (SERVER.up === false) {
424
+ html += '<div class="conn-line" role="status"><strong>' + esc(connTitle()) + '</strong><span class="conn-sub">' +
425
+ esc((SERVER.lastUpAt ? 'Last updated ' + connWhen(SERVER.lastUpAt) + '. ' : '') + 'What is on screen may be out of date. ' + connWhy()) + '</span>' +
426
+ '<button type="button" class="btn small" data-action="conn-retry"' + (CONN.retrying ? ' disabled' : '') + '>' + (CONN.retrying ? 'Checking' : 'Retry') + '</button></div>';
427
+ }
428
+ var ends = signinEndsAt(), left = ends - Date.now();
429
+ if (ends && left > 0 && left <= SIGNIN_WARN_MS && CONN.hideFor !== ends) {
430
+ html += '<div class="conn-line" role="status"><strong>Sign-in here ends ' + esc(connWhen(ends)) + '.</strong><span class="conn-sub">' +
431
+ esc('Sign in again before then from COS Control Settings on your Mac: Open COS on your phone.') + '</span>' +
432
+ '<button type="button" class="btn small" data-action="conn-hide-signin">Hide</button></div>';
433
+ }
434
+ return html;
435
+ }
436
+ function connPaint() {
437
+ var box = $('conn-notice'); if (!box) return;
438
+ var html = connHtml();
439
+ if (box.innerHTML !== html) box.innerHTML = html;
440
+ box.hidden = !html;
441
+ }
442
+ function connSchedule() {
443
+ if (CONN.timer) { clearTimeout(CONN.timer); CONN.timer = null; }
444
+ if (SERVER.up !== false) return;
445
+ CONN.timer = setTimeout(function () { CONN.timer = null; if (!document.hidden) connRetry(false); else connSchedule(); }, CONN_RETRY_MS);
446
+ }
447
+ function connRetry(byHand) {
448
+ if (CONN.retrying) return Promise.resolve(false);
449
+ CONN.retrying = true; connPaint();
450
+ return api('/api/health', { timeoutMs: 10000 }).then(function () { return true; }, function () { return false; }).then(function (up) {
451
+ CONN.retrying = false;
452
+ if (up) { connPaint(); if (typeof route === 'function') route(); }
453
+ else { if (byHand) noteServer(false); connPaint(); connSchedule(); }
454
+ return up;
455
+ });
456
+ }
457
+ SERVER.hooks.push(function () { connPaint(); connSchedule(); });
458
+ // ── end Mac reachability and sign-in lifetime
459
+
213
460
  // Control's order (ActivitySection.visibleSections): Today, then Messages to Dictation. Usage is a
214
461
  // view inside Sessions on the Mac; its route stays so old links work, and the rail marks Sessions.
215
462
  var ROOT_PANES = ['today', 'messages', 'speakers', 'meetings', 'memories', 'threads', 'sessions', 'work', 'dictation', 'usage', 'settings'];
@@ -278,7 +525,10 @@
278
525
  var hash = '#/' + [pane].concat(args || []).map(encodeURIComponent).join('/');
279
526
  if (location.hash === hash) route(); else location.hash = hash;
280
527
  }
281
- function isPhone() { return !window.matchMedia('(min-width: 600px)').matches; }
528
+ // 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
529
+ // Comet): height is its scarce axis, so it gets the phone Ask sheet and the 44 px tap floor (app.css), as folded does.
530
+ var SHORT_TOUCH_MQ = '(pointer: coarse) and (max-height: 720px)';
531
+ function isPhone() { return !window.matchMedia('(min-width: 600px)').matches || window.matchMedia(SHORT_TOUCH_MQ).matches; }
282
532
  /** A keyboard and a fine pointer are the way in (a Mac, a PC): Return sends. Touch screens type a new line. */
283
533
  function keyboardFirst() { return !!(window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches); }
284
534
  // Return in a text box sends only where a keyboard and a pointer are the way in (cos-chat.js keyboardFirst): never on
@@ -379,7 +629,10 @@
379
629
  // ── auth ────────────────────────────────────────────────
380
630
  function showAuth(message) {
381
631
  readerImagesStop($('session-turns')); readerImagesStop($('ta-last'));
632
+ if (typeof liveHide === 'function') { liveHide(); liveStopWatch(); LIVE.mode = 'idle'; }
382
633
  var alreadyOpen = !$('auth-gate').classList.contains('hidden');
634
+ if (typeof CONN === 'object' && !$('app-shell').classList.contains('hidden')) CONN.lapsed = true;
635
+ if (typeof authOffline === 'function') authOffline(false);
383
636
  $('app-shell').classList.add('hidden');
384
637
  $('auth-gate').classList.remove('hidden');
385
638
  // A later 401 from a pane still polling (W.api passes no message) must not blank what the gate already says:
@@ -388,14 +641,19 @@
388
641
  setStatus($('auth-error'), message || '', true);
389
642
  $('auth-error').classList.toggle('hidden', !message);
390
643
  }
644
+ function signedInOnce() { var shell = typeof $ === 'function' ? $('app-shell') : null; return !!(shell && !shell.classList.contains('hidden')) || CONN.lapsed; }
391
645
  function showShell(info) {
646
+ if (typeof signinRemember === 'function') signinRemember(info);
647
+ if (typeof authOffline === 'function') authOffline(false);
392
648
  $('auth-gate').classList.add('hidden');
393
649
  $('app-shell').classList.remove('hidden');
394
650
  state.serverName = (info && info.serverName) || 'Your Mac';
395
651
  $('host-name').textContent = state.serverName.replace(/-/g, ' ');
396
652
  loadTailnetUrl();
397
653
  runHooks(shellHooks, info);
654
+ if (typeof connPaint === 'function') connPaint();
398
655
  route();
656
+ if (typeof liveStart === 'function') liveStart(); // 7.0.1: the one live stream for this page (a no-op if it is already open)
399
657
  // Reloaded with Today's overlay open: its history entry is still current. The overlay opens the same thing again
400
658
  // (panes/today-actions.js restore), so Back closes it; if it cannot load, the entry is unmarked.
401
659
  var held = history.state;
@@ -419,15 +677,24 @@
419
677
  // stories all asked for it separately. Now there is one copy: a request younger than maxAgeMs is
420
678
  // answered from it, a request already on the wire is shared, and panes can paint the last copy at
421
679
  // once while a fresh one loads (sessionListCached).
422
- var SL = { data: null, at: 0, inflight: null, waiting: null };
680
+ var SL = { data: null, at: 0, inflight: null, waiting: null, failed: false };
423
681
  function sessionList(maxAgeMs) {
424
682
  if (SL.data && Date.now() - SL.at < (maxAgeMs || 0)) return Promise.resolve(SL.data);
425
683
  if (SL.inflight) return SL.inflight;
426
684
  SL.inflight = api('/api/agent-sessions?limit=80&sort=updated').then(function (d) {
685
+ // 7.0.5: a sessions read (Sessions pane or Today) that follows a failed one is a reconnect: Today's next summary
686
+ // ask says so (requestBrief), once.
687
+ if (SL.failed) { SL.failed = false; if (typeof BRIEF === 'object' && BRIEF) BRIEF.reconnect = true; }
427
688
  SL.data = d; SL.at = Date.now(); SL.inflight = null;
689
+ // 7.0.1: a fresh list is corrected by the live model at once, so it never paints a state the stream already moved.
690
+ // Only while a stream is open and live (review W1): reconnecting, busy or hidden, the list is the newest truth.
691
+ if (typeof liveCanOverlay === 'function' && d && Array.isArray(d.sessions)) {
692
+ if (liveCanOverlay()) liveOverlay(d.sessions, { placeholders: true, unknown: true });
693
+ else if (liveOn()) LIVE.freshAt = SL.at;
694
+ }
428
695
  noteWaiting(d);
429
696
  return d;
430
- }, function (err) { SL.inflight = null; throw err; });
697
+ }, function (err) { SL.inflight = null; SL.failed = true; throw err; });
431
698
  return SL.inflight;
432
699
  }
433
700
  function sessionListCached() { return SL.data ? { data: SL.data, at: SL.at } : null; }
@@ -449,6 +716,438 @@
449
716
  if (fresh) questionsNudge();
450
717
  }
451
718
 
719
+ // ── live state (7.0.1) ──────────────────────────────────
720
+ // Miles 11/07: the pet, the session rows, Today's counts and the running clocks showed the last fetch, not now. One
721
+ // EventSource('/api/events') per page (docs/live-events-contract.md) tells the page when the server's own session
722
+ // state moves: ids, states, counts and times only. The list (/api/agent-sessions) still carries every word; the
723
+ // stream corrects its rows in place and asks for a fresh list when a session appears, leaves or replies.
724
+ // mode 'idle' not started (signed out)
725
+ // 'connecting' a stream is opening; nothing painted differently yet
726
+ // 'live' a frame arrived inside the last 30 s: counts are live
727
+ // 'reconnecting' the stream dropped or went quiet: "Reconnecting", the list read every 5 s until it is back
728
+ // 'busy' the server has no room for another stream (503): retried in 30 s, no fast polling
729
+ // 'absent' no /api/events (an older server, or COS_WEB_LIVE_EVENTS=0): exactly what 7.0.0 did
730
+ // A hidden page holds no stream and runs no timer; coming back re-reads the list at once and resumes from the last
731
+ // id (`?after=<epoch>.<seq>`, the contract's manual resume, since new EventSource cannot set Last-Event-ID).
732
+ var LIVE_SILENT_MS = 30000, LIVE_POLL_MS = 5000, LIVE_WATCH_MS = 5000, LIVE_RETRY_MS = 3000, LIVE_RETRY_MAX_MS = 30000;
733
+ 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;
734
+ var LIVE = {
735
+ mode: 'idle', es: null, lastId: '', epoch: null, seq: -1, frameAt: 0, skew: 0, gotFrame: false,
736
+ sessions: {}, // key (provider:sessionid, lowercase) -> LiveSession
737
+ questions: null, queue: null, degraded: {},
738
+ freshAt: 0, // when the counts on screen were last confirmed: a frame on a live stream, or a list read
739
+ retryMs: LIVE_RETRY_MS, retry: null, poll: null, watch: null, listTimer: null, paintTimer: null,
740
+ absentAt: 0, hooks: [], gen: 0, pendingRows: [], pendingStructural: false, listAt: 0, listDue: 0, noteSaid: null
741
+ };
742
+ function liveOnMode(m) { return m === 'live' || m === 'reconnecting' || m === 'busy'; }
743
+ function liveOn() { return liveOnMode(LIVE.mode); }
744
+ /** The live model corrects list rows only while a stream is open and live. Reconnecting, busy or hidden, the list
745
+ * read is newer than the model (review W1). The model is kept only as the base a resume applies to. */
746
+ function liveCanOverlay() { return LIVE.mode === 'live' && !!LIVE.es && LIVE.gotFrame; }
747
+ function liveKey(provider, id) { return String(provider || 'claude').toLowerCase() + ':' + String(id || '').toLowerCase(); }
748
+ function liveRowKey(s) { var k = splitSessionId(s.provider, s.session_id || s.id); return liveKey(k.provider, k.id); }
749
+ // The contract's join (review B1): the full id, lowercase. The 8-character prefix joins only when one side is
750
+ // exactly 8 characters (the list may carry a Claude tab under the registry's short id until it finds the
751
+ // transcript) and only one candidate matches, the rule countFor uses on the server. Codex ids are UUIDv7: their
752
+ // first 8 characters are a timestamp and stay the same for about 65 s, so a prefix alone joins parallel sessions.
753
+ function liveSame(a, b) {
754
+ if (a === b) return true;
755
+ var ia = a.slice(a.indexOf(':') + 1), ib = b.slice(b.indexOf(':') + 1);
756
+ if (a.slice(0, a.length - ia.length) !== b.slice(0, b.length - ib.length)) return false;
757
+ // One side must BE the other's first 8 characters, so it is exactly 8 long: two full ids never join here.
758
+ return ia.length === 8 ? ib.slice(0, 8) === ia : ia.slice(0, 8) === ib;
759
+ }
760
+ /** The one key in `keys` this key joins, or '' (none, or more than one by prefix). */
761
+ function liveMatch(key, keys) {
762
+ if (keys.indexOf(key) >= 0) return key;
763
+ var hits = keys.filter(function (k) { return liveSame(key, k); });
764
+ return hits.length === 1 ? hits[0] : '';
765
+ }
766
+ function liveIso(ms) { return typeof ms === 'number' && isFinite(ms) ? new Date(ms).toISOString() : null; }
767
+ /** The list arrays on this page (usually one array, shared): every row a live frame must correct. */
768
+ function liveLists() {
769
+ var out = [];
770
+ [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); });
771
+ return out;
772
+ }
773
+ /** Write one LiveSession onto a list row, in the list's own fields (sxState, running_active, running). */
774
+ // Claude: the registry and the hooks know whether the tab is open, so the stream decides open or closed too. Codex
775
+ // and Cursor (review W2): the stream's "open" is only a 30-minute hook window, while the list reads the process
776
+ // itself, so the list decides open or closed and alive; the stream moves working, waiting or idle and the times.
777
+ function liveOwnsOpen(provider) { return String(provider || 'claude').toLowerCase() === 'claude'; }
778
+ function liveWrite(row, ls) {
779
+ var st = ls.state;
780
+ row._live = true;
781
+ row._liveTurnAt = (st === 'working' || st === 'waiting') && typeof ls.turnStartedAt === 'number' ? ls.turnStartedAt : null;
782
+ row.state_source = ls.source === 'registry' ? 'registry' : 'hook';
783
+ row.running_active = st === 'working';
784
+ if (liveOwnsOpen(ls.provider)) {
785
+ row.running = st !== 'ended';
786
+ row.alive = st !== 'ended';
787
+ row.agent_state = st === 'working' ? 'running' : st === 'waiting' ? 'waiting' : st === 'ended' ? 'ended' : ls.failed ? 'failed' : 'idle';
788
+ } else if (st === 'working' || st === 'waiting') {
789
+ row.agent_state = st === 'working' ? 'running' : 'waiting';
790
+ } else if (row.agent_state === 'running' || row.agent_state === 'waiting' || row.agent_state === 'idle' || row.agent_state === 'failed' || !row.agent_state) {
791
+ row.agent_state = row.running === false ? 'ended' : ls.failed && st !== 'ended' ? 'failed' : 'idle';
792
+ }
793
+ if (row._liveTurnAt != null) row.state_since = liveIso(row._liveTurnAt);
794
+ else if (typeof ls.updatedAt === 'number' && row._liveState !== st) row.state_since = liveIso(ls.updatedAt);
795
+ if (typeof ls.updatedAt === 'number') row.last_activity_at = liveIso(ls.updatedAt);
796
+ if (typeof ls.queued === 'number') row.queued_turns = ls.queued;
797
+ row._liveState = st;
798
+ }
799
+ /** A session the stream says left the live model: no longer working or open (it may stay in the list as recent). */
800
+ function liveForget(row) {
801
+ row._live = false; row._liveTurnAt = null; row._liveState = '';
802
+ row.running_active = false;
803
+ if (!liveOwnsOpen(splitSessionId(row.provider, row.session_id || row.id).provider)) {
804
+ // Codex and Cursor: still open or closed as the list says; only no longer working or waiting.
805
+ if (row.agent_state === 'running' || row.agent_state === 'waiting') row.agent_state = row.running === false ? 'ended' : 'idle';
806
+ return;
807
+ }
808
+ row.running = false;
809
+ if (row.agent_state === 'running' || row.agent_state === 'waiting' || row.agent_state === 'idle') row.agent_state = 'ended';
810
+ }
811
+ /** Overlay the live model on the list rows. Returns what moved: { changed: [rows], added: n, unknown: n }. */
812
+ function liveOverlay(rows, opts) {
813
+ var o = opts || {}, out = { changed: [], added: 0, unknown: [] }, byJoin = {};
814
+ Object.keys(LIVE.sessions).forEach(function (k) { var ls = LIVE.sessions[k]; byJoin[liveKey(ls.provider, ls.sessionId)] = ls; });
815
+ var keys = Object.keys(byJoin), seen = {};
816
+ (rows || []).forEach(function (row) {
817
+ if (!row) return;
818
+ var j = liveMatch(liveRowKey(row), keys), ls = j ? byJoin[j] : null;
819
+ 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); }
820
+ // A row this page corrected before, now missing from a whole snapshot: it left. Never a row the stream never
821
+ // spoke for (a Codex or Cursor session whose hooks never reach the server stays as the list says), and never
822
+ // while the server says it could not read its registry (degraded: absence is not "nothing running").
823
+ else if (o.whole && row._live && !LIVE.degraded.registry) { liveForget(row); out.changed.push(row); }
824
+ });
825
+ // A session the list has not caught up with yet: a placeholder row while it works or waits, so the pet and the
826
+ // Running count move now, and a fresh list (debounced) brings its title.
827
+ if (o.placeholders || o.unknown) {
828
+ Object.keys(byJoin).forEach(function (j) {
829
+ if (seen[j]) return;
830
+ var ls = byJoin[j];
831
+ out.unknown.push(j);
832
+ if (!o.placeholders || (ls.state !== 'working' && ls.state !== 'waiting')) return;
833
+ var row = { provider: ls.provider, session_id: ls.sessionId, id: ls.sessionId, title: 'New ' + providerLabel(ls.provider) + ' session', _placeholder: true, running: true, alive: true };
834
+ liveWrite(row, ls);
835
+ rows.unshift(row); out.added++; out.changed.push(row);
836
+ });
837
+ }
838
+ return out;
839
+ }
840
+ /** Apply the live model to every list on the page, then repaint what shows. */
841
+ function liveApply(whole) {
842
+ if (!liveCanOverlay()) return { changed: 0, added: 0, unknown: 0 };
843
+ var lists = liveLists(), changed = [], added = 0, unknown = [];
844
+ lists.forEach(function (rows, i) {
845
+ var r = liveOverlay(rows, { whole: whole, placeholders: i === 0, unknown: i === 0 });
846
+ changed = changed.concat(r.changed); added += r.added; if (i === 0) unknown = r.unknown;
847
+ });
848
+ // A session the list has not shown yet: the snapshot and each new session already ask for one fresh list
849
+ // (liveListSoon in liveFrame), so nothing more here.
850
+ if (changed.length || added) livePaint(changed, added > 0);
851
+ return { changed: changed.length, added: added, unknown: unknown.length };
852
+ }
853
+ function liveRunHooks(ev) { LIVE.hooks.slice().forEach(function (fn) { try { fn(ev); } catch (err) { console.error(err); } }); }
854
+ // One frame. Returns false when the stream must be reopened (a gap or a server restart).
855
+ function liveFrame(f) {
856
+ if (!f || typeof f !== 'object' || typeof f.kind !== 'string') return true;
857
+ var now = Date.now(), couldOverlay = liveCanOverlay();
858
+ LIVE.frameAt = now;
859
+ if (typeof f.at === 'number' && isFinite(f.at)) LIVE.skew = now - f.at;
860
+ var stateFrame = f.kind === 'snapshot' || f.kind === 'session' || f.kind === 'session.removed' || f.kind === 'questions' || f.kind === 'queue';
861
+ if (f.kind === 'snapshot') {
862
+ LIVE.epoch = f.epoch; LIVE.seq = typeof f.seq === 'number' ? f.seq : -1;
863
+ LIVE.sessions = {};
864
+ (Array.isArray(f.sessions) ? f.sessions : []).forEach(function (ls) { if (ls && ls.sessionId) LIVE.sessions[liveKey(ls.provider, ls.sessionId)] = ls; });
865
+ LIVE.questions = f.questions || null; LIVE.queue = f.queue || null;
866
+ LIVE.degraded = f.degraded && typeof f.degraded === 'object' ? f.degraded : {};
867
+ } else if (typeof f.seq === 'number') {
868
+ if (LIVE.epoch !== null && f.epoch != null && f.epoch !== LIVE.epoch) return false; // a new server life
869
+ if (stateFrame ? f.seq > LIVE.seq + 1 : f.seq > LIVE.seq) return false; // missed a frame
870
+ if (stateFrame && f.seq <= LIVE.seq) return true; // already applied
871
+ }
872
+ if (stateFrame && typeof f.seq === 'number') { LIVE.seq = f.seq; if (f.epoch != null) LIVE.epoch = f.epoch; LIVE.lastId = LIVE.epoch + '.' + LIVE.seq; }
873
+ if (!LIVE.gotFrame) { LIVE.gotFrame = true; LIVE.retryMs = LIVE_RETRY_MS; }
874
+ var wasMode = LIVE.mode;
875
+ LIVE.mode = 'live';
876
+ if (!LIVE.degraded.registry) LIVE.freshAt = now;
877
+ liveStopPoll();
878
+ // The first frame on a (re)opened stream, after a list read won (Reconnecting, busy, or hidden and back): the
879
+ // resumed model corrects the rows again, including a resume that replayed nothing.
880
+ if (!couldOverlay && f.kind !== 'snapshot') liveApply(false);
881
+ if (f.kind === 'snapshot') {
882
+ liveApply(true);
883
+ liveListSoon();
884
+ questionsNudge();
885
+ liveRunHooks({ kind: 'snapshot' });
886
+ } else if (f.kind === 'session' && f.session && f.session.sessionId) {
887
+ var k = liveKey(f.session.provider, f.session.sessionId), was = LIVE.sessions[k];
888
+ LIVE.sessions[k] = f.session;
889
+ var r = liveApply(false);
890
+ // New: the words come from the list now. A reply landed (`replies` went up): the words come from the list, at
891
+ // most one read per LIVE_REPLY_LIST_GAP_MS (ghost W7: dozens of sessions ending turns would poll the list).
892
+ if (f.change === 'added' || !was) liveListSoon();
893
+ else if (typeof f.session.replies === 'number' && f.session.replies > (was.replies || 0)) liveListSoon(LIVE_REPLY_LIST_GAP_MS);
894
+ if (f.session.state === 'waiting' && (!was || was.state !== 'waiting')) questionsNudge();
895
+ liveRunHooks({ kind: 'session', key: k, session: f.session, previous: was || null, moved: r.changed });
896
+ } else if (f.kind === 'session.removed') {
897
+ var j = liveKey(f.provider, f.sessionId), gone = [];
898
+ delete LIVE.sessions[j];
899
+ var others = Object.keys(LIVE.sessions).map(function (k) { return liveKey(LIVE.sessions[k].provider, LIVE.sessions[k].sessionId); });
900
+ liveLists().forEach(function (rows) {
901
+ for (var i = rows.length - 1; i >= 0; i--) {
902
+ var row = rows[i]; if (!row) continue;
903
+ var rk = liveRowKey(row);
904
+ // This row is the removed session's (by the same join), and no remaining live session claims it.
905
+ if (liveMatch(rk, others.concat([j])) !== j) continue;
906
+ if (row._placeholder) { rows.splice(i, 1); gone.push(row); } else if (row._live) { liveForget(row); gone.push(row); }
907
+ }
908
+ });
909
+ if (gone.length) livePaint(gone, true);
910
+ liveListSoon();
911
+ liveRunHooks({ kind: 'session.removed', key: liveKey(f.provider, f.sessionId) });
912
+ } else if (f.kind === 'questions') {
913
+ LIVE.questions = { total: f.total, approvals: f.approvals, bySession: f.bySession };
914
+ questionsNudge(); // the cards come from /api/session-questions (questions.js keeps its own poll as the fallback)
915
+ liveRunHooks({ kind: 'questions' });
916
+ } else if (f.kind === 'queue') {
917
+ LIVE.queue = { total: f.total, bySession: f.bySession };
918
+ liveRunHooks({ kind: 'queue' });
919
+ }
920
+ if (wasMode !== 'live') livePaint([], true);
921
+ return true;
922
+ }
923
+ function liveUrl() { return '/api/events' + (LIVE.lastId ? '?after=' + encodeURIComponent(LIVE.lastId) : ''); }
924
+ function liveVisible() { return !document.hidden; }
925
+ function liveSignedIn() { var sh = $('app-shell'); return !!(sh && !sh.classList.contains('hidden')); }
926
+ /** Open the one stream (never a second while one is open). */
927
+ function liveOpen() {
928
+ clearTimeout(LIVE.retry); LIVE.retry = null;
929
+ if (LIVE.es || !liveVisible() || !liveSignedIn()) return;
930
+ if (typeof EventSource !== 'function') { liveSetMode('absent'); return; }
931
+ var es, gen = ++LIVE.gen;
932
+ try { es = new EventSource(liveUrl()); } catch (_e) { liveSetMode('absent'); return; }
933
+ LIVE.es = es; LIVE.gotFrame = false;
934
+ if (LIVE.mode !== 'live' && LIVE.mode !== 'reconnecting' && LIVE.mode !== 'busy') LIVE.mode = 'connecting';
935
+ LIVE.frameAt = Date.now();
936
+ es.onmessage = function (ev) {
937
+ if (LIVE.es !== es || gen !== LIVE.gen) return;
938
+ var f; try { f = JSON.parse(ev.data); } catch (_e) { return; }
939
+ if (liveFrame(f) === false) {
940
+ // A gap or a new server life: reopen. A gap resumes from the last applied id; a new life starts fresh.
941
+ if (f && f.epoch != null && LIVE.epoch !== null && f.epoch !== LIVE.epoch) { LIVE.lastId = ''; LIVE.epoch = null; LIVE.seq = -1; }
942
+ liveClose(); liveOpen();
943
+ }
944
+ };
945
+ es.onerror = function () {
946
+ if (LIVE.es !== es || gen !== LIVE.gen) return;
947
+ if (!LIVE.gotFrame) {
948
+ // Never got a frame: EventSource hides the status, so one GET reads it (the readers' fallback-status pattern).
949
+ liveClose();
950
+ liveProbe(gen);
951
+ return;
952
+ }
953
+ // The stream had been live. The browser reconnects on its own with Last-Event-ID while readyState is 0; a closed
954
+ // stream (2) is reopened here with ?after. Either way the page says so and reads the list until a frame lands.
955
+ LIVE.gotFrame = false;
956
+ if (es.readyState === 2) { liveClose(); liveReconnect(); }
957
+ else liveSetMode('reconnecting');
958
+ };
959
+ liveStartWatch();
960
+ }
961
+ function liveClose() {
962
+ if (LIVE.es) { try { LIVE.es.close(); } catch (_e) {} }
963
+ LIVE.es = null; LIVE.gen++;
964
+ }
965
+ // What the route answered, read once without signing the page out: 404 (no route, or the switch off) and 401 or 403
966
+ // mean no live stream here, so the page does exactly what 7.0.0 did. 503 is a full server. Anything else (a
967
+ // network failure, a 200 that dropped) is a stream to reopen.
968
+ function liveProbe(gen) {
969
+ var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
970
+ var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, 10000) : null;
971
+ fetch(liveUrl(), { headers: { 'X-COS-Web': '1', Accept: 'text/event-stream' }, credentials: 'same-origin', signal: ctrl ? ctrl.signal : undefined })
972
+ .then(function (res) {
973
+ var code = res.status, type = '';
974
+ try { type = String((res.headers && res.headers.get && res.headers.get('content-type')) || ''); } catch (_e) { type = ''; }
975
+ if (ctrl) ctrl.abort();
976
+ return { code: code, stream: type.indexOf('text/event-stream') === 0 };
977
+ }, function () { return { code: 0, stream: false }; })
978
+ .then(function (r) {
979
+ if (timer) clearTimeout(timer);
980
+ if (LIVE.es || LIVE.mode === 'idle') return;
981
+ if (r.code === 503) { liveSetMode('busy'); LIVE.retry = setTimeout(liveOpen, LIVE_BUSY_MS); return; }
982
+ if (r.code === 0 || (r.code === 200 && r.stream)) { liveReconnect(); return; }
983
+ // 404, 401, 403, any other status, or a 200 that is not a stream: polling as today.
984
+ liveSetMode('absent');
985
+ });
986
+ }
987
+ function liveReconnect() {
988
+ liveSetMode('reconnecting');
989
+ clearTimeout(LIVE.retry);
990
+ var wait = LIVE.retryMs;
991
+ LIVE.retryMs = Math.min(LIVE.retryMs * 2, LIVE_RETRY_MAX_MS);
992
+ LIVE.retry = setTimeout(liveOpen, wait);
993
+ }
994
+ function liveSetMode(m) {
995
+ var was = LIVE.mode;
996
+ LIVE.mode = m;
997
+ if (m === 'absent') {
998
+ // Exactly 7.0.0: no stream, no extra timer, no label. Rows this page corrected keep their last values until the
999
+ // next list read replaces them, as 7.0.0's rows did.
1000
+ liveClose(); liveStopPoll(); liveStopWatch(); clearTimeout(LIVE.retry); LIVE.retry = null;
1001
+ LIVE.absentAt = Date.now(); LIVE.sessions = {};
1002
+ }
1003
+ if (m === 'reconnecting') liveStartPoll(); else if (m !== 'live') liveStopPoll();
1004
+ if (was === 'live' && m !== 'live') liveLetGo();
1005
+ if (was !== m && (liveOnMode(was) || liveOnMode(m))) livePaint([], true);
1006
+ }
1007
+ /** Leaving live (review W1): placeholder rows go, and no row keeps a live mark the next list read would not
1008
+ * replace. The model stays only as the base a resume applies to; nothing overlays it until a frame lands. */
1009
+ function liveLetGo() {
1010
+ liveLists().forEach(function (rows) {
1011
+ for (var i = rows.length - 1; i >= 0; i--) {
1012
+ var row = rows[i]; if (!row) continue;
1013
+ if (row._placeholder) rows.splice(i, 1); else if (row._live) { row._live = false; row._liveTurnAt = null; row._liveState = ''; }
1014
+ }
1015
+ });
1016
+ }
1017
+ // ── the fallbacks: a watchdog for a quiet stream, and the 5 s list read while reconnecting ──
1018
+ function liveStartWatch() {
1019
+ if (LIVE.watch) return;
1020
+ LIVE.watch = setInterval(function () {
1021
+ if (!liveVisible()) return;
1022
+ if ((LIVE.mode === 'live' || LIVE.mode === 'connecting' || LIVE.mode === 'reconnecting') && LIVE.es && Date.now() - LIVE.frameAt > LIVE_SILENT_MS) {
1023
+ // No frame of any kind for 30 s (the heartbeat is every 15 s): the stream is dead whatever readyState says.
1024
+ liveClose(); liveReconnect();
1025
+ }
1026
+ if (liveOn() || LIVE.mode === 'connecting') liveNoteTick();
1027
+ }, LIVE_WATCH_MS);
1028
+ }
1029
+ function liveStopWatch() { clearInterval(LIVE.watch); LIVE.watch = null; }
1030
+ function liveStartPoll() {
1031
+ if (LIVE.poll) return;
1032
+ LIVE.poll = setInterval(function () { if (liveVisible() && LIVE.mode === 'reconnecting') livePollOnce(); }, LIVE_POLL_MS);
1033
+ }
1034
+ function liveStopPoll() { clearInterval(LIVE.poll); LIVE.poll = null; }
1035
+ /** Read the list now, and swap it in on every pane that shows it. Resolves when it landed (or failed). */
1036
+ function livePollOnce() {
1037
+ var before = SL.data && SL.data.sessions;
1038
+ LIVE.listAt = Date.now();
1039
+ var keysOf = function (a) { return (a || []).map(function (s) { return s ? liveRowKey(s) : ''; }).join(','); };
1040
+ return sessionList(0).then(function (d) {
1041
+ var rows = (d && d.sessions) || [];
1042
+ var moved = keysOf(before) !== keysOf(rows);
1043
+ if (state.sessions === before || !state.sessions || !state.sessions.length) state.sessions = rows;
1044
+ if (typeof SX !== 'undefined' && SX && SX.rows === before) { SX.rows = rows; SX.loadedAt = Date.now(); }
1045
+ LIVE.freshAt = Date.now();
1046
+ if (liveOn()) livePaint(rows, moved);
1047
+ return true;
1048
+ }, function () { if (liveOn()) livePaint([], false); return false; });
1049
+ }
1050
+ /** A fresh list soon (debounced), for the words of a session that appeared, left or replied. With `gapMs` (a reply),
1051
+ * no sooner than gapMs after the last list read, and never later than a read already due. */
1052
+ function liveListSoon(gapMs) {
1053
+ var now = Date.now(), due = now + LIVE_LIST_DEBOUNCE_MS;
1054
+ if (gapMs) {
1055
+ if (LIVE.listTimer) return; // a read is already due; it brings this reply's words too
1056
+ due = Math.max(due, (LIVE.listAt || 0) + gapMs);
1057
+ }
1058
+ clearTimeout(LIVE.listTimer);
1059
+ LIVE.listDue = due;
1060
+ LIVE.listTimer = setTimeout(function () { LIVE.listTimer = null; if (liveVisible()) livePollOnce(); }, due - now);
1061
+ }
1062
+ // ── coming back: visible, back from the back-forward cache, back online ──
1063
+ function liveResume() {
1064
+ if (!liveSignedIn()) return;
1065
+ var wasOn = liveOn();
1066
+ if (LIVE.mode === 'absent') {
1067
+ // 7.0.0 behaviour; look again only after a long while (a server updated or switched on since). That look is one
1068
+ // stream attempt, nothing else: the panes read what 7.0.0 read.
1069
+ if (Date.now() - LIVE.absentAt < LIVE_ABSENT_RECHECK_MS) return;
1070
+ LIVE.mode = 'connecting';
1071
+ }
1072
+ if (LIVE.mode === 'idle') return;
1073
+ if (wasOn) livePollOnce();
1074
+ if (!LIVE.es) { clearTimeout(LIVE.retry); LIVE.retry = null; LIVE.retryMs = LIVE_RETRY_MS; liveOpen(); }
1075
+ liveStartWatch();
1076
+ liveTickStart(); // stops itself at once when nothing on screen ticks
1077
+ liveNoteTick();
1078
+ }
1079
+ function liveHide() {
1080
+ // A hidden page holds no stream (a locked phone drops it anyway) and runs no live timer: not the stream, the list
1081
+ // read, the retry, the watchdog or the 1 s clock (ghost note 1).
1082
+ liveClose(); liveStopPoll(); liveStopWatch(); liveTickStop(); clearTimeout(LIVE.retry); LIVE.retry = null; clearTimeout(LIVE.listTimer); LIVE.listTimer = null;
1083
+ }
1084
+ function liveStart() {
1085
+ if (LIVE.mode !== 'idle') return;
1086
+ LIVE.mode = 'connecting';
1087
+ liveOpen();
1088
+ }
1089
+ // ── honesty: how old the counts on screen are ──
1090
+ /** "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
1091
+ * at all (7.0.0 showed none). */
1092
+ function liveAge(now) {
1093
+ if (LIVE.mode === 'idle' || LIVE.mode === 'absent' || LIVE.mode === 'connecting') return '';
1094
+ var at = Math.max(LIVE.freshAt || 0, LIVE.mode === 'live' && !LIVE.degraded.registry ? LIVE.frameAt : 0);
1095
+ if (!at) return '';
1096
+ var s = Math.max(0, ((now || Date.now()) - at) / 1000);
1097
+ if (s * 1000 < LIVE_STALE_MS) return '';
1098
+ if (s < 60) return 'As of ' + Math.floor(s) + ' s ago';
1099
+ if (s < 3600) return 'As of ' + Math.floor(s / 60) + ' min ago';
1100
+ return 'As of ' + Math.floor(s / 3600) + ' h ago';
1101
+ }
1102
+ /** The running clock: "12s", "4m 12s", "1h 5m". */
1103
+ function liveClock(sec) {
1104
+ var s = Math.max(0, Math.floor(sec));
1105
+ if (s < 60) return s + 's';
1106
+ if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
1107
+ return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
1108
+ }
1109
+ /** Seconds a working row has run: from the stream's turnStartedAt (skew from the frame's `at`), else the row's own
1110
+ * state_since on the Mac's clock. */
1111
+ function liveElapsed(row, now) {
1112
+ var t = now || Date.now();
1113
+ if (row && typeof row._liveTurnAt === 'number') return (t - LIVE.skew - row._liveTurnAt) / 1000;
1114
+ var d = row ? toDate(row.state_since || row.last_activity_at) : null;
1115
+ return d ? (sxServerTime(t) - d.getTime()) / 1000 : 0;
1116
+ }
1117
+ /** Server ms a row's clock counts from, for the data attribute the 1 s tick reads. */
1118
+ function liveSince(row) {
1119
+ if (row && typeof row._liveTurnAt === 'number') return row._liveTurnAt;
1120
+ var d = row ? toDate(row.state_since || row.last_activity_at) : null;
1121
+ return d ? d.getTime() - SX_CLOCK.skew - LIVE.skew : null;
1122
+ }
1123
+ // ── the 1 s tick: every running clock on screen, never on a hidden page ──
1124
+ var LIVE_TICK = { timer: null };
1125
+ function liveTickStart() {
1126
+ if (LIVE_TICK.timer) return;
1127
+ LIVE_TICK.timer = setInterval(liveTickOnce, 1000);
1128
+ }
1129
+ function liveTickStop() { clearInterval(LIVE_TICK.timer); LIVE_TICK.timer = null; }
1130
+ function liveTickOnce() {
1131
+ if (document.hidden) return; // paused while hidden; visible again paints at once (liveResume, liveNoteTick)
1132
+ var els = document.querySelectorAll('[data-live-since]'), now = Date.now();
1133
+ if (!els.length && !liveNoteBusy()) { liveTickStop(); return; }
1134
+ for (var i = 0; i < els.length; i++) {
1135
+ var since = Number(els[i].getAttribute('data-live-since'));
1136
+ if (isFinite(since)) els[i].textContent = liveClock((now - LIVE.skew - since) / 1000);
1137
+ }
1138
+ liveNoteTick();
1139
+ }
1140
+ // Repaints are batched: a burst of frames paints once.
1141
+ function livePaint(rows, structural) {
1142
+ LIVE.pendingRows = (LIVE.pendingRows || []).concat(rows || []);
1143
+ LIVE.pendingStructural = LIVE.pendingStructural || !!structural;
1144
+ if (LIVE.paintTimer) return;
1145
+ LIVE.paintTimer = setTimeout(function () {
1146
+ var r = LIVE.pendingRows || [], st = LIVE.pendingStructural;
1147
+ LIVE.paintTimer = null; LIVE.pendingRows = []; LIVE.pendingStructural = false;
1148
+ try { liveRepaint(r, st); } catch (err) { console.error(err); }
1149
+ }, 30);
1150
+ }
452
1151
  // ── the session pet (Today) ─────────────────────────────
453
1152
  // Same pet and the same pose rule as COS Control (PetSpritePose.resolve): waiting with nothing running
454
1153
  // is "waiting", then one, two, three, four or more sessions in play are working, duel, trio, swarm, an open
@@ -665,28 +1364,32 @@
665
1364
  }
666
1365
 
667
1366
  // ── Today ───────────────────────────────────────────────
668
- function loadToday() {
1367
+ // force (7.0.1, Today's Refresh): every source read again now, none from a cache. Resolves when the sessions, Work,
1368
+ // welcome and recording reads have painted.
1369
+ function loadToday(force) {
669
1370
  var now = new Date();
670
1371
  $('today-date').textContent = now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) + ', ' + clock(now);
671
1372
  var gen = ++TODAY.gen;
672
1373
  // Voices and memories answer on their own time (the memories status runs a CLI on the Mac): Today paints what it
673
1374
  // has and says it is still reading, then repaints as each one lands.
674
- if (Date.now() - TODAY.extraAt > 60000 || TODAY.voices.state === 'failed' || TODAY.memories.state === 'failed') {
1375
+ var extras = [];
1376
+ if (force === true || Date.now() - TODAY.extraAt > 60000 || TODAY.voices.state === 'failed' || TODAY.memories.state === 'failed') {
675
1377
  TODAY.extraAt = Date.now();
676
1378
  TODAY.voices = { state: 'pending', groups: [] }; TODAY.memories = { state: 'pending', n: 0 };
677
- api('/api/voice/held-groups').then(function (d) {
1379
+ extras.push(api('/api/voice/held-groups').then(function (d) {
678
1380
  TODAY.voices = { state: 'answered', groups: (d && Array.isArray(d.groups)) ? d.groups : [] };
679
- }, function () { TODAY.voices = { state: 'failed', groups: [] }; }).then(function () { paintNeeds(); });
680
- api('/api/web-bridge/memories', { method: 'POST', body: { id: 'today-status', op: 'status', args: {} } }).then(function (d) {
1381
+ }, function () { TODAY.voices = { state: 'failed', groups: [] }; }).then(function () { paintNeeds(); }));
1382
+ extras.push(api('/api/web-bridge/memories', { method: 'POST', body: { id: 'today-status', op: 'status', args: {} } }).then(function (d) {
681
1383
  var n = d && d.details ? d.details.learningToReview : null;
682
1384
  TODAY.memories = typeof n === 'number' ? { state: 'answered', n: Math.max(0, n) } : { state: 'failed', n: 0 };
683
- }, function () { TODAY.memories = { state: 'failed', n: 0 }; }).then(function () { paintNeeds(); });
1385
+ }, function () { TODAY.memories = { state: 'failed', n: 0 }; }).then(function () { paintNeeds(); }));
684
1386
  }
685
- Promise.all([
686
- sessionList(15000).catch(function () { return null; }),
1387
+ var main = Promise.all([
1388
+ sessionList(force === true ? 0 : 15000).catch(function () { return null; }),
687
1389
  api('/api/work-board/your-move').catch(function () { return null; }),
688
1390
  api('/api/welcome-context').catch(function () { return null; }),
689
- api('/api/meetings/live-chunks').catch(function () { return null; })
1391
+ api('/api/meetings/live-chunks').catch(function () { return null; }),
1392
+ loadRunJobs()
690
1393
  ]).then(function (res) {
691
1394
  if (gen !== TODAY.gen) return;
692
1395
  var sessions = (res[0] && res[0].sessions) || [];
@@ -698,6 +1401,9 @@
698
1401
  var open = sessions.filter(function (s) { return s.running && !s.running_active; });
699
1402
  var recent = live.filter(function (s) { return Date.now() - (s.mtimeMs || 0) < LIVE_FRESH_MS; });
700
1403
  var offline = !res[0] && !res[1];
1404
+ // 7.0.5: the sessions or Today read recovered from a failed one, so the next summary ask says "reconnect" (the
1405
+ // server lets that one start skip its 10-minute spacing, at most once per 10 minutes).
1406
+ if ((TODAY.sessionsState === 'failed' || TODAY.offline === true) && res[0] && !offline) BRIEF.reconnect = true;
701
1407
  TODAY.needs = needs; TODAY.working = working; TODAY.open = open; TODAY.welcome = welcome; TODAY.offline = offline; TODAY.at = Date.now();
702
1408
  // your-move answers {available:false} when the board cannot be read: that is a failed read, not "nothing".
703
1409
  TODAY.sessionsState = res[0] ? 'answered' : 'failed';
@@ -706,19 +1412,25 @@
706
1412
  TODAY.recording = false;
707
1413
 
708
1414
  renderRunning(working, open);
1415
+ runJobsSchedule();
709
1416
  if (!offline) renderPet(sessions);
710
1417
  paintNeeds();
711
1418
  renderWeather(welcome);
712
1419
  renderNext(welcome);
1420
+ // PH plan P1: the rest of today under Next up. Kept out of renderNext so that stays a pure render.
1421
+ var nx = welcome && welcome.nextEvent;
1422
+ loadCalendarLater(nx, nx && toDate(nx.time));
713
1423
  loadTodayIntake();
714
1424
  setBadge('sessions', working.length > 0);
715
- liveSession(recent).then(function (rec) {
1425
+ return liveSession(recent).then(function (rec) {
716
1426
  if (gen !== TODAY.gen) return;
717
1427
  renderRecording(rec);
718
1428
  setBadge('meetings', !!rec);
719
1429
  if (rec) { TODAY.recording = true; paintNeeds(); }
720
1430
  });
721
1431
  });
1432
+ // A forced read waits for the voices and memories too, so the summary it asks for sees every source.
1433
+ return force === true ? Promise.all([main].concat(extras)) : main;
722
1434
  }
723
1435
  // A capture counts as live only while it is still being written: an abandoned
724
1436
  // active-sessions file must never read as "Recording now".
@@ -738,19 +1450,21 @@
738
1450
  var box = $('today-rec');
739
1451
  if (!rec) { box.innerHTML = ''; return; }
740
1452
  box.innerHTML = '<button type="button" class="rec-banner" data-goto="meetings"><span class="rec-dot"></span>' +
741
- '<span class="rec-copy"><strong>Recording now</strong><span>' + esc(rec.source + (rec.startTime ? ', ' + span(rec.startTime) + ' so far' : '')) + '</span></span>' +
1453
+ '<span class="rec-copy"><strong>Recording now</strong><span>' + esc(rec.source + (rec.startTime ? ', ' + span(rec.startTime) + ' so far' : '') + (rec.source === SOURCES.mac ? '. Stop it in COS Control on your Mac.' : '')) + '</span></span>' +
742
1454
  '<span class="btn small">Live view</span></button>';
743
1455
  }
744
1456
  // ── The Today summary (6.99.26 candidate) ───────────────
745
- // "The orchestrator's eyes" (Miles, 11/07): two or three sentences on what the sessions add up to, written by Haiku on
1457
+ // "The orchestrator's eyes" (Miles, 11/07): two or three sentences on what the sessions add up to, written by Sonnet on
746
1458
  // the server (lib/today-brief.ts). Today sends what it already read: session titles, states, how long, each session's
747
1459
  // latest line and what needs you. The server decides whether a new one is written (off unless switched on, only when
748
- // the inputs changed, 10 minutes apart, 60 a day, a breaker). Today never waits for it: the counts line paints at once
749
- // and the summary replaces it when one arrives. Never shown over a session list that was not read.
750
- var BRIEF = { view: null, inflight: false, sentKey: '', sentAt: 0 };
1460
+ // the inputs changed, 10 minutes apart, the Summaries per day cap, a breaker). Today never waits for it: the counts line paints at once
1461
+ // and the summary replaces it when one arrives. T1 spec (lead 23:04, same rule as Control 0.5.388): today's summary
1462
+ // stays through a pending or failed session read (its age line already says what could not be read), the block keeps
1463
+ // its tallest height so nothing under it moves while Miles reads, and unchanged text is never written again.
1464
+ var BRIEF = { view: null, inflight: false, sentKey: '', sentAt: 0, reconnect: false, heldPx: 0, heldDay: '', heldWidth: -1, shownKind: '', shownText: '', shownEl: null };
751
1465
  // 6.99.26 QA: the same rows are asked about again only after five minutes (a Today that sits open still learns what
752
1466
  // the server reads itself: sub-agents and Work cards); changed rows are asked about at once, one ask at a time.
753
- var BRIEF_RESEND_MS = 5 * 60000, BRIEF_OFF_RECHECK_MS = 5 * 60000, BRIEF_TICK_MS = 60000;
1467
+ var BRIEF_RESEND_MS = 5 * 60000, BRIEF_OFF_RECHECK_MS = 5 * 60000, BRIEF_TICK_MS = 60000, BRIEF_LIVE_GAP_MS = 60000;
754
1468
  function briefMinutes(v) { var d = toDate(v); return d ? Math.max(0, Math.round((Date.now() - d.getTime()) / 60000)) : 0; }
755
1469
  function briefInputs(m) {
756
1470
  var sessions = [];
@@ -783,23 +1497,68 @@
783
1497
  var body = briefInputs(m);
784
1498
  var key = JSON.stringify([body.sessions.map(function (s) { return [s.title, s.state, s.line, s.waiting, s.agents]; }), body.needs, body.later, body.sessionsRead, body.needsComplete, body.unread, body.recording]);
785
1499
  var since = Date.now() - BRIEF.sentAt;
786
- if (BRIEF.view && BRIEF.view.state === 'off' ? since < BRIEF_OFF_RECHECK_MS : key === BRIEF.sentKey && since < BRIEF_RESEND_MS) return;
1500
+ // 7.0.5: right after a read recovered, ask at once (the server still holds its own gates).
1501
+ var reconnect = BRIEF.reconnect && !(BRIEF.view && BRIEF.view.state === 'off');
1502
+ if (!reconnect) {
1503
+ if (BRIEF.view && BRIEF.view.state === 'off' ? since < BRIEF_OFF_RECHECK_MS : key === BRIEF.sentKey && since < BRIEF_RESEND_MS) return;
1504
+ // 7.0.1: with the live stream Today repaints on every session move, so a changed Today asks at most once a minute
1505
+ // (Refresh asks at once). Without it, 7.0.0's rule above is the only one.
1506
+ if (typeof liveOn === 'function' && liveOn() && since < BRIEF_LIVE_GAP_MS) return;
1507
+ }
1508
+ BRIEF.reconnect = false;
1509
+ if (reconnect) body.reason = 'reconnect';
787
1510
  BRIEF.inflight = true; BRIEF.sentKey = key; BRIEF.sentAt = Date.now();
788
- api('/api/today/brief', { method: 'POST', body: body, timeoutMs: 45000 }).then(function (v) {
1511
+ var ask = function (b) { return api('/api/today/brief', { method: 'POST', body: b, timeoutMs: 45000 }); };
1512
+ ask(body).catch(function (err) {
1513
+ // A server before 7.0.5 refuses the unknown `reason` (400 invalid_today_brief): ask it the ordinary way.
1514
+ if (!(reconnect && err && err.status === 400 && err.code === 'invalid_today_brief')) throw err;
1515
+ var again = {};
1516
+ Object.keys(body).forEach(function (k) { if (k !== 'reason') again[k] = body[k]; });
1517
+ return ask(again);
1518
+ }).then(function (v) {
789
1519
  BRIEF.view = v && typeof v === 'object' ? v : null;
790
1520
  }, function () { /* keep what was shown: the counts line or the last summary */ }).then(function () {
791
1521
  BRIEF.inflight = false;
792
1522
  if (TODAY.model && state.pane === 'today') renderStory(TODAY.model);
793
1523
  });
794
1524
  }
795
- /** The summary to show, or null: only with text, only over a session list that was read. */
1525
+ /** The summary to show, or null: only with text, fresh or stale, and written today. T1 spec 0: a session read that is
1526
+ * pending or failed no longer swaps it for the counts line (an offline Today still hides it, in renderStory). */
796
1527
  function briefShown() {
797
1528
  var v = BRIEF.view;
798
- if (!v || !v.text || (v.state !== 'fresh' && v.state !== 'stale') || TODAY.sessionsState !== 'answered') return null;
1529
+ if (!v || !v.text || (v.state !== 'fresh' && v.state !== 'stale')) return null;
1530
+ var at = toDate(v.at);
1531
+ if (!at || at.toDateString() !== new Date().toDateString()) return null;
799
1532
  return v;
800
1533
  }
801
- // Forget the summary (Settings turned it on or off): the next Today paint asks at once.
802
- function resetBrief() { BRIEF.view = null; BRIEF.sentKey = ''; BRIEF.sentAt = 0; }
1534
+ // Forget the summary (Settings turned it on or off): the next Today paint asks at once, and the block may shrink.
1535
+ function resetBrief() { BRIEF.view = null; BRIEF.sentKey = ''; BRIEF.sentAt = 0; BRIEF.heldPx = 0; }
1536
+ // T1 spec 1 and 2: #today-story keeps the tallest height it has had, so a shorter rewrite, or the fall back to the
1537
+ // counts line, never pulls the page up under Miles. It may shrink on a new local day, when its width changes (resize,
1538
+ // Duo fold or unfold, rotation reflow the text anyway), after Refresh lands and after resetBrief; never on a rewrite.
1539
+ function storyHold(el) {
1540
+ if (!el || !el.style || typeof el.getBoundingClientRect !== 'function') return;
1541
+ var day = new Date().toDateString(), w = el.clientWidth || 0;
1542
+ if (BRIEF.heldDay !== day || Math.abs(w - BRIEF.heldWidth) >= 1) { BRIEF.heldPx = 0; BRIEF.heldDay = day; BRIEF.heldWidth = w; }
1543
+ el.style.minHeight = '';
1544
+ var h = Math.ceil(el.getBoundingClientRect().height || 0);
1545
+ if (h > BRIEF.heldPx) BRIEF.heldPx = h;
1546
+ el.style.minHeight = BRIEF.heldPx ? BRIEF.heldPx + 'px' : '';
1547
+ }
1548
+ // T1 spec 3: Today repaints on every session move; the same words are not written again (no reflow, no aria-live
1549
+ // re-announcement). Only the age line under a summary moves on.
1550
+ function storyWrite(el, kind, text, when) {
1551
+ if (BRIEF.shownEl !== el || BRIEF.shownKind !== kind || BRIEF.shownText !== text) {
1552
+ if (kind === 'brief') el.innerHTML = esc(text) + '<span class="story-when">' + esc(when) + '</span>';
1553
+ else el.textContent = text;
1554
+ BRIEF.shownEl = el; BRIEF.shownKind = kind; BRIEF.shownText = text;
1555
+ } else if (kind === 'brief') {
1556
+ var w = typeof document !== 'undefined' && document.querySelector ? document.querySelector('#today-story .story-when') : null;
1557
+ if (w) { if (w.textContent !== when) w.textContent = when; }
1558
+ else el.innerHTML = esc(text) + '<span class="story-when">' + esc(when) + '</span>';
1559
+ }
1560
+ storyHold(el);
1561
+ }
803
1562
  // Every minute while Today is on screen: "Updated N min ago" moves on, and an unchanged Today asks again once its
804
1563
  // five minutes are up (requestBrief's own throttle). Hidden or another pane: nothing.
805
1564
  setInterval(function () {
@@ -821,13 +1580,22 @@
821
1580
  function renderStory(m) {
822
1581
  var el = $('today-story');
823
1582
  el.className = 'story';
824
- if (TODAY.offline) { el.textContent = 'COS on ' + state.serverName + ' is not answering right now. Today fills in when it does.'; return; }
1583
+ // Today round 2: "Ask about this" shows under a model summary only (paintTodayAsk, below the Today block). The test
1584
+ // harnesses that run this block alone do not have it, so it is looked up, never assumed.
1585
+ var askBox = typeof paintTodayAsk === 'function' ? paintTodayAsk : function () {};
1586
+ // The harnesses that run this block alone have no storyWrite either: a plain write there.
1587
+ var write = typeof storyWrite === 'function' ? storyWrite : function (e, kind, text, when) {
1588
+ if (kind === 'brief') e.innerHTML = esc(text) + '<span class="story-when">' + esc(when) + '</span>'; else e.textContent = text;
1589
+ };
1590
+ if (TODAY.offline) { askBox(null); write(el, 'offline', 'COS on ' + state.serverName + ' is not answering right now. Today fills in when it does.'); return; }
825
1591
  // The model's summary when there is one (it never blocks: the counts line below shows until it lands). The test
826
1592
  // harnesses that run this block alone have no requestBrief, so it is looked up, never assumed.
1593
+ askBox(null);
827
1594
  if (typeof requestBrief === 'function') {
828
1595
  requestBrief(m);
829
1596
  var brief = briefShown();
830
- if (brief) { el.innerHTML = esc(brief.text) + '<span class="story-when">' + esc(briefWhen(brief, m)) + '</span>'; return; }
1597
+ askBox(brief);
1598
+ if (brief) { write(el, 'brief', brief.text, briefWhen(brief, m)); return; }
831
1599
  }
832
1600
  var first = '';
833
1601
  if (m.sessionsWaiting > 0) first = countWord(m.sessionsWaiting) + ' ' + plural(m.sessionsWaiting, 'session is', 'sessions are') + ' waiting on you';
@@ -851,35 +1619,240 @@
851
1619
  var others = m.failed.filter(function (n) { return n !== 'sessions'; });
852
1620
  if (!m.settled && !m.now.length && !m.later.length) out += (unread ? others : m.failed).length ? ' Couldn\u2019t read ' + listWords(unread ? others : m.failed) + ', so this may not be everything.' : unread ? ' This may not be everything that needs you.' : ' Still reading what needs you.';
853
1621
  else if (unread) out += ' This may not be everything that needs you.';
854
- el.textContent = out;
1622
+ write(el, 'counts', out);
855
1623
  }
856
1624
  function listWords(names) { return names.length < 2 ? names.join('') : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1]; }
857
1625
  function runRow(s, activeNow) {
858
1626
  var key = splitSessionId(s.provider, s.session_id);
1627
+ // 7.0.1: with the live stream, a working row's clock ticks every second from the turn's start (liveTickOnce).
1628
+ // Without it (an older server, the switch off), the minute line 7.0.0 showed.
1629
+ var since = activeNow && typeof liveOn === 'function' && liveOn() ? liveSince(s) : null;
859
1630
  var meta = activeNow
860
- ? 'Working for ' + span(s.state_since || s.last_activity_at)
1631
+ ? 'Working for ' + (since != null ? '\u0000' : span(s.state_since || s.last_activity_at))
861
1632
  : 'Open, quiet for ' + span(s.last_activity_at || s.state_since);
862
1633
  if (s.project && s.project !== usualProject()) meta += ', in ' + String(s.project).replace(/^-Users-[^-]+-/, '');
1634
+ var metaHtml = since != null
1635
+ ? esc(meta).replace('\u0000', '<span class="run-clock" data-live-since="' + esc(String(since)) + '">' + esc(liveClock(liveElapsed(s))) + '</span>')
1636
+ : esc(meta);
863
1637
  // A tap opens the peek on Today (panes/today-actions.js); it falls back to the reader if the peek cannot load.
864
1638
  return '<button type="button" class="run-row" data-today="run" data-key="' + esc(key.provider) + '|' + esc(key.id) + '">' +
865
1639
  '<span class="run-who">' + badge(key.provider) + (activeNow ? '<span class="pulse" aria-label="working"></span>' : '') + '</span>' +
866
1640
  '<span class="run-title">' + esc(sessionTitle(s)) + '</span>' +
867
- '<span class="run-meta">' + esc(meta) + '</span></button>';
1641
+ '<span class="run-meta">' + metaHtml + '</span></button>';
868
1642
  }
869
1643
  function renderRunning(working, open) {
870
- $('run-count').textContent = working.length ? String(working.length) : '';
1644
+ // W1: COS's own jobs (questions it is answering, routines, Work runs) count as running too, as on the Mac.
1645
+ var jobs = typeof runJobsHtml === 'function' ? runJobsHtml() : { html: '', n: 0 };
1646
+ $('run-count').textContent = working.length + jobs.n ? String(working.length + jobs.n) : '';
871
1647
  var list = $('run-list');
872
1648
  // Never "Nothing running" about a list that was not read.
873
- if (TODAY.sessionsState === 'failed') { list.innerHTML = '<button type="button" class="run-more run-failed" data-today-needs="retry">Couldn\u2019t read sessions. Try again</button>'; return; }
1649
+ if (TODAY.sessionsState === 'failed') { list.innerHTML = jobs.html + '<button type="button" class="run-more run-failed" data-today-needs="retry">Couldn\u2019t read sessions. Try again</button>'; return; }
874
1650
  var shown = working.slice(0, 3);
875
- var html = shown.map(function (s) { return runRow(s, true); }).join('');
1651
+ var html = jobs.html + shown.map(function (s) { return runRow(s, true); }).join('');
876
1652
  if (!shown.length) {
877
- html = open.slice(0, 2).map(function (s) { return runRow(s, false); }).join('');
1653
+ html = jobs.html + open.slice(0, 2).map(function (s) { return runRow(s, false); }).join('');
878
1654
  if (!html) html = '<div class="run-empty">Nothing running. Start a session on your Mac and it shows here.</div>';
879
1655
  }
880
1656
  var more = (working.length - shown.length) + (shown.length ? open.length : Math.max(0, open.length - 2));
881
1657
  if (more > 0) html += '<button type="button" class="run-more" data-goto="sessions">' + countWord(more) + ' more open. All sessions</button>';
882
1658
  list.innerHTML = html;
1659
+ if (typeof liveTickStart === 'function' && list.querySelector && list.querySelector('[data-live-since]')) liveTickStart();
1660
+ }
1661
+ // ── Running beyond sessions (W1, 7.0.x): audit 02 F10, Q1 M6. The same job rows COS Control 0.5.386 draws
1662
+ // (Models.swift TodayHome running rows, b02d1ad 14185-14207), from the same server list, GET /api/query-jobs/active
1663
+ // (titles only, never a prompt or an answer):
1664
+ // person "A question to COS" row, "COS is answering, N min", Open in Messages
1665
+ // routine "Routine" row, "running since 7:30 PM", Open in Messages
1666
+ // task "A captured task" row, "a captured task, on the server, N min", In Sessions
1667
+ // other never a row: it is a Control Work session or a meeting review, already shown as the row it is
1668
+ // No Stop on any of them, as on the Mac: Control's only Stop is on a Work run it started itself, which lives in the Mac's
1669
+ // own handoff store and is not visible to this page.
1670
+ var RUN_JOB_ROW = {
1671
+ person: { word: 'Question', title: 'A question to COS', open: 'messages', label: 'Open in Messages' },
1672
+ routine: { word: 'Routine', title: 'Routine', open: 'messages', label: 'Open in Messages' },
1673
+ task: { word: 'Work', title: 'A captured task', open: 'sessions', label: 'In Sessions' },
1674
+ };
1675
+ var RUN_JOBS = { items: null, timer: null };
1676
+ var RUN_JOBS_POLL_MS = 15000;
1677
+ function runJobValid(j) { return j && typeof j.jobId === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(j.jobId) && RUN_JOB_ROW[j.origin]; }
1678
+ function loadRunJobs() {
1679
+ return api('/api/query-jobs/active').then(function (d) {
1680
+ // An older server answers 400 or no protocol: no jobs row, never an error line (Control keys off the field, not the status).
1681
+ RUN_JOBS.items = d && d.protocolVersion === 1 && d.enabled !== false && Array.isArray(d.items) ? d.items.filter(runJobValid).slice(0, 8) : [];
1682
+ }, function () { RUN_JOBS.items = null; });
1683
+ }
1684
+ function runJobsSchedule() {
1685
+ if (RUN_JOBS.timer) { clearTimeout(RUN_JOBS.timer); RUN_JOBS.timer = null; }
1686
+ if (!RUN_JOBS.items || !RUN_JOBS.items.length) return;
1687
+ RUN_JOBS.timer = setTimeout(function () {
1688
+ RUN_JOBS.timer = null;
1689
+ // Only while Today is in front: a hidden page reads nothing (it reads again when Today comes back).
1690
+ if (state.pane !== 'today' || document.hidden) return;
1691
+ loadRunJobs().then(function () { renderRunning(TODAY.working || [], TODAY.open || []); runJobsSchedule(); });
1692
+ }, RUN_JOBS_POLL_MS);
1693
+ }
1694
+ function runJobMinutes(j) { return Math.max(0, Math.round((Number(j.elapsedMs) || 0) / 60000)); }
1695
+ function runJobRow(j) {
1696
+ var kind = RUN_JOB_ROW[j.origin], mins = runJobMinutes(j);
1697
+ var started = new Date(Date.now() - (Number(j.elapsedMs) || 0));
1698
+ var meta = j.origin === 'routine' ? 'running since ' + clock(started)
1699
+ : j.origin === 'person' ? 'COS is answering, ' + mins + ' min'
1700
+ : 'a captured task, on the server, ' + mins + ' min';
1701
+ return '<div class="run-row run-job"><span class="run-who"><span class="badge work">' + esc(kind.word) + '</span></span>' +
1702
+ '<span class="run-title">' + esc(plain(j.title || '').trim() || kind.title) + '</span>' +
1703
+ '<span class="run-meta">' + esc(meta) + '</span>' +
1704
+ '<span class="row-actions"><button type="button" class="link-btn" data-goto="' + kind.open + '">' + esc(kind.label) + '</button></span></div>';
1705
+ }
1706
+ function runJobsHtml() {
1707
+ var items = RUN_JOBS.items || [];
1708
+ return { html: items.map(runJobRow).join(''), n: items.length };
1709
+ }
1710
+ // ── end running beyond sessions
1711
+
1712
+ // ── live state on screen (7.0.1): Today's counts and rows, the pet, the Sessions rows, the header line ──
1713
+ // The live model (above) calls this, batched, after a frame or a list read moved something.
1714
+ function liveRepaint(rows, structural) {
1715
+ if (!liveSignedIn()) return;
1716
+ if (state.pane === 'today' && TODAY.at && !TODAY.offline && TODAY.sessionsState === 'answered') {
1717
+ var sessions = state.sessions || [];
1718
+ TODAY.working = sessions.filter(function (s) { return s.running_active; });
1719
+ TODAY.open = sessions.filter(function (s) { return s.running && !s.running_active; });
1720
+ renderRunning(TODAY.working, TODAY.open);
1721
+ renderPet(sessions);
1722
+ paintNeeds();
1723
+ setBadge('sessions', TODAY.working.length > 0);
1724
+ }
1725
+ if (state.pane === 'sessions' && SX.rows && SX.view === 'sessions') liveSessionRows(rows, structural);
1726
+ liveNoteTick();
1727
+ }
1728
+ // Sessions: a row whose state moved is redrawn where it stands (the list never jumps under a finger); a session that
1729
+ // appeared or left redraws the list. A search on screen keeps its hits; only the hero's counts move.
1730
+ function liveSessionRows(rows, structural) {
1731
+ var list = $('sessions-list');
1732
+ if (!list) return;
1733
+ if (sxQueryActive()) { paintSessionsHero(); return; }
1734
+ if (structural) { renderSessionGroups(); return; }
1735
+ var amb = sxAmbiguous(sxVisible());
1736
+ (rows || []).forEach(function (s) {
1737
+ if (!s || sxKeepWarm(s)) return;
1738
+ var k = splitSessionId(s.provider, s.session_id || s.id), want = k.provider + '|' + k.id;
1739
+ var els = list.querySelectorAll('[data-session]');
1740
+ for (var i = 0; i < els.length; i++) {
1741
+ if (els[i].getAttribute('data-session') !== want) continue;
1742
+ var html = sxRow(s, null, amb);
1743
+ if (els[i].__liveHtml !== html) {
1744
+ var tmp = document.createElement('div'); tmp.innerHTML = html;
1745
+ var next = tmp.firstElementChild || tmp.firstChild;
1746
+ if (next) { next.__liveHtml = html; els[i].parentNode.replaceChild(next, els[i]); }
1747
+ }
1748
+ break;
1749
+ }
1750
+ });
1751
+ paintSessionsHero();
1752
+ }
1753
+ // ── Today's header line and Refresh ──
1754
+ // Refresh (Miles 11/07: "a 'refresh' button to the home 'today' tab that allows the user to get the latest state and
1755
+ // to update the summary"): every Today source read again now, then a new summary asked for with force (the server
1756
+ // still holds its switch, its one-per-30-s refresh limit and its daily cap, and says which in `reason`).
1757
+ var TR = { busy: false, doneAt: 0, msg: '', retryAt: 0 };
1758
+ var TR_REASONS = {
1759
+ daily_cap: 'Daily summary limit reached',
1760
+ recording: 'No new summary while a meeting records',
1761
+ maintenance: 'No new summary while COS updates',
1762
+ paused: 'The summary is paused for now. Try again later',
1763
+ failed: 'The summary did not update',
1764
+ interval: 'The summary updated recently. Try again in a few minutes'
1765
+ };
1766
+ /** The words for a refresh answer, or '' when the summary updated (or is switched off). */
1767
+ function refreshWords(v) {
1768
+ if (!v || typeof v !== 'object') return '';
1769
+ if (typeof v.message === 'string' && v.message.trim()) return v.message.trim();
1770
+ if (v.state === 'off') return '';
1771
+ if (v.reason === 'refresh_throttled') return '';
1772
+ return v.state === 'fresh' && !v.reason ? '' : (TR_REASONS[v.reason] || '');
1773
+ }
1774
+ function todayRefresh() {
1775
+ if (TR.busy) return Promise.resolve(false);
1776
+ TR.busy = true; TR.msg = ''; TR.retryAt = 0;
1777
+ var btn = $('today-refresh'); if (btn) btn.disabled = true;
1778
+ liveNoteTick();
1779
+ var done = function (msg) {
1780
+ TR.busy = false; TR.doneAt = Date.now(); TR.msg = msg || '';
1781
+ var b = $('today-refresh'); if (b) b.disabled = false;
1782
+ liveNoteTick(); liveTickStart();
1783
+ return true;
1784
+ };
1785
+ return Promise.resolve(loadToday(true)).then(function () {
1786
+ if (typeof liveOn === 'function' && liveOn()) LIVE.freshAt = Date.now();
1787
+ if (!TODAY.model || TODAY.offline) return done('');
1788
+ // The summary switched off: the state only.
1789
+ if (BRIEF.view && (BRIEF.view.state === 'off' || BRIEF.view.enabled === false)) return done('');
1790
+ var body = briefInputs(TODAY.model);
1791
+ body.force = true;
1792
+ BRIEF.inflight = true;
1793
+ // A 7.0.0 server refuses the unknown `force` (400 invalid_today_brief): ask it the ordinary way instead.
1794
+ var ask = function (b) { return api('/api/today/brief', { method: 'POST', body: b, timeoutMs: 45000 }); };
1795
+ return ask(body).catch(function (err) {
1796
+ if (!(err && err.status === 400 && err.code === 'invalid_today_brief')) throw err;
1797
+ delete body.force;
1798
+ return ask(body);
1799
+ }).then(function (v) {
1800
+ BRIEF.inflight = false;
1801
+ if (v && typeof v === 'object') { BRIEF.view = v; BRIEF.sentAt = Date.now(); }
1802
+ if (v && v.reason === 'refresh_throttled') TR.retryAt = Date.now() + Math.max(1000, Number(v.retryAfterMs) || 30000);
1803
+ // T1 spec 2: Refresh re-fits the block to what it now holds.
1804
+ BRIEF.heldPx = 0;
1805
+ if (TODAY.model && state.pane === 'today') renderStory(TODAY.model);
1806
+ return done(refreshWords(v));
1807
+ }, function (err) {
1808
+ BRIEF.inflight = false;
1809
+ return done(err && err.code === 'unreachable' ? err.message : 'The summary did not update');
1810
+ });
1811
+ }, function () { return done('Could not read everything. Try again'); });
1812
+ }
1813
+ function liveNoteBusy() {
1814
+ var now = Date.now();
1815
+ return TR.busy || TR.retryAt > now || (TR.doneAt && now - TR.doneAt < 10 * 60000) || !!liveAge(now) || LIVE.mode === 'reconnecting';
1816
+ }
1817
+ /** The line beside Refresh: Updating, Reconnecting, the counts' age, when Today was refreshed, the server's limit. */
1818
+ function liveNoteText(now) {
1819
+ now = now || Date.now();
1820
+ if (TR.busy) return 'Updating';
1821
+ var parts = [];
1822
+ if (LIVE.mode === 'reconnecting') parts.push('Reconnecting');
1823
+ var age = liveAge(now);
1824
+ if (age) parts.push(age);
1825
+ if (TR.doneAt && !age) {
1826
+ var m = Math.floor((now - TR.doneAt) / 60000);
1827
+ if (m < 10) parts.push(m < 1 ? 'Updated just now' : 'Updated ' + m + ' min ago');
1828
+ }
1829
+ if (TR.retryAt > now) parts.push('Try again in ' + Math.ceil((TR.retryAt - now) / 1000) + ' s');
1830
+ else if (TR.msg && TR.doneAt && now - TR.doneAt < 10 * 60000) parts.push(TR.msg);
1831
+ return parts.join('. ');
1832
+ }
1833
+ /** What the line says, without its numbers: the polite status line speaks only when this changes (review N10). */
1834
+ function liveNoteState(now) {
1835
+ now = now || Date.now();
1836
+ var age = liveAge(now);
1837
+ return [TR.busy ? 'busy' : '', LIVE.mode === 'reconnecting' ? 'reconnecting' : '', age ? 'age' : '',
1838
+ TR.doneAt && !age && now - TR.doneAt < 10 * 60000 ? 'done' : '', TR.retryAt > now ? 'retry' : '',
1839
+ TR.msg && TR.doneAt && now - TR.doneAt < 10 * 60000 ? TR.msg : ''].join('|');
1840
+ }
1841
+ function liveNoteTick() {
1842
+ var el = $('today-live-note'), t = liveNoteText();
1843
+ if (el) { if (el.textContent !== t) el.textContent = t; el.classList.toggle('warn', LIVE.mode === 'reconnecting'); }
1844
+ var said = $('today-live-status'), key = liveNoteState();
1845
+ if (said && LIVE.noteSaid !== key) { LIVE.noteSaid = key; said.textContent = t; }
1846
+ var sx = document.querySelector('#sessions-hero .sx-live-note');
1847
+ if (sx) { var n = liveSessionsNote(); sx.textContent = n ? ' · ' + n : ''; }
1848
+ }
1849
+ /** The Sessions hero's honesty suffix: "Reconnecting" and the counts' age, or ''. */
1850
+ function liveSessionsNote() {
1851
+ var bits = [];
1852
+ if (LIVE.mode === 'reconnecting') bits.push('Reconnecting');
1853
+ var age = liveAge();
1854
+ if (age) bits.push(age.charAt(0).toLowerCase() + age.slice(1));
1855
+ return bits.join(' · ');
883
1856
  }
884
1857
  function needWhy(item) {
885
1858
  var p = item.progress || {};
@@ -900,8 +1873,29 @@
900
1873
  // Control's askedTitle and askedWhy for a session waiting on you.
901
1874
  // Control's TodayHome: guarded(title) ?? "A session". Never the workspace path or the session id: this line is shown
902
1875
  // on Today and goes into the Today summary sent to the model, which carries no ids.
1876
+ // ── A turn that ended asking you (7.0.18, X1 contract v1; Miles 2026-11-10 07:14) ──
1877
+ // A Claude session ended its turn with the app's next-step box: a question and reply buttons. The server marks the row
1878
+ // (waiting_source 'turn_end', waiting_choices: 0 to 2 labels) only while COS_TURN_END_ASKS is on. Unlike a question at
1879
+ // the Mac's prompt, a reply IS the answer here, so Today and the reader offer the choices and the reply box. What is
1880
+ // sent is the contract's exact text: it never reads as typed at the desk, and the session may ask him to confirm.
1881
+ var TURN_END_PREFIX = 'Answer sent from COS Control Web through COS, not typed in this session: ';
1882
+ var TURN_END_LINE = 'A choice or a reply here reaches the session as a message from COS, not typed in the session, and the session may ask you to confirm.';
1883
+ /** The turn-end ask on a session row, or null: any other row, an older server, or the switch off. */
1884
+ function turnEndOf(s) {
1885
+ if (!s || s.waiting_source !== 'turn_end' || s.agent_state !== 'waiting') return null;
1886
+ var clean = function (v, n) { return String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, n); };
1887
+ var choices = Array.isArray(s.waiting_choices) ? s.waiting_choices.filter(function (c) { return typeof c === 'string' && clean(c, 40); }).map(function (c) { return clean(c, 40); }).slice(0, 2) : [];
1888
+ return { ask: clean(s.waiting_detail, 160), choices: choices };
1889
+ }
1890
+ /** The exact words a choice or a reply sends (contract v1): attributed to COS Control Web, never to the desk. */
1891
+ function turnEndPrompt(text) { return TURN_END_PREFIX + turnEndBare(text); }
1892
+ /** The words as he wrote them: a message that came back to the box already carries the prefix, never twice. */
1893
+ function turnEndBare(text) { var t = String(text || '').trim(); return t.indexOf(TURN_END_PREFIX) === 0 ? t.slice(TURN_END_PREFIX.length).trim() : t; }
903
1894
  function askedTitle(s) {
904
1895
  var t = plain(s.display_label || s.custom_title || '').replace(/\s+/g, ' ').trim() || 'A session';
1896
+ // The harnesses that run this block alone have no turnEndOf: looked up, never assumed.
1897
+ var te = typeof turnEndOf === 'function' ? turnEndOf(s) : null;
1898
+ if (te) return te.ask ? t + ' asked: ' + te.ask : t + ' asked you something';
905
1899
  if (sxProvider(s.provider) !== 'claude') return t + ' is waiting for your reply';
906
1900
  if (s.waiting_kind === 'plan') return t + ' has a plan for you to approve';
907
1901
  if (s.waiting_kind === 'permission') return t + ' wants your permission';
@@ -912,12 +1906,20 @@
912
1906
  var TOOL_NAMES = ['AskUserQuestion', 'ExitPlanMode', 'Bash', 'BashOutput', 'KillShell', 'Edit', 'Write', 'Read', 'Grep', 'Glob', 'LS', 'Task', 'Agent',
913
1907
  'WebFetch', 'WebSearch', 'TodoWrite', 'NotebookEdit', 'MultiEdit', 'SlashCommand', 'Skill', 'ToolSearch'];
914
1908
  function askedWhy(s) {
1909
+ var te = typeof turnEndOf === 'function' ? turnEndOf(s) : null;
1910
+ if (te) return te.choices.length ? 'Tap a choice or reply here' : 'Reply here';
915
1911
  if (s.waiting_kind === 'plan') return 'Has a plan for you to approve';
916
1912
  var d = String(s.waiting_detail || '').trim();
917
1913
  if (TOOL_NAMES.indexOf(d) >= 0) d = '';
918
- if (s.waiting_kind === 'permission' && d) return 'Wants to run ' + (d.length > 80 ? d.slice(0, 79) + '…' : d);
1914
+ // 7.0.4 W5: the server shows the home folder in this command as "[home]" (lib/local-path-redact.ts COMMAND_KEYS), so
1915
+ // the line is a display, labelled as one. The exact command is on the approval card, which is not redacted.
1916
+ if (s.waiting_kind === 'permission' && d) {
1917
+ var shown = d.length > 80 ? d.slice(0, 79) + '…' : d;
1918
+ return d.indexOf('[home]') >= 0 ? 'Wants to run (home folder hidden) ' + shown : 'Wants to run ' + shown;
1919
+ }
919
1920
  if (s.waiting_kind === 'question' && d) return d.length > 160 ? d.slice(0, 159) + '…' : d;
920
- return 'Waiting for you' + (sxProvider(s.provider) === 'claude' ? '. Paused until you answer.' : '');
1921
+ // W1 A5: a Claude session waits on its own prompt; the card under it says where, once.
1922
+ return sxProvider(s.provider) === 'claude' ? 'Waiting for your answer' : 'Waiting for you';
921
1923
  }
922
1924
  // 6.99.26: where a waiting session's question can be answered. 'here': this page holds it (panes/questions.js), as
923
1925
  // cards in its waiting sheet. 'mac': a Claude session's own prompt (or a held one that went back to the Mac), which
@@ -932,16 +1934,111 @@
932
1934
  return q && q.question ? clipWords(q.question, 160) : '';
933
1935
  }
934
1936
  function clipWords(text, n) { var t = String(text || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; }
935
- function whereFor(provider, id) {
1937
+ function whereFor(provider, id, row) {
936
1938
  var q = sessionQs();
937
1939
  var held = q ? q.itemsFor(id) : [], mac = q && q.atMacFor ? q.atMacFor(id) : [];
938
1940
  if (held.length) return { where: 'here', ask: askedHere(held) };
1941
+ // 7.0.18: a turn that ended asking him is answered by a reply, here or anywhere: never "at the Mac".
1942
+ var te = row && typeof turnEndOf === 'function' ? turnEndOf(row) : null;
1943
+ if (te) return { where: 'turnend', ask: te.ask, choices: te.choices };
939
1944
  if (q && q.answeredFor && q.answeredFor(id)) return { where: 'answered', ask: '' };
940
- var words = mac.length && q.wordsOf ? q.wordsOf(mac[0].id) : null;
1945
+ // X1 contract: a card built from the server's `waiting` list carries its own words; a held one gone back to the Mac
1946
+ // has the words this page saw while it was held.
1947
+ var first = mac[0];
1948
+ var words = first && first.fromWaiting ? { questions: (first.questions || []).map(function (x) { return x.question; }), summary: '' }
1949
+ : first && q.wordsOf ? q.wordsOf(first.id) : null;
941
1950
  var ask = words ? clipWords(words.questions[0] || (words.summary ? 'Wants to run ' + words.summary : ''), 160) : '';
942
- if (mac.length || sxProvider(provider) === 'claude') return { where: 'mac', ask: ask };
1951
+ if (mac.length) return { where: 'mac', ask: ask };
1952
+ // DESIGN.md step 4 #5: the Mac line only for a Claude row that waits on a question or an approval, never for any
1953
+ // waiting Claude session. A row the page has not read says nothing about it, so it keeps the Mac line as before.
1954
+ var kind = row ? String(row.waiting_kind || '') : '';
1955
+ if (sxProvider(provider) === 'claude' && (!row || kind === 'question' || kind === 'permission')) return { where: 'mac', ask: ask };
943
1956
  return { where: '', ask: '' };
944
1957
  }
1958
+ // ── At the Mac's prompt, in the session reader (W1 A5) ──
1959
+ // Miles 11/09: "when I actually navigate to the session, there is no ability to answer the question." The reader cannot
1960
+ // answer a question that waits on the Mac's own prompt either, so it says so at the top, with the words when this page
1961
+ // has them (a held question it saw, or the server's waiting line) and plainly when it does not. Nothing here sends.
1962
+ var MAC_Q = { line: "Answer this at your Mac's prompt. A message sent from here does not answer it; it waits until the question is answered at the Mac.", none: "COS cannot read this question's words from here. They show at the Mac's prompt." };
1963
+ /** 7.0.18: the reader's card for a turn that ended asking him: the question, a button per choice, the honest line. */
1964
+ function readerTurnEndHtml(r, row) {
1965
+ var te = turnEndOf(row);
1966
+ if (!te) return '';
1967
+ var title = plain((row && (row.display_label || row.custom_title)) || '').replace(/\s+/g, ' ').trim() || 'This session';
1968
+ var busy = !!(r && r.teSending);
1969
+ return '<p class="sx-mac-q-title"><strong>' + esc(te.ask ? title + ' asked:' : title + ' asked you something') + '</strong></p>' +
1970
+ (te.ask ? '<blockquote class="ta-quote sx-te-ask">' + esc(te.ask) + '</blockquote>' : '') +
1971
+ (te.choices.length ? '<div class="sx-te-choices">' + te.choices.map(function (c, i) {
1972
+ return '<button type="button" class="cx-btn' + (i === 0 ? ' primary' : '') + '" data-sx="turnend" data-i="' + i + '"' + (busy ? ' disabled' : '') + '>' + esc(c) + '</button>';
1973
+ }).join('') + '</div>' : '') +
1974
+ '<p class="sx-note sx-te-line">' + esc(TURN_END_LINE) + '</p>';
1975
+ }
1976
+ function readerMacHtml(provider, id, row) {
1977
+ var w = whereFor(provider, id, row);
1978
+ if (w.where !== 'mac') return '';
1979
+ // X1 contract: when the server listed this question (`waiting`), the shared card list mounted above draws it, with
1980
+ // its options. This block is only for a question it did not list (an older server, or asked before a restart).
1981
+ var qs0 = sessionQs();
1982
+ if (qs0 && qs0.atMacFor && qs0.atMacFor(id).length) return '';
1983
+ var q = sessionQs();
1984
+ var why = row ? askedWhy(row) : '';
1985
+ var words = w.ask || (why && !/^Waiting for (you|your answer)/.test(why) ? why : '');
1986
+ return '<p class="sx-mac-q-title"><strong>Waiting for your answer at the Mac</strong></p>' +
1987
+ (words ? '<blockquote class="ta-quote sx-mac-q-words">' + esc(words) + '</blockquote>' : '<p class="sx-note sx-mac-q-none">' + esc((q && q.macNoWords) || MAC_Q.none) + '</p>') +
1988
+ '<p class="sx-note sx-mac-q-line">' + esc((q && q.macLine) || MAC_Q.line) + '</p>';
1989
+ }
1990
+ function readerRowOf(r) {
1991
+ return (state.sessions || []).filter(function (s) { var k = splitSessionId(s.provider, s.session_id || s.id); return k.provider === r.provider && String(k.id).toLowerCase() === String(r.id).toLowerCase(); })[0] || null;
1992
+ }
1993
+ // 7.0.18: a tap on a choice sends the contract's exact text through the reader's own Continue (continueSession), so the
1994
+ // queue, the held reply and the turn watch behave as for any message. The outcome is said in the status line.
1995
+ var TURN_END_SENT = function (label) { return 'Sent \u201c' + label + '\u201d. It reaches the session as a message from COS; it may ask you to confirm.'; };
1996
+ var TURN_END_GONE = 'Already answered on another screen, or the session moved on. Nothing was sent.';
1997
+ function sendTurnEndChoice(r, i) {
1998
+ if (!r || r.sending) return;
1999
+ var te = turnEndOf(readerRowOf(r));
2000
+ if (!te) { sessionStatus(TURN_END_GONE); readerMacPaint(r, r.waitingNow); return; }
2001
+ var label = te.choices[i];
2002
+ if (!label) return;
2003
+ r.sending = true; r.teSending = true; readerMacPaint(r, r.waitingNow);
2004
+ var done = function () { r.sending = false; r.teSending = false; if (state.reading === r) readerMacPaint(r, r.waitingNow); };
2005
+ return continueSession(r, turnEndPrompt(label), {}).then(function (how) {
2006
+ done();
2007
+ if (state.reading !== r) return how;
2008
+ if (how === 'sent') sessionStatus(TURN_END_SENT(label));
2009
+ else if (how === 'changed') {
2010
+ // The box under it would offer Continue anyway for the composer's words, not this choice: a choice says it plainly.
2011
+ r.refusal = null; r.sendAnyway = null; renderVerdict(r);
2012
+ sessionStatus('The session moved on before this reached it. \u201c' + label + '\u201d was not sent.', 'error');
2013
+ } else if (how === 'refused') sessionStatus('The session did not take \u201c' + label + '\u201d. Try again, or answer it on your Mac.', 'error');
2014
+ return how;
2015
+ }, function (err) {
2016
+ done();
2017
+ if (state.reading !== r) return;
2018
+ var why = err && err.name === 'AbortError' ? 'No answer from your Mac yet. It may still land; tapping again is safe, it will not post twice.' : (err && err.message) || 'Could not reach your Mac.';
2019
+ sessionStatus('Not sent: ' + why, 'error');
2020
+ });
2021
+ }
2022
+ function readerMacPaint(r, waiting) {
2023
+ var el = $('session-mac-q');
2024
+ if (!el || !r) return;
2025
+ r.waitingNow = !!waiting;
2026
+ var row = readerRowOf(r);
2027
+ // 7.0.18: a turn that ended asking him gets its card whether or not the reader thinks it waits (the row is the truth).
2028
+ var te = typeof turnEndOf === 'function' ? turnEndOf(row) : null;
2029
+ r.turnEnd = te;
2030
+ var html = te ? readerTurnEndHtml(r, row) : waiting ? readerMacHtml(r.provider, r.id, row) : '';
2031
+ if (el.innerHTML !== html) el.innerHTML = html;
2032
+ el.hidden = !html;
2033
+ // Q1 M3: the Send box under it says plainly that a message is not the answer, whichever block shows the question
2034
+ // (this one, a Mac card or a held card above).
2035
+ var qs1 = sessionQs(), open = (!!html && !te) || !!(qs1 && ((qs1.itemsFor && qs1.itemsFor(r.id).length) || (qs1.atMacFor && qs1.atMacFor(r.id).length)));
2036
+ var box = $('session-input');
2037
+ // 7.0.18: here a reply IS the answer (sent with the contract's attribution), so the box says so.
2038
+ if (box && box.setAttribute) box.setAttribute('placeholder', te ? 'Or write your own answer' : open ? 'Message this session (not an answer)' : 'Continue this session…');
2039
+ }
2040
+ // ── end at the Mac's prompt
2041
+
945
2042
  function needsModel() {
946
2043
  var states = { sessions: TODAY.sessionsState, work: TODAY.workState, voices: TODAY.voices.state, memories: TODAY.memories.state };
947
2044
  var rows = [], seen = {};
@@ -949,8 +2046,8 @@
949
2046
  if (sxKeepWarm(s) || sxState(s) !== 'waiting') return;
950
2047
  var k = splitSessionId(s.provider, s.session_id || s.id);
951
2048
  seen[k.provider + '|' + String(k.id).toLowerCase()] = 1;
952
- var w = whereFor(k.provider, k.id);
953
- rows.push({ kind: 'session', tier: 1, title: askedTitle(s), why: askedWhy(s), since: s.state_since || s.last_activity_at, key: k.provider + '|' + k.id, where: w.where, ask: w.ask });
2049
+ var w = whereFor(k.provider, k.id, s);
2050
+ rows.push({ kind: 'session', tier: 1, title: askedTitle(s), why: askedWhy(s), since: s.state_since || s.last_activity_at, key: k.provider + '|' + k.id, where: w.where, ask: w.ask, choices: w.choices || [] });
954
2051
  });
955
2052
  // A question this page holds for a session the list has not shown waiting yet: its own row, so every held
956
2053
  // question is in front of you (two at once each get one).
@@ -966,6 +2063,11 @@
966
2063
  (TODAY.needs || []).forEach(function (it) {
967
2064
  var k = splitSessionId(it.provider, it.sessionId);
968
2065
  if (k.id && seen[k.provider + '|' + String(k.id).toLowerCase()]) return; // Control: a Work row for a waiting session is the session's row
2066
+ // 7.0.1 (Miles 11/07): the server saw you start a turn in the session after its needs-input or blocked report
2067
+ // (work-activity.ts supersededBy). That report is history, not something waiting on you. Only the item that
2068
+ // regrouped to running leaves: a superseded completed handoff regroups to "Needs a look", which the Messages
2069
+ // badge counts, so it stays here too (review N6).
2070
+ if (it.supersededAt && it.group === 'running') return;
969
2071
  rows.push({ kind: 'work', tier: 3, item: it, key: k.id ? k.provider + '|' + k.id : '' });
970
2072
  });
971
2073
  var groups = TODAY.voices.groups || [];
@@ -1005,7 +2107,8 @@
1005
2107
  return bits.join(', ');
1006
2108
  }
1007
2109
  function needRowHtml(r, i) {
1008
- var age = r.since ? ago(r.since) : r.item ? ago(r.item.updatedAt || r.item.createdAt) : '';
2110
+ // A Work row is dated by when it started waiting (7.0.1), not by the card's newest event.
2111
+ var age = r.since ? ago(r.since) : r.item ? ago(r.item.waitingSince || r.item.updatedAt || r.item.createdAt) : '';
1009
2112
  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>'; };
1010
2113
  if (r.kind === 'work') {
1011
2114
  var it = r.item, k = splitSessionId(it.provider, it.sessionId);
@@ -1020,7 +2123,8 @@
1020
2123
  // The session's question shows in the overlay's peek, with its cards to answer. Under it, where it can be answered.
1021
2124
  var q = sessionQs(), whereLine = r.where === 'here' ? 'Answer it here.' : r.where === 'mac' ? (q && q.macLine) || '' : r.where === 'answered' ? (q && q.answeredLine) || '' : '';
1022
2125
  return '<div class="need-row">' +
1023
- '<button type="button" class="need-open" data-today="wait" data-key="' + esc(r.key) + '">' + top(r.title) + '<span class="need-foot">' + esc(r.ask || r.why) + '</span>' +
2126
+ // 7.0.18: a turn-end row's title already says the question, so its foot says how to answer.
2127
+ '<button type="button" class="need-open" data-today="wait" data-key="' + esc(r.key) + '">' + top(r.title) + '<span class="need-foot">' + esc(r.where === 'turnend' ? r.why : (r.ask || r.why)) + '</span>' +
1024
2128
  (whereLine ? '<span class="need-foot need-where" data-where="' + esc(r.where) + '">' + esc(whereLine) + '</span>' : '') + '</button>' +
1025
2129
  '<div class="need-actions"><button type="button" class="btn small' + (i === 0 ? ' primary' : '') + '" data-session="' + esc(r.key) + '">Open session</button></div></div>';
1026
2130
  }
@@ -1066,16 +2170,237 @@
1066
2170
  // Control's Next up (todayPrepButtons): Prep me opens the Prep composer in the COS chat (panes/cos-chat.js prepFor),
1067
2171
  // Open meeting opens Meetings. Without a server to answer, Prep me is not offered.
1068
2172
  var prep = !TODAY.offline && window.COSWeb && window.COSWeb.cosChat && window.COSWeb.cosChat.prepFor;
2173
+ // Prep on every row (Miles 11/09): "Add context" opens the same inline panel the later rows' Prep opens, for this
2174
+ // meeting. Not on a hold (the server's nextEvent.hold), and not while neither Prep nor Ask is on the Mac.
2175
+ var key = nextPrepKey(n.title, d), ctx = !n.hold && nextPrepMode();
2176
+ if (ctx) NEXT_PREP.rows[key] = { title: n.title, start: d.toISOString() };
1069
2177
  $('next-actions').innerHTML = (prep ? '<button type="button" class="btn small primary" data-today-next="prep">Prep me</button>' : '') +
2178
+ (ctx ? '<button type="button" class="link-btn next-prep-open" data-next-prep="' + esc(key) + '" aria-expanded="' + (NEXT_PREP.open === key) + '">Add context</button>' : '') +
1070
2179
  '<button type="button" class="link-btn" data-goto="meetings">Open meeting</button>';
2180
+ NEXT_PREP.firstKey = ctx ? key : '';
2181
+ paintNextPrepSlot($('next-first-prep'), ctx && NEXT_PREP.open === key ? key : '');
1071
2182
  card.classList.remove('hidden');
1072
2183
  }
2184
+ // PH plan P1: the rest of today from GET /api/calendar/today (selected calendars), under Next up.
2185
+ // An aging snapshot says when it was read. Not connected or nothing more: the block stays hidden.
2186
+ // Today round 2 (Miles 11/09): Next up is 5 rows in all, the event above plus up to 4 more, then "+N more today"
2187
+ // (opens Meetings) when the calendar has more meetings left today than are shown. Desktop Control 0.5.384 matches.
2188
+ function loadCalendarLater(n, d) {
2189
+ var LATER_ROWS = 4;
2190
+ var box = $('next-later'); if (!box) return;
2191
+ if (!n || !n.title || !d) { NEXT_PREP.later = null; clearLater(box); return; }
2192
+ var gen = TODAY.gen;
2193
+ api('/api/calendar/today').then(function (c) {
2194
+ if (gen !== TODAY.gen) return;
2195
+ if (!c || !c.connected) { NEXT_PREP.later = null; clearLater(box); return; }
2196
+ var same = function (r) { var rd = toDate(r.start); return r.title === n.title && rd && d && Math.abs(rd.getTime() - d.getTime()) < 60000; };
2197
+ var list = c.next || [];
2198
+ var rows = list.filter(function (r) { return !r.allDay && !same(r); }).slice(0, LATER_ROWS);
2199
+ // What is left today past the rows on screen (the event above counts when it is one of today's).
2200
+ var shown = rows.length + (list.some(same) ? 1 : 0), more = (Number(c.remainingCount) || 0) - shown;
2201
+ NEXT_PREP.later = { rows: rows, more: more, asOf: c.stale && c.asOf ? toDate(c.asOf) : null };
2202
+ paintCalendarLater();
2203
+ }, function () { NEXT_PREP.later = null; clearLater(box); });
2204
+ }
2205
+ // Emptied, and its last write forgotten, so the next read that has rows writes them again (writeKeepingPrep skips a
2206
+ // write that matches the last one).
2207
+ function clearLater(box) { box.classList.add('hidden'); box.innerHTML = ''; box.cosPrepHtml = ''; }
2208
+ // The later rows from the last calendar read. Each meeting that is not a hold (the server's `hold`) gets a quiet
2209
+ // Prep, and the open row's panel sits under it. Written only when the markup changed, so a poll never takes the words
2210
+ // or the caret from the panel; when it is written, the words, the focus and the caret are put back.
2211
+ function paintCalendarLater() {
2212
+ var box = $('next-later'), L = NEXT_PREP.later; if (!box || !L) return;
2213
+ var mode = nextPrepMode(), openKey = '';
2214
+ var html = L.rows.map(function (r) {
2215
+ var rd = toDate(r.start), key = rd ? nextPrepKey(r.title, rd) : '', can = !!(mode && key && r.hold !== true);
2216
+ if (can) { NEXT_PREP.rows[key] = { title: r.title, start: rd.toISOString() }; if (NEXT_PREP.open === key) openKey = key; }
2217
+ return '<div class="later-row"><span class="later-when">' + esc(rd ? clock(rd) : '') + '</span><span class="later-what">' + esc(r.title) + '</span>' +
2218
+ (can ? '<button type="button" class="link-btn later-prep" data-next-prep="' + esc(key) + '" aria-expanded="' + (NEXT_PREP.open === key) + '">Prep</button>' : '') + '</div>' +
2219
+ (can && NEXT_PREP.open === key ? nextPrepPanelHtml(key, mode) : '');
2220
+ }).join('');
2221
+ if (L.more > 0) html += '<button type="button" class="link-btn later-more" data-goto="meetings">' + esc('+' + L.more + ' more today') + '</button>';
2222
+ if (L.asOf) html += '<div class="later-asof">' + esc('Calendar as of ' + clock(L.asOf)) + '</div>';
2223
+ // Shown before it is written: autoGrow measures the box, and a hidden one measures 0.
2224
+ box.classList.toggle('hidden', !html);
2225
+ writeKeepingPrep(box, html, openKey);
2226
+ }
2227
+
2228
+ // ── Prep on every Next up row (Miles typed 2026-11-09 13:00 CST) ─────────
2229
+ // "Inline options to prep for a given meeting which starts the gotCOS ask and gives us the ability to add in more
2230
+ // context for the prep." Prep on a later row (or Add context beside the first row's Prep me) opens a small panel
2231
+ // under that row: the context box, Add files, Start prep and Cancel. One panel at a time; Escape closes it. The words
2232
+ // and files are kept per meeting (title plus start) while the page polls. Start prep goes to Messages, then COS, and
2233
+ // hands them to cos-chat.js prepStart: the Prep composer for that meeting (the context joins its notes, the files go
2234
+ // up through its own path, then Prep me runs), or, with Prep off on the Mac and Ask on, the plain Ask with the context
2235
+ // as its quoted reference. Neither on (or the Mac not answering): no Prep on any row.
2236
+ var NEXT_PREP = { open: '', rows: {}, drafts: {}, later: null, firstKey: '', busy: false, capsAsked: false };
2237
+ function nextPrepKey(title, d) { return String(title || '') + '|' + (d ? d.toISOString() : ''); }
2238
+ function nextPrepChat() { return typeof window !== 'undefined' && window.COSWeb && window.COSWeb.cosChat || null; }
2239
+ /** 'prep' (Prep on the Mac), 'ask' (Ask only), or '' (neither, the Mac not answering, or not known yet). */
2240
+ function nextPrepMode() {
2241
+ var chat = nextPrepChat();
2242
+ if (TODAY.offline || !chat || !chat.prepStart) return '';
2243
+ if (chat.known && !chat.known()) {
2244
+ // The chat reads /api/health once per page; paint the rows again when it lands.
2245
+ if (!NEXT_PREP.capsAsked && chat.ensureCaps) {
2246
+ NEXT_PREP.capsAsked = true;
2247
+ chat.ensureCaps().then(function () { if (TODAY.welcome) renderNext(TODAY.welcome); paintCalendarLater(); }, function () {});
2248
+ }
2249
+ return '';
2250
+ }
2251
+ if (chat.prepCaps && chat.prepCaps()) return 'prep';
2252
+ return chat.askOn && chat.askOn() ? 'ask' : '';
2253
+ }
2254
+ function nextPrepDraft(key) { return NEXT_PREP.drafts[key] || (NEXT_PREP.drafts[key] = { text: '', files: [] }); }
2255
+ function nextPrepMax() { var chat = nextPrepChat(), c = chat && chat.prepCaps && chat.prepCaps(); return c || { maxFiles: 10, steerChars: 4000 }; }
2256
+ function nextPrepPanelHtml(key, mode) {
2257
+ var row = NEXT_PREP.rows[key] || { title: '' }, dr = nextPrepDraft(key), chat = nextPrepChat();
2258
+ var label = 'Add context for this prep (goals, people, what you want out of it)';
2259
+ var files = mode === 'prep' && dr.files.length ? '<ul class="next-prep-files">' + dr.files.map(function (f, i) {
2260
+ return '<li><span class="next-prep-file">' + esc(f.name || 'File') + '</span><button type="button" class="link-btn" data-next-prep-remove="' + i + '" aria-label="' + esc('Remove ' + (f.name || 'file')) + '">Remove</button></li>';
2261
+ }).join('') + '</ul>' : '';
2262
+ var add = mode === 'prep' ? '<label class="cx-btn quiet next-prep-add" title="Add files to this prep"><svg class="icon" aria-hidden="true"><use href="#i-clip"/></svg><span>Add files</span>' +
2263
+ '<input type="file" class="cc-file" multiple accept="' + esc((chat && chat.prepAccept) || '') + '" data-next-prep-file aria-label="' + esc('Add files to the prep for ' + row.title) + '"></label>' : '';
2264
+ return '<form class="next-prep" data-next-prep-form data-key="' + esc(key) + '" autocomplete="off" aria-label="' + esc('Prep for ' + row.title) + '">' +
2265
+ '<textarea class="field next-prep-input" rows="2" maxlength="' + (mode === 'prep' ? nextPrepMax().steerChars : 2000) + '" data-next-prep-steer placeholder="' + esc(label) + '" aria-label="' + esc(label) + '"></textarea>' +
2266
+ files +
2267
+ '<div class="next-prep-acts">' + add + '<span class="next-prep-gap"></span>' +
2268
+ '<button type="button" class="link-btn next-prep-cancel" data-next-prep-cancel>Cancel</button>' +
2269
+ '<button type="submit" class="cx-btn primary next-prep-start"' + (NEXT_PREP.busy ? ' disabled' : '') + '>Start prep</button></div>' +
2270
+ '</form>';
2271
+ }
2272
+ // The first row's panel, in its own slot under Prep me and Open meeting.
2273
+ function paintNextPrepSlot(slot, key) {
2274
+ if (!slot) return;
2275
+ var mode = key ? nextPrepMode() : '';
2276
+ // Shown before it is written: autoGrow measures the box, and a hidden one measures 0.
2277
+ slot.classList.toggle('hidden', !(key && mode));
2278
+ writeKeepingPrep(slot, key && mode ? nextPrepPanelHtml(key, mode) : '', key);
2279
+ }
2280
+ /** Writes `html` into `el` unless it is already there, then puts the open panel's words back (and the focus and caret). */
2281
+ function writeKeepingPrep(el, html, key) {
2282
+ if (el.cosPrepHtml === html) return;
2283
+ var doc = typeof document !== 'undefined' ? document : null, active = doc && doc.activeElement;
2284
+ var had = !!(active && el.contains && el.contains(active) && active.hasAttribute && active.hasAttribute('data-next-prep-steer'));
2285
+ var a = had ? active.selectionStart : 0, b = had ? active.selectionEnd : 0;
2286
+ el.innerHTML = html;
2287
+ el.cosPrepHtml = html;
2288
+ var box = key && el.querySelector ? el.querySelector('[data-next-prep-steer]') : null;
2289
+ if (!box) return;
2290
+ box.value = nextPrepDraft(key).text;
2291
+ autoGrow(box);
2292
+ if (had) { box.focus(); try { box.setSelectionRange(a, b); } catch (_e) {} }
2293
+ }
2294
+ function repaintNextPrep() {
2295
+ if (TODAY.welcome && TODAY.welcome.nextEvent) renderNext(TODAY.welcome);
2296
+ paintCalendarLater();
2297
+ }
2298
+ function toggleNextPrep(key) {
2299
+ NEXT_PREP.open = NEXT_PREP.open === key ? '' : key;
2300
+ repaintNextPrep();
2301
+ if (!NEXT_PREP.open || typeof document === 'undefined') return;
2302
+ var box = document.querySelector('[data-next-prep-form] [data-next-prep-steer]');
2303
+ if (box) { box.focus(); if (box.scrollIntoView) box.scrollIntoView({ block: 'nearest' }); }
2304
+ }
2305
+ // Escape closes the panel and keeps the words (they come back when it opens again); Cancel closes it and drops them.
2306
+ function closeNextPrep(drop) {
2307
+ var key = NEXT_PREP.open; if (!key) return false;
2308
+ if (drop) delete NEXT_PREP.drafts[key];
2309
+ NEXT_PREP.open = '';
2310
+ repaintNextPrep();
2311
+ var opener = typeof document !== 'undefined' && typeof CSS !== 'undefined' && CSS.escape ? document.querySelector('[data-next-prep="' + CSS.escape(key) + '"]') : null;
2312
+ if (opener && opener.focus) opener.focus();
2313
+ return true;
2314
+ }
2315
+ function nextPrepInput(box) { var key = NEXT_PREP.open; if (key) nextPrepDraft(key).text = box.value; }
2316
+ function addNextPrepFiles(list) {
2317
+ var key = NEXT_PREP.open; if (!key) return;
2318
+ var dr = nextPrepDraft(key), max = nextPrepMax().maxFiles, chat = nextPrepChat(), refused = [];
2319
+ Array.prototype.slice.call(list || []).forEach(function (f) {
2320
+ if (dr.files.length >= max) { refused.push('A prep holds ' + max + ' files. ' + (f.name || 'A file') + ' was not added.'); return; }
2321
+ var c = chat && chat.classify ? chat.classify(f) : {};
2322
+ if (c && c.refuse) { refused.push(c.refuse); return; }
2323
+ if (c && c.kind === 'video') { refused.push((f.name || 'That video') + ' is a video. Prep reads documents and the text in images.'); return; }
2324
+ dr.files.push(f);
2325
+ });
2326
+ if (refused.length) toast(refused.join(' '));
2327
+ repaintNextPrep();
2328
+ }
2329
+ function removeNextPrepFile(i) {
2330
+ var key = NEXT_PREP.open; if (!key) return;
2331
+ nextPrepDraft(key).files.splice(Number(i), 1);
2332
+ repaintNextPrep();
2333
+ }
2334
+ function startNextPrep() {
2335
+ var key = NEXT_PREP.open, row = key && NEXT_PREP.rows[key], chat = nextPrepChat();
2336
+ if (!row || NEXT_PREP.busy || !chat || !chat.prepStart) return;
2337
+ var mode = nextPrepMode(); if (!mode) return;
2338
+ var dr = nextPrepDraft(key);
2339
+ var extra = { steer: dr.text.trim(), files: mode === 'prep' ? dr.files.slice() : [] };
2340
+ NEXT_PREP.busy = true; NEXT_PREP.open = '';
2341
+ repaintNextPrep();
2342
+ go('messages', ['cos']);
2343
+ // The words and files stay with the meeting until the prep (or the Ask) is on its way; a failure keeps them.
2344
+ chat.prepStart(row.title, row.start, extra).then(function (how) {
2345
+ if (how) delete NEXT_PREP.drafts[key];
2346
+ else toast('Prep is not on for this Mac.');
2347
+ }, function (err) {
2348
+ toast((err && err.message) || 'Prep did not start. Your context is still under the meeting on Today.');
2349
+ }).then(function () { NEXT_PREP.busy = false; });
2350
+ }
1073
2351
  function prepNext() {
1074
2352
  var n = TODAY.welcome && TODAY.welcome.nextEvent, d = n && toDate(n.time), chat = window.COSWeb && window.COSWeb.cosChat;
1075
2353
  if (!n || !n.title || !chat || !chat.prepFor) return;
1076
2354
  go('messages', ['cos']);
1077
2355
  chat.prepFor(n.title, d ? d.toISOString() : null).catch(function (err) { toast((err && err.message) || 'Prep me did not start.'); });
1078
2356
  }
2357
+ // ── Ask about this (Today round 2, Miles 11/09) ─────────
2358
+ // "Ask COS from this page about the summary and take action coming off of that summary": one box under the model's
2359
+ // summary. Send goes to Messages, then COS, and submits an Ask (panes/cos-chat.js askAbout) that carries the summary,
2360
+ // when it was written and the sessions it read, as the Ask's quoted reference (data, never instructions; no transcript
2361
+ // text, only the last message into each session). COS can then answer, or pass an instruction to a session by its
2362
+ // title through the Ask's own relay ("Sent to <title>" on the answer card). Hidden without a model summary, without
2363
+ // Ask on the Mac (capabilities: durable query jobs) or while the Mac does not answer.
2364
+ function paintTodayAsk(brief) {
2365
+ var form = $('today-ask'); if (!form) return;
2366
+ var chat = window.COSWeb && window.COSWeb.cosChat;
2367
+ // Ask's capability is read once per page (the chat's /api/health read); paint again when it lands.
2368
+ if (brief && chat && chat.known && !chat.known() && chat.ensureCaps && !TODAY.askCaps) {
2369
+ TODAY.askCaps = true;
2370
+ chat.ensureCaps().then(function () { if (TODAY.model && state.pane === 'today') renderStory(TODAY.model); }, function () {});
2371
+ }
2372
+ // T1 spec 0: the box stays while a session read is pending or failed; briefShown decides whether there is a summary.
2373
+ var on = !!(brief && !TODAY.offline && chat && chat.askAbout && chat.askOn && chat.askOn());
2374
+ form.classList.toggle('hidden', !on);
2375
+ }
2376
+ // The sessions the summary read: the server's own list with the summary (7.0.10+ round 2 `sessions`: title, provider,
2377
+ // state, when it last moved, the last message into it), else what this page holds for the same rows.
2378
+ function todayAskContext(b) {
2379
+ var rows = [];
2380
+ if (Array.isArray(b.sessions) && b.sessions.length) {
2381
+ b.sessions.forEach(function (r) { if (r && r.title) rows.push({ title: String(r.title), provider: String(r.provider || 'claude'), state: String(r.state || ''), movedAt: r.movedAt || null, lastAsk: r.lastAsk ? String(r.lastAsk) : '' }); });
2382
+ } else {
2383
+ (state.sessions || []).forEach(function (s) {
2384
+ if (sxKeepWarm(s)) return;
2385
+ var kind = sxState(s) === 'waiting' ? 'waiting' : s.running_active ? 'working' : s.running ? 'open' : '';
2386
+ if (!kind) return;
2387
+ var k = splitSessionId(s.provider, s.session_id || s.id);
2388
+ rows.push({ title: sessionTitle(s), provider: k.provider, state: kind, movedAt: kind === 'open' ? (s.last_activity_at || s.state_since) : (s.state_since || s.last_activity_at), lastAsk: '' });
2389
+ });
2390
+ }
2391
+ return { summary: String(b.text || ''), at: b.at || null, sessions: rows };
2392
+ }
2393
+ function sendTodayAsk() {
2394
+ var input = $('today-ask-input'), text = input ? input.value.trim() : '';
2395
+ var chat = window.COSWeb && window.COSWeb.cosChat, b = briefShown();
2396
+ if (!text || TODAY.askBusy || !b || !chat || !chat.askAbout) return;
2397
+ TODAY.askBusy = true;
2398
+ var ctx = todayAskContext(b);
2399
+ go('messages', ['cos']);
2400
+ chat.askAbout(text, ctx).then(function () { input.value = ''; autoGrow(input); }, function (err) {
2401
+ toast((err && err.message) || 'Ask did not send. Your words are still in the box on Today.');
2402
+ }).then(function () { TODAY.askBusy = false; });
2403
+ }
1079
2404
  // From today's meetings: Work's suggestions from meetings held today. They stay here until
1080
2405
  // midnight, then join Sort in Work; Review opens them in Work's Sort.
1081
2406
  function localDay(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); }
@@ -1291,7 +2616,7 @@
1291
2616
  var stats = view === 'sessions' ? sxStats(vis) : [];
1292
2617
  var busy = view === 'sessions' ? SX.loading : SX.usageLoading;
1293
2618
  hero.innerHTML = '<span class="cx-glyph"><svg class="icon"><use href="#i-sessions"/></svg></span>' +
1294
- '<div class="cx-hero-text"><h1 class="cx-title">Sessions</h1><p class="cx-detail-line">' + esc(detail) + '</p></div>' +
2619
+ '<div class="cx-hero-text"><h1 class="cx-title">Sessions</h1><p class="cx-detail-line">' + esc(detail) + liveHeroNote(view) + '</p></div>' +
1295
2620
  '<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>' +
1296
2621
  (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>' : '');
1297
2622
  var clockOn = view === 'sessions' && !sxQueryActive();
@@ -1306,6 +2631,12 @@
1306
2631
  if (usage) usage.classList.toggle('hidden', view === 'sessions');
1307
2632
  }
1308
2633
 
2634
+ // 7.0.1: the hero's counts say when they are not live ("Reconnecting", "as of 2 min ago"); nothing without a stream.
2635
+ function liveHeroNote(view) {
2636
+ if (view !== 'sessions' || typeof liveSessionsNote !== 'function') return '';
2637
+ var n = liveSessionsNote();
2638
+ return '<span class="sx-live-note">' + (n ? ' · ' + esc(n) : '') + '</span>';
2639
+ }
1309
2640
  function loadSessions(force) {
1310
2641
  var r = parseRoute();
1311
2642
  var reading = r.pane === 'sessions' && r.args.length === 2;
@@ -1371,7 +2702,7 @@
1371
2702
  var meta = sxBadge(key.provider) +
1372
2703
  (s.pinned ? '<span class="sx-pinned">PINNED</span>' : '') +
1373
2704
  (label ? '<span class="sx-chip ' + st + '">' + esc(label) + '</span>' : '') +
1374
- (notSentFor(key.provider, key.id) ? '<span class="sx-chip refused" title="' + esc(notSentFor(key.provider, key.id).line) + '">Not sent</span>' : '') +
2705
+ (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>' : '') +
1375
2706
  (upd ? '<span class="sx-when">Updated ' + esc(sxShortDate(upd)) + '</span>' : '') +
1376
2707
  (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>' : '') +
1377
2708
  (s.project && String(s.project) !== title ? '<span class="sx-ws">' + esc(s.project) + '</span>' : '');
@@ -1514,20 +2845,84 @@
1514
2845
  renderSessionGroups();
1515
2846
  });
1516
2847
  }
1517
- // The meaning finder the Ask sheet offers ("Find a session about this").
1518
- function findByMeaning(q) {
1519
- return api('/api/agent-sessions/find', { method: 'POST', body: { query: q } }).then(function (d) {
1520
- if (d && d.available === false) return emptyBox('Meaning search is off', d.message || 'Turn on a decision provider in Settings.');
1521
- var results = (d && d.results) || [];
1522
- if (!results.length) return emptyBox('Nothing matched that meaning', 'Try different words, or search by Words.');
1523
- return '<h2 class="group-title"><span>About “' + esc(q) + '”</span><span>' + results.length + '</span></h2><div class="list">' + results.map(function (r) {
1524
- var s = r.session || r;
1525
- if (!s.provider && r.provider) s.provider = r.provider;
1526
- if (!s.session_id) s.session_id = r.sessionId || r.id;
1527
- return sessionRow(s, r.reason || s.project);
1528
- }).join('') + '</div>';
1529
- }).catch(function (err) { return emptyBox('Meaning search did not run', err.message); });
2848
+ // ── Meaning search (W1, 7.0.x): "Find a session about this", as COS Control's command panel does it
2849
+ // (SetupAssistant.swift 1476-1900). POST /api/agent-sessions/find takes exactly { query, consentedProvider, stage }
2850
+ // (routes/session-find.ts:45-51). Nothing is sent before the person says yes for the decision model that decides search
2851
+ // now. A yes for Jev is no yes for SubQ. The yes is kept in this browser only (the Mac keeps its own).
2852
+ var MEANING_STORE = 'cos.web.meaningConsent';
2853
+ var MEANING_NAME = { jev: 'Jev', subq: 'SubQ' };
2854
+ var MEANING_TITLE = { consent_required: 'Meaning search needs your okay', provider_changed: 'The decision model changed',
2855
+ decision_not_configured: 'Meaning search needs a key', jev_not_configured: 'Meaning search needs a key', sessions_off: 'Sessions are off',
2856
+ find_off: 'Meaning search is off', cap_reached: 'Meaning search is used up for today', breaker_open: 'Meaning search is paused',
2857
+ query_too_short: 'Type a few more words', failed: 'Meaning search did not answer' };
2858
+ var MEANING_STRENGTH = { strong: 'Strong match', likely: 'Likely match', possible: 'Possible match' };
2859
+ var MEANING = { view: null, at: 0 };
2860
+ function meaningChoices() {
2861
+ try { var v = JSON.parse(localStorage.getItem(MEANING_STORE) || '{}'); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; } catch (e) { return {}; }
2862
+ }
2863
+ function meaningChoose(provider, choice) {
2864
+ if (!MEANING_NAME[provider]) return;
2865
+ var c = meaningChoices();
2866
+ if (choice === 'on' || choice === 'never') c[provider] = choice; else delete c[provider];
2867
+ try { localStorage.setItem(MEANING_STORE, JSON.stringify(c)); } catch (e) { /* private mode: the question comes back next time */ }
2868
+ }
2869
+ // GET /api/decision-provider: the model that decides search now, whether its key is saved, and whether this server can
2870
+ // find sessions by meaning at all (capabilities.sessionFind). Read again after a minute, or after a refusal.
2871
+ function meaningSettings(force) {
2872
+ if (!force && MEANING.view && Date.now() - MEANING.at < 60000) return Promise.resolve(MEANING.view);
2873
+ return api('/api/decision-provider').then(function (d) {
2874
+ var p = d && d.effective ? d.effective.search : null;
2875
+ p = MEANING_NAME[p] ? p : null;
2876
+ MEANING.view = { provider: p, keyed: !!(p && d.configured && d.configured[p] === true), capable: !!(d && d.capabilities && d.capabilities.sessionFind === true) };
2877
+ MEANING.at = Date.now();
2878
+ return MEANING.view;
2879
+ });
1530
2880
  }
2881
+ function meaningConsentText(provider) {
2882
+ return 'Each search sends the words you type here, plus the title and first 150 characters of the first prompt from up to 508 recent sessions, to ' +
2883
+ MEANING_NAME[provider] + '. Emails, links, home-folder paths and key-shaped text are removed first. Names and other details are not.' +
2884
+ (provider === 'subq' ? ' SubQ keeps what it is sent.' : '');
2885
+ }
2886
+ function meaningConsentHtml(provider) {
2887
+ // Lead decision (11/09): the choice is kept in this browser only, and the card says so.
2888
+ return '<div class="list meaning-consent"><p class="empty"><strong>Find sessions by meaning with ' + esc(MEANING_NAME[provider]) + '?</strong>' + esc(meaningConsentText(provider)) +
2889
+ ' ' + esc('Your choice applies to this browser only.') + '</p>' +
2890
+ '<div class="row-actions"><button type="button" class="btn small primary" data-action="meaning-consent" data-provider="' + esc(provider) + '" data-choice="on">Turn on</button>' +
2891
+ '<button type="button" class="btn small" data-action="meaning-consent" data-provider="' + esc(provider) + '" data-choice="never">Never</button></div></div>';
2892
+ }
2893
+ function meaningRows(q, d) {
2894
+ var rows = (d && Array.isArray(d.rows)) ? d.rows : [];
2895
+ return '<h2 class="group-title"><span>About \u201c' + esc(q) + '\u201d</span><span>' + rows.length + '</span></h2><div class="list">' + rows.map(function (r) {
2896
+ var s = { provider: r.provider, session_id: r.session_id, display_label: r.display_label, custom_title: r.custom_title, first_prompt: r.first_prompt, project: r.project,
2897
+ modified: r.modified, last_activity_at: r.last_activity_at, alive: r.alive, pinned: r.pinned, slug: r.slug };
2898
+ return sessionRow(s, MEANING_STRENGTH[r.strength] || MEANING_STRENGTH.possible);
2899
+ }).join('') + '</div>';
2900
+ }
2901
+ function meaningRun(q, provider, stage) {
2902
+ return api('/api/agent-sessions/find', { method: 'POST', body: { query: q, consentedProvider: provider, stage: stage }, timeoutMs: 90000 }).then(function (d) {
2903
+ if (d && d.available === false) {
2904
+ if (d.reason === 'provider_changed') MEANING.view = null;
2905
+ return emptyBox(MEANING_TITLE[d.reason] || 'Meaning search did not run', d.message || '');
2906
+ }
2907
+ var rows = (d && Array.isArray(d.rows)) ? d.rows : [];
2908
+ // The first stage reads the newest 254 sessions; with nothing there, look once further back (Control's "Looking further back").
2909
+ if (!rows.length && stage === 'first') return meaningRun(q, provider, 'full');
2910
+ if (!rows.length) return emptyBox('Nothing close by meaning', 'Checked ' + (Number(d && d.asked) || 0) + ' sessions. Try other words, or search by words.');
2911
+ return meaningRows(q, d);
2912
+ });
2913
+ }
2914
+ function findByMeaning(q) {
2915
+ return meaningSettings(false).then(function (m) {
2916
+ if (!m.capable) return emptyBox('Meaning search is not on here', 'This COS server cannot find sessions by meaning. Search by words instead.');
2917
+ if (!m.provider || !m.keyed) return emptyBox('Meaning search needs a decision model', 'Choose Jev or SubQ in Settings, then save its key in COS Control on your Mac: search Settings there for Jev key or SubQ key.');
2918
+ var choice = meaningChoices()[m.provider];
2919
+ if (choice === 'never') return emptyBox('Meaning search is off in this browser', 'You chose Never for ' + MEANING_NAME[m.provider] + '.') +
2920
+ '<div class="row-actions"><button type="button" class="btn small" data-action="meaning-consent" data-provider="' + esc(m.provider) + '" data-choice="ask">Ask me again</button></div>';
2921
+ if (choice !== 'on') return meaningConsentHtml(m.provider);
2922
+ return meaningRun(q, m.provider, 'first');
2923
+ }).catch(function (err) { return emptyBox('Meaning search did not run', (err && err.message) || ''); });
2924
+ }
2925
+ // ── end meaning search
1531
2926
 
1532
2927
  // The hooks banner (ActivityWindow.swift 2867-2905). Installing hooks writes to the Mac's Claude
1533
2928
  // settings, which stays a Mac action, so the web names where to do it.
@@ -1577,10 +2972,12 @@
1577
2972
  }
1578
2973
  if (act === 'clear') { var input = $('sessions-q'); input.value = ''; searchSessions(); input.focus(); return; }
1579
2974
  if (act === 'copy-session') return copySessionBrief();
2975
+ if (act === 'full') { if (SX_SPLIT) SX_SPLIT.toggleFull(); return; }
1580
2976
  if (act === 'refresh-turns' && rd) { rd.refusal = null; renderVerdict(rd); refreshTurns(rd.provider, rd.id); return; }
1581
2977
  if (act === 'retry' && rd) { rd.refusal = null; renderVerdict(rd); return sendSessionTurn(!!rd.retryFork); }
1582
2978
  if (act === 'caution-send' && rd) { rd.cautionAck = true; rd.cautionAsk = false; renderVerdict(rd); return sendSessionTurn(false); }
1583
2979
  if (act === 'caution-cancel' && rd) { rd.cautionAsk = false; renderVerdict(rd); return; }
2980
+ if (act === 'turnend' && rd) return sendTurnEndChoice(rd, Number(t.getAttribute('data-i')));
1584
2981
  if (act === 'usage-clear') { SX.uTool = 'All tools'; SX.uModel = 'All models'; SX.uToolBefore = null; uSliceChanged(); return paintUsage(); }
1585
2982
  if (act === 'usage-range') return uOpenRange(t, true);
1586
2983
  });
@@ -1605,6 +3002,153 @@
1605
3002
  });
1606
3003
  }
1607
3004
 
3005
+ // Duo tables: the divider between two columns, where both show side by side. One kit (splitKit) for every pane that
3006
+ // has them: a list beside its reader in Sessions (760 px and up), Memories (760 px and up) and Messages with the COS
3007
+ // 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).
3008
+ // Drag it with a finger, mouse or pen (pointer events, captured, so a table's own sideways scroll and text selection
3009
+ // in the panes never start a drag), or focus it and use the arrow keys (Shift for bigger steps, Home and End).
3010
+ // 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
3011
+ // list keeps 240 px and the reader 380 px, also after the screen turns) and is remembered on this device, one key per
3012
+ // pane; "Full width" in the reader hides the list and "Show list" brings it back. Storage that throws (a private
3013
+ // window) only means the choice lasts this visit. Below a pane's own breakpoint the divider is hidden by CSS and the
3014
+ // kit does nothing (o.media), so a phone's layout never changes.
3015
+ 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;
3016
+ // o: key (storage), prefix (class names: <prefix>-sized, -full and -dragging), cssVar (the share, default
3017
+ // --<prefix>-list-share), label (what the divider sizes, for screen readers), sized (the column the share sizes,
3018
+ // default the list), side ('end' when that column is on the right: a side panel grows as the line moves left),
3019
+ // fullSel (the Full width buttons), media (where the divider shows), listMin (the sized column's minimum), readerMin
3020
+ // (the other column's), handleW (px, the same numbers as the pane's CSS clamp), memo (this visit's choice, kept across
3021
+ // a pane's repaint when storage is off).
3022
+ function splitKit(split, handle, storage, o) {
3023
+ var KEY = o.key, PRE = o.prefix || 'split', LIST_MIN = o.listMin || SX_LIST_MIN, READER_MIN = o.readerMin || SX_READER_MIN,
3024
+ 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,
3025
+ VAR = o.cssVar || '--' + PRE + '-list-share', END = o.side === 'end';
3026
+ var saved = {};
3027
+ try { var raw = JSON.parse(storage.getItem(KEY) || 'null'); if (raw && typeof raw === 'object') saved = raw; } catch (_e) { saved = {}; }
3028
+ if (memo && memo.at) saved = memo;
3029
+ var share = typeof saved.share === 'number' && saved.share > 0 && saved.share < 100 ? saved.share : null, full = saved.full === true, lastTap = 0;
3030
+ function save() {
3031
+ if (memo) { memo.share = share; memo.full = full; memo.at = Date.now(); }
3032
+ try { storage.setItem(KEY, JSON.stringify({ share: share, full: full })); } catch (_e) { /* this visit only */ }
3033
+ }
3034
+ // Narrow (a phone, the folded Duo): the divider is not shown, and nothing here may move the layout.
3035
+ function narrow() { return !!o.media && typeof matchMedia === 'function' && !matchMedia(o.media).matches; }
3036
+ function total() { return split.getBoundingClientRect().width; }
3037
+ function listWidth() { var l = split.querySelector(LIST_SEL); return l ? l.getBoundingClientRect().width : 0; }
3038
+ function bounds(t) { return { min: LIST_MIN, max: Math.max(LIST_MIN, t - HANDLE_W - READER_MIN) }; }
3039
+ function aria() {
3040
+ var t = total(); if (!(t > 0)) return;
3041
+ var b = bounds(t), pct = function (px) { return Math.round(px / t * 100); }, now = pct(listWidth());
3042
+ handle.setAttribute('aria-valuemin', String(pct(b.min)));
3043
+ handle.setAttribute('aria-valuemax', String(pct(b.max)));
3044
+ handle.setAttribute('aria-valuenow', String(now));
3045
+ handle.setAttribute('aria-valuetext', (o.label || 'List') + ' ' + now + '% of the width');
3046
+ }
3047
+ function paint() {
3048
+ split.classList.toggle(PRE + '-sized', share != null);
3049
+ if (share != null) split.style.setProperty(VAR, share.toFixed(2) + '%'); else split.style.removeProperty(VAR);
3050
+ split.classList.toggle(PRE + '-full', full);
3051
+ document.querySelectorAll(FULL_SEL).forEach(function (b) { b.setAttribute('aria-pressed', String(full)); b.textContent = full ? 'Show list' : 'Full width'; });
3052
+ aria();
3053
+ }
3054
+ // px: the sized column's width asked for; kept inside both columns' minimums for the split's width now.
3055
+ function setWidth(px, persist) {
3056
+ if (narrow()) return;
3057
+ var t = total(); if (!(t > 0)) return;
3058
+ var b = bounds(t);
3059
+ share = Math.min(b.max, Math.max(b.min, px)) / t * 100;
3060
+ paint();
3061
+ if (persist) save();
3062
+ }
3063
+ function reset() { share = null; paint(); save(); }
3064
+ handle.addEventListener('pointerdown', function (e) {
3065
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
3066
+ if (narrow()) return;
3067
+ e.preventDefault();
3068
+ var x0 = e.clientX, start = listWidth(), moved = false, id = e.pointerId;
3069
+ try { handle.setPointerCapture(id); } catch (_e) {}
3070
+ split.classList.add(PRE + '-dragging');
3071
+ // A column on the right grows as the pointer moves left.
3072
+ function dx(ev) { return END ? x0 - ev.clientX : ev.clientX - x0; }
3073
+ function move(ev) {
3074
+ if (ev.pointerId !== id) return;
3075
+ if (!moved && Math.abs(ev.clientX - x0) < 4) return;
3076
+ moved = true;
3077
+ setWidth(start + dx(ev), false);
3078
+ }
3079
+ function end(ev) {
3080
+ if (ev.pointerId !== id) return;
3081
+ handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', end); handle.removeEventListener('pointercancel', end);
3082
+ split.classList.remove(PRE + '-dragging');
3083
+ // The lift's own position counts: a browser may fold the last moves into it.
3084
+ if (moved) { if (ev.type === 'pointerup') setWidth(start + dx(ev), false); save(); lastTap = 0; return; }
3085
+ if (ev.type !== 'pointerup') return;
3086
+ var now = Date.now();
3087
+ if (now - lastTap < SX_DOUBLE_MS) { lastTap = 0; reset(); } else lastTap = now;
3088
+ }
3089
+ handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', end); handle.addEventListener('pointercancel', end);
3090
+ });
3091
+ handle.addEventListener('dblclick', function (e) { if (narrow()) return; e.preventDefault(); reset(); });
3092
+ handle.addEventListener('keydown', function (e) {
3093
+ if (narrow()) return;
3094
+ var t = total(), b = bounds(t), w = listWidth(), step = e.shiftKey ? SX_KEY_STEP * 4 : SX_KEY_STEP;
3095
+ // The arrows move the line the way they point; Home and End take it to the left and right limits.
3096
+ var left = END ? 1 : -1;
3097
+ 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;
3098
+ if (to == null) return;
3099
+ e.preventDefault();
3100
+ setWidth(to, true);
3101
+ });
3102
+ paint();
3103
+ return {
3104
+ setWidth: setWidth, reset: reset, paint: paint,
3105
+ toggleFull: function () { full = !full; paint(); save(); },
3106
+ state: function () { return { share: share, full: full }; }
3107
+ };
3108
+ }
3109
+ // Sessions: the first pane with the divider, unchanged (its markup is in index.html, its CSS in panes/sessions.css).
3110
+ var SX_SPLIT_OPTS = { key: SX_SPLIT_KEY, prefix: 'sx', label: 'Session list', fullSel: '[data-sx="full"]', media: '(min-width: 760px)',
3111
+ listMin: SX_LIST_MIN, readerMin: SX_READER_MIN, handleW: SX_HANDLE_W };
3112
+ function sxSplitKit(split, handle, storage) { return splitKit(split, handle, storage, SX_SPLIT_OPTS); }
3113
+ // The other panes (W.splitKit): the divider's markup, a Full width button, and mount, which binds the kit to a split
3114
+ // 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.
3115
+ var SPLITS = {}, SPLIT_MEMO = {}, SPLIT_NO_STORE = { getItem: function () { return null; }, setItem: function () {} };
3116
+ function splitStorage() { try { return window.localStorage || null; } catch (_e) { return null; } }
3117
+ function splitHandleHtml(o) {
3118
+ return '<div class="split-handle" role="separator" aria-orientation="vertical" aria-controls="' + esc(o.controls) + '" aria-label="' + esc(o.label + ' width') +
3119
+ '" tabindex="0" title="Drag to resize. Double-tap or double-click to reset."' + (o.id ? ' id="' + esc(o.id) + '"' : '') + '></div>';
3120
+ }
3121
+ function splitFullButton(key, controls) {
3122
+ var live = SPLITS[key], full = !!(live && live.kit.state().full);
3123
+ 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>';
3124
+ }
3125
+ function mountSplit(split, handle, o) {
3126
+ if (!split || !handle || !o || !o.key) return null;
3127
+ var live = SPLITS[o.key];
3128
+ if (live && live.split === split && live.handle === handle) return live.kit;
3129
+ var memo = SPLIT_MEMO[o.key] || (SPLIT_MEMO[o.key] = {});
3130
+ var kit = splitKit(split, handle, splitStorage() || SPLIT_NO_STORE, Object.assign({}, o, { memo: memo }));
3131
+ SPLITS[o.key] = { kit: kit, split: split, handle: handle };
3132
+ return kit;
3133
+ }
3134
+ var SX_SPLIT = null;
3135
+ (function () {
3136
+ var split = $('sessions-split'), handle = $('sessions-handle');
3137
+ if (!split || !handle) return;
3138
+ var store = splitStorage();
3139
+ SX_SPLIT = sxSplitKit(split, handle, store || SPLIT_NO_STORE);
3140
+ })();
3141
+ // A turned screen or a fold changes the split's width: every divider on the page repaints its limits.
3142
+ window.addEventListener('resize', function () {
3143
+ if (SX_SPLIT) SX_SPLIT.paint();
3144
+ Object.keys(SPLITS).forEach(function (k) { var s = SPLITS[k]; if (s.split.isConnected) s.kit.paint(); });
3145
+ });
3146
+ document.addEventListener('click', function (e) {
3147
+ var b = e.target && e.target.closest ? e.target.closest('[data-split-full]') : null;
3148
+ var live = b ? SPLITS[b.getAttribute('data-split-full')] : null;
3149
+ if (live) live.kit.toggleFull();
3150
+ });
3151
+
1608
3152
  // ── Sessions: read, send, queue, fork, stop (the glasses' Continue, from a browser) ──
1609
3153
  // The server owns every safety rule (agent-session-bindings.ts): COS writes into a desktop
1610
3154
  // thread only through a binding, attaches only when no live process owns the thread, and
@@ -1654,14 +3198,19 @@
1654
3198
  // queue replaces it, so a row the server drops clears the mark.
1655
3199
  var NOT_SENT_KEY = 'cos_web_not_sent', NOT_SENT_KEEP_MS = 86400000;
1656
3200
  function notSentRows(rows) { return (rows || []).filter(function (t) { return t && (t.status === 'refused' || t.status === 'expired'); }); }
3201
+ // A row COS cannot tell went in or not (deliveryState unknown, e.g. a steer it could not confirm) is not "Not sent":
3202
+ // it may be in the session already, so it has its own label and its own line, never a "Not sent:" prefix.
3203
+ function maybeSent(t) { return !!(t && t.deliveryState === 'unknown'); }
3204
+ function notSentLabel(t) { return maybeSent(t) ? 'May have been sent' : 'Not sent'; }
1657
3205
  function notSentLine(t) {
3206
+ if (maybeSent(t)) return String(t.reasonCopy || 'May not have been sent. Read it, then say it again.').trim();
1658
3207
  var why = String((t && t.reasonCopy) || (t && t.status === 'expired' ? 'the thread stayed busy too long.' : 'the session refused it.')).trim();
1659
3208
  return /^not sent\b/i.test(why) ? why : 'Not sent: ' + why.charAt(0).toLowerCase() + why.slice(1);
1660
3209
  }
1661
3210
  function noteNotSent(provider, id, rows) {
1662
3211
  var all = readStore(NOT_SENT_KEY), k = provider + '|' + id, now = Date.now(), list = notSentRows(rows);
1663
3212
  Object.keys(all).forEach(function (x) { if (!all[x] || now - (Number(all[x].at) || 0) > NOT_SENT_KEEP_MS) delete all[x]; });
1664
- if (list.length) all[k] = { n: list.length, line: notSentLine(list[list.length - 1]).slice(0, 240), at: now }; else delete all[k];
3213
+ 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];
1665
3214
  writeStore(NOT_SENT_KEY, all);
1666
3215
  }
1667
3216
  function notSentFor(provider, id) {
@@ -1738,6 +3287,11 @@
1738
3287
  return 'Showing the last ' + turns.length + (total > turns.length && !d.truncated ? ' of ' + total : '') + ' turns. Copy session keeps the original request plus the newest context.';
1739
3288
  }
1740
3289
 
3290
+ // Wide layouts: give the reader the whole width, or bring the list back (sxSplitKit). Hidden under 760 px by CSS.
3291
+ function sxFullButton() {
3292
+ var full = !!(SX_SPLIT && SX_SPLIT.state().full);
3293
+ 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>';
3294
+ }
1741
3295
  function openSession(provider, id) {
1742
3296
  var reader = $('session-reader');
1743
3297
  if (!provider || !id) return;
@@ -1752,7 +3306,7 @@
1752
3306
  var listed = (SX.rows || []).filter(function (s) { return sxSame(sxKey(s), provider + ':' + String(id).toLowerCase()); })[0];
1753
3307
  if (listed) window.COSWeb.setCrumb(sxTitle(listed));
1754
3308
  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);
1755
- api(sessionPath(provider, id, '?turns=' + SX_TURNS)).then(function (d) {
3309
+ api(sessionPath(provider, id, '?turns=' + SX_TURNS + '&cards=1')).then(function (d) {
1756
3310
  if (state.reading !== r) return;
1757
3311
  var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : ((d && d.recent_turns && d.recent_turns.turns) || (d && d.turns) || []);
1758
3312
  r.detail = d; r.turns = turns; r.more = d.recent_turns_more === true;
@@ -1761,15 +3315,19 @@
1761
3315
  window.COSWeb.setCrumb(title);
1762
3316
  var trunc = sxTruncNote(d, turns, r.more);
1763
3317
  reader.innerHTML =
1764
- '<div class="sx-reader-head"><div class="sx-head-line">' + sxBadge(provider) + '<h2 class="sx-reader-title">' + esc(title) + '</h2></div>' +
3318
+ '<div class="sx-reader-head"><div class="sx-head-line">' + sxBadge(provider) + '<h2 class="sx-reader-title">' + esc(title) + '</h2>' + sxFullButton() + '</div>' +
1765
3319
  '<p class="sx-subtitle">' + esc(sxSubtitle(d, provider, turns, r.more)) + '</p></div>' +
1766
3320
  '<div class="sx-activity" id="session-live" aria-live="polite"></div>' +
3321
+ '<div class="sx-mac-q" id="session-mac-q" role="status" hidden></div>' +
1767
3322
  (trunc ? '<p class="sx-note">' + esc(trunc) + '</p>' : '') +
1768
3323
  '<div class="turns sx-turns" id="session-turns">' + renderTurns(turns, d) + '</div>' +
1769
3324
  '<div class="sx-foot"><button type="button" class="cx-btn quiet" data-sx="copy-session" title="A kickstart brief for another agent, built from the turns COS serves. Not a Claude Code resume: it does not reopen this session.">Copy session</button>' +
1770
3325
  '<span class="sx-foot-note">Kickstart brief for another agent. Not a Claude Code resume.</span></div>' +
1771
3326
  '<p class="sx-copy-note hidden" id="session-copy-note" role="status"></p>' +
1772
3327
  '<div class="sx-chat">' +
3328
+ // DESIGN.md step 4 #3: this session's question cards (held and answerable, or at the Mac with its words), above
3329
+ // the verdict and the Send box, the same component Today's sheet mounts, so the full hold lights up here.
3330
+ '<div class="sx-questions" id="session-questions"></div>' +
1773
3331
  '<div id="session-verdict"></div>' +
1774
3332
  '<form class="composer att-composer" id="session-form" data-provider="' + esc(provider) + '" data-id="' + esc(id) + '">' +
1775
3333
  '<div class="cc-chips att-files hidden" id="session-files"></div>' +
@@ -1783,9 +3341,13 @@
1783
3341
  readerImagesMount($('session-turns'), r);
1784
3342
  sxNoteList(r, d);
1785
3343
  setLive({});
3344
+ if (typeof readerMacPaint === 'function') readerMacPaint(r, sxState(d) === 'waiting');
3345
+ var qk = window.COSWeb.sessionQuestions, qhost = $('session-questions');
3346
+ if (qk && qk.mount && qhost) r.qMount = qk.mount(qhost, id, function () { if (state.reading === r && typeof readerMacPaint === 'function') readerMacPaint(r, r.waitingNow); });
1786
3347
  paintReaderFiles();
3348
+ paintWebKeyOffer(r);
1787
3349
  // The Mac's answer about files may not be in yet: ask once, then draw the clip.
1788
- var kitR = readerKit(); if (kitR && !kitR.turnFiles()) kitR.ensureCaps().then(paintReaderFiles, function () {});
3350
+ var kitR = readerKit(); if (kitR && !kitR.turnFiles()) kitR.ensureCaps().then(function () { paintReaderFiles(); paintWebKeyOffer(r); }, function () {});
1789
3351
  renderHeld(state.reading);
1790
3352
  loadQueue(provider, id);
1791
3353
  sxProbeAttach(r);
@@ -2057,6 +3619,7 @@
2057
3619
  var waiting = !working && (e.state === 'waiting' || e.state === 'needs_input' || e.agent_state === 'waiting');
2058
3620
  var el = $('session-state');
2059
3621
  if (el) el.textContent = working ? 'Working now' : waiting ? 'Waiting for you' : 'Open';
3622
+ if (typeof readerMacPaint === 'function') readerMacPaint(r, waiting);
2060
3623
  if (wasWorking && !working) { refreshTurns(provider, id); replyEnded(r); }
2061
3624
  wasWorking = working;
2062
3625
  // Gone to waiting: its question may be parked for this page now (6.99.27).
@@ -2082,7 +3645,7 @@
2082
3645
  }
2083
3646
  function refreshTurns(provider, id) {
2084
3647
  var reading = state.reading;
2085
- api(sessionPath(provider, id, '?turns=' + SX_TURNS)).then(function (d) {
3648
+ api(sessionPath(provider, id, '?turns=' + SX_TURNS + '&cards=1')).then(function (d) {
2086
3649
  var r = state.reading;
2087
3650
  if (!r || r !== reading || r.id !== id || r.provider !== provider) return;
2088
3651
  var turns = Array.isArray(d && d.recent_turns) ? d.recent_turns : [];
@@ -2122,7 +3685,7 @@
2122
3685
  (mine ? '<button type="button" class="cx-btn quiet" data-action="unqueue" data-turn-key="' + esc(t.clientTurnId) + '" title="Cancel this queued message. It will not be sent.">Cancel</button>' : '') + '</div>';
2123
3686
  }).join('') +
2124
3687
  notSent.map(function (t) {
2125
- return '<div class="sx-queue-row sx-queue-refused"><span class="sx-queue-state">Not sent</span><span class="sx-queue-text">' + esc(t.preview || t.prompt || 'Message') +
3688
+ 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') +
2126
3689
  '<span class="sx-queue-reason">' + esc(notSentLine(t)) + '</span></span></div>';
2127
3690
  }).join('') + '</div>';
2128
3691
  }).catch(function () {});
@@ -2143,7 +3706,9 @@
2143
3706
  var b = a.body || {};
2144
3707
  if (a.status !== 200 || typeof b.attachable !== 'boolean') { failed(); return; }
2145
3708
  var owners = Number(b.ownerCount) || 0;
2146
- r.verdict = { attachable: b.attachable, reason: String(b.reason || ''), reasonCopy: String(b.reasonCopy || ''), ownerCount: owners, caution: b.attachable === true && owners > 0 };
3709
+ r.verdict = { attachable: b.attachable, reason: String(b.reason || ''), reasonCopy: String(b.reasonCopy || ''), ownerCount: owners, caution: b.attachable === true && owners > 0,
3710
+ // Steer mid-turn: the server says whether a message sent now goes into the running turn.
3711
+ midTurn: !!(b.midTurn && b.midTurn.available === true) };
2147
3712
  // 6.99.29: the gate's answer is a signal of the one state, as of now.
2148
3713
  sxNoteGate(r, b.attachable === false && b.reason === 'native_thread_working');
2149
3714
  sxRepaint(r);
@@ -2154,6 +3719,17 @@
2154
3719
  codex: 'Working right now. A message you send joins the Codex app’s queue for this thread.',
2155
3720
  claude: 'Working right now. A message you send waits and lands when this turn ends.'
2156
3721
  };
3722
+ // Steer mid-turn: only when the server says this session takes a message inside its running turn.
3723
+ var SX_PARK_MIDTURN = 'Working right now. Your message lands at its next step.';
3724
+ // 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
3725
+ // turn end, and the park answer says landsAt turn_end). With files in the box the note says so.
3726
+ var SX_PARK_FILES = 'Working right now. A message with files waits and lands when this turn ends.';
3727
+ // `hasFiles`: a view (Today's sheet) says whether its own box holds files; the reader's box is read here.
3728
+ function sxParkLine(r, hasFiles) {
3729
+ if (!(r.verdict && r.verdict.midTurn === true)) return SX_PARK[sxProvider(r.provider)];
3730
+ if (hasFiles === undefined) hasFiles = sxReaderHasFiles(r);
3731
+ return hasFiles ? SX_PARK_FILES : SX_PARK_MIDTURN;
3732
+ }
2157
3733
  /** The composer's "Working right now" note: a busy gate verdict, and the one state agrees. */
2158
3734
  function sxParkNoteShows(r) {
2159
3735
  var v = r.verdict;
@@ -2186,7 +3762,7 @@
2186
3762
  (f.fork ? forkBtn : ''));
2187
3763
  } else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
2188
3764
  // 6.99.29: "Working right now" only while the one state says so.
2189
- if (sxParkNoteShows(r)) html = '<div class="sx-park"><p>' + esc(SX_PARK[sxProvider(r.provider)]) + '</p>' + (/fork/i.test(v.reasonCopy) ? '<div class="sx-box-actions">' + forkBtn + '</div>' : '') + '</div>';
3765
+ 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>';
2190
3766
  } else if (v && v.attachable === false && v.reasonCopy) {
2191
3767
  html = refusalBox(v.reasonCopy, sxSupplement(v.reason), /fork/i.test(v.reasonCopy) ? forkBtn : '');
2192
3768
  }
@@ -2221,7 +3797,8 @@
2221
3797
  // The box is cleared only of the very words that were sent; anything typed since stays.
2222
3798
  function clearBoxOf(text) {
2223
3799
  var input = $('session-input');
2224
- if (input && input.value.trim() === String(text).trim()) { input.value = ''; autoGrow(input); }
3800
+ // 7.0.18: an answer to a turn-end ask was sent with the attribution in front; the box holds his words without it.
3801
+ if (input && (input.value.trim() === String(text).trim() || input.value.trim() === turnEndBare(text))) { input.value = ''; autoGrow(input); }
2225
3802
  if (state.reading && state.reading.draft && state.reading.draft.text === text) state.reading.draft = null;
2226
3803
  }
2227
3804
  // Words go back in the box only if the box is empty; otherwise the status line keeps them. `files` says what became
@@ -2282,6 +3859,12 @@
2282
3859
  if (!READER_FILES[key].anyBytes && !READER_FILES[key].atts.length && kit.turnFileBytes()) READER_FILES[key].anyBytes = kit.turnFileBytes();
2283
3860
  return READER_FILES[key];
2284
3861
  }
3862
+ // Whether the reader's own box holds a file for this session (any state: a file still uploading goes with the message).
3863
+ function sxReaderHasFiles(r) {
3864
+ if (!r || r.view || state.reading !== r || !readerFilesOn()) return false;
3865
+ var box = readerBox(r);
3866
+ return !!(box && box.atts.length);
3867
+ }
2285
3868
  function readerFilesState(box) {
2286
3869
  var kit = readerKit();
2287
3870
  if (!kit || !box || !box.atts.length) return 'none';
@@ -2297,6 +3880,8 @@
2297
3880
  Array.prototype.forEach.call(slot.querySelectorAll('[data-sx-file]'), function (i) { i.disabled = full || !!r.sending; });
2298
3881
  list.innerHTML = kit.chipsHtml(box, 'data-action') + (box.atts.length ? '<p class="att-limit">' + esc(kit.limitLine(box)) + '</p>' : '');
2299
3882
  list.classList.toggle('hidden', !box.atts.length);
3883
+ // QA 7.0.0: the "Working right now" note depends on whether files go with the message (sxParkLine).
3884
+ if (r && r.verdict) renderVerdict(r);
2300
3885
  // Send and Fork wait while a file uploads. A failed file never greys them out (QA 6.99.27): a tap says why.
2301
3886
  var st = readerFilesState(box), form = $('session-form');
2302
3887
  if (!form || r.sending) return;
@@ -2360,6 +3945,9 @@
2360
3945
  // during the first attach could park the same text in the queue while the first lands.
2361
3946
  if (r.sending) return;
2362
3947
  if (r.refusal || r.cautionAsk) { r.refusal = null; r.cautionAsk = false; renderVerdict(r); }
3948
+ // 7.0.18: while a turn-end ask is open, the box's words are his own answer ('Other' in the contract): sent with the
3949
+ // same attribution as a choice. A fork goes to a copy and is not an answer, so it is sent as written.
3950
+ if (!fork && r.turnEnd && turnEndOf(readerRowOf(r))) text = turnEndPrompt(text);
2363
3951
  r.sending = true;
2364
3952
  var btns = form.querySelectorAll('button'); btns.forEach(function (b) { b.disabled = true; });
2365
3953
  var done = function () { r.sending = false; btns.forEach(function (b) { b.disabled = false; }); paintReaderFiles(); };
@@ -2462,6 +4050,205 @@
2462
4050
  });
2463
4051
  }
2464
4052
 
4053
+ // ── BEGIN web-key handoff (6.99.31, Phase 3, review W2) ──
4054
+ // With the Mac's webKeySigner capability (only with COS_WEB_KEY_AUTHORITY on) and an enrolled phone key, the session
4055
+ // composer offers "Approve on this phone". That is a TOP-LEVEL navigation to the signer page on port 3144 carrying a
4056
+ // one-use handoff id in the fragment. The words stay on the Mac until the signer claims them; they are never in a URL.
4057
+ // Without the capability nothing here runs: no request, no button, the composer exactly as before.
4058
+ var WEB_KEY = { status: null, statusAt: 0, loading: null, generation: 0, views: [], refreshing: null, navigation: null };
4059
+ var WEB_KEY_COPY = {
4060
+ no_phone_key: 'No phone has a key yet. Set one up on the approval page first.',
4061
+ attachments_not_signable: 'Files cannot be approved on the phone yet. Remove them, or use Send.',
4062
+ words_too_long: 'That message is too long to approve on the phone. Shorten it, or use Send.',
4063
+ handoff_cap_reached: 'Too many approvals are waiting. Finish one, or wait two minutes.',
4064
+ origin_unavailable: 'Your Mac does not know its Tailscale name yet, so the approval page cannot open.',
4065
+ web_key_authority_off: 'Phone approval is turned off on the Mac.'
4066
+ };
4067
+ function webKeyCapable() { var k = readerKit(); return !!(k && typeof k.webKeySigner === 'function' && k.webKeySigner() === 1); }
4068
+ function webKeyStatus() {
4069
+ var age = Date.now() - WEB_KEY.statusAt;
4070
+ if (WEB_KEY.status && age >= 0 && age < 30000) return Promise.resolve(WEB_KEY.status);
4071
+ if (!WEB_KEY.loading) {
4072
+ var generation = WEB_KEY.generation;
4073
+ WEB_KEY.loading = apiAnswer('/api/web-key/status', { timeoutMs: 15000 }).then(function (a) {
4074
+ if (generation !== WEB_KEY.generation) return null;
4075
+ WEB_KEY.loading = null;
4076
+ WEB_KEY.status = a.status === 200 && a.body && a.body.ok === true && Number.isSafeInteger(a.body.activeKeys) && a.body.activeKeys >= 0 ? { activeKeys: a.body.activeKeys } : null;
4077
+ WEB_KEY.statusAt = Date.now();
4078
+ return WEB_KEY.status;
4079
+ }, function () { if (generation === WEB_KEY.generation) { WEB_KEY.loading = null; WEB_KEY.status = null; } return null; });
4080
+ }
4081
+ return WEB_KEY.loading;
4082
+ }
4083
+ function webKeySetupUrl() {
4084
+ var host = location.hostname;
4085
+ // The passkey belongs to this Mac's full MagicDNS host, never an IP, local alias or public suffix.
4086
+ if (typeof host !== 'string' || host.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){2,}ts\.net$/.test(host)) return null;
4087
+ return 'https://' + host + ':3144/';
4088
+ }
4089
+ function phoneApprovalView(r, opts) {
4090
+ opts = opts || {};
4091
+ var prefix = opts.prefix || 'session';
4092
+ return { prefix: prefix, form: $(prefix + '-form'), send: $(prefix + '-send'), input: $(prefix + '-input'),
4093
+ live: opts.live || function () { return state.reading === r; },
4094
+ status: opts.status || sessionStatus, files: opts.files || function () { return readerFilesState(readerFilesOn() ? readerBox(r) : null); } };
4095
+ }
4096
+ function phoneApprovalViewLive(view) {
4097
+ return view.live() && view.form && view.send && view.form === $(view.prefix + '-form') && view.send === $(view.prefix + '-send');
4098
+ }
4099
+ function clearPhoneOffer(r, view) {
4100
+ var offer = view.form.querySelector('[data-phone-offer]'), packets = view.form.querySelector('[data-phone-packets]');
4101
+ if (offer && offer.parentNode) offer.parentNode.removeChild(offer);
4102
+ if (packets && packets.parentNode) packets.parentNode.removeChild(packets);
4103
+ r.phonePackets = {}; r.phonePacketSha256 = ''; r.phoneDraft = null;
4104
+ }
4105
+ function refreshWebKeyOffers() {
4106
+ if (!webKeyCapable() || WEB_KEY.refreshing) return;
4107
+ WEB_KEY.status = null; WEB_KEY.statusAt = 0; WEB_KEY.loading = null; WEB_KEY.generation += 1;
4108
+ WEB_KEY.views = WEB_KEY.views.filter(function (entry) { return phoneApprovalViewLive(entry.view); });
4109
+ if (!WEB_KEY.views.length) return;
4110
+ var views = WEB_KEY.views.slice();
4111
+ views.forEach(function (entry) { clearPhoneOffer(entry.r, entry.view); });
4112
+ var fresh = webKeyStatus(); WEB_KEY.refreshing = fresh;
4113
+ fresh.then(function () { if (WEB_KEY.refreshing === fresh) WEB_KEY.refreshing = null; });
4114
+ views.forEach(function (entry) { paintWebKeyOffer(entry.r, entry.opts); });
4115
+ }
4116
+ // Safari can restore this exact DOM after enrollment or revocation on the separate signer origin.
4117
+ // Refetch once per persisted return; closed/replaced readers are pruned and no timer is installed.
4118
+ // (Guarded: it runs at load, and a host without window events, the web-control vm harnesses, must still load.)
4119
+ if (window.addEventListener) window.addEventListener('pageshow', function (e) {
4120
+ if (!e || !e.persisted) return;
4121
+ var nav = WEB_KEY.navigation; WEB_KEY.navigation = null;
4122
+ if (nav && nav.token.completed && nav.r.phoneNavigation === nav.token) {
4123
+ if (nav.r.sending === nav.token) nav.r.sending = false;
4124
+ nav.r.phoneNavigation = null;
4125
+ }
4126
+ refreshWebKeyOffers();
4127
+ });
4128
+ function phonePacketDetails(p, sha) {
4129
+ return 'Requested ' + p.action + ': ' + p.artifact + ' ' + p.version + '\nChannel: ' + p.channel + '\nDigest: ' + p.digest +
4130
+ '\nReceiver: ' + p.provider + ' / ' + p.sessionId + '\nExpires: ' + new Date(p.exp).toISOString() + '\nPacket: ' + sha +
4131
+ '\nThis request approves nothing until you review it and confirm on the phone.';
4132
+ }
4133
+ function paintWebKeyOffer(r, opts) {
4134
+ if (!webKeyCapable()) return;
4135
+ var view = phoneApprovalView(r, opts);
4136
+ if (!phoneApprovalViewLive(view)) return;
4137
+ WEB_KEY.views = WEB_KEY.views.filter(function (entry) { return phoneApprovalViewLive(entry.view) && entry.view.form !== view.form; });
4138
+ WEB_KEY.views.push({ r: r, opts: opts, view: view });
4139
+ var generation = WEB_KEY.generation;
4140
+ webKeyStatus().then(function (st) {
4141
+ if (generation !== WEB_KEY.generation || !phoneApprovalViewLive(view)) return;
4142
+ if (!st) { clearPhoneOffer(r, view); return; }
4143
+ var offer = view.form.querySelector('[data-phone-offer]');
4144
+ if (offer && !!view.form.querySelector('[data-phone-setup]') === (st.activeKeys === 0)) return;
4145
+ if (offer || st.activeKeys === 0) clearPhoneOffer(r, view);
4146
+ if (st.activeKeys === 0) {
4147
+ var setupUrl = webKeySetupUrl(); if (!setupUrl) return;
4148
+ var setup = document.createElement('a');
4149
+ setup.className = 'btn small'; setup.setAttribute('data-phone-offer', '1');
4150
+ setup.setAttribute('data-phone-setup', '1'); setup.setAttribute('href', setupUrl); setup.setAttribute('target', '_top');
4151
+ setup.textContent = 'Set up phone approvals';
4152
+ view.send.parentNode.insertBefore(setup, view.send);
4153
+ return;
4154
+ }
4155
+ var b = document.createElement('button');
4156
+ b.type = 'button'; b.className = 'btn small'; b.setAttribute('data-phone-offer', '1');
4157
+ b.setAttribute(view.prefix === 'ta' ? 'data-ta' : 'data-action', view.prefix === 'ta' ? 'approve-phone' : 'approve-on-phone');
4158
+ b.title = 'Review an exact request on the approval page, then confirm with your phone passkey';
4159
+ b.textContent = 'Approve on this phone';
4160
+ view.send.parentNode.insertBefore(b, view.send);
4161
+ return apiAnswer('/api/web-key/packets?provider=' + encodeURIComponent(r.provider) + '&sessionId=' + encodeURIComponent(r.id)).then(function (a) {
4162
+ if (generation !== WEB_KEY.generation || !phoneApprovalViewLive(view) || view.form.querySelector('[data-phone-offer]') !== b) return;
4163
+ var rows = a.status === 200 && a.body && a.body.ok === true && Array.isArray(a.body.packets) ? a.body.packets : [];
4164
+ rows = rows.filter(function (row) { var p = row && row.packet; return p && p.provider === r.provider && p.sessionId === r.id &&
4165
+ (p.action === 'install' || p.action === 'publish') && Number.isSafeInteger(p.exp) && p.exp >= Date.now() && /^[0-9a-f]{64}$/.test(row.packetSha256 || ''); });
4166
+ r.phonePackets = {}; rows.forEach(function (row) { r.phonePackets[row.packetSha256] = row.packet; });
4167
+ if (!rows.length) return;
4168
+ var box = document.createElement('div'), label = document.createElement('label'), select = document.createElement('select'), disclosure = document.createElement('details'), summary = document.createElement('summary'), detail = document.createElement('pre');
4169
+ box.className = 'phone-approval-packet'; box.setAttribute('data-phone-packets', '1'); select.id = view.prefix + '-phone-packet'; detail.id = view.prefix + '-phone-details';
4170
+ label.textContent = 'What to approve'; label.setAttribute('for', select.id);
4171
+ var message = document.createElement('option'); message.value = ''; message.textContent = 'Message only — no release approval'; select.appendChild(message);
4172
+ rows.forEach(function (row) { var option = document.createElement('option'), p = row.packet; option.value = row.packetSha256;
4173
+ option.textContent = p.action + ' ' + p.artifact + ' ' + p.version + ' / ' + p.channel; select.appendChild(option); });
4174
+ select.value = r.phonePacketSha256 || '';
4175
+ var update = function () { r.phonePacketSha256 = select.value; var p = r.phonePackets[select.value];
4176
+ detail.textContent = p ? phonePacketDetails(p, select.value) : 'Message only. This authorizes no install, publish or push.';
4177
+ if (p && view.input && !view.input.value.trim()) view.input.value = 'Approve ' + p.action + ' ' + p.artifact + ' ' + p.version + ' on ' + p.channel + '.';
4178
+ };
4179
+ select.addEventListener('change', update); update();
4180
+ summary.textContent = 'Review release request'; disclosure.appendChild(summary); disclosure.appendChild(detail);
4181
+ box.appendChild(label); box.appendChild(select); box.appendChild(disclosure); view.form.insertBefore(box, view.form.firstChild);
4182
+ }, function () { /* No packet is selected implicitly when this read fails. */ });
4183
+ });
4184
+ }
4185
+ // Only the signer on this same host, port 3144, path /, with nothing but the handoff id in the fragment.
4186
+ function webKeySignerUrlOk(u) {
4187
+ try {
4188
+ var x = new URL(u);
4189
+ return x.protocol === 'https:' && x.hostname === location.hostname && x.port === '3144' && x.pathname === '/' && !x.search && /^#h=[A-Za-z0-9_-]{16,64}$/.test(x.hash);
4190
+ } catch (_e) { return false; }
4191
+ }
4192
+ function approveOnPhone(opts) {
4193
+ opts = opts || {};
4194
+ var r = opts.r || state.reading; if (!r || r.sending) return Promise.resolve('busy');
4195
+ var view = phoneApprovalView(r, opts), say = view.status;
4196
+ if (!view.live()) return Promise.resolve('stale');
4197
+ var input = view.input, text = input ? input.value.trim() : '';
4198
+ if (!text) { say('Write a message first.', 'error'); return Promise.resolve('empty'); }
4199
+ if (view.files() !== 'none') { say(WEB_KEY_COPY.attachments_not_signable, 'error'); return Promise.resolve('files'); }
4200
+ var provider = r.provider, id = r.id, packetSha = r.phonePacketSha256 || '';
4201
+ var packet = packetSha && r.phonePackets && r.phonePackets[packetSha];
4202
+ if (packetSha && (!packet || packet.exp < Date.now() || packet.provider !== provider || packet.sessionId !== id)) { say('That release request expired or changed. Reopen this session to load its current request.', 'error'); return Promise.resolve('packet'); }
4203
+ var draft = r.phoneDraft;
4204
+ if (!draft || draft.text !== text || draft.packetSha256 !== packetSha) draft = r.phoneDraft = { text: text, key: newTurnKey(), packetSha256: packetSha };
4205
+ var bound = bindingFor(provider, id);
4206
+ // Other senders assign boolean true. This unique truthy owner lets late callbacks and a BFCache return
4207
+ // release only this phone operation, never a newer ordinary send or another phone operation.
4208
+ var operation = { completed: false };
4209
+ r.phoneNavigation = operation; r.sending = operation;
4210
+ var ownsSending = function () { return r.sending === operation && r.phoneNavigation === operation; };
4211
+ var releaseSending = function () { if (ownsSending()) { r.sending = false; r.phoneNavigation = null; } };
4212
+ say('Opening the approval page.');
4213
+ var attach = bound ? Promise.resolve(bound) : apiAnswer(sessionPath(provider, id, '/attach'), { method: 'POST', body: { cosSessionId: cosSessionId() }, timeoutMs: 20000 }).then(function (a) {
4214
+ if (a.status >= 200 && a.status < 300 && a.body.attached === true && a.body.bindingId && typeof a.body.epoch === 'number') {
4215
+ var b = { bindingId: a.body.bindingId, epoch: a.body.epoch, at: Date.now() };
4216
+ rememberBinding(provider, id, b);
4217
+ return b;
4218
+ }
4219
+ return null;
4220
+ });
4221
+ return attach.then(function (b) {
4222
+ if (!ownsSending() || !phoneApprovalViewLive(view)) return 'stale';
4223
+ if (!b) { say('COS cannot write to this session right now.', 'error'); return 'refused'; }
4224
+ var request = { bindingId: b.bindingId, epoch: b.epoch, targetKey: targetKey(provider, id), clientTurnId: draft.key, prompt: draft.text };
4225
+ if (packetSha) request.packetSha256 = packetSha;
4226
+ return apiAnswer('/api/web-key/handoff', { method: 'POST', timeoutMs: 15000, body: request }).then(function (h) {
4227
+ if (!ownsSending() || !phoneApprovalViewLive(view)) return 'stale';
4228
+ var body = h.body || {};
4229
+ if (h.status === 201 && body.ok === true && webKeySignerUrlOk(body.signerUrl)) {
4230
+ say('Opening the approval page.');
4231
+ location.assign(body.signerUrl);
4232
+ operation.completed = true;
4233
+ WEB_KEY.navigation = { r: r, token: operation };
4234
+ return 'opened';
4235
+ }
4236
+ if (h.status === 201) { say('Open Control from your Mac’s Tailscale name to approve on this phone.', 'error'); return 'refused'; }
4237
+ if (/^(binding_changed|unknown_binding)$/.test(body.error || '') && !opts.reattached) {
4238
+ rememberBinding(provider, id, null);
4239
+ releaseSending();
4240
+ return approveOnPhone(Object.assign({}, opts, { r: r, reattached: true }));
4241
+ }
4242
+ say(WEB_KEY_COPY[body.error] || 'The Mac did not open an approval for that message.', 'error');
4243
+ return 'refused';
4244
+ });
4245
+ }).then(function (how) { if (how !== 'opened') releaseSending(); return how; }, function () {
4246
+ if (ownsSending()) { releaseSending(); say('Could not reach your Mac.', 'error'); }
4247
+ return 'failed';
4248
+ });
4249
+ }
4250
+ // ── END web-key handoff ──
4251
+
2465
4252
  var HELD_MAX_MS = 5 * 60000, HELD_RETRY_MS = 8000;
2466
4253
  function holdUntilReplyEnds(r, draft) {
2467
4254
  if (!r.held.some(function (h) { return h.key === draft.key; })) {
@@ -2526,7 +4313,7 @@
2526
4313
  rememberParked(r, draft.key);
2527
4314
  if (opts.held) settleHeld(r, opts.held, 'parked');
2528
4315
  else { view.pending(r, draft.text, 'waiting in line'); view.clear(r, draft.text); }
2529
- view.status(r, b.error === 'duplicate_turn' ? 'Already waiting to send.' : (b.position ? 'The session is busy. Yours is #' + (b.position + 1) + ' in line and sends on its own.' : 'The session is busy. Yours sends next, on its own.'));
4316
+ 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.'));
2530
4317
  view.queue(r);
2531
4318
  // 6.99.29: "The session is busy" is the gate's word as of now; the card shows the same. Last, and
2532
4319
  // never allowed to cost the parked message its settlement.
@@ -2622,11 +4409,50 @@
2622
4409
  }
2623
4410
 
2624
4411
  // Output previews are separate from composer attachments. Only the DTO fields reach the lazy viewer.
4412
+ // opts.part (sxTurnHtml): 'before' is only the step that came before a reply's words, drawn above them; 'after' is
4413
+ // everything else, drawn below; 'user' is a YOU turn's attached images and never a reply's outputs. No opts: all of it.
2625
4414
  function readerImagesHtml(turn) {
4415
+ var opts = arguments[1], part = opts && opts.part;
4416
+ // 6.99.35: "Show files and images your sessions make" is off (Settings, Privacy). The server sent no names, only this mark.
4417
+ if (turn && turn.previewsOff === true) return part === 'before' ? '' : '<p class="rm-status rm-off">Previews are off. Turn them on in Settings, Privacy.</p>';
4418
+ if (part === 'before') return readerToolsHtml(turn, true);
4419
+ if (part === 'user') return readerToolsHtml(turn, false);
4420
+ var head = part === 'after' ? '' : readerToolsHtml(turn, true);
2626
4421
  var images = turn && Array.isArray(turn.outputImages) ? turn.outputImages.filter(function (x) {
2627
4422
  return x && /^ai_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable');
2628
4423
  }).map(function (x) { return { id: x.id, label: String(x.label || 'Output image').slice(0, 180), state: x.state, reason: x.reason }; }) : [];
2629
- return images.length ? '<div data-reader-images="' + esc(JSON.stringify(images)) + '"><p class="rm-status" role="status">Loading image previews…</p></div>' : '';
4424
+ 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);
4425
+ }
4426
+ // 11/08: images the session looked at (a Read of a PNG, a screenshot) and a prompt's attached images, as the Claude
4427
+ // app shows them under a step: "Read", "1 of 2". Only the checked DTO fields travel; the viewer loads them lazily by
4428
+ // opaque id. `before`: the step came before the turn's words. The count of more rides on the last strip shown.
4429
+ var READER_TOOLS = ['Read', 'Screenshot', 'Image', 'Attached'];
4430
+ function readerToolsHtml(turn, before) {
4431
+ var all = turn && Array.isArray(turn.toolImages) ? turn.toolImages.filter(function (x) {
4432
+ return x && /^ti_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable');
4433
+ }).slice(0, 12) : [];
4434
+ var mine = all.filter(function (x) { return (x.before === true) === before; }).map(function (x) {
4435
+ 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,
4436
+ reason: ['outside_workspace', 'unavailable', 'too_large', 'missing', 'secret_file'].indexOf(x.reason) >= 0 ? x.reason : undefined };
4437
+ });
4438
+ var hasAfter = all.some(function (x) { return x.before !== true; });
4439
+ var more = Math.max(0, Math.min(9999, Math.floor(Number(turn && turn.toolImagesMore)) || 0));
4440
+ var hasBefore = all.some(function (x) { return x.before === true; });
4441
+ var mineMore = more && (before ? hasBefore && !hasAfter : !(hasBefore && !hasAfter)) ? more : 0;
4442
+ if (!mine.length && !mineMore) return '';
4443
+ return '<div data-reader-tools="' + esc(JSON.stringify({ items: mine, more: mineMore, before: before })) + '"><p class="rm-status" role="status">Loading images…</p></div>';
4444
+ }
4445
+ // 6.99.35: files a Claude session sent with SendUserFile that are not images. Names, types and sizes only; the
4446
+ // lazy viewer opens them through the authenticated output-files route by opaque id.
4447
+ var READER_FILE_KINDS = ['pdf', 'text', 'html', 'image', 'file'];
4448
+ function readerFilesHtml(turn) {
4449
+ var files = turn && Array.isArray(turn.outputFiles) ? turn.outputFiles.filter(function (x) {
4450
+ return x && /^af_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable');
4451
+ }).map(function (x) {
4452
+ return { id: x.id, name: String(x.name || 'File').slice(0, 120), kind: READER_FILE_KINDS.indexOf(x.kind) >= 0 ? x.kind : 'file',
4453
+ 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 };
4454
+ }) : [];
4455
+ return files.length ? '<div data-reader-files="' + esc(JSON.stringify(files)) + '"><p class="rm-status" role="status">Loading files…</p></div>' : '';
2630
4456
  }
2631
4457
  function readerImagesStop(host) {
2632
4458
  if (!host) return;
@@ -2635,15 +4461,17 @@
2635
4461
  }
2636
4462
  function readerImagesMount(host, r) {
2637
4463
  if (!host || !r) return;
2638
- if (!host.querySelectorAll('[data-reader-images]').length) { readerImagesStop(host); return; }
4464
+ 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; }
2639
4465
  var owner = host.__cosImageOwner = {};
2640
- ensurePane('readerMedia', 4000).then(function (kit) {
4466
+ // Preview engine phase 1: the shared viewer loads first, so link cards and sent files open in it. If it cannot load,
4467
+ // links stay their words and sent files keep the 6.99.35 viewer.
4468
+ ensurePane('preview', 4000).catch(function () { return null; }).then(function () { return ensurePane('readerMedia', 4000); }).then(function (kit) {
2641
4469
  if (host.__cosImageOwner !== owner || host.isConnected === false) return;
2642
4470
  if (!kit) throw new Error('Image previews could not load.');
2643
4471
  kit.mount(host, r.provider, r.id);
2644
4472
  }).catch(function () {
2645
4473
  if (host.__cosImageOwner !== owner || host.isConnected === false) return;
2646
- host.querySelectorAll('[data-reader-images]').forEach(function (slot) {
4474
+ 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) {
2647
4475
  slot.textContent = 'Image previews could not load. ';
2648
4476
  var retry = document.createElement('button'); retry.type = 'button'; retry.className = 'link-btn'; retry.textContent = 'Retry images';
2649
4477
  retry.addEventListener('click', function () { readerImagesMount(host, r); }); slot.appendChild(retry);
@@ -2669,7 +4497,7 @@
2669
4497
  out += original ? '## Original request\n\n' + original + '\n\n## Conversation\n\n' : '## Conversation\n\n';
2670
4498
  if (r.more) out += '[Older turns omitted. COS serves the newest ' + turns.length + '. Original request kept.]\n\n';
2671
4499
  turns.forEach(function (t) {
2672
- var text = String(t.text).trim();
4500
+ var text = String(t.text).trim().replace(/\[([^\]\n]*)\]\(cos-file:\d{1,2}\)/g, '$1');
2673
4501
  if (original && t.role === 'user' && text === original) return;
2674
4502
  out += '**' + (t.role === 'user' ? 'You' : 'Assistant') + '**\n\n' + text + '\n\n';
2675
4503
  });
@@ -2687,9 +4515,16 @@
2687
4515
  // Markdown for an assistant turn (Control renders replies with COSMarkdownView): headings, lists,
2688
4516
  // code, quotes, tables, rules, links. Everything is escaped first, so a reply can never add
2689
4517
  // markup of its own; a link keeps only an http(s) address.
4518
+ // Preview engine phase 1: the server turns a reply's local link into `[words](cos-file:N)`, N its card in the turn's
4519
+ // outputLinks. While a turn with cards renders (sxInline.links), that becomes a slot panes/reader-media.js fills with the
4520
+ // card; anywhere else (a file shown in the viewer, previews off, an older turn) it is just the words.
2690
4521
  function sxInline(s) {
4522
+ var links = sxInline.links;
2691
4523
  var codes = [];
2692
4524
  s = s.replace(/`([^`\n]+)`/g, function (_m, c) { codes.push(c); return '\u0000' + (codes.length - 1) + '\u0000'; });
4525
+ s = s.replace(/\[([^\]\n]*)\]\(cos-file:(\d{1,2})\)/g, function (_m, words, n) {
4526
+ return links && Number(n) < links ? '<span class="rm-link-slot" data-reader-link="' + Number(n) + '">' + words + '</span>' : words;
4527
+ });
2693
4528
  s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
2694
4529
  s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>').replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
2695
4530
  s = s.replace(/(^|[^*\w])\*([^*\s][^*\n]*?)\*(?!\w)/g, '$1<em>$2</em>');
@@ -2700,7 +4535,13 @@
2700
4535
  var SX_RULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
2701
4536
  var SX_TABLE_SEP = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/;
2702
4537
  function sxCells(row) { return row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function (c) { return c.trim(); }); }
2703
- function sxMd(text) {
4538
+ function sxMd(text, links) {
4539
+ // links: the turn's card list. Only a real list turns markers into slots (the count bounds the indexes).
4540
+ var held = sxInline.links;
4541
+ sxInline.links = Array.isArray(links) && links.length ? Math.min(20, links.length) : null;
4542
+ try { return sxMdBody(text); } finally { sxInline.links = held; }
4543
+ }
4544
+ function sxMdBody(text) {
2704
4545
  var lines = esc(String(text || '')).replace(/\r\n?/g, '\n').split('\n');
2705
4546
  var out = [], para = [], i = 0;
2706
4547
  function flush() { if (para.length) { out.push('<p>' + para.map(sxInline).join('<br>') + '</p>'); para = []; } }
@@ -2722,12 +4563,16 @@
2722
4563
  if (line.indexOf('|') >= 0 && i + 1 < lines.length && SX_TABLE_SEP.test(lines[i + 1])) {
2723
4564
  flush();
2724
4565
  var head = sxCells(line), aligns = sxCells(lines[i + 1]).map(function (a) { return /^:-+:$/.test(a) ? 'center' : /-:$/.test(a) ? 'right' : ''; });
2725
- var cell = function (tag, c, k) { return '<' + tag + (aligns[k] ? ' style="text-align:' + aligns[k] + '"' : '') + '>' + sxInline(c) + '</' + tag + '>'; };
2726
4566
  i += 2;
2727
4567
  var body = [];
2728
4568
  while (i < lines.length && lines[i].indexOf('|') >= 0 && lines[i].trim()) { body.push(sxCells(lines[i])); i += 1; }
2729
- out.push('<div class="sx-table"><table><thead><tr>' + head.map(function (c, k) { return cell('th', c, k); }).join('') + '</tr></thead><tbody>' +
2730
- body.map(function (row) { return '<tr>' + head.map(function (_c, k) { return cell('td', row[k] || '', k); }).join('') + '</tr>'; }).join('') + '</tbody></table></div>');
4569
+ var fit = tableFit([head].concat(body));
4570
+ var cell = function (tag, c, k, first) {
4571
+ var st = [aligns[k] ? 'text-align:' + aligns[k] : '', fit.width(k, first)].filter(Boolean).join(';');
4572
+ return '<' + tag + (st ? ' style="' + st + '"' : '') + '>' + fit.cell(k, sxInline(c), first) + '</' + tag + '>';
4573
+ };
4574
+ 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>' +
4575
+ body.map(function (row) { return '<tr>' + head.map(function (_c, k) { return cell('td', row[k] || '', k, false); }).join('') + '</tr>'; }).join('') + '</tbody></table></div>');
2731
4576
  continue;
2732
4577
  }
2733
4578
  if (/^\s*&gt;/.test(line)) {
@@ -2748,6 +4593,17 @@
2748
4593
  sxMd.unescaped = function (lines) {
2749
4594
  return lines.join('\n').split(/\n\s*\n/).map(function (p) { return '<p>' + p.split('\n').map(sxInline).join('<br>') + '</p>'; }).join('');
2750
4595
  };
4596
+ // The card list a turn's Markdown box carries for panes/reader-media.js, checked here: ids and words only.
4597
+ sxMd.linksAttr = function (turn) {
4598
+ var list = turn && Array.isArray(turn.outputLinks) ? turn.outputLinks.slice(0, 20).map(function (x) {
4599
+ return x && /^al_[a-f0-9]{64}$/.test(x.id || '') && (x.state === 'available' || x.state === 'unavailable') ? {
4600
+ 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),
4601
+ type: String(x.type || 'File').slice(0, 40), size: typeof x.size === 'number' && x.size >= 0 ? x.size : undefined,
4602
+ 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
4603
+ } : { id: '', state: 'invalid' };
4604
+ }) : [];
4605
+ return list.length ? ' data-reader-links="' + esc(JSON.stringify(list)) + '"' : '';
4606
+ };
2751
4607
  // Nested lists by indent: a deeper item opens a list inside the open item, a shallower one closes back.
2752
4608
  function sxListBlock(lines, i) {
2753
4609
  var html = '', stack = [];
@@ -2783,7 +4639,7 @@
2783
4639
  // One turn's identity, for telling which turns a refresh brought that the page already shows.
2784
4640
  function sxTurnSig(t) {
2785
4641
  var text = String(t.text || t.content || '').trim();
2786
- return (String(t.role || '').toLowerCase() === 'user' ? 'u' : 'a') + '|' + (t.at || '') + '|' + text.length + '|' + text.slice(0, 48) + '|' + readerImagesHtml(t);
4642
+ return (String(t.role || '').toLowerCase() === 'user' ? 'u' : 'a') + '|' + (t.at || '') + '|' + text.length + '|' + text.slice(0, 48) + '|' + readerImagesHtml(t) + (sxMd.linksAttr ? sxMd.linksAttr(t) : '');
2787
4643
  }
2788
4644
  function sxTurnHtml(t) {
2789
4645
  var role = String(t.role || '').toLowerCase() === 'user' ? 'user' : 'assistant';
@@ -2792,7 +4648,9 @@
2792
4648
  var when = at ? (Date.now() - at.getTime() < 86400000 ? clock(at) : sxShortDate(at)) : '';
2793
4649
  return '<div class="turn ' + role + ' sx-turn" data-sig="' + esc(sxTurnSig(t)) + '"><span class="who"><span>' + (role === 'user' ? 'YOU' : 'ASSISTANT') + '</span>' +
2794
4650
  (when ? '<span class="sx-turn-when">' + esc(when) + '</span>' : '') + '</span>' +
2795
- (role === 'user' ? '<div class="sx-plain">' + esc(text) + '</div>' : '<div class="sx-md">' + sxMd(text) + '</div>' + readerImagesHtml(t)) + '</div>';
4651
+ // A YOU turn shows only its attached images (and the previews-off mark); never a reply's outputs.
4652
+ (role === 'user' ? '<div class="sx-plain">' + esc(text) + '</div>' + readerImagesHtml(t, { part: 'user' })
4653
+ : readerImagesHtml(t, { part: 'before' }) + '<div class="sx-md"' + (sxMd.linksAttr ? sxMd.linksAttr(t) : '') + '>' + sxMd(text, t.outputLinks) + '</div>' + readerImagesHtml(t, { part: 'after' })) + '</div>';
2796
4654
  }
2797
4655
  // A refresh never rebuilds what you are reading (Miles, /qa: "start to scroll and then it reloads
2798
4656
  // the section"). The server's window slides as a session grows, so the turns the page already
@@ -4665,11 +6523,12 @@
4665
6523
 
4666
6524
  // ── events (one delegated listener) ─────────────────────
4667
6525
  document.addEventListener('click', function (e) {
4668
- var t = e.target.closest('[data-today-next],[data-today-needs],[data-today],[data-goto],[data-pane],[data-session],[data-meeting],[data-speakers],[data-action],[data-open-ask],[data-theme-choice],#topbar-back,#section-btn,#sign-out,#copy-link,#ask-close,#auth-switch,#auth-token-show,#device-code-btn');
6526
+ var t = e.target.closest('[data-today-next],[data-next-prep],[data-next-prep-cancel],[data-next-prep-remove],[data-today-needs],[data-today],[data-goto],[data-pane],[data-session],[data-meeting],[data-speakers],[data-action],[data-open-ask],[data-theme-choice],#topbar-back,#section-btn,#sign-out,#copy-link,#ask-close,#auth-switch,#auth-token-show,#auth-retry,#device-code-btn');
4669
6527
  if (!t) { if (e.target.id === 'ask-sheet') closeAsk(); if (e.target.id === 'section-menu') closeMenu(); return; }
4670
6528
  var inSheet = !!t.closest('#ask-sheet');
4671
6529
  if (t.id === 'topbar-back') return goBack();
4672
6530
  if (t.id === 'section-btn') return openMenu();
6531
+ if (t.id === 'auth-retry') { t.disabled = true; t.textContent = 'Checking'; bootStatus().then(function () { t.disabled = false; t.textContent = 'Try again'; }); return; }
4673
6532
  if (t.id === 'auth-switch') {
4674
6533
  var toToken = $('auth-token-block').classList.contains('hidden');
4675
6534
  $('auth-token-block').classList.toggle('hidden', !toToken);
@@ -4698,6 +6557,9 @@
4698
6557
  if (t.hasAttribute('data-open-ask')) return openAsk();
4699
6558
  if (t.hasAttribute('data-today')) return t.getAttribute('data-today') === 'pet' ? petTap(t) : openTodayAction(t);
4700
6559
  if (t.hasAttribute('data-today-next')) return prepNext();
6560
+ if (t.hasAttribute('data-next-prep')) return toggleNextPrep(t.getAttribute('data-next-prep'));
6561
+ if (t.hasAttribute('data-next-prep-cancel')) return closeNextPrep(true);
6562
+ if (t.hasAttribute('data-next-prep-remove')) return removeNextPrepFile(t.getAttribute('data-next-prep-remove'));
4701
6563
  if (t.hasAttribute('data-today-needs')) {
4702
6564
  var which = t.getAttribute('data-today-needs');
4703
6565
  if (which === 'all') TODAY.showAllNeeds = true;
@@ -4717,6 +6579,9 @@
4717
6579
  if (t.hasAttribute('data-speakers')) return go('speakers', [t.getAttribute('data-speakers')]);
4718
6580
  var action = t.getAttribute('data-action');
4719
6581
  if (action === 'fork-session') return sendSessionTurn(true);
6582
+ if (action === 'conn-retry') return connRetry(true);
6583
+ if (action === 'conn-hide-signin') { CONN.hideFor = signinEndsAt(); return connPaint(); }
6584
+ if (action === 'approve-on-phone') return approveOnPhone();
4720
6585
  if (action === 'send-anyway') { var rev = state.reading && state.reading.sendAnyway; if (state.reading) state.reading.sendAnyway = null; return sendSessionTurn(false, { acknowledgedRevision: rev }); }
4721
6586
  if (action === 'stop-session') return stopSession();
4722
6587
  if (action === 'drop-after') return dropHeld();
@@ -4742,6 +6607,7 @@
4742
6607
  if (action === 'ask-cos' && q) return askSheetSend(q);
4743
6608
  if (action === 'ask-file' && q) return fileToInbox(q);
4744
6609
  if (action === 'ask-meaning' && q) { $('ask-hits').innerHTML = skeletonRows(3); findByMeaning(q).then(function (html) { var el = $('ask-hits'); if (el) el.innerHTML = html; }); return; }
6610
+ if (action === 'meaning-consent') { meaningChoose(t.getAttribute('data-provider'), t.getAttribute('data-choice')); if (q) { $('ask-hits').innerHTML = skeletonRows(3); findByMeaning(q).then(function (html) { var el = $('ask-hits'); if (el) el.innerHTML = html; }); } return; }
4745
6611
  if (action === 'ask-work' && q) {
4746
6612
  closeAsk(); go('work');
4747
6613
  // The Work pane (panes/work.js) owns its search box; hand it the query.
@@ -4764,6 +6630,8 @@
4764
6630
  showAuth(err && err.status ? err.message : UNREACHABLE_COPY);
4765
6631
  });
4766
6632
  } else if (f.id === 'session-form') sendSessionTurn(false);
6633
+ else if (f.id === 'today-ask') sendTodayAsk();
6634
+ else if (f.hasAttribute && f.hasAttribute('data-next-prep-form')) startNextPrep();
4767
6635
  else if (f.id === 'sessions-search-form') { $('sessions-q').blur(); searchSessions(); }
4768
6636
  else if (f.id === 'ask-form') {
4769
6637
  var hits = $('ask-hits'); if (hits && $('ask-q').value.trim().length >= 2) renderAsk();
@@ -4774,6 +6642,7 @@
4774
6642
  document.addEventListener('change', function (e) {
4775
6643
  if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-ask-file')) { addAskFiles(e.target.files); e.target.value = ''; }
4776
6644
  if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-sx-file')) { addReaderFiles(e.target.files); e.target.value = ''; }
6645
+ if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-next-prep-file')) { addNextPrepFiles(e.target.files); e.target.value = ''; }
4777
6646
  });
4778
6647
  // The Sessions reader's box: a pasted screenshot, and files dropped on it.
4779
6648
  document.addEventListener('paste', function (e) {
@@ -4803,6 +6672,7 @@
4803
6672
  document.addEventListener('input', function (e) {
4804
6673
  if (e.target.tagName === 'TEXTAREA') autoGrow(e.target);
4805
6674
  if (e.target.id === 'ask-q') renderAsk();
6675
+ if (e.target.hasAttribute && e.target.hasAttribute('data-next-prep-steer')) nextPrepInput(e.target);
4806
6676
  if (e.target.id === 'sessions-q' && !e.target.value.trim()) renderSessionGroups(state.sessions);
4807
6677
  });
4808
6678
  document.addEventListener('keydown', function (e) {
@@ -4810,6 +6680,7 @@
4810
6680
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openAsk(); return; }
4811
6681
  if (e.key === 'Escape' && !$('ask-sheet').classList.contains('hidden')) { closeAsk(); return; }
4812
6682
  if (e.key === 'Escape' && !$('section-menu').classList.contains('hidden')) { closeMenu(); $('section-btn').focus(); return; }
6683
+ if (e.key === 'Escape' && NEXT_PREP.open && closeNextPrep(false)) { e.preventDefault(); return; }
4813
6684
  if (returnSends(e)) { e.preventDefault(); var form = e.target.closest('form'); if (form) form.requestSubmit(); }
4814
6685
  });
4815
6686
  document.querySelectorAll('[data-mode]').forEach(function (b) {
@@ -4825,6 +6696,7 @@
4825
6696
  // is closed and the page's last reading is however old the visit was. Open the stream again and read the turns.
4826
6697
  window.addEventListener('pageshow', function (e) {
4827
6698
  if (!e || !e.persisted) return;
6699
+ liveResume(); // 7.0.1: re-read the list now, then resume the live stream from its last id
4828
6700
  var r = state.reading;
4829
6701
  if (r && r.provider && r.id) {
4830
6702
  if (state.stream) { try { state.stream.close(); } catch (_e) {} state.stream = null; }
@@ -4833,13 +6705,24 @@
4833
6705
  if (state.pane === 'today' && !$('app-shell').classList.contains('hidden')) loadToday();
4834
6706
  });
4835
6707
  document.addEventListener('visibilitychange', function () {
4836
- if (document.hidden || $('app-shell').classList.contains('hidden')) return;
6708
+ if (document.hidden) { liveHide(); return; }
6709
+ if ($('app-shell').classList.contains('hidden')) return;
6710
+ liveResume(); // 7.0.1: re-read the list now, then resume the live stream from its last id
4837
6711
  // Coming back to the page refreshes Today in place. Other panes keep what is on screen:
4838
6712
  // rebuilding them here moved the page under Miles's finger ("it reloads the section").
4839
6713
  if (state.pane === 'today' && Date.now() - (state.lastLoad.today || 0) > 60000) { state.lastLoad.today = Date.now(); loadToday(); }
4840
6714
  // Back within the minute: Today keeps its data, but the pet's loop stopped while hidden, so restart it.
4841
6715
  else if (state.pane === 'today' && PET.pose && !PET.timer) renderPet(state.sessions || []);
4842
6716
  });
6717
+ // 7.0.1: back on the network, the same as coming back to the page.
6718
+ window.addEventListener('online', function () { if (!document.hidden) liveResume(); });
6719
+ // 7.0.1: Today's Refresh (todayRefresh).
6720
+ document.addEventListener('click', function (e) {
6721
+ var b = e.target && e.target.closest && e.target.closest('[data-today-refresh]');
6722
+ if (!b) return;
6723
+ e.preventDefault();
6724
+ todayRefresh();
6725
+ });
4843
6726
  window.addEventListener('message', function (e) {
4844
6727
  if (e.origin !== location.origin) return;
4845
6728
  var d = e.data;
@@ -4886,20 +6769,64 @@
4886
6769
  // inset, so a pinch zoom magnifies the sheet instead of shrinking its layout into the zoomed window.
4887
6770
  // below what of the layout viewport lies under that box: the backdrop alone reaches down over it.
4888
6771
  // No visualViewport (old browsers): the whole layout viewport, nothing covered.
6772
+ // 7.0.2 (A3, the Duo audit): some web views shrink innerHeight itself while the keyboard is up (WKWebView in Comet or
6773
+ // 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
6774
+ // screen, while a field that types has focus, layoutH is the tallest unzoomed innerHeight seen at this width (so this
6775
+ // orientation): the keyboard is the same inset whether the browser shrinks visualViewport only or the whole web view.
6776
+ // - It never goes past the last height read with no field focused (free) plus VP_SLACK_PX, so a browser bar that
6777
+ // comes back while typing is never an 80 px keyboard (QA W1). It never goes under innerHeight itself.
6778
+ // - It starts, on focus, from free: the full height, never a height read while a keyboard was still closing. For
6779
+ // VP_SETTLE_MS after a blur, free only grows (QA W2).
6780
+ // - With no field focused, or on a fine pointer (a Mac window resized or DevTools docked while typing, QA N3), it
6781
+ // follows innerHeight exactly as before 7.0.2. A select is not typing: its picker never counts as a keyboard.
6782
+ // - A new width (a turn) starts over. Pinch zoom keeps the height read before the zoom.
6783
+ // dock the CURRENT layout viewport under the visible box (innerHeight less top and height): what a sticky or
6784
+ // fixed composer clears to sit above the keyboard. 0 when the web view itself shrank.
4889
6785
  var VP = { layoutH: 0 };
6786
+ VP.maxH = 0; VP.w = -1; VP.free = 0; VP.typing = false; VP.blurAt = 0;
6787
+ var VP_SLACK_PX = 20, VP_SETTLE_MS = 1000;
6788
+ function vpTyping() {
6789
+ var a = document.activeElement;
6790
+ if (!a || !a.tagName) return false;
6791
+ var tag = String(a.tagName).toUpperCase();
6792
+ if (tag === 'TEXTAREA') return true;
6793
+ if (tag === 'INPUT') return !/^(checkbox|radio|button|submit|reset|range|color|file|image|hidden)$/i.test(String(a.type || 'text'));
6794
+ return !!a.isContentEditable;
6795
+ }
4890
6796
  function viewportFit() {
4891
6797
  var vv = window.visualViewport, de = document.documentElement;
4892
6798
  var inner = window.innerHeight || (de && de.clientHeight) || 0;
4893
6799
  var scale = vv && vv.scale > 0 ? vv.scale : 1, zoomed = scale > 1.01;
4894
- if (!zoomed || !VP.layoutH) VP.layoutH = zoomed ? Math.max(inner, (de && de.clientHeight) || 0) : inner;
6800
+ // A turn always changes the width. The orientation type is not read: an engine that derives it from the viewport's
6801
+ // aspect flips it when the keyboard shrinks a web view wider than it is then tall (WebKit, 382x266), which would
6802
+ // throw away the very height the keyboard is measured against.
6803
+ var w = window.innerWidth || 0;
6804
+ if (w !== VP.w) { VP.w = w; VP.maxH = 0; VP.free = 0; }
6805
+ if (!zoomed) {
6806
+ var coarse = !window.matchMedia || !!window.matchMedia('(any-pointer: coarse)').matches;
6807
+ var typing = coarse && vpTyping(), now = Date.now();
6808
+ if (!typing) {
6809
+ if (VP.typing) VP.blurAt = now;
6810
+ VP.free = VP.free && now - VP.blurAt < VP_SETTLE_MS ? Math.max(VP.free, inner) : inner;
6811
+ VP.maxH = inner;
6812
+ VP.layoutH = inner;
6813
+ } else {
6814
+ if (!VP.free) VP.free = inner;
6815
+ VP.maxH = Math.max(VP.typing ? VP.maxH : VP.free, inner);
6816
+ VP.layoutH = Math.max(inner, Math.min(VP.maxH, VP.free + VP_SLACK_PX));
6817
+ }
6818
+ VP.typing = typing;
6819
+ }
6820
+ else if (!VP.layoutH) VP.layoutH = Math.max(inner, (de && de.clientHeight) || 0);
4895
6821
  var H = VP.layoutH;
4896
- if (!vv || !(vv.height > 0)) return { layoutH: H, top: 0, height: H, inset: 0, below: 0, scale: 1, zoomed: false, vv: false };
6822
+ if (!vv || !(vv.height > 0)) return { layoutH: H, top: 0, height: H, inset: 0, below: 0, dock: 0, scale: 1, zoomed: false, vv: false };
4897
6823
  var inset = Math.max(0, Math.round(H - vv.height * scale));
4898
6824
  var top, height;
4899
6825
  if (!zoomed) { top = Math.max(0, Math.round(vv.offsetTop || 0)); height = Math.min(Math.round(vv.height), H); }
4900
6826
  else { height = H - inset; top = Math.min(Math.max(0, Math.round(vv.offsetTop || 0)), inset); }
4901
6827
  if (top + height > H) top = Math.max(0, H - height);
4902
- return { layoutH: H, top: top, height: height, inset: inset, below: Math.max(0, H - top - height), scale: scale, zoomed: zoomed, vv: true };
6828
+ var below = Math.max(0, H - top - height);
6829
+ 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 };
4903
6830
  }
4904
6831
 
4905
6832
  // ── layout breadcrumbs (6.99.30) ──────────────────────
@@ -4908,8 +6835,12 @@
4908
6835
  // rotation, 30 lines per 10 s per client) when an overlay opens and each time its keyboard state changes. Numbers and
4909
6836
  // a browser family only: no address, no text, no ids, never the user agent string itself. At most one identical
4910
6837
  // line per 2 s and LAYOUT_CRUMB_MAX a page load, fire and forget.
4911
- var LAYOUT_CRUMB_MAX = 40, LAYOUT_CRUMB_GAP_MS = 2000;
4912
- var crumbs = { sent: 0, last: '', lastAt: 0 };
6838
+ // 7.0.2 (A1): every pane reports now (focus, keyboard open and close, the gap after it closes), so the cap is 80 a
6839
+ // page load, in two budgets: focus lines have LAYOUT_FOCUS_MAX of them, everything else the rest, so a page of typing
6840
+ // never starves the keyboard reports (QA W3). At most LAYOUT_CRUMB_RATE leave in any 10 s, under the server's 30
6841
+ // (routes/diag.ts), and a focus line only while that window is under half full.
6842
+ var LAYOUT_CRUMB_MAX = 80, LAYOUT_FOCUS_MAX = 20, LAYOUT_CRUMB_GAP_MS = 2000, LAYOUT_CRUMB_RATE = 20, LAYOUT_CRUMB_WINDOW_MS = 10000;
6843
+ var crumbs = { sent: 0, focus: 0, last: '', lastAt: 0, recent: [] };
4913
6844
  /** The browser family, from the UA string, for the breadcrumb. Comet first: it says Chrome or Safari too. */
4914
6845
  function uaClass(ua) {
4915
6846
  ua = String(ua == null ? (navigator && navigator.userAgent) || '' : ua);
@@ -4935,8 +6866,13 @@
4935
6866
  var x = extra || {};
4936
6867
  Object.keys(x).forEach(function (k) { var v = x[k]; if (/^[a-z][a-zA-Z0-9]{0,15}$/.test(k) && (typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 24))) data[k] = typeof v === 'number' ? r(v) : v; });
4937
6868
  var key = JSON.stringify(data), now = Date.now();
4938
- if (crumbs.sent >= LAYOUT_CRUMB_MAX || (key === crumbs.last && now - crumbs.lastAt < LAYOUT_CRUMB_GAP_MS)) return false;
4939
- crumbs.sent++; crumbs.last = key; crumbs.lastAt = now;
6869
+ var focusLine = data.what === 'focus';
6870
+ if (focusLine ? crumbs.focus >= LAYOUT_FOCUS_MAX : crumbs.sent - crumbs.focus >= LAYOUT_CRUMB_MAX - LAYOUT_FOCUS_MAX) return false;
6871
+ if (key === crumbs.last && now - crumbs.lastAt < LAYOUT_CRUMB_GAP_MS) return false;
6872
+ crumbs.recent = crumbs.recent.filter(function (t) { return now - t < LAYOUT_CRUMB_WINDOW_MS; });
6873
+ if (crumbs.recent.length >= (focusLine ? LAYOUT_CRUMB_RATE / 2 : LAYOUT_CRUMB_RATE)) return false;
6874
+ crumbs.sent++; if (focusLine) crumbs.focus++;
6875
+ crumbs.last = key; crumbs.lastAt = now; crumbs.recent.push(now);
4940
6876
  api('/api/diag/client', { method: 'POST', body: { ts: now, level: 'info', event: 'web.layout', data: data }, timeoutMs: 5000 }).catch(function () {});
4941
6877
  return true;
4942
6878
  } catch (_e) { return false; }
@@ -4944,7 +6880,7 @@
4944
6880
 
4945
6881
  // ── the shared kit for pane modules (panes/*.js) ───────
4946
6882
  window.COSWeb = {
4947
- $: $, esc: esc, md: md, plain: plain, api: api, toast: toast, ago: ago, clock: clock, toDate: toDate,
6883
+ $: $, esc: esc, md: md, plain: plain, structureLensAnswer: structureLensAnswer, tableFit: tableFit, api: api, toast: toast, ago: ago, clock: clock, toDate: toDate,
4948
6884
  plural: plural, countWord: countWord, dayWord: dayWord, plainDay: plainDay, providerLabel: providerLabel, domainLabel: domainLabel,
4949
6885
  skeletonRows: skeletonRows, emptyBox: emptyBox, setStatus: setStatus, autoGrow: autoGrow,
4950
6886
  readStore: readStore, writeStore: writeStore, copyText: copyText, isPhone: isPhone,
@@ -4971,7 +6907,25 @@
4971
6907
  serverUp: function () { return SERVER.up; },
4972
6908
  // Panes with their own fetch (the questions poll) say whether the Mac answered, so a "down" mark never outlives it.
4973
6909
  noteServer: function (up) { noteServer(up); },
4974
- onServer: function (fn) { SERVER.hooks.push(fn); }
6910
+ onServer: function (fn) { SERVER.hooks.push(fn); },
6911
+ // Duo tables: the resizable divider between a list and its reader (splitKit above), for panes/*.js.
6912
+ splitKit: { mount: mountSplit, handle: splitHandleHtml, fullButton: splitFullButton },
6913
+ // 7.0.1: the one live stream (docs/live-events-contract.md). mode(): idle, connecting, live, reconnecting, busy or
6914
+ // absent. on(fn) hears each applied frame ({ kind, key, session, previous }). session(provider, id): the live
6915
+ // record, or null. age(): "As of 2 min ago" when the counts are not live, else ''.
6916
+ live: {
6917
+ mode: function () { return LIVE.mode; },
6918
+ on: function (fn) { if (typeof fn === 'function') LIVE.hooks.push(fn); },
6919
+ session: function (provider, id) {
6920
+ if (!liveCanOverlay()) return null; // outside live the list is the truth (review W1)
6921
+ var byKey = {};
6922
+ Object.keys(LIVE.sessions).forEach(function (k) { var ls = LIVE.sessions[k]; byKey[liveKey(ls.provider, ls.sessionId)] = ls; });
6923
+ var j = liveMatch(liveKey(provider, id), Object.keys(byKey));
6924
+ return j ? byKey[j] : null;
6925
+ },
6926
+ age: function () { return liveAge(); },
6927
+ clock: liveClock
6928
+ }
4975
6929
  };
4976
6930
  // Today as a place to act (panes/today-actions.js, Miles 11/06): what the Today cards show, a repaint after a
4977
6931
  // decision, and the Sessions reader's own pieces, so the overlay sends and reads through the reader's code.
@@ -5000,6 +6954,7 @@
5000
6954
  questionsChanged: function () { if (TODAY.at) { paintNeeds(); if (state.pane === 'today' && !TODAY.offline) renderPet(state.sessions || []); } }
5001
6955
  };
5002
6956
  window.COSWeb.sessionKit = {
6957
+ phoneOffer: paintWebKeyOffer, approvePhone: approveOnPhone,
5003
6958
  // A session object for continueSession, with the overlay's own view (READER_VIEW's methods).
5004
6959
  session: function (provider, id, view) {
5005
6960
  return { provider: provider, id: id, draft: null, held: [], view: view, verdict: null, refusal: null, cautionAck: false, feed: newFeed(), live: null, queued: 0 };
@@ -5008,14 +6963,153 @@
5008
6963
  newKey: newTurnKey,
5009
6964
  probe: function (r) { return sxProbeAttach(r); },
5010
6965
  path: sessionPath, answer: apiAnswer,
5011
- notSent: { rows: notSentRows, line: notSentLine, note: noteNotSent, get: notSentFor },
5012
- imageHtml: readerImagesHtml, imagesMount: readerImagesMount, imagesStop: readerImagesStop,
6966
+ notSent: { rows: notSentRows, line: notSentLine, label: notSentLabel, note: noteNotSent, get: notSentFor },
6967
+ imageHtml: readerImagesHtml, imagesMount: readerImagesMount, imagesStop: readerImagesStop, linksAttr: function (t) { return sxMd.linksAttr(t); },
5013
6968
  feed: { fresh: newFeed, apply: applyFeed, word: sxFeedWord, detail: sxFeedDetail, tool: sxToolLine, elapsed: sxElapsed, fallback: sxFallbackLine,
5014
6969
  truth: sxTruth, settle: sxSettle, noteList: sxNoteList, noteGate: sxNoteGate, gateStale: sxGateStale, promptLine: sxPromptLine, parkNoteShows: sxParkNoteShows },
5015
6970
  title: sxTitle, stateOf: sxState, stateLabel: function (s) { return sxStateLabel(s, sxState(s)); },
5016
- parkCopy: function (p) { return SX_PARK[sxProvider(p)]; }, providerLabel: sxProviderLabel, markdown: sxMd
6971
+ parkCopy: function (p, r, hasFiles) { return r ? sxParkLine(r, hasFiles) : SX_PARK[sxProvider(p)]; }, providerLabel: sxProviderLabel, markdown: sxMd
5017
6972
  };
5018
6973
 
6974
+ // ── layout reports and the visible box, every pane (7.0.2) ──
6975
+ // The Duo audit (11/07): only Today's session sheet ever sent a layout report, and only when its classes flipped, so
6976
+ // the COS chat composer, the front screen Miles types on most, never sent one, and a keyboard the code failed to see
6977
+ // sent nothing at all. Now, through layoutCrumb (numbers and fixed labels only, never text or ids):
6978
+ // device once per page load, after sign-in: the screen, the pixel ratio, safe areas, pointer kind
6979
+ // focus a field that types gets focus, on any pane, on a touch screen: its computed font size, the scroll
6980
+ // position and range, and innerWidth against scrollWidth, as scw (zoom vs pan vs a wider page)
6981
+ // kb-open, kb-close EVERY keyboard open and close, numbered, so a repeat is never dropped as a duplicate; with
6982
+ // innerHeight against the layout height (shrank: the web view itself got shorter)
6983
+ // kb-after KB_AFTER_MS after a close: the gap below the composer that had the keyboard, scrollY against its
6984
+ // range, and 100dvh, 100svh and 100lvh in px; measured before and after the panes settle
6985
+ // The same pass publishes the visible box for CSS on <html>: --vv-top, --vv-h, --vv-below (the layout viewport under
6986
+ // the visible box, viewportFit's dock) and html.kb-up while an on-screen keyboard is up (KB_UP_PX or more covered
6987
+ // with a typing field focused). Panes hear open, close, move (the visible box changed while it is up) and after
6988
+ // through COSWeb.onKeyboard; COSWeb.keyboardUp says whether it is up now. Which composer the keyboard is for is the
6989
+ // pane's to mark (panes/cos-chat.js sets html.kb-chat and .cc-kb).
6990
+ var KB_UP_PX = 120, KB_AFTER_MS = 300;
6991
+ var LR = { up: false, frame: 0, after: 0, cycle: 0, focusN: 0, device: false, field: null, hooks: [], box: '', zoomed: false, touchAt: 0 };
6992
+ function lrPane() { var r = parseRoute(); return String(r.pane + (r.args[0] === 'cos' ? '.cos' : '')).slice(0, 24); }
6993
+ function lrKind(a) {
6994
+ var tag = String((a && a.tagName) || '').toLowerCase();
6995
+ return (tag === 'input' ? 'input.' + String(a.type || 'text').toLowerCase() : tag || 'other').slice(0, 24);
6996
+ }
6997
+ function lrScroll() {
6998
+ var se = document.scrollingElement || document.documentElement;
6999
+ var sy = Math.round(window.scrollY || window.pageYOffset || 0);
7000
+ var smax = se ? Math.max(0, Math.round((se.scrollHeight || 0) - (window.innerHeight || se.clientHeight || 0))) : 0;
7001
+ return { sy: sy, smax: smax, sw: se ? Math.round(se.scrollWidth || 0) : 0 };
7002
+ }
7003
+ // One hidden element, measured and removed: its height (h) or a computed padding in px. Null where it cannot be read.
7004
+ function lrProbe(css, prop) {
7005
+ var b = document.body;
7006
+ if (!b || !document.createElement) return null;
7007
+ var el = document.createElement('div'), v = null;
7008
+ el.setAttribute('aria-hidden', 'true');
7009
+ el.style.cssText = 'position:fixed;left:0;top:0;width:1px;visibility:hidden;pointer-events:none;' + css;
7010
+ b.appendChild(el);
7011
+ try { v = prop === 'h' ? el.offsetHeight : parseFloat(getComputedStyle(el)[prop]); } catch (_e) { v = null; }
7012
+ b.removeChild(el);
7013
+ return typeof v === 'number' && isFinite(v) ? v : null;
7014
+ }
7015
+ function lrMatch(q) { try { return !!window.matchMedia(q).matches; } catch (_e) { return false; } }
7016
+ function lrDevice() {
7017
+ if (LR.device) return;
7018
+ LR.device = true;
7019
+ var sc = window.screen || {};
7020
+ layoutCrumb('device', { sw: sc.width, sh: sc.height, sah: sc.availHeight, oh: window.outerHeight, ow: window.outerWidth, dpr: window.devicePixelRatio,
7021
+ sat: lrProbe('padding-top:env(safe-area-inset-top)', 'paddingTop'), sab: lrProbe('padding-bottom:env(safe-area-inset-bottom)', 'paddingBottom'),
7022
+ anyCoarse: lrMatch('(any-pointer: coarse)'), shortTouch: lrMatch(SHORT_TOUCH_MQ), phone: isPhone() });
7023
+ }
7024
+ // The focus line is for touch screens only (QA W3): on a Mac it would only spend the budget.
7025
+ function lrFocus() {
7026
+ if (!vpTyping()) return;
7027
+ var a = document.activeElement, fs = null;
7028
+ LR.field = a;
7029
+ if (!lrMatch('(pointer: coarse)')) return;
7030
+ try { fs = parseFloat(getComputedStyle(a).fontSize); } catch (_e) { fs = null; }
7031
+ var s = lrScroll();
7032
+ layoutCrumb('focus', { pane: lrPane(), field: lrKind(a), fs: fs, sy: s.sy, smax: s.smax, scw: s.sw, kb: LR.up, n: ++LR.focusN });
7033
+ }
7034
+ function lrHooks(what, v) { LR.hooks.forEach(function (fn) { try { fn(what, v); } catch (err) { console.error(err); } }); }
7035
+ function lrGap(v) {
7036
+ var f = LR.field, box = f && f.closest ? (f.closest('form') || f) : f;
7037
+ if (!box || box.isConnected === false || !box.getBoundingClientRect) return null;
7038
+ return Math.round(v.top + v.height - box.getBoundingClientRect().bottom);
7039
+ }
7040
+ function lrAfter() {
7041
+ LR.after = 0;
7042
+ if (LR.up) return;
7043
+ var v = viewportFit(), s = lrScroll(), gap = lrGap(v);
7044
+ lrHooks('after', v);
7045
+ var s2 = lrScroll();
7046
+ 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,
7047
+ dvh: lrProbe('height:100dvh', 'h'), svh: lrProbe('height:100svh', 'h'), lvh: lrProbe('height:100lvh', 'h'), lh: v.layoutH });
7048
+ }
7049
+ // 7.0.17: when the page's zoom crosses 1.01 either way, one breadcrumb (inside layoutCrumb's caps): the scale, the
7050
+ // pane, the focused element's tag (never its text or value) and how long since the last touch. A double-tap zoom
7051
+ // reads as a few hundred ms after a touch with no field focused; a focus zoom as an INPUT or TEXTAREA. Read only:
7052
+ // nothing here changes the zoom.
7053
+ function zoomCrumb(v) {
7054
+ var zoomed = !!(v && v.zoomed);
7055
+ if (zoomed === LR.zoomed) return false;
7056
+ LR.zoomed = zoomed;
7057
+ var a = document.activeElement, tag = a && a !== document.body && a.tagName ? String(a.tagName).toLowerCase().slice(0, 24) : 'none';
7058
+ return layoutCrumb(zoomed ? 'zoom-in' : 'zoom-out', { pane: lrPane(), scale: v.scale, tag: tag, sinceTouch: LR.touchAt ? Date.now() - LR.touchAt : -1 });
7059
+ }
7060
+ function lrApply() {
7061
+ LR.frame = 0;
7062
+ var v = viewportFit(), de = document.documentElement;
7063
+ zoomCrumb(v);
7064
+ // Written only when they change: a pinch or pan fires this every frame, and each write restyles the page.
7065
+ var box = v.top + '|' + v.height + '|' + v.dock, moved = box !== LR.box;
7066
+ if (moved && de && de.style && de.style.setProperty) {
7067
+ LR.box = box;
7068
+ de.style.setProperty('--vv-top', v.top + 'px'); de.style.setProperty('--vv-h', v.height + 'px'); de.style.setProperty('--vv-below', v.dock + 'px');
7069
+ }
7070
+ var up = !!(v.vv && v.inset >= KB_UP_PX && vpTyping());
7071
+ if (de && de.classList) de.classList.toggle('kb-up', up);
7072
+ if (up === LR.up) { if (up && moved) lrHooks('move', v); return v; }
7073
+ LR.up = up; LR.cycle++;
7074
+ 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,
7075
+ zoomed: v.zoomed, shrank: Math.round(window.innerHeight || 0) < v.layoutH });
7076
+ if (LR.after) { clearTimeout(LR.after); LR.after = 0; }
7077
+ if (!up) LR.after = setTimeout(lrAfter, KB_AFTER_MS);
7078
+ lrHooks(up ? 'open' : 'close', v);
7079
+ return v;
7080
+ }
7081
+ function lrFit() {
7082
+ if (LR.frame) return;
7083
+ if (!window.requestAnimationFrame) { lrApply(); return; }
7084
+ LR.frame = window.requestAnimationFrame(lrApply);
7085
+ }
7086
+ (function lrListen() {
7087
+ var vv = window.visualViewport;
7088
+ if (vv && vv.addEventListener) { vv.addEventListener('resize', lrFit); vv.addEventListener('scroll', lrFit); }
7089
+ window.addEventListener('resize', lrFit); window.addEventListener('orientationchange', lrFit); window.addEventListener('pageshow', lrFit);
7090
+ document.addEventListener('focusin', function () { lrFocus(); lrFit(); });
7091
+ // 7.0.17: when the last touch landed, for the zoom breadcrumb only. Passive: it never blocks a gesture.
7092
+ document.addEventListener('touchstart', function () { LR.touchAt = Date.now(); }, { capture: true, passive: true });
7093
+ document.addEventListener('focusout', function () { setTimeout(lrFit, 0); });
7094
+ shellHooks.push(function () { lrDevice(); lrFit(); });
7095
+ })();
7096
+ // 7.0.17 (Miles 2026-11-10, the Duo web app zooming in and out around the keyboard): on iOS only, the viewport is
7097
+ // capped at maximum-scale=1, once, as the page loads. Other platforms keep the viewport exactly as it is. Never while
7098
+ // the page is already zoomed (a reload mid-zoom), so the zoom is never changed under the person's fingers; nothing
7099
+ // calls this again after load.
7100
+ function viewportCap(meta, ua, vv) {
7101
+ if (!meta || ua.os !== 'ios') return false;
7102
+ if (vv && vv.scale > 1.01) return false;
7103
+ var content = String(meta.getAttribute('content') || '');
7104
+ if (/maximum-scale\s*=/.test(content)) return false;
7105
+ meta.setAttribute('content', content + (content ? ', ' : '') + 'maximum-scale=1');
7106
+ return true;
7107
+ }
7108
+ try { viewportCap(document.querySelector('meta[name="viewport"]'), uaClass(), window.visualViewport); } catch (_e) { /* the viewport stays as it is */ }
7109
+ window.COSWeb.keyboardUp = function () { return LR.up; };
7110
+ window.COSWeb.onKeyboard = function (fn) { if (typeof fn === 'function') LR.hooks.push(fn); };
7111
+ // ── end layout reports
7112
+
5019
7113
  // Every pane module is a plain script after this one; boot waits for all of them.
5020
7114
  function boot() {
5021
7115
  var params = new URLSearchParams(location.search);
@@ -5024,8 +7118,8 @@
5024
7118
  history.replaceState({}, document.title, location.pathname + location.hash);
5025
7119
  api('/api/web-session/login', { method: 'POST', body: { code: linkCode } }).then(showShell)
5026
7120
  .catch(function (err) {
5027
- showAuth(err && err.status ? err.message : UNREACHABLE_COPY);
5028
- var box = $('auth-code'); if (box) box.value = linkCode;
7121
+ if (!(err && err.status)) { showAuth(UNREACHABLE_COPY); var box0 = $('auth-code'); if (box0) box0.value = linkCode; return; }
7122
+ return linkFailed(err.message, function () { var box = $('auth-code'); if (box) box.value = linkCode; });
5029
7123
  });
5030
7124
  return;
5031
7125
  }
@@ -5033,10 +7127,65 @@
5033
7127
  if (handoff) {
5034
7128
  history.replaceState({}, document.title, location.pathname + location.hash);
5035
7129
  api('/api/web-session/login', { method: 'POST', body: { handoff: handoff } }).then(showShell)
5036
- .catch(function (err) { showAuth(err && err.status ? err.message : 'That sign-in link did not work. Get a new one from COS Control on your Mac or the COS Glasses app, or use a code or the token.'); });
7130
+ .catch(function (err) {
7131
+ if (!(err && err.status)) return showAuth('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.');
7132
+ return linkFailed(err.message);
7133
+ });
5037
7134
  return;
5038
7135
  }
5039
- api('/api/web-session/status').then(function (st) { if (st && st.authenticated) showShell(st); else showAuth(); }).catch(function () { showAuth(UNREACHABLE_COPY); });
7136
+ // The status read stays here in boot() itself (routes/web-phone-link.test.ts runs boot alone and pins it).
7137
+ api('/api/web-session/status').then(function (st) { return bootAnswer(st); }, function (err) { return bootFail(err); });
7138
+ }
7139
+ // S1 item 7 (lead go, 20:11): a link or code the server refused may be old while this browser is still signed in (an
7140
+ // old QR, a reopened tab). Ask status first: signed in opens the app and says the link was old; signed out shows the
7141
+ // refusal as before, with nothing else changed. A status read that fails also shows the refusal.
7142
+ var OLD_LINK_COPY = 'That sign-in link was old or already used. This browser is still signed in.';
7143
+ function linkFailed(message, after) {
7144
+ return api('/api/web-session/status').then(function (st) {
7145
+ if (st && st.authenticated) { showShell(st); toast(OLD_LINK_COPY); return; }
7146
+ showAuth(message); if (after) after();
7147
+ }, function () { showAuth(message); if (after) after(); });
7148
+ }
7149
+ // The same read on every retry of the not-answering card.
7150
+ function bootStatus() {
7151
+ return api('/api/web-session/status').then(function (st) { return bootAnswer(st); }, function (err) { return bootFail(err); });
7152
+ }
7153
+ function bootAnswer(st) {
7154
+ if (st && st.authenticated) return showShell(st);
7155
+ // Q1 S2: a browser that was signed in before learns why it is not now (the 30 days, no use in its last 2 days, a sign-out).
7156
+ showAuth(typeof signinKnown === 'function' && signinKnown() ? signinLapsedCopy() : undefined);
7157
+ }
7158
+ function bootFail(err) {
7159
+ // F23: a Mac that did not answer is not a sign-in problem. Say so, offer Try again, keep the form out of the way.
7160
+ if (err && err.status) showAuth(err.message); else { showAuth(); authOffline(true); }
7161
+ }
7162
+ // Q1 M2: the not-answering card checks again every 30 s while the page is in front, and stops while it is hidden.
7163
+ var AUTH_RETRY = { timer: null, ms: 30000 };
7164
+ function authRetrySchedule(on) {
7165
+ if (AUTH_RETRY.timer) { clearTimeout(AUTH_RETRY.timer); AUTH_RETRY.timer = null; }
7166
+ if (!on || document.hidden) return;
7167
+ AUTH_RETRY.timer = setTimeout(function () { AUTH_RETRY.timer = null; if (!document.hidden) bootStatus(); }, AUTH_RETRY.ms);
7168
+ }
7169
+ document.addEventListener('visibilitychange', function () {
7170
+ var box = $('auth-offline');
7171
+ var showing = !!(box && !box.classList.contains('hidden'));
7172
+ if (!showing) return;
7173
+ if (document.hidden) authRetrySchedule(false); else bootStatus();
7174
+ });
7175
+ // The sign-in card's "Your Mac is not answering" state (boot only): the form hides until the Mac answers.
7176
+ function authOffline(on) {
7177
+ var box = $('auth-offline'), form = $('auth-form');
7178
+ if (!box || !form) return;
7179
+ box.classList.toggle('hidden', !on); form.classList.toggle('hidden', !!on);
7180
+ var title = $('auth-title'), intro = $('auth-intro');
7181
+ var offline = typeof connOffline === 'function' && connOffline();
7182
+ if (title) title.textContent = on ? (offline ? 'This device is offline' : 'COS on your Mac is not answering') : 'Connect this browser';
7183
+ if (intro) intro.classList.toggle('hidden', !!on);
7184
+ var text = $('auth-offline-text');
7185
+ if (text && on) text.textContent = offline ? 'Connect to the internet and Tailscale. This page checks again every 30 seconds.'
7186
+ : 'Your Mac may be asleep, or this device may be offline or off Tailscale. This page checks again every 30 seconds.';
7187
+ authRetrySchedule(!!on);
7188
+ if (on) { var err = $('auth-error'); if (err) { setStatus(err, '', true); err.classList.add('hidden'); } }
5040
7189
  }
5041
7190
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
5042
7191
  })();