@gotcos/glasses-server 6.99.33 → 7.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
@@ -33,9 +33,56 @@
33
33
  var PROVIDER_NAME = { claude: 'Claude', codex: 'Codex', cursor: 'Cursor', ollama: 'Local model' };
34
34
  var MODELS = [['', 'Server default'], ['opus', 'Claude Opus'], ['fable', 'Claude Fable'], ['sonnet', 'Claude Sonnet'],
35
35
  ['codex-frontier', 'Codex frontier'], ['codex-balanced', 'Codex balanced']];
36
+ // ── The models a New session may start on (Q1 P1, P2): COS Control's own catalog rule, ported as is from
37
+ // HelperSources/main.swift workModelCatalog (b02d1ad) over the same two reads, GET /api/health and GET /api/models.
38
+ // A slot is available only with durable jobs on, its provider ready and one unambiguous catalog entry (Claude slots
39
+ // are aliases and need no entry). Read once a minute at most; null when either read failed.
40
+ var WM_SLOTS = [['sonnet', 'claude', 'Claude Sonnet'], ['opus', 'claude', 'Claude Opus'], ['fable', 'claude', 'Claude Fable'], ['haiku', 'claude', 'Claude Haiku'],
41
+ ['codex-frontier', 'codex', 'OpenAI via Codex · Frontier'], ['codex-balanced', 'codex', 'OpenAI via Codex · Balanced'],
42
+ ['cursor-grok', 'cursor', 'Grok via Cursor'], ['cursor-composer', 'cursor', 'Cursor Composer'], ['ollama', 'ollama', 'Ollama']];
43
+ var WM = { models: null, at: 0, loading: null, failed: false };
44
+ function workModelCatalog(health, catalog) {
45
+ var f = (health && health.features) || {}, c = catalog || {};
46
+ var durable = f.durableQueryJobs === true && f.durableQueryJobsProtocol === 1;
47
+ var options = Array.isArray(c.options) ? c.options : [];
48
+ return WM_SLOTS.map(function (slot) {
49
+ var matches = options.filter(function (o) { return o && o.preference === slot[0]; });
50
+ var concrete = matches.length === 1 && typeof matches[0].id === 'string' ? matches[0].id : null;
51
+ var catalogValid = slot[1] === 'claude' || !!(concrete && concrete.length <= 160);
52
+ var providerReady = f[slot[1]] === true && (slot[1] !== 'cursor' || c.cursorReady === true) && (slot[1] !== 'ollama' || c.ollamaReady === true);
53
+ return { id: slot[0], provider: slot[1], title: slot[2], available: !!(durable && providerReady && catalogValid) };
54
+ });
55
+ }
56
+ function loadWorkModels(force) {
57
+ if (WM.loading) return WM.loading;
58
+ if (!force && WM.at && Date.now() - WM.at < 60000) return Promise.resolve(WM.models);
59
+ WM.loading = Promise.all([api('/api/health'), api('/api/models')]).then(function (both) {
60
+ WM.models = workModelCatalog(both[0], both[1]); WM.failed = false;
61
+ }, function () { WM.models = null; WM.failed = true; }).then(function () { WM.at = Date.now(); WM.loading = null; return WM.models; });
62
+ return WM.loading;
63
+ }
64
+ // WorkOrchestrator.preferredModel: Claude's Opus when it is available, else the provider's first available model.
65
+ function preferredModel(provider, models) {
66
+ var open = (models || []).filter(function (m) { return m.provider === provider && m.available; }).map(function (m) { return m.id; });
67
+ if (provider === 'claude') { var opus = open.indexOf('opus') >= 0 ? 'opus' : open.filter(function (id) { return /opus/i.test(id); })[0]; if (opus) return opus; }
68
+ return open[0] || '';
69
+ }
70
+ // WorkHandoffStore.defaultDraft: a card with no session of its own starts on Claude (Opus), else Codex, whichever has
71
+ // an available model first.
72
+ function defaultNewModel(models) {
73
+ var provider = ['claude', 'codex'].filter(function (p) { return (models || []).some(function (m) { return m.provider === p && m.available; }); })[0];
74
+ return provider ? preferredModel(provider, models) : '';
75
+ }
76
+ // WorkOrchestrator.newSessionProviders: a provider with an available model whose plan usage is not used up.
77
+ function newSessionProviders(models, usage) {
78
+ var out = {}; (usage || []).forEach(function (u) { if (u.state === 'exhausted') out[u.provider] = 1; });
79
+ return ['claude', 'codex'].filter(function (p) { return !out[p] && (models || []).some(function (m) { return m.provider === p && m.available; }); });
80
+ }
36
81
  var MODEL_PROVIDER = { opus: 'claude', fable: 'claude', sonnet: 'claude', haiku: 'claude', 'codex-frontier': 'codex', 'codex-balanced': 'codex' };
37
82
  var HELPER = "'/Applications/COS Control.app/Contents/Resources/cos-control-helper'";
38
- var UNDO_MISSING = 'Undo needs a newer COS server. To change a card, open it in Work.';
83
+ // W1 (7.0.x): no COS server serves the undo or batch routes yet (audit 02 F6), so Undo, Keep and Drop are offered only
84
+ // where the server answers for them, and nothing on the page promises a newer server.
85
+ var UNDO_FAILED = 'Undo did not go through. To change a card, open it in Work.';
39
86
  var INTAKE_REFUSALS = {
40
87
  meeting_identity_changed: 'That meeting was renamed or merged after it was scored. Dismiss this item; the renamed meeting is checked again.',
41
88
  meeting_unavailable: 'That meeting can’t be found; it may have been renamed or removed. Dismiss this item.',
@@ -51,13 +98,21 @@
51
98
  intake_store_closed: 'Intake is unavailable while the server restarts. Try again shortly.',
52
99
  work_intake_unavailable: 'Intake is unavailable right now. Try again shortly.' };
53
100
  var REQUEST_LINE = {
54
- pending: 'Waiting for COS Control on your Mac to pick this up. Nothing has been sent yet.',
101
+ pending: 'Waiting for COS Control on your Mac to pick this up. COS Control has to be open on the Mac. Nothing has been sent yet.',
55
102
  claimed: 'COS Control picked it up and is sending it.',
56
103
  sent: 'Sent. The session has the card’s context.',
57
104
  refused: 'COS Control did not send it.',
58
105
  unresolved: 'COS Control sent it but could not confirm it arrived. Check the session before sending again.',
59
106
  unconfirmed: 'COS Control took it and has not said whether it was sent. Check the session before sending again.',
60
- expired: 'COS Control did not pick it up in time. Nothing was sent.' };
107
+ expired: 'COS Control on your Mac did not pick this up within 10 minutes, so nothing was sent. Open COS Control on the Mac, then send it again.' };
108
+ // W1 (audit 02 F8): a request from this page only goes through when COS Control on the Mac picks it up, within 10 minutes
109
+ // of when it was made (lib/work-handoff-requests.ts pendingMs). While it waits, the line says how long is left.
110
+ function requestLine(req) {
111
+ var line = REQUEST_LINE[req.state] || req.state;
112
+ if (req.state !== 'pending') return line;
113
+ var until = W.toDate(req.expiresAt), left = until ? Math.ceil((until.getTime() - Date.now()) / 60000) : 0;
114
+ return left > 0 ? line + ' It waits ' + (left === 1 ? '1 more minute.' : left + ' more minutes.') : line;
115
+ }
61
116
  var STOP = {};
62
117
  ('a an and are as at be by for from in into is me my of on or our so that the their this to up was we with you your').split(' ').forEach(function (w) { STOP[w] = 1; });
63
118
 
@@ -831,8 +886,7 @@
831
886
  if (S.sortIndex > n - 1) S.sortIndex = Math.max(0, n - 1);
832
887
  var g = n ? groups[S.sortIndex] : null, batch = S.caps.workBatch === 1 && S.caps.writable === true;
833
888
  S.sortGroupId = g ? g.id : '';
834
- var undo = S.undoBatch ? (S.loopAvailable === true ? '<button type="button" class="wk-text-btn" data-wk="undo">Undo last decision</button>'
835
- : S.loopAvailable === false ? '<span class="wk-muted">' + esc(UNDO_MISSING) + '</span>' : '') : '';
889
+ var undo = S.undoBatch && S.loopAvailable === true ? '<button type="button" class="wk-text-btn" data-wk="undo">Undo last decision</button>' : '';
836
890
  var body;
837
891
  if (S.intakeError && !(S.intake && S.intake.length)) body = '<p class="wk-muted">' + esc(S.intakeError) + '</p>';
838
892
  else if (!S.intake) body = '<p class="wk-muted">Loading Intake…</p>';
@@ -844,10 +898,10 @@
844
898
  g.cards.map(function (t) {
845
899
  var meta = [str(t.createdAt), str(t.owner), str(t.dueDate) ? 'Due ' + t.dueDate : ''].filter(Boolean).join(' · ');
846
900
  return '<div class="wk-sort-card"><p class="wk-intake-text">' + inline(titleOf(t)) + '</p>' + (meta ? '<p class="wk-muted">' + esc(meta) + '</p>' : '') +
847
- '<div class="wk-row"><button type="button" class="cx-btn quiet" data-wk="card-keep" data-id="' + esc(identity(t)) + '"' + (batch && !S.batchBusy ? '' : ' disabled') + '>Keep</button>' +
848
- '<button type="button" class="cx-btn quiet" data-wk="card-drop" data-id="' + esc(identity(t)) + '"' + (batch && !S.batchBusy ? '' : ' disabled') + '>Drop</button>' +
901
+ '<div class="wk-row">' + (batch ? '<button type="button" class="cx-btn quiet" data-wk="card-keep" data-id="' + esc(identity(t)) + '"' + (S.batchBusy ? ' disabled' : '') + '>Keep</button>' +
902
+ '<button type="button" class="cx-btn quiet" data-wk="card-drop" data-id="' + esc(identity(t)) + '"' + (S.batchBusy ? ' disabled' : '') + '>Drop</button>' : '') +
849
903
  '<button type="button" class="wk-text-btn" data-wk="open" data-id="' + esc(identity(t)) + '">Open card</button></div></div>';
850
- }).join('') + (g.cards.length && !batch ? '<p class="wk-fine">Keep and Drop need a COS server with Work batch changes.</p>' : '') +
904
+ }).join('') +
851
905
  (g.items.length ? '<div class="wk-items"><div class="wk-row"><h4>Items to decide</h4><span class="wk-muted">' + g.items.length + '</span></div>' +
852
906
  '<p class="wk-muted">Keep what you own. Other people’s asks can become cards when you take them on.</p>' +
853
907
  '<div class="wk-items-list">' + g.items.map(intakeRow).join('') + '</div></div>' : '') +
@@ -1016,7 +1070,7 @@
1016
1070
  if (r.request) {
1017
1071
  var req = r.request, done = ['sent', 'refused', 'unresolved', 'unconfirmed', 'expired'].indexOf(req.state) >= 0;
1018
1072
  return '<div class="wk-handoff" data-tone="' + (req.state === 'sent' ? 'green' : req.state === 'refused' || req.state === 'expired' ? 'danger' : 'accent') + '"><p class="wk-state">' +
1019
- esc(REQUEST_LINE[req.state] || req.state) + '</p>' + (req.reason ? '<p class="wk-small">' + esc(req.reason) + '</p>' : '') +
1073
+ esc(requestLine(req)) + '</p>' + (req.reason ? '<p class="wk-small">' + esc(req.reason) + '</p>' : '') +
1020
1074
  (r.pollStopped ? '<p class="wk-small wk-muted">' + esc(r.pollStopped) + '</p>' : done ? '' : '<p class="wk-fine">This page checks every few seconds.</p>') +
1021
1075
  (done || r.pollStopped ? '<div class="wk-row"><button type="button" class="wk-text-btn" data-wk="reply-reset" data-id="' + esc(it.id) + '">Dismiss</button></div>' : '') + '</div>';
1022
1076
  }
@@ -1126,6 +1180,13 @@
1126
1180
  (str(t.source) ? fact('Source', t.source) : '') + (str(t.runAt) ? fact('Scheduled', t.runAt) : '') +
1127
1181
  '<p class="wk-fine">Task editing, scheduling, and completion stay attached to the original task. Agent output does not change its completion state.</p></section>';
1128
1182
  }
1183
+ // Duo tables: the divider between a card and its side panel (app.js splitKit, 900 px and up). The side panel is the
1184
+ // column on the right, so it grows as the line moves left; it keeps 320 px and the card 380 px, past a 22 px divider
1185
+ // that is the old gap. A repaint in place keeps the same divider. (DIVIDER, since SPLIT is the Sort split.)
1186
+ // A page without the kit (a test's stand-in W) draws no divider and keeps working.
1187
+ var SPLIT_KIT = W.splitKit || { handle: function () { return ''; }, mount: function () { return null; } };
1188
+ var DIVIDER = { key: 'cos_web_work_split', label: 'Side panel', side: 'end', sized: '.wk-detail-side', cssVar: '--split-side-share', media: '(min-width: 900px)', listMin: 320, readerMin: 380, handleW: 22 };
1189
+ var SPLIT_HANDLE = SPLIT_KIT.handle({ id: 'wk-handle', controls: 'wk-part-side', label: DIVIDER.label });
1129
1190
  function renderDetail(arg) {
1130
1191
  var it = itemFor(arg);
1131
1192
  S.viewWaiting = !it && (S.loading || !S.tasks);
@@ -1151,11 +1212,12 @@
1151
1212
  } else {
1152
1213
  S.chatDeferred = false;
1153
1214
  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>';
1215
+ '<div class="wk-detail' + (SPLIT_HANDLE ? ' split-side' : '') + '" data-wid="' + esc(it.wid) + '"><div class="wk-detail-main"><div id="wk-part-main" class="wk-part">' + parts.main + '</div>' + chatSectionHtml(it) + '</div>' +
1216
+ SPLIT_HANDLE + '<aside class="wk-detail-side" id="wk-part-side">' + parts.side + '</aside></div></div>';
1156
1217
  [['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
1218
  var hist = $('wk-part-chat'); if (hist) hist.__html = hist.innerHTML;
1158
1219
  }
1220
+ SPLIT_KIT.mount(pane.querySelector('.wk-detail'), $('wk-handle'), DIVIDER);
1159
1221
  chatSyncComposer(it);
1160
1222
  if (!S.activity[it.wid] && !S.activityError[it.wid] && !S.activityLoading[it.wid]) {
1161
1223
  S.activityLoading[it.wid] = true;
@@ -1258,9 +1320,10 @@
1258
1320
  state: CHAT_ROUND_WORD[r.state] ? r.state : 'planFailed', roundState: chatClean(r.roundState, 40), inHistory: r.inHistory === true,
1259
1321
  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
1322
  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 };
1323
+ pollSince: Number(r.pollSince) || 0, pollStopped: r.pollStopped === true, up: Number(r.up) || 0,
1324
+ offerNewSession: CHAT_NEW_PROVIDERS.indexOf(r.offerNewSession) >= 0 ? r.offerNewSession : '' };
1262
1325
  // 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.'; }
1326
+ if (out.state === 'planning') { out.state = 'planFailed'; out.error = 'Planning stopped when this page closed. Nothing was sent. Plan again to plan it.'; }
1264
1327
  return out;
1265
1328
  }
1266
1329
  function chatOf(wid) {
@@ -1328,7 +1391,8 @@
1328
1391
  function chatStored(r) {
1329
1392
  return { key: r.key, roundId: r.roundId, question: r.question, answer: r.answer, assignments: r.assignments, planner: r.planner, model: r.model,
1330
1393
  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 };
1394
+ at: r.at, sentAt: r.sentAt, sendUnknown: r.sendUnknown, context: r.context, skipped: r.skipped, pollSince: r.pollSince || 0, pollStopped: !!r.pollStopped,
1395
+ offerNewSession: r.offerNewSession || '' };
1332
1396
  }
1333
1397
  function chatTrim(rounds) {
1334
1398
  var newest = rounds.slice(-CHAT_KEEP.rounds);
@@ -1451,6 +1515,29 @@
1451
1515
  return 'COS found ' + total + ' session' + (total === 1 ? '' : 's') + ' for this task, none can take a Continue now' +
1452
1516
  (named.length ? ': ' + named.join('; ') + (more > 0 ? '; and ' + more + ' more' : '') : '') + '. The planner can fork one or start a new session.';
1453
1517
  }
1518
+ // ── An empty card (Control 0.5.386, Miles 2026-11-09; WorkOrchestrator.swift "An empty card") ──
1519
+ // A card with no session that can take a Continue or a Fork plans exactly one New session: Claude, or Codex when Claude's
1520
+ // plan usage is low (a used-up provider is never offered). COS Control on the Mac picks the model when it sends: Opus when
1521
+ // available on 0.5.386. Nothing starts until a Send.
1522
+ var CHAT_NEW_PROVIDERS = ['claude', 'codex'];
1523
+ function chatNothingToContinue(rows) {
1524
+ return !rows.some(function (r) { return chatAutomatic(r) || (r.forkable && FORKABLE[r.id.slice(0, r.id.indexOf(':'))]); });
1525
+ }
1526
+ function chatEmptyCardProvider(offered, usage) {
1527
+ var claudeLow = usage.some(function (u) { return u.provider === 'claude' && u.state === 'low'; });
1528
+ if (offered.indexOf('claude') >= 0 && !(claudeLow && offered.indexOf('codex') >= 0)) return 'claude';
1529
+ return offered.indexOf('codex') >= 0 ? 'codex' : '';
1530
+ }
1531
+ function chatOneNewSession(list) {
1532
+ var seen = false;
1533
+ return list.filter(function (a) { if (a.mode !== 'newSession') return true; var keep = !seen; seen = true; return keep; });
1534
+ }
1535
+ function chatEmptyPhase(p) { return 'No session on this task yet. Planning a new ' + providerName(p) + ' session\u2026'; }
1536
+ function chatEmptyNote(p) { return 'No session on this task yet. COS will start a new ' + providerName(p) + ' session when you press Send.'; }
1537
+ function chatStartNewTitle(p) { return 'Start a new ' + providerName(p) + ' session with this request'; }
1538
+ // Plan again (WorkChatPolicy.canPlanAgain): a round that failed before the planner answered, so nothing was planned or sent.
1539
+ function chatCanPlanAgain(r) { return !!(r && r.state === 'planFailed' && !r.answer && !r.assignments.length && !r.roundId && !r.sentAt); }
1540
+
1454
1541
  // The planner input, exactly as the server's parseWorkChatInput reads an orchestrator client's (Control 0.5.333).
1455
1542
  function chatPlanBody(it, question, input, c, wid) {
1456
1543
  var t = it.task, ids = {};
@@ -1468,10 +1555,9 @@
1468
1555
  var st = (last.states || {})[a.id] || {};
1469
1556
  return { sessionID: a.sessionID, status: chatClean(CHAT_ASSIGN_WORD[st.state] || 'Not sent', 100), detail: chatClean(st.detail, 2000), result: '' };
1470
1557
  }) : [];
1471
- var exhausted = {}; input.usage.forEach(function (u) { if (u.state === 'exhausted') exhausted[u.provider] = 1; });
1472
1558
  var body = { workID: wid, revision: str(t.workRevision), question: question, context: chatContext(t, wid), sessions: sessions, history: history, updates: updates,
1473
1559
  mode: 'plan', orchestrator: true, now: new Date().toISOString(), usage: input.usage, leftOut: input.leftOut,
1474
- newSessionProviders: ['claude', 'codex'].filter(function (p) { return !exhausted[p]; }) };
1560
+ newSessionProviders: newSessionProviders(WM.models, input.usage) };
1475
1561
  var planner = chatPlanner(), effort = chatEffort();
1476
1562
  if (planner) body.planner = planner; // only when the server advertised a choice, as Control does
1477
1563
  if (effort) body.effort = effort;
@@ -1536,8 +1622,10 @@
1536
1622
  if (c.rounds.some(function (r) { return r.state === 'planning'; })) return 'A plan is still being made for this card.';
1537
1623
  return '';
1538
1624
  }
1539
- function chatPlan(it) {
1540
- var c = chatOf(it.wid), question = c.draft.trim();
1625
+ function chatPlan(it, replanKey) {
1626
+ var c = chatOf(it.wid), again = replanKey ? chatFindRound(it.wid, replanKey) : null;
1627
+ if (replanKey && !chatCanPlanAgain(again)) return Promise.resolve(false);
1628
+ var question = again ? again.question : c.draft.trim();
1541
1629
  if (c.busy || !question || chatProblem(it, c)) return Promise.resolve(false);
1542
1630
  var wid = chatWorkId(it.task);
1543
1631
  c.busy = true; c.error = ''; c.phase = 'Reading the task and its connections…';
@@ -1551,28 +1639,43 @@
1551
1639
  throw chatOwn('This task changed since the page loaded. Check it, then plan again. Your request is still here.');
1552
1640
  }
1553
1641
  // 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);
1642
+ // Plan again reuses the failed round in its place (same key, same words), so one request never shows twice, and the
1643
+ // composer's draft is left alone.
1644
+ var base = { key: again ? again.key : 'web-' + uuid(), roundId: '', question: question, answer: '', assignments: [], planner: chatPlanner(), model: '', effort: '', note: '', meter: null,
1645
+ state: 'planning', roundState: '', inHistory: false, states: {}, error: '', at: Date.now(), sentAt: 0, sendUnknown: false, context: '', skipped: {}, offerNewSession: '' };
1646
+ var r = again ? Object.assign(again, base) : base;
1647
+ if (!again) { c.rounds.push(r); c.draft = ''; c.draftAt = Date.now(); chatTouch(c); chatResetComposer(it.wid); }
1648
+ else chatTouch(c);
1558
1649
  c.phase = 'Finding this task’s sessions…'; chatRepaint(it.wid);
1559
1650
  var meetings = (Array.isArray(it.task.meetingRefs) ? it.task.meetingRefs : []).map(function (m) { return m && typeof m.recordId === 'string' ? 'meeting:' + m.recordId : ''; })
1560
1651
  .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) {
1652
+ return loadWorkModels(false).then(function () {
1653
+ return api('/api/work/orchestrator/input', { method: 'POST', body: { workID: wid, meetingWorkIDs: meetings }, timeoutMs: CHAT_TIMEOUT.input });
1654
+ }).then(function (raw) {
1562
1655
  var input = chatReadInput(raw);
1563
1656
  if (!input || raw.workID !== wid) throw chatOwn('The server’s list of this task’s sessions could not be read. Nothing was sent.');
1564
1657
  var body = chatPlanBody(it, question, input, c, wid);
1658
+ // Empty card: the planner is offered exactly one provider for its one New session.
1659
+ var empty = chatNothingToContinue(input.rows) ? chatEmptyCardProvider(body.newSessionProviders, input.usage) : '';
1660
+ if (empty) body.newSessionProviders = [empty];
1565
1661
  r.note = chatSessionsNote(input);
1566
1662
  r.meter = chatMeter(question, body.context, wid);
1567
1663
  r.context = body.context.slice(0, 4000);
1568
- c.phase = 'Thinking through the request with ' + plannerName(body.planner || 'sonnet') + '…'; chatRepaint(it.wid);
1664
+ c.phase = empty ? chatEmptyPhase(empty) : 'Thinking through the request with ' + plannerName(body.planner || 'sonnet') + '…'; chatRepaint(it.wid);
1569
1665
  return api('/api/work/chat', { method: 'POST', body: body, timeoutMs: CHAT_TIMEOUT.plan }).then(function (answer) {
1570
1666
  var plan = chatReadPlan(answer, input, body);
1571
1667
  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
1668
  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;
1669
+ var planned = plan.assignments;
1670
+ if (empty) {
1671
+ // One New session at most. With it the round stops at Ready to send; without one, the round offers it as one tap.
1672
+ planned = chatOneNewSession(planned);
1673
+ if (planned.some(function (a) { return a.mode === 'newSession'; })) r.note = chatEmptyNote(empty);
1674
+ else if (!planned.length) r.offerNewSession = empty;
1675
+ }
1676
+ r.answer = plan.answer; r.assignments = planned;
1574
1677
  r.model = chatClean(answer.model, 100); r.effort = CHAT_EFFORTS.indexOf(answer.effort) >= 0 ? answer.effort : '';
1575
- r.state = plan.assignments.length ? 'ready' : 'answered';
1678
+ r.state = planned.length ? 'ready' : 'answered';
1576
1679
  chatTouch(c);
1577
1680
  // The plan is saved to the Mac only on Send. Today's COS Control files every saved round as a plan "from your
1578
1681
  // glasses" and posts a notice, so saving on Plan would announce a plan nobody chose to send. Send saves it
@@ -1771,6 +1874,23 @@
1771
1874
  chatTouch(c); chatRepaint(it.wid);
1772
1875
  return true;
1773
1876
  }
1877
+ // "Start a new Claude session with this request" (WorkTaskChat.startNewSession): the tap is the Send. It makes the one New
1878
+ // session assignment (the request as its prompt) and sends it through chatSend, so the server's one-send-per-round rule,
1879
+ // the saved round and COS Control's fences all apply. Once only: the offer is gone as soon as it is tapped.
1880
+ function chatStartNewSession(it, r) {
1881
+ var p = r && r.offerNewSession;
1882
+ if (!p || CHAT_NEW_PROVIDERS.indexOf(p) < 0 || r.state !== 'answered' || r.assignments.length || r.sending || chatOf(it.wid).busy) return Promise.resolve(false);
1883
+ if (!r.question.trim() || r.question.length > 2000 || /COS-WORK/i.test(r.question.replace(/[*_`‌‍]/g, ''))) {
1884
+ r.error = 'This request cannot be sent as it is. Edit it and plan again. Nothing was sent.'; chatRepaint(it.wid); return Promise.resolve(false);
1885
+ }
1886
+ r.assignments = [{ id: 'a1', mode: 'newSession', provider: p, prompt: r.question, title: 'New ' + providerName(p) + ' session' }];
1887
+ r.offerNewSession = ''; r.state = 'ready'; r.note = chatEmptyNote(p);
1888
+ chatTouch(chatOf(it.wid));
1889
+ // Lead decision (11/09, Q1 W-D1): two taps on the web, like every send here. This tap opens the usual confirm
1890
+ // ("Send to a new Claude session?"), so the round's record, confirm: two-step, is what happened.
1891
+ chatAskSend(it, r, ['a1']);
1892
+ return Promise.resolve(true);
1893
+ }
1774
1894
  function chatAgain(it, r) {
1775
1895
  var c = chatOf(it.wid);
1776
1896
  if (!r || c.draft.trim() || c.busy) return;
@@ -1887,6 +2007,13 @@
1887
2007
  (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
2008
  } else if (r.state === 'quiet') {
1889
2009
  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>';
2010
+ } else if (r.state === 'answered' && !r.assignments.length && r.offerNewSession) {
2011
+ actions = '<div class="wk-row wk-wrap wk-round-actions"><button type="button" class="cx-btn primary wk-start-new" data-wk="chat-start-new" data-key="' + esc(r.key) + '"' + (r.sending || c.busy ? ' disabled' : '') + '>' +
2012
+ esc(chatStartNewTitle(r.offerNewSession)) + '</button></div>' +
2013
+ '<p class="wk-fine">No session on this task yet, and the plan did not start one. Nothing is sent until you press this.</p>';
2014
+ } else if (chatCanPlanAgain(r)) {
2015
+ actions = '<div class="wk-row"><button type="button" class="cx-btn quiet" data-wk="chat-plan-again" data-key="' + esc(r.key) + '"' + (c.busy ? ' disabled' : '') + '>Plan again</button>' +
2016
+ '<span class="wk-small wk-muted">Plans this request again here. Nothing is sent until you press Send.</span></div>';
1890
2017
  } else if (r.state === 'planFailed' || (r.state === 'notDelivered' && !r.roundId)) {
1891
2018
  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
2019
  }
@@ -1899,7 +2026,7 @@
1899
2026
  var waited = Date.now() - (r.sentAt || Date.now());
1900
2027
  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
2028
  : 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.'
2029
+ : waited < 60000 ? 'Waiting for COS Control on your Mac to send this. COS Control has to be open on the Mac. Nothing has been sent yet.'
1903
2030
  : '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
2031
  return '<article class="wk-round" data-key="' + esc(r.key) + '">' +
1905
2032
  '<p class="wk-round-q">' + esc(r.question) + '</p>' +
@@ -2074,14 +2201,28 @@
2074
2201
  }
2075
2202
  return rows;
2076
2203
  }
2204
+ // Q1 P2: the list a New session picks from is the Mac's available Claude and Codex models, so what shows as picked is
2205
+ // what runs. A saved destination's model stays listed even when it is not offered now; "Server default" shows only for
2206
+ // a saved destination that chose it.
2207
+ function startModelOptions(st) {
2208
+ var list = WM.models ? WM.models.filter(function (m) { return m.available && (m.provider === 'claude' || m.provider === 'codex'); }).map(function (m) { return [m.id, m.title]; })
2209
+ : MODELS.filter(function (m) { return m[0]; });
2210
+ if (st.model && !list.some(function (m) { return m[0] === st.model; })) list.unshift([st.model, (MODELS.filter(function (m) { return m[0] === st.model; })[0] || [st.model, st.model])[1]]);
2211
+ if (!st.model && st.fromSaved) list.unshift(['', 'Server default']);
2212
+ return list;
2213
+ }
2077
2214
  function startState(it) {
2078
2215
  var st = S.start[it.wid];
2079
2216
  if (st) return st;
2080
2217
  var saved = (S.activity[it.wid] || {}).savedDestination, rows = sessionChoices(it);
2081
- st = { mode: 'newSession', sessionId: '', model: '', note: '', sending: false, request: null, error: '', fromSaved: false };
2218
+ st = { mode: 'newSession', sessionId: '', model: defaultNewModel(WM.models), note: '', sending: false, request: null, error: '', fromSaved: false, modelPicked: false };
2219
+ // As COS Control 0.5.386 (WorkHandoffStore.swift draft(for:) 910-912, defaultDraft 927-937): a saved destination wins;
2220
+ // without one, a card with a session of its own opens on Continue, and a card with none on Start a new session with
2221
+ // Claude's Opus (else Codex's first model).
2222
+ var own = rows.filter(function (c) { return !c.saved; });
2082
2223
  if (saved && saved.mode) {
2083
2224
  st.mode = saved.mode; st.sessionId = str(saved.sessionId); st.model = str(saved.model); st.fromSaved = true;
2084
- } else if (rows.length) { st.mode = 'continueSession'; st.sessionId = rows[0].sessionId; }
2225
+ } else if (own.length) { st.mode = 'continueSession'; st.sessionId = own[0].sessionId; }
2085
2226
  S.start[it.wid] = st;
2086
2227
  return st;
2087
2228
  }
@@ -2107,6 +2248,11 @@
2107
2248
  return;
2108
2249
  }
2109
2250
  var st = startState(it), blocking = blockingHandoff(it), caps = (S.activity[it.wid] || {}).capabilities || S.openCaps || {};
2251
+ if (!WM.at && !WM.loading) loadWorkModels(false).then(function () {
2252
+ if (!st.fromSaved && !st.modelPicked && st.mode === 'newSession' && !st.request) st.model = defaultNewModel(WM.models) || st.model;
2253
+ var r = W.parseRoute(); if (r.pane === 'work' && r.args[0] === arg && r.args[1] === 'start') renderStart(arg);
2254
+ });
2255
+ if (!WM.models && !WM.failed && !st.model && !st.fromSaved) st.model = 'opus';
2110
2256
  var sessions = sessionChoices(it);
2111
2257
  var problem = startProblem(it, st), taking = caps.requests === 1;
2112
2258
  function choice(mode, title, detail, disabled) {
@@ -2115,7 +2261,8 @@
2115
2261
  }
2116
2262
  var picker = st.mode === 'newSession'
2117
2263
  ? '<label class="wk-pick wk-pick-wide"><span>Model</span><select class="field" data-wk-start="model"' + (st.sending || st.request ? ' disabled' : '') + '>' +
2118
- MODELS.map(function (m) { return '<option value="' + m[0] + '"' + (m[0] === st.model ? ' selected' : '') + '>' + esc(m[1]) + '</option>'; }).join('') + '</select></label>'
2264
+ startModelOptions(st).map(function (m) { return '<option value="' + esc(m[0]) + '"' + (m[0] === st.model ? ' selected' : '') + '>' + esc(m[1]) + '</option>'; }).join('') + '</select></label>' +
2265
+ (WM.failed ? '<p class="wk-small wk-muted">COS could not read the models on your Mac. These are the usual ones.</p>' : '')
2119
2266
  : '<label class="wk-pick wk-pick-wide"><span>Session</span><select class="field" data-wk-start="session"' + (st.sending || st.request ? ' disabled' : '') + '>' +
2120
2267
  (sessions.length ? '' : '<option value="">No session has worked on this card yet</option>') +
2121
2268
  sessions.filter(function (c) { return st.mode !== 'fork' || FORKABLE[c.provider]; }).map(function (c) {
@@ -2125,7 +2272,7 @@
2125
2272
  if (req) {
2126
2273
  var terminal = ['sent', 'refused', 'unresolved', 'unconfirmed', 'expired'].indexOf(req.state) >= 0;
2127
2274
  reqHtml = '<div class="wk-handoff" data-tone="' + (req.state === 'sent' ? 'green' : req.state === 'refused' || req.state === 'expired' ? 'danger' : 'accent') + '">' +
2128
- '<p class="wk-state">' + esc(REQUEST_LINE[req.state] || req.state) + '</p>' + (req.reason ? '<p class="wk-small">' + esc(req.reason) + '</p>' : '') +
2275
+ '<p class="wk-state">' + esc(requestLine(req)) + '</p>' + (req.reason ? '<p class="wk-small">' + esc(req.reason) + '</p>' : '') +
2129
2276
  (st.pollStopped ? '<p class="wk-small wk-muted">' + esc(st.pollStopped) + '</p>' : '') +
2130
2277
  (terminal || st.pollStopped ? '<div class="wk-row"><button type="button" class="cx-btn quiet" data-wk="back">Back to the card</button></div>' : '<p class="wk-fine">This page checks every few seconds.</p>') + '</div>';
2131
2278
  }
@@ -2313,7 +2460,7 @@
2313
2460
  if (!S.undoBatch) return;
2314
2461
  api('/api/work-intake/undo', { method: 'POST', body: { batchId: S.undoBatch } }).then(function () {
2315
2462
  S.undoBatch = null; loadIntake(true); loadBoard(true);
2316
- }, function (err) { if (err.status === 404) S.loopAvailable = false; S.sortError = err.status === 404 ? UNDO_MISSING : errText(err); render(); });
2463
+ }, function (err) { if (err.status === 404) S.loopAvailable = false; S.sortError = err.status === 404 ? UNDO_FAILED : errText(err); render(); });
2317
2464
  }
2318
2465
  function batchOp(t, action, fields) {
2319
2466
  if (S.batchBusy || !(S.caps.workBatch === 1 && S.caps.writable === true)) return;
@@ -2322,7 +2469,7 @@
2322
2469
  S.batchBusy = true; S.loopError = ''; render();
2323
2470
  api('/api/work-board/batch', { method: 'POST', body: { domain: t.domain, ops: [op] } }).then(function () {
2324
2471
  S.batchBusy = false; loadBoard(true); if (S.tab === 'waiting') loadWaiting();
2325
- }, function (err) { S.batchBusy = false; S.loopError = err.status === 404 ? 'Waiting on needs a newer COS server.' : errText(err); render(); });
2472
+ }, function (err) { S.batchBusy = false; S.loopError = err.status === 404 ? 'Waiting on is not on this COS server.' : errText(err); render(); });
2326
2473
  }
2327
2474
  function loadWaiting() {
2328
2475
  var mode = S.waitingDropped ? 'dropped' : 'waiting';
@@ -2330,7 +2477,7 @@
2330
2477
  var rows = (d && (d.tasks || d.items || d.cards)) || [];
2331
2478
  if (mode === 'dropped') S.dropped = rows; else S.waiting = rows;
2332
2479
  S.loopError = ''; render();
2333
- }, function (err) { S.loopError = err.status === 404 ? (mode === 'dropped' ? 'Dropped cards need a newer COS server.' : 'Waiting on needs a newer COS server.') : errText(err);
2480
+ }, function (err) { S.loopError = err.status === 404 ? (mode === 'dropped' ? 'Dropped cards are not on this COS server.' : 'Waiting on is not on this COS server.') : errText(err);
2334
2481
  if (mode === 'dropped') S.dropped = []; else S.waiting = []; render(); });
2335
2482
  }
2336
2483
  // Meaning search: asked only when Return is pressed in the search box (never while typing).
@@ -2464,6 +2611,10 @@
2464
2611
  it = current(); if (!it) return;
2465
2612
  var draftBox = $('wk-chat-text'); if (draftBox) { chatOf(it.wid).draft = draftBox.value; draftBox.blur(); }
2466
2613
  chatPlan(it); return;
2614
+ case 'chat-plan-again': case 'chat-start-new':
2615
+ it = current(); var again = it && chatFindRound(it.wid, t.getAttribute('data-key')); if (!again) return;
2616
+ if (act === 'chat-plan-again') chatPlan(it, again.key); else chatStartNewSession(it, again);
2617
+ return;
2467
2618
  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
2619
  it = current(); var round = it && chatFindRound(it.wid, t.getAttribute('data-key')); if (!round) return;
2469
2620
  var ids = String(t.getAttribute('data-ids') || '').split(',').filter(Boolean);
@@ -2555,7 +2706,7 @@
2555
2706
  var rows = sessionChoices(it).filter(function (c) { return st.mode !== 'fork' || FORKABLE[c.provider]; });
2556
2707
  if (st.mode !== 'newSession' && !rows.some(function (c) { return c.sessionId === st.sessionId; })) st.sessionId = rows.length ? rows[0].sessionId : '';
2557
2708
  } else if (el.getAttribute('data-wk-start') === 'session') { st.sessionId = el.value; st.fromSaved = false; }
2558
- else if (el.getAttribute('data-wk-start') === 'model') { st.model = el.value; st.fromSaved = false; }
2709
+ else if (el.getAttribute('data-wk-start') === 'model') { st.model = el.value; st.fromSaved = false; st.modelPicked = true; }
2559
2710
  return renderStart(it.id);
2560
2711
  }
2561
2712
  if (el.hasAttribute('data-wk-edit')) { it = current(); if (it) { var ed = editState(it); ed[el.getAttribute('data-wk-edit')] = el.value; renderEdit(it.id); } }
@@ -2671,9 +2822,9 @@
2671
2822
  },
2672
2823
  leave: function () { stopPoll(); stopHandoffPolls(); chatSave(true); },
2673
2824
  // 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,
2825
+ _chat: { S: S, WM: WM, catalog: workModelCatalog, of: chatOf, plan: chatPlan, send: chatSend, stop: chatStop, again: chatAgain, poll: chatPoll, caps: loadChatCaps, readCaps: chatReadCaps,
2675
2826
  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,
2827
+ store: function () { return chatStore; }, save: chatSave, timers: CHAT_TIMERS, render: renderDetail, renderStart: renderStart, sortView: sortView, restore: chatRestored, item: itemFor,
2677
2828
  mergeIn: function () { chatMergeIn(chatReadDisk()); }, limits: { questionChars: CHAT_MAX, pollMaxMs: CHAT_POLL.max } },
2678
2829
  // The Ask sheet's "Find a task about this": open the board on this query and ask by meaning.
2679
2830
  search: function (q) { S.q = String(q || ''); S.tab = 'board'; render(); searchMeaning(); },
@@ -77,10 +77,13 @@
77
77
  }
78
78
  // About 16 reads: never two runs at once, never more than one a minute, two at a time through storyApi, and never
79
79
  // ahead of the pane the person just opened (Safari keeps 6 connections to the Mac over HTTP/1.1).
80
- // On the closed Duo (below 600px) the stories show only inside the section menu, so they load
81
- // when the menu opens rather than costing the pane its connections on every visit.
80
+ // Below 600 px wide (the folded Duo) the rail is hidden and the stories show only inside the section menu, so they
81
+ // load when the menu opens rather than costing the pane its connections on every visit. This is the WIDTH test on
82
+ // purpose, not W.isPhone: from 7.0.2 a short touch screen at any width counts as a phone (the unfolded Duo, 951x539),
83
+ // and it still has the rail, its story lines and hot dots.
84
+ function railHidden() { return !window.matchMedia('(min-width: 600px)').matches; }
82
85
  function loadStories(force) {
83
- if (!force && W.isPhone()) return;
86
+ if (!force && railHidden()) return;
84
87
  if (Date.now() - storiesAt < 60000) return;
85
88
  storiesAt = Date.now();
86
89
  var now = new Date();
@@ -210,17 +213,17 @@
210
213
  setStory('threads', active + ' active' + (list[0] && list[0].name ? ', newest ' + clip(list[0].name, 40) : ''), false);
211
214
  });
212
215
 
213
- // Memories (TodayHome.memoriesStory): n is the gated needs-you count (capped, from the
214
- // bridge's status, which runs the same CLI Control's helper runs); total is the number of
215
- // rows to review. "Next n of total" when there are more rows than n, else "n to review".
216
- Promise.all([
217
- soft(storyApi('/api/web-bridge/memories', { method: 'POST', body: { id: 'rail-status', op: 'status', args: {} } })),
218
- soft(storyApi('/api/context/learning/review?limit=200'))
219
- ]).then(function (res) {
220
- var n = res[0] && res[0].details ? res[0].details.learningToReview : null;
216
+ // Memories (TodayHome.memoriesStory): n is the needs-review count (capped, from the bridge's
217
+ // status, which runs the same CLI Control's helper runs, judged by memory-review.js); total is
218
+ // every item that needs review by the same predicate, never the To review list's length (that
219
+ // list also holds suggestions and proposals with no decision waiting). "Next n of total" when
220
+ // more need review than are shown, else "n to review".
221
+ soft(storyApi('/api/web-bridge/memories', { method: 'POST', body: { id: 'rail-status', op: 'status', args: {} } })).then(function (r) {
222
+ var d = r && r.details ? r.details : {};
223
+ var n = d.learningToReview;
221
224
  if (typeof n !== 'number') return setStory('memories', FALLBACK.memories, false);
222
225
  if (n <= 0) return setStory('memories', 'Nothing to review', false);
223
- var total = res[1] && Array.isArray(res[1].events) ? res[1].events.length : null;
226
+ var total = typeof d.learningToReviewTotal === 'number' ? d.learningToReviewTotal : null;
224
227
  setStory('memories', total != null && total > n ? 'Next ' + n + ' of ' + total : n + ' to review', true);
225
228
  });
226
229
  // Dictation lives on the Mac (its history and suggestions are local files).
@@ -260,9 +263,9 @@
260
263
  }
261
264
  crumbs.innerHTML = parts.join('');
262
265
  $('nav-back').disabled = onToday;
263
- // The phone top bar names the detail too, so a reader says where it is.
266
+ // The phone top bar (below 600 px, the same width test) names the detail too, so a reader says where it is.
264
267
  var label = $('section-name');
265
- if (label && detail && W.isPhone()) label.textContent = detail;
268
+ if (label && detail && railHidden()) label.textContent = detail;
266
269
  }
267
270
 
268
271
  document.addEventListener('click', function (e) {
@@ -0,0 +1,11 @@
1
+ {
2
+ "v": 1,
3
+ "algorithm": "sha256",
4
+ "files": {
5
+ "/index.html": "3bea8ab14118e48c8bbf5f09de2e8b65ca1225b7e7b564982a84477bafc8341e",
6
+ "/signer-proof.js": "a8f8f10608fa1ec0e8da0280fdb27c16c78c011e6ff935477593e25a8e6a26e0",
7
+ "/signer-store.js": "ae123328f922a509eea4d8f9e8440c9ef6972eeb14bf90b9e8bde3c84f7132bc",
8
+ "/signer.css": "e19f5ec70c9dae28347ad40cd08742842e964b3d0b973a5c39f48f9cafdd319b",
9
+ "/signer.js": "c92dae47694a42aec0995a8cb0e70ba54979183711d840b2935528a197b11735"
10
+ }
11
+ }
@@ -0,0 +1,74 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <meta name="referrer" content="no-referrer">
7
+ <title>COS approval</title>
8
+ <link rel="stylesheet" href="/signer.css">
9
+ </head>
10
+ <body>
11
+ <main>
12
+ <h1>COS approval</h1>
13
+
14
+ <section id="loading"><p>Loading.</p></section>
15
+
16
+ <section id="framed" hidden>
17
+ <p>This page only works when you open it directly. Nothing was signed.</p>
18
+ </section>
19
+
20
+ <section id="enroll" hidden>
21
+ <h2>Set up this phone</h2>
22
+ <p>This lets you approve messages to your Mac with a user-verified passkey on this phone. Nothing else is needed. The key stays on this phone.</p>
23
+ <p>This phone will show a 6-digit code. Type it in COS Control on your Mac, compare the key fingerprints, then approve with Touch ID.</p>
24
+ <label for="enroll-label">Name for this phone</label>
25
+ <input id="enroll-label" type="text" maxlength="64" autocomplete="off" value="iPhone">
26
+ <button type="button" id="enroll-start">Set up phone approval</button>
27
+ <button type="button" id="enroll-confirm" hidden>Confirm with passkey</button>
28
+ <p class="status" id="enroll-status" role="status"></p>
29
+ </section>
30
+
31
+ <section id="pending" hidden>
32
+ <h2>Type this code on your Mac</h2>
33
+ <p class="code" id="pending-code"></p>
34
+ <p>Key fingerprint <span class="mono" id="pending-fingerprint"></span></p>
35
+ <p>In COS Control on your Mac, type this code and check that its key fingerprint matches this phone. Then approve with Touch ID. This page updates when you do.</p>
36
+ </section>
37
+
38
+ <section id="sign" hidden>
39
+ <h2>You are about to approve</h2>
40
+ <p class="label">Words</p>
41
+ <pre id="sign-words"></pre>
42
+ <dl>
43
+ <dt>Sent to</dt><dd id="sign-provider"></dd>
44
+ <dt>Session</dt><dd class="mono" id="sign-session"></dd>
45
+ <dt>What it allows</dt><dd id="sign-scope"></dd>
46
+ </dl>
47
+ <pre id="sign-packet" hidden></pre>
48
+ <button type="button" id="sign-approve">Approve with phone passkey</button>
49
+ <button type="button" id="sign-cancel" class="quiet">Cancel approval</button>
50
+ <p class="status" id="sign-status" role="status"></p>
51
+ </section>
52
+
53
+ <section id="done" hidden>
54
+ <p id="done-text"></p>
55
+ <p><a id="done-back" href="#">Back to Control</a></p>
56
+ </section>
57
+
58
+ <section id="idle" hidden>
59
+ <p id="idle-text">This phone has a key. Start an approval from a session in Control.</p>
60
+ <p><span id="key-label"></span> <span class="mono" id="key-fingerprint"></span></p>
61
+ <button type="button" id="key-revoke" class="quiet">Remove this phone&rsquo;s key</button>
62
+ </section>
63
+
64
+ <section id="problem" hidden>
65
+ <p id="problem-text"></p>
66
+ </section>
67
+
68
+ <p class="foot"><a id="back" href="#">Back to Control</a></p>
69
+ </main>
70
+ <script src="/signer-proof.js"></script>
71
+ <script src="/signer-store.js"></script>
72
+ <script src="/signer.js"></script>
73
+ </body>
74
+ </html>