@terminus-ai/cli 0.0.1

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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1055 -0
  3. package/bin/agent-discovery.mjs +71 -0
  4. package/bin/agent-icon.mjs +77 -0
  5. package/bin/agent-models.mjs +77 -0
  6. package/bin/agent-type.mjs +51 -0
  7. package/bin/agentdev.mjs +657 -0
  8. package/bin/app-route-script.mjs +59 -0
  9. package/bin/app-runtime-contract.mjs +2 -0
  10. package/bin/appdev-remote.mjs +346 -0
  11. package/bin/appdev.mjs +4446 -0
  12. package/bin/apps.mjs +5512 -0
  13. package/bin/capability-calls.mjs +437 -0
  14. package/bin/capsule-data.mjs +260 -0
  15. package/bin/client.mjs +189 -0
  16. package/bin/commands.mjs +1194 -0
  17. package/bin/dev-capsules.mjs +1599 -0
  18. package/bin/dev-contract.mjs +262 -0
  19. package/bin/dev-data.mjs +287 -0
  20. package/bin/dev-members.mjs +18 -0
  21. package/bin/dev-net.mjs +316 -0
  22. package/bin/dev-notification-popup.mjs +628 -0
  23. package/bin/dev-ports.mjs +567 -0
  24. package/bin/dev-server-binding.mjs +35 -0
  25. package/bin/dev-server-ops.mjs +1086 -0
  26. package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
  27. package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
  28. package/bin/dev-ui/OFL.txt +92 -0
  29. package/bin/dev-ui/agent-robot.webp +0 -0
  30. package/bin/dev-ui/app.js +5217 -0
  31. package/bin/dev-ui/highlight.js +195 -0
  32. package/bin/dev-ui/index.html +34 -0
  33. package/bin/dev-ui/style.css +3640 -0
  34. package/bin/devlint.mjs +112 -0
  35. package/bin/devserver.mjs +2127 -0
  36. package/bin/devtriggers.mjs +367 -0
  37. package/bin/endpoints.mjs +156 -0
  38. package/bin/errors.mjs +61 -0
  39. package/bin/files.mjs +169 -0
  40. package/bin/horizontal-capabilities/v1/contract.json +280 -0
  41. package/bin/http.mjs +500 -0
  42. package/bin/lint-manifests/justbash-commands.json +88 -0
  43. package/bin/lint-manifests/python-stdlib.json +295 -0
  44. package/bin/login-page.mjs +488 -0
  45. package/bin/schedules.mjs +664 -0
  46. package/bin/server-sandbox.mjs +204 -0
  47. package/bin/servicedev.mjs +425 -0
  48. package/bin/sync.mjs +357 -0
  49. package/bin/terminus.js +3666 -0
  50. package/bin/toolchain.mjs +125 -0
  51. package/bin/vendor/app-runtime-v1/app-host.json +124 -0
  52. package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
  53. package/bin/vendor/app-runtime-v1/doors.json +2867 -0
  54. package/bin/vendor/appd/node-harness.mjs +209 -0
  55. package/bin/vendor/appd/python-harness.py +12 -0
  56. package/bin/vendor/appd/server-protocol.json +84 -0
  57. package/bin/vendor/where.mjs +541 -0
  58. package/bin/versioning.mjs +72 -0
  59. package/bin/write-rules.mjs +398 -0
  60. package/package.json +41 -0
@@ -0,0 +1,628 @@
1
+ /*
2
+ * The local host's notification corner.
3
+ *
4
+ * On Terminus a notification lands on the account, the bell in the menu bar
5
+ * grows a red dot, and a card arrives at the top right in the desk's own
6
+ * glass. `terminus dev` has no menu bar and no desk, so this is the stand-in:
7
+ * the same corner, the same card, the same two answers on an invitation —
8
+ * drawn here in a shadow root so an app's own stylesheet can neither reach it
9
+ * nor be reached by it.
10
+ *
11
+ * It says DEV on every card, because a local harness that cannot be told
12
+ * from the real thing is a lie about which world you are in. Beyond that it
13
+ * is cut to match the app it floats over rather than the desk it imitates: the
14
+ * app's 13px panel radius, the desk's frost made nearly opaque (a card over
15
+ * somebody's controls must not show them through it), and two lines — who,
16
+ * then what.
17
+ *
18
+ * The measurements are the desk card's, carried here by hand because nothing
19
+ * of the product's CSS reaches a dev server: 244px wide, an 18px face in the
20
+ * line of facts rather than an avatar down the side, that line in 12px grey,
21
+ * the thing itself in 13px, and 22px answers. Keep them in step with
22
+ * `DraftNoticeBody` and `.aqua-notice-card` in terminus-frontend.
23
+ */
24
+
25
+ const POPUP_STYLES = String.raw`
26
+ :host {
27
+ all: initial;
28
+ }
29
+
30
+ * {
31
+ box-sizing: border-box;
32
+ }
33
+
34
+ [hidden] {
35
+ display: none !important;
36
+ }
37
+
38
+ /* The column: notifications stack down from the corner, newest on top. */
39
+ .stack {
40
+ position: fixed;
41
+ z-index: 2147483647;
42
+ top: 12px;
43
+ right: 12px;
44
+ display: grid;
45
+ gap: 8px;
46
+ width: min(244px, calc(100vw - 24px));
47
+ font: 400 13px/1.35 -apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, "Segoe UI", sans-serif;
48
+ letter-spacing: -0.01em;
49
+ -webkit-font-smoothing: antialiased;
50
+ }
51
+
52
+ /* Two lines for one thought: who, then what. It was three — the app's name on
53
+ a line of its own, then the sender, then the message — and the app's name
54
+ said nothing, because every card on a dev server comes from the app being
55
+ developed. The face, the sender and the message are the whole of it; DEV
56
+ and the time are metadata, so they sit at the end of the first line where
57
+ metadata goes. */
58
+ .notice {
59
+ position: relative;
60
+ display: block;
61
+ padding: 8px 12px;
62
+ border: 0.5px solid rgb(0 0 0 / 0.22);
63
+ /* The app's own panel radius, not the desk's menu radius: this card lives
64
+ inside an app window and should be cut like the panels next to it. */
65
+ border-radius: 13px;
66
+ /* Frosted, not see-through. On the desk a notification floats on wallpaper
67
+ and translucency is the point; over an app it floats on somebody's
68
+ controls, and a header icon showing through a card reads as a second
69
+ button on the card. The blur stays; the see-through does not. */
70
+ background: color-mix(in srgb, #2d68d8 4%, rgb(250 251 254 / 0.92));
71
+ -webkit-backdrop-filter: blur(36px) saturate(200%);
72
+ backdrop-filter: blur(36px) saturate(200%);
73
+ box-shadow: 0 10px 30px rgb(0 0 0 / 0.18), inset 0 1px 0 rgb(255 255 255 / 0.4);
74
+ color: #1c1c1c;
75
+ animation: notice-in 200ms cubic-bezier(0.2, 1.24, 0.36, 1) both;
76
+ }
77
+
78
+ .face {
79
+ position: relative;
80
+ display: grid;
81
+ flex: 0 0 18px;
82
+ width: 18px;
83
+ height: 18px;
84
+ place-items: center;
85
+ overflow: hidden;
86
+ border-radius: 50%;
87
+ background: linear-gradient(180deg, hsl(var(--hue, 211) 44% 69%), hsl(var(--hue, 211) 42% 53%));
88
+ box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.3), inset 0 0 0 0.5px rgb(0 0 0 / 0.06);
89
+ color: #fff;
90
+ font-size: 8px;
91
+ font-weight: 600;
92
+ text-transform: uppercase;
93
+ }
94
+
95
+ .face img {
96
+ position: absolute;
97
+ inset: 0;
98
+ width: 100%;
99
+ height: 100%;
100
+ object-fit: cover;
101
+ }
102
+
103
+ .text { min-width: 0; }
104
+
105
+ .top {
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 8px;
109
+ min-width: 0;
110
+ color: #55565f;
111
+ font-size: 12px;
112
+ }
113
+
114
+ .who {
115
+ min-width: 0;
116
+ flex: 1 1 auto;
117
+ overflow: hidden;
118
+ font-weight: 600;
119
+ text-overflow: ellipsis;
120
+ white-space: nowrap;
121
+ }
122
+
123
+ /* Which world this is, said once and small. */
124
+ .meta {
125
+ flex: 0 0 auto;
126
+ font-variant-numeric: tabular-nums;
127
+ }
128
+
129
+ .tag {
130
+ /* The chip and the time share one span, so the line's gap does not reach
131
+ between them; this does. */
132
+ margin-right: 4px;
133
+ padding: 0 4px;
134
+ border-radius: 3px;
135
+ background: rgb(8 20 48 / 0.08);
136
+ color: #4a4d57;
137
+ font-size: 9px;
138
+ font-weight: 700;
139
+ letter-spacing: 0.06em;
140
+ text-transform: uppercase;
141
+ }
142
+
143
+ .line {
144
+ display: -webkit-box;
145
+ overflow: hidden;
146
+ margin: 4px 0 0;
147
+ font-size: 13px;
148
+ line-height: 18px;
149
+ -webkit-box-orient: vertical;
150
+ -webkit-line-clamp: 2;
151
+ }
152
+
153
+ .error {
154
+ margin: 3px 0 0;
155
+ color: #b03a34;
156
+ font-size: 11.5px;
157
+ }
158
+
159
+ .actions {
160
+ display: flex;
161
+ gap: 8px;
162
+ margin-top: 8px;
163
+ }
164
+
165
+ .action {
166
+ border: 0;
167
+ border-radius: 7px;
168
+ min-height: 22px;
169
+ padding: 0 10px;
170
+ cursor: pointer;
171
+ font: 590 11px/1.25 inherit;
172
+ letter-spacing: -0.01em;
173
+ transition: background 140ms ease, opacity 140ms ease, scale 140ms cubic-bezier(0.2, 1.24, 0.36, 1);
174
+ }
175
+
176
+ .action:focus-visible {
177
+ outline: 2px solid rgb(45 104 216 / 0.45);
178
+ outline-offset: 2px;
179
+ }
180
+
181
+ .action:active:not(:disabled) { scale: 0.97; }
182
+ .action:disabled { cursor: wait; opacity: 0.55; }
183
+
184
+ /* The app's own gel, so Accept is the same object as every other affirmative
185
+ control in the window it floats over. */
186
+ .accept {
187
+ border: 1px solid transparent;
188
+ background-image:
189
+ linear-gradient(rgb(255 255 255 / 0.85), transparent 9px),
190
+ linear-gradient(rgb(255 255 255 / 0.25) 9px, transparent 9px),
191
+ linear-gradient(#2d68d8, color-mix(in srgb, #2d68d8 18%, #ffffff)),
192
+ linear-gradient(color-mix(in srgb, #2d68d8 62%, #06122c), hsl(0 0% 38%));
193
+ background-origin: padding-box, padding-box, padding-box, border-box;
194
+ background-clip: padding-box, padding-box, padding-box, border-box;
195
+ box-shadow: 0 1px 2px rgb(10 35 90 / 0.28);
196
+ color: #fff;
197
+ }
198
+
199
+ .accept:hover:not(:disabled) { filter: brightness(1.05); }
200
+
201
+ .decline {
202
+ background: rgb(8 20 48 / 0.07);
203
+ color: #1c1c1c;
204
+ }
205
+
206
+ .decline:hover:not(:disabled) { background: rgb(8 20 48 / 0.12); }
207
+
208
+ /* The dismiss, at the end of the first line. Revealed by pointing at the
209
+ card; always there on a surface that has nothing to point with. */
210
+ .close {
211
+ display: grid;
212
+ width: 15px;
213
+ height: 15px;
214
+ flex: 0 0 15px;
215
+ align-self: center;
216
+ place-items: center;
217
+ border: 0;
218
+ border-radius: 9999px;
219
+ padding: 0;
220
+ background: rgb(8 20 48 / 0.08);
221
+ color: rgb(28 28 28 / 0.7);
222
+ cursor: pointer;
223
+ font: 400 11px/1 ui-sans-serif, system-ui, sans-serif;
224
+ opacity: 0;
225
+ transition: opacity 140ms cubic-bezier(0.2, 0.9, 0.3, 1);
226
+ }
227
+
228
+ .close:hover { background: rgb(8 20 48 / 0.14); color: #1c1c1c; }
229
+
230
+ .notice:hover .close,
231
+ .close:focus-visible {
232
+ opacity: 1;
233
+ outline: none;
234
+ }
235
+
236
+ @media (hover: none) {
237
+ .close { opacity: 1; }
238
+ }
239
+
240
+ @keyframes notice-in {
241
+ from { opacity: 0; transform: translateY(-6px) scale(0.98); }
242
+ }
243
+
244
+ @media (prefers-reduced-motion: reduce) {
245
+ .notice { animation: none; }
246
+ .action, .close { transition: none; }
247
+ }
248
+ `;
249
+
250
+ export const DEV_NOTIFICATION_POPUP_PATH = "/__terminus_dev/notification-popup.js";
251
+ /** The corner's own account stream: the member's account frames, which is
252
+ * what the desk listens to on Terminus — not an app stream, so an open page
253
+ * never makes its member "online". */
254
+ export const DEV_NOTIFICATION_STREAM_PATH = "/__terminus_dev/notifications/stream";
255
+ /** How often the corner looks without a stream to tell it: never faster. */
256
+ export const DEV_NOTIFICATION_SLOW_POLL_MS = 15_000;
257
+
258
+ /**
259
+ * A local-host control, intentionally outside the app SDK. The built app may
260
+ * create invitations and notifications through `/_terminus`; only this dev
261
+ * harness surface presents and resolves them, because on Terminus that is the
262
+ * account's Notification Center, which an app never draws.
263
+ *
264
+ * Both kinds are shown, because both are what a person testing an app is
265
+ * actually told: an invitation is a question with two answers and stays until
266
+ * one is given, and a notification is a statement that steps aside after a
267
+ * few seconds the way the desk's does.
268
+ */
269
+ export const DEV_NOTIFICATION_POPUP_SCRIPT = String.raw`
270
+ (() => {
271
+ if (document.querySelector("terminus-dev-notification-popup")) return;
272
+
273
+ const host = document.createElement("terminus-dev-notification-popup");
274
+ const root = host.attachShadow({ mode: "open" });
275
+ const style = document.createElement("style");
276
+ style.textContent = ${JSON.stringify(POPUP_STYLES)};
277
+ const stack = document.createElement("div");
278
+ stack.className = "stack";
279
+ stack.setAttribute("aria-live", "polite");
280
+ stack.setAttribute("role", "status");
281
+ root.append(style, stack);
282
+ document.documentElement.append(host);
283
+
284
+ /** How many arrive at once before the rest wait their turn. */
285
+ const COLUMN = 3;
286
+ /** How long a card that only says something stays. */
287
+ const NOTICE_MS = 8000;
288
+ /** What is being tested, from the runtime's own bootstrap. The port's owner
289
+ * is not named on a card: this window IS that person, and telling them
290
+ * their own handle where the sender's belongs is how the first version of
291
+ * this read as though @person-2 had written to themselves. */
292
+ let appName = "";
293
+ let booted = false;
294
+ /** The seven hues Chats draws contact monograms in, so a face without a
295
+ * picture is the same face here as it is inside the app. */
296
+ const HUES = [211, 254, 291, 338, 14, 32, 168];
297
+ const dismissed = new Set();
298
+ const seen = new Set();
299
+ let cards = new Map();
300
+ let refreshing = false;
301
+ /** A reason to look again that arrived while a look was under way. */
302
+ let again = false;
303
+ let resolving = false;
304
+
305
+ function ago(value) {
306
+ const at = Date.parse(value || "");
307
+ if (!Number.isFinite(at)) return "now";
308
+ const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
309
+ if (seconds < 45) return "now";
310
+ if (seconds < 3600) return Math.round(seconds / 60) + "m";
311
+ return Math.round(seconds / 3600) + "h";
312
+ }
313
+
314
+ function build(item) {
315
+ const notice = document.createElement("div");
316
+ notice.className = "notice";
317
+ notice.innerHTML =
318
+ "<div class='text'><div class='top'><span class='face' aria-hidden='true'></span>"
319
+ + "<span class='who'></span>"
320
+ + "<span class='meta'><span class='tag'>Dev</span><span class='when'></span></span>"
321
+ + "<button class='close' aria-label='Clear notification' title='Clear' type='button'>&times;</button>"
322
+ + "</div>"
323
+ + "<p class='line'></p>"
324
+ + "<p class='error' hidden></p>"
325
+ + "<div class='actions' hidden>"
326
+ + "<button class='action accept' type='button'>Accept</button>"
327
+ + "<button class='action decline' type='button'>Decline</button>"
328
+ + "</div></div>";
329
+
330
+ const face = notice.querySelector(".face");
331
+ const who = notice.querySelector(".who");
332
+ const when = notice.querySelector(".when");
333
+ const line = notice.querySelector(".line");
334
+ const error = notice.querySelector(".error");
335
+ const actions = notice.querySelector(".actions");
336
+ const accept = notice.querySelector(".accept");
337
+ const decline = notice.querySelector(".decline");
338
+ const close = notice.querySelector(".close");
339
+
340
+ who.textContent = item.who;
341
+ when.textContent = ago(item.createdAt);
342
+ paintFace(face, item.from, item.who);
343
+ line.textContent = item.line;
344
+ line.hidden = !item.line;
345
+ actions.hidden = !item.invitation;
346
+
347
+ function fail(message) {
348
+ error.textContent = message;
349
+ error.hidden = false;
350
+ }
351
+
352
+ function busy(on, label) {
353
+ resolving = on;
354
+ accept.disabled = on;
355
+ decline.disabled = on;
356
+ close.disabled = on;
357
+ accept.textContent = label === "accept" ? "Accepting…" : "Accept";
358
+ decline.textContent = label === "decline" ? "Declining…" : "Decline";
359
+ }
360
+
361
+ async function answer(action) {
362
+ if (resolving) return;
363
+ error.hidden = true;
364
+ busy(true, action);
365
+ try {
366
+ const reply = await fetch(
367
+ "/__terminus_dev/requests/" + encodeURIComponent(item.invitation) + "/" + action,
368
+ { method: "POST" },
369
+ );
370
+ const body = await reply.json().catch(() => null);
371
+ if (!reply.ok) {
372
+ throw new Error((body && body.error && body.error.message) || "Could not answer this request");
373
+ }
374
+ drop(item.id);
375
+ } catch (caught) {
376
+ fail(caught instanceof Error ? caught.message : "Could not answer this request");
377
+ } finally {
378
+ busy(false, null);
379
+ void refresh();
380
+ }
381
+ }
382
+
383
+ accept.addEventListener("click", () => void answer("accept"));
384
+ decline.addEventListener("click", () => void answer("decline"));
385
+ close.addEventListener("click", () => {
386
+ if (resolving) return;
387
+ retire(item);
388
+ });
389
+
390
+ // A statement steps aside on its own. A question waits to be answered.
391
+ if (!item.invitation) {
392
+ window.setTimeout(() => retire(item), NOTICE_MS);
393
+ }
394
+ return notice;
395
+ }
396
+
397
+ /** A monogram, with the platform's portrait over it when there is one — the
398
+ * same order the app itself draws, so a face that fails to load reads as
399
+ * somebody without a picture rather than as a broken image. */
400
+ function paintFace(node, person, fallback) {
401
+ const label = (person && (person.handle || person.display_name)) || fallback || "?";
402
+ let hash = 0;
403
+ for (const character of String(label)) hash = (hash * 31 + character.charCodeAt(0)) | 0;
404
+ node.style.setProperty("--hue", String(HUES[Math.abs(hash) % HUES.length]));
405
+ const parts = String(label).replace(/^@/, "").split(/[\s._-]+/).filter(Boolean);
406
+ node.textContent = (parts.slice(0, 2).map((part) => part[0]).join("") || "?").toUpperCase();
407
+ const source = person && person.avatar_url;
408
+ if (!source) return;
409
+ const image = document.createElement("img");
410
+ image.alt = "";
411
+ image.src = source;
412
+ image.addEventListener("load", () => {
413
+ node.textContent = "";
414
+ node.append(image);
415
+ });
416
+ }
417
+
418
+ /**
419
+ * A statement leaves, and is read when it does — whether it was put away or
420
+ * simply ran its eight seconds. Only the put-away path used to mark it read,
421
+ * so a card that timed out stayed unread on the runtime, and every refresh
422
+ * raised every past message again in a fresh stack: the page's memory of
423
+ * what it had shown is gone after a reload, and the row still said unread.
424
+ * A question is only hidden; it comes back until it is actually answered.
425
+ */
426
+ function retire(item) {
427
+ if (!item.invitation && item.notificationId) {
428
+ void fetch(
429
+ "/_terminus/notifications/" + encodeURIComponent(item.notificationId) + "/read",
430
+ { method: "POST" },
431
+ ).catch(() => undefined);
432
+ }
433
+ drop(item.id);
434
+ }
435
+
436
+ function drop(id) {
437
+ dismissed.add(id);
438
+ const node = cards.get(id);
439
+ if (node) node.remove();
440
+ cards.delete(id);
441
+ }
442
+
443
+ function paint(items) {
444
+ const wanted = items.filter((item) => !dismissed.has(item.id)).slice(0, COLUMN);
445
+ const keep = new Set(wanted.map((item) => item.id));
446
+ for (const [id, node] of cards) {
447
+ if (keep.has(id)) continue;
448
+ node.remove();
449
+ cards.delete(id);
450
+ }
451
+ // Newest on top — and a card already on screen is NEVER moved. This used
452
+ // to re-append every wanted card on every poll to keep the order, and
453
+ // re-inserting a DOM node restarts its CSS animation: once a second, each
454
+ // card replayed its entrance from opacity 0, which is the twinkle. A card
455
+ // is inserted once, at its place; only a genuinely new one goes in, and it
456
+ // goes in ahead of the ones already there without displacing them.
457
+ wanted.forEach((item, index) => {
458
+ let node = cards.get(item.id);
459
+ if (!node) {
460
+ node = build(item);
461
+ cards.set(item.id, node);
462
+ }
463
+ const at = stack.children[index] || null;
464
+ if (at !== node) stack.insertBefore(node, at);
465
+ });
466
+ }
467
+
468
+ // Signed out of the app (/_terminus/logout): no door answers until the app
469
+ // is opened again — a page load, which brings a new corner with it — so
470
+ // this one stops listening and asking instead of collecting 401s.
471
+ let signedOut = false;
472
+
473
+ async function read(path) {
474
+ const reply = await fetch(path, { cache: "no-store", headers: { accept: "application/json" } });
475
+ if (reply.status === 401) signedOut = true;
476
+ if (!reply.ok) throw new Error(path);
477
+ return reply.json();
478
+ }
479
+
480
+ /** Look again. A reason that arrives while a look is under way is kept and
481
+ * answered by one more look as soon as it ends — never dropped, never a
482
+ * pile of them. */
483
+ async function refresh() {
484
+ if (resolving || signedOut) return;
485
+ if (refreshing) {
486
+ again = true;
487
+ return;
488
+ }
489
+ refreshing = true;
490
+ try {
491
+ do {
492
+ again = false;
493
+ await look();
494
+ } while (again && !signedOut);
495
+ } finally {
496
+ // Same turn as the last check of again: nothing slips between them.
497
+ refreshing = false;
498
+ }
499
+ if (signedOut) quiet();
500
+ }
501
+
502
+ async function look() {
503
+ try {
504
+ if (!booted) {
505
+ booted = true;
506
+ const boot = await read("/_terminus/bootstrap").catch(() => null);
507
+ appName = (boot && boot.app && boot.app.name) || "";
508
+ }
509
+ const [requests, notices] = await Promise.all([
510
+ read("/__terminus_dev/requests").catch(() => ({ requests: [] })),
511
+ read("/_terminus/notifications").catch(() => ({ notifications: [] })),
512
+ ]);
513
+ const items = [];
514
+ for (const request of Array.isArray(requests.requests) ? requests.requests : []) {
515
+ const inviter = request.inviter || {};
516
+ items.push({
517
+ createdAt: request.created_at,
518
+ from: inviter,
519
+ id: "invite:" + request.id,
520
+ invitation: request.id,
521
+ // The question, in the words it would be asked in: the sender is
522
+ // already the first line, so the second does not repeat them.
523
+ line: request.kind === "group"
524
+ ? "Invited you to " + (request.space_name || "a group")
525
+ : "Wants to chat with you",
526
+ who: inviter.display_name || (inviter.handle ? "@" + inviter.handle : "Someone"),
527
+ });
528
+ }
529
+ for (const notice of Array.isArray(notices.notifications) ? notices.notifications : []) {
530
+ // An invitation's own row is the same question, said twice.
531
+ if (notice.read_at || notice.kind === "invite") continue;
532
+ const id = "notice:" + notice.id;
533
+ // A row that was already on screen once does not come back after it
534
+ // has stepped aside; only new ones do.
535
+ if (seen.has(id) && !cards.has(id)) continue;
536
+ seen.add(id);
537
+ const from = notice.data && notice.data.from;
538
+ items.push({
539
+ createdAt: notice.created_at,
540
+ from,
541
+ id,
542
+ line: notice.body || "",
543
+ notificationId: notice.id,
544
+ who: (from && from.display_name) || notice.title || appName || "Notification",
545
+ });
546
+ }
547
+ items.sort((left, right) => (
548
+ Date.parse(right.createdAt || "") - Date.parse(left.createdAt || "")
549
+ ));
550
+ paint(items);
551
+ } catch {
552
+ // The app stays usable if the local control plane restarts. The next
553
+ // look restores the corner rather than turning a dev aid into an error
554
+ // overlay over the app being developed.
555
+ }
556
+ }
557
+
558
+ // What tells the corner to look: the member's account stream — a
559
+ // notification, an invitation answered in another window, a space joined
560
+ // or gone. Only a corner without one (no EventSource, or a stream that
561
+ // gave up) polls, and never faster than every ${DEV_NOTIFICATION_SLOW_POLL_MS / 1000} seconds; a
562
+ // window that comes back always looks.
563
+ let source = null;
564
+ let poll = null;
565
+
566
+ function slowPoll(on) {
567
+ if (on && !poll && !signedOut) poll = window.setInterval(() => void refresh(), ${DEV_NOTIFICATION_SLOW_POLL_MS});
568
+ if (!on && poll) {
569
+ window.clearInterval(poll);
570
+ poll = null;
571
+ }
572
+ }
573
+
574
+ function quiet() {
575
+ slowPoll(false);
576
+ if (source) source.close();
577
+ source = null;
578
+ }
579
+
580
+ function listen() {
581
+ if (typeof EventSource !== "function") return slowPoll(true);
582
+ source = new EventSource(${JSON.stringify(DEV_NOTIFICATION_STREAM_PATH)});
583
+ // Opened, or opened again after the harness restarted: what was missed
584
+ // meanwhile is read once.
585
+ source.onopen = () => {
586
+ slowPoll(false);
587
+ void refresh();
588
+ };
589
+ source.onmessage = (message) => {
590
+ let frame = null;
591
+ try {
592
+ frame = JSON.parse(message.data);
593
+ } catch {
594
+ return;
595
+ }
596
+ if (frame && frame.type === "account_event") void refresh();
597
+ };
598
+ source.onerror = () => {
599
+ // A dropped stream reconnects by itself; one that gave up leaves the
600
+ // slow poll in its place.
601
+ if (source && source.readyState === EventSource.CLOSED) slowPoll(true);
602
+ };
603
+ }
604
+
605
+ document.addEventListener("keydown", (event) => {
606
+ if (event.key !== "Escape" || resolving) return;
607
+ const newest = [...cards.keys()].pop();
608
+ if (newest) drop(newest);
609
+ });
610
+ window.addEventListener("focus", () => void refresh());
611
+ document.addEventListener("visibilitychange", () => {
612
+ if (!document.hidden) void refresh();
613
+ });
614
+ listen();
615
+ void refresh();
616
+ })();
617
+ `;
618
+
619
+
620
+ const POPUP_TAG = `<script src="${DEV_NOTIFICATION_POPUP_PATH}" type="module"></script>`;
621
+
622
+ /** Add the local Terminus host control without touching an app's source. */
623
+ export function injectDevNotificationPopup(html) {
624
+ if (html.includes(DEV_NOTIFICATION_POPUP_PATH)) return html;
625
+ const closingBody = html.toLowerCase().lastIndexOf("</body>");
626
+ if (closingBody < 0) return `${html}${POPUP_TAG}`;
627
+ return `${html.slice(0, closingBody)}${POPUP_TAG}${html.slice(closingBody)}`;
628
+ }