@gotcos/glasses-server 6.99.25 → 6.99.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/package.json +1 -1
  3. package/server/index.ts +46 -4
  4. package/server/lib/agent-session-output-images.ts +280 -0
  5. package/server/lib/agent-session-store.ts +3 -1
  6. package/server/lib/agent-session-turns.ts +18 -7
  7. package/server/lib/client-liveness.ts +38 -3
  8. package/server/lib/codex-subagent-activity.ts +36 -0
  9. package/server/lib/g2-turn-provenance.ts +679 -0
  10. package/server/lib/media-store.ts +41 -2
  11. package/server/lib/permission-broker.ts +94 -17
  12. package/server/lib/queue-turn-evidence.ts +19 -2
  13. package/server/lib/session-halt.ts +15 -1
  14. package/server/lib/session-stream-events.ts +4 -0
  15. package/server/lib/session-subagents.ts +155 -0
  16. package/server/lib/session-turn-attachments.ts +245 -0
  17. package/server/lib/thread-turn-queue-deliver.ts +17 -2
  18. package/server/lib/thread-turn-queue.ts +10 -0
  19. package/server/lib/today-brief.ts +731 -0
  20. package/server/lib/turn-file-store.ts +202 -0
  21. package/server/lib/turn-footer.ts +81 -0
  22. package/server/routes/agent-session-bindings.ts +164 -16
  23. package/server/routes/agent-sessions.ts +77 -8
  24. package/server/routes/client-instance.ts +12 -1
  25. package/server/routes/cursor-stop-followup.ts +30 -1
  26. package/server/routes/g2-turns.ts +46 -0
  27. package/server/routes/health.ts +9 -0
  28. package/server/routes/media.ts +32 -8
  29. package/server/routes/permission-broker.ts +47 -4
  30. package/server/routes/thread-turn-queue.ts +148 -8
  31. package/server/routes/today-brief.ts +148 -0
  32. package/server/web-control/app.css +2 -0
  33. package/server/web-control/app.js +738 -102
  34. package/server/web-control/index.html +5 -1
  35. package/server/web-control/panes/cos-chat.css +18 -0
  36. package/server/web-control/panes/cos-chat.js +239 -45
  37. package/server/web-control/panes/meetings.js +20 -9
  38. package/server/web-control/panes/questions.css +39 -1
  39. package/server/web-control/panes/questions.js +368 -51
  40. package/server/web-control/panes/reader-media.js +154 -0
  41. package/server/web-control/panes/sessions.css +2 -0
  42. package/server/web-control/panes/settings.js +34 -2
  43. package/server/web-control/panes/today-actions.css +62 -4
  44. package/server/web-control/panes/today-actions.js +384 -53
@@ -7,12 +7,18 @@
7
7
  older server answers it 401, which would sign the page out, so the page never calls it there);
8
8
  - the broker is on (sessionQuestions.enabled);
9
9
  - this tab is visible and signed in.
10
- `client=web` makes the Mac count this page as a client that can answer (the same 30 s window as the phone), so it
11
- holds a question for it instead of showing its own dialog at once. A hidden tab stops polling, and the Mac takes
12
- its questions back within 30 s. The poll is a plain fetch: a 401 stops the poll quietly and never signs the page out
13
- (the panes say so on their next read).
10
+ An open tab never makes the Mac hold a question back from its own pop-up (6.99.13, Miles's option B). What the page
11
+ adds (6.99.26, Miles 11/07: "Away rule, like phone") is its touch: `hold=1&presenceAgeMs=<ms>`, how long ago a touch
12
+ or a key landed on this page while it was in front. While the Mac's desk is idle, a question that starts within 30
13
+ minutes of that touch is held for the away hold (up to 10 minutes), answerable here and on the glasses or phone; at
14
+ the desk the Mac's own pop-up shows at once. No touch since the page opened sends no presence. The poll is a plain
15
+ fetch: a 401 stops the poll quietly and never signs the page out (the panes say so on their next read).
14
16
 
15
- Allow once, Deny and Send are tap-then-confirm, as in the Even app: an approval runs a command on the Mac. Leave for
17
+ A question the Mac's own prompt waits on (`settled` with `waitingAtMac`, a held one that went back to the Mac) shows
18
+ read-only in the cards drawn for that session (Today's waiting sheet), with its words when this page saw them while
19
+ it was held: the server keeps codes only once a question settles. Nothing here can answer it.
20
+
21
+ Allow once, Deny and Submit are tap-then-confirm, as in the Even app: an approval runs a command on the Mac. Leave for
16
22
  the Mac hands the request back to the Mac's own dialog. Plan approvals never come here: the broker never holds
17
23
  ExitPlanMode (lib/permission-broker.ts UNBROKERED_TOOLS). */
18
24
  (function () {
@@ -32,11 +38,56 @@
32
38
  sending: {}, // id -> clientAnswerId while a POST is out
33
39
  notes: {}, // id -> one line under the card
34
40
  answerIds: {}, // id -> the clientAnswerId this page answers under (a retry replays it)
41
+ atMac: [], // settled items the Mac's own prompt still waits on, newest first
42
+ seen: {}, // id -> { questions: [text], summary } the words of a card this page drew while it was held
43
+ answered: {}, // id -> { sessionId, at } answered here or anywhere (settled `answered`), for two minutes
44
+ missing: {}, // id -> the question a Submit tap found unanswered, until the next pick
35
45
  timer: null,
36
46
  tick: null,
37
- stopped: false
47
+ stopped: false,
48
+ inflight: false, // a poll is on the wire
49
+ again: false, // something asked for a poll while one was out: one more as soon as it lands
50
+ lastPollAt: 0 // when the last poll went out (Date.now)
38
51
  };
39
52
  var DEFAULT_POLL_MS = 10000;
53
+ // 6.99.27 (Miles 11/07 09:29, "a big delay to see the questions on the homepage"): while this tab is visible on
54
+ // Today it reads every 3 s, whatever the server's idle pace (10 s). Before, a question parked just after an idle read
55
+ // waited up to 10 s plus the round trip for the next one. Hidden, the page reads nothing (visibilitychange below).
56
+ var TODAY_POLL_MS = 3000;
57
+ // A nudge (a session that just went to waiting, Today coming on screen) never reads more often than this.
58
+ var NUDGE_GAP_MS = 1000;
59
+ // Miles's words for a question at the Mac (11/07), one place: Today's Needs you reads them from here too.
60
+ var MAC_LINE = "Waiting on your Mac's prompt. Answer it on the Mac (or through Screens).";
61
+ var ANSWERED_LINE = 'Answered. The session carries on.';
62
+ var ANSWERED_MS = 120000;
63
+
64
+ // ── this page's touch, for the Mac's away hold (6.99.26) ──
65
+ // A real touch, click or key while the tab is in front. Kept on both clocks; its age is the larger of the two, so a
66
+ // phone that slept never sends a touch younger than it is (the server's own rule, lib/client-liveness).
67
+ var PRESENCE_MAX_MS = 86400000;
68
+ var touch = { mono: null, wall: null };
69
+ function monoNow() { return typeof performance !== 'undefined' && performance && typeof performance.now === 'function' ? performance.now() : null; }
70
+ function presenceAge() {
71
+ if (touch.wall === null) return null;
72
+ var m = monoNow();
73
+ var age = Math.max(0, Date.now() - touch.wall, m !== null && touch.mono !== null ? m - touch.mono : 0);
74
+ age = Math.round(age);
75
+ return age <= PRESENCE_MAX_MS ? age : null;
76
+ }
77
+ function noteTouch(e) {
78
+ if (document.hidden || (e && e.isTrusted === false)) return;
79
+ var before = presenceAge();
80
+ touch.mono = monoNow(); touch.wall = Date.now();
81
+ // The first touch in a while reaches the Mac now, not at the next poll, so a question that starts a moment later
82
+ // is held for this page.
83
+ // QA 6.99.27 (N4): through nudge, so it keeps the 1 s gap and the one read on the wire like every other ask.
84
+ if (before === null || before > 60000) nudge();
85
+ }
86
+ ['pointerdown', 'touchstart', 'keydown'].forEach(function (t) { document.addEventListener(t, noteTouch, { capture: true, passive: true }); });
87
+ function pollUrl() {
88
+ var age = presenceAge();
89
+ return '/api/session-questions?client=web&hold=1' + (age === null ? '' : '&presenceAgeMs=' + age);
90
+ }
40
91
 
41
92
  function signedIn() { var shell = document.getElementById('app-shell'); return !!(shell && !shell.classList.contains('hidden')); }
42
93
  function answerId() {
@@ -62,46 +113,92 @@
62
113
  }, function () { return null; });
63
114
  }
64
115
  function usable(cap) { return !!(cap && cap.enabled === true && cap.webClient === true); }
116
+ // The capability for this poll. Once this server has said the web may poll, a poll never waits on /api/models again:
117
+ // an old answer is refreshed alongside the poll (6.99.27: after five minutes away, coming back read /api/models first
118
+ // and only then the questions). The first read of a page still waits for it, so an older server is never polled.
119
+ function capForPoll() {
120
+ if (usable(Q.cap)) { if (Date.now() - Q.capAt >= 5 * 60000) readCap(); return Promise.resolve(Q.cap); }
121
+ return readCap();
122
+ }
123
+ function onToday() { var st = W.state; return !!(st && st.pane === 'today'); }
124
+ // The wait before the next read: the server's pace, 3 s while one waits (an answer elsewhere clears it here too), and
125
+ // 3 s while this tab shows Today.
126
+ function nextWait(every) { return Q.items.length || onToday() ? Math.min(every, TODAY_POLL_MS) : every; }
65
127
  function schedule(ms) {
66
128
  clearTimeout(Q.timer);
67
129
  Q.timer = setTimeout(poll, ms);
68
130
  }
131
+ function landed() {
132
+ Q.inflight = false;
133
+ // QA 6.99.27 (N4): the read asked for while this one was out goes through nudge, so it waits out the 1 s gap too.
134
+ if (Q.again) { Q.again = false; nudge(); }
135
+ }
69
136
  function poll() {
70
137
  clearTimeout(Q.timer);
71
138
  if (Q.stopped || document.hidden || !signedIn()) return;
72
- readCap().then(function (cap) {
73
- if (!usable(cap)) { clearCards(); schedule(60000); return; }
74
- return fetch('/api/session-questions?client=web', { headers: { 'X-COS-Web': '1' }, credentials: 'same-origin', cache: 'no-store' }).then(function (res) {
75
- if (res.status === 401) { Q.stopped = true; clearCards(); return; }
76
- if (!res.ok) { schedule(DEFAULT_POLL_MS); return; }
139
+ // One read on the wire at a time: a second ask while one is out becomes one more read when it lands.
140
+ if (Q.inflight) { Q.again = true; return; }
141
+ Q.inflight = true; Q.again = false; Q.lastPollAt = Date.now();
142
+ capForPoll().then(function (cap) {
143
+ if (!usable(cap)) { Q.again = false; landed(); clearCards(); schedule(60000); return; }
144
+ return fetch(pollUrl(), { headers: { 'X-COS-Web': '1' }, credentials: 'same-origin', cache: 'no-store' }).then(function (res) {
145
+ if (res.status === 401) { Q.again = false; landed(); Q.stopped = true; clearCards(); return; }
146
+ if (!res.ok) { schedule(DEFAULT_POLL_MS); landed(); return; }
77
147
  return res.json().then(function (d) {
78
- if (!d || d.enabled === false) { clearCards(); schedule(60000); return; }
79
- take(Array.isArray(d.items) ? d.items : []);
148
+ if (!d || d.enabled === false) { Q.again = false; landed(); clearCards(); schedule(60000); return; }
149
+ // Painted as soon as the read lands: take() draws the cards and repaints Today's Needs you and the pet.
150
+ take(Array.isArray(d.items) ? d.items : [], Array.isArray(d.settled) ? d.settled : []);
80
151
  var every = Number(d.pollIntervalMs) > 0 ? Number(d.pollIntervalMs) : DEFAULT_POLL_MS;
81
- // While one waits, read faster so an answer from the glasses or the Mac clears it here too.
82
- schedule(Q.items.length ? Math.min(every, 3000) : every);
152
+ schedule(nextWait(every));
153
+ landed();
83
154
  });
84
- }, function () { schedule(DEFAULT_POLL_MS); });
85
- });
155
+ }, function () { schedule(DEFAULT_POLL_MS); landed(); });
156
+ }).catch(function () { schedule(DEFAULT_POLL_MS); landed(); });
157
+ }
158
+ // Read now, unless a read went out under NUDGE_GAP_MS ago (then at the end of that gap) or one is on the wire (then
159
+ // once more when it lands). Today calls this when a session goes to waiting, so its question shows at once.
160
+ function nudge() {
161
+ if (Q.stopped || document.hidden || !signedIn()) return;
162
+ if (Q.inflight) { Q.again = true; return; }
163
+ var since = Date.now() - Q.lastPollAt;
164
+ schedule(since >= NUDGE_GAP_MS ? 0 : NUDGE_GAP_MS - since);
86
165
  }
87
- function start() { Q.stopped = false; poll(); }
166
+ // The first read of the page goes at once; after that (back from hidden, a new sign-in), through nudge (QA 6.99.27, N4).
167
+ function start() { Q.stopped = false; if (!Q.lastPollAt) poll(); else nudge(); }
88
168
  function stop() { clearTimeout(Q.timer); }
89
169
 
90
170
  // ── state from a poll ─────────────────────────────────
91
- function take(items) {
171
+ function take(items, settled) {
92
172
  var pending = items.filter(function (i) { return i && i.answerable !== false && (i.kind === 'question' || i.kind === 'approval'); });
93
- var before = Q.items.map(function (i) { return i.id; }).join('|');
94
- var after = pending.map(function (i) { return i.id; }).join('|');
173
+ var atMac = (settled || []).filter(function (i) { return i && i.waitingAtMac === true && (i.kind === 'question' || i.kind === 'approval'); });
174
+ var before = Q.items.map(function (i) { return i.id; }).join('|') + '/' + Q.atMac.map(function (i) { return i.id; }).join('|');
175
+ var after = pending.map(function (i) { return i.id; }).join('|') + '/' + atMac.map(function (i) { return i.id; }).join('|');
176
+ // The words of a held card, kept while the server lists it either way, so one that goes back to the Mac still says
177
+ // what it asks. Only in memory.
178
+ pending.forEach(function (i) {
179
+ Q.seen[i.id] = { questions: (i.questions || []).map(function (q) { return String((q && q.question) || ''); }).filter(Boolean),
180
+ summary: i.approval && i.approval.summary ? String(i.approval.summary) : '' };
181
+ });
182
+ var listed = {}; pending.concat(atMac).forEach(function (i) { listed[i.id] = 1; });
183
+ Object.keys(Q.seen).forEach(function (id) { if (!listed[id]) delete Q.seen[id]; });
184
+ Q.atMac = atMac;
185
+ // Answered on any screen: the session list may still call the session waiting for a moment; this says it is not.
186
+ (settled || []).forEach(function (i) {
187
+ if (i && i.resolution === 'answered' && i.sessionId && !Q.answered[i.id]) Q.answered[i.id] = { sessionId: String(i.sessionId).toLowerCase(), at: Date.parse(i.settledAt) || Date.now() };
188
+ });
189
+ Object.keys(Q.answered).forEach(function (id) { if (Date.now() - Q.answered[id].at > ANSWERED_MS) delete Q.answered[id]; });
95
190
  var ids = {}; pending.forEach(function (i) { ids[i.id] = 1; });
96
- Object.keys(Q.picks).forEach(function (id) { if (!ids[id]) { delete Q.picks[id]; delete Q.armed[id]; delete Q.notes[id]; delete Q.answerIds[id]; } });
191
+ Object.keys(Q.picks).forEach(function (id) { if (!ids[id]) { delete Q.picks[id]; delete Q.armed[id]; delete Q.notes[id]; delete Q.answerIds[id]; delete Q.missing[id]; } });
97
192
  pending.forEach(function (i) {
98
193
  if (!Q.picks[i.id]) Q.picks[i.id] = (i.questions || []).map(function () { return { labels: [], other: '' }; });
99
194
  });
100
195
  Q.items = pending;
101
196
  // A card that is already drawn is never redrawn by a poll: a half-typed Other answer stays as it is.
102
- if (before !== after) render();
197
+ if (before !== after) { render(); changed(); }
103
198
  }
104
- function clearCards() { if (Q.items.length) { Q.items = []; render(); } }
199
+ function clearCards() { if (Q.items.length || Q.atMac.length) { Q.items = []; Q.atMac = []; render(); changed(); } }
200
+ // Today's Needs you says, per session, whether it can be answered here or waits on the Mac.
201
+ function changed() { if (W.today && W.today.questionsChanged) { try { W.today.questionsChanged(); } catch (_e) {} } }
105
202
 
106
203
  // ── drawing ───────────────────────────────────────────
107
204
  function complete(item) {
@@ -116,30 +213,91 @@
116
213
  var text = Q.notes[item.id] || '';
117
214
  return '<p class="sq-note' + (text ? '' : ' hidden') + '" data-sq-note="' + esc(item.id) + '" role="status">' + esc(text) + '</p>';
118
215
  }
216
+ // The card reads like Claude Code's own question in the terminal (Miles 11/07): a strip naming each question, ☒ once
217
+ // it has an answer, then ✓ Submit; each question in bold, numbered options with their descriptions under them, Other
218
+ // as the last number, typed in place; keys 1 to 9 pick while a question has focus. Then the answers to review, and
219
+ // Submit, tap then tap again. Stacked rather than one question per page: Today's sheet is at most 480 px wide, folded
220
+ // or not, and a stacked card keeps every choice in view while you review. The strip jumps to each part.
221
+ function qLabel(q, qi) { return q && q.header ? String(q.header) : 'Question ' + (qi + 1); }
222
+ function answerWords(item, qi) {
223
+ var p = (Q.picks[item.id] || [])[qi] || { labels: [], other: '' };
224
+ var parts = p.labels.slice();
225
+ if (p.other.trim()) parts.push(p.other.trim());
226
+ return parts.join(', ');
227
+ }
228
+ function stepText(item, qi) { return (answerWords(item, qi) ? '☒ ' : '☐ ') + qLabel(item.questions[qi], qi); }
229
+ function stripHtml(item) {
230
+ var qs = item.questions || [];
231
+ if (qs.length < 2) return '';
232
+ return '<nav class="sq-steps" aria-label="Questions">' + qs.map(function (q, qi) {
233
+ return '<button type="button" class="sq-step" data-sq="step" data-sq-id="' + esc(item.id) + '" data-sq-step="' + qi + '">' + esc(stepText(item, qi)) + '</button>';
234
+ }).join('') + '<button type="button" class="sq-step" data-sq="step" data-sq-id="' + esc(item.id) + '" data-sq-step="submit">✓ Submit</button></nav>';
235
+ }
236
+ function reviewRows(item) {
237
+ return (item.questions || []).map(function (q, qi) {
238
+ var a = answerWords(item, qi);
239
+ return '<li><span class="sq-review-q">' + esc(qLabel(q, qi)) + '</span><span class="sq-review-a' + (a ? '' : ' empty') + '">' + esc(a || 'Not answered yet') + '</span></li>';
240
+ }).join('');
241
+ }
242
+ function reviewHtml(item) {
243
+ if ((item.questions || []).length < 2) return '';
244
+ return '<div class="sq-review" data-sq-review="' + esc(item.id) + '"><p class="sq-review-head">Your answers</p><ol class="sq-review-list">' + reviewRows(item) + '</ol></div>';
245
+ }
246
+ // 6.99.27 (Miles's phone, 11/07 09:32: "It's not allowing me to answer the Qs"): Submit was greyed out until every
247
+ // question had an answer, below the fold on a long card, and a multi-select said "Pick any that apply" though the broker
248
+ // takes no empty answer (validateQuestionAnswers: `unanswered`). Now the card says what each question needs, keeps
249
+ // its progress and the answer button in a footer that stays on screen, and a tap with something missing goes to it.
250
+ function needText(q) { return q.multiSelect ? 'Pick one or more.' : 'Pick one.'; }
251
+ function answeredCount(item) { var n = 0; (item.questions || []).forEach(function (_q, qi) { if (answerWords(item, qi)) n++; }); return n; }
252
+ function firstMissing(item, after) {
253
+ var qs = item.questions || [], n = qs.length;
254
+ for (var k = 1; k <= n; k++) { var qi = ((after == null ? -1 : after) + k) % n; if (!answerWords(item, qi)) return qi; }
255
+ return -1;
256
+ }
257
+ function progressText(item) {
258
+ var qs = item.questions || [], done = answeredCount(item), miss = Q.missing[item.id];
259
+ if (miss != null && !complete(item)) return qLabel(qs[miss], miss) + ' still needs an answer. ' + needText(qs[miss]);
260
+ if (qs.length < 2) return done ? 'Ready to submit.' : 'Not answered yet. ' + needText(qs[0]);
261
+ return done + ' of ' + qs.length + ' answered';
262
+ }
263
+ function sendText(id) {
264
+ if (Q.sending[id]) return 'Submitting…';
265
+ if (Q.armed[id] === 'send') return 'Tap again to submit';
266
+ var item = itemOf(id);
267
+ return item && (item.questions || []).length > 1 ? 'Review and submit' : 'Submit';
268
+ }
119
269
  function questionCard(item, scope) {
120
270
  var picks = Q.picks[item.id] || [];
121
271
  var sending = !!Q.sending[item.id];
272
+ var single = (item.questions || []).length < 2;
122
273
  var blocks = (item.questions || []).map(function (q, qi) {
123
274
  var p = picks[qi] || { labels: [], other: '' };
124
275
  var type = q.multiSelect ? 'checkbox' : 'radio';
125
276
  var name = (scope || 'sq') + '-' + item.id + '-' + qi;
126
- return '<fieldset class="sq-q"><legend class="sq-q-text">' + esc(q.question) + (q.header ? ' <span class="sq-q-tag">' + esc(q.header) + '</span>' : '') + '</legend>' +
127
- (q.multiSelect ? '<p class="sq-fine">Pick any that apply.</p>' : '') +
277
+ return '<fieldset class="sq-q" data-sq-qblock="' + qi + '" data-sq-id="' + esc(item.id) + '"><legend class="sq-q-text">' + esc(q.question) +
278
+ (single && q.header ? ' <span class="sq-q-tag">' + esc(q.header) + '</span>' : '') + '</legend>' +
279
+ '<p class="sq-need" data-sq-need="' + qi + '" data-state="' + (answerWords(item, qi) ? 'done' : 'todo') + '">' + esc(needText(q)) + '</p>' +
128
280
  '<div class="sq-options">' + q.options.map(function (o, oi) {
129
281
  var on = p.labels.indexOf(o.label) >= 0;
130
282
  return '<label class="sq-option"><input type="' + type + '" name="' + esc(name) + '" data-sq-pick data-sq-id="' + esc(item.id) + '" data-q="' + qi + '" data-o="' + oi + '"' + (on ? ' checked' : '') + (sending ? ' disabled' : '') + '>' +
283
+ '<span class="sq-num" aria-hidden="true">' + (oi + 1) + '.</span>' +
131
284
  '<span class="sq-option-text"><span class="sq-option-label">' + esc(o.label) + '</span>' + (o.description ? '<span class="sq-option-desc">' + esc(o.description) + '</span>' : '') + '</span></label>';
132
- }).join('') + '</div>' +
133
- '<input class="field sq-other" type="text" maxlength="2000" placeholder="Other: type your own answer" aria-label="Your own answer" data-sq-other data-sq-id="' + esc(item.id) + '" data-q="' + qi + '" value="' + esc(p.other) + '"' + (sending ? ' disabled' : '') + '>' +
134
- '</fieldset>';
285
+ }).join('') +
286
+ '<label class="sq-option sq-option-other"><span class="sq-num" aria-hidden="true">' + (q.options.length + 1) + '.</span>' +
287
+ '<input class="field sq-other" type="text" maxlength="2000" placeholder="Other: type your own answer" aria-label="Your own answer" data-sq-other data-sq-id="' + esc(item.id) + '" data-q="' + qi + '" value="' + esc(p.other) + '"' + (sending ? ' disabled' : '') + '></label>' +
288
+ '</div></fieldset>';
135
289
  }).join('');
136
- var armed = Q.armed[item.id] === 'send';
137
290
  return '<article class="sq-card" data-sq-card="' + esc(item.id) + '" data-kind="question">' +
138
291
  '<header class="sq-head"><h2 class="sq-title">' + esc(engine(item)) + ' has a question</h2><span class="sq-secs" data-sq-secs="' + esc(item.id) + '">' + esc(timeLeft(secondsLeft(item))) + '</span></header>' +
139
- blocks +
140
- '<div class="sq-acts"><button type="button" class="cx-btn primary sq-send" data-sq="send" data-sq-id="' + esc(item.id) + '"' + (sending || !complete(item) ? ' disabled' : '') + '>' + (sending ? 'Sending…' : armed ? 'Tap again to send' : 'Send') + '</button>' +
292
+ stripHtml(item) + blocks + reviewHtml(item) +
293
+ '<p class="sq-fine sq-keys">Type a number to pick.</p>' +
294
+ // Sticky to the bottom of the screen while the card is on it (questions.css): the progress, and the button that
295
+ // always answers: it never greys out for a missing answer, it goes to it.
296
+ '<div class="sq-foot" data-sq-foot="' + esc(item.id) + '">' +
297
+ '<p class="sq-progress" data-sq-progress="' + esc(item.id) + '" role="status"' + (Q.missing[item.id] != null && !complete(item) ? ' data-alert="1"' : '') + '>' + esc(progressText(item)) + '</p>' +
298
+ '<div class="sq-acts"><button type="button" class="cx-btn primary sq-send" data-sq="send" data-sq-id="' + esc(item.id) + '"' + (sending ? ' disabled' : '') + '>' + sendText(item.id) + '</button>' +
141
299
  '<button type="button" class="link-btn sq-leave" data-sq="leave" data-sq-id="' + esc(item.id) + '"' + (sending ? ' disabled' : '') + '>Leave for the Mac</button></div>' +
142
- noteHtml(item) + '</article>';
300
+ noteHtml(item) + '</div></article>';
143
301
  }
144
302
  function approvalCard(item) {
145
303
  var a = item.approval || { tool: item.tool, summary: '', detail: '' };
@@ -154,6 +312,35 @@
154
312
  '<button type="button" class="link-btn sq-leave" data-sq="leave" data-sq-id="' + esc(item.id) + '"' + (sending ? ' disabled' : '') + '>Leave for the Mac</button></div>' +
155
313
  noteHtml(item) + '</article>';
156
314
  }
315
+ // A question or an approval the Mac's own prompt waits on: what it asks when this page saw it held, and where to answer.
316
+ // No buttons: nothing here can deliver an answer to that prompt.
317
+ function macCard(item) {
318
+ var words = Q.seen[item.id] || { questions: [], summary: '' };
319
+ var title = item.kind === 'approval' ? engine(item) + ' wants to use ' + (item.tool || 'a tool') : engine(item) + ' has a question';
320
+ var body = item.kind === 'approval'
321
+ ? (words.summary ? '<pre class="sq-command">' + esc(words.summary) + '</pre>' : '')
322
+ : words.questions.map(function (q) { return '<p class="sq-q-text sq-mac-q">' + esc(q) + '</p>'; }).join('');
323
+ return '<article class="sq-card sq-mac" data-sq-mac="' + esc(item.id) + '" data-kind="' + esc(item.kind) + '">' +
324
+ '<header class="sq-head"><h2 class="sq-title">' + esc(title) + '</h2></header>' + body +
325
+ '<p class="sq-mac-line">' + esc(MAC_LINE) + '</p></article>';
326
+ }
327
+ function atMacFor(sessionId) {
328
+ // Every item names its session (the broker validates it), so no session asked for matches none: the top of every
329
+ // pane never draws a Mac card.
330
+ var want = String(sessionId || '').toLowerCase();
331
+ return Q.atMac.filter(function (i) { return String(i.sessionId || '').toLowerCase() === want; });
332
+ }
333
+ // True when this session's newest event here is an answer (from this page or any other screen) of the last two
334
+ // minutes, with nothing held for it now and nothing at the Mac since.
335
+ function answeredFor(sessionId) {
336
+ var want = String(sessionId || '').toLowerCase();
337
+ if (!want || itemsFor(want).length) return false;
338
+ var at = 0, macAt = 0;
339
+ Object.keys(Q.answered).forEach(function (id) { var a = Q.answered[id]; if (a.sessionId === want && Date.now() - a.at <= ANSWERED_MS) at = Math.max(at, a.at); });
340
+ atMacFor(want).forEach(function (i) { macAt = Math.max(macAt, Date.parse(i.settledAt) || Date.parse(i.createdAt) || 0); });
341
+ return at > 0 && at >= macAt;
342
+ }
343
+ function wordsOf(id) { var w = Q.seen[id]; return w ? { questions: w.questions.slice(), summary: w.summary } : null; }
157
344
  // Other places can show the cards for one session (Today's overlay, panes/today-actions.js): the same items, picks,
158
345
  // answer ids and posts, drawn into their own element. A mount is dropped when its element leaves the page.
159
346
  var mounts = [];
@@ -168,14 +355,14 @@
168
355
  function drawInto(m) {
169
356
  var active = document.activeElement;
170
357
  var focus = active && active.hasAttribute && active.hasAttribute('data-sq-other') && m.el.contains && m.el.contains(active) ? { id: active.getAttribute('data-sq-id'), q: active.getAttribute('data-q'), start: active.selectionStart, end: active.selectionEnd } : null;
171
- var list = itemsFor(m.sessionId);
172
- m.el.innerHTML = list.map(function (i) { return i.kind === 'approval' ? approvalCard(i) : questionCard(i, m.scope); }).join('');
173
- m.el.hidden = !list.length;
358
+ var list = itemsFor(m.sessionId), mac = atMacFor(m.sessionId);
359
+ m.el.innerHTML = list.map(function (i) { return i.kind === 'approval' ? approvalCard(i) : questionCard(i, m.scope); }).join('') + mac.map(macCard).join('');
360
+ m.el.hidden = !list.length && !mac.length;
174
361
  if (focus) {
175
362
  var box = m.el.querySelector('[data-sq-other][data-sq-id="' + focus.id + '"][data-q="' + focus.q + '"]');
176
363
  if (box) { box.focus({ preventScroll: true }); try { box.setSelectionRange(focus.start, focus.end); } catch (_e) {} }
177
364
  }
178
- if (m.onChange) { try { m.onChange(list.length); } catch (_e) {} }
365
+ if (m.onChange) { try { m.onChange(list.length, mac.length); } catch (_e) {} }
179
366
  }
180
367
  function render() {
181
368
  hostsOf().forEach(drawInto);
@@ -197,7 +384,16 @@
197
384
  }
198
385
  function patchOne(card, id, item) {
199
386
  var send = card.querySelector('[data-sq="send"]');
200
- if (send) { send.disabled = !!Q.sending[id] || !complete(item); send.textContent = Q.sending[id] ? 'Sending…' : Q.armed[id] === 'send' ? 'Tap again to send' : 'Send'; }
387
+ if (send) { send.disabled = !!Q.sending[id]; send.textContent = sendText(id); }
388
+ var progress = card.querySelector('[data-sq-progress]');
389
+ if (progress && item.kind === 'question') {
390
+ progress.textContent = progressText(item);
391
+ if (Q.missing[id] != null && !complete(item)) progress.setAttribute('data-alert', '1'); else progress.removeAttribute('data-alert');
392
+ }
393
+ card.querySelectorAll('[data-sq-need]').forEach(function (need) { need.setAttribute('data-state', answerWords(item, Number(need.getAttribute('data-sq-need'))) ? 'done' : 'todo'); });
394
+ // The strip's ☐ and ☒ and the answers to review follow each pick, without redrawing the boxes being typed in.
395
+ card.querySelectorAll('[data-sq-step]').forEach(function (step) { var n = step.getAttribute('data-sq-step'); if (n !== 'submit') step.textContent = stepText(item, Number(n)); });
396
+ var review = card.querySelector('[data-sq-review] .sq-review-list'); if (review) review.innerHTML = reviewRows(item);
201
397
  var allow = card.querySelector('[data-sq="allow"]'); if (allow) allow.textContent = Q.armed[id] === 'allow' ? 'Tap again to allow' : 'Allow once';
202
398
  var deny = card.querySelector('[data-sq="deny"]'); if (deny) deny.textContent = Q.armed[id] === 'deny' ? 'Tap again to deny' : 'Deny';
203
399
  var note = card.querySelector('[data-sq-note]'); if (note) { note.textContent = Q.notes[id] || ''; note.classList.toggle('hidden', !Q.notes[id]); }
@@ -209,26 +405,47 @@
209
405
  handed_to_desk: 'The Mac has this one now. Answer it there.', hook_gone: 'The session stopped waiting for this answer.',
210
406
  not_found: 'This question is gone.', invalid_answer: 'That answer was not accepted. Check each question has a choice.'
211
407
  };
408
+ // QA round 1: an answer that never reached the server (no HTTP status: offline, a dropped link, a timeout) says so in
409
+ // plain words, with the button to tap; the retry goes under the same clientAnswerId, so the server replays it.
410
+ function unreachable(item, what) {
411
+ // QA 6.99.27 (User 7): the button's own name. A card with more than one question says Review and submit.
412
+ var tap = what === 'leave' ? 'Leave for the Mac' : item && item.kind === 'approval' ? (what === 'deny' ? 'Deny' : 'Allow once')
413
+ : item && (item.questions || []).length > 1 ? 'Review and submit' : 'Submit';
414
+ return 'Could not reach your Mac. Tap ' + tap + ' to retry.';
415
+ }
416
+ // Answered, here or on another screen: recorded before the card is redrawn (a waiting sheet reads it as it repaints),
417
+ // then Today stops pointing at the Mac and reads its lists again.
418
+ function noteAnswered(item, id) { if (item && item.sessionId) Q.answered[id] = { sessionId: String(item.sessionId).toLowerCase(), at: Date.now() }; }
419
+ function answeredTold() {
420
+ changed();
421
+ if (W.today && W.today.refresh) { try { W.today.refresh(); } catch (_e) {} }
422
+ }
212
423
  function post(id, body, doneLine) {
213
424
  Q.sending[id] = 1; Q.armed[id] = ''; Q.notes[id] = '';
214
425
  if (!Q.answerIds[id]) Q.answerIds[id] = answerId();
215
426
  body.clientAnswerId = Q.answerIds[id];
216
427
  render();
428
+ var asked = itemOf(id);
217
429
  api('/api/session-questions/' + encodeURIComponent(id) + '/answer', { method: 'POST', body: body, timeoutMs: 20000 }).then(function () {
218
430
  delete Q.sending[id];
431
+ noteAnswered(asked, id);
219
432
  Q.items = Q.items.filter(function (i) { return i.id !== id; });
220
- render();
433
+ render(); answeredTold();
221
434
  W.toast(doneLine);
222
435
  schedule(500);
223
436
  }, function (err) {
224
437
  delete Q.sending[id];
225
438
  var settled = err && REFUSED[err.code] && err.code !== 'invalid_answer';
226
439
  if (settled) {
440
+ if (err.code === 'already_answered') noteAnswered(asked, id);
227
441
  Q.items = Q.items.filter(function (i) { return i.id !== id; });
228
- render(); W.toast(REFUSED[err.code]); schedule(500); return;
442
+ render();
443
+ if (err.code === 'already_answered') answeredTold(); else changed();
444
+ W.toast(REFUSED[err.code]); schedule(500); return;
229
445
  }
230
446
  // A timeout or a lost answer: the same clientAnswerId goes again on the next tap and the server replays it.
231
- Q.notes[id] = REFUSED[err && err.code] || ('Not sent: ' + ((err && err.message) || 'the Mac did not answer') + ' Tap again to retry.');
447
+ Q.notes[id] = REFUSED[err && err.code] || (err && err.status == null ? unreachable(asked, body.decision || 'send')
448
+ : 'Not sent: ' + ((err && err.message) || 'the Mac did not answer') + ' Tap again to retry.');
232
449
  render();
233
450
  });
234
451
  }
@@ -242,11 +459,51 @@
242
459
  });
243
460
  post(item.id, { answers: answers }, 'Sent to ' + engine(item) + '.');
244
461
  }
462
+ // Scroll a part of the card into view: smooth unless reduced motion asks otherwise. Questions carry a scroll margin
463
+ // (questions.css) so the top bar and the sticky footer never cover them.
464
+ function bringIn(el, block) {
465
+ if (!el || !el.scrollIntoView) return;
466
+ var still = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
467
+ el.scrollIntoView({ block: block || 'start', behavior: still ? 'auto' : 'smooth' });
468
+ }
469
+ function goToQuestion(card, qi) {
470
+ var to = card && card.querySelector('[data-sq-qblock="' + qi + '"]');
471
+ bringIn(to, 'start');
472
+ var f = to && to.querySelector('input');
473
+ if (f && f.focus && !f.disabled) f.focus({ preventScroll: true });
474
+ }
475
+ function goToSubmit(card) {
476
+ bringIn(card && card.querySelector('[data-sq-review]'), 'nearest');
477
+ var send = card && card.querySelector('[data-sq="send"]');
478
+ if (send && send.focus && !send.disabled) send.focus({ preventScroll: true });
479
+ }
480
+ // As in the terminal: a pick on a single-choice question moves on to the next one still unanswered, or to Submit.
481
+ var ADVANCE_MS = 160;
482
+ function advance(card, item, qi) {
483
+ if (!card || (item.questions || []).length < 2) return;
484
+ var next = firstMissing(item, qi);
485
+ setTimeout(function () { if (itemOf(item.id) && !Q.sending[item.id]) { if (next === -1) goToSubmit(card); else goToQuestion(card, next); } }, ADVANCE_MS);
486
+ }
245
487
  function onClick(e) {
246
488
  var b = e.target.closest('[data-sq]');
247
489
  if (!b || b.disabled) return;
248
490
  var id = b.getAttribute('data-sq-id'), what = b.getAttribute('data-sq'), item = itemOf(id);
249
491
  if (!item) return;
492
+ var card = b.closest ? b.closest('.sq-card') : null;
493
+ if (what === 'step') {
494
+ // The strip brings a question to the top, or the answers and Submit into view.
495
+ var n = b.getAttribute('data-sq-step');
496
+ if (n === 'submit') goToSubmit(card); else goToQuestion(card, Number(n));
497
+ return;
498
+ }
499
+ if (what === 'send' && !complete(item)) {
500
+ // Something is missing: say which, and go to it. Never a greyed-out button that does nothing.
501
+ Q.armed[id] = '';
502
+ Q.missing[id] = firstMissing(item, null);
503
+ patchCard(id);
504
+ goToQuestion(card, Q.missing[id]);
505
+ return;
506
+ }
250
507
  if (what === 'leave') {
251
508
  Q.armed[id] = '';
252
509
  if (!Q.answerIds[id]) Q.answerIds[id] = answerId();
@@ -255,14 +512,18 @@
255
512
  delete Q.sending[id]; Q.items = Q.items.filter(function (i) { return i.id !== id; }); render(); W.toast('Left for the Mac.'); schedule(500);
256
513
  }, function (err) {
257
514
  delete Q.sending[id];
258
- if (err && REFUSED[err.code] && err.code !== 'invalid_answer') { Q.items = Q.items.filter(function (i) { return i.id !== id; }); render(); W.toast(REFUSED[err.code]); return; }
259
- Q.notes[id] = 'Not sent: ' + ((err && err.message) || 'the Mac did not answer'); render();
515
+ if (err && REFUSED[err.code] && err.code !== 'invalid_answer') { Q.items = Q.items.filter(function (i) { return i.id !== id; }); render(); changed(); W.toast(REFUSED[err.code]); return; }
516
+ Q.notes[id] = err && err.status == null ? unreachable(item, 'leave') : 'Not sent: ' + ((err && err.message) || 'the Mac did not answer'); render();
260
517
  });
261
518
  return;
262
519
  }
263
520
  // Tap, then tap again: the first tap only arms the button, and the second counts only once the first has been on
264
521
  // screen a moment (COSWeb.confirmReady, the rule Today's overlay uses), so a double tap never allows or sends.
265
- if (Q.armed[id] !== what) { Q.armed[id] = what; Q.armedAt[id] = Date.now(); patchCard(id); return; }
522
+ if (Q.armed[id] !== what) {
523
+ Q.armed[id] = what; Q.armedAt[id] = Date.now(); patchCard(id);
524
+ if (what === 'send') goToSubmit(card); // the first tap is the review: the answers come into view
525
+ return;
526
+ }
266
527
  if (W.confirmReady && !W.confirmReady(Q.armedAt[id])) return;
267
528
  if (what === 'send') return sendAnswers(item);
268
529
  if (what === 'allow' || what === 'deny') return post(id, { decision: what }, what === 'allow' ? 'Allowed once.' : 'Denied.');
@@ -270,16 +531,52 @@
270
531
  function onChange(e) {
271
532
  var t = e.target;
272
533
  if (!t.hasAttribute('data-sq-pick')) return;
273
- var id = t.getAttribute('data-sq-id'), item = itemOf(id); if (!item) return;
274
- var qi = Number(t.getAttribute('data-q')), q = item.questions[qi], label = q.options[Number(t.getAttribute('data-o'))].label;
275
- var p = Q.picks[id][qi];
276
- if (q.multiSelect) p.labels = t.checked ? p.labels.concat([label]) : p.labels.filter(function (l) { return l !== label; });
534
+ var id = t.getAttribute('data-sq-id'), qi = Number(t.getAttribute('data-q')), item = itemOf(id);
535
+ if (!applyPick(id, qi, Number(t.getAttribute('data-o')), !!t.checked)) return;
536
+ if (!item.questions[qi].multiSelect) advance(t.closest ? t.closest('.sq-card') : null, item, qi);
537
+ }
538
+ // One pick, from a tap or a number key: the model first, then every drawn copy of the card follows it.
539
+ function applyPick(id, qi, oi, checked) {
540
+ var item = itemOf(id); if (!item || Q.sending[id]) return false;
541
+ var q = item.questions[qi]; if (!q || !q.options[oi]) return false;
542
+ var label = q.options[oi].label, p = Q.picks[id][qi];
543
+ if (q.multiSelect) p.labels = checked ? (p.labels.indexOf(label) >= 0 ? p.labels : p.labels.concat([label])) : p.labels.filter(function (l) { return l !== label; });
277
544
  else {
278
545
  p.labels = [label]; p.other = '';
279
546
  hostsOf().forEach(function (m) { var box = m.el.querySelector('[data-sq-other][data-sq-id="' + id + '"][data-q="' + qi + '"]'); if (box) box.value = ''; });
280
547
  }
548
+ hostsOf().forEach(function (m) {
549
+ m.el.querySelectorAll('[data-sq-pick][data-sq-id="' + id + '"][data-q="' + qi + '"]').forEach(function (r) { r.checked = p.labels.indexOf(q.options[Number(r.getAttribute('data-o'))].label) >= 0; });
550
+ });
551
+ // A change after Submit was armed takes the arm back: review, then submit.
281
552
  Q.armed[id] = '';
553
+ if (Q.missing[id] != null) delete Q.missing[id];
282
554
  patchCard(id);
555
+ return true;
556
+ }
557
+ // Keys 1 to 9 while a question has focus, as in the terminal: the option with that number, or the last number to type
558
+ // your own. Never while typing in Other, and never with a modifier held.
559
+ function onKey(e) {
560
+ var k = e.key;
561
+ if (!/^[1-9]$/.test(k) || e.metaKey || e.ctrlKey || e.altKey) return;
562
+ var t = e.target;
563
+ if (!t || !t.closest || (t.hasAttribute && t.hasAttribute('data-sq-other'))) return;
564
+ var block = t.closest('[data-sq-qblock]');
565
+ if (!block) return;
566
+ var id = block.getAttribute('data-sq-id'), qi = Number(block.getAttribute('data-sq-qblock')), item = itemOf(id);
567
+ var q = item && item.questions[qi], n = Number(k);
568
+ if (!q || Q.sending[id]) return;
569
+ if (n <= q.options.length) {
570
+ var label = q.options[n - 1].label, on = (Q.picks[id][qi].labels.indexOf(label) >= 0);
571
+ e.preventDefault();
572
+ applyPick(id, qi, n - 1, q.multiSelect ? !on : true);
573
+ var input = block.querySelector('[data-sq-pick][data-o="' + (n - 1) + '"]');
574
+ if (input && input.focus) input.focus({ preventScroll: true });
575
+ if (!q.multiSelect) advance(block.closest ? block.closest('.sq-card') : null, item, qi);
576
+ } else if (n === q.options.length + 1) {
577
+ var other = block.querySelector('[data-sq-other]');
578
+ if (other && other.focus) { e.preventDefault(); other.focus(); }
579
+ }
283
580
  }
284
581
  function onInput(e) {
285
582
  var t = e.target;
@@ -293,21 +590,41 @@
293
590
  hostsOf().forEach(function (m) { m.el.querySelectorAll('[data-sq-pick][data-sq-id="' + id + '"][data-q="' + qi + '"]').forEach(function (r) { r.checked = false; }); });
294
591
  }
295
592
  Q.armed[id] = '';
593
+ if (Q.missing[id] != null) delete Q.missing[id];
296
594
  patchCard(id);
297
595
  }
298
- function listen(el) { el.addEventListener('click', onClick); el.addEventListener('change', onChange); el.addEventListener('input', onInput); }
596
+ function listen(el) { el.addEventListener('click', onClick); el.addEventListener('change', onChange); el.addEventListener('input', onInput); el.addEventListener('keydown', onKey); }
299
597
  listen(host);
300
598
  // Draw this session's cards into `el` and keep them in step; `onChange(count)` hears each redraw.
301
599
  function mount(el, sessionId, onChange) {
302
600
  if (!el) return null;
303
601
  var m = { el: el, sessionId: String(sessionId || '').toLowerCase(), scope: 'sqm' + (mounts.length + 1), onChange: onChange || null };
304
602
  mounts.push(m); listen(el); drawInto(m);
305
- if (!Q.stopped) { clearTimeout(Q.timer); Q.timer = setTimeout(poll, 0); }
603
+ if (!Q.stopped) nudge();
306
604
  return { count: function () { return itemsFor(m.sessionId).length; }, unmount: function () { mounts = mounts.filter(function (x) { return x !== m; }); } };
307
605
  }
308
606
 
607
+ // QA 6.99.27 (User 2): Today's own card (#sq-host) has a sticky footer, and iOS puts the keyboard over the layout
608
+ // viewport, so with Other focused the footer sat under it. The keyboard's height (what of the layout viewport the
609
+ // visible one leaves at the bottom) goes on the host as --sq-kb, and questions.css lifts the footer by it. Today's
610
+ // overlay needs none of this: its layer already follows the visible viewport (today-actions.js fit).
611
+ function keyboardInset() {
612
+ var vv = window.visualViewport;
613
+ if (!vv) return 0;
614
+ var inset = Math.round((window.innerHeight || 0) - (vv.height + vv.offsetTop));
615
+ return inset > 0 ? inset : 0;
616
+ }
617
+ function fitKeyboard() {
618
+ if (host.style && host.style.setProperty) host.style.setProperty('--sq-kb', keyboardInset() + 'px');
619
+ }
620
+ if (window.visualViewport) { window.visualViewport.addEventListener('resize', fitKeyboard); window.visualViewport.addEventListener('scroll', fitKeyboard); }
621
+
622
+ // Hidden: no reads at all (a phone in a pocket spends nothing). Visible again: one read at once (within the 1 s gap).
309
623
  document.addEventListener('visibilitychange', function () { if (document.hidden) stop(); else if (signedIn()) start(); });
310
624
  W.onShell(function () { start(); });
625
+ // Coming to Today: a read at once (within the nudge gap), then every 3 s while it stays on screen.
626
+ if (W.onRoute) W.onRoute(function (r) { if (r && r.pane === 'today') nudge(); });
311
627
 
312
- W.sessionQuestions = { _state: Q, _take: take, _render: render, poll: poll, mount: mount, itemsFor: itemsFor };
628
+ W.sessionQuestions = { _state: Q, _take: take, _render: render, _presenceAge: presenceAge, _pollUrl: pollUrl, _key: onKey, _advanceMs: ADVANCE_MS, _todayPollMs: TODAY_POLL_MS, _fitKeyboard: fitKeyboard, poll: poll, nudge: nudge, mount: mount,
629
+ itemsFor: itemsFor, atMacFor: atMacFor, answeredFor: answeredFor, wordsOf: wordsOf, macLine: MAC_LINE, answeredLine: ANSWERED_LINE };
313
630
  })();