@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
@@ -32,6 +32,8 @@
32
32
  cap: null, // capabilities.sessionQuestions, once read
33
33
  capAt: 0,
34
34
  items: [], // pending items, oldest first
35
+ waiting: [], // X1 contract: the questions live sessions wait on (held, at_mac, answered)
36
+ webAnswer: null, // X1 final contract: what an answer from this page must carry right now (only with a hold switch on)
35
37
  picks: {}, // id -> [{ labels: [], other: '' }] per question
36
38
  armed: {}, // id -> 'send' | 'allow' | 'deny'
37
39
  armedAt: {}, // id -> when it was armed (COSWeb.confirmReady)
@@ -47,7 +49,8 @@
47
49
  stopped: false,
48
50
  inflight: false, // a poll is on the wire
49
51
  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)
52
+ lastPollAt: 0, // when the last poll went out (Date.now)
53
+ load: 'unread' // W13: 'unread' (no read yet), 'ok' (the last read was the server's answer) or 'failed' (it was not)
51
54
  };
52
55
  var DEFAULT_POLL_MS = 10000;
53
56
  var POLL_TIMEOUT_MS = 15000;
@@ -58,7 +61,12 @@
58
61
  // A nudge (a session that just went to waiting, Today coming on screen) never reads more often than this.
59
62
  var NUDGE_GAP_MS = 1000;
60
63
  // Miles's words for a question at the Mac (11/07), one place: Today's Needs you reads them from here too.
61
- var MAC_LINE = "Waiting on your Mac's prompt. Answer it on the Mac (or through Screens).";
64
+ // W1 A5 (Miles 11/09, "there is no ability to answer the question"): said once, and honest that this page cannot answer it.
65
+ // Q1 M3 (X1 canary A1; lib/thread-turn-queue.ts:451): a message sent to a session held on its prompt is queued for the
66
+ // end of the turn. It does not answer the question, and nothing promises a way to answer it here.
67
+ var MAC_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.";
68
+ // A question that went straight to the Mac's prompt: no route carries its words (the hook keeps none), so say so.
69
+ var MAC_NO_WORDS = "COS cannot read this question's words from here. They show at the Mac's prompt.";
62
70
  var ANSWERED_LINE = 'Answered. The session carries on.';
63
71
  var ANSWERED_MS = 120000;
64
72
 
@@ -134,6 +142,19 @@
134
142
  // 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.
135
143
  if (Q.again) { Q.again = false; nudge(); }
136
144
  }
145
+ // W13 (QA week of 11/01-11/08): whether the last read was the server's answer. A failed read leaves no cards, and Today's
146
+ // sheet read "no card" as "at the Mac's prompt" (a Claude session) with no way to read again. Each change tells the
147
+ // mounted sheets (their onChange), never on a read that changed nothing.
148
+ function noteLoad(state) {
149
+ if (Q.load === state) return;
150
+ Q.load = state;
151
+ tellMounts();
152
+ }
153
+ function tellMounts() {
154
+ mounts.forEach(function (m) {
155
+ if (m.onChange) { try { m.onChange(itemsFor(m.sessionId).length, atMacFor(m.sessionId).length); } catch (_e) {} }
156
+ });
157
+ }
137
158
  function poll() {
138
159
  clearTimeout(Q.timer);
139
160
  if (Q.stopped || document.hidden || !signedIn()) return;
@@ -141,7 +162,7 @@
141
162
  if (Q.inflight) { Q.again = true; return; }
142
163
  Q.inflight = true; Q.again = false; Q.lastPollAt = Date.now();
143
164
  capForPoll().then(function (cap) {
144
- if (!usable(cap)) { Q.again = false; landed(); clearCards(); schedule(60000); return; }
165
+ if (!usable(cap)) { Q.again = false; landed(); clearCards(); schedule(60000); noteLoad(cap ? 'ok' : 'failed'); return; }
145
166
  // 6.99.32 (QA 6.99.30 canary W1): a Mac that hangs (asleep, a stale Tailscale route) never answered and never
146
167
  // refused, so this read stayed in flight forever: no more polls, and the Mac never marked down. It gives up after
147
168
  // POLL_TIMEOUT_MS, says the Mac is not answering, and polls again. The limit covers the body too (QA 6.99.32
@@ -153,27 +174,32 @@
153
174
  // answered (any status) or did not (below); otherwise a "down" mark stranded Submit, Allow and Deny for minutes.
154
175
  if (W.noteServer) W.noteServer(true);
155
176
  if (res.status === 401) { clearTimeout(limit); Q.again = false; landed(); Q.stopped = true; clearCards(); return; }
156
- if (!res.ok) { clearTimeout(limit); schedule(DEFAULT_POLL_MS); landed(); return; }
177
+ if (!res.ok) { clearTimeout(limit); schedule(DEFAULT_POLL_MS); landed(); noteLoad('failed'); return; }
157
178
  return res.json().then(function (d) {
158
179
  clearTimeout(limit);
159
- if (!d || d.enabled === false) { Q.again = false; landed(); clearCards(); schedule(60000); return; }
180
+ if (!d || d.enabled === false) { Q.again = false; landed(); clearCards(); schedule(60000); noteLoad('ok'); return; }
160
181
  // Painted as soon as the read lands: take() draws the cards and repaints Today's Needs you and the pet.
161
- take(Array.isArray(d.items) ? d.items : [], Array.isArray(d.settled) ? d.settled : []);
182
+ // The state first, so a redraw take() makes already reads 'ok'; a read that changed no card redraws nothing, so
183
+ // the sheets still hear that the failure is over.
184
+ var was = Q.load;
185
+ Q.load = 'ok';
186
+ take(Array.isArray(d.items) ? d.items : [], Array.isArray(d.settled) ? d.settled : [], Array.isArray(d.waiting) ? d.waiting : null, d.webAnswer);
187
+ if (was !== 'ok') tellMounts();
162
188
  var every = Number(d.pollIntervalMs) > 0 ? Number(d.pollIntervalMs) : DEFAULT_POLL_MS;
163
189
  schedule(nextWait(every));
164
190
  landed();
165
191
  }, function () {
166
192
  // The body never finished (cut off, or the limit fired mid-body). The Mac did answer the headers, so it is not
167
193
  // marked down; the poll is freed and goes again.
168
- clearTimeout(limit); schedule(DEFAULT_POLL_MS); landed();
194
+ clearTimeout(limit); schedule(DEFAULT_POLL_MS); landed(); noteLoad('failed');
169
195
  });
170
196
  }, function () {
171
197
  // No answer at all: refused, unreachable, or the limit fired before the headers (the only abort this read has).
172
198
  clearTimeout(limit);
173
199
  if (W.noteServer) W.noteServer(false);
174
- schedule(DEFAULT_POLL_MS); landed();
200
+ schedule(DEFAULT_POLL_MS); landed(); noteLoad('failed');
175
201
  });
176
- }).catch(function () { schedule(DEFAULT_POLL_MS); landed(); });
202
+ }).catch(function () { schedule(DEFAULT_POLL_MS); landed(); noteLoad('failed'); });
177
203
  }
178
204
  // 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
179
205
  // once more when it lands). Today calls this when a session goes to waiting, so its question shows at once.
@@ -188,7 +214,28 @@
188
214
  function stop() { clearTimeout(Q.timer); }
189
215
 
190
216
  // ── state from a poll ─────────────────────────────────
191
- function take(items, settled) {
217
+ // ── X1 contract v1 (COORD 2026-11-09 21:35): `waiting[]`, every AskUserQuestion a live Claude session waits on that the
218
+ // server saw asked: state held (a COS surface can answer it now), at_mac (only the Mac's own prompt can) or answered
219
+ // (kept 120 s). Words only in memory, as on the server. Keyed by `<sessionId>:<toolUseId>`, never by session alone.
220
+ var WAITING_STATES = { held: 1, at_mac: 1, answered: 1 };
221
+ function waitingEntry(w) {
222
+ if (!w || typeof w.key !== 'string' || !WAITING_STATES[w.state] || typeof w.sessionId !== 'string') return null;
223
+ var qs = (Array.isArray(w.questions) ? w.questions : []).slice(0, 4).map(function (q) {
224
+ return { question: String((q && q.question) || ''), header: String((q && q.header) || ''), multiSelect: !!(q && q.multiSelect),
225
+ options: (q && Array.isArray(q.options) ? q.options : []).slice(0, 8).map(function (o) { return { label: String((o && o.label) || ''), description: String((o && o.description) || '') }; }) };
226
+ }).filter(function (q) { return q.question; });
227
+ return { key: w.key, sessionId: w.sessionId.toLowerCase(), provider: 'claude', toolUseId: typeof w.toolUseId === 'string' ? w.toolUseId : null, state: w.state,
228
+ answerable: w.answerable === true && w.state === 'held', heldId: typeof w.heldId === 'string' && /^pq_[a-f0-9]{24}$/.test(w.heldId) ? w.heldId : null,
229
+ questions: qs, askedAt: Date.parse(w.askedAt) || 0, answeredAt: Date.parse(w.answeredAt) || 0 };
230
+ }
231
+ function take(items, settled, waiting, webAnswer) {
232
+ // An older server sends no `waiting`: keep the last list only for a moment's read, never invent one.
233
+ Q.waiting = Array.isArray(waiting) ? waiting.map(waitingEntry).filter(Boolean) : [];
234
+ // X1 final contract (`webAnswer`, sent with `waiting` while a hold switch is on): { touchRequired, touchMaxMs,
235
+ // confirmApprovals }. Absent: the page learns any rule from a refusal, as before.
236
+ Q.webAnswer = webAnswer && typeof webAnswer === 'object'
237
+ ? { touchRequired: webAnswer.touchRequired === true, touchMaxMs: Number(webAnswer.touchMaxMs) > 0 ? Number(webAnswer.touchMaxMs) : 60000, confirmApprovals: webAnswer.confirmApprovals === true }
238
+ : null;
192
239
  var pending = items.filter(function (i) { return i && i.answerable !== false && (i.kind === 'question' || i.kind === 'approval'); });
193
240
  var atMac = (settled || []).filter(function (i) { return i && i.waitingAtMac === true && (i.kind === 'question' || i.kind === 'approval'); });
194
241
  var before = Q.items.map(function (i) { return i.id; }).join('|') + '/' + Q.atMac.map(function (i) { return i.id; }).join('|');
@@ -230,9 +277,11 @@
230
277
  return n > 0 && (q.multiSelect || n === 1);
231
278
  });
232
279
  }
280
+ // X1 contract: a question held for the whole wait (fullHold) is on the Mac's screen too. Both answers count; the first wins.
281
+ var FULL_HOLD_LINE = 'Answer here or at your Mac. The first answer counts.';
233
282
  function noteHtml(item) {
234
283
  var text = Q.notes[item.id] || '';
235
- return '<p class="sq-note' + (text ? '' : ' hidden') + '" data-sq-note="' + esc(item.id) + '" role="status">' + esc(text) + '</p>';
284
+ return (item.fullHold === true ? '<p class="sq-fullhold">' + esc(FULL_HOLD_LINE) + '</p>' : '') + '<p class="sq-note' + (text ? '' : ' hidden') + '" data-sq-note="' + esc(item.id) + '" role="status">' + esc(text) + '</p>';
236
285
  }
237
286
  // The card reads like Claude Code's own question in the terminal (Miles 11/07): a strip naming each question, ☒ once
238
287
  // it has an answer, then ✓ Submit; each question in bold, numbered options with their descriptions under them, Other
@@ -338,11 +387,22 @@
338
387
  // 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.
339
388
  // No buttons: nothing here can deliver an answer to that prompt.
340
389
  function macCard(item) {
390
+ if (item.fromWaiting) {
391
+ var body0 = (item.questions || []).map(function (q) {
392
+ return '<p class="sq-q-text sq-mac-q">' + esc(q.question) + '</p>' + (q.options.length ? '<ul class="sq-mac-opts">' + q.options.map(function (o) {
393
+ return '<li>' + esc(o.label) + (o.description ? '<span class="sq-mac-desc"> ' + esc(o.description) + '</span>' : '') + '</li>';
394
+ }).join('') + '</ul>' : '');
395
+ }).join('') || '<p class="sq-mac-nowords">' + esc(MAC_NO_WORDS) + '</p>';
396
+ return '<article class="sq-card sq-mac" data-sq-mac="' + esc(item.id) + '" data-kind="question">' +
397
+ '<header class="sq-head"><h2 class="sq-title">' + esc(engine(item)) + ' has a question</h2></header>' + body0 +
398
+ '<p class="sq-mac-line">' + esc(MAC_LINE) + '</p></article>';
399
+ }
341
400
  var words = Q.seen[item.id] || { questions: [], summary: '' };
342
401
  var title = item.kind === 'approval' ? engine(item) + ' wants to use ' + (item.tool || 'a tool') : engine(item) + ' has a question';
343
402
  var body = item.kind === 'approval'
344
403
  ? (words.summary ? '<pre class="sq-command">' + esc(words.summary) + '</pre>' : '')
345
404
  : words.questions.map(function (q) { return '<p class="sq-q-text sq-mac-q">' + esc(q) + '</p>'; }).join('');
405
+ if (!body) body = '<p class="sq-mac-nowords">' + esc(MAC_NO_WORDS) + '</p>';
346
406
  return '<article class="sq-card sq-mac" data-sq-mac="' + esc(item.id) + '" data-kind="' + esc(item.kind) + '">' +
347
407
  '<header class="sq-head"><h2 class="sq-title">' + esc(title) + '</h2></header>' + body +
348
408
  '<p class="sq-mac-line">' + esc(MAC_LINE) + '</p></article>';
@@ -351,7 +411,24 @@
351
411
  // Every item names its session (the broker validates it), so no session asked for matches none: the top of every
352
412
  // pane never draws a Mac card.
353
413
  var want = String(sessionId || '').toLowerCase();
354
- return Q.atMac.filter(function (i) { return String(i.sessionId || '').toLowerCase() === want; });
414
+ // X1 contract: a question at the Mac's own prompt that this server saw asked has its words in `waiting`. One card per
415
+ // question, matched by tool_use_id: a held item that went back to the Mac and the waiting entry are the same question.
416
+ var waits = Q.waiting.filter(function (w) { return w.sessionId === want && w.state === 'at_mac'; });
417
+ var cards = waits.map(function (w) {
418
+ return { id: 'w:' + w.key, kind: 'question', sessionId: w.sessionId, provider: 'claude', toolUseId: w.toolUseId, fromWaiting: true, questions: w.questions };
419
+ });
420
+ var ids = {}; waits.forEach(function (w) { if (w.toolUseId) ids[w.toolUseId] = 1; });
421
+ var settled = Q.atMac.filter(function (i) {
422
+ if (String(i.sessionId || '').toLowerCase() !== want) return false;
423
+ if (i.toolUseId) return !ids[i.toolUseId];
424
+ // An older server gives a settled item no tool_use_id: show it only when no waiting entry speaks for the session.
425
+ return !waits.length;
426
+ });
427
+ return settled.concat(cards);
428
+ }
429
+ function waitingFor(sessionId) {
430
+ var want = String(sessionId || '').toLowerCase();
431
+ return Q.waiting.filter(function (w) { return w.sessionId === want; });
355
432
  }
356
433
  // True when this session's newest event here is an answer (from this page or any other screen) of the last two
357
434
  // minutes, with nothing held for it now and nothing at the Mac since.
@@ -361,6 +438,12 @@
361
438
  var at = 0, macAt = 0;
362
439
  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); });
363
440
  atMacFor(want).forEach(function (i) { macAt = Math.max(macAt, Date.parse(i.settledAt) || Date.parse(i.createdAt) || 0); });
441
+ // X1 contract: answered anywhere (the Mac's prompt or a COS surface) in the last 120 s, newer than any question still open.
442
+ Q.waiting.forEach(function (w) {
443
+ if (w.sessionId !== want) return;
444
+ if (w.state === 'answered' && Date.now() - w.answeredAt <= ANSWERED_MS) at = Math.max(at, w.answeredAt);
445
+ else if (w.state !== 'answered') macAt = Math.max(macAt, w.askedAt || Date.now());
446
+ });
364
447
  return at > 0 && at >= macAt;
365
448
  }
366
449
  function wordsOf(id) { var w = Q.seen[id]; return w ? { questions: w.questions.slice(), summary: w.summary } : null; }
@@ -439,10 +522,32 @@
439
522
 
440
523
  // ── answering ─────────────────────────────────────────
441
524
  var REFUSED = {
442
- already_answered: 'Already answered, on the glasses or the Mac.', expired: 'Too late: the Mac took this back.',
443
- handed_to_desk: 'The Mac has this one now. Answer it there.', hook_gone: 'The session stopped waiting for this answer.',
444
- not_found: 'This question is gone.', invalid_answer: 'That answer was not accepted. Check each question has a choice.'
525
+ // Q1 (D4 card review M2, same server): already_answered is another COS device (a lens, the phone, Control or a browser);
526
+ // an answer at the Mac comes back as handed_to_desk with reason retracted (RETRACTED_LINE).
527
+ already_answered: 'Already answered on another device.', expired: 'This question timed out here. Answer it at the Mac.',
528
+ handed_to_desk: 'Left for the Mac. Answer it there.', hook_gone: 'This question is no longer waiting.',
529
+ not_found: 'This question is gone.', invalid_answer: 'That answer was not accepted. Check each question has a choice.',
530
+ // X1 contract, web door with a full-hold flag on: none of these settle the card; the person taps again.
531
+ web_touch_required: 'Not sent: tap anywhere on this page, then answer again.',
532
+ // X1 final contract (Q1 O1): held, but not on your devices yet (the Mac has not been idle long enough). Not settled.
533
+ not_shown: 'Not sent: this question is not on your devices yet. Answer it at the Mac, or try again in a minute.',
534
+ confirm_required: 'Not sent: tap again to confirm.', confirm_invalid: 'Not sent: that confirmation ran out. Tap again to confirm.'
445
535
  };
536
+ var RETRACTED_LINE = 'Already answered at the Mac, or the session moved on.';
537
+ // The page's api() reads a body's `reason` before its `error` (other panes rely on that), so a refusal such as
538
+ // {error: 'handed_to_desk', reason: 'retracted'} or {error: 'invalid_answer', reason: 'client_answer_id'} arrives with the
539
+ // reason as its code and the error word as its message. Put the server's error word back as the code, the reason beside it.
540
+ function refusal(err) {
541
+ if (!err || err.status == null) return err;
542
+ var word = typeof err.message === 'string' ? err.message : '';
543
+ if (!REFUSED[err.code] && REFUSED[word]) { err.reason = err.code; err.code = word; }
544
+ return err;
545
+ }
546
+ function refusedLine(err) {
547
+ if (err && err.code === 'handed_to_desk' && err.reason === 'retracted') return RETRACTED_LINE;
548
+ return err ? REFUSED[err.code] : '';
549
+ }
550
+ var KEEPS_CARD = { invalid_answer: 1, web_touch_required: 1, confirm_required: 1, confirm_invalid: 1, not_shown: 1 };
446
551
  // QA round 1: an answer that never reached the server (no HTTP status: offline, a dropped link, a timeout) says so in
447
552
  // plain words, with the button to tap; the retry goes under the same clientAnswerId, so the server replays it.
448
553
  function unreachable(item, what) {
@@ -458,10 +563,37 @@
458
563
  changed();
459
564
  if (W.today && W.today.refresh) { try { W.today.refresh(); } catch (_e) {} }
460
565
  }
566
+ // X1 contract: the web door sends how long ago this page was last touched (the tap that sent it counts). An older server
567
+ // or a flags-off server ignores the field.
568
+ function withTouch(body) {
569
+ var age = presenceAge();
570
+ if (age !== null) body.presenceAgeMs = age;
571
+ return body;
572
+ }
573
+ function confirmThen(id, body, doneLine) {
574
+ Q.sending[id] = 1; render();
575
+ var c = withTouch({ decision: body.decision });
576
+ api('/api/session-questions/' + encodeURIComponent(id) + '/confirm', { method: 'POST', body: c, timeoutMs: 20000 }).then(function (d) {
577
+ if (!d || typeof d.confirmId !== 'string' || !/^pc_[a-f0-9]{24}$/.test(d.confirmId)) throw Object.assign(new Error('confirm'), { status: 403, code: 'confirm_invalid' });
578
+ body.confirmId = d.confirmId;
579
+ post(id, body, doneLine);
580
+ }, function (err) {
581
+ delete Q.sending[id];
582
+ err = refusal(err);
583
+ var settles = err && REFUSED[err.code] && !KEEPS_CARD[err.code];
584
+ if (settles) { Q.items = Q.items.filter(function (i) { return i.id !== id; }); render(); changed(); W.toast(refusedLine(err)); schedule(500); return; }
585
+ Q.notes[id] = refusedLine(err) || (err && err.status == null ? unreachable(itemOf(id), body.decision) : 'Not sent: ' + ((err && err.message) || 'the Mac did not answer') + ' Tap again to retry.');
586
+ render();
587
+ });
588
+ }
461
589
  function post(id, body, doneLine) {
590
+ // X1 final contract: when the server says approvals need a confirm, get it first rather than learn it from a refused
591
+ // answer. It still follows the person's second tap; a 403 confirm_required below stays the fallback.
592
+ if (body.decision && !body.confirmId && Q.webAnswer && Q.webAnswer.confirmApprovals) return confirmThen(id, body, doneLine);
462
593
  Q.sending[id] = 1; Q.armed[id] = ''; Q.notes[id] = '';
463
594
  if (!Q.answerIds[id]) Q.answerIds[id] = answerId();
464
595
  body.clientAnswerId = Q.answerIds[id];
596
+ withTouch(body);
465
597
  render();
466
598
  var asked = itemOf(id);
467
599
  api('/api/session-questions/' + encodeURIComponent(id) + '/answer', { method: 'POST', body: body, timeoutMs: 20000 }).then(function () {
@@ -473,16 +605,22 @@
473
605
  schedule(500);
474
606
  }, function (err) {
475
607
  delete Q.sending[id];
476
- var settled = err && REFUSED[err.code] && err.code !== 'invalid_answer';
608
+ err = refusal(err);
609
+ // X1 contract: an approval the server wants confirmed gets its confirm (bound to this item and decision, 60 s, single
610
+ // use) and goes once more. The person already tapped twice; nothing is sent without a second tap.
611
+ if (err && err.status === 403 && err.code === 'confirm_required' && body.decision && !body.confirmId) {
612
+ return confirmThen(id, body, doneLine);
613
+ }
614
+ var settled = err && REFUSED[err.code] && !KEEPS_CARD[err.code];
477
615
  if (settled) {
478
616
  if (err.code === 'already_answered') noteAnswered(asked, id);
479
617
  Q.items = Q.items.filter(function (i) { return i.id !== id; });
480
618
  render();
481
619
  if (err.code === 'already_answered') answeredTold(); else changed();
482
- W.toast(REFUSED[err.code]); schedule(500); return;
620
+ W.toast(refusedLine(err)); schedule(500); return;
483
621
  }
484
622
  // A timeout or a lost answer: the same clientAnswerId goes again on the next tap and the server replays it.
485
- Q.notes[id] = REFUSED[err && err.code] || (err && err.status == null ? unreachable(asked, body.decision || 'send')
623
+ Q.notes[id] = refusedLine(err) || (err && err.status == null ? unreachable(asked, body.decision || 'send')
486
624
  : 'Not sent: ' + ((err && err.message) || 'the Mac did not answer') + ' Tap again to retry.');
487
625
  render();
488
626
  });
@@ -547,11 +685,13 @@
547
685
  Q.armed[id] = '';
548
686
  if (!Q.answerIds[id]) Q.answerIds[id] = answerId();
549
687
  Q.sending[id] = 1; render();
550
- api('/api/session-questions/' + encodeURIComponent(id) + '/answer', { method: 'POST', body: { clientAnswerId: Q.answerIds[id], handBack: true }, timeoutMs: 20000 }).then(function () {
688
+ api('/api/session-questions/' + encodeURIComponent(id) + '/answer', { method: 'POST', body: withTouch({ clientAnswerId: Q.answerIds[id], handBack: true }), timeoutMs: 20000 }).then(function () {
551
689
  delete Q.sending[id]; Q.items = Q.items.filter(function (i) { return i.id !== id; }); render(); W.toast('Left for the Mac.'); schedule(500);
552
690
  }, function (err) {
553
691
  delete Q.sending[id];
554
- 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; }
692
+ err = refusal(err);
693
+ if (err && REFUSED[err.code] && !KEEPS_CARD[err.code]) { Q.items = Q.items.filter(function (i) { return i.id !== id; }); render(); changed(); W.toast(refusedLine(err)); return; }
694
+ if (err && KEEPS_CARD[err.code]) { Q.notes[id] = refusedLine(err); render(); return; }
555
695
  Q.notes[id] = err && err.status == null ? unreachable(item, 'leave') : 'Not sent: ' + ((err && err.message) || 'the Mac did not answer'); render();
556
696
  });
557
697
  return;
@@ -707,5 +847,6 @@
707
847
  if (W.onRoute) W.onRoute(function (r) { if (r && r.pane === 'today') nudge(); });
708
848
 
709
849
  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,
710
- itemsFor: itemsFor, atMacFor: atMacFor, answeredFor: answeredFor, wordsOf: wordsOf, macLine: MAC_LINE, answeredLine: ANSWERED_LINE };
850
+ itemsFor: itemsFor, atMacFor: atMacFor, waitingFor: waitingFor, answeredFor: answeredFor, wordsOf: wordsOf, fullHoldLine: FULL_HOLD_LINE, macLine: MAC_LINE, macNoWords: MAC_NO_WORDS, answeredLine: ANSWERED_LINE,
851
+ loadState: function () { return Q.load; }, retry: function () { nudge(); } };
711
852
  })();