@gotcos/glasses-server 6.99.33 → 7.0.7

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 (76) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +417 -0
  3. package/README.md +3 -1
  4. package/managed-runtime-contract.json +3 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +29 -7
  7. package/server/lib/agent-session-output-images.ts +627 -49
  8. package/server/lib/agent-session-store.ts +24 -11
  9. package/server/lib/agent-session-tool-images.ts +224 -0
  10. package/server/lib/agent-session-turns.ts +19 -2
  11. package/server/lib/api-auth.ts +6 -0
  12. package/server/lib/ask-work-plan.ts +3 -1
  13. package/server/lib/calendar-source.ts +491 -0
  14. package/server/lib/codex-bridge.ts +4 -1
  15. package/server/lib/codex-run-ledger.ts +29 -0
  16. package/server/lib/context-builder.ts +2 -1
  17. package/server/lib/drop-p2-runtime.ts +1 -1
  18. package/server/lib/drop-store.ts +10 -4
  19. package/server/lib/learning-needs-you.ts +24 -7
  20. package/server/lib/live-events.ts +533 -0
  21. package/server/lib/local-path-redact.ts +272 -0
  22. package/server/lib/meeting-ask-orientation.ts +40 -6
  23. package/server/lib/meeting-ask-runner.ts +30 -10
  24. package/server/lib/morning-brief-runtime.ts +4 -2
  25. package/server/lib/native-mac-caller.ts +29 -0
  26. package/server/lib/ping-engine.ts +1 -1
  27. package/server/lib/ping-sources.ts +11 -6
  28. package/server/lib/preview-content.ts +172 -0
  29. package/server/lib/preview-error-code.ts +10 -0
  30. package/server/lib/preview-folders-setting.ts +133 -0
  31. package/server/lib/preview-log-throttle.ts +18 -0
  32. package/server/lib/preview-thumbs.ts +139 -0
  33. package/server/lib/secret-path-guard.ts +80 -0
  34. package/server/lib/session-files-setting.ts +88 -0
  35. package/server/lib/session-links.ts +280 -0
  36. package/server/lib/thread-turn-queue.ts +10 -0
  37. package/server/lib/today-brief.ts +119 -28
  38. package/server/lib/web-memories-bridge.ts +3 -1
  39. package/server/lib/work-activity.ts +56 -6
  40. package/server/lib/work-session-activity.ts +260 -0
  41. package/server/lib/your-move-count.ts +9 -3
  42. package/server/routes/agent-session-bindings.ts +13 -0
  43. package/server/routes/agent-session-stream.ts +16 -0
  44. package/server/routes/agent-sessions.ts +146 -11
  45. package/server/routes/ask-work-plan.ts +4 -1
  46. package/server/routes/claude-sessions.ts +5 -1
  47. package/server/routes/health.ts +35 -0
  48. package/server/routes/live-events.ts +329 -0
  49. package/server/routes/permission-broker.ts +69 -5
  50. package/server/routes/session-preview.ts +200 -0
  51. package/server/routes/thread-turn-queue.ts +207 -3
  52. package/server/routes/today-brief.ts +44 -9
  53. package/server/routes/welcome-context.ts +23 -3
  54. package/server/routes/work-board.ts +9 -2
  55. package/server/web-control/app.css +40 -1
  56. package/server/web-control/app.js +1309 -56
  57. package/server/web-control/control.css +25 -0
  58. package/server/web-control/index.html +7 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +42 -3
  61. package/server/web-control/panes/cos-chat.js +267 -15
  62. package/server/web-control/panes/meetings.css +18 -2
  63. package/server/web-control/panes/meetings.js +24 -8
  64. package/server/web-control/panes/memories.css +17 -2
  65. package/server/web-control/panes/memories.js +127 -42
  66. package/server/web-control/panes/messages.js +21 -2
  67. package/server/web-control/panes/preview.js +318 -0
  68. package/server/web-control/panes/questions.js +28 -8
  69. package/server/web-control/panes/reader-media.js +409 -23
  70. package/server/web-control/panes/sessions.css +32 -3
  71. package/server/web-control/panes/settings.js +96 -5
  72. package/server/web-control/panes/today-actions.css +11 -0
  73. package/server/web-control/panes/today-actions.js +45 -12
  74. package/server/web-control/panes/work.css +10 -0
  75. package/server/web-control/panes/work.js +10 -2
  76. package/server/web-control/shell.js +17 -14
@@ -0,0 +1,318 @@
1
+ /* The preview engine's web viewer (phase 1, PLAN_preview_engine_2026-11-08 4d). Link cards where a reply links a local
2
+ * file, and one viewer for what a card or a sent file opens: Markdown rendered with the reply renderer, text and code
3
+ * with line numbers and jump-to-line, CSV and TSV as a table, HTML with scripts off (a per-open "Run scripts" runs them
4
+ * in an opaque origin with no network), a PDF inline on a desktop and in a new tab on a phone, pictures through the
5
+ * normalizer. Only opaque ids reach the server (GET /api/preview/...); a Mac path never reaches this page.
6
+ * The viewer shell (history, Back, focus, scroll restore, the 2-request budget) is panes/reader-media.js. */
7
+ (function () {
8
+ 'use strict';
9
+ var W = window.COSWeb;
10
+ if (!W) return;
11
+ var LINK_ID = /^al_[a-f0-9]{64}$/, FILE_ID = /^af_[a-f0-9]{64}$/;
12
+ var KINDS = ['image', 'pdf', 'html', 'markdown', 'text', 'code', 'table', 'document', 'slides', 'sheet', 'audio', 'video', 'archive', 'folder', 'file'];
13
+ var OFF = 'Not available on this Mac';
14
+ var REASONS = { outside_workspace: OFF, missing: OFF, secret_file: 'Not shown: this file may hold secrets.', unavailable: 'Not available right now. Reopen the session to check again.' };
15
+ var NORMALIZED = /\.(png|jpe?g|webp|hei[cf]|avif)$/i;
16
+ var THUMBS = { pdf: 1, document: 1, slides: 1, sheet: 1 };
17
+ var style = document.createElement('style');
18
+ style.textContent = '.pv-chip{display:inline-flex;align-items:center;gap:8px;vertical-align:middle;max-width:100%;min-height:44px;margin:2px 0;padding:4px 10px 4px 4px;border:1px solid var(--panel-border);border-radius:10px;background:var(--panel);color:var(--ink);font:inherit;text-align:left;cursor:pointer;box-sizing:border-box}' +
19
+ 'button.pv-chip:hover{border-color:var(--sub)}button.pv-chip:focus-visible{outline:2px solid var(--accent,currentColor);outline-offset:2px}' +
20
+ '.pv-badge{flex:none;display:inline-flex;align-items:center;justify-content:center;min-width:34px;height:34px;padding:0 4px;border-radius:7px;background:var(--bg);border:1px solid var(--panel-border);font:600 10.5px/1 var(--body);letter-spacing:.04em;color:var(--sub);box-sizing:border-box}' +
21
+ '.pv-chip-text{display:inline-flex;flex-direction:column;min-width:0;line-height:1.25}.pv-chip-label{font:500 14px/1.3 var(--body);overflow-wrap:anywhere}.pv-chip-meta{font:12px/1.3 var(--body);color:var(--sub);overflow-wrap:anywhere}' +
22
+ '.pv-chip.off{cursor:default;opacity:.8;border-style:dashed}.pv-chip.off .pv-chip-meta{color:var(--sub)}.pv-chip.blocked .pv-badge{color:var(--amber,var(--sub))}' +
23
+ '.pv-chip.has-thumb{padding:4px 12px 4px 4px}.pv-thumb{flex:none;display:inline-flex;align-items:center;justify-content:center;width:48px;height:62px;overflow:hidden;border-radius:6px;background:#fff;border:1px solid var(--panel-border);font:600 10.5px/1 var(--body);color:#6e675d}.pv-thumb img{width:100%;height:100%;object-fit:contain;display:block}' +
24
+ // The viewer sits on a dark overlay (reader-media.js .rm-view), so everything drawn on a panel names its own ink:
25
+ // inherited, it was the overlay's white on a light panel (7.0.5 render: Markdown, code and the line field were blank).
26
+ '.pv-scroll{flex:1;min-height:0;width:100%;overflow:auto;border:1px solid var(--panel-border);border-radius:10px;background:var(--panel);color:var(--ink)}' +
27
+ '.pv-md{padding:12px 16px;max-width:none}.pv-code{font:12.5px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;padding:6px 0;min-width:max-content}.pv-line{display:flex;scroll-margin:40px 0}.pv-line.hit{background:var(--gold-bg,rgba(185,138,62,.14))}' +
28
+ '.pv-n{flex:none;width:4.2em;padding-right:12px;text-align:right;color:var(--sub);user-select:none;-webkit-user-select:none}.pv-t{white-space:pre;padding-right:16px}' +
29
+ '.pv-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;width:100%}.pv-bar .rm-status{flex:1;min-width:12em}.pv-jump{display:flex;gap:6px;align-items:center}.pv-jump input{width:6.5em;min-height:44px;padding:0 10px;border:1px solid var(--panel-border);border-radius:10px;background:var(--panel);color:var(--ink);font:16px var(--body);box-sizing:border-box}' +
30
+ // A table is as wide as its columns need (a cell wraps by words past 22em, never mid-word), and scrolls sideways
31
+ // inside its own box on a phone; the page itself never does (7.0.5 render: 458 px crushed "industry" to "ind ustr y").
32
+ '.pv-table{border-collapse:collapse;font:13px/1.4 var(--body);width:max-content;min-width:100%}.pv-table th,.pv-table td{padding:6px 10px;border-bottom:1px solid var(--panel-border);text-align:left;vertical-align:top;max-width:22em;overflow-wrap:break-word}.pv-table thead th{position:sticky;top:0;background:var(--panel);z-index:1;font-weight:600}.pv-table td.pv-rn,.pv-table th.pv-rn{color:var(--sub);text-align:right;width:1%;white-space:nowrap}' +
33
+ '.pv-frame{flex:1;min-height:0;width:100%;border:1px solid var(--panel-border);border-radius:10px;background:#fff}.pv-other{display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center}.pv-other .pv-thumb{width:160px;height:206px}' +
34
+ '.rm-view-body.pv-body{align-items:stretch;justify-content:flex-start}';
35
+ document.head.appendChild(style);
36
+
37
+ function element(tag, cls, text) { var el = document.createElement(tag); if (cls) el.className = cls; if (text != null) el.textContent = text; return el; }
38
+ function button(text, action, cls) { var b = element('button', cls || 'cx-btn quiet', text); b.type = 'button'; b.addEventListener('click', action); return b; }
39
+ function kindOf(name) {
40
+ var n = String(name || '');
41
+ var map = [[NORMALIZED, 'image'], [/\.gif$/i, 'image'], [/\.pdf$/i, 'pdf'], [/\.html?$/i, 'html'], [/\.(md|markdown|mdx)$/i, 'markdown'],
42
+ [/\.(csv|tsv)$/i, 'table'], [/\.(txt|log|text)$/i, 'text'],
43
+ [/\.(json|jsonl|ya?ml|toml|xml|svg|tsx?|mts|cts|jsx?|mjs|cjs|py|swift|sh|zsh|bash|s?css|rb|go|rs|java|kt|c|h|cc|cpp|hpp|m|mm|sql|lua|php|plist|ini|conf|cfg|properties)$/i, 'code'],
44
+ [/\.(docx?|pages|rtf)$/i, 'document'], [/\.(xlsx?|numbers)$/i, 'sheet'], [/\.(pptx?|key)$/i, 'slides'],
45
+ [/\.(wav|mp3|m4a|aac|flac|aiff?)$/i, 'audio'], [/\.(mp4|mov|webm|m4v)$/i, 'video'], [/\.(zip|tar|gz|tgz|bz2|xz|7z|ehpk)$/i, 'archive']];
46
+ for (var i = 0; i < map.length; i++) if (map[i][0].test(n)) return map[i][1];
47
+ return 'file';
48
+ }
49
+ // The checked DTO. Anything else from the server is ignored; a card never trusts a field it did not expect.
50
+ function clean(dto) {
51
+ if (!dto || (!LINK_ID.test(dto.id || '') && !FILE_ID.test(dto.id || '')) || ['available', 'unavailable'].indexOf(dto.state) < 0) return null;
52
+ var name = String(dto.name || 'File').slice(0, 120);
53
+ return {
54
+ id: dto.id, name: name, label: String(dto.label || name).slice(0, 120),
55
+ kind: KINDS.indexOf(dto.kind) >= 0 ? dto.kind : kindOf(name), type: String(dto.type || 'File').slice(0, 40),
56
+ size: typeof dto.size === 'number' && dto.size >= 0 ? dto.size : null,
57
+ line: Math.max(0, Math.min(9999999, Math.floor(Number(dto.line)) || 0)) || null,
58
+ state: dto.state, reason: REASONS[dto.reason] ? dto.reason : (dto.state === 'available' ? null : 'unavailable')
59
+ };
60
+ }
61
+ function parts(f) {
62
+ if (f.kind === 'folder' || f.state !== 'available') return [];
63
+ var p = ['raw'];
64
+ if (f.kind === 'pdf' || (f.kind === 'image' && NORMALIZED.test(f.name))) p.push('inline');
65
+ if (['markdown', 'text', 'code', 'html', 'table'].indexOf(f.kind) >= 0) p.push('text');
66
+ if (f.kind === 'table') p.push('table');
67
+ if (f.kind === 'html') p.push('html', 'html-run');
68
+ if (THUMBS[f.kind] || f.kind === 'html' || (f.kind === 'image' && NORMALIZED.test(f.name))) p.push('thumb');
69
+ return p;
70
+ }
71
+ function url(scope, id, part, q) {
72
+ return '/api/preview/agent-sessions/' + encodeURIComponent(scope.provider) + '/' + encodeURIComponent(scope.id) + '/' + encodeURIComponent(id) + '/content?part=' + part + (q || '');
73
+ }
74
+ function bytesWords(n) {
75
+ if (typeof n !== 'number' || !(n >= 0)) return '';
76
+ if (n < 1024) return n + ' B';
77
+ if (n < 1024 * 1024) return Math.max(1, Math.round(n / 1024)) + ' KB';
78
+ return (n / 1024 / 1024).toFixed(n < 10 * 1024 * 1024 ? 1 : 0) + ' MB';
79
+ }
80
+ function badge(f) {
81
+ if (f.kind === 'folder') return 'DIR';
82
+ if (f.kind === 'markdown') return 'MD';
83
+ var ext = /\.([a-z0-9]{1,4})$/i.exec(f.name); return ext ? ext[1].toUpperCase() : 'FILE';
84
+ }
85
+ // 7.0.4 QA, kept in the shared viewer: 503 previews_busy means the Mac is already reading its limit of previews.
86
+ var BUSY_WORDS = 'Your Mac is busy with other previews. Try again in a moment.';
87
+ function isBusy(err) { return !!(err && err.status === 503 && err.code === 'previews_busy'); }
88
+ function failWords(err) {
89
+ if (isBusy(err)) return BUSY_WORDS;
90
+ if (err && err.status === 401) return 'Signed out. Connect this browser again to open the file.';
91
+ if (err && err.status === 403) return err.code === 'secret_file_denied' ? REASONS.secret_file : 'Previews are off. Turn them on in Settings, Privacy.';
92
+ if (err && err.status === 404) return 'This file is no longer in the recent turns. Reopen the session to refresh it.';
93
+ if (err && err.status === 413) return 'Too large to open here. Open it on your Mac.';
94
+ if (err && err.status === 415) return 'This file is not text. Download it instead.';
95
+ return 'The file could not load. Check that your Mac is awake, then retry.';
96
+ }
97
+ function metaWords(f) {
98
+ if (f.kind === 'folder') return 'Folder · Open it on your Mac';
99
+ if (f.state !== 'available') return f.type + ' · ' + (REASONS[f.reason] || OFF);
100
+ return [f.type, bytesWords(f.size), f.line ? 'line ' + f.line : ''].filter(Boolean).join(' · ');
101
+ }
102
+
103
+ // A card where the link sits. An unavailable, blocked or folder card is words, never a button.
104
+ function chip(scope, dto, words, onOpen) {
105
+ var f = clean(dto); if (!f) return null;
106
+ var open = f.state === 'available' && f.kind !== 'folder';
107
+ var el = element(open ? 'button' : 'span', 'pv-chip' + (open ? '' : ' off') + (f.reason === 'secret_file' ? ' blocked' : ''));
108
+ if (open) { el.type = 'button'; el.setAttribute('aria-label', 'Open ' + f.type + ': ' + f.name + (f.line ? ', line ' + f.line : '')); }
109
+ var lead = element('span', 'pv-badge', badge(f)); lead.setAttribute('aria-hidden', 'true');
110
+ var text = element('span', 'pv-chip-text');
111
+ text.appendChild(element('span', 'pv-chip-label', String(words || f.label).slice(0, 120)));
112
+ text.appendChild(element('span', 'pv-chip-meta', metaWords(f)));
113
+ var card = { el: el, f: f, url: '', generation: 0 };
114
+ if (open && f.kind === 'pdf' && parts(f).indexOf('thumb') >= 0) {
115
+ el.className += ' has-thumb'; lead = element('span', 'pv-thumb', 'PDF'); lead.setAttribute('aria-hidden', 'true'); card.thumb = lead;
116
+ }
117
+ el.appendChild(lead); el.appendChild(text);
118
+ if (open) el.addEventListener('click', function () { onOpen(f, el); });
119
+ return card;
120
+ }
121
+
122
+ // ── Renderers. `v` is the viewer shell from reader-media.js: { body, acts, alive(), request(url, foreground, type) } ──
123
+ function status(v, words) { v.body.textContent = ''; var p = element('p', 'rm-status', words); p.setAttribute('role', 'status'); v.body.appendChild(p); return p; }
124
+ // A busy Mac, or one that did not answer, gets a "Try again" that draws the same file again; a refusal (secret,
125
+ // previews off, gone, too large) does not, because trying again cannot change it.
126
+ function fail(v) {
127
+ return function (err) {
128
+ if (!v.alive()) return;
129
+ status(v, failWords(err));
130
+ var again = isBusy(err) || !(err && err.status) || err.status === 503;
131
+ if (again && typeof v.retry === 'function') v.body.appendChild(button('Try again', function () { v.retry(); }));
132
+ };
133
+ }
134
+ function textPart(v, scope, f, offset, limit) {
135
+ return v.request(url(scope, f.id, 'text', '&offset=' + (offset || 0) + (limit ? '&limit=' + limit : '')), true, 'json').then(function (d) {
136
+ if (!d || typeof d.text !== 'string') throw new Error('No text');
137
+ return d;
138
+ });
139
+ }
140
+ function moreButton(v, words, run) { var b = button(words, function () { b.disabled = true; b.textContent = 'Loading…'; run(b); }); return b; }
141
+
142
+ function renderMarkdown(v, scope, f) {
143
+ var all = '';
144
+ var box = element('div', 'pv-scroll'), md = element('div', 'sx-md pv-md'); box.appendChild(md);
145
+ function load(offset) {
146
+ return textPart(v, scope, f, offset).then(function (d) {
147
+ if (!v.alive()) return;
148
+ all += d.text;
149
+ // The reply renderer: everything escaped first, a small subset of Markdown, links only to https.
150
+ md.innerHTML = W.sessionKit && W.sessionKit.markdown ? W.sessionKit.markdown(all) : '';
151
+ if (!md.innerHTML) md.textContent = all;
152
+ if (box.parentNode !== v.body) { v.body.textContent = ''; v.body.appendChild(box); }
153
+ if (d.more) md.appendChild(moreButton(v, 'Show more', function () { load(d.end); }));
154
+ });
155
+ }
156
+ load(0).catch(fail(v));
157
+ }
158
+
159
+ function renderCode(v, scope, f) {
160
+ var box = element('div', 'pv-scroll'), code = element('div', 'pv-code'), lines = [], next = 1, carry = '', loaded = false;
161
+ box.appendChild(code);
162
+ var bar = element('div', 'pv-bar'), note = element('span', 'rm-status');
163
+ var jump = element('form', 'pv-jump'), input = element('input'); input.type = 'number'; input.min = '1'; input.inputMode = 'numeric'; input.setAttribute('aria-label', 'Line number'); input.placeholder = 'Line';
164
+ jump.appendChild(input); jump.appendChild(button('Go to line', function () { go(Number(input.value)); }));
165
+ jump.addEventListener('submit', function (e) { e.preventDefault(); go(Number(input.value)); });
166
+ bar.appendChild(jump); bar.appendChild(note);
167
+ var more = null, end = 0;
168
+ function addLines(text, final) {
169
+ var parts = (carry + text).split('\n');
170
+ carry = final ? '' : parts.pop();
171
+ if (final && parts.length && parts[parts.length - 1] === '' ) parts.pop();
172
+ parts.forEach(function (t) {
173
+ var row = element('div', 'pv-line'); row.setAttribute('data-line', String(next));
174
+ row.appendChild(element('span', 'pv-n', String(next))); row.appendChild(element('span', 'pv-t', t || ' '));
175
+ code.appendChild(row); lines.push(row); next++;
176
+ });
177
+ }
178
+ function go(n) {
179
+ n = Math.floor(n);
180
+ if (!(n >= 1)) return;
181
+ if (n > lines.length) { note.textContent = more ? 'Line ' + n + ' is further down. Show more first.' : 'This file has ' + lines.length + ' lines.'; return; }
182
+ lines.forEach(function (r) { r.classList.remove('hit'); });
183
+ var row = lines[n - 1]; row.classList.add('hit');
184
+ try { row.scrollIntoView({ block: 'center' }); } catch (_e) { box.scrollTop = row.offsetTop - 40; }
185
+ note.textContent = 'Line ' + n;
186
+ }
187
+ function load(offset) {
188
+ return textPart(v, scope, f, offset).then(function (d) {
189
+ if (!v.alive()) return;
190
+ addLines(d.text, !d.more); end = d.end;
191
+ if (!loaded) { loaded = true; v.body.textContent = ''; v.body.appendChild(bar); v.body.appendChild(box); }
192
+ if (more) { more.remove(); more = null; }
193
+ if (d.more) { more = moreButton(v, 'Show more', function () { load(end); }); code.appendChild(more); }
194
+ if (f.line && lines.length >= f.line && !box.__jumped) { box.__jumped = true; input.value = String(f.line); go(f.line); }
195
+ else if (f.line && !d.more && !box.__jumped) { box.__jumped = true; go(f.line); }
196
+ });
197
+ }
198
+ load(0).catch(fail(v));
199
+ }
200
+
201
+ function renderTable(v, scope, f) {
202
+ var box = element('div', 'pv-scroll'), table = element('table', 'pv-table'), head = element('thead'), body = element('tbody'), note = element('p', 'rm-status');
203
+ table.appendChild(head); table.appendChild(body); box.appendChild(table);
204
+ var more = null, shown = 0;
205
+ function load(offset) {
206
+ return v.request(url(scope, f.id, 'table', '&offset=' + offset), true, 'json').then(function (d) {
207
+ if (!v.alive()) return;
208
+ if (!d || !Array.isArray(d.rows) || !Array.isArray(d.header)) throw new Error('No table');
209
+ if (!shown && !head.firstChild) {
210
+ var tr = element('tr'); tr.appendChild(element('th', 'pv-rn', '#'));
211
+ d.header.forEach(function (c) { var th = element('th', '', String(c)); th.setAttribute('scope', 'col'); tr.appendChild(th); });
212
+ head.appendChild(tr);
213
+ v.body.textContent = ''; v.body.appendChild(box); v.body.appendChild(note);
214
+ }
215
+ d.rows.forEach(function (r) {
216
+ var tr = element('tr'); shown++; tr.appendChild(element('td', 'pv-rn', String(shown)));
217
+ for (var k = 0; k < d.header.length || k < r.length; k++) tr.appendChild(element('td', '', String(r[k] == null ? '' : r[k])));
218
+ body.appendChild(tr);
219
+ });
220
+ if (more) { more.remove(); more = null; }
221
+ var words = [shown + (shown === 1 ? ' row' : ' rows') + ' shown'];
222
+ if (d.capped && !d.more) words.push('The preview stops at 5,000 rows. Download it for the rest.');
223
+ if (d.columnsCut) words.push('Only the first 50 columns are shown.');
224
+ note.textContent = words.join('. ') + (words.length > 1 ? '' : '.');
225
+ if (d.more) { more = moreButton(v, 'Show 200 more rows', function () { load(d.next); }); v.body.appendChild(more); }
226
+ });
227
+ }
228
+ load(0).catch(fail(v));
229
+ }
230
+
231
+ // HTML is served by the server under its own sandbox CSP (scripts off, no network), with the same policy injected as a
232
+ // <meta> at the top of the page. The frame is sandboxed too and never gets allow-same-origin. Full length.
233
+ function renderHtml(v, scope, f) {
234
+ var bar = element('div', 'pv-bar'), note = element('span', 'rm-status'), toggle = null, frame = null, on = false;
235
+ function show() {
236
+ if (frame) frame.remove();
237
+ frame = element('iframe', 'pv-frame');
238
+ frame.setAttribute('sandbox', on ? 'allow-scripts' : '');
239
+ frame.setAttribute('referrerpolicy', 'no-referrer');
240
+ frame.title = 'Preview of ' + f.name + (on ? ' (scripts on, no network)' : ' (scripts off)');
241
+ frame.src = url(scope, f.id, on ? 'html-run' : 'html');
242
+ v.body.appendChild(frame);
243
+ // Honest words: with scripts on, the page still cannot fetch anything or read COS, but a script can move its own
244
+ // frame to another address (no browser can stop that), so "cannot reach the internet" would overclaim.
245
+ note.textContent = on ? 'Scripts are on for this view only. They cannot fetch data or read COS.' : 'Scripts are off. Nothing on this page loads from the internet.';
246
+ toggle.textContent = on ? 'Turn scripts off' : 'Run scripts';
247
+ toggle.setAttribute('aria-pressed', String(on));
248
+ }
249
+ toggle = button('Run scripts', function () { on = !on; show(); });
250
+ bar.appendChild(toggle); bar.appendChild(note);
251
+ v.body.textContent = ''; v.body.appendChild(bar);
252
+ show();
253
+ }
254
+
255
+ // Decision 4a: a PDF opens inline on a desktop and in its own tab on a phone. "Phone" here is any touch-first screen,
256
+ // the unfolded Duo (703 wide) included: iOS Safari draws only part of a PDF inside a frame (plan canary 17, unrun).
257
+ function isPhone() {
258
+ try {
259
+ if (typeof W.isPhone === 'function' && W.isPhone()) return true;
260
+ return !!(window.matchMedia && window.matchMedia('(hover: none) and (pointer: coarse)').matches);
261
+ } catch (_e) { return false; }
262
+ }
263
+ function renderPdf(v, scope, f) {
264
+ v.body.textContent = '';
265
+ if (isPhone()) {
266
+ var p = element('p', 'rm-status', 'PDFs open in a new tab on a phone.'); v.body.appendChild(p);
267
+ var a = element('a', 'cx-btn', 'Open ' + f.name); a.href = url(scope, f.id, 'inline'); a.target = '_blank'; a.rel = 'noopener'; v.body.appendChild(a);
268
+ return;
269
+ }
270
+ // Same origin, so the browser's own PDF viewer draws it; the server sends it as application/pdf, nosniff.
271
+ var frame = element('iframe', 'pv-frame'); frame.title = f.name; frame.src = url(scope, f.id, 'inline');
272
+ v.body.appendChild(frame);
273
+ }
274
+
275
+ function renderImage(v, scope, f) {
276
+ status(v, 'Loading image…');
277
+ var gif = !NORMALIZED.test(f.name);
278
+ v.request(url(scope, f.id, gif ? 'raw' : 'inline'), true, 'blob').then(function (blob) {
279
+ if (!v.alive()) return;
280
+ if (!blob || !blob.size) throw new Error('No image');
281
+ var typed = new Blob([blob], { type: gif ? 'image/gif' : 'image/jpeg' });
282
+ v.setUrl(URL.createObjectURL(typed));
283
+ v.body.textContent = ''; var img = element('img'); img.alt = f.name; img.src = v.url(); v.body.appendChild(img);
284
+ }).catch(fail(v));
285
+ }
286
+
287
+ function renderOther(v, scope, f) {
288
+ v.body.textContent = '';
289
+ var box = element('div', 'pv-other');
290
+ if (parts(f).indexOf('thumb') >= 0) {
291
+ var t = element('span', 'pv-thumb', badge(f)); box.appendChild(t);
292
+ v.request(url(scope, f.id, 'thumb'), true, 'blob').then(function (blob) {
293
+ if (!v.alive() || !blob || !blob.size) return;
294
+ v.setUrl(URL.createObjectURL(new Blob([blob], { type: 'image/png' })));
295
+ t.textContent = ''; var img = element('img'); img.alt = ''; img.src = v.url(); t.appendChild(img);
296
+ }, function () { t.textContent = 'No preview image'; });
297
+ }
298
+ box.appendChild(element('p', 'rm-status', f.type + (f.size != null ? ' · ' + bytesWords(f.size) : '') + '. Download it, or open it on your Mac.'));
299
+ v.body.appendChild(box);
300
+ }
301
+
302
+ function render(v, scope, dto) {
303
+ var f = clean(dto); if (!f) return status(v, OFF);
304
+ if ((' ' + v.body.className + ' ').indexOf(' pv-body ') < 0) v.body.className += ' pv-body';
305
+ v.retry = function () { if (v.alive()) render(v, scope, dto); };
306
+ if (f.state !== 'available') return status(v, REASONS[f.reason] || OFF);
307
+ if (f.kind === 'markdown') return renderMarkdown(v, scope, f);
308
+ if (f.kind === 'text' || f.kind === 'code') return renderCode(v, scope, f);
309
+ if (f.kind === 'table') return renderTable(v, scope, f);
310
+ if (f.kind === 'html') return renderHtml(v, scope, f);
311
+ if (f.kind === 'pdf') return renderPdf(v, scope, f);
312
+ if (f.kind === 'image') return renderImage(v, scope, f);
313
+ return renderOther(v, scope, f);
314
+ }
315
+
316
+ W.preview = { chip: chip, render: render, clean: clean, parts: parts, kindOf: kindOf, url: url, failWords: failWords, isBusy: isBusy, reasons: REASONS, isPhone: isPhone };
317
+ W.registerPane('preview', W.preview);
318
+ })();
@@ -47,7 +47,8 @@
47
47
  stopped: false,
48
48
  inflight: false, // a poll is on the wire
49
49
  again: false, // something asked for a poll while one was out: one more as soon as it lands
50
- lastPollAt: 0 // when the last poll went out (Date.now)
50
+ lastPollAt: 0, // when the last poll went out (Date.now)
51
+ load: 'unread' // W13: 'unread' (no read yet), 'ok' (the last read was the server's answer) or 'failed' (it was not)
51
52
  };
52
53
  var DEFAULT_POLL_MS = 10000;
53
54
  var POLL_TIMEOUT_MS = 15000;
@@ -134,6 +135,19 @@
134
135
  // QA 6.99.27 (N4): the read asked for while this one was out goes through nudge, so it waits out the 1 s gap too.
135
136
  if (Q.again) { Q.again = false; nudge(); }
136
137
  }
138
+ // W13 (QA week of 11/01-11/08): whether the last read was the server's answer. A failed read leaves no cards, and Today's
139
+ // sheet read "no card" as "at the Mac's prompt" (a Claude session) with no way to read again. Each change tells the
140
+ // mounted sheets (their onChange), never on a read that changed nothing.
141
+ function noteLoad(state) {
142
+ if (Q.load === state) return;
143
+ Q.load = state;
144
+ tellMounts();
145
+ }
146
+ function tellMounts() {
147
+ mounts.forEach(function (m) {
148
+ if (m.onChange) { try { m.onChange(itemsFor(m.sessionId).length, atMacFor(m.sessionId).length); } catch (_e) {} }
149
+ });
150
+ }
137
151
  function poll() {
138
152
  clearTimeout(Q.timer);
139
153
  if (Q.stopped || document.hidden || !signedIn()) return;
@@ -141,7 +155,7 @@
141
155
  if (Q.inflight) { Q.again = true; return; }
142
156
  Q.inflight = true; Q.again = false; Q.lastPollAt = Date.now();
143
157
  capForPoll().then(function (cap) {
144
- if (!usable(cap)) { Q.again = false; landed(); clearCards(); schedule(60000); return; }
158
+ if (!usable(cap)) { Q.again = false; landed(); clearCards(); schedule(60000); noteLoad(cap ? 'ok' : 'failed'); return; }
145
159
  // 6.99.32 (QA 6.99.30 canary W1): a Mac that hangs (asleep, a stale Tailscale route) never answered and never
146
160
  // refused, so this read stayed in flight forever: no more polls, and the Mac never marked down. It gives up after
147
161
  // POLL_TIMEOUT_MS, says the Mac is not answering, and polls again. The limit covers the body too (QA 6.99.32
@@ -153,27 +167,32 @@
153
167
  // answered (any status) or did not (below); otherwise a "down" mark stranded Submit, Allow and Deny for minutes.
154
168
  if (W.noteServer) W.noteServer(true);
155
169
  if (res.status === 401) { clearTimeout(limit); Q.again = false; landed(); Q.stopped = true; clearCards(); return; }
156
- if (!res.ok) { clearTimeout(limit); schedule(DEFAULT_POLL_MS); landed(); return; }
170
+ if (!res.ok) { clearTimeout(limit); schedule(DEFAULT_POLL_MS); landed(); noteLoad('failed'); return; }
157
171
  return res.json().then(function (d) {
158
172
  clearTimeout(limit);
159
- if (!d || d.enabled === false) { Q.again = false; landed(); clearCards(); schedule(60000); return; }
173
+ if (!d || d.enabled === false) { Q.again = false; landed(); clearCards(); schedule(60000); noteLoad('ok'); return; }
160
174
  // Painted as soon as the read lands: take() draws the cards and repaints Today's Needs you and the pet.
175
+ // The state first, so a redraw take() makes already reads 'ok'; a read that changed no card redraws nothing, so
176
+ // the sheets still hear that the failure is over.
177
+ var was = Q.load;
178
+ Q.load = 'ok';
161
179
  take(Array.isArray(d.items) ? d.items : [], Array.isArray(d.settled) ? d.settled : []);
180
+ if (was !== 'ok') tellMounts();
162
181
  var every = Number(d.pollIntervalMs) > 0 ? Number(d.pollIntervalMs) : DEFAULT_POLL_MS;
163
182
  schedule(nextWait(every));
164
183
  landed();
165
184
  }, function () {
166
185
  // The body never finished (cut off, or the limit fired mid-body). The Mac did answer the headers, so it is not
167
186
  // marked down; the poll is freed and goes again.
168
- clearTimeout(limit); schedule(DEFAULT_POLL_MS); landed();
187
+ clearTimeout(limit); schedule(DEFAULT_POLL_MS); landed(); noteLoad('failed');
169
188
  });
170
189
  }, function () {
171
190
  // No answer at all: refused, unreachable, or the limit fired before the headers (the only abort this read has).
172
191
  clearTimeout(limit);
173
192
  if (W.noteServer) W.noteServer(false);
174
- schedule(DEFAULT_POLL_MS); landed();
193
+ schedule(DEFAULT_POLL_MS); landed(); noteLoad('failed');
175
194
  });
176
- }).catch(function () { schedule(DEFAULT_POLL_MS); landed(); });
195
+ }).catch(function () { schedule(DEFAULT_POLL_MS); landed(); noteLoad('failed'); });
177
196
  }
178
197
  // Read now, unless a read went out under NUDGE_GAP_MS ago (then at the end of that gap) or one is on the wire (then
179
198
  // once more when it lands). Today calls this when a session goes to waiting, so its question shows at once.
@@ -707,5 +726,6 @@
707
726
  if (W.onRoute) W.onRoute(function (r) { if (r && r.pane === 'today') nudge(); });
708
727
 
709
728
  W.sessionQuestions = { _state: Q, _take: take, _render: render, _presenceAge: presenceAge, _pollUrl: pollUrl, _key: onKey, _advanceMs: ADVANCE_MS, _todayPollMs: TODAY_POLL_MS, _fitKeyboard: fitKeyboard, poll: poll, nudge: nudge, mount: mount,
710
- itemsFor: itemsFor, atMacFor: atMacFor, answeredFor: answeredFor, wordsOf: wordsOf, macLine: MAC_LINE, answeredLine: ANSWERED_LINE };
729
+ itemsFor: itemsFor, atMacFor: atMacFor, answeredFor: answeredFor, wordsOf: wordsOf, macLine: MAC_LINE, answeredLine: ANSWERED_LINE,
730
+ loadState: function () { return Q.load; }, retry: function () { nudge(); } };
711
731
  })();