@bongos/core 1.24.26 → 1.24.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 (95) hide show
  1. package/.bongos-core.json +142 -92
  2. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +12 -0
  3. package/docs/copy-inventory.md +67 -48
  4. package/docs/copy-registry.json +279 -78
  5. package/docs/module-api-changelog.md +2 -0
  6. package/docs/page-inventory.json +10 -4
  7. package/docs/page-readings.json +4335 -4314
  8. package/docs/recipes/ui-look-before-you-ship.md +1 -0
  9. package/modules/autonomy/fence.js +16 -1
  10. package/modules/government/protected-surfaces.json +2 -0
  11. package/modules/hall-ui/public/ai-studio.html +1 -1
  12. package/modules/hall-ui/public/atlas.html +1 -1
  13. package/modules/hall-ui/public/blockers.html +1 -1
  14. package/modules/hall-ui/public/board-room.html +1 -1
  15. package/modules/hall-ui/public/city.html +1 -1
  16. package/modules/hall-ui/public/collab.html +1 -1
  17. package/modules/hall-ui/public/commands.html +1 -1
  18. package/modules/hall-ui/public/copy-desk.html +1 -1
  19. package/modules/hall-ui/public/core-move-notice.js +9 -6
  20. package/modules/hall-ui/public/deploy-handoff.js +203 -0
  21. package/modules/hall-ui/public/deploy.html +4 -2
  22. package/modules/hall-ui/public/deploy.js +7 -1
  23. package/modules/hall-ui/public/diagrams.html +1 -1
  24. package/modules/hall-ui/public/drachmae.html +1 -1
  25. package/modules/hall-ui/public/fleet.html +1 -1
  26. package/modules/hall-ui/public/gate.html +1 -1
  27. package/modules/hall-ui/public/goal-map.html +1 -1
  28. package/modules/hall-ui/public/goals.html +1 -1
  29. package/modules/hall-ui/public/government.html +1 -1
  30. package/modules/hall-ui/public/idea.html +1 -1
  31. package/modules/hall-ui/public/ideas.html +1 -1
  32. package/modules/hall-ui/public/index.html +1 -1
  33. package/modules/hall-ui/public/market-watch-item.html +1 -1
  34. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  35. package/modules/hall-ui/public/market-watch-settings.html +1 -1
  36. package/modules/hall-ui/public/market-watch.html +1 -1
  37. package/modules/hall-ui/public/modules.html +1 -1
  38. package/modules/hall-ui/public/my-agents.html +1 -1
  39. package/modules/hall-ui/public/primer.html +1 -1
  40. package/modules/hall-ui/public/profile.html +1 -1
  41. package/modules/hall-ui/public/project-settings.html +1 -1
  42. package/modules/hall-ui/public/ranks.html +1 -1
  43. package/modules/hall-ui/public/roadmap.html +1 -1
  44. package/modules/hall-ui/public/roster.html +1 -1
  45. package/modules/hall-ui/public/sessions.html +1 -1
  46. package/modules/hall-ui/public/settings-software-update.js +22 -8
  47. package/modules/hall-ui/public/settings.html +7 -5
  48. package/modules/hall-ui/public/shell.js +25 -73
  49. package/modules/hall-ui/public/street.html +1 -1
  50. package/modules/hall-ui/public/studio.html +1 -1
  51. package/modules/hall-ui/public/style.css +119 -39
  52. package/modules/hall-ui/public/task.html +1 -1
  53. package/modules/hall-ui/public/thinking.html +1 -1
  54. package/modules/hall-ui/public/tweak-editor.html +1 -1
  55. package/modules/hall-ui/public/update-pill.js +623 -0
  56. package/modules/hall-ui/public/watch.html +1 -1
  57. package/modules/hall-ui/public/work.html +1 -1
  58. package/modules/hall-ui/records/update-pill.md +12 -0
  59. package/modules/lifecycle/github-push.js +1 -0
  60. package/modules/lifecycle/routes/tasks.js +2 -2
  61. package/modules/lifecycle/runner-self-guard.js +122 -0
  62. package/modules/lifecycle/runner-self.js +154 -0
  63. package/modules/public-landing/public/account.html +3 -3
  64. package/modules/public-landing/public/assets/cosmos.css +45 -0
  65. package/modules/public-landing/public/community.html +4 -4
  66. package/modules/public-landing/public/connections.html +1 -1
  67. package/modules/public-landing/public/guild.html +1 -1
  68. package/modules/public-landing/public/invitations.html +4 -4
  69. package/modules/public-landing/public/projects.html +26 -26
  70. package/modules/status-ui/public/index.html +2 -2
  71. package/modules/status-ui/public/index.states.json +12 -0
  72. package/modules/status-ui/public/status.js +10 -2
  73. package/modules/status-ui/public/style.css +57 -0
  74. package/modules/ui-design/kit/README.md +1 -1
  75. package/modules/ui-design/kit/serve.js +8 -1
  76. package/package-lock.json +2 -2
  77. package/package.json +1 -1
  78. package/release-notes.json +14 -0
  79. package/scripts/bongos/autobongos-loop.js +4 -0
  80. package/scripts/bongos/autobongos-run.js +21 -4
  81. package/scripts/bongos/autobongos-tweak-lane.js +5 -3
  82. package/scripts/bongos/ship-flow.js +20 -0
  83. package/src/module-api.js +1 -1
  84. package/tests/autobongos_host_access.mjs +5 -3
  85. package/tests/autobongos_self_modification.mjs +420 -0
  86. package/tests/core_update_banner.mjs +6 -92
  87. package/tests/core_upgrade_owner_door.mjs +4 -56
  88. package/tests/deploy_handoff.mjs +206 -0
  89. package/tests/government_protected_surfaces.mjs +15 -0
  90. package/tests/hub_connections_page.mjs +1 -1
  91. package/tests/hub_invitations_page.mjs +1 -1
  92. package/tests/loading_mark.mjs +203 -0
  93. package/tests/nav_permission_atoms.mjs +3 -2
  94. package/tests/software_update.mjs +6 -2
  95. package/tests/update_pill.mjs +431 -0
@@ -0,0 +1,623 @@
1
+ // update-pill.js — the hall's Update pill: a newer core, taken in place (task 1005041).
2
+ //
3
+ // The owner, 2026-11-03: upgrading Bongos from a project's hall was "way too obstructing and
4
+ // not obvious what to do", and it should feel like the Claude app, which just tells you to
5
+ // restart. Before this, a hosted project's owner met a full-width banner, went to Settings,
6
+ // pressed Update, landed on the hub's deploy door on another site, pressed Check, then
7
+ // Confirm, then waited there. Now it is one control in the top bar, in four faces:
8
+ // ready "Update". Pressing it opens a small card: what the update brings, how long it
9
+ // takes, that it puts itself back if it goes wrong, and one Update now button.
10
+ // running "Updating…", and the card says where it has got to.
11
+ // done "Reload". The Claude moment: the new version is running, this page is not.
12
+ // held the newest version was tried here and rolled back; the card says so plainly.
13
+ //
14
+ // TWO STEPS, STILL (ADR 0293 D4). Opening the card is step one: it names the version that
15
+ // would go in. Update now is step two and sends THAT version. `offered` is read off the
16
+ // reading the card was drawn from, so a release that lands while the card is open cannot
17
+ // change what the press does.
18
+ //
19
+ // WHERE THE ACT HAPPENS IS UNCHANGED (ADR 0293 D1, ADR 0339 D3). The server names this
20
+ // project's door (GET /software-update `door`), and doorPlan() reads it:
21
+ // local this hall runs the door (the platform): it calls its own core-upgrade route.
22
+ // hub a hosted project: the act stays on the hub. A small hub window opens on the door
23
+ // with &handoff=1, this page tells it the version over postMessage, and the hub
24
+ // posts the move with the owner's OWN hub session (deploy-handoff.js). This
25
+ // project's server gains no capability; the only thing that crosses is a version.
26
+ // ask a hosted project the hub's operator runs: "Ask for this update" (task 1004595).
27
+ // cli a self-hosted instance: no button; the card names the command.
28
+ //
29
+ // PROGRESS IS READ, NEVER GUESSED (deploy.js's rule). The page watches its own public
30
+ // /version: still the old version is "starting", no answer is "restarting", the new one is
31
+ // done. A rollback is the server's upgrade hold naming the version (GET /core-update `held`).
32
+ // The run is remembered in this browser, so it follows the owner from page to page and a
33
+ // reload in the middle picks it up again.
34
+ //
35
+ // Its own file, loaded by shell.js only when there is something to say (shell.js is at its
36
+ // 1,500-line ratchet), and by settings.html, whose Update button runs the same flow.
37
+ // Node-require-safe (github-chip.js's tail), so the tests run the pure half without a page.
38
+ (function (root) {
39
+ 'use strict';
40
+
41
+ var RUN_KEY = 'bongos.update.run';
42
+ var MSG = 'bongos-update'; // the tag deploy-handoff.js answers to; both sides check it
43
+ var VERSION_RE = /^\d+\.\d+\.\d+$/;
44
+ // A hub door as the server words it (core-update.js HUB_UPGRADE_URL_RE's shape): an
45
+ // origin and /deploy?project=<slug>, nothing else. Anything else draws no button.
46
+ var HUB_DOOR_RE = /^(https?:\/\/[^\s"'<>/]+)\/deploy\?project=[A-Za-z0-9._%-]+$/;
47
+ var SETTINGS_HREF = '/settings#software-update';
48
+ var HIGHLIGHTS = 3;
49
+ var LINE_MAX = 120;
50
+ // The runner drains on a two-minute tick, then installs, migrates, restarts and checks
51
+ // health. Past SLOW_MS the card says it is taking longer than usual; past GIVE_UP_MS a
52
+ // remembered run is forgotten (the Settings history still has it).
53
+ var SLOW_MS = 8 * 60 * 1000;
54
+ var GIVE_UP_MS = 45 * 60 * 1000;
55
+ // How long a closed hub window is given to show that the update started anyway, for the
56
+ // case where the window could not talk back (its own one-press fallback was used).
57
+ var UNHEARD_MS = 4 * 60 * 1000;
58
+ var HOLD_EVERY = 5; // the hold is asked for on every fifth look, /version on every one
59
+ function num(v, dflt) { return typeof v === 'number' && isFinite(v) ? v : dflt; }
60
+ // A harness drives the loop without sleeping through it (deploy.js's __deployPollMs).
61
+ var POLL_MS = num(root && root.__updatePollMs, 4000);
62
+
63
+ // ---------- the pure half ----------
64
+
65
+ function isVersion(v) { return typeof v === 'string' && VERSION_RE.test(v); }
66
+
67
+ // Voice rule V4 (docs/voice.md): a release-note line is quoted ledger data, so its dashes
68
+ // become commas at render, the way settings-software-update.js does it.
69
+ function unDash(raw) {
70
+ return String(raw == null ? '' : raw).replace(/\s*[—–]\s*/g, ', ').replace(/,\s*,/g, ',').trim();
71
+ }
72
+ function clip(s) {
73
+ if (s.length <= LINE_MAX) return s;
74
+ var cut = s.slice(0, LINE_MAX - 1);
75
+ var space = cut.lastIndexOf(' ');
76
+ return (space > LINE_MAX / 2 ? cut.slice(0, space) : cut).replace(/[,;:.\s]+$/, '') + '…';
77
+ }
78
+
79
+ /** What the update brings, from GET /software-update's `whats_new`: the first few lines,
80
+ * newest first, and how many there are. `more` is true when older versions were left out
81
+ * of the reading, so the count is a floor. */
82
+ function highlights(whatsNew, n) {
83
+ var w = whatsNew || {};
84
+ var versions = Array.isArray(w.versions) ? w.versions : [];
85
+ var lines = [];
86
+ var count = 0;
87
+ for (var i = 0; i < versions.length; i++) {
88
+ var entries = versions[i] && Array.isArray(versions[i].entries) ? versions[i].entries : [];
89
+ for (var j = 0; j < entries.length; j++) {
90
+ var t = unDash(entries[j] && entries[j].text);
91
+ if (!t) continue;
92
+ count++;
93
+ if (lines.length < n) lines.push(clip(t));
94
+ }
95
+ }
96
+ return { lines: lines, count: count, more: !!w.more, read: w.ok !== false && versions.length > 0 };
97
+ }
98
+
99
+ /** Where Update now goes, from GET /software-update. Same rules as the Settings panel:
100
+ * no button for a viewer the door would refuse, and only a known door shape is used. */
101
+ function doorPlan(sw) {
102
+ if (!sw || sw.can_update !== true) return { kind: 'none' };
103
+ var door = sw.door || {};
104
+ var ask = sw.ask || {};
105
+ if (door.kind === 'local' && door.url === '/deploy') return { kind: 'local' };
106
+ if (door.kind === 'hub') {
107
+ var m = HUB_DOOR_RE.exec(String(door.url || ''));
108
+ if (!m) return ask.available ? { kind: 'ask' } : { kind: 'none' };
109
+ return { kind: 'hub', url: door.url, origin: m[1] };
110
+ }
111
+ if (door.kind === 'cli') return ask.available ? { kind: 'ask' } : { kind: 'cli' };
112
+ return { kind: 'none' };
113
+ }
114
+
115
+ /** The run this browser remembers: { to, from, at, via }, or null. Every touch is guarded,
116
+ * because storage throws in a private window and is absent in a vm. */
117
+ function readRun(storage, now) {
118
+ var run = null;
119
+ try { run = JSON.parse((storage && storage.getItem(RUN_KEY)) || 'null'); } catch (e) { run = null; }
120
+ if (!run || !isVersion(run.to) || !isFinite(run.at)) return null;
121
+ if (now - run.at > GIVE_UP_MS) { forgetRun(storage); return null; }
122
+ return run;
123
+ }
124
+ function keepRun(storage, run) {
125
+ try { if (storage) storage.setItem(RUN_KEY, JSON.stringify(run)); } catch (e) { /* this page still watches */ }
126
+ }
127
+ function forgetRun(storage) {
128
+ try { if (storage) storage.removeItem(RUN_KEY); } catch (e) { /* best-effort */ }
129
+ }
130
+
131
+ /** Which face the pill shows when a page loads, from GET /core-update and the remembered
132
+ * run. A run whose version this server already runs has landed (the page that loaded IS
133
+ * the new version), so there is nothing to say; the one-time what's-new note says it. */
134
+ function pillState(o) {
135
+ var u = (o && o.update) || {};
136
+ var run = o && o.run;
137
+ var held = o && o.held;
138
+ if (run) return u.running === run.to ? 'landed' : 'running';
139
+ if (held && isVersion(held.version) && held.version === u.latest) return 'held';
140
+ // "Behind" is said only from an answer (shell.js's old banner rule): a registry that did
141
+ // not answer is not "up to date", and it is not "update ready" either.
142
+ if (u.reading === 'ok' && u.behind && isVersion(u.latest)) return 'ready';
143
+ return 'none';
144
+ }
145
+
146
+ /** One look folded into the run's stage. `look.version` is what /version said, or null
147
+ * when the server did not answer; `look.held` is the held version when it was asked. */
148
+ function stageOf(run, look, now) {
149
+ if (look.version === run.to) return 'done';
150
+ if (look.held && look.held === run.to) return 'failed';
151
+ var slow = now - run.at > SLOW_MS;
152
+ if (look.version === null || run.down) return slow ? 'slow' : 'restarting';
153
+ return slow ? 'slow' : 'starting';
154
+ }
155
+
156
+ // ---------- the page half ----------
157
+
158
+ var state = {
159
+ api: null, update: null, held: null, sw: null, swAsked: null, swError: null, ended: null, note: null,
160
+ face: 'none', stage: null, run: null, error: null, pill: null, card: null,
161
+ popup: null, plan: null, offered: null, looks: 0, timer: null, popupTimer: null, listening: false,
162
+ };
163
+
164
+ function doc() { return root && root.document; }
165
+ function store() { try { return root && root.localStorage; } catch (e) { return null; } }
166
+ function now() { return Date.now(); }
167
+ function esc(s) { return root.OTB.escapeHtml(String(s == null ? '' : s)); }
168
+ function mono(v) { return '<span class="upd-ver">' + esc(v) + '</span>'; }
169
+
170
+ var DOT = '<span class="upd-pill__dot" aria-hidden="true"></span>';
171
+ var SPIN = '<span class="upd-pill__spin" aria-hidden="true"></span>';
172
+ var FACES = {
173
+ ready: { label: 'Update', title: 'A new version of Bongos is ready', lead: DOT },
174
+ held: { label: 'Update held', title: 'The newest version did not go in here', lead: DOT },
175
+ running: { label: 'Updating…', title: 'Bongos is updating', lead: SPIN },
176
+ done: { label: 'Reload', title: 'Bongos is updated. Reload to use it', lead: DOT },
177
+ };
178
+
179
+ function drawPill() {
180
+ var d = doc();
181
+ var face = FACES[state.face];
182
+ if (!d || !face) { if (state.pill) state.pill.remove(); state.pill = null; return; }
183
+ var side = d.querySelector && d.querySelector('#app-topbar .topbar-side');
184
+ if (!state.pill) {
185
+ if (!side) return;
186
+ var b = d.createElement('button');
187
+ b.type = 'button';
188
+ b.id = 'upd-pill';
189
+ b.className = 'upd-pill';
190
+ b.setAttribute('aria-haspopup', 'dialog');
191
+ b.setAttribute('aria-expanded', 'false');
192
+ b.setAttribute('aria-controls', 'upd-card');
193
+ b.addEventListener('click', function () {
194
+ if (state.face === 'done') { reload(); return; }
195
+ toggleCard();
196
+ });
197
+ side.insertBefore(b, side.firstChild);
198
+ state.pill = b;
199
+ }
200
+ state.pill.setAttribute('data-face', state.face);
201
+ state.pill.title = face.title;
202
+ state.pill.setAttribute('aria-label', face.title);
203
+ state.pill.innerHTML = face.lead + '<span class="upd-pill__label">' + face.label + '</span>';
204
+ }
205
+
206
+ function cardHtml(face) {
207
+ var u = state.update || {};
208
+ var close = '<button type="button" class="upd-card__close" id="upd-close" aria-label="Close">×</button>';
209
+ var run = state.run || state.ended;
210
+ var from = run ? run.from : u.running;
211
+ var to = run ? run.to : state.offered;
212
+ var versions = isVersion(from) && isVersion(to) ? '<p class="upd-card__ver">' + mono(from) + ' → ' + mono(to) + '</p>' : '';
213
+ var err = (state.error ? '<p class="upd-card__err" role="alert">' + esc(state.error) + '</p>' : '') +
214
+ (state.note ? '<p role="status">' + esc(state.note) + '</p>' : '');
215
+
216
+ if (face === 'held') {
217
+ var msg = (state.held && state.held.message) || ('Version ' + (state.held && state.held.version) + ' was tried on this project and rolled back. It is still on ' + (u.running || 'its current version') + ' and working.');
218
+ return close + '<h2 class="upd-card__h" id="upd-card-h">The newest version did not go in</h2>' +
219
+ '<p>' + esc(msg) + '</p><p class="upd-card__note">Nothing needs doing. The next version will be offered here when it is out.</p>' +
220
+ '<div class="upd-card__actions"><a class="upd-btn" href="' + SETTINGS_HREF + '">Details</a></div>';
221
+ }
222
+ if (face === 'running') {
223
+ var words = {
224
+ starting: 'Starting. The update begins within two minutes.',
225
+ restarting: 'Installing and restarting. This hall may stop answering for a moment.',
226
+ slow: 'This is taking longer than usual. It keeps going on its own.',
227
+ unheard: 'Checking that the update started…',
228
+ };
229
+ return close + '<h2 class="upd-card__h" id="upd-card-h">Updating Bongos</h2>' +
230
+ '<div class="upd-track" aria-hidden="true"><span></span></div>' +
231
+ '<p role="status">' + esc(words[state.stage] || words.starting) + '</p>' + versions +
232
+ '<p class="upd-card__note">You can keep working or close this card. The button up top says when it is done.</p>' +
233
+ (state.stage === 'slow' ? '<div class="upd-card__actions"><a class="upd-btn" href="' + SETTINGS_HREF + '">See where it got to</a></div>' : '');
234
+ }
235
+ if (face === 'done') {
236
+ return close + '<h2 class="upd-card__h" id="upd-card-h">Bongos is updated</h2>' +
237
+ '<p role="status">Reload to start using version ' + esc(to) + '.</p>' +
238
+ '<div class="upd-card__actions"><button type="button" class="upd-btn upd-btn--go" id="upd-reload">Reload</button></div>';
239
+ }
240
+ if (face === 'failed') {
241
+ return close + '<h2 class="upd-card__h" id="upd-card-h">The update did not go in</h2>' +
242
+ '<p role="status">The new version was not healthy, so this project went back to ' + esc(from) + '. It is working.</p>' +
243
+ '<div class="upd-card__actions"><a class="upd-btn" href="' + SETTINGS_HREF + '">Details</a></div>';
244
+ }
245
+ // ready
246
+ if (!state.sw) {
247
+ return close + '<h2 class="upd-card__h" id="upd-card-h">A new version of Bongos is ready</h2>' +
248
+ (state.swError ? '<p class="upd-card__err" role="alert">Could not read what it brings: ' + esc(state.swError) + '.</p>'
249
+ : '<p class="upd-card__note" role="status">Reading what it brings…</p>') + versions;
250
+ }
251
+ var h = highlights((state.sw.update || {}).whats_new, HIGHLIGHTS);
252
+ var brings = '';
253
+ if (h.lines.length) {
254
+ var lead = h.count > h.lines.length ? (h.more ? 'More than ' : '') + h.count + ' improvements, including:' : (h.count === 1 ? 'One improvement:' : h.count + ' improvements:');
255
+ brings = '<p>' + esc(lead) + '</p><ul class="upd-card__list">' + h.lines.map(function (l) { return '<li>' + esc(l) + '</li>'; }).join('') + '</ul>';
256
+ }
257
+ var plan = doorPlan(state.sw);
258
+ var action = '';
259
+ var note = '';
260
+ if (plan.kind === 'local' || plan.kind === 'hub') {
261
+ var host = '';
262
+ if (plan.kind === 'hub') { try { host = new URL(plan.origin).host; } catch (e) { host = ''; } }
263
+ note = '<p class="upd-card__note">Takes a few minutes. The project restarts once, and goes back to the version it runs now on its own if the new one is not healthy.' +
264
+ (host ? ' A small window from ' + esc(host) + ' opens for a moment to start it.' : '') + '</p>';
265
+ action = '<button type="button" class="upd-btn upd-btn--go" id="upd-go">Update now</button>';
266
+ } else if (plan.kind === 'ask') {
267
+ var ask = state.sw.ask || {};
268
+ var team = ask.hub_host ? 'the team at ' + ask.hub_host : 'the team that runs this project\'s server';
269
+ var asked = ask.requested && ask.requested.version === state.offered;
270
+ note = '<p class="upd-card__note">' + esc(asked ? 'You asked ' + team + ' for this update. This shows the new version once it is installed.'
271
+ : 'This project runs on a server that ' + team + ' looks after, so they install its updates.') + '</p>';
272
+ if (!asked) action = '<button type="button" class="upd-btn upd-btn--go" id="upd-ask">Ask for this update</button>';
273
+ } else if (plan.kind === 'cli') {
274
+ note = '<p class="upd-card__note">This project is updated on the server that runs it: <code>bongos upgrade</code> there takes this version, and puts the old one back if it fails.</p>';
275
+ } else {
276
+ note = '<p class="upd-card__note">Only the people allowed to update this project can start it.</p>';
277
+ }
278
+ return close + '<h2 class="upd-card__h" id="upd-card-h">A new version of Bongos is ready</h2>' + brings + note + versions + err +
279
+ '<div class="upd-card__actions">' + action + '<a class="upd-btn" href="' + SETTINGS_HREF + '">What\'s new</a></div>';
280
+ }
281
+
282
+ // The card's own face: the pill's, except a rollback seen on this page, which the card
283
+ // tells as what just happened while the pill settles on "Update held".
284
+ function cardFace() { return state.face === 'held' && state.stage === 'failed' ? 'failed' : state.face; }
285
+
286
+ function drawCard() {
287
+ if (!doc() || !state.card) return;
288
+ state.card.innerHTML = cardHtml(cardFace());
289
+ wireCard();
290
+ }
291
+
292
+ function wireCard() {
293
+ var c = state.card;
294
+ var on = function (id, fn) { var el = c.querySelector('#' + id); if (el) el.addEventListener('click', fn); };
295
+ on('upd-close', closeCard);
296
+ on('upd-go', function () { updateNow(); });
297
+ on('upd-ask', function (e) { askForIt(e.currentTarget); });
298
+ on('upd-reload', reload);
299
+ on('upd-open', function () { updateNow(); });
300
+ }
301
+
302
+ // Under the pill, its left edge on the pill's, pulled in where that would run off the
303
+ // right of the window. On a phone the sheet's own rule (16px each side) wins.
304
+ function placeCard() {
305
+ if (!state.card || !state.pill || !state.pill.getBoundingClientRect) return;
306
+ var r = state.pill.getBoundingClientRect();
307
+ var s = state.card.style;
308
+ s.top = Math.round(r.bottom + 8) + 'px';
309
+ var vw = root.innerWidth || 0;
310
+ if (vw <= 640) { s.left = ''; s.right = ''; return; }
311
+ var width = Math.min(360, vw - 32);
312
+ s.left = Math.round(Math.max(16, Math.min(r.left, vw - width - 16))) + 'px';
313
+ s.right = 'auto';
314
+ }
315
+
316
+ function openCard() {
317
+ var d = doc();
318
+ if (!d) return;
319
+ if (!state.card) {
320
+ var c = d.createElement('div');
321
+ c.id = 'upd-card';
322
+ c.className = 'upd-card';
323
+ c.setAttribute('role', 'dialog');
324
+ c.setAttribute('aria-labelledby', 'upd-card-h');
325
+ d.body.appendChild(c);
326
+ state.card = c;
327
+ if (root.addEventListener) root.addEventListener('resize', function () { if (state.card && !state.card.hidden) placeCard(); });
328
+ d.addEventListener('keydown', function (e) { if (e.key === 'Escape' && state.card && !state.card.hidden) { closeCard(); if (state.pill) state.pill.focus(); } });
329
+ d.addEventListener('click', function (e) {
330
+ if (!state.card || state.card.hidden) return;
331
+ var t = e.target;
332
+ if (state.card.contains(t) || (state.pill && state.pill.contains(t))) return;
333
+ closeCard();
334
+ });
335
+ }
336
+ state.card.hidden = false;
337
+ if (state.pill) state.pill.setAttribute('aria-expanded', 'true');
338
+ placeCard();
339
+ drawCard();
340
+ var first = state.card.querySelector('.upd-btn--go') || state.card.querySelector('#upd-close');
341
+ if (first && first.focus) first.focus();
342
+ if (state.face === 'ready') readSoftwareUpdate();
343
+ }
344
+ function closeCard() {
345
+ if (state.card) state.card.hidden = true;
346
+ if (state.pill) state.pill.setAttribute('aria-expanded', 'false');
347
+ }
348
+ function toggleCard() { if (state.card && !state.card.hidden) closeCard(); else openCard(); }
349
+
350
+ function setFace(face) {
351
+ state.face = face;
352
+ drawPill();
353
+ if (state.card && !state.card.hidden) drawCard();
354
+ }
355
+
356
+ // The card's details, read on first open: an update's notes come out of the newer
357
+ // package's download, and most page loads never open the card.
358
+ function readSoftwareUpdate() {
359
+ if (state.sw || state.swAsked) return state.swAsked;
360
+ state.swError = null;
361
+ state.swAsked = state.api.request('GET', '/api/bongos/software-update').then(function (res) {
362
+ if (!res || !res.ok || !res.data) throw new Error((res && res.data && res.data.error && res.data.error.message) || 'HTTP ' + (res && res.status));
363
+ takeReading(res.data);
364
+ }).then(null, function (e) {
365
+ state.swAsked = null;
366
+ state.swError = String((e && e.message) || 'no answer').replace(/\.$/, '');
367
+ if (state.card && !state.card.hidden) drawCard();
368
+ });
369
+ return state.swAsked;
370
+ }
371
+ function takeReading(sw) {
372
+ state.sw = sw;
373
+ var u = sw && sw.update;
374
+ // The version this card offers is the Settings reading's own `offered` (the same channel
375
+ // rule the banner read applies), and only ever one the server named.
376
+ if (u && u.reading === 'ok' && isVersion(u.offered)) state.offered = u.offered;
377
+ if (state.card && !state.card.hidden) drawCard();
378
+ }
379
+
380
+ function fail(message) {
381
+ state.error = message;
382
+ if (state.face === 'running' && !state.run) state.face = 'ready';
383
+ setFace(state.face);
384
+ if (!state.card || state.card.hidden) openCard();
385
+ }
386
+
387
+ /** Step two. Synchronous up to window.open, which a browser allows only inside the press. */
388
+ function updateNow(sw) {
389
+ if (sw) takeReading(sw);
390
+ state.error = null;
391
+ state.note = null;
392
+ var to = state.offered;
393
+ var plan = doorPlan(state.sw);
394
+ if (!isVersion(to) || (plan.kind !== 'local' && plan.kind !== 'hub')) return false;
395
+ state.plan = plan;
396
+ if (plan.kind === 'hub') {
397
+ var w = root.open(plan.url + '&handoff=1', 'bongos-update', 'popup=yes,width=460,height=620');
398
+ if (!w) {
399
+ state.error = 'Your browser blocked the update window.';
400
+ if (!state.card || state.card.hidden) openCard(); else drawCard();
401
+ var go = state.card && state.card.querySelector('#upd-go');
402
+ if (go) { go.id = 'upd-open'; go.textContent = 'Open the update window'; wireCard(); }
403
+ return false;
404
+ }
405
+ state.popup = w;
406
+ listen();
407
+ state.stage = 'starting';
408
+ setFace('running');
409
+ watchPopup();
410
+ return true;
411
+ }
412
+ state.stage = 'starting';
413
+ setFace('running');
414
+ startLocal(to);
415
+ return true;
416
+ }
417
+
418
+ // The platform's own hall runs the door itself: the same two calls deploy.js makes, with
419
+ // the version this card showed. A preview that names a different version (a release landed
420
+ // in between, or the door's own reading is a few minutes behind the registry's) is never
421
+ // sent: the card names the door's version instead, and the next press takes THAT one, as
422
+ // the hub's window does. Shown, then confirmed; never swapped under the owner.
423
+ function startLocal(to) {
424
+ var api = state.api;
425
+ api.request('GET', '/api/bongos/provisioning/control-plane').then(function (res) {
426
+ var p = res && res.ok && res.data && res.data.preview;
427
+ if (!p || !p.instance_id) throw new Error(errorOf(res, 'This platform has no update door yet.'));
428
+ if (p.recommended_version !== to) {
429
+ if (!isVersion(p.recommended_version)) throw new Error('This platform\'s update door has no version to offer just now.');
430
+ state.offered = p.recommended_version;
431
+ state.note = 'The update door offers version ' + p.recommended_version + ', not the one shown before. Update now takes ' + p.recommended_version + '.';
432
+ state.face = 'ready';
433
+ setFace('ready');
434
+ if (!state.card || state.card.hidden) openCard();
435
+ return null;
436
+ }
437
+ return api.request('POST', '/api/bongos/provisioning/instances/' + encodeURIComponent(p.instance_id) + '/core-upgrade', { body: { to: to } });
438
+ }).then(function (res) {
439
+ if (res === null) return;
440
+ if (!res || !res.ok) throw new Error(errorOf(res, 'The update could not be started.'));
441
+ begin(to);
442
+ }).then(null, function (e) { fail(String((e && e.message) || 'The update could not be started.')); });
443
+ }
444
+ // The server's own sentence when it wrote one; its refusals say what to do instead.
445
+ function errorOf(res, fallback) {
446
+ var err = (res && res.data && res.data.error) || {};
447
+ return err.message && err.message !== err.code ? err.message : (res && res.status ? fallback + ' (' + res.status + ')' : fallback);
448
+ }
449
+
450
+ // ---------- the hub window ----------
451
+
452
+ function listen() {
453
+ if (state.listening || !root.addEventListener) return;
454
+ state.listening = true;
455
+ root.addEventListener('message', onMessage);
456
+ }
457
+ /** Only the window this page opened, on the hub's origin, speaking this protocol. */
458
+ function onMessage(e) {
459
+ var plan = state.plan;
460
+ if (!plan || plan.kind !== 'hub' || !state.popup || e.source !== state.popup || e.origin !== plan.origin) return;
461
+ var m = e.data;
462
+ if (!m || m.tag !== MSG) return;
463
+ if (m.step === 'ready') {
464
+ state.popup.postMessage({ tag: MSG, step: 'go', to: state.offered }, plan.origin);
465
+ } else if (m.step === 'started' && m.to === state.offered) {
466
+ begin(state.offered);
467
+ } else if (m.step === 'refused') {
468
+ stopPopupWatch();
469
+ fail(typeof m.message === 'string' && m.message ? m.message.slice(0, 400) : 'The update was refused.');
470
+ }
471
+ }
472
+ // A window closed before it said "started". If it could not talk back at all (a browser
473
+ // that cuts the link between the two windows), the owner may still have pressed its own
474
+ // button, so the page watches for a short while before saying it did not start.
475
+ function watchPopup() {
476
+ stopPopupWatch();
477
+ state.popupTimer = root.setInterval(function () {
478
+ var w = state.popup;
479
+ if (state.run) { stopPopupWatch(); return; }
480
+ if (w && !w.closed) return;
481
+ stopPopupWatch();
482
+ if (state.face !== 'running') return;
483
+ state.stage = 'unheard';
484
+ begin(state.offered, { unheard: true });
485
+ }, 700);
486
+ }
487
+ function stopPopupWatch() {
488
+ if (state.popupTimer) root.clearInterval(state.popupTimer);
489
+ state.popupTimer = null;
490
+ }
491
+
492
+ // ---------- watching it go in ----------
493
+
494
+ function begin(to, opts) {
495
+ var u = state.update || {};
496
+ stopPopupWatch();
497
+ var unheard = !!(opts && opts.unheard);
498
+ state.run = { to: to, from: u.running || null, at: now(), via: state.plan ? state.plan.kind : null, unheard: unheard };
499
+ if (!unheard) keepRun(store(), state.run);
500
+ state.stage = unheard ? 'unheard' : 'starting';
501
+ setFace('running');
502
+ schedule();
503
+ }
504
+
505
+ function schedule() {
506
+ if (state.timer) root.clearTimeout(state.timer);
507
+ state.timer = root.setTimeout(look, POLL_MS);
508
+ }
509
+
510
+ function look() {
511
+ state.timer = null;
512
+ var run = state.run;
513
+ if (!run) return;
514
+ state.looks++;
515
+ var askHold = state.looks % HOLD_EVERY === 0;
516
+ var version = root.fetch('/version', { cache: 'no-store', credentials: 'same-origin' }).then(function (r) {
517
+ return r && r.ok ? r.json().then(function (j) { return j && isVersion(j.coreVersion) ? j.coreVersion : null; }) : null;
518
+ }).then(null, function () { return null; });
519
+ var held = askHold ? state.api.request('GET', '/api/bongos/core-update').then(function (res) {
520
+ return res && res.status === 200 && res.data && res.data.held ? res.data.held.version : null;
521
+ }, function () { return null; }) : null;
522
+ Promise.all([version, held]).then(function (got) {
523
+ if (state.run !== run) return;
524
+ var v = got[0];
525
+ if (v === null) run.down = true;
526
+ var stage = stageOf(run, { version: v, held: got[1] }, now());
527
+ if (run.unheard) {
528
+ // Nothing heard from the window: the first sign of movement makes it a real run;
529
+ // no sign at all inside UNHEARD_MS means it did not start.
530
+ if (stage === 'starting' || stage === 'slow') {
531
+ if (now() - run.at > UNHEARD_MS) {
532
+ state.run = null;
533
+ fail('The update window closed before the update started.');
534
+ return;
535
+ }
536
+ stage = 'unheard';
537
+ } else if (stage !== 'done' && stage !== 'failed') {
538
+ run.unheard = false;
539
+ keepRun(store(), run);
540
+ }
541
+ }
542
+ state.stage = stage;
543
+ if (stage === 'done' || stage === 'failed') {
544
+ forgetRun(store());
545
+ state.run = null;
546
+ state.ended = { to: run.to, from: run.from };
547
+ if (stage === 'failed') {
548
+ // The hold now names this version, so the pill stays as "Update held" and the
549
+ // card says what happened, once, without being asked.
550
+ state.held = { version: run.to };
551
+ setFace('held');
552
+ if (!state.card || state.card.hidden) openCard();
553
+ return;
554
+ }
555
+ setFace('done');
556
+ return;
557
+ }
558
+ setFace('running');
559
+ schedule();
560
+ });
561
+ }
562
+
563
+ function reload() {
564
+ forgetRun(store());
565
+ if (root.location && root.location.reload) root.location.reload();
566
+ }
567
+
568
+ // "Ask for this update" (task 1004595): one press reaches the people who run the server.
569
+ function askForIt(btn) {
570
+ btn.disabled = true;
571
+ btn.textContent = 'Asking…';
572
+ state.api.request('POST', '/api/bongos/software-update/request', { body: {} }).then(function (res) {
573
+ var data = (res && res.data) || {};
574
+ var request = data.request || (data.error && data.error.details && data.error.details.request);
575
+ if (!((res.ok || res.status === 429) && request)) throw new Error(errorOf(res, 'Could not send the request.'));
576
+ state.sw.ask = Object.assign({}, state.sw.ask, { requested: request });
577
+ drawCard();
578
+ }).then(null, function (e) {
579
+ state.error = String((e && e.message) || 'Could not send the request.');
580
+ drawCard();
581
+ });
582
+ }
583
+
584
+ /** shell.js hands over GET /core-update's answer; the pill decides what, if anything, to show. */
585
+ function mount(data, opts) {
586
+ state.api = opts && opts.api;
587
+ if (!state.api || !root.document) return null;
588
+ var d = data || {};
589
+ state.update = d.update || null;
590
+ state.held = d.held || null;
591
+ if (state.update && isVersion(state.update.latest) && !state.offered) state.offered = state.update.latest;
592
+ var run = readRun(store(), now());
593
+ var face = pillState({ update: state.update, held: state.held, run: run });
594
+ if (face === 'landed') { forgetRun(store()); face = 'none'; }
595
+ if (face === 'running') {
596
+ state.run = run;
597
+ state.offered = run.to;
598
+ state.stage = 'starting';
599
+ }
600
+ state.face = face;
601
+ drawPill();
602
+ if (face === 'running') schedule();
603
+ return face;
604
+ }
605
+
606
+ var api = {
607
+ mount: mount,
608
+ updateNow: function (sw, opts) {
609
+ if (opts && opts.api) state.api = opts.api;
610
+ if (!state.api) return false;
611
+ if (!state.update && sw && sw.update) state.update = { running: sw.update.running, latest: sw.update.offered, reading: sw.update.reading, behind: !!sw.update.offered };
612
+ if (!state.pill) { state.face = 'ready'; drawPill(); }
613
+ return updateNow(sw);
614
+ },
615
+ // the pure half, for the tests
616
+ highlights: highlights, doorPlan: doorPlan, pillState: pillState, stageOf: stageOf,
617
+ readRun: readRun, keepRun: keepRun, forgetRun: forgetRun, isVersion: isVersion,
618
+ RUN_KEY: RUN_KEY, MSG: MSG, SLOW_MS: SLOW_MS, GIVE_UP_MS: GIVE_UP_MS,
619
+ _state: state,
620
+ };
621
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
622
+ if (root && !root.OTBUpdatePill) root.OTBUpdatePill = api;
623
+ })(typeof window !== 'undefined' ? window : null);
@@ -168,7 +168,7 @@
168
168
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
169
169
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
170
170
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
171
- <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
171
+ <script src="/builders/shell.js?v=2026-11-03-update-pill"></script>
172
172
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
173
173
  <script src="/builders/palette.js" defer></script>
174
174
  <!-- shared component kit (window.OTBKit) — tabs, ledger, pager (task 2166). -->
@@ -174,7 +174,7 @@
174
174
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
175
175
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
176
176
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
177
- <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
177
+ <script src="/builders/shell.js?v=2026-11-03-update-pill"></script>
178
178
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
179
179
  <script src="/builders/palette.js" defer></script>
180
180
  <!-- task 1004727: the ONE karma vote control every task/idea list renders -->
@@ -0,0 +1,12 @@
1
+ # The Update pill — a newer core, taken in place from any hall page (task 1005041)
2
+
3
+ The owner, 2026-11-03: upgrading Bongos from a project's hall was "way too obstructing and not obvious what to do", and it should feel like the Claude app, which just tells you to restart. A hosted project's owner used to meet a full-width banner, go to Settings, press Update, land on the hub's deploy door on another site, press Check, then Confirm, then wait there. What this settled:
4
+
5
+ - **A pill, not a strip.** `update-pill.js` draws one button at the head of the top bar's right side (the Link GitHub chip's spot and shape, one step louder: an accent dot), for the same viewers the banner had (`core.pin.move`). Four faces: `Update`, `Updating…`, `Reload` (filled accent, the Claude moment) and `Update held`. It has no dismiss: it is quiet enough to leave. The one-time "Bongos is updated" note (`core-move-notice.js`) is the same card, pinned under the top bar's right end, not a second strip.
6
+ - **The card is step one, Update now is step two (ADR 0293 D4).** The card names the version (`from → to`, small mono under plain-language highlights from the release notes) and Update now sends exactly that one. A door that names another version is never sent: the card shows the door's version and the next press takes it.
7
+ - **The act stays where it was.** On the platform the pill calls its own door (`/provisioning/control-plane`, then the core-upgrade POST). On a hosted project it opens the hub's door as a small window (`&handoff=1`); `deploy-handoff.js` there says `ready`, hears `go` with the version, and moves the project with the owner's own hub session. The hub hears only the window that opened it, from the project's registered address (`domain`), naming the version its own preview would move to. No word from the hall means one press in the window, never a move on load. Self-hosted: the card names `bongos upgrade`; operator-run: "Ask for this update".
8
+ - **Progress is read off the project's own `/version`**, with the hold (`GET /core-update` `held`) asked every fifth look for a rollback. The run is remembered in `localStorage` (`bongos.update.run`) so it follows the owner across pages and survives the restart; it is forgotten when the version lands or after 45 minutes.
9
+ - **Settings uses the same flow.** Settings → Software update's Update button hands its reading to `OTBUpdatePill.updateNow`; its `href` stays the door's for a page whose pill script did not load.
10
+ - **shell.js got smaller.** The banner's 109 lines became a 61-line loader that fetches `/core-update` once per page and loads the pill only when there is news or a run in flight (1,499 → 1,451 lines).
11
+
12
+ Tests: `tests/update_pill.mjs` (the pure half, and the real shell + pill in a DOM stub: who sees it, never a link, both doors, refusals, a blocked window, held, a run across pages, done → Reload), `tests/deploy_handoff.mjs` (the hub's three refusals, and its page run for real). Each origin, opener and version check was deleted once and watched to go red. Harness fixture: `core-update.json` (behind, so the pill shows).