@gotcos/glasses-server 7.0.7 → 7.0.27

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 (97) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +305 -0
  3. package/docs/web-key-verifier-contract.md +378 -0
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -3
  6. package/server/bootstrap.ts +10 -0
  7. package/server/index.ts +129 -23
  8. package/server/lib/agent-session-older.ts +218 -0
  9. package/server/lib/agent-session-recent.ts +321 -0
  10. package/server/lib/agent-session-store.ts +102 -20
  11. package/server/lib/api-auth.ts +37 -1
  12. package/server/lib/calendar-meetings.ts +70 -0
  13. package/server/lib/calendar-source.ts +54 -9
  14. package/server/lib/claude-hooks-installer.ts +111 -12
  15. package/server/lib/conversation.ts +56 -28
  16. package/server/lib/cors-reject-log.ts +51 -0
  17. package/server/lib/cos-started-sessions.ts +101 -0
  18. package/server/lib/disk-io-meter.ts +202 -0
  19. package/server/lib/drop-service.ts +47 -12
  20. package/server/lib/drop-store.ts +21 -1
  21. package/server/lib/event-loop-stall.ts +62 -0
  22. package/server/lib/full-hold-engine.ts +114 -0
  23. package/server/lib/full-hold.ts +124 -0
  24. package/server/lib/g2-turn-provenance.ts +128 -9
  25. package/server/lib/list-model-memo.ts +85 -0
  26. package/server/lib/listener-startup.ts +18 -0
  27. package/server/lib/meeting-batch-transcribe.ts +32 -5
  28. package/server/lib/model-efforts.ts +40 -0
  29. package/server/lib/permission-broker.ts +443 -11
  30. package/server/lib/secret-path-guard.ts +4 -1
  31. package/server/lib/session-cache-writer.ts +8 -1
  32. package/server/lib/session-hook-events.ts +23 -1
  33. package/server/lib/session-hooks-runtime.ts +34 -0
  34. package/server/lib/session-log.ts +313 -4
  35. package/server/lib/session-organizer.ts +305 -0
  36. package/server/lib/session-state-derive.ts +36 -0
  37. package/server/lib/session-stream-events.ts +39 -3
  38. package/server/lib/thread-turn-queue.ts +24 -0
  39. package/server/lib/today-brief.ts +522 -58
  40. package/server/lib/turn-end-ask.ts +320 -0
  41. package/server/lib/turn-footer.ts +3 -3
  42. package/server/lib/web-control-integrity.ts +160 -0
  43. package/server/lib/web-control-offnet-page.ts +58 -0
  44. package/server/lib/web-control-static.ts +53 -9
  45. package/server/lib/web-key-authority.ts +372 -0
  46. package/server/lib/web-key-envelope.ts +98 -0
  47. package/server/lib/web-key-handoff.ts +135 -0
  48. package/server/lib/web-key-origin.ts +21 -0
  49. package/server/lib/web-key-passkey.ts +155 -0
  50. package/server/lib/web-key-registry.ts +825 -0
  51. package/server/lib/web-key-signer-app.ts +152 -0
  52. package/server/lib/web-session-auth.ts +133 -15
  53. package/server/lib/whisper-local.ts +93 -7
  54. package/server/lib/whisper-preview.ts +5 -3
  55. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  56. package/server/lib/work-review-runtime.ts +17 -4
  57. package/server/routes/agent-session-bindings.ts +16 -1
  58. package/server/routes/agent-session-stream.ts +7 -0
  59. package/server/routes/agent-sessions.ts +106 -2
  60. package/server/routes/drops.ts +7 -2
  61. package/server/routes/g2-turns.ts +35 -0
  62. package/server/routes/health.ts +14 -1
  63. package/server/routes/live-events.ts +6 -1
  64. package/server/routes/permission-broker.ts +26 -0
  65. package/server/routes/query-jobs.ts +3 -0
  66. package/server/routes/session-organizer.ts +149 -0
  67. package/server/routes/sessions.ts +15 -24
  68. package/server/routes/thread-turn-queue.ts +50 -14
  69. package/server/routes/today-brief.ts +91 -36
  70. package/server/routes/web-control.ts +37 -4
  71. package/server/routes/web-key.ts +284 -0
  72. package/server/routes/welcome-context.ts +6 -2
  73. package/server/routes/work-orchestrator.ts +12 -7
  74. package/server/scripts/write-web-control-manifest.mjs +43 -0
  75. package/server/web-control/.manifest.json +44 -0
  76. package/server/web-control/app.css +45 -0
  77. package/server/web-control/app.js +1680 -108
  78. package/server/web-control/apple-touch-icon.png +0 -0
  79. package/server/web-control/index.html +54 -17
  80. package/server/web-control/panes/cos-chat.css +52 -6
  81. package/server/web-control/panes/cos-chat.js +364 -32
  82. package/server/web-control/panes/questions.js +135 -14
  83. package/server/web-control/panes/sessions.css +280 -0
  84. package/server/web-control/panes/settings.js +1 -1
  85. package/server/web-control/panes/today-actions.css +35 -0
  86. package/server/web-control/panes/today-actions.js +121 -6
  87. package/server/web-control/panes/work.css +3 -0
  88. package/server/web-control/panes/work.js +185 -38
  89. package/server/web-control/theme.js +33 -0
  90. package/server/web-signer/.manifest.json +11 -0
  91. package/server/web-signer/index.html +74 -0
  92. package/server/web-signer/signer-proof.js +348 -0
  93. package/server/web-signer/signer-store.js +39 -0
  94. package/server/web-signer/signer.css +23 -0
  95. package/server/web-signer/signer.js +341 -0
  96. package/shared/web-approval-packets.golden.json +124 -0
  97. package/shared/web-turn-envelope.ts +271 -0
@@ -267,6 +267,7 @@
267
267
  if (window.requestAnimationFrame) window.requestAnimationFrame(go); else go();
268
268
  }
269
269
  function fit() {
270
+ if (O.open) panCheck();
270
271
  if (KB.frame || !O.open) return;
271
272
  if (!window.requestAnimationFrame) return applyFit(); // every browser with visualViewport has frames
272
273
  KB.frame = window.requestAnimationFrame(applyFit);
@@ -321,6 +322,49 @@
321
322
  // opt.from: a session opened from the pet's list ({ scroll }), which gets Back to sessions and its own history entry,
322
323
  // so the phone's Back returns to the list too (Miles's iPhone, 11/07). opt.again: the list shown again from there,
323
324
  // its entry already current.
325
+ // Miles 11/10 20:16 (IMG_0570): with the panel open on his Duo, the panel and the page under it moved together. Two
326
+ // stories fit: the page scrolled behind the panel, or the visible area panned past the layout viewport (the page
327
+ // drawn at 0.85). These numbers tell them apart, so nothing is fixed on a guess (lead 20:26): while the panel is open,
328
+ // ONE layout report per open when window.scrollY, visualViewport.pageTop or visualViewport.offsetTop first changes,
329
+ // with the values at open and now, the panel's top and bottom, and whether a keyboard was up. layoutCrumb adds
330
+ // innerHeight (ih), the visible height (vvh) and scale (vvs) itself. Numbers only, inside layoutCrumb's caps.
331
+ var PAN = { at: null, sent: false, fn: null };
332
+ function panNum(v) { return typeof v === 'number' && isFinite(v) ? Math.round(v) : null; }
333
+ function panOffsets() {
334
+ var vv = window.visualViewport;
335
+ return { sy: panNum(window.scrollY), pt: vv ? panNum(vv.pageTop) : null, ot: vv ? panNum(vv.offsetTop) : null,
336
+ sx: panNum(window.scrollX), pl: vv ? panNum(vv.pageLeft) : null, ol: vv ? panNum(vv.offsetLeft) : null };
337
+ }
338
+ function panState() {
339
+ var r = layer && typeof layer.getBoundingClientRect === 'function' ? layer.getBoundingClientRect() : null, o = panOffsets();
340
+ var rail = document.querySelector ? document.querySelector('.rail') : null, rr = rail && rail.getBoundingClientRect ? rail.getBoundingClientRect() : null;
341
+ o.pTop = r ? panNum(r.top) : null; o.pBot = r ? panNum(r.bottom) : null; o.pL = r ? panNum(r.left) : null; o.pR = r ? panNum(r.right) : null;
342
+ o.railL = rr ? panNum(rr.left) : null; o.railR = rr ? panNum(rr.right) : null;
343
+ return o;
344
+ }
345
+ // Runs on the window's scroll and, through fit, on every visualViewport resize and scroll while the panel is open.
346
+ // The panel's box is read only once something has moved, and only once per open.
347
+ function panCheck() {
348
+ // PAN.at is set only between open and close (close stops the watch before it clears O.open).
349
+ if (PAN.sent || !PAN.at) return;
350
+ var moved = panOffsets(), was = PAN.at;
351
+ // Up and down (sy, pt, ot) and, like split-drag, sideways (sx, pl, ol: Q1 20:44, lead 20:4x).
352
+ if (moved.sy === was.sy && moved.pt === was.pt && moved.ot === was.ot && moved.sx === was.sx && moved.pl === was.pl && moved.ol === was.ol) return;
353
+ PAN.sent = true;
354
+ var now = panState();
355
+ if (W.layoutCrumb) W.layoutCrumb('ta-pan', { kind: O.open.kind, kb: !!KB.on, sy0: was.sy, pt0: was.pt, ot0: was.ot, sy: now.sy, pt: now.pt, ot: now.ot,
356
+ sx0: was.sx, pl0: was.pl, ol0: was.ol, sx: now.sx, pl: now.pl, ol: now.ol,
357
+ pTop: now.pTop, pBot: now.pBot, pL: now.pL, pR: now.pR, railL: now.railL, railR: now.railR });
358
+ }
359
+ function panWatch(on) {
360
+ if (on) {
361
+ PAN.at = panOffsets(); PAN.sent = false; PAN.fn = panCheck;
362
+ window.addEventListener('scroll', panCheck, { passive: true });
363
+ } else if (PAN.fn) {
364
+ if (window.removeEventListener) window.removeEventListener('scroll', panCheck, { passive: true });
365
+ PAN.fn = null; PAN.at = null;
366
+ }
367
+ }
324
368
  function open(kind, key, opener, opt) {
325
369
  opt = opt || {};
326
370
  build();
@@ -334,6 +378,7 @@
334
378
  document.documentElement.classList.add('ta-open');
335
379
  var shell = $('app-shell'); if (shell) shell.setAttribute('inert', '');
336
380
  listenViewport(true); applyFit();
381
+ panWatch(true);
337
382
  // The entry names what is open, so a reload can open it again and Back still closes it (restore below).
338
383
  var entry = { cosTodayOverlay: 1, kind: kind, key: O.open.key };
339
384
  if (O.back) entry.from = 'pet';
@@ -390,6 +435,7 @@
390
435
  function close(how) {
391
436
  if (!O.open) return;
392
437
  stopSession();
438
+ panWatch(false);
393
439
  O.open = null;
394
440
  if (layer) layer.hidden = true;
395
441
  unfit();
@@ -537,6 +583,8 @@
537
583
  return open(pk === 'wait' ? 'wait' : 'run', id, O.opener, { from: { scroll: lb ? lb.scrollTop || 0 : 0 } });
538
584
  }
539
585
  if (what === 'send') return sendReply();
586
+ if (what === 'turnend') return sendChoice(Number(id));
587
+ if (what === 'approve-phone' && K.approvePhone && O.r) return K.approvePhone(phoneApprovalOptions(O.r));
540
588
  if (what === 'att-remove' || what === 'att-retry') {
541
589
  var fb = filesBox(); if (!fb) return;
542
590
  O.note = { text: '', kind: '' }; paintNote();
@@ -772,7 +820,7 @@
772
820
  }, function (err) {
773
821
  row.undoing = false;
774
822
  if (err && err.status === 404) O.loop = false;
775
- row.undoNote = err && err.status === 404 ? 'Undo needs a newer COS server. Change it in Work.' : (err && err.message) || 'Undo did not go through.';
823
+ row.undoNote = err && err.status === 404 ? 'Undo did not go through. Change it in Work.' : (err && err.message) || 'Undo did not go through.';
776
824
  if (row.state === 'dismissed') { row.until = Date.now() + UNDO_SECONDS * 1000; expireLater(); }
777
825
  }).then(paintIntake);
778
826
  }
@@ -829,7 +877,8 @@
829
877
  paintFiles();
830
878
  // The Mac's answer about files may not be in yet: ask once, then draw the clip.
831
879
  var kit = KIT(), r0 = O.r;
832
- if (kit && !kit.turnFiles()) kit.ensureCaps().then(function () { if (O.r === r0) paintFiles(); }, function () {});
880
+ paintPhoneOffer(r0);
881
+ if (kit && !kit.turnFiles()) kit.ensureCaps().then(function () { if (O.r === r0) { paintFiles(); paintPhoneOffer(r0); } }, function () {});
833
882
  // Files of a send a reload cut off go again with the same words; say so, since their chips are gone.
834
883
  if (O.r.draft && O.r.draft.restored) {
835
884
  var nf = O.r.draft.ids.length;
@@ -911,6 +960,14 @@
911
960
  '<p class="status-line" id="ta-status" role="status" aria-live="polite"></p>';
912
961
  }
913
962
 
963
+ // The same exact packet picker and handoff as Sessions. This does not upgrade an ordinary Send.
964
+ function phoneApprovalOptions(r) {
965
+ return { r: r, prefix: 'ta', live: function () { return O.r === r && !!O.open; },
966
+ status: function (text, kind) { if (O.r === r) { O.note = { text: text, kind: kind || '' }; paintNote(); } },
967
+ files: function () { return filesState(filesBox(r)); } };
968
+ }
969
+ function paintPhoneOffer(r) { if (K.phoneOffer) K.phoneOffer(r, phoneApprovalOptions(r)); }
970
+
914
971
  // ── files with a reply or a note (the COS chat's attach kit) ──
915
972
  // One box per session, kept while this tab is open, so closing and reopening the sheet keeps what was attached.
916
973
  var FILES = {};
@@ -1020,12 +1077,14 @@
1020
1077
  // A session waiting on you: what it waits on, its question to answer here when the Mac holds one for this page;
1021
1078
  // when the Mac's own prompt has it, that, read-only; else (a reply is the answer) the reply box below. No live
1022
1079
  // peek and no Stop: it is not at work (W4-r2).
1023
- html += '<p class="ta-sub">' + T.badge(O.r ? O.r.provider : 'claude') + ' <span>' + esc(O.wait ? O.wait.why : 'Waiting for you') + '</span></p>';
1024
1080
  var where = waitWhere();
1081
+ html += '<p class="ta-sub">' + T.badge(O.r ? O.r.provider : 'claude') + ' <span>' + esc(waitWhy(where)) + '</span></p>';
1025
1082
  // W13 (QA week of 11/01-11/08): the words Today's row showed for it (its `ask`) stay here, read-only, when no card
1026
1083
  // draws them: the Mac line and a failed read alike. Never twice: not when the line above already says them.
1027
1084
  var ask = waitAsk();
1028
1085
  if (ask && (where === 'mac' || where === 'failed') && !O.macCount) html += '<blockquote class="ta-quote ta-mac-ask">' + esc(ask) + '</blockquote>';
1086
+ // W1 A5: no words anywhere (a question that went straight to the Mac): say that, rather than a card with nothing in it.
1087
+ if (!ask && where === 'mac' && !O.macCount && !askInWhy()) html += '<p class="ta-sub ta-mac-nowords">' + esc(macNoWords()) + '</p>';
1029
1088
  // The Mac card says it itself when there is one; say it here only when there is none.
1030
1089
  if (where === 'mac' && !O.macCount) html += '<p class="ta-sub ta-mac-line">' + esc(macLine()) + '</p>';
1031
1090
  // W13: the questions could not be read, so whether one waits here is unknown. Say so, never "at the Mac", and read again.
@@ -1033,6 +1092,7 @@
1033
1092
  ' <button type="button" class="link-btn" data-ta="q-retry">Retry</button></p>';
1034
1093
  if (where === 'answered') html += '<p class="ta-sub ta-mac-line">' + esc((W.sessionQuestions && W.sessionQuestions.answeredLine) || 'Answered. The session carries on.') + '</p>';
1035
1094
  if (where === 'reply') html += '<p class="ta-sub">' + esc('Answer it in a reply below, or open the session.') + '</p>';
1095
+ if (where === 'turnend') html += turnEndHtml();
1036
1096
  } else if (O.row || O.r) {
1037
1097
  // Once the live feed speaks, the line says what it says, so the header and the peek never disagree (qa2-user N3).
1038
1098
  html += '<p class="ta-sub">' + T.badge(O.r ? O.r.provider : O.row.provider) + ' <span id="ta-run-state">' + esc(runWords()) + '</span></p>';
@@ -1047,7 +1107,7 @@
1047
1107
  box.innerHTML = html;
1048
1108
  // The reply box shows only where a reply can be the answer; what was typed stays in it, hidden.
1049
1109
  var foot = $('ta-foot');
1050
- if (foot && O.open.kind === 'wait') { var w = waitWhere(); foot.hidden = w === 'failed' ? !!(O.r && O.r.provider === 'claude') : w !== 'reply'; }
1110
+ if (foot && O.open.kind === 'wait') { var w = waitWhere(); foot.hidden = w === 'failed' ? !!(O.r && O.r.provider === 'claude') : w !== 'reply' && w !== 'turnend'; }
1051
1111
  }
1052
1112
  // Where a waiting session's question is answered: 'here' (its cards in this sheet), 'answered' (on some screen just
1053
1113
  // now), 'mac' (a Claude session's own prompt, or a held question that went back to the Mac), or 'reply' (a Codex or
@@ -1056,6 +1116,8 @@
1056
1116
  // reply box stays a Codex or Cursor one.
1057
1117
  function waitWhere() {
1058
1118
  if (O.qCount) return 'here';
1119
+ // 7.0.18: a turn that ended asking him is answered by a reply (a choice or his own words), so the reply box shows.
1120
+ if (O.wait && O.wait.where === 'turnend') return 'turnend';
1059
1121
  if (O.r && W.sessionQuestions && W.sessionQuestions.answeredFor && W.sessionQuestions.answeredFor(O.r.id)) return 'answered';
1060
1122
  if (!O.macCount && W.sessionQuestions && W.sessionQuestions.loadState && W.sessionQuestions.loadState() === 'failed') return 'failed';
1061
1123
  if (O.macCount || (O.r && O.r.provider === 'claude') || (O.wait && O.wait.where === 'mac')) return 'mac';
@@ -1066,7 +1128,57 @@
1066
1128
  var a = O.wait && O.wait.ask ? String(O.wait.ask).trim() : '';
1067
1129
  return a && !(O.wait.why && String(O.wait.why).trim() === a) ? a : '';
1068
1130
  }
1069
- function macLine() { return (W.sessionQuestions && W.sessionQuestions.macLine) || "Waiting on your Mac's prompt. Answer it on the Mac (or through Screens)."; }
1131
+ // 7.0.18: a turn that ended asking him (X1 contract v1). The question as the row has it, a button per choice, and the
1132
+ // required honest line. A tap sends the contract's exact text through the reader's Continue (K.send); his own words
1133
+ // in the box below are sent the same way.
1134
+ 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.';
1135
+ var TURN_END_PREFIX = 'Answer sent from COS Control Web through COS, not typed in this session: ';
1136
+ function turnEndAnswer(text) { var t = String(text || '').trim(); return TURN_END_PREFIX + (t.indexOf(TURN_END_PREFIX) === 0 ? t.slice(TURN_END_PREFIX.length).trim() : t); }
1137
+ function turnEndHtml() {
1138
+ var w = O.wait || {}, choices = Array.isArray(w.choices) ? w.choices : [], busy = !!(O.r && O.r.sending);
1139
+ return (w.ask ? '<blockquote class="ta-quote ta-te-ask">' + esc(w.ask) + '</blockquote>' : '') +
1140
+ (choices.length ? '<div class="ta-te-choices">' + choices.map(function (c, i) {
1141
+ return '<button type="button" class="cx-btn' + (i === 0 ? ' primary' : '') + '" data-ta="turnend" data-id="' + i + '"' + (busy ? ' disabled' : '') + '>' + esc(c) + '</button>';
1142
+ }).join('') + '</div>' : '') +
1143
+ '<p class="ta-sub ta-te-line">' + esc(TURN_END_LINE) + '</p>';
1144
+ }
1145
+ /** Still waiting on this answer? The latest session list decides; a reply from anywhere clears it on the server. */
1146
+ function turnEndStill() {
1147
+ var nm = T.needs ? T.needs() : null, key = O.wait && O.wait.key;
1148
+ return !!(nm && key && nm.rows.some(function (x) { return x.kind === 'session' && x.key === key && x.where === 'turnend'; }));
1149
+ }
1150
+ function sendChoice(i) {
1151
+ var r = O.r, w = O.wait; if (!r || !w || r.sending) return;
1152
+ var label = (Array.isArray(w.choices) ? w.choices : [])[i];
1153
+ if (!label) return;
1154
+ if (!turnEndStill()) { O.note = { text: 'Already answered on another screen, or the session moved on. Nothing was sent.', kind: '' }; paintNote(); return; }
1155
+ r.sending = true; setSending(true); paintMeta();
1156
+ var done = function () { r.sending = false; if (O.r === r) { setSending(false); paintMeta(); } };
1157
+ return K.send(r, turnEndAnswer(label), {}).then(function (how) {
1158
+ done();
1159
+ if (O.r !== r) return;
1160
+ if (how === 'sent') O.note = { text: 'Sent \u201c' + label + '\u201d. It reaches the session as a message from COS; it may ask you to confirm.', kind: '' };
1161
+ else if (how === 'changed') { r.refusal = null; r.sendAnyway = null; paintVerdict(); O.note = { text: 'The session moved on before this reached it. \u201c' + label + '\u201d was not sent.', kind: 'error' }; }
1162
+ else if (how === 'refused') O.note = { text: 'The session did not take \u201c' + label + '\u201d. Try again, or answer it on your Mac.', kind: 'error' };
1163
+ paintNote();
1164
+ }, function (err) {
1165
+ done();
1166
+ if (O.r !== r) return;
1167
+ O.note = { text: 'Not sent: ' + (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.'), kind: 'error' };
1168
+ paintNote();
1169
+ });
1170
+ }
1171
+ function macLine() { return (W.sessionQuestions && W.sessionQuestions.macLine) || "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."; }
1172
+ function macNoWords() { return (W.sessionQuestions && W.sessionQuestions.macNoWords) || "COS cannot read this question's words from here. They show at the Mac's prompt."; }
1173
+ // W1 A5: the head line says the state once. At the Mac's prompt the line below says where; the head does not repeat it.
1174
+ var GENERIC_WAIT = /^Waiting for (you|your answer)(\. Paused until you answer\.)?$/;
1175
+ function waitWhy(where) {
1176
+ var why = O.wait ? String(O.wait.why || '') : '';
1177
+ if (!why) return 'Waiting for you';
1178
+ return where === 'mac' && GENERIC_WAIT.test(why) ? 'Waiting for your answer' : why;
1179
+ }
1180
+ // The head line already carries the question's words (the server named them in the waiting line).
1181
+ function askInWhy() { return !!(O.wait && O.wait.why && !GENERIC_WAIT.test(String(O.wait.why))); }
1070
1182
 
1071
1183
  // ═════════════ The pet's sessions ═════════════
1072
1184
  // The same sessions the pet's caption counts (app.js petPlay): waiting on you, running, then done today. Each row
@@ -1266,7 +1378,8 @@
1266
1378
  var fb = sent.length ? filesBox(r) : null, kit = KIT();
1267
1379
  if (fb && kit) fb.atts.filter(function (t) { return sent.indexOf(t.mediaId) >= 0; }).forEach(function (t) { kit.remove(fb, t.id); });
1268
1380
  var box = r === O.r ? $('ta-input') : null;
1269
- var left = box ? (box.value.trim() === String(text).trim() ? '' : box.value) : '';
1381
+ // 7.0.18: his answer to a turn-end ask went with the attribution in front; the box holds his words without it.
1382
+ var left = box ? (box.value.trim() === String(text).trim() || (box.value.trim() && turnEndAnswer(box.value) === String(text).trim()) ? '' : box.value) : '';
1270
1383
  if (box && !left) { box.value = ''; W.autoGrow(box); }
1271
1384
  keepDraft(r, left, null);
1272
1385
  },
@@ -1342,6 +1455,8 @@
1342
1455
  if (r.refusal || O.cautionAsk) { r.refusal = null; O.cautionAsk = false; paintVerdict(); }
1343
1456
  r.sending = true; setSending(true);
1344
1457
  var done = function () { r.sending = false; if (O.r === r) setSending(false); };
1458
+ // 7.0.18: his own words in answer to a turn-end ask carry the same attribution as a choice ('Other' in the contract).
1459
+ if (O.open && O.open.kind === 'wait' && waitWhere() === 'turnend' && turnEndStill()) text = turnEndAnswer(text);
1345
1460
  var sending = K.send(r, text, opts || {});
1346
1461
  // continueSession picks the key at once (draftFor): keep it until the Mac takes the words.
1347
1462
  if (r.draft) keepDraft(r, text, r.draft);
@@ -323,3 +323,6 @@
323
323
  .wk-detail.split-side > .split-handle { display: flex; }
324
324
  }
325
325
  @media (min-width: 1300px) { .wk-detail.split-side { grid-template-columns: minmax(0, 1fr) 22px 452px; } }
326
+
327
+ /* W1 (0.5.386 parity): the empty card's one-tap New session button wraps on a folded phone instead of running off the card. */
328
+ .wk-start-new { white-space: normal; max-width: 100%; height: auto; min-height: 40px; text-align: center; line-height: 1.3; padding-top: 8px; padding-bottom: 8px; }