@gotcos/glasses-server 6.99.20 → 6.99.25

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 (33) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/docs/work-orchestrator.md +20 -0
  3. package/package.json +1 -1
  4. package/server/index.ts +2 -1
  5. package/server/lib/web-control-static.ts +226 -0
  6. package/server/lib/web-memories-bridge.ts +9 -8
  7. package/server/lib/web-pet-choice.ts +225 -0
  8. package/server/lib/web-pet.ts +226 -0
  9. package/server/lib/work-lens-rounds.ts +29 -4
  10. package/server/routes/web-control.ts +110 -7
  11. package/server/routes/work-lens-rounds.ts +19 -5
  12. package/server/web-control/app.css +24 -5
  13. package/server/web-control/app.js +646 -108
  14. package/server/web-control/control.css +1 -1
  15. package/server/web-control/index.html +27 -13
  16. package/server/web-control/memories/memory-workspace.js +1 -1
  17. package/server/web-control/panes/cos-chat.css +4 -0
  18. package/server/web-control/panes/cos-chat.js +26 -4
  19. package/server/web-control/panes/meetings.css +15 -2
  20. package/server/web-control/panes/memories.css +291 -0
  21. package/server/web-control/panes/memories.js +2208 -0
  22. package/server/web-control/panes/questions.js +59 -21
  23. package/server/web-control/panes/settings.js +102 -4
  24. package/server/web-control/panes/today-actions.css +109 -0
  25. package/server/web-control/panes/today-actions.js +972 -0
  26. package/server/web-control/panes/work.css +54 -0
  27. package/server/web-control/panes/work.js +1037 -18
  28. package/server/web-control/shell.js +6 -2
  29. package/server/web-control/theme.js +0 -2
  30. package/server/web-control/memories/memories-app.js +0 -1561
  31. package/server/web-control/memories/memories-theme.css +0 -73
  32. package/server/web-control/memories/memories.html +0 -72
  33. package/server/web-control/memories/memory-stewardship.js +0 -63
@@ -418,7 +418,7 @@
418
418
  // ── data ─────────────────────────────────────────────────
419
419
  function soft(p) { return p.then(function (d) { return d; }, function () { return null; }); }
420
420
  function loadSessions() {
421
- return soft(api('/api/agent-sessions?limit=80&sort=updated')).then(function (d) {
421
+ return soft(W.sessionList ? W.sessionList(15000) : api('/api/agent-sessions?limit=80&sort=updated')).then(function (d) {
422
422
  if (!d || !Array.isArray(d.sessions)) return;
423
423
  var map = {};
424
424
  d.sessions.forEach(function (s) { if (s && s.session_id) map[String(s.session_id).toLowerCase()] = s; });
@@ -954,36 +954,55 @@
954
954
  : '<p class="wk-muted">' + esc(activityTitle(a)) + '</p>') +
955
955
  (['failed', 'refused', 'canceled'].indexOf(a.status) >= 0 ? '<p class="wk-small"><strong>' + esc('Latest handoff: ' + a.status) + '</strong></p>' +
956
956
  (a.error ? '<p class="wk-small wk-clamp3">' + esc(String(a.error).slice(0, 240)) + '</p>' : '') : '') +
957
- '<div class="wk-row wk-wrap">' + (sess ? '<button type="button" class="wk-text-btn" data-wk="session" data-provider="' + esc(sess.provider) + '" data-sid="' + esc(sess.id) + '">' +
957
+ '<div class="wk-row wk-wrap">' + (sess ? '<button type="button" class="wk-text-btn" data-wk="inspect" data-id="' + esc(it.id) + '" data-sid="' + esc(a.sessionId) + '">View progress</button>' +
958
+ '<button type="button" class="wk-text-btn" data-wk="session" data-provider="' + esc(sess.provider) + '" data-sid="' + esc(sess.id) + '">' +
958
959
  esc(sess.provider === 'cursor' ? 'Open Cursor Agents' : 'Open ' + providerName(sess.provider)) + '</button>' +
959
960
  '<button type="button" class="wk-text-btn" data-wk="copy-session" data-sid="' + esc(a.sessionId) + '">Copy session ID</button>' : '') +
960
961
  '<span class="wk-fine">Receipt history</span></div></div>';
961
962
  }).join('');
962
963
  var refs = (t.meetingRefs || []).filter(function (m) { return m && m.filename; });
963
- return '<section class="wk-section"><h2 class="wk-h2">Connections</h2>' +
964
+ return '<section class="wk-section"><div class="wk-row"><h2 class="wk-h2 wk-grow">Connections</h2>' +
965
+ '<button type="button" class="cx-btn quiet" data-wk="show-workspace">Agent workspace</button></div>' +
964
966
  '<div class="wk-row"><strong>Sessions</strong><span class="wk-muted">' + rows.length + ' sessions</span></div>' +
965
967
  '<p class="wk-muted">' + esc(err ? err : !known ? 'Loading sessions…' : 'Each session keeps its own progress. You control the shared task’s stage.') + '</p>' +
966
968
  (known && !rows.length && !err ? '<p class="wk-muted">Copy context into Claude, Codex or Cursor, then link its exact session ID.</p>' : '') + sessions +
967
969
  '<p class="wk-fine">Linking an existing session to this card is done in COS Control on your Mac.</p>' +
968
- '<div class="wk-sub"><strong>Source meetings</strong>' +
969
- (refs.length ? refs.map(meetingLink).join('') : '<p class="wk-muted">No confirmed meeting link. Attach a saved meeting to connect its tasks and sessions.</p>') + '</div></section>';
970
+ '<div class="wk-sub"><div class="wk-row"><strong class="wk-grow">Source meetings</strong>' +
971
+ '<button type="button" class="cx-btn quiet" data-wk="link-meeting" data-id="' + esc(it.id) + '"' + (linkMeetingProblem(t) ? ' disabled title="' + esc(linkMeetingProblem(t)) + '"' : '') + '>' +
972
+ (S.linkMeeting && S.linkMeeting.wid === it.wid ? 'Close' : 'Link a meeting') + '</button></div>' +
973
+ (refs.length ? refs.map(meetingLink).join('') : '<p class="wk-muted">No confirmed meeting link. Attach a saved meeting to connect its tasks and sessions.</p>') +
974
+ (S.linkMeeting && S.linkMeeting.wid === it.wid ? meetingPicker(it) : '') + '</div></section>';
970
975
  }
971
976
  function workspace(it) {
972
- var data = S.activity[it.wid], rows = contributors(it), latest = rows[0];
977
+ var data = S.activity[it.wid], rows = contributors(it), chosen = S.inspect[it.wid];
978
+ // View progress (Control's workspaceSessions): the chosen session's newest handoff; otherwise the card's newest.
979
+ var latest = (chosen && rows.filter(function (a) { return String(a.sessionId || '').toLowerCase() === chosen.toLowerCase(); })[0]) || rows[0];
973
980
  var status = '';
981
+ if (latest && latest.sessionId) {
982
+ status += '<div class="wk-row wk-wrap"><span class="wk-small wk-muted wk-grow">' + esc('Inspecting ' + (str(latest.sessionTitle) || latest.sessionId)) + '</span>' +
983
+ (chosen && latest !== rows[0] ? '<button type="button" class="wk-text-btn" data-wk="inspect-clear" data-id="' + esc(it.id) + '">Latest handoff</button>' : '') + '</div>';
984
+ }
974
985
  if (latest) {
975
986
  var st = cardState(latest), tr = tracking(latest);
976
987
  var line = tr && tr.phase === 'done' ? (tr.byModel ? tr.provider + ' read the session’s reply as done.' : 'The session reported this done.') + ' Check the result, then mark it complete when you agree.'
977
988
  : tr && tr.phase === 'blocked' ? 'The session is blocked on this.' : tr ? 'The session needs you before it can finish this.' : '';
978
- status = '<div class="wk-handoff" data-tone="' + STATE_TONE[st] + '"><p class="wk-state" data-tone="' + STATE_TONE[st] + '">' + esc(STATE_LABEL[st] + ' · ' + providerName(latest.provider) + ' · ' + ageText(latest, st)) + '</p>' +
989
+ status += '<div class="wk-handoff" data-tone="' + STATE_TONE[st] + '"><p class="wk-state" data-tone="' + STATE_TONE[st] + '">' + esc(STATE_LABEL[st] + ' · ' + providerName(latest.provider) + ' · ' + ageText(latest, st)) + '</p>' +
979
990
  '<p><strong>' + esc(str(latest.sessionTitle) || 'Session') + '</strong></p>' + (line ? '<p class="wk-small">' + esc(line) + '</p>' : '') +
980
- (tr && tr.evidence ? '<p class="wk-quote">“' + esc(tr.evidence) + '”</p>' : '') + '</div>';
991
+ (tr && tr.evidence ? '<p class="wk-quote">“' + esc(tr.evidence) + '”</p>' : '') +
992
+ // WorkHandoffView's "Open session": the session this handoff went to, in Sessions (Cursor opens Cursor Agents there).
993
+ (splitSession(latest.sessionId) ? '<div class="wk-row"><button type="button" class="cx-btn quiet" data-wk="session" data-provider="' + esc(splitSession(latest.sessionId).provider) +
994
+ '" data-sid="' + esc(splitSession(latest.sessionId).id) + '">Open session</button></div>' : '') +
995
+ // You agree it is finished: COS never marks work complete by itself (WorkHandoffView "Mark complete").
996
+ (tr && tr.phase === 'done' && !it.completed && canChangeStage(it.task)
997
+ ? (S.pendingComplete === it.wid && S.completeHere ? completeConfirm(it) : '<div class="wk-row"><button type="button" class="cx-btn quiet" data-wk="complete-here" data-id="' + esc(it.id) + '" title="You agree it is finished. COS never marks work complete by itself.">Mark complete</button></div>')
998
+ : '') + '</div>';
981
999
  }
982
- var reply = latest ? replyBox(it, latest) : '';
1000
+ // A reply answers the card's newest handoff only (the request names it), so it shows for that one.
1001
+ var reply = latest && latest === rows[0] ? replyBox(it, latest) : '';
983
1002
  return '<section class="wk-section" id="wk-agent-workspace"><h2 class="wk-h2">Agent workspace</h2>' + status + reply +
984
1003
  (!it.completed ? '<button type="button" class="cx-btn primary" data-wk="start" data-id="' + esc(it.id) + '">Start work</button>' : '') +
985
1004
  (data && data.savedDestination ? '<p class="wk-fine">' + esc('Saved destination: ' + destinationLabel(data.savedDestination)) + '</p>' : '') +
986
- '<p class="wk-fine">Card files and Work chat stay on your Mac. Add or send files from this card in COS Control.</p></section>';
1005
+ '<p class="wk-fine">Card files stay on your Mac. Add or remove them on this card in COS Control.</p></section>';
987
1006
  }
988
1007
  // Answer (needs input or blocked) and Not done yet (a reported done): a reply request that continues the same session
989
1008
  // with a note, naming the handoff it answers. COS Control on the Mac sends it, as it does for the glasses.
@@ -1015,6 +1034,7 @@
1015
1034
  var a = contributors(it)[0], r = S.reply[it.wid];
1016
1035
  if (!a || !r || !r.text.trim() || r.sending) return;
1017
1036
  if (/COS-WORK/i.test(r.text.replace(/[*_`‌‍]/g, ''))) { r.error = 'A note cannot carry a COS-WORK status line.'; return renderDetail(it.id); }
1037
+ r.intent = intent;
1018
1038
  var body = { clientRequestId: uuid(), domain: it.domain, workIdentity: it.id, intent: intent, mode: 'continueSession', sessionId: a.sessionId,
1019
1039
  note: r.text.trim(), replyTo: a.id, destinationSource: 'user',
1020
1040
  expectedTaskRevision: str((S.activity[it.wid] || {}).taskRevision) || str(it.task.taskRevision) };
@@ -1053,16 +1073,22 @@
1053
1073
  function stopHandoffPolls() {
1054
1074
  HANDOFF_TIMERS.forEach(function (t) { clearTimeout(t); }); HANDOFF_TIMERS.clear();
1055
1075
  [S.start, S.reply].forEach(function (all) { Object.keys(all).forEach(function (k) { all[k].timer = null; }); });
1076
+ chatStopTimers();
1056
1077
  }
1057
1078
  // Back on Work: pick up any request that was still open when the pane was left.
1058
1079
  function resumeHandoffPolls() {
1059
1080
  Object.keys(S.start).forEach(function (wid) { var h = S.start[wid]; if (requestOpen(h) && !h.timer && !h.pollStopped) pollHandoff(h, startUpdate(wid)); });
1060
1081
  Object.keys(S.reply).forEach(function (wid) { var h = S.reply[wid]; if (requestOpen(h) && !h.timer && !h.pollStopped) pollHandoff(h, replyUpdate(wid)); });
1082
+ chatResumePolls();
1061
1083
  }
1062
1084
  function replyUpdate(wid) {
1063
1085
  return function () {
1064
1086
  var h = S.reply[wid], it = itemByWid(wid); if (!h || !it) return;
1065
1087
  if (h.request && h.request.state === 'sent') { delete S.activity[wid]; }
1088
+ if (h.request && h.request.state === 'sent' && h.intent === 'notDone' && !h.movedBack) {
1089
+ h.movedBack = true;
1090
+ if ((it.stage === 'built' || it.stage === 'qa') && canChangeStage(it.task)) moveStage(it, 'draft');
1091
+ }
1066
1092
  var rt = W.parseRoute();
1067
1093
  if (rt.pane === 'work' && rt.args.length === 1 && rt.args[0] === it.id) renderDetail(it.id);
1068
1094
  };
@@ -1087,7 +1113,7 @@
1087
1113
  '<div class="wk-row wk-actions"><button type="button" class="cx-btn quiet" data-wk="edit" data-id="' + esc(it.id) + '">Edit task</button>' +
1088
1114
  (!t.checked ? '<button type="button" class="cx-btn quiet" data-wk="start" data-id="' + esc(it.id) + '">Start work</button>' : '') + '</div></div>' +
1089
1115
  '<p class="wk-muted">' + esc(domainName(t.domain) + ' · ' + (t.checked ? 'Completed task' : STAGE_TITLE[it.stage])) + '</p>' +
1090
- (S.pendingComplete === it.wid ? completeConfirm(it) : '<div class="wk-row wk-wrap">' + stageMenu(it, 'wk-stage-lg') +
1116
+ (S.pendingComplete === it.wid && !S.completeHere ? completeConfirm(it) : '<div class="wk-row wk-wrap">' + stageMenu(it, 'wk-stage-lg') +
1091
1117
  '<button type="button" class="cx-btn quiet" data-wk="copy-id" data-id="' + esc(it.id) + '"' + (canExport(t) ? '' : ' disabled') + ' title="Copy this task’s canonical Work ID for another session.">Copy task ID</button>' +
1092
1118
  '<button type="button" class="cx-btn quiet" data-wk="copy-context" data-id="' + esc(it.id) + '"' + (canExport(t) ? '' : ' disabled') + ' title="Copy the task, source references and instructions for linking a session and updating its stage.">Copy context</button>' +
1093
1119
  (S.busy ? '<span class="wk-muted">Saving…</span>' : '') + '</div>') +
@@ -1109,15 +1135,927 @@
1109
1135
  return;
1110
1136
  }
1111
1137
  W.setCrumb(clip(plainTitle(it.title), 60));
1112
- pane.innerHTML = '<div class="cx wk" data-tint="work">' + backBar(backLabel()) + notices() +
1113
- '<div class="wk-detail">' + '<div class="wk-detail-main">' + detailHeader(it) + taskBody(it) + '</div>' +
1114
- '<aside class="wk-detail-side">' + connections(it) + workspace(it) + '</aside></div></div>';
1138
+ if (!S.chatCaps || Date.now() - S.chatCaps.at > (S.chatCaps.ok ? 60000 : 15000)) loadChatCaps();
1139
+ // Work chat's text box is never replaced while this card shows: a repaint writes the parts around it, so a draft,
1140
+ // the caret and the phone's keyboard survive every poll. While the box has focus only the composer itself changes,
1141
+ // so the page never moves under the keyboard; the rest catches up when the box loses focus.
1142
+ var mount = pane.querySelector('.wk-detail'), box = $('wk-chat-text');
1143
+ var parts = { notices: notices(), main: detailHeader(it) + taskBody(it), side: connections(it) + workspace(it) };
1144
+ if (mount && mount.getAttribute('data-wid') === it.wid && (!!box === chatOffered() || chatComposerFocused())) {
1145
+ if (chatComposerFocused()) S.chatDeferred = true;
1146
+ else {
1147
+ S.chatDeferred = false;
1148
+ chatSetPart('wk-part-notices', parts.notices); chatSetPart('wk-part-main', parts.main);
1149
+ chatSetPart('wk-part-chat', chatHistory(it)); chatSetPart('wk-part-side', parts.side);
1150
+ }
1151
+ } else {
1152
+ S.chatDeferred = false;
1153
+ pane.innerHTML = '<div class="cx wk" data-tint="work">' + backBar(backLabel()) + '<div id="wk-part-notices" class="wk-part">' + parts.notices + '</div>' +
1154
+ '<div class="wk-detail" data-wid="' + esc(it.wid) + '"><div class="wk-detail-main"><div id="wk-part-main" class="wk-part">' + parts.main + '</div>' + chatSectionHtml(it) + '</div>' +
1155
+ '<aside class="wk-detail-side" id="wk-part-side">' + parts.side + '</aside></div></div>';
1156
+ [['wk-part-notices', parts.notices], ['wk-part-main', parts.main], ['wk-part-side', parts.side]].forEach(function (p) { var el = $(p[0]); if (el) el.__html = p[1]; });
1157
+ var hist = $('wk-part-chat'); if (hist) hist.__html = hist.innerHTML;
1158
+ }
1159
+ chatSyncComposer(it);
1115
1160
  if (!S.activity[it.wid] && !S.activityError[it.wid] && !S.activityLoading[it.wid]) {
1116
1161
  S.activityLoading[it.wid] = true;
1117
1162
  loadActivity(it).then(function () { delete S.activityLoading[it.wid]; }).then(function () { var r = W.parseRoute(); if (r.pane === 'work' && r.args[0] === arg && r.args.length === 1) renderDetail(arg); });
1118
1163
  }
1119
1164
  }
1120
1165
 
1166
+ // ── Work chat: message the orchestrator about this card (Control's WorkTaskChatView) ──
1167
+ // The Mac plans with GET /api/work/chat, POST /api/work/orchestrator/input and POST /api/work/chat, then sends with its
1168
+ // own Send. The web plans with the same three routes and the same body, then hands the plan to COS Control through the
1169
+ // lens-round contract, as the glasses do (source "web", webPlans 1): POST /api/work/chat/lens-rounds saves it into this
1170
+ // card's Work chat on the Mac, POST .../:roundId/send with the two-step confirm asks Control to send it, and
1171
+ // GET .../:roundId says how it went. Nothing here sends to a session: COS Control does, with its fences, receipts,
1172
+ // card and meeting files, and Stop. A round's id (web-<uuid>) is made once, so a retried save is the same round, and the
1173
+ // server holds one send per round, so a second Send answers send_in_progress or already_sent and never sends twice.
1174
+ var CHAT_STORE = 'cos.web.work.chat';
1175
+ // The question limit: the server's planner refuses a longer question (lib/work-chat.ts parseWorkChatInput, text(v.question,
1176
+ // 2000); not exported or advertised). work-composer-web.test.ts runs the real parser against this value.
1177
+ var CHAT_MAX = 2000;
1178
+ var CHAT_MAX_TEXT = CHAT_MAX.toLocaleString('en-US');
1179
+ // WorkContextMeter.cosText and meetingsHeader, and WorkProgress.instruction (Control), for the text estimate.
1180
+ var CHAT_COS = 'Preserve other session work. Report changes, checks and questions. This assignment does not authorize external publication, messages to other people, deletion or security changes.';
1181
+ var CHAT_MEETINGS = 'Confirmed source meetings (references; transcript evidence is not included):';
1182
+ var CHAT_TIMEOUT = { caps: 8000, read: 20000, input: 60000, plan: 190000, record: 15000, send: 15000, round: 8000 };
1183
+ // max: how long one round is read before this page stops, from when it was first read (kept with the round, so a return
1184
+ // to Work never starts it again). The server's own end for a send: picked up within 10 minutes (LENS_SEND_PICKUP_MS) and
1185
+ // reported within 15 after that (LENS_SEND_REPORT_MS), lib/work-lens-rounds.ts, plus a minute. Tested against them.
1186
+ var CHAT_POLL = { fast: 3000, slow: 10000, fastFor: 120000, max: 26 * 60000, reply: 15000, replyFor: 30 * 60000 };
1187
+ var CHAT_KEEP = { cards: 30, rounds: 8, draft: 8000 };
1188
+ var CHAT_SID = /^(claude|codex|cursor):[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
1189
+ var CHAT_PLANNERS = ['sonnet', 'opus'], CHAT_EFFORTS = ['low', 'medium', 'high'];
1190
+ var CHAT_EVIDENCE = ['link', 'created', 'forked', 'continued', 'prompt', 'status', 'listed', 'meeting'];
1191
+ var CHAT_ROW_REASONS = { not_in_session_list: 'not in the session list just read (fork it or start new)', app_owned: 'open in its app (continue it there)',
1192
+ cursor: 'Cursor (send in its app)', plan_limit: 'its plan usage is used up', on_another_card: 'working on another task now' };
1193
+ var CHAT_ROUND_WORD = { planning: 'Planning', planFailed: 'Needs attention', ready: 'Ready to send', answered: 'Answered', sending: 'Sending',
1194
+ held: 'Saved for the Mac', sent: 'Dispatched', partly: 'Partly sent', notDelivered: 'Not delivered', refused: 'Not delivered', stopped: 'Stopped',
1195
+ expired: 'Not sent', quiet: 'Still with COS Control' };
1196
+ var CHAT_ASSIGN_WORD = { pending: 'Waiting', held: 'Queued', sent: 'Sent', delivered: 'Delivered', failed: 'Failed', refused: 'Refused', stopped: 'Stopped' };
1197
+ var CHAT_DELIVERED = 'Instruction delivered to the session. Inspect the response; this does not mark the task complete.';
1198
+ var CHAT_MOVING = { sending: 1, held: 1 };
1199
+ var CHAT_FINAL = { sent: 1, delivered: 1, failed: 1, refused: 1, stopped: 1 };
1200
+ S.chat = {}; S.chatCaps = null; S.chatCapsLoading = null; S.chatDeferred = false;
1201
+ S.inspect = {}; S.completeHere = false; S.linkMeeting = null;
1202
+ var CHAT_TIMERS = new Set();
1203
+ // This browser's store, read again before every use: another tab may have saved since (two tabs share it).
1204
+ function chatReadDisk() {
1205
+ var v = W.readStore(CHAT_STORE);
1206
+ return v && v.v === 1 && v.cards && typeof v.cards === 'object' && !Array.isArray(v.cards) ? v : { v: 1, cards: {} };
1207
+ }
1208
+ var chatStore = chatReadDisk();
1209
+
1210
+ function chatOwn(message) { var e = new Error(message); e.own = true; return e; }
1211
+ function chatClean(v, n) { return typeof v === 'string' ? v.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, ' ').slice(0, n) : ''; }
1212
+ function chatTokens(text) { return Math.ceil(Array.from(String(text || '')).length / 4); }
1213
+ // WorkContextMeter.format: "≈ 950" or "≈ 18.4k".
1214
+ function chatTokenText(n) { return n < 1000 ? '≈ ' + n : '≈ ' + (n / 1000).toFixed(1) + 'k'; }
1215
+ function chatWorkId(t) { return /^[a-z_]+$/.test(str(t.domain)) && /^[a-f0-9]{12}$/.test(identity(t)) ? 'task:' + t.domain + ':' + identity(t) : ''; }
1216
+ function chatInstruction(wid) {
1217
+ var tag = String(wid).split(':')[2] || '';
1218
+ return '\n\nWhen you finish this task, or if you need input or are blocked, make this the first line of your reply, in plain text (not bold, not in a code block), so COS can track it:\n' +
1219
+ 'COS-WORK ' + tag + ': <done, needs input or blocked>: <one sentence of evidence>\n' +
1220
+ 'Use done only when the work is finished and checked. If you are handling several COS tasks, give one line per task.';
1221
+ }
1222
+ // The task context Control's helper builds for the planner (work-context), from the same board row.
1223
+ function chatContext(t, wid) {
1224
+ var text = 'Canonical Work ID: ' + wid + '\nTask: ' + str(t.text) + '\nDomain: ' + str(t.domain) + '\nStage: ' + str(t.workStage) +
1225
+ '\nDone when: ' + str(t.doneWhen) + '\nSource: ' + str(t.source) + '\n' + CHAT_MEETINGS;
1226
+ (Array.isArray(t.meetingRefs) ? t.meetingRefs : []).forEach(function (m) {
1227
+ if (!m || typeof m !== 'object') return;
1228
+ text += '\n- ' + (typeof m.title === 'string' ? m.title : 'Saved meeting') + ' | canonical ID: ' + str(m.recordId) + ' | domain: ' + str(m.domain) +
1229
+ ' | month: ' + str(m.month) + ' | file: ' + str(m.filename);
1230
+ });
1231
+ return chatClean(text, 16000);
1232
+ }
1233
+ // What each assignment sends as text, by Control's own estimate: the request, the task, one line per meeting and COS
1234
+ // instructions with the status line. Files and earlier updates are added by COS Control on the Mac and are not counted.
1235
+ function chatMeter(question, context, wid) {
1236
+ var parts = context.split('\n' + CHAT_MEETINGS), meetings = 0;
1237
+ var tokens = chatTokens(question) + chatTokens(parts[0].trim()) + chatTokens(CHAT_COS) + Math.ceil(chatInstruction(wid).length / 4);
1238
+ (parts[1] || '').split('\n').forEach(function (raw) { var line = raw.trim(); if (line.indexOf('- ') === 0) { meetings++; tokens += chatTokens(line); } });
1239
+ return { tokens: tokens, meetings: meetings };
1240
+ }
1241
+ function chatMeterText(m) {
1242
+ return 'Each assignment sends ' + chatTokenText(m.tokens) + ' tokens of text: your request, the task, ' +
1243
+ (m.meetings === 1 ? '1 meeting reference' : m.meetings + ' meeting references') + ' and COS instructions. COS Control adds the card’s files on your Mac; they are not counted here.';
1244
+ }
1245
+ function chatWhen(ms) {
1246
+ var d = new Date(ms); if (isNaN(d.getTime())) return '';
1247
+ var time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
1248
+ return localDay(d) === localDay() ? time : MONTHS[d.getMonth()] + ' ' + d.getDate() + ', ' + time;
1249
+ }
1250
+
1251
+ // ── what is kept on this browser (drafts and this browser's rounds, never a token) ──
1252
+ function chatRestored(r) {
1253
+ if (!r || typeof r.key !== 'string' || !/^web-[0-9a-f-]{36}$/.test(r.key) || typeof r.question !== 'string') return null;
1254
+ var out = { key: r.key, roundId: typeof r.roundId === 'string' && /^lr_[a-f0-9]{16}$/.test(r.roundId) ? r.roundId : '', question: r.question.slice(0, CHAT_MAX),
1255
+ answer: chatClean(r.answer, 6000), assignments: Array.isArray(r.assignments) ? r.assignments.slice(0, 3).filter(function (a) { return a && typeof a.id === 'string' && typeof a.prompt === 'string'; }) : [],
1256
+ planner: CHAT_PLANNERS.indexOf(r.planner) >= 0 ? r.planner : '', model: chatClean(r.model, 100), effort: CHAT_EFFORTS.indexOf(r.effort) >= 0 ? r.effort : '',
1257
+ note: chatClean(r.note, 1200), meter: r.meter && typeof r.meter.tokens === 'number' ? { tokens: r.meter.tokens, meetings: r.meter.meetings || 0 } : null,
1258
+ state: CHAT_ROUND_WORD[r.state] ? r.state : 'planFailed', roundState: chatClean(r.roundState, 40), inHistory: r.inHistory === true,
1259
+ states: r.states && typeof r.states === 'object' ? r.states : {}, error: chatClean(r.error, 400), at: Number(r.at) || 0, sentAt: Number(r.sentAt) || 0,
1260
+ sendUnknown: r.sendUnknown === true, context: chatClean(r.context, 4000), skipped: r.skipped && typeof r.skipped === 'object' ? r.skipped : {},
1261
+ pollSince: Number(r.pollSince) || 0, pollStopped: r.pollStopped === true, up: Number(r.up) || 0 };
1262
+ // A plan in flight lives only in this page. After a reload it is gone, and nothing was saved or sent for it.
1263
+ if (out.state === 'planning') { out.state = 'planFailed'; out.error = 'Planning stopped when this page closed. Nothing was sent. Use this request again to plan it.'; }
1264
+ return out;
1265
+ }
1266
+ function chatOf(wid) {
1267
+ var c = S.chat[wid];
1268
+ if (c) return c;
1269
+ chatStore = chatReadDisk();
1270
+ var saved = chatStore.cards[wid] || {};
1271
+ c = S.chat[wid] = { draft: typeof saved.draft === 'string' ? saved.draft.slice(0, CHAT_KEEP.draft) : '', at: Number(saved.at) || 0, draftAt: Number(saved.draftAt) || 0,
1272
+ removed: saved.removed && typeof saved.removed === 'object' && !Array.isArray(saved.removed) ? Object.assign({}, saved.removed) : {},
1273
+ rounds: (Array.isArray(saved.rounds) ? saved.rounds : []).filter(function (x) { return !(saved.removed && x && saved.removed[x.key]); })
1274
+ .map(function (x) { var r = chatRestored(x); if (r) chatStamp(r, Number(x.up) || 0); return r; }).filter(Boolean),
1275
+ busy: false, phase: '', error: '' };
1276
+ return c;
1277
+ }
1278
+ // A round's own clock for merging two tabs: it moves when the round's saved form changes in this tab.
1279
+ function chatStamp(r, given) {
1280
+ var sig = JSON.stringify(chatStored(r));
1281
+ if (given != null) { r.sig = sig; r.up = given; return r.up; }
1282
+ if (sig !== r.sig) { r.sig = sig; r.up = Date.now(); }
1283
+ return r.up || 0;
1284
+ }
1285
+ // A round still on its way (a saved round id, sending or held) is never dropped: this page is its only handle here.
1286
+ function chatKeepRound(r) { return !!(r && r.roundId && CHAT_MOVING[r.state]); }
1287
+ // Fold what another tab saved into this tab: per round id the newer copy wins; a round this tab is sending keeps its own
1288
+ // copy; rounds only the other tab has are adopted; the newer draft wins unless this tab's box has it in hand.
1289
+ function chatMergeIn(disk) {
1290
+ Object.keys(S.chat).forEach(function (wid) {
1291
+ var c = S.chat[wid], d = disk.cards[wid];
1292
+ if (!d) return;
1293
+ c.removed = c.removed || {};
1294
+ if (d.removed && typeof d.removed === 'object') Object.keys(d.removed).forEach(function (k) { c.removed[k] = Math.max(c.removed[k] || 0, Number(d.removed[k]) || 0); });
1295
+ c.rounds = c.rounds.filter(function (r) { return !c.removed[r.key] || chatKeepRound(r); });
1296
+ (Array.isArray(d.rounds) ? d.rounds : []).forEach(function (x) {
1297
+ if (!x || typeof x.key !== 'string' || c.removed[x.key]) return;
1298
+ var mine = c.rounds.filter(function (r) { return r.key === x.key; })[0], theirsUp = Number(x.up) || 0;
1299
+ if (!mine) {
1300
+ // A plan another tab is still making shows as planning there; one left behind by a closed tab is interrupted.
1301
+ var fresh = chatRestored(x.state === 'planning' && Date.now() - (Number(x.at) || 0) < 240000 ? Object.assign({}, x, { state: 'ready' }) : x);
1302
+ if (!fresh) return;
1303
+ if (x.state === 'planning' && Date.now() - (Number(x.at) || 0) < 240000) { fresh.state = 'planning'; fresh.elsewhere = true; }
1304
+ chatStamp(fresh, theirsUp); c.rounds.push(fresh);
1305
+ if (chatKeepRound(fresh) && !fresh.timer && !fresh.pollStopped && W.parseRoute().pane === 'work') chatSchedule(wid, fresh.key, 0);
1306
+ return;
1307
+ }
1308
+ if (mine.sending || mine.state === 'planning' && !mine.elsewhere) return;
1309
+ if (theirsUp > chatStamp(mine)) {
1310
+ var next = chatRestored(x.state === 'planning' ? Object.assign({}, x, { state: 'ready' }) : x);
1311
+ if (!next) return;
1312
+ if (x.state === 'planning') next.state = 'planning';
1313
+ var keep = { timer: mine.timer, confirm: mine.confirm };
1314
+ Object.keys(chatStored(next)).forEach(function (k) { mine[k] = next[k]; });
1315
+ mine.elsewhere = x.state === 'planning'; mine.timer = keep.timer; mine.confirm = keep.confirm;
1316
+ chatStamp(mine, theirsUp);
1317
+ }
1318
+ });
1319
+ c.rounds.sort(function (a, b) { return (a.at || 0) - (b.at || 0); });
1320
+ var theirDraftAt = Number(d.draftAt) || 0;
1321
+ if (theirDraftAt > (c.draftAt || 0) && typeof d.draft === 'string') {
1322
+ var box = $('wk-chat-text'), host = $('wk-composer'), here = box && host && host.getAttribute('data-wid') === wid;
1323
+ if (!(here && chatComposerFocused())) { c.draft = d.draft.slice(0, CHAT_KEEP.draft); c.draftAt = theirDraftAt; if (here) box.value = c.draft; }
1324
+ }
1325
+ });
1326
+ if ((Number(disk.prefsAt) || 0) > (Number(chatStore.prefsAt) || 0)) { chatStore.planner = disk.planner; chatStore.effort = disk.effort; chatStore.prefsAt = disk.prefsAt; }
1327
+ }
1328
+ function chatStored(r) {
1329
+ return { key: r.key, roundId: r.roundId, question: r.question, answer: r.answer, assignments: r.assignments, planner: r.planner, model: r.model,
1330
+ effort: r.effort, note: r.note, meter: r.meter, state: r.state, roundState: r.roundState, inHistory: r.inHistory, states: r.states, error: r.error,
1331
+ at: r.at, sentAt: r.sentAt, sendUnknown: r.sendUnknown, context: r.context, skipped: r.skipped, pollSince: r.pollSince || 0, pollStopped: !!r.pollStopped };
1332
+ }
1333
+ function chatTrim(rounds) {
1334
+ var newest = rounds.slice(-CHAT_KEEP.rounds);
1335
+ return rounds.filter(function (r) { return newest.indexOf(r) >= 0 || chatKeepRound(r); });
1336
+ }
1337
+ var chatSaveTimer = null;
1338
+ function chatSave(now) {
1339
+ clearTimeout(chatSaveTimer); chatSaveTimer = null;
1340
+ if (!now) { chatSaveTimer = setTimeout(function () { chatSave(true); }, 300); return; }
1341
+ // Read the store again and merge: another tab may have saved since this one last did (it never loses that tab's rounds).
1342
+ var disk = chatReadDisk();
1343
+ chatMergeIn(disk);
1344
+ var cards = {};
1345
+ Object.keys(disk.cards).forEach(function (k) { cards[k] = disk.cards[k]; });
1346
+ Object.keys(S.chat).forEach(function (wid) {
1347
+ var c = S.chat[wid];
1348
+ if (!c.draft && !c.rounds.length && !Object.keys(c.removed || {}).length) { if (!(disk.cards[wid] && (Number(disk.cards[wid].at) || 0) > (c.at || 0))) delete cards[wid]; return; }
1349
+ var removed = {}; Object.keys(c.removed || {}).sort(function (x, y) { return c.removed[y] - c.removed[x]; }).slice(0, 40).forEach(function (k) { removed[k] = c.removed[k]; });
1350
+ cards[wid] = { at: Math.max(c.at || 0, Number((disk.cards[wid] || {}).at) || 0) || Date.now(), draft: c.draft.slice(0, CHAT_KEEP.draft), draftAt: c.draftAt || 0, removed: removed,
1351
+ rounds: chatTrim(c.rounds).map(function (r) { var o = chatStored(r); o.up = chatStamp(r); return o; }) };
1352
+ });
1353
+ var next = { v: 1, cards: {} };
1354
+ if (chatStore.planner) next.planner = chatStore.planner;
1355
+ if (chatStore.effort) next.effort = chatStore.effort;
1356
+ if (chatStore.prefsAt) next.prefsAt = chatStore.prefsAt;
1357
+ var order = Object.keys(cards).sort(function (a, b) { return (Number(cards[b].at) || 0) - (Number(cards[a].at) || 0); });
1358
+ order.forEach(function (k, i) {
1359
+ var keep = i < CHAT_KEEP.cards || (cards[k].rounds || []).some(function (r) { return chatKeepRound(r); });
1360
+ if (keep) next.cards[k] = cards[k];
1361
+ });
1362
+ chatStore = next;
1363
+ W.writeStore(CHAT_STORE, next); // wrapped in try/catch by the kit: a full or blocked store keeps the page working
1364
+ }
1365
+ // Another tab saved: show its rounds here too. Merge only; writing back here would answer every save with a save.
1366
+ window.addEventListener('storage', function (e) {
1367
+ if (e && e.key !== CHAT_STORE && e.key !== null) return;
1368
+ chatMergeIn(chatReadDisk()); chatRepaintCurrent();
1369
+ });
1370
+ function chatTouch(c) { c.at = Date.now(); chatSave(); }
1371
+
1372
+ // ── what this server offers (GET /api/work/chat) ─────────
1373
+ // chat: GET /api/work/chat (planners, efforts, lensPlansControl). web: GET /api/work/chat/web-plans, which a server
1374
+ // that records web plans answers { webPlans: 1 } and an older one answers 404.
1375
+ function chatReadCaps(chat, web) {
1376
+ var at = Date.now(), status = chat.status, d = chat.d;
1377
+ if (status === 200 && d && d.protocolVersion === 1 && d.orchestrator === 1 && d.lensPlans === 1 && web.status !== 200 && web.status !== 404) {
1378
+ return { at: at, ok: false, retry: true, reason: web.status === 401 ? 'Signed out. Connect this browser again to use Work chat.' : 'Could not check Work chat on your Mac. Check that it is awake, then try again.' };
1379
+ }
1380
+ if (status === 200 && d && d.protocolVersion === 1 && d.orchestrator === 1 && d.lensPlans === 1 && web.status === 200 && web.d && web.d.webPlans === 1) {
1381
+ var ctl = d.lensPlansControl && typeof d.lensPlansControl === 'object' ? d.lensPlansControl : null;
1382
+ return { at: at, ok: true,
1383
+ planners: CHAT_PLANNERS.filter(function (p) { return Array.isArray(d.planners) && d.planners.indexOf(p) >= 0; }),
1384
+ efforts: CHAT_EFFORTS.filter(function (e) { return Array.isArray(d.efforts) && d.efforts.indexOf(e) >= 0; }),
1385
+ control: { connected: !!(ctl && ctl.connected === true), lastSeenSec: ctl && typeof ctl.lastSeenSec === 'number' ? ctl.lastSeenSec : null } };
1386
+ }
1387
+ if (status === 200 && d && d.orchestrator === 1 && d.lensPlans === 1) {
1388
+ return { at: at, ok: false, reason: 'This COS server plans Work chat for the Mac and the glasses, but not for the web yet. Update the COS server on your Mac to message this task from here.' };
1389
+ }
1390
+ if (status === 200 || status === 404) return { at: at, ok: false, reason: 'Work chat needs a newer COS server on your Mac. Until then, message this task from COS Control on the Mac.' };
1391
+ if (status === 401) return { at: at, ok: false, retry: true, reason: 'Signed out. Connect this browser again to use Work chat.' };
1392
+ return { at: at, ok: false, retry: true, reason: 'Could not check Work chat on your Mac. Check that it is awake, then try again.' };
1393
+ }
1394
+ function loadChatCaps(force) {
1395
+ var c = S.chatCaps;
1396
+ if (S.chatCapsLoading) return S.chatCapsLoading;
1397
+ if (!force && c && Date.now() - c.at < (c.ok ? 60000 : 15000)) return Promise.resolve();
1398
+ function read(path) { return api(path, { timeoutMs: CHAT_TIMEOUT.caps }).then(function (d) { return { status: 200, d: d }; }, function (err) { return { status: err && err.status || 0, d: null }; }); }
1399
+ S.chatCapsLoading = Promise.all([read('/api/work/chat'), read('/api/work/chat/web-plans')]).then(function (both) { S.chatCaps = chatReadCaps(both[0], both[1]); })
1400
+ .then(function () { S.chatCapsLoading = null; chatRepaintCurrent(); });
1401
+ return S.chatCapsLoading;
1402
+ }
1403
+ function chatPlanner() { var caps = S.chatCaps || {}, list = caps.planners || []; if (!list.length) return ''; return list.indexOf(chatStore.planner) >= 0 ? chatStore.planner : list.indexOf('opus') >= 0 ? 'opus' : list[0]; }
1404
+ function chatEffort() { var caps = S.chatCaps || {}, list = caps.efforts || []; if (!list.length) return ''; return list.indexOf(chatStore.effort) >= 0 ? chatStore.effort : list.indexOf('high') >= 0 ? 'high' : list[list.length - 1]; }
1405
+ function plannerName(p) { return p === 'opus' ? 'Opus' : 'Sonnet'; }
1406
+ // WorkChatPolicy.plannerLabel: only the server's report of what ran is a claim; a request stays a request.
1407
+ function chatPlannerLine(r) {
1408
+ var ran = String(r.model || '').trim(), base = '';
1409
+ if (/\(requested\)$/.test(ran) || (!ran && r.planner)) base = 'requested ' + plannerName(r.planner || (ran.replace(/\s*\(requested\)$/, '').split(':').pop() || '').trim()) + ' (not confirmed)';
1410
+ else if (ran) base = 'planned with ' + (ran.indexOf(':') >= 0 ? ran.slice(ran.indexOf(':') + 1) : ran);
1411
+ if (!base) return '';
1412
+ return CHAT_EFFORTS.indexOf(r.effort) >= 0 ? base + ' · ' + r.effort + ' effort' : base;
1413
+ }
1414
+
1415
+ // ── the planner's input and answer (lens-work-chat.ts readOrchestratorInput / readLensWorkChatPlan) ──
1416
+ function chatIso(v) { return typeof v === 'string' && v.length <= 40 && !isNaN(Date.parse(v)) ? v : null; }
1417
+ function chatReadInput(raw) {
1418
+ if (!raw || typeof raw !== 'object' || raw.protocolVersion !== 1 || !Array.isArray(raw.rows)) return null;
1419
+ var rows = [], seen = {};
1420
+ raw.rows.slice(0, 12).forEach(function (r) {
1421
+ if (!r || typeof r.id !== 'string' || !CHAT_SID.test(r.id) || seen[r.id]) return;
1422
+ seen[r.id] = 1;
1423
+ var row = { id: r.id, title: chatClean(r.title, 300), summary: chatClean(r.summary, 2000), status: chatClean(r.status, 100),
1424
+ discoveredBy: Array.isArray(r.discoveredBy) ? r.discoveredBy.filter(function (k, i, all) { return CHAT_EVIDENCE.indexOf(k) >= 0 && all.indexOf(k) === i; }) : [],
1425
+ linked: r.linked === true, member: r.member === true, lastTouchedCard: chatIso(r.lastTouchedCard), lastActivity: chatIso(r.lastActivity),
1426
+ live: r.live === true, busy: r.busy === true, pickup: chatClean(r.pickup, 1500), available: r.available === true,
1427
+ forkable: r.forkable === true && r.id.indexOf('cursor:') !== 0 };
1428
+ if (CHAT_ROW_REASONS[r.reason]) row.reason = r.reason;
1429
+ rows.push(row);
1430
+ });
1431
+ var leftOut = (Array.isArray(raw.leftOut) ? raw.leftOut : []).filter(function (l) { return l && typeof l.id === 'string' && CHAT_SID.test(l.id) && !seen[l.id]; })
1432
+ .slice(0, 12).map(function (l) { return { id: l.id, title: chatClean(l.title, 300), reason: chatClean(l.reason, 120) || 'left out' }; });
1433
+ var usage = (Array.isArray(raw.usage) ? raw.usage : []).filter(function (u) { return u && ['claude', 'codex', 'cursor'].indexOf(u.provider) >= 0 && ['ok', 'low', 'exhausted', 'unknown'].indexOf(u.state) >= 0; })
1434
+ .slice(0, 3).map(function (u) {
1435
+ return { provider: u.provider, state: u.state, headroomPct: typeof u.headroomPct === 'number' && u.headroomPct >= 0 && u.headroomPct <= 100 ? u.headroomPct : null,
1436
+ resetsAt: chatIso(u.resetsAt), ageSec: Number.isInteger(u.ageSec) && u.ageSec >= 0 ? u.ageSec : null, source: chatClean(u.source, 40) || 'unknown',
1437
+ plan: typeof u.plan === 'string' ? chatClean(u.plan, 40) : null, stale: u.stale === true, note: chatClean(u.note, 200) };
1438
+ });
1439
+ return { rows: rows, leftOut: leftOut, usage: usage };
1440
+ }
1441
+ // Control's rule for a Continue (WorkOrchestrator.automatic): Claude or Codex, and nothing the server says stops it.
1442
+ function chatAutomatic(row) { return row.available && /^(claude|codex):/.test(row.id); }
1443
+ // WorkOrchestrator.emptyExplanation: never plan from an empty list in silence.
1444
+ function chatSessionsNote(input) {
1445
+ var rows = input.rows, left = input.leftOut;
1446
+ if (rows.some(chatAutomatic)) return '';
1447
+ if (!rows.length && !left.length) return 'COS found no session for this task (no link, receipt or COS-WORK handoff). The planner can still start a new session.';
1448
+ var total = rows.length + left.length;
1449
+ var named = rows.slice(0, 4).map(function (r) { return '“' + r.title.slice(0, 40) + '” (' + (CHAT_ROW_REASONS[r.reason] || 'available') + ')'; });
1450
+ var more = total - named.length;
1451
+ return 'COS found ' + total + ' session' + (total === 1 ? '' : 's') + ' for this task, none can take a Continue now' +
1452
+ (named.length ? ': ' + named.join('; ') + (more > 0 ? '; and ' + more + ' more' : '') : '') + '. The planner can fork one or start a new session.';
1453
+ }
1454
+ // The planner input, exactly as the server's parseWorkChatInput reads an orchestrator client's (Control 0.5.333).
1455
+ function chatPlanBody(it, question, input, c, wid) {
1456
+ var t = it.task, ids = {};
1457
+ var sessions = input.rows.map(function (r) {
1458
+ ids[r.id] = 1;
1459
+ var s = { id: r.id, title: r.title, summary: r.summary, status: r.status, automatic: chatAutomatic(r), discoveredBy: r.discoveredBy, linked: r.linked, member: r.member,
1460
+ lastTouchedCard: r.lastTouchedCard, lastActivity: r.lastActivity, live: r.live, busy: r.busy, pickup: r.pickup, available: r.available, forkable: r.forkable };
1461
+ if (r.reason) s.reason = r.reason;
1462
+ return s;
1463
+ });
1464
+ // This browser's earlier rounds on the card (the Mac sends its own last six the same way).
1465
+ var history = c.rounds.filter(function (r) { return r.answer; }).slice(-6).map(function (r) { return { question: r.question.slice(0, 2000), answer: r.answer.slice(0, 6000) }; });
1466
+ var last = c.rounds.filter(function (r) { return r.assignments.length && r.roundId; }).slice(-1)[0];
1467
+ var updates = last ? last.assignments.filter(function (a) { return a.sessionID && ids[a.sessionID]; }).map(function (a) {
1468
+ var st = (last.states || {})[a.id] || {};
1469
+ return { sessionID: a.sessionID, status: chatClean(CHAT_ASSIGN_WORD[st.state] || 'Not sent', 100), detail: chatClean(st.detail, 2000), result: '' };
1470
+ }) : [];
1471
+ var exhausted = {}; input.usage.forEach(function (u) { if (u.state === 'exhausted') exhausted[u.provider] = 1; });
1472
+ var body = { workID: wid, revision: str(t.workRevision), question: question, context: chatContext(t, wid), sessions: sessions, history: history, updates: updates,
1473
+ mode: 'plan', orchestrator: true, now: new Date().toISOString(), usage: input.usage, leftOut: input.leftOut,
1474
+ newSessionProviders: ['claude', 'codex'].filter(function (p) { return !exhausted[p]; }) };
1475
+ var planner = chatPlanner(), effort = chatEffort();
1476
+ if (planner) body.planner = planner; // only when the server advertised a choice, as Control does
1477
+ if (effort) body.effort = effort;
1478
+ return body;
1479
+ }
1480
+ function chatReadPlan(raw, input, body) {
1481
+ if (!raw || typeof raw !== 'object' || typeof raw.answer !== 'string' || !raw.answer.trim() || raw.answer.length > 6000 || !Array.isArray(raw.assignments)) return null;
1482
+ if (raw.assignments.length > 3) return { tooMany: true };
1483
+ var rows = {}; input.rows.forEach(function (r) { rows[r.id] = r; });
1484
+ var out = [], targets = {};
1485
+ for (var i = 0; i < raw.assignments.length; i++) {
1486
+ var a = raw.assignments[i];
1487
+ if (!a || typeof a.prompt !== 'string' || !a.prompt.trim() || a.prompt.length > 2000) return null;
1488
+ if (a.mode === 'newSession') {
1489
+ if (body.newSessionProviders.indexOf(a.provider) < 0) return null;
1490
+ out.push({ id: 'a' + (i + 1), mode: 'newSession', provider: a.provider, prompt: a.prompt, title: 'New ' + providerName(a.provider) + ' session' });
1491
+ continue;
1492
+ }
1493
+ var row = rows[a.sessionID], mode = a.mode === 'fork' ? 'fork' : 'continue';
1494
+ if (!row || targets[row.id] || (mode === 'continue' ? !chatAutomatic(row) : !row.forkable)) return null;
1495
+ targets[row.id] = 1;
1496
+ var provider = row.id.slice(0, row.id.indexOf(':')), title = row.title || providerName(provider);
1497
+ out.push({ id: 'a' + (i + 1), mode: mode, sessionID: row.id, provider: provider, prompt: a.prompt, title: mode === 'fork' ? 'Fork of ' + title : title });
1498
+ }
1499
+ return { answer: raw.answer, assignments: out };
1500
+ }
1501
+ function chatRoundBody(it, r) {
1502
+ var body = { v: 1, workID: chatWorkId(it.task), clientRoundId: r.key, question: r.question, answer: r.answer, orchestrator: true, source: 'web',
1503
+ createdAt: new Date(r.at || Date.now()).toISOString(),
1504
+ assignments: r.assignments.map(function (a) {
1505
+ var x = { id: a.id, mode: a.mode, provider: a.provider, prompt: a.prompt };
1506
+ if (a.sessionID) x.sessionID = a.sessionID;
1507
+ return x;
1508
+ }) };
1509
+ // Truthful: the planner the server says ran, never only the one asked for ("claude:opus (requested)" is not a run).
1510
+ var reported = /\(requested\)\s*$/.test(r.model) ? '' : r.model;
1511
+ var ran = /\bopus\b/.test(reported) ? 'opus' : /\bsonnet\b/.test(reported) ? 'sonnet' : '';
1512
+ if (ran) body.planner = ran;
1513
+ if (CHAT_EFFORTS.indexOf(r.effort) >= 0) body.effort = r.effort;
1514
+ return body;
1515
+ }
1516
+
1517
+ // ── plan, save, send, read (the lens runner's steps, createLensPlanRunner) ──
1518
+ function chatFailure(err, step) {
1519
+ var code = err && err.code, status = err && err.status;
1520
+ if (code === 'timeout' || !status) return step === 'plan' ? 'Your Mac did not answer while planning. Nothing was sent. Plan again when it is awake.' : 'Your Mac did not answer. Nothing was sent. Try again when it is awake.';
1521
+ if (code === 'work_chat_busy') return 'COS is planning another request right now. Nothing was sent. Plan again in a moment.';
1522
+ if (status === 404) return 'This COS server does not plan Work chat. Update the COS server on your Mac. Nothing was sent.';
1523
+ if (code === 'invalid_work_chat' || code === 'invalid_orchestrator_input') return 'The server did not accept this plan request. Nothing was sent.';
1524
+ if (code === 'orchestrator_input_unavailable') return 'COS could not read this task’s sessions just now. Nothing was sent. Plan again in a moment.';
1525
+ if (code === 'work_chat_unavailable') return 'No assignment was dispatched. The planner did not finish; plan again when you are ready.';
1526
+ return (err && err.message ? err.message + ' ' : '') + 'Nothing was sent.';
1527
+ }
1528
+ function chatProblem(it, c) {
1529
+ var caps = S.chatCaps;
1530
+ if (!caps || !caps.ok) return caps ? caps.reason : 'Checking Work chat on your Mac…';
1531
+ if (!chatWorkId(it.task)) return 'This card has no Work identity yet. Refresh Work, then plan.';
1532
+ if (!/^[a-f0-9]{64}$/.test(str(it.task.workRevision))) return 'This card has no saved revision. Refresh Work, then plan.';
1533
+ if (c.busy) return '';
1534
+ var text = c.draft.trim();
1535
+ if (text.length > CHAT_MAX) return 'Keep your request under ' + CHAT_MAX_TEXT + ' characters.';
1536
+ if (c.rounds.some(function (r) { return r.state === 'planning'; })) return 'A plan is still being made for this card.';
1537
+ return '';
1538
+ }
1539
+ function chatPlan(it) {
1540
+ var c = chatOf(it.wid), question = c.draft.trim();
1541
+ if (c.busy || !question || chatProblem(it, c)) return Promise.resolve(false);
1542
+ var wid = chatWorkId(it.task);
1543
+ c.busy = true; c.error = ''; c.phase = 'Reading the task and its connections…';
1544
+ chatRepaint(it.wid);
1545
+ // The card as it is NOW: a card that changed since this page loaded is planned only after it is read again.
1546
+ return api('/api/work-board/activity?domain=' + encodeURIComponent(it.domain) + '&workIdentity=' + encodeURIComponent(it.id), { timeoutMs: CHAT_TIMEOUT.read }).then(function (d) {
1547
+ if (d) { S.activity[it.wid] = d; delete S.activityError[it.wid]; }
1548
+ var fresh = str(d && d.taskRevision), shown = str(it.task.taskRevision);
1549
+ if (fresh && shown && fresh !== shown) {
1550
+ loadBoard(true);
1551
+ throw chatOwn('This task changed since the page loaded. Check it, then plan again. Your request is still here.');
1552
+ }
1553
+ // From here the request belongs to a round, as on the Mac: the composer is cleared and the round keeps the words.
1554
+ var r = { key: 'web-' + uuid(), roundId: '', question: question, answer: '', assignments: [], planner: chatPlanner(), model: '', effort: '', note: '', meter: null,
1555
+ state: 'planning', roundState: '', inHistory: false, states: {}, error: '', at: Date.now(), sentAt: 0, sendUnknown: false, context: '', skipped: {} };
1556
+ c.rounds.push(r); c.draft = ''; c.draftAt = Date.now(); chatTouch(c);
1557
+ chatResetComposer(it.wid);
1558
+ c.phase = 'Finding this task’s sessions…'; chatRepaint(it.wid);
1559
+ var meetings = (Array.isArray(it.task.meetingRefs) ? it.task.meetingRefs : []).map(function (m) { return m && typeof m.recordId === 'string' ? 'meeting:' + m.recordId : ''; })
1560
+ .filter(function (m) { return m && m.length <= 250 && /^meeting:[^\u0000-\u001f]+$/.test(m); }).slice(0, 20);
1561
+ return api('/api/work/orchestrator/input', { method: 'POST', body: { workID: wid, meetingWorkIDs: meetings }, timeoutMs: CHAT_TIMEOUT.input }).then(function (raw) {
1562
+ var input = chatReadInput(raw);
1563
+ if (!input || raw.workID !== wid) throw chatOwn('The server’s list of this task’s sessions could not be read. Nothing was sent.');
1564
+ var body = chatPlanBody(it, question, input, c, wid);
1565
+ r.note = chatSessionsNote(input);
1566
+ r.meter = chatMeter(question, body.context, wid);
1567
+ r.context = body.context.slice(0, 4000);
1568
+ c.phase = 'Thinking through the request with ' + plannerName(body.planner || 'sonnet') + '…'; chatRepaint(it.wid);
1569
+ return api('/api/work/chat', { method: 'POST', body: body, timeoutMs: CHAT_TIMEOUT.plan }).then(function (answer) {
1570
+ var plan = chatReadPlan(answer, input, body);
1571
+ if (plan && plan.tooMany) throw chatOwn('The plan has more than 3 assignments, more than COS sends from one plan. Nothing was sent. Plan again.');
1572
+ if (!plan) throw chatOwn('The plan named a session it may not send to here, or broke a limit. Nothing was sent. Plan again.');
1573
+ r.answer = plan.answer; r.assignments = plan.assignments;
1574
+ r.model = chatClean(answer.model, 100); r.effort = CHAT_EFFORTS.indexOf(answer.effort) >= 0 ? answer.effort : '';
1575
+ r.state = plan.assignments.length ? 'ready' : 'answered';
1576
+ chatTouch(c);
1577
+ // The plan is saved to the Mac only on Send. Today's COS Control files every saved round as a plan "from your
1578
+ // glasses" and posts a notice, so saving on Plan would announce a plan nobody chose to send. Send saves it
1579
+ // under this round's id first (the same id on every retry), then asks COS Control to send it.
1580
+ return true;
1581
+ });
1582
+ }).catch(function (err) {
1583
+ r.state = 'planFailed'; r.error = err && err.own ? err.message : chatFailure(err, 'plan');
1584
+ chatTouch(c);
1585
+ return false;
1586
+ });
1587
+ }).catch(function (err) {
1588
+ // Nothing was planned: the words stay in the composer.
1589
+ c.error = err && err.own ? err.message : chatFailure(err, 'read');
1590
+ return false;
1591
+ }).then(function (ok) { c.busy = false; c.phase = ''; chatRepaint(it.wid); return ok; });
1592
+ }
1593
+ function chatRecord(it, r) {
1594
+ return api('/api/work/chat/lens-rounds', { method: 'POST', body: chatRoundBody(it, r), timeoutMs: CHAT_TIMEOUT.record }).then(function (d) {
1595
+ if (d && typeof d.roundId === 'string' && /^lr_[a-f0-9]{16}$/.test(d.roundId)) { r.roundId = d.roundId; r.roundState = str(d.state) || 'recorded'; r.error = ''; }
1596
+ return r.roundId;
1597
+ }, function (err) {
1598
+ var status = err && err.status;
1599
+ if (!status || status === 503 || err.code === 'timeout') { r.error = 'Not sent: ' + (status ? 'COS is busy or updating.' : 'your Mac did not answer.') + ' The plan is kept; send it again.'; return ''; }
1600
+ r.state = 'planFailed';
1601
+ r.error = err.code === 'unknown_work' ? 'This card is not on the Work board any more. Nothing was sent.'
1602
+ : (err.message || 'The server did not save this plan.') + ' Nothing was sent.';
1603
+ return '';
1604
+ }).then(function (id) { chatTouch(chatOf(it.wid)); return id; });
1605
+ }
1606
+ // Send: the two-step confirm is this tap, after the plan was shown. One send per round on the server: a second tap,
1607
+ // a retry after a lost answer, or another device answers send_in_progress or already_sent, never a second send.
1608
+ // What this page may still send: assignments never sent, not skipped here, of a round that is not on its way. A plan held
1609
+ // for the Mac (COS Control was not connected) is never offered here again.
1610
+ function chatUnsent(r) {
1611
+ if (!r || !r.assignments.length || CHAT_MOVING[r.state] || ['planning', 'planFailed', 'answered', 'quiet'].indexOf(r.state) >= 0) return [];
1612
+ if (r.state === 'notDelivered' && !r.roundId) return [];
1613
+ return r.assignments.filter(function (a) { var st = (r.states[a.id] || {}).state; return !(r.skipped || {})[a.id] && (!st || st === 'pending'); });
1614
+ }
1615
+ function chatTarget(a) {
1616
+ if (a.mode === 'newSession') return 'a new ' + providerName(a.provider) + ' session';
1617
+ var title = clip(String(a.title || '').replace(/^Fork of /, ''), 40);
1618
+ return a.mode === 'fork' ? 'a fork of “' + title + '”' : '“' + title + '”';
1619
+ }
1620
+ function chatNames(list) { var n = list.map(chatTarget); return n.length < 2 ? n.join('') : n.slice(0, -1).join(', ') + ' and ' + n[n.length - 1]; }
1621
+ // Send is two taps: Send, then Send to <session>. The second button takes no tap in its first 450 ms, so a double tap on
1622
+ // Send never confirms by itself.
1623
+ var CHAT_CONFIRM_MS = 450;
1624
+ function chatAskSend(it, r, ids) {
1625
+ var open = chatUnsent(r).map(function (a) { return a.id; });
1626
+ ids = (ids || []).filter(function (id) { return open.indexOf(id) >= 0; });
1627
+ if (!ids.length || r.sending) return;
1628
+ r.confirm = { ids: ids, at: Date.now() }; r.error = '';
1629
+ chatRepaint(it.wid);
1630
+ var timer = setTimeout(function () { CHAT_TIMERS.delete(timer); chatRepaint(it.wid); }, CHAT_CONFIRM_MS + 20);
1631
+ CHAT_TIMERS.add(timer);
1632
+ }
1633
+ function chatConfirmSend(it, r) {
1634
+ if (!r || !r.confirm || Date.now() - r.confirm.at < CHAT_CONFIRM_MS) return Promise.resolve(false);
1635
+ var ids = r.confirm.ids; r.confirm = null;
1636
+ return chatSend(it, r, ids);
1637
+ }
1638
+ function chatSend(it, r, ids) {
1639
+ var c = chatOf(it.wid), open = chatUnsent(r).map(function (a) { return a.id; });
1640
+ if (!r || r.sending || !ids || !ids.length || ids.some(function (id) { return open.indexOf(id) < 0; })) return Promise.resolve(false);
1641
+ r.sending = true; r.error = ''; chatRepaint(it.wid);
1642
+ var start = r.roundId ? Promise.resolve(r.roundId) : chatRecord(it, r);
1643
+ return start.then(function (id) {
1644
+ if (!id) return false;
1645
+ // After a send whose answer was lost, read the round first: it may already be on its way.
1646
+ var check = r.sendUnknown ? api('/api/work/chat/lens-rounds/' + encodeURIComponent(id), { timeoutMs: CHAT_TIMEOUT.round }).then(function (v) { return v; }, function () { return null; }) : Promise.resolve(null);
1647
+ return check.then(function (view) {
1648
+ if (view && ['send_requested', 'sending', 'sent', 'partly_sent'].indexOf(view.state) >= 0) { chatApplyView(r, view); r.sendUnknown = false; r.sentAt = r.sentAt || Date.now(); return true; }
1649
+ return api('/api/work/chat/lens-rounds/' + encodeURIComponent(id) + '/send', { method: 'POST', body: { v: 1, confirm: 'two-step', confirmedAt: new Date().toISOString(), assignmentIds: ids }, timeoutMs: CHAT_TIMEOUT.send })
1650
+ .then(function () {
1651
+ r.state = 'sending'; r.roundState = 'send_requested'; r.sentAt = Date.now(); r.sendUnknown = false;
1652
+ ids.forEach(function (x) { r.states[x] = { state: 'pending', detail: '', sessionID: '', receiptId: '' }; });
1653
+ return true;
1654
+ }, function (err) {
1655
+ var code = err && err.code, status = err && err.status;
1656
+ // Already on its way (a second device, a lost answer): read the round for where it really is.
1657
+ if (status === 409 && (code === 'send_in_progress' || code === 'already_sent')) { r.state = 'sending'; r.roundState = 'checking'; r.sentAt = r.sentAt || Date.now(); r.sendUnknown = false; return true; }
1658
+ if (status === 423) { r.state = 'held'; r.error = ''; r.sendUnknown = false; return true; }
1659
+ if (status === 404) { r.state = 'notDelivered'; r.roundId = ''; r.error = 'Your Mac no longer has this plan. Nothing was sent. Plan again.'; return false; }
1660
+ if (status === 409 && code === 'plan_stale') { r.state = 'notDelivered'; r.error = 'This card’s sessions changed since the plan. Nothing was sent. Plan again.'; return false; }
1661
+ if (!status || code === 'timeout') { r.sendUnknown = true; r.error = 'Your Mac did not answer, so this page cannot tell whether COS Control got the send. Send again checks first; it never sends twice.'; return false; }
1662
+ r.error = (err.message || 'The Mac did not take the send.') + ' The plan is kept.';
1663
+ return false;
1664
+ });
1665
+ });
1666
+ }).then(function (moving) {
1667
+ r.sending = false; chatTouch(c); chatRepaint(it.wid);
1668
+ if (moving) chatSchedule(it.wid, r.key, 0);
1669
+ return moving;
1670
+ });
1671
+ }
1672
+ function chatApplyView(r, v) {
1673
+ var states = {};
1674
+ (Array.isArray(v.assignments) ? v.assignments : []).forEach(function (a) {
1675
+ if (!a || typeof a.id !== 'string' || !/^a[1-9]$/.test(a.id)) return;
1676
+ states[a.id] = { state: CHAT_ASSIGN_WORD[a.state] ? a.state : 'pending', detail: chatClean(a.detail, 200),
1677
+ sessionID: typeof a.sessionID === 'string' && CHAT_SID.test(a.sessionID) ? a.sessionID : '', receiptId: chatClean(a.receiptId, 80) };
1678
+ });
1679
+ r.states = states; r.inHistory = v.inHistory === true; r.roundState = str(v.state);
1680
+ var s = r.roundState;
1681
+ if (s === 'send_requested' || s === 'sending') r.state = 'sending';
1682
+ else if (s === 'sent') r.state = 'sent';
1683
+ else if (s === 'partly_sent') r.state = 'partly';
1684
+ else if (s === 'failed') r.state = 'notDelivered';
1685
+ else if (s === 'refused') r.state = 'refused';
1686
+ else if (s === 'stopped') r.state = 'stopped';
1687
+ else if (s === 'expired') r.state = 'expired';
1688
+ // recorded or in_history: before any send; a plan held for the Mac stays held until the Mac sends it.
1689
+ }
1690
+ function chatFindRound(wid, key) { var c = S.chat[wid]; return c ? c.rounds.filter(function (r) { return r.key === key; })[0] || null : null; }
1691
+ function chatSchedule(wid, key, ms) {
1692
+ var r = chatFindRound(wid, key); if (!r) return;
1693
+ if (r.timer) { clearTimeout(r.timer); CHAT_TIMERS.delete(r.timer); r.timer = null; }
1694
+ r.pollSince = r.pollSince || Date.now();
1695
+ var wait = ms != null ? ms : r.state === 'held' ? CHAT_POLL.slow : Date.now() - r.pollSince < CHAT_POLL.fastFor ? CHAT_POLL.fast : CHAT_POLL.slow;
1696
+ var timer = setTimeout(function () { CHAT_TIMERS.delete(timer); r.timer = null; chatPoll(wid, key); }, wait);
1697
+ r.timer = timer; CHAT_TIMERS.add(timer);
1698
+ }
1699
+ function chatPoll(wid, key) {
1700
+ var r = chatFindRound(wid, key);
1701
+ if (!r || !r.roundId || !CHAT_MOVING[r.state] || W.parseRoute().pane !== 'work') return Promise.resolve();
1702
+ var before = JSON.stringify([r.state, r.roundState, r.states, r.inHistory]);
1703
+ return api('/api/work/chat/lens-rounds/' + encodeURIComponent(r.roundId), { timeoutMs: CHAT_TIMEOUT.round }).then(function (v) {
1704
+ r.fails = 0;
1705
+ if (v && typeof v.state === 'string') chatApplyView(r, v);
1706
+ }, function (err) {
1707
+ if (err && err.status === 404) { r.state = 'notDelivered'; r.error = 'Your Mac no longer has this plan. Check Work on the Mac.'; return; }
1708
+ r.fails = (r.fails || 0) + 1;
1709
+ }).then(function () {
1710
+ var changed = before !== JSON.stringify([r.state, r.roundState, r.states, r.inHistory]);
1711
+ // A send COS Control has not picked up for a minute is said so, even when nothing else changed.
1712
+ var late = r.state === 'sending' && r.roundState === 'send_requested' && Date.now() - (r.sentAt || Date.now()) >= 60000;
1713
+ if (late !== !!r.lateShown) { r.lateShown = late; if (!changed) chatRepaint(wid); }
1714
+ if (changed) { chatTouch(S.chat[wid]); chatReplyRefresh(wid); chatRepaint(wid); }
1715
+ if (!CHAT_MOVING[r.state]) { r.pollSince = 0; r.pollStopped = false; chatWatchReplies(wid); return; }
1716
+ // Past the bound the plan is not called stopped (the server has not said so); this page stops reading it.
1717
+ if (Date.now() - (r.pollSince || Date.now()) > CHAT_POLL.max) {
1718
+ r.pollStopped = true;
1719
+ if (r.state === 'sending') { r.state = 'quiet'; r.error = 'Still with COS Control. Check Work on the Mac, or check again here.'; }
1720
+ chatTouch(S.chat[wid]); chatRepaint(wid);
1721
+ return;
1722
+ }
1723
+ chatSchedule(wid, key);
1724
+ });
1725
+ }
1726
+ // The session's reply lands in the card's activity (the receipt COS Control made). Read it again, bounded.
1727
+ function chatReplyRefresh(wid) {
1728
+ var it = itemByWid(wid); if (!it) return Promise.resolve();
1729
+ return loadActivity(it, true).then(function () { chatRepaint(wid); });
1730
+ }
1731
+ function chatWatchReplies(wid) {
1732
+ var c = S.chat[wid]; if (!c) return;
1733
+ if (c.replyTimer) { clearTimeout(c.replyTimer); CHAT_TIMERS.delete(c.replyTimer); c.replyTimer = null; }
1734
+ var it = itemByWid(wid), rt = W.parseRoute();
1735
+ if (!it || rt.pane !== 'work' || rt.args[0] !== it.id) return;
1736
+ var waiting = c.rounds.some(function (r) {
1737
+ return r.sentAt && Date.now() - r.sentAt < CHAT_POLL.replyFor && r.assignments.some(function (a) { var st = (r.states[a.id] || {}).state; return (st === 'sent' || st === 'delivered') && !chatReport(it, r, a); });
1738
+ });
1739
+ if (!waiting) return;
1740
+ var timer = setTimeout(function () { CHAT_TIMERS.delete(timer); c.replyTimer = null; chatReplyRefresh(wid).then(function () { chatWatchReplies(wid); }); }, CHAT_POLL.reply);
1741
+ c.replyTimer = timer; CHAT_TIMERS.add(timer);
1742
+ }
1743
+ function chatStop(it, r) {
1744
+ var ids = chatStoppable(r);
1745
+ if (!r || !r.roundId || !ids.length || r.stopping) return Promise.resolve(false);
1746
+ r.stopping = true; r.error = ''; chatRepaint(it.wid);
1747
+ return api('/api/work/chat/lens-rounds/' + encodeURIComponent(r.roundId) + '/stop', { method: 'POST', body: { v: 1, assignmentIds: ids }, timeoutMs: CHAT_TIMEOUT.send })
1748
+ .then(function () { r.note2 = 'Asked COS Control to stop what has not gone yet. The rows show when it has stopped.'; chatSchedule(it.wid, r.key, 0); return true; },
1749
+ function (err) { r.error = err && err.status === 423 ? 'COS Control is not connected. Stop it from Work on the Mac.' : chatFailure(err, 'stop'); return false; })
1750
+ .then(function (ok) { r.stopping = false; chatTouch(chatOf(it.wid)); chatRepaint(it.wid); return ok; });
1751
+ }
1752
+ function chatStoppable(r) {
1753
+ if (!r || !CHAT_MOVING[r.state]) return [];
1754
+ return r.assignments.map(function (a) { return a.id; }).filter(function (id) { var st = (r.states[id] || {}).state; return !CHAT_FINAL[st]; });
1755
+ }
1756
+ // Use this request again (WorkChatRecoveryControls): the words come back for review; nothing is sent.
1757
+ // A round that never reached COS Control's Send can be removed from this browser's history. Nothing is called on the
1758
+ // server: a round never saved exists only here; a saved one stays in the card's Work chat on the Mac (no route deletes it).
1759
+ function chatRemovable(r) {
1760
+ if (!r || r.sending || CHAT_MOVING[r.state] || r.state === 'planning' || r.state === 'quiet') return false;
1761
+ // Not after a send whose answer was lost (sendUnknown): COS Control may already be sending it, and this round is the
1762
+ // page's only handle on it. sentAt is set once the server confirmed the send request.
1763
+ return !r.sentAt && !r.sendUnknown;
1764
+ }
1765
+ function chatRemove(it, r) {
1766
+ var c = chatOf(it.wid);
1767
+ if (!chatRemovable(r)) return false;
1768
+ if (r.timer) { clearTimeout(r.timer); CHAT_TIMERS.delete(r.timer); r.timer = null; }
1769
+ c.rounds = c.rounds.filter(function (x) { return x !== r; });
1770
+ (c.removed || (c.removed = {}))[r.key] = Date.now();
1771
+ chatTouch(c); chatRepaint(it.wid);
1772
+ return true;
1773
+ }
1774
+ function chatAgain(it, r) {
1775
+ var c = chatOf(it.wid);
1776
+ if (!r || c.draft.trim() || c.busy) return;
1777
+ c.draft = r.question; c.draftAt = Date.now(); chatTouch(c);
1778
+ var box = $('wk-chat-text'); if (box) { box.value = c.draft; chatGrow(box); }
1779
+ chatRepaint(it.wid);
1780
+ }
1781
+ function chatResumePolls() {
1782
+ // Rounds still on their way when this page last closed: read them again, so their outcome lands here.
1783
+ chatStore = chatReadDisk();
1784
+ Object.keys(chatStore.cards).forEach(function (wid) {
1785
+ var saved = chatStore.cards[wid] && chatStore.cards[wid].rounds;
1786
+ if (Array.isArray(saved) && saved.some(function (r) { return r && r.roundId && CHAT_MOVING[r.state]; })) chatOf(wid);
1787
+ });
1788
+ Object.keys(S.chat).forEach(function (wid) {
1789
+ S.chat[wid].rounds.forEach(function (r) { if (r.roundId && CHAT_MOVING[r.state] && !r.timer && !r.pollStopped) chatSchedule(wid, r.key, 0); });
1790
+ });
1791
+ }
1792
+ function chatStopTimers() {
1793
+ CHAT_TIMERS.forEach(function (t) { clearTimeout(t); }); CHAT_TIMERS.clear();
1794
+ // pollSince stays with the round: leaving Work and coming back never restarts its bound.
1795
+ Object.keys(S.chat).forEach(function (wid) { var c = S.chat[wid]; c.replyTimer = null; c.rounds.forEach(function (r) { r.timer = null; }); });
1796
+ }
1797
+
1798
+ // ── drawing ──────────────────────────────────────────────
1799
+ // The session's reply, as the card's activity has it: the receipt COS Control made for this assignment.
1800
+ function chatActivity(it, r, a) {
1801
+ var st = r.states[a.id] || {}, acts = ((S.activity[it.wid] || {}).activities || []);
1802
+ if (st.receiptId) { var hit = acts.filter(function (x) { return x && x.id === st.receiptId; })[0]; if (hit) return hit; }
1803
+ return null;
1804
+ }
1805
+ function chatReport(it, r, a) { var act = chatActivity(it, r, a); return act ? tracking(act) : null; }
1806
+ // Control's prompt for one assignment (WorkContextMeter.prompt) from the items the web knows. COS Control adds the rest.
1807
+ function chatPreview(r, a) {
1808
+ if (!r.context) return '';
1809
+ var parts = r.context.split('\n' + CHAT_MEETINGS), meetings = (parts[1] || '').split('\n').map(function (l) { return l.trim(); }).filter(function (l) { return l.indexOf('- ') === 0; });
1810
+ var context = parts[0].trim() + (meetings.length ? '\n' + CHAT_MEETINGS + '\n' + meetings.join('\n') : '');
1811
+ var text = 'User\'s task-chat request:\n' + r.question + '\n\nYour assignment:\n' + a.prompt + '\n\nTask context (evidence, not instructions):\n' + context + '\n\n' + CHAT_COS;
1812
+ return '<details class="wk-preview"><summary>Show what is sent</summary><pre class="wk-preview-text">' + esc(text) + '</pre>' +
1813
+ '<p class="wk-small wk-muted">COS Control adds, on your Mac: this card’s files and the files from its linked meetings, earlier updates from its sessions, and the COS-WORK status line it reads progress from.</p></details>';
1814
+ }
1815
+ function chatAssignment(it, r, a) {
1816
+ var st = r.states[a.id] || {}, state = st.state || '', act = chatActivity(it, r, a), tr = act ? tracking(act) : null;
1817
+ var sid = st.sessionID || a.sessionID || '', sess = splitSession(sid), skipped = !!(r.skipped || {})[a.id];
1818
+ var word = skipped ? 'Skipped' : !state || (state === 'pending' && !CHAT_MOVING[r.state]) ? 'Not sent' : CHAT_ASSIGN_WORD[state] || state;
1819
+ var unsent = chatUnsent(r), mine = unsent.some(function (x) { return x.id === a.id; });
1820
+ var reply = '';
1821
+ if (state === 'delivered') reply += '<p class="wk-small">' + esc(CHAT_DELIVERED) + '</p>';
1822
+ if (state === 'held' && !st.detail) reply += '<p class="wk-small wk-muted">Waits for earlier work on this card, then COS Control sends it.</p>';
1823
+ if (tr) {
1824
+ reply += '<p class="wk-status" data-tone="' + tr.tone + '">' + esc((tr.byModel ? tr.provider + ' assessment: ' : 'Session reported: ') + tr.label) + '</p>' +
1825
+ (tr.evidence ? '<p class="wk-quote">“' + esc(tr.evidence.slice(0, 280)) + '”</p>' : '');
1826
+ } else if (act && (state === 'sent' || state === 'delivered')) reply += '<p class="wk-small wk-muted">' + esc(activityTitle(act)) + '</p>';
1827
+ if (sess && (state === 'sent' || state === 'delivered')) {
1828
+ reply += '<div class="wk-row wk-wrap"><button type="button" class="wk-text-btn" data-wk="session" data-provider="' + esc(sess.provider) + '" data-sid="' + esc(sess.id) + '">' +
1829
+ esc(sess.provider === 'cursor' ? 'Open Cursor Agents' : (state === 'delivered' || tr ? 'Read the reply in ' : 'Open the session in ') + providerName(sess.provider)) + '</button></div>';
1830
+ }
1831
+ var own = mine && unsent.length > 1 && !r.confirm ? '<div class="wk-row wk-wrap"><button type="button" class="cx-btn quiet" data-wk="chat-send" data-key="' + esc(r.key) + '" data-ids="' + esc(a.id) + '"' + (r.sending ? ' disabled' : '') + '>Send</button>' +
1832
+ '<button type="button" class="wk-text-btn" data-wk="chat-skip" data-key="' + esc(r.key) + '" data-ids="' + esc(a.id) + '">Skip</button></div>' : '';
1833
+ return '<div class="wk-assign"><div class="wk-row wk-top"><strong class="wk-grow">' + esc(a.mode === 'newSession' ? a.title : (a.mode === 'fork' ? '' : 'Continue · ') + a.title) + '</strong>' +
1834
+ '<span class="wk-mono">' + esc(word) + '</span></div>' +
1835
+ '<p class="wk-small wk-pre">' + esc(a.prompt) + '</p>' + (st.detail ? '<p class="wk-small wk-muted">' + esc(st.detail) + '</p>' : '') + reply +
1836
+ (skipped ? '<p class="wk-small wk-muted">Skipped here. Nothing was sent to it.</p>' : '') + own + chatPreview(r, a) + '</div>';
1837
+ }
1838
+ // Latest updates: what was delivered, what a session reported and what is still outstanding, read from the round and
1839
+ // the card's receipts. Counted here, never written by a model.
1840
+ function chatUpdates(it, r) {
1841
+ if (!r.sentAt && !Object.keys(r.states).length) return '';
1842
+ var delivered = 0, sent = 0, held = 0, lost = 0, outstanding = 0, skipped = 0, lines = [];
1843
+ r.assignments.forEach(function (a) {
1844
+ var st = (r.states[a.id] || {}).state, tr = chatReport(it, r, a);
1845
+ if ((r.skipped || {})[a.id]) { skipped++; return; }
1846
+ if (st === 'delivered') delivered++; else if (st === 'sent') sent++; else if (st === 'held') held++; else if (st === 'failed' || st === 'refused' || st === 'stopped') lost++;
1847
+ if (tr) lines.push('“' + clip(a.title, 50) + '” reported: ' + tr.label.toLowerCase());
1848
+ if (!(tr && tr.phase === 'done') && st !== 'failed' && st !== 'refused' && st !== 'stopped') outstanding++;
1849
+ });
1850
+ var head = [];
1851
+ if (delivered) head.push('Delivered to ' + delivered + (delivered === 1 ? ' session' : ' sessions'));
1852
+ if (sent) head.push('Sent, not yet confirmed: ' + sent);
1853
+ if (held) head.push('Queued behind earlier work: ' + held);
1854
+ if (lost) head.push('Not sent: ' + lost);
1855
+ if (skipped) head.push('Skipped here: ' + skipped);
1856
+ lines.push(outstanding ? 'Still outstanding: ' + outstanding : 'Nothing outstanding');
1857
+ return '<div class="wk-updates"><h3>Latest updates</h3><ul>' + head.concat(lines).map(function (l) { return '<li>' + esc(l) + '</li>'; }).join('') + '</ul></div>';
1858
+ }
1859
+ // The round's state in plain words. "Sent" appears only once COS Control reported it sent; until then it says it waits.
1860
+ function chatRoundWord(r) {
1861
+ var reached = r.assignments.filter(function (a) { var st = (r.states[a.id] || {}).state; return st === 'sent' || st === 'delivered'; }).length;
1862
+ if (r.state === 'sending') return r.roundState === 'sending' ? 'COS Control is sending' : r.roundState === 'checking' ? 'Checking' : 'Waiting for COS Control';
1863
+ if (chatUnsent(r).length) return reached ? 'Ready to send the rest · ' + reached + ' of ' + r.assignments.length + ' sent' : 'Ready to send';
1864
+ return CHAT_ROUND_WORD[r.state] || r.state;
1865
+ }
1866
+ function chatRoundHtml(it, r) {
1867
+ var c = chatOf(it.wid), caps = S.chatCaps || {}, unsent = chatUnsent(r), planned = chatPlannerLine(r);
1868
+ var reached = r.assignments.some(function (a) { var st = (r.states[a.id] || {}).state; return st === 'sent' || st === 'delivered'; });
1869
+ var line = chatRoundWord(r) + (reached ? ' · sent from this browser' : '') + (planned ? ' · ' + planned : '');
1870
+ var actions = '';
1871
+ if (r.confirm && unsent.length) {
1872
+ var picked = r.assignments.filter(function (a) { return r.confirm.ids.indexOf(a.id) >= 0; }), early = Date.now() - r.confirm.at < CHAT_CONFIRM_MS;
1873
+ actions = '<div class="wk-confirm wk-send-confirm"><p>' + esc('Send to ' + chatNames(picked) + '?') + '</p>' +
1874
+ '<p class="wk-small wk-muted">COS Control on your Mac sends ' + (picked.length === 1 ? 'it' : 'each one') + ' with this card’s context and files. Nothing is published.</p>' +
1875
+ '<div class="wk-row wk-wrap"><button type="button" class="cx-btn primary" data-wk="chat-confirm" data-key="' + esc(r.key) + '"' + (early || r.sending ? ' disabled' : '') + '>' +
1876
+ esc(picked.length === 1 ? 'Send to ' + chatTarget(picked[0]).replace(/[“”]/g, '') : 'Send to ' + picked.length + ' sessions') + '</button>' +
1877
+ '<button type="button" class="wk-text-btn" data-wk="chat-cancel" data-key="' + esc(r.key) + '">Cancel</button></div></div>';
1878
+ } else if (unsent.length) {
1879
+ actions = '<div class="wk-row wk-wrap wk-round-actions"><button type="button" class="cx-btn primary" data-wk="chat-send" data-key="' + esc(r.key) + '" data-ids="' + esc(unsent.map(function (a) { return a.id; }).join(',')) + '"' + (r.sending ? ' disabled' : '') + '>' +
1880
+ esc(r.sending ? 'Sending…' : unsent.length > 1 ? 'Send all ' + unsent.length : 'Send') + '</button></div>' +
1881
+ '<p class="wk-fine">Review each assignment. Nothing is sent until you press Send.' + (caps.control && !caps.control.connected ? ' COS Control on your Mac is not connected right now, so the plan would wait there for you to send it from the Mac.' : '') + '</p>' +
1882
+ (r.state === 'ready' && !r.roundId && !reached ? '<p class="wk-fine">This plan stays on this browser until you send it. Send saves it to this card’s Work chat on the Mac.</p>' : '') +
1883
+ (r.state === 'ready' && r.roundId && !r.inHistory && !reached ? '<p class="wk-fine">Saved on the server. COS Control adds it to this card’s Work chat on the Mac when it next checks.</p>' : '') +
1884
+ (r.state === 'expired' ? '<p class="wk-small">COS Control did not pick it up within 10 minutes. Nothing was sent. You can send it again.</p>' : '');
1885
+ } else if (r.state === 'held') {
1886
+ actions = '<p class="wk-small">Saved in this card’s Work chat. COS Control was not connected, so send it from Work on the Mac. It cannot be sent again from here.</p>' +
1887
+ (r.pollStopped ? '<p class="wk-fine">This page stopped checking it. Check again to see whether the Mac sent it.</p><div class="wk-row"><button type="button" class="cx-btn quiet" data-wk="chat-check" data-key="' + esc(r.key) + '">Check again</button></div>' : '');
1888
+ } else if (r.state === 'quiet') {
1889
+ actions = '<div class="wk-row"><button type="button" class="cx-btn quiet" data-wk="chat-check" data-key="' + esc(r.key) + '">Check again</button></div>';
1890
+ } else if (r.state === 'planFailed' || (r.state === 'notDelivered' && !r.roundId)) {
1891
+ actions = '<div class="wk-row"><button type="button" class="cx-btn quiet" data-wk="chat-again" data-key="' + esc(r.key) + '"' + (c.draft.trim() || c.busy ? ' disabled title="Clear the message box first"' : '') + '>Use this request again</button></div>';
1892
+ }
1893
+ if (chatRemovable(r) && !r.confirm) {
1894
+ actions += '<div class="wk-row"><button type="button" class="wk-text-btn" data-wk="chat-remove" data-key="' + esc(r.key) + '">Remove</button>' +
1895
+ '<span class="wk-small wk-muted">' + esc(r.roundId ? 'Removes it from this browser. It stays in this card’s Work chat on the Mac.' : 'Never sent. Removes it from this browser.') + '</span></div>';
1896
+ }
1897
+ var stop = chatStoppable(r).length && r.roundId ? '<div class="wk-row"><button type="button" class="cx-btn quiet" data-wk="chat-stop" data-key="' + esc(r.key) + '"' + (r.stopping ? ' disabled' : '') + '>' + (r.stopping ? 'Stopping…' : 'Stop') + '</button>' +
1898
+ '<span class="wk-small wk-muted">Stops what COS Control has not sent yet.</span></div>' : '';
1899
+ var waited = Date.now() - (r.sentAt || Date.now());
1900
+ var sendingLine = r.state !== 'sending' ? '' : '<p class="wk-small wk-muted">' + esc(r.roundState === 'sending' ? 'COS Control on your Mac is sending it. This page checks every few seconds.'
1901
+ : r.roundState === 'checking' ? 'COS Control already has a send for this plan. Checking where it is…'
1902
+ : waited < 60000 ? 'Waiting for COS Control on your Mac to send this. Nothing has been sent yet.'
1903
+ : 'Waiting for COS Control on your Mac to send this. It has not picked it up yet: the Mac may be asleep or COS Control closed. The request waits 10 minutes, and nothing is sent until COS Control takes it.') + '</p>';
1904
+ return '<article class="wk-round" data-key="' + esc(r.key) + '">' +
1905
+ '<p class="wk-round-q">' + esc(r.question) + '</p>' +
1906
+ '<p class="wk-fine wk-round-from">' + esc('From this browser · ' + chatWhen(r.at) + (r.inHistory ? ' · in this card’s Work chat on the Mac' : '')) + '</p>' +
1907
+ (r.note ? '<p class="wk-small wk-muted">' + esc(r.note) + '</p>' : '') +
1908
+ (r.answer ? '<div class="cx-card-body md wk-round-answer">' + W.md(r.answer) + '</div>' : '') +
1909
+ (r.assignments.length && r.meter ? '<p class="wk-small wk-muted">' + esc(chatMeterText(r.meter)) + '</p>' : '') +
1910
+ r.assignments.map(function (a) { return chatAssignment(it, r, a); }).join('') +
1911
+ chatUpdates(it, r) +
1912
+ (r.error ? '<p class="wk-error">' + esc(r.error) + '</p>' : '') + (r.note2 && CHAT_MOVING[r.state] ? '<p class="wk-small wk-muted">' + esc(r.note2) + '</p>' : '') +
1913
+ sendingLine + '<p class="wk-fine wk-round-state">' + esc(line) + '</p>' + actions + stop + '</article>';
1914
+ }
1915
+ function chatHistory(it) {
1916
+ var c = chatOf(it.wid), caps = S.chatCaps;
1917
+ var rounds = c.rounds.map(function (r) { return chatRoundHtml(it, r); }).join('');
1918
+ var reason = !caps ? '<p class="wk-muted">Checking Work chat on your Mac…</p>' : !caps.ok ? '<div class="cx-notice' + (caps.retry ? ' warn' : '') + '"><span>' + esc(caps.reason) +
1919
+ (c.draft.trim() ? ' Your draft is kept on this browser.' : '') + '</span>' + (caps.retry ? '<button type="button" class="wk-text-btn" data-wk="chat-caps">Try again</button>' : '') + '</div>' : '';
1920
+ return rounds + reason;
1921
+ }
1922
+ function chatOffered() { return !!(S.chatCaps && S.chatCaps.ok); }
1923
+ function chatComposerHtml(it) {
1924
+ var nav = window.navigator || {}, c = chatOf(it.wid), mac = /Mac|iPhone|iPad/.test(nav.platform || nav.userAgent || '');
1925
+ var fine = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches;
1926
+ return '<div class="wk-cmp" id="wk-composer" data-wid="' + esc(it.wid) + '">' +
1927
+ '<div class="wk-cmp-status" id="wk-cmp-status" aria-live="polite"></div>' +
1928
+ '<p class="wk-small wk-cmp-files"><strong>Files to send.</strong> COS Control attaches this card’s files and the files from its linked meetings on your Mac when it sends. Add or remove them on this card in COS Control.</p>' +
1929
+ '<div class="wk-cmp-picks" id="wk-cmp-picks"></div>' +
1930
+ '<textarea id="wk-chat-text" class="field wk-cmp-text" rows="3" aria-label="Message this task" aria-describedby="wk-cmp-count" placeholder="Describe the outcome you want" autocomplete="off" autocapitalize="sentences" spellcheck="true" enterkeyhint="enter">' + esc(c.draft) + '</textarea>' +
1931
+ '<div class="wk-cmp-actions" id="wk-cmp-actions"><span class="wk-mono wk-cmp-count" id="wk-cmp-count"></span>' +
1932
+ '<span class="wk-small wk-muted wk-cmp-size" id="wk-cmp-size"></span>' +
1933
+ (fine ? '<span class="wk-fine wk-cmp-hint">' + (mac ? '⌘' : 'Ctrl') + ' Return to plan · Return adds a line</span>' : '') +
1934
+ '<button type="button" class="cx-btn primary wk-cmp-plan" id="wk-cmp-plan" data-wk="chat-plan">Plan</button></div></div>';
1935
+ }
1936
+ function chatPicksHtml() {
1937
+ var caps = S.chatCaps || {}, p = chatPlanner(), e = chatEffort();
1938
+ if (!(caps.planners || []).length) return '<p class="wk-fine">This server plans with Sonnet.</p>';
1939
+ return '<label class="wk-pick"><span>Plan with</span><select class="field" data-wk-chat="planner">' +
1940
+ caps.planners.map(function (x) { return '<option value="' + x + '"' + (x === p ? ' selected' : '') + '>' + plannerName(x) + '</option>'; }).join('') + '</select></label>' +
1941
+ ((caps.efforts || []).length ? '<label class="wk-pick"><span>Effort</span><select class="field" data-wk-chat="effort">' +
1942
+ caps.efforts.map(function (x) { return '<option value="' + x + '"' + (x === e ? ' selected' : '') + '>' + x.charAt(0).toUpperCase() + x.slice(1) + '</option>'; }).join('') + '</select></label>' : '');
1943
+ }
1944
+ function chatSectionHtml(it) {
1945
+ return '<section class="wk-section wk-chat" id="wk-chat" aria-labelledby="wk-chat-h"><h2 class="wk-h2" id="wk-chat-h">Work chat</h2>' +
1946
+ '<p class="wk-muted">Describe the outcome. COS explains its plan and drafts assignments for this card’s sessions. You review the plan, then send it; COS Control on your Mac sends each one and their updates come back here.</p>' +
1947
+ '<div id="wk-part-chat" class="wk-part wk-rounds">' + chatHistory(it) + '</div>' + (chatOffered() ? chatComposerHtml(it) : '') + '</section>';
1948
+ }
1949
+ // The live bits of the composer, set in place so the text box (and the phone's keyboard) is never replaced.
1950
+ function chatSyncComposer(it) {
1951
+ var box = $('wk-chat-text'); if (!box) return;
1952
+ var c = chatOf(it.wid), n = box.value.length, problem = chatProblem(it, c);
1953
+ var count = $('wk-cmp-count'); if (count) { count.textContent = n.toLocaleString('en-US') + '/' + CHAT_MAX_TEXT; count.classList.toggle('over', box.value.trim().length > CHAT_MAX); }
1954
+ var size = $('wk-cmp-size');
1955
+ if (size) { var text = box.value.trim(), wid = chatWorkId(it.task); size.textContent = text && wid ? 'Sends ' + chatTokenText(chatMeter(text, chatContext(it.task, wid), wid).tokens) + ' tokens of text' : ''; }
1956
+ var plan = $('wk-cmp-plan');
1957
+ if (plan) { plan.disabled = c.busy || !box.value.trim() || !!problem; plan.textContent = c.busy ? 'Planning…' : 'Plan'; }
1958
+ var picks = $('wk-cmp-picks');
1959
+ if (picks && !picks.contains(document.activeElement)) { var html = chatPicksHtml(); if (picks.__html !== html) { picks.innerHTML = html; picks.__html = html; } }
1960
+ if (picks) picks.querySelectorAll('select').forEach(function (s) { s.disabled = c.busy; });
1961
+ var status = $('wk-cmp-status');
1962
+ if (status) {
1963
+ var caps = S.chatCaps || {}, parts = [];
1964
+ if (c.busy && c.phase) parts.push('<p class="wk-cmp-phase"><span class="wk-spin" aria-hidden="true"></span>' + esc(c.phase) + '</p>');
1965
+ if (c.error) parts.push('<p class="wk-error">' + esc(c.error) + '</p>');
1966
+ else if (problem && box.value.trim()) parts.push('<p class="wk-small wk-muted">' + esc(problem) + '</p>');
1967
+ if (caps.ok && caps.control && !caps.control.connected && !c.busy) parts.push('<p class="wk-small wk-warn">COS Control on your Mac is not connected right now. You can plan; the plan waits on the Mac until COS Control sends it.</p>');
1968
+ var html2 = parts.join('');
1969
+ if (status.__html !== html2) { status.innerHTML = html2; status.__html = html2; }
1970
+ }
1971
+ chatGrow(box);
1972
+ }
1973
+ function chatGrow(box) {
1974
+ var vv = window.visualViewport, h = vv ? vv.height : window.innerHeight || 600;
1975
+ var max = Math.max(96, Math.min(320, Math.round(h * 0.42)));
1976
+ box.style.height = 'auto';
1977
+ box.style.height = Math.min(box.scrollHeight + 2, max) + 'px';
1978
+ box.style.overflowY = box.scrollHeight + 2 > max ? 'auto' : 'hidden';
1979
+ }
1980
+ function chatResetComposer(wid) {
1981
+ var box = $('wk-chat-text'), host = $('wk-composer');
1982
+ if (box && host && host.getAttribute('data-wid') === wid) { box.value = chatOf(wid).draft; box.blur(); }
1983
+ }
1984
+ // With the on-screen keyboard up, keep the text box and the Plan row inside what is visible: scroll only as much as
1985
+ // needed, never past the top of the box. On a folded or unfolded Duo the visual viewport tells us what is visible.
1986
+ function chatKeepInView() {
1987
+ var box = $('wk-chat-text'), row = $('wk-cmp-actions'), vv = window.visualViewport;
1988
+ if (!box || !row || document.activeElement !== box) return;
1989
+ var top = vv ? vv.offsetTop : 0, height = vv ? vv.height : window.innerHeight;
1990
+ var bar = document.querySelector('.topbar'), barBottom = bar ? bar.getBoundingClientRect().bottom : 0;
1991
+ var visTop = Math.max(top, barBottom > top ? barBottom : top) + 6, visBottom = top + height - 8;
1992
+ var boxTop = box.getBoundingClientRect().top, rowBottom = row.getBoundingClientRect().bottom, delta = 0;
1993
+ if (rowBottom > visBottom) delta = rowBottom - visBottom;
1994
+ if (boxTop - delta < visTop) delta = boxTop - visTop;
1995
+ if (Math.abs(delta) > 2) window.scrollBy(0, delta);
1996
+ }
1997
+ function chatComposerFocused() { var a = document.activeElement; return !!(a && a.id === 'wk-chat-text'); }
1998
+ function chatRepaintCurrent() { var rt = W.parseRoute(); if (rt.pane === 'work' && rt.args.length === 1) renderDetail(rt.args[0]); }
1999
+ function chatRepaint(wid) {
2000
+ var rt = W.parseRoute(), it = itemByWid(wid);
2001
+ if (it && rt.pane === 'work' && rt.args.length === 1 && rt.args[0] === it.id) renderDetail(it.id);
2002
+ }
2003
+ function chatSetPart(id, html) { var el = $(id); if (el && el.__html !== html) { el.innerHTML = html; el.__html = html; } }
2004
+
2005
+ // ── Link a meeting: Control's meeting picker (MeetingLibraryBody, selection only) and POST /api/work-board/meeting ──
2006
+ var LINK_REFUSALS = { meeting_identity_changed: 'That meeting changed since the list loaded. Refresh the list and pick it again.',
2007
+ meeting_identity_required: 'Refresh the list and pick the exact saved meeting.', invalid_meeting: 'Pick a saved meeting from the list.',
2008
+ work_board_read_only: 'This server can’t write Work links yet.', revision_changed: 'This card changed. Refresh Work, then link the meeting.',
2009
+ task_revision_changed: 'This card changed. Refresh Work, then link the meeting.', meeting_links_full: 'This card already links eight meetings.' };
2010
+ function linkMeetingProblem(t) {
2011
+ if (!(S.caps.version === 1 && S.caps.writable === true)) return 'This server can’t write Work links yet.';
2012
+ if (t.workMetadataError) return t.workMetadataError;
2013
+ if (!/^[a-f0-9]{64}$/.test(str(t.workRevision))) return 'This card has no saved revision. Refresh Work first.';
2014
+ return '';
2015
+ }
2016
+ function thisMonth() { var d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); }
2017
+ function loadPickerMeetings() {
2018
+ var lm = S.linkMeeting; if (!lm) return;
2019
+ lm.loading = true; lm.error = '';
2020
+ var month = lm.month, seq = (lm.seq = (lm.seq || 0) + 1);
2021
+ api('/api/meetings?limit=200&domain=all&month=' + encodeURIComponent(month)).then(function (d) {
2022
+ if (S.linkMeeting !== lm || lm.seq !== seq) return;
2023
+ lm.loading = false;
2024
+ lm.list = ((d && d.meetings) || []).filter(function (m) { return m && m.domain && m.month && m.filename && m.recordId; });
2025
+ }, function (err) { if (S.linkMeeting !== lm || lm.seq !== seq) return; lm.loading = false; lm.list = []; lm.error = errText(err); })
2026
+ .then(function () { var it = itemByWid(lm.wid); if (it) renderDetail(it.id); });
2027
+ }
2028
+ function meetingPicker(it) {
2029
+ var lm = S.linkMeeting, linked = {};
2030
+ (it.task.meetingRefs || []).forEach(function (m) { if (m && m.recordId) linked[m.recordId] = 1; });
2031
+ var words = queryWords(lm.q || ''), list = (lm.list || []).filter(function (m) {
2032
+ if (!words.length) return true;
2033
+ var toks = tokens([m.title, m.domain, m.date || m.month].join(' '));
2034
+ return words.every(function (w) { return found(w, toks); });
2035
+ });
2036
+ var rows = list.slice(0, 60).map(function (m) {
2037
+ var on = linked[m.recordId];
2038
+ return '<div class="wk-pick-row"><div class="wk-grow"><strong class="wk-clamp2">' + esc(m.title || 'Saved meeting') + '</strong>' +
2039
+ '<span class="wk-small wk-muted">' + esc([domainName(m.domain), str(m.date) || str(m.month)].filter(Boolean).join(' · ')) + '</span></div>' +
2040
+ (on ? '<span class="wk-small wk-muted">Linked</span>' : '<button type="button" class="cx-btn quiet" data-wk="link-meeting-pick" data-rid="' + esc(m.recordId) + '"' + (lm.busy ? ' disabled' : '') + '>Link</button>') + '</div>';
2041
+ }).join('');
2042
+ return '<div class="wk-picker"><p class="wk-small wk-muted">Choose a saved meeting. Linking does not start an agent.</p>' +
2043
+ '<div class="wk-row wk-wrap"><input class="field wk-grow" type="search" id="wk-link-q" placeholder="Find a meeting by title" value="' + esc(lm.q || '') + '" aria-label="Find a meeting by title">' +
2044
+ '<label class="wk-pick"><span>Month</span><input class="field" type="month" id="wk-link-month" value="' + esc(lm.month) + '"></label></div>' +
2045
+ (lm.error ? '<p class="wk-error">' + esc(lm.error) + '</p>' : '') +
2046
+ (lm.loading ? '<p class="wk-muted">Loading meetings…</p>' : rows ? '<div class="wk-pick-list">' + rows + '</div>' : '<p class="wk-muted">No saved meetings here. Try another month.</p>') + '</div>';
2047
+ }
2048
+ function linkMeeting(it, recordId) {
2049
+ var lm = S.linkMeeting, t = it.task, m = lm && (lm.list || []).filter(function (x) { return x.recordId === recordId; })[0];
2050
+ if (!m || lm.busy || linkMeetingProblem(t)) return;
2051
+ lm.busy = true; lm.error = ''; renderDetail(it.id);
2052
+ api('/api/work-board/meeting', { method: 'POST', body: { domain: t.domain, id: t.id, expectedText: titleOf(t), expectedRevision: str(t.workRevision),
2053
+ meeting: { domain: m.domain, month: m.month, filename: m.filename, recordId: m.recordId } } }).then(function () {
2054
+ S.linkMeeting = null; W.toast('Linked “' + clip(m.title || 'Saved meeting', 50) + '” to this card.');
2055
+ return loadBoard(true);
2056
+ }, function (err) { lm.busy = false; lm.error = errText(err, LINK_REFUSALS); renderDetail(it.id); });
2057
+ }
2058
+
1121
2059
  // ── Start work: a handoff request COS Control on the Mac claims and sends (glasses path, 6.59.0) ──
1122
2060
  var FORKABLE = { claude: 1, codex: 1 };
1123
2061
  // The sessions Start work can continue or fork: this card's own, plus the saved destination's session when it names one
@@ -1323,7 +2261,7 @@
1323
2261
  function moveStage(it, stage) {
1324
2262
  var t = it.task;
1325
2263
  if (!canChangeStage(t) || stageOf(t) === stage || STAGES.indexOf(stage) < 0) { render(); return; }
1326
- if (stage === 'complete' && S.pendingComplete !== it.wid) { S.pendingComplete = it.wid; render(); return; }
2264
+ if (stage === 'complete' && S.pendingComplete !== it.wid) { S.pendingComplete = it.wid; S.completeHere = false; render(); return; }
1327
2265
  S.pendingComplete = null;
1328
2266
  afterWrite(api('/api/work-board/stage', { method: 'POST', body: { domain: t.domain, id: t.id, expectedText: titleOf(t), expectedRevision: str(t.workRevision), workStage: stage } }));
1329
2267
  }
@@ -1461,6 +2399,8 @@
1461
2399
  if (S.moreOpen && !e.target.closest('.wk-more')) { S.moreOpen = false; if (!t) { renderList(); return; } }
1462
2400
  if (!t || t.disabled) return;
1463
2401
  var act = t.getAttribute('data-wk'), id = t.getAttribute('data-id'), it;
2402
+ // A tap on the card's own controls first lets go of the Work chat box, so its repaint is not held for the keyboard.
2403
+ if (act !== 'chat-plan' && chatComposerFocused()) { var held = $('wk-chat-text'); if (held) held.blur(); S.chatDeferred = false; }
1464
2404
  switch (act) {
1465
2405
  case 'refresh': return refreshAll();
1466
2406
  case 'capture-toggle':
@@ -1505,7 +2445,7 @@
1505
2445
  if (matches.length === 1) return W.go('work', [matches[0].id]);
1506
2446
  return renderList();
1507
2447
  case 'complete-yes': it = itemFor(id); if (it) moveStage(it, 'complete'); return;
1508
- case 'complete-no': S.pendingComplete = null; return render();
2448
+ case 'complete-no': S.pendingComplete = null; S.completeHere = false; return render();
1509
2449
  case 'copy-id': case 'copy-context':
1510
2450
  it = itemFor(id); if (!it || !canExport(it.task)) return;
1511
2451
  W.copyText(act === 'copy-id' ? workId(it.task) : contextText(it.task)).then(function () {
@@ -1520,6 +2460,38 @@
1520
2460
  it = itemFor(id); if (it && S.reply[it.wid]) { S.reply[it.wid].open = act === 'reply-open'; renderDetail(it.id); } return;
1521
2461
  case 'reply-send': it = itemFor(id); if (it) sendReply(it, t.getAttribute('data-intent')); return;
1522
2462
  case 'reply-reset': it = itemFor(id); if (it) { delete S.reply[it.wid]; delete S.activity[it.wid]; renderDetail(it.id); } return;
2463
+ case 'chat-plan':
2464
+ it = current(); if (!it) return;
2465
+ var draftBox = $('wk-chat-text'); if (draftBox) { chatOf(it.wid).draft = draftBox.value; draftBox.blur(); }
2466
+ chatPlan(it); return;
2467
+ case 'chat-send': case 'chat-confirm': case 'chat-cancel': case 'chat-skip': case 'chat-stop': case 'chat-again': case 'chat-check': case 'chat-remove':
2468
+ it = current(); var round = it && chatFindRound(it.wid, t.getAttribute('data-key')); if (!round) return;
2469
+ var ids = String(t.getAttribute('data-ids') || '').split(',').filter(Boolean);
2470
+ if (act === 'chat-send') chatAskSend(it, round, ids);
2471
+ else if (act === 'chat-confirm') chatConfirmSend(it, round);
2472
+ else if (act === 'chat-cancel') { round.confirm = null; renderDetail(it.id); }
2473
+ else if (act === 'chat-skip') { ids.forEach(function (x) { round.skipped[x] = true; }); round.confirm = null; chatTouch(chatOf(it.wid)); renderDetail(it.id); }
2474
+ else if (act === 'chat-stop') chatStop(it, round);
2475
+ else if (act === 'chat-again') chatAgain(it, round);
2476
+ else if (act === 'chat-remove') chatRemove(it, round);
2477
+ else { if (round.state === 'quiet') round.state = 'sending'; round.error = ''; round.pollSince = 0; round.pollStopped = false; chatSchedule(it.wid, round.key, 0); renderDetail(it.id); }
2478
+ return;
2479
+ case 'chat-caps': loadChatCaps(true); return;
2480
+ case 'show-workspace': var ws = $('wk-agent-workspace'); if (ws && ws.scrollIntoView) ws.scrollIntoView({ block: 'start', behavior: 'smooth' }); return;
2481
+ case 'inspect': case 'inspect-clear':
2482
+ it = itemFor(id); if (!it) return;
2483
+ if (act === 'inspect') S.inspect[it.wid] = t.getAttribute('data-sid'); else delete S.inspect[it.wid];
2484
+ renderDetail(it.id);
2485
+ var wsp = $('wk-agent-workspace'); if (act === 'inspect' && wsp && wsp.scrollIntoView) wsp.scrollIntoView({ block: 'start', behavior: 'smooth' });
2486
+ return;
2487
+ case 'complete-here': it = itemFor(id); if (it) { S.pendingComplete = it.wid; S.completeHere = true; renderDetail(it.id); } return;
2488
+ case 'link-meeting':
2489
+ it = itemFor(id); if (!it) return;
2490
+ if (S.linkMeeting && S.linkMeeting.wid === it.wid) { S.linkMeeting = null; return renderDetail(it.id); }
2491
+ S.linkMeeting = { wid: it.wid, month: thisMonth(), q: '', list: null };
2492
+ loadPickerMeetings(); renderDetail(it.id);
2493
+ return;
2494
+ case 'link-meeting-pick': it = current(); if (it) linkMeeting(it, t.getAttribute('data-rid')); return;
1523
2495
  case 'edit-save': it = current(); if (it) editSave(it); return;
1524
2496
  case 'edit-cancel':
1525
2497
  it = current(); if (!it) return W.goBack();
@@ -1568,7 +2540,13 @@
1568
2540
  return renderList();
1569
2541
  }
1570
2542
  if (el.hasAttribute('data-wk-stage')) { it = itemFor(el.getAttribute('data-wk-stage')); if (it) moveStage(it, el.value); return; }
2543
+ if (el.hasAttribute('data-wk-chat')) {
2544
+ chatStore[el.getAttribute('data-wk-chat') === 'effort' ? 'effort' : 'planner'] = el.value; chatStore.prefsAt = Date.now(); chatSave(true);
2545
+ it = current(); if (it) chatSyncComposer(it);
2546
+ return;
2547
+ }
1571
2548
  if (el.id === 'wk-capture-domain') { S.captureDomain = el.value; return renderList(); }
2549
+ if (el.id === 'wk-link-month' && S.linkMeeting) { if (/^\d{4}-\d{2}$/.test(el.value)) { S.linkMeeting.month = el.value; loadPickerMeetings(); it = current(); if (it) renderDetail(it.id); } return; }
1572
2550
  if (el.name === 'wk-mode' || el.hasAttribute('data-wk-start')) {
1573
2551
  it = current(); if (!it) return;
1574
2552
  var st = startState(it);
@@ -1596,6 +2574,17 @@
1596
2574
  if (b) b.disabled = !(S.captureText.trim() && domainNames().indexOf(S.captureDomain) >= 0 && !S.captureBusy);
1597
2575
  return;
1598
2576
  }
2577
+ if (el.id === 'wk-link-q' && S.linkMeeting) {
2578
+ S.linkMeeting.q = el.value;
2579
+ clearTimeout(typing); typing = setTimeout(function () { var cur = current(); if (cur) { renderDetail(cur.id); var q = $('wk-link-q'); if (q) { q.focus(); try { q.setSelectionRange(q.value.length, q.value.length); } catch (_e) {} } } }, 200);
2580
+ return;
2581
+ }
2582
+ if (el.id === 'wk-chat-text') {
2583
+ it = current(); if (!it) return;
2584
+ var cc = chatOf(it.wid); cc.draft = el.value; cc.draftAt = Date.now(); if (cc.error) cc.error = '';
2585
+ chatTouch(cc); chatSyncComposer(it); chatKeepInView();
2586
+ return;
2587
+ }
1599
2588
  if (el.hasAttribute('data-wk-reply')) {
1600
2589
  it = itemFor(el.getAttribute('data-wk-reply')); var rr = it && S.reply[it.wid]; if (!rr) return;
1601
2590
  rr.text = el.value;
@@ -1616,6 +2605,25 @@
1616
2605
  if (save) save.disabled = !(!ed.busy && !editUnavailable(it.task) && !editValidation(ed) && ed.dirty);
1617
2606
  }
1618
2607
  });
2608
+ // Work chat: Command or Control Return plans (Control's shortcut); Return adds a line.
2609
+ pane.addEventListener('keydown', function (e) {
2610
+ if (e.target && e.target.id === 'wk-chat-text' && e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.isComposing) {
2611
+ e.preventDefault();
2612
+ var plan = $('wk-cmp-plan'); if (plan && !plan.disabled) plan.click();
2613
+ }
2614
+ });
2615
+ pane.addEventListener('focusin', function (e) { if (e.target && e.target.id === 'wk-chat-text') setTimeout(chatKeepInView, 320); });
2616
+ pane.addEventListener('focusout', function (e) {
2617
+ if (!e.target || e.target.id !== 'wk-chat-text') return;
2618
+ chatSave(true);
2619
+ if (S.chatDeferred) setTimeout(function () { if (!chatComposerFocused()) chatRepaintCurrent(); }, 0);
2620
+ });
2621
+ // The keyboard opening or closing, and the Duo folding or unfolding, change what is visible: keep the box in view and
2622
+ // the draft saved. The draft is in this browser's storage, so a reload or a page Safari dropped comes back with it.
2623
+ if (window.visualViewport) window.visualViewport.addEventListener('resize', function () { var b = $('wk-chat-text'); if (b) { chatGrow(b); chatKeepInView(); } });
2624
+ window.addEventListener('resize', function () { var b = $('wk-chat-text'); if (b) { chatSave(true); chatGrow(b); } });
2625
+ window.addEventListener('pagehide', function () { chatSave(true); });
2626
+ document.addEventListener('visibilitychange', function () { if (document.hidden) chatSave(true); });
1619
2627
  pane.addEventListener('submit', function (e) {
1620
2628
  var f = e.target.getAttribute('data-wk-form');
1621
2629
  if (!f) return;
@@ -1654,14 +2662,25 @@
1654
2662
  return;
1655
2663
  }
1656
2664
  startPoll(); resumeHandoffPolls();
2665
+ // After Today decided a suggestion (intakeChanged), the board and Intake are read again on any route, a card
2666
+ // included, and the read starts before the first paint, so a new card waits for it instead of reading as gone.
2667
+ if (S.reloadNext && !S.loading) { S.reloadNext = false; loadBoard(); }
1657
2668
  render(); // one paint now; loads that finish later go through backgroundPaint
1658
2669
  if (!S.tasks && !S.loading) loadBoard();
1659
2670
  else if (!args.length && Date.now() - S.loadedAt > 60000 && !S.loading) loadBoard();
1660
2671
  },
1661
- leave: function () { stopPoll(); stopHandoffPolls(); },
2672
+ leave: function () { stopPoll(); stopHandoffPolls(); chatSave(true); },
2673
+ // For web-control/work-composer-web.test.ts: Work chat's steps, run against a fake server.
2674
+ _chat: { S: S, of: chatOf, plan: chatPlan, send: chatSend, stop: chatStop, again: chatAgain, poll: chatPoll, caps: loadChatCaps, readCaps: chatReadCaps,
2675
+ find: chatFindRound, planBody: chatPlanBody, context: chatContext, meter: chatMeter, readPlan: chatReadPlan, readInput: chatReadInput,
2676
+ store: function () { return chatStore; }, save: chatSave, timers: CHAT_TIMERS, render: renderDetail, restore: chatRestored, item: itemFor,
2677
+ mergeIn: function () { chatMergeIn(chatReadDisk()); }, limits: { questionChars: CHAT_MAX, pollMaxMs: CHAT_POLL.max } },
1662
2678
  // The Ask sheet's "Find a task about this": open the board on this query and ask by meaning.
1663
2679
  search: function (q) { S.q = String(q || ''); S.tab = 'board'; render(); searchMeaning(); },
1664
2680
  // Back unwinds one step: Start work and Edit go to the card, the card to the list.
1665
- up: function (args) { return args.length >= 2 ? [args[0]] : null; }
2681
+ up: function (args) { return args.length >= 2 ? [args[0]] : null; },
2682
+ // Today's overlay (panes/today-actions.js) decided a suggestion through this pane's route. The next time Work opens,
2683
+ // on the list, Sort or a card (Today's "Open card"), load() reads the board again, and Intake after it.
2684
+ intakeChanged: function () { S.intake = null; SPLIT = null; S.reloadNext = true; }
1666
2685
  });
1667
2686
  })();