@gotcos/glasses-server 7.0.7 → 7.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +305 -0
  3. package/docs/web-key-verifier-contract.md +378 -0
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -3
  6. package/server/bootstrap.ts +10 -0
  7. package/server/index.ts +129 -23
  8. package/server/lib/agent-session-older.ts +218 -0
  9. package/server/lib/agent-session-recent.ts +321 -0
  10. package/server/lib/agent-session-store.ts +102 -20
  11. package/server/lib/api-auth.ts +37 -1
  12. package/server/lib/calendar-meetings.ts +70 -0
  13. package/server/lib/calendar-source.ts +54 -9
  14. package/server/lib/claude-hooks-installer.ts +111 -12
  15. package/server/lib/conversation.ts +56 -28
  16. package/server/lib/cors-reject-log.ts +51 -0
  17. package/server/lib/cos-started-sessions.ts +101 -0
  18. package/server/lib/disk-io-meter.ts +202 -0
  19. package/server/lib/drop-service.ts +47 -12
  20. package/server/lib/drop-store.ts +21 -1
  21. package/server/lib/event-loop-stall.ts +62 -0
  22. package/server/lib/full-hold-engine.ts +114 -0
  23. package/server/lib/full-hold.ts +124 -0
  24. package/server/lib/g2-turn-provenance.ts +128 -9
  25. package/server/lib/list-model-memo.ts +85 -0
  26. package/server/lib/listener-startup.ts +18 -0
  27. package/server/lib/meeting-batch-transcribe.ts +32 -5
  28. package/server/lib/model-efforts.ts +40 -0
  29. package/server/lib/permission-broker.ts +443 -11
  30. package/server/lib/secret-path-guard.ts +4 -1
  31. package/server/lib/session-cache-writer.ts +8 -1
  32. package/server/lib/session-hook-events.ts +23 -1
  33. package/server/lib/session-hooks-runtime.ts +34 -0
  34. package/server/lib/session-log.ts +313 -4
  35. package/server/lib/session-organizer.ts +305 -0
  36. package/server/lib/session-state-derive.ts +36 -0
  37. package/server/lib/session-stream-events.ts +39 -3
  38. package/server/lib/thread-turn-queue.ts +24 -0
  39. package/server/lib/today-brief.ts +522 -58
  40. package/server/lib/turn-end-ask.ts +320 -0
  41. package/server/lib/turn-footer.ts +3 -3
  42. package/server/lib/web-control-integrity.ts +160 -0
  43. package/server/lib/web-control-offnet-page.ts +58 -0
  44. package/server/lib/web-control-static.ts +53 -9
  45. package/server/lib/web-key-authority.ts +372 -0
  46. package/server/lib/web-key-envelope.ts +98 -0
  47. package/server/lib/web-key-handoff.ts +135 -0
  48. package/server/lib/web-key-origin.ts +21 -0
  49. package/server/lib/web-key-passkey.ts +155 -0
  50. package/server/lib/web-key-registry.ts +825 -0
  51. package/server/lib/web-key-signer-app.ts +152 -0
  52. package/server/lib/web-session-auth.ts +133 -15
  53. package/server/lib/whisper-local.ts +93 -7
  54. package/server/lib/whisper-preview.ts +5 -3
  55. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  56. package/server/lib/work-review-runtime.ts +17 -4
  57. package/server/routes/agent-session-bindings.ts +16 -1
  58. package/server/routes/agent-session-stream.ts +7 -0
  59. package/server/routes/agent-sessions.ts +106 -2
  60. package/server/routes/drops.ts +7 -2
  61. package/server/routes/g2-turns.ts +35 -0
  62. package/server/routes/health.ts +14 -1
  63. package/server/routes/live-events.ts +6 -1
  64. package/server/routes/permission-broker.ts +26 -0
  65. package/server/routes/query-jobs.ts +3 -0
  66. package/server/routes/session-organizer.ts +149 -0
  67. package/server/routes/sessions.ts +15 -24
  68. package/server/routes/thread-turn-queue.ts +50 -14
  69. package/server/routes/today-brief.ts +91 -36
  70. package/server/routes/web-control.ts +37 -4
  71. package/server/routes/web-key.ts +284 -0
  72. package/server/routes/welcome-context.ts +6 -2
  73. package/server/routes/work-orchestrator.ts +12 -7
  74. package/server/scripts/write-web-control-manifest.mjs +43 -0
  75. package/server/web-control/.manifest.json +44 -0
  76. package/server/web-control/app.css +45 -0
  77. package/server/web-control/app.js +1680 -108
  78. package/server/web-control/apple-touch-icon.png +0 -0
  79. package/server/web-control/index.html +54 -17
  80. package/server/web-control/panes/cos-chat.css +52 -6
  81. package/server/web-control/panes/cos-chat.js +364 -32
  82. package/server/web-control/panes/questions.js +135 -14
  83. package/server/web-control/panes/sessions.css +280 -0
  84. package/server/web-control/panes/settings.js +1 -1
  85. package/server/web-control/panes/today-actions.css +35 -0
  86. package/server/web-control/panes/today-actions.js +121 -6
  87. package/server/web-control/panes/work.css +3 -0
  88. package/server/web-control/panes/work.js +185 -38
  89. package/server/web-control/theme.js +33 -0
  90. package/server/web-signer/.manifest.json +11 -0
  91. package/server/web-signer/index.html +74 -0
  92. package/server/web-signer/signer-proof.js +348 -0
  93. package/server/web-signer/signer-store.js +39 -0
  94. package/server/web-signer/signer.css +23 -0
  95. package/server/web-signer/signer.js +341 -0
  96. package/shared/web-approval-packets.golden.json +124 -0
  97. package/shared/web-turn-envelope.ts +271 -0
@@ -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
  }
@@ -1266,9 +1320,10 @@
1266
1320
  state: CHAT_ROUND_WORD[r.state] ? r.state : 'planFailed', roundState: chatClean(r.roundState, 40), inHistory: r.inHistory === true,
1267
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,
1268
1322
  sendUnknown: r.sendUnknown === true, context: chatClean(r.context, 4000), skipped: r.skipped && typeof r.skipped === 'object' ? r.skipped : {},
1269
- 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 : '' };
1270
1325
  // A plan in flight lives only in this page. After a reload it is gone, and nothing was saved or sent for it.
1271
- 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.'; }
1272
1327
  return out;
1273
1328
  }
1274
1329
  function chatOf(wid) {
@@ -1336,7 +1391,8 @@
1336
1391
  function chatStored(r) {
1337
1392
  return { key: r.key, roundId: r.roundId, question: r.question, answer: r.answer, assignments: r.assignments, planner: r.planner, model: r.model,
1338
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,
1339
- 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 || '' };
1340
1396
  }
1341
1397
  function chatTrim(rounds) {
1342
1398
  var newest = rounds.slice(-CHAT_KEEP.rounds);
@@ -1459,6 +1515,29 @@
1459
1515
  return 'COS found ' + total + ' session' + (total === 1 ? '' : 's') + ' for this task, none can take a Continue now' +
1460
1516
  (named.length ? ': ' + named.join('; ') + (more > 0 ? '; and ' + more + ' more' : '') : '') + '. The planner can fork one or start a new session.';
1461
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
+
1462
1541
  // The planner input, exactly as the server's parseWorkChatInput reads an orchestrator client's (Control 0.5.333).
1463
1542
  function chatPlanBody(it, question, input, c, wid) {
1464
1543
  var t = it.task, ids = {};
@@ -1476,10 +1555,9 @@
1476
1555
  var st = (last.states || {})[a.id] || {};
1477
1556
  return { sessionID: a.sessionID, status: chatClean(CHAT_ASSIGN_WORD[st.state] || 'Not sent', 100), detail: chatClean(st.detail, 2000), result: '' };
1478
1557
  }) : [];
1479
- var exhausted = {}; input.usage.forEach(function (u) { if (u.state === 'exhausted') exhausted[u.provider] = 1; });
1480
1558
  var body = { workID: wid, revision: str(t.workRevision), question: question, context: chatContext(t, wid), sessions: sessions, history: history, updates: updates,
1481
1559
  mode: 'plan', orchestrator: true, now: new Date().toISOString(), usage: input.usage, leftOut: input.leftOut,
1482
- newSessionProviders: ['claude', 'codex'].filter(function (p) { return !exhausted[p]; }) };
1560
+ newSessionProviders: newSessionProviders(WM.models, input.usage) };
1483
1561
  var planner = chatPlanner(), effort = chatEffort();
1484
1562
  if (planner) body.planner = planner; // only when the server advertised a choice, as Control does
1485
1563
  if (effort) body.effort = effort;
@@ -1544,8 +1622,10 @@
1544
1622
  if (c.rounds.some(function (r) { return r.state === 'planning'; })) return 'A plan is still being made for this card.';
1545
1623
  return '';
1546
1624
  }
1547
- function chatPlan(it) {
1548
- 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();
1549
1629
  if (c.busy || !question || chatProblem(it, c)) return Promise.resolve(false);
1550
1630
  var wid = chatWorkId(it.task);
1551
1631
  c.busy = true; c.error = ''; c.phase = 'Reading the task and its connections…';
@@ -1559,28 +1639,43 @@
1559
1639
  throw chatOwn('This task changed since the page loaded. Check it, then plan again. Your request is still here.');
1560
1640
  }
1561
1641
  // From here the request belongs to a round, as on the Mac: the composer is cleared and the round keeps the words.
1562
- var r = { key: 'web-' + uuid(), roundId: '', question: question, answer: '', assignments: [], planner: chatPlanner(), model: '', effort: '', note: '', meter: null,
1563
- state: 'planning', roundState: '', inHistory: false, states: {}, error: '', at: Date.now(), sentAt: 0, sendUnknown: false, context: '', skipped: {} };
1564
- c.rounds.push(r); c.draft = ''; c.draftAt = Date.now(); chatTouch(c);
1565
- 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);
1566
1649
  c.phase = 'Finding this task’s sessions…'; chatRepaint(it.wid);
1567
1650
  var meetings = (Array.isArray(it.task.meetingRefs) ? it.task.meetingRefs : []).map(function (m) { return m && typeof m.recordId === 'string' ? 'meeting:' + m.recordId : ''; })
1568
1651
  .filter(function (m) { return m && m.length <= 250 && /^meeting:[^\u0000-\u001f]+$/.test(m); }).slice(0, 20);
1569
- 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) {
1570
1655
  var input = chatReadInput(raw);
1571
1656
  if (!input || raw.workID !== wid) throw chatOwn('The server’s list of this task’s sessions could not be read. Nothing was sent.');
1572
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];
1573
1661
  r.note = chatSessionsNote(input);
1574
1662
  r.meter = chatMeter(question, body.context, wid);
1575
1663
  r.context = body.context.slice(0, 4000);
1576
- 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);
1577
1665
  return api('/api/work/chat', { method: 'POST', body: body, timeoutMs: CHAT_TIMEOUT.plan }).then(function (answer) {
1578
1666
  var plan = chatReadPlan(answer, input, body);
1579
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.');
1580
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.');
1581
- 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;
1582
1677
  r.model = chatClean(answer.model, 100); r.effort = CHAT_EFFORTS.indexOf(answer.effort) >= 0 ? answer.effort : '';
1583
- r.state = plan.assignments.length ? 'ready' : 'answered';
1678
+ r.state = planned.length ? 'ready' : 'answered';
1584
1679
  chatTouch(c);
1585
1680
  // The plan is saved to the Mac only on Send. Today's COS Control files every saved round as a plan "from your
1586
1681
  // glasses" and posts a notice, so saving on Plan would announce a plan nobody chose to send. Send saves it
@@ -1779,6 +1874,23 @@
1779
1874
  chatTouch(c); chatRepaint(it.wid);
1780
1875
  return true;
1781
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
+ }
1782
1894
  function chatAgain(it, r) {
1783
1895
  var c = chatOf(it.wid);
1784
1896
  if (!r || c.draft.trim() || c.busy) return;
@@ -1895,6 +2007,13 @@
1895
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>' : '');
1896
2008
  } else if (r.state === 'quiet') {
1897
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>';
1898
2017
  } else if (r.state === 'planFailed' || (r.state === 'notDelivered' && !r.roundId)) {
1899
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>';
1900
2019
  }
@@ -1907,7 +2026,7 @@
1907
2026
  var waited = Date.now() - (r.sentAt || Date.now());
1908
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.'
1909
2028
  : r.roundState === 'checking' ? 'COS Control already has a send for this plan. Checking where it is…'
1910
- : 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.'
1911
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>';
1912
2031
  return '<article class="wk-round" data-key="' + esc(r.key) + '">' +
1913
2032
  '<p class="wk-round-q">' + esc(r.question) + '</p>' +
@@ -1979,7 +2098,9 @@
1979
2098
  chatGrow(box);
1980
2099
  }
1981
2100
  function chatGrow(box) {
1982
- var vv = window.visualViewport, h = vv ? vv.height : window.innerHeight || 600;
2101
+ // The visible height the one way Control Web reads it (COSWeb.viewportFit): a visible height of 0 (the unfolded Duo
2102
+ // after a keyboard closed, 11/10) reads as innerHeight, never as a 96 px box.
2103
+ var fit = W.viewportFit ? W.viewportFit() : null, h = fit ? fit.height : window.innerHeight || 600;
1983
2104
  var max = Math.max(96, Math.min(320, Math.round(h * 0.42)));
1984
2105
  box.style.height = 'auto';
1985
2106
  box.style.height = Math.min(box.scrollHeight + 2, max) + 'px';
@@ -1992,9 +2113,11 @@
1992
2113
  // With the on-screen keyboard up, keep the text box and the Plan row inside what is visible: scroll only as much as
1993
2114
  // needed, never past the top of the box. On a folded or unfolded Duo the visual viewport tells us what is visible.
1994
2115
  function chatKeepInView() {
1995
- var box = $('wk-chat-text'), row = $('wk-cmp-actions'), vv = window.visualViewport;
2116
+ var box = $('wk-chat-text'), row = $('wk-cmp-actions'), fit = W.viewportFit ? W.viewportFit() : null;
1996
2117
  if (!box || !row || document.activeElement !== box) return;
1997
- var top = vv ? vv.offsetTop : 0, height = vv ? vv.height : window.innerHeight;
2118
+ // Through viewportFit, so a visible height of 0 (11/10) never puts the visible bottom above its top and scrolls the
2119
+ // page away; it reads as innerHeight from offset 0.
2120
+ var top = fit ? fit.top : 0, height = fit ? fit.height : window.innerHeight;
1998
2121
  var bar = document.querySelector('.topbar'), barBottom = bar ? bar.getBoundingClientRect().bottom : 0;
1999
2122
  var visTop = Math.max(top, barBottom > top ? barBottom : top) + 6, visBottom = top + height - 8;
2000
2123
  var boxTop = box.getBoundingClientRect().top, rowBottom = row.getBoundingClientRect().bottom, delta = 0;
@@ -2082,14 +2205,28 @@
2082
2205
  }
2083
2206
  return rows;
2084
2207
  }
2208
+ // Q1 P2: the list a New session picks from is the Mac's available Claude and Codex models, so what shows as picked is
2209
+ // what runs. A saved destination's model stays listed even when it is not offered now; "Server default" shows only for
2210
+ // a saved destination that chose it.
2211
+ function startModelOptions(st) {
2212
+ 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]; })
2213
+ : MODELS.filter(function (m) { return m[0]; });
2214
+ 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]]);
2215
+ if (!st.model && st.fromSaved) list.unshift(['', 'Server default']);
2216
+ return list;
2217
+ }
2085
2218
  function startState(it) {
2086
2219
  var st = S.start[it.wid];
2087
2220
  if (st) return st;
2088
2221
  var saved = (S.activity[it.wid] || {}).savedDestination, rows = sessionChoices(it);
2089
- st = { mode: 'newSession', sessionId: '', model: '', note: '', sending: false, request: null, error: '', fromSaved: false };
2222
+ st = { mode: 'newSession', sessionId: '', model: defaultNewModel(WM.models), note: '', sending: false, request: null, error: '', fromSaved: false, modelPicked: false };
2223
+ // As COS Control 0.5.386 (WorkHandoffStore.swift draft(for:) 910-912, defaultDraft 927-937): a saved destination wins;
2224
+ // without one, a card with a session of its own opens on Continue, and a card with none on Start a new session with
2225
+ // Claude's Opus (else Codex's first model).
2226
+ var own = rows.filter(function (c) { return !c.saved; });
2090
2227
  if (saved && saved.mode) {
2091
2228
  st.mode = saved.mode; st.sessionId = str(saved.sessionId); st.model = str(saved.model); st.fromSaved = true;
2092
- } else if (rows.length) { st.mode = 'continueSession'; st.sessionId = rows[0].sessionId; }
2229
+ } else if (own.length) { st.mode = 'continueSession'; st.sessionId = own[0].sessionId; }
2093
2230
  S.start[it.wid] = st;
2094
2231
  return st;
2095
2232
  }
@@ -2115,6 +2252,11 @@
2115
2252
  return;
2116
2253
  }
2117
2254
  var st = startState(it), blocking = blockingHandoff(it), caps = (S.activity[it.wid] || {}).capabilities || S.openCaps || {};
2255
+ if (!WM.at && !WM.loading) loadWorkModels(false).then(function () {
2256
+ if (!st.fromSaved && !st.modelPicked && st.mode === 'newSession' && !st.request) st.model = defaultNewModel(WM.models) || st.model;
2257
+ var r = W.parseRoute(); if (r.pane === 'work' && r.args[0] === arg && r.args[1] === 'start') renderStart(arg);
2258
+ });
2259
+ if (!WM.models && !WM.failed && !st.model && !st.fromSaved) st.model = 'opus';
2118
2260
  var sessions = sessionChoices(it);
2119
2261
  var problem = startProblem(it, st), taking = caps.requests === 1;
2120
2262
  function choice(mode, title, detail, disabled) {
@@ -2123,7 +2265,8 @@
2123
2265
  }
2124
2266
  var picker = st.mode === 'newSession'
2125
2267
  ? '<label class="wk-pick wk-pick-wide"><span>Model</span><select class="field" data-wk-start="model"' + (st.sending || st.request ? ' disabled' : '') + '>' +
2126
- MODELS.map(function (m) { return '<option value="' + m[0] + '"' + (m[0] === st.model ? ' selected' : '') + '>' + esc(m[1]) + '</option>'; }).join('') + '</select></label>'
2268
+ startModelOptions(st).map(function (m) { return '<option value="' + esc(m[0]) + '"' + (m[0] === st.model ? ' selected' : '') + '>' + esc(m[1]) + '</option>'; }).join('') + '</select></label>' +
2269
+ (WM.failed ? '<p class="wk-small wk-muted">COS could not read the models on your Mac. These are the usual ones.</p>' : '')
2127
2270
  : '<label class="wk-pick wk-pick-wide"><span>Session</span><select class="field" data-wk-start="session"' + (st.sending || st.request ? ' disabled' : '') + '>' +
2128
2271
  (sessions.length ? '' : '<option value="">No session has worked on this card yet</option>') +
2129
2272
  sessions.filter(function (c) { return st.mode !== 'fork' || FORKABLE[c.provider]; }).map(function (c) {
@@ -2133,7 +2276,7 @@
2133
2276
  if (req) {
2134
2277
  var terminal = ['sent', 'refused', 'unresolved', 'unconfirmed', 'expired'].indexOf(req.state) >= 0;
2135
2278
  reqHtml = '<div class="wk-handoff" data-tone="' + (req.state === 'sent' ? 'green' : req.state === 'refused' || req.state === 'expired' ? 'danger' : 'accent') + '">' +
2136
- '<p class="wk-state">' + esc(REQUEST_LINE[req.state] || req.state) + '</p>' + (req.reason ? '<p class="wk-small">' + esc(req.reason) + '</p>' : '') +
2279
+ '<p class="wk-state">' + esc(requestLine(req)) + '</p>' + (req.reason ? '<p class="wk-small">' + esc(req.reason) + '</p>' : '') +
2137
2280
  (st.pollStopped ? '<p class="wk-small wk-muted">' + esc(st.pollStopped) + '</p>' : '') +
2138
2281
  (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>';
2139
2282
  }
@@ -2321,7 +2464,7 @@
2321
2464
  if (!S.undoBatch) return;
2322
2465
  api('/api/work-intake/undo', { method: 'POST', body: { batchId: S.undoBatch } }).then(function () {
2323
2466
  S.undoBatch = null; loadIntake(true); loadBoard(true);
2324
- }, function (err) { if (err.status === 404) S.loopAvailable = false; S.sortError = err.status === 404 ? UNDO_MISSING : errText(err); render(); });
2467
+ }, function (err) { if (err.status === 404) S.loopAvailable = false; S.sortError = err.status === 404 ? UNDO_FAILED : errText(err); render(); });
2325
2468
  }
2326
2469
  function batchOp(t, action, fields) {
2327
2470
  if (S.batchBusy || !(S.caps.workBatch === 1 && S.caps.writable === true)) return;
@@ -2330,7 +2473,7 @@
2330
2473
  S.batchBusy = true; S.loopError = ''; render();
2331
2474
  api('/api/work-board/batch', { method: 'POST', body: { domain: t.domain, ops: [op] } }).then(function () {
2332
2475
  S.batchBusy = false; loadBoard(true); if (S.tab === 'waiting') loadWaiting();
2333
- }, function (err) { S.batchBusy = false; S.loopError = err.status === 404 ? 'Waiting on needs a newer COS server.' : errText(err); render(); });
2476
+ }, function (err) { S.batchBusy = false; S.loopError = err.status === 404 ? 'Waiting on is not on this COS server.' : errText(err); render(); });
2334
2477
  }
2335
2478
  function loadWaiting() {
2336
2479
  var mode = S.waitingDropped ? 'dropped' : 'waiting';
@@ -2338,7 +2481,7 @@
2338
2481
  var rows = (d && (d.tasks || d.items || d.cards)) || [];
2339
2482
  if (mode === 'dropped') S.dropped = rows; else S.waiting = rows;
2340
2483
  S.loopError = ''; render();
2341
- }, 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);
2484
+ }, 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);
2342
2485
  if (mode === 'dropped') S.dropped = []; else S.waiting = []; render(); });
2343
2486
  }
2344
2487
  // Meaning search: asked only when Return is pressed in the search box (never while typing).
@@ -2472,6 +2615,10 @@
2472
2615
  it = current(); if (!it) return;
2473
2616
  var draftBox = $('wk-chat-text'); if (draftBox) { chatOf(it.wid).draft = draftBox.value; draftBox.blur(); }
2474
2617
  chatPlan(it); return;
2618
+ case 'chat-plan-again': case 'chat-start-new':
2619
+ it = current(); var again = it && chatFindRound(it.wid, t.getAttribute('data-key')); if (!again) return;
2620
+ if (act === 'chat-plan-again') chatPlan(it, again.key); else chatStartNewSession(it, again);
2621
+ return;
2475
2622
  case 'chat-send': case 'chat-confirm': case 'chat-cancel': case 'chat-skip': case 'chat-stop': case 'chat-again': case 'chat-check': case 'chat-remove':
2476
2623
  it = current(); var round = it && chatFindRound(it.wid, t.getAttribute('data-key')); if (!round) return;
2477
2624
  var ids = String(t.getAttribute('data-ids') || '').split(',').filter(Boolean);
@@ -2563,7 +2710,7 @@
2563
2710
  var rows = sessionChoices(it).filter(function (c) { return st.mode !== 'fork' || FORKABLE[c.provider]; });
2564
2711
  if (st.mode !== 'newSession' && !rows.some(function (c) { return c.sessionId === st.sessionId; })) st.sessionId = rows.length ? rows[0].sessionId : '';
2565
2712
  } else if (el.getAttribute('data-wk-start') === 'session') { st.sessionId = el.value; st.fromSaved = false; }
2566
- else if (el.getAttribute('data-wk-start') === 'model') { st.model = el.value; st.fromSaved = false; }
2713
+ else if (el.getAttribute('data-wk-start') === 'model') { st.model = el.value; st.fromSaved = false; st.modelPicked = true; }
2567
2714
  return renderStart(it.id);
2568
2715
  }
2569
2716
  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); } }
@@ -2679,9 +2826,9 @@
2679
2826
  },
2680
2827
  leave: function () { stopPoll(); stopHandoffPolls(); chatSave(true); },
2681
2828
  // For web-control/work-composer-web.test.ts: Work chat's steps, run against a fake server.
2682
- _chat: { S: S, of: chatOf, plan: chatPlan, send: chatSend, stop: chatStop, again: chatAgain, poll: chatPoll, caps: loadChatCaps, readCaps: chatReadCaps,
2829
+ _chat: { S: S, WM: WM, catalog: workModelCatalog, of: chatOf, plan: chatPlan, send: chatSend, stop: chatStop, again: chatAgain, poll: chatPoll, caps: loadChatCaps, readCaps: chatReadCaps,
2683
2830
  find: chatFindRound, planBody: chatPlanBody, context: chatContext, meter: chatMeter, readPlan: chatReadPlan, readInput: chatReadInput,
2684
- store: function () { return chatStore; }, save: chatSave, timers: CHAT_TIMERS, render: renderDetail, restore: chatRestored, item: itemFor,
2831
+ store: function () { return chatStore; }, save: chatSave, timers: CHAT_TIMERS, render: renderDetail, renderStart: renderStart, sortView: sortView, restore: chatRestored, item: itemFor,
2685
2832
  mergeIn: function () { chatMergeIn(chatReadDisk()); }, limits: { questionChars: CHAT_MAX, pollMaxMs: CHAT_POLL.max } },
2686
2833
  // The Ask sheet's "Find a task about this": open the board on this query and ask by meaning.
2687
2834
  search: function (q) { S.q = String(q || ''); S.tab = 'board'; render(); searchMeaning(); },
@@ -19,5 +19,38 @@
19
19
  apply: function () { return apply(read()); }
20
20
  };
21
21
  apply(read());
22
+ // 7.0.19: the first paint (index.html #first-paint) shows only when this script ran, so with JavaScript off the page
23
+ // never shows a mark that nothing would take away. app.js removes the class when the sign-in card or the app shows.
24
+ document.documentElement.classList.add('fp-wait');
22
25
  if (media && media.addEventListener) media.addEventListener('change', function () { if (read() === 'system') apply('system'); });
23
26
  })();
27
+
28
+ /* The app's scripts (index.html #boot-scripts), loaded one frame after the page is drawn so the first paint never waits
29
+ for them. Same files, same order (async=false keeps insertion order), and app.js's boot runs only once every one of
30
+ them has run, as when they were in the page: window.CosBoot.after(boot). The frame wait has a 50 ms fallback, because
31
+ a page opened in the background gets no animation frames. A script that fails to load counts as settled, as a failed
32
+ <script> tag did; nothing waits for it forever. */
33
+ (function () {
34
+ var waiting = [], settled = false;
35
+ function settle() { settled = true; var fns = waiting; waiting = []; fns.forEach(function (fn) { try { fn(); } catch (_e) { setTimeout(function () { throw _e; }, 0); } }); }
36
+ window.CosBoot = { after: function (fn) { if (typeof fn !== 'function') return; if (settled) fn(); else waiting.push(fn); } };
37
+ function load() {
38
+ var list = [];
39
+ try { list = JSON.parse((document.getElementById('boot-scripts') || {}).textContent || '[]'); } catch (_e) { list = []; }
40
+ list = Array.isArray(list) ? list.filter(function (u) { return typeof u === 'string' && u.indexOf('/control/') === 0; }) : [];
41
+ var left = list.length;
42
+ if (!left) { settle(); return; }
43
+ list.forEach(function (src) {
44
+ var el = document.createElement('script');
45
+ el.src = src; el.async = false;
46
+ el.onload = el.onerror = function () { if (--left === 0) settle(); };
47
+ document.body.appendChild(el);
48
+ });
49
+ }
50
+ function afterFrame() {
51
+ var done = false, go = function () { if (done) return; done = true; setTimeout(load, 0); };
52
+ if (window.requestAnimationFrame) window.requestAnimationFrame(go);
53
+ setTimeout(go, 50);
54
+ }
55
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', afterFrame); else afterFrame();
56
+ })();
@@ -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>