@monster-orange/den 0.4.15 → 0.4.17

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 (37) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +137 -11
  4. package/package.json +1 -1
  5. package/src/shared/agents/conversations.ts +122 -4
  6. package/src/shared/agents/den-claude.ts +13 -1
  7. package/src/shared/agents/records.ts +19 -0
  8. package/src/shared/agents/runtime.ts +5 -0
  9. package/src/shared/github-app.ts +7 -0
  10. package/src/ui/cli/agent-ops.ts +304 -1
  11. package/src/ui/cli/agents-command.ts +71 -7
  12. package/src/ui/server/agent-routes.ts +92 -2
  13. package/src/ui/server/check-pool.ts +72 -0
  14. package/src/ui/server/github-routes.ts +1 -1
  15. package/src/ui/server/index.ts +127 -15
  16. package/src/ui/server/public/agents-page.js +358 -38
  17. package/src/ui/server/public/agents.html +10 -0
  18. package/src/ui/server/public/environments.html +223 -140
  19. package/src/ui/server/public/error-page.js +70 -15
  20. package/src/ui/server/public/error.html +18 -0
  21. package/src/ui/server/public/graph.css +18 -0
  22. package/src/ui/server/public/graph.js +23 -6
  23. package/src/ui/server/public/i18n/es.js +47 -5
  24. package/src/ui/server/public/i18n/ja.js +47 -5
  25. package/src/ui/server/public/index.html +6 -0
  26. package/src/ui/server/public/local-dev.html +188 -25
  27. package/src/ui/server/public/panel.js +1 -1
  28. package/src/ui/server/public/persona-ui.css +11 -0
  29. package/src/ui/server/public/release.html +19 -1
  30. package/src/ui/server/public/shell.js +187 -12
  31. package/src/ui/server/public/topbar.js +34 -1
  32. package/src/ui/server/public/view-scope.js +61 -0
  33. package/src/ui/server/public/workspace-picker.js +2 -0
  34. package/src/ui/server/route-classes.ts +8 -0
  35. package/src/ui/server/run-history.ts +181 -0
  36. package/src/ui/server/session-routes.ts +97 -17
  37. package/src/ui/server/ui-assets.ts +2 -0
@@ -145,6 +145,21 @@
145
145
  .dot.slow {
146
146
  background: var(--warn);
147
147
  }
148
+ /* 0.4.17: a check in flight; its row is there before it lands. */
149
+ .dot.checking {
150
+ animation: env-pulse 1.2s ease-in-out infinite;
151
+ }
152
+ @keyframes env-pulse {
153
+ 50% {
154
+ opacity: 0.25;
155
+ }
156
+ }
157
+ @media (prefers-reduced-motion: reduce) {
158
+ .dot.checking {
159
+ animation: none;
160
+ opacity: 0.5;
161
+ }
162
+ }
148
163
  .empty {
149
164
  color: var(--muted);
150
165
  padding: 24px 0;
@@ -212,13 +227,8 @@
212
227
  <main id="den-content">
213
228
  <div class="controls">
214
229
  <button id="refresh">Re-check</button>
215
- <label class="note"
216
- >org
217
- <select id="org-filter">
218
- <option value="">all</option>
219
- </select></label
220
- >
221
- <span class="note" id="status">checking…</span>
230
+ <span id="view-note"></span>
231
+ <span class="note" id="status" aria-live="polite">checking…</span>
222
232
  <span class="note"
223
233
  >Each repo's <code>__DEN_MANIFEST_YML__</code> health checks, run
224
234
  against every environment. ↗ opens the service in a new tab. beta and
@@ -247,6 +257,7 @@
247
257
  initLogBadge,
248
258
  initWorkspacePill,
249
259
  } from "/header.js";
260
+ import { viewNoteHtml, viewQuery, viewScope } from "/view-scope.js";
250
261
  void Promise.all([
251
262
  initHealth(),
252
263
  initWorkspacePill(),
@@ -306,12 +317,13 @@
306
317
  : r.ok === false
307
318
  ? "err"
308
319
  : "";
309
- const what = r.status
310
- ? String(r.status)
311
- : r.ok
312
- ? "up"
313
- : r.error || "down";
314
- const title = [r.label, checked, r.error].filter(Boolean).join(" — ");
320
+ // A check that ran out of time says how long it waited (0.4.17).
321
+ const why =
322
+ r.error === "timeout" && r.ms
323
+ ? `timed out after ${Math.max(1, Math.round(r.ms / 1000))} s`
324
+ : r.error;
325
+ const what = r.status ? String(r.status) : r.ok ? "up" : why || "down";
326
+ const title = [r.label, checked, why].filter(Boolean).join(" — ");
315
327
  const link =
316
328
  r.url && /^https?:/.test(r.url)
317
329
  ? `<a href="${esc(r.url)}" target="_blank" rel="noopener" title="${esc(title)}">${esc(what)}</a>`
@@ -320,157 +332,228 @@
320
332
  return `<span class="cell"><span class="dot ${cls}"></span>${link}${ms}${openLink(r.openUrl, `${r.repo}/${r.service}`)}</span>`;
321
333
  }
322
334
 
323
- async function load(refresh = false) {
324
- const status = document.getElementById("status");
325
- status.textContent = "checking…";
326
- const envs = await fetch("/api/environments")
327
- .then((r) => r.json())
328
- .then((v) => (Array.isArray(v) ? v : ["local"]))
329
- .catch(() => ["local"]);
330
- const [results, orgs, relCfg] = await Promise.all([
331
- Promise.all(
332
- envs.map((env) =>
333
- fetch(
334
- `/api/health?env=${encodeURIComponent(env)}${refresh ? "&refresh=1" : ""}`,
335
+ // 0.4.17: every row at once, each cell "checking" until its result
336
+ // streams in (/api/health/stream, one per environment, in parallel);
337
+ // the page's controls never wait. The rows are the View's (the top
338
+ // bar's Org and Workspace): this page doesn't filter by org itself.
339
+ const checking = () =>
340
+ '<span class="cell" data-checking><span class="dot checking"></span><span class="none">checking…</span></span>';
341
+ const st = {
342
+ gen: 0,
343
+ envs: [],
344
+ rows: new Map(),
345
+ orgInfo: {},
346
+ release: {},
347
+ streams: [],
348
+ pending: new Set(),
349
+ view: { scope: "all", inView: [] },
350
+ };
351
+
352
+ function stopStreams() {
353
+ for (const es of st.streams) es.close();
354
+ st.streams = [];
355
+ }
356
+
357
+ function sortedRows() {
358
+ const orgRank = (o) => {
359
+ const i = Object.keys(st.orgInfo).indexOf(o);
360
+ return i < 0 ? 1e6 : i;
361
+ };
362
+ return [...st.rows.values()].sort(
363
+ (a, b) =>
364
+ orgRank(a.org) - orgRank(b.org) ||
365
+ a.idx - b.idx ||
366
+ a.service.localeCompare(b.service),
367
+ );
368
+ }
369
+
370
+ function cellHtml(row, env) {
371
+ const r = row.byEnv[env];
372
+ if (r) return cell(r);
373
+ return st.pending.has(env) ? checking() : '<span class="none">—</span>';
374
+ }
375
+
376
+ function render() {
377
+ const envs = st.envs;
378
+ document.getElementById("head").innerHTML =
379
+ `<tr><th>Service</th>${envs.map((e) => `<th>${esc(e)}</th>`).join("")}</tr>`;
380
+ const body = document.getElementById("body");
381
+ const sorted = sortedRows();
382
+ if (!sorted.length) {
383
+ body.innerHTML = st.pending.size
384
+ ? `<tr><td class="empty" colspan="${envs.length + 1}">Loading…</td></tr>`
385
+ : `<tr><td class="empty" colspan="${envs.length + 1}">No services in view have a __DEN_MANIFEST_YML__ yet.</td></tr>`;
386
+ return;
387
+ }
388
+ let html = "",
389
+ lastRepo = null,
390
+ lastOrg = null;
391
+ for (const row of sorted) {
392
+ if (row.org !== lastOrg) {
393
+ const o = st.orgInfo[row.org];
394
+ html += `<tr class="org" style="${o?.color ? `--org:${esc(o.color)}` : ""}"><td colspan="${envs.length + 1}"><span class="swatch"></span>${esc(o?.label || row.org || "no org")}</td></tr>`;
395
+ lastOrg = row.org;
396
+ }
397
+ if (row.repo !== lastRepo) {
398
+ const entry =
399
+ sorted.find((r) => r.repo === row.repo && r.isEntrypoint) || row;
400
+ html += `<tr class="repo" data-repo="${esc(row.repo)}"><td>${esc(row.repo)}</td>${envs
401
+ .map(
402
+ (e) =>
403
+ `<td class="rel">${relCell(st.release[row.repo]?.[e])}${openLink(entry.byEnv[e]?.openUrl, `${row.repo} on ${e}`)}</td>`,
335
404
  )
336
- .then((r) => r.json())
337
- .catch(() => []),
338
- ),
339
- ),
405
+ .join("")}</tr>`;
406
+ lastRepo = row.repo;
407
+ }
408
+ html += `<tr data-svc="${esc(row.repo)}/${esc(row.service)}"><td class="svc">${esc(row.service)}</td>${envs.map((e) => `<td data-env="${esc(e)}">${cellHtml(row, e)}</td>`).join("")}</tr>`;
409
+ }
410
+ body.innerHTML = html;
411
+ }
412
+
413
+ // Results land many per second: paint once per frame.
414
+ let painting = false;
415
+ function paintSoon() {
416
+ if (painting) return;
417
+ painting = true;
418
+ requestAnimationFrame(() => {
419
+ painting = false;
420
+ render();
421
+ paintStatus();
422
+ });
423
+ }
424
+
425
+ function paintStatus() {
426
+ const status = document.getElementById("status");
427
+ const all = [...st.rows.values()].flatMap((r) => Object.values(r.byEnv));
428
+ const down = all.filter((r) => r.ok === false).length;
429
+ status.textContent = st.pending.size
430
+ ? `checking ${st.pending.size} environment${st.pending.size === 1 ? "" : "s"}…${down ? ` · ${down} failing so far` : ""}`
431
+ : `checked ${new Date().toLocaleTimeString()} · ${down ? down + " failing" : "nothing failing"}`;
432
+ }
433
+
434
+ function upsert(r, env, orgOfRepo) {
435
+ const key = `${r.repo}/${r.service}`;
436
+ let row = st.rows.get(key);
437
+ if (!row) {
438
+ row = {
439
+ repo: r.repo,
440
+ service: r.service,
441
+ label: r.label,
442
+ org: orgOfRepo[r.repo] || "",
443
+ isEntrypoint: !!r.isEntrypoint,
444
+ idx: r.idx ?? 1e9,
445
+ seeded: false,
446
+ byEnv: {},
447
+ };
448
+ st.rows.set(key, row);
449
+ }
450
+ if (!row.seeded && r.idx != null) row.idx = Math.min(row.idx, r.idx);
451
+ if (r.isEntrypoint) row.isEntrypoint = true;
452
+ if (env) row.byEnv[env] = r;
453
+ return row;
454
+ }
455
+
456
+ async function load(refresh = false) {
457
+ const gen = ++st.gen;
458
+ stopStreams();
459
+ st.view = await viewScope();
460
+ document.getElementById("view-note").innerHTML = viewNoteHtml(st.view);
461
+ const q = viewQuery(st.view);
462
+ const [envs, orgs, relCfg, seed] = await Promise.all([
463
+ fetch("/api/environments")
464
+ .then((r) => r.json())
465
+ .then((v) => (Array.isArray(v) && v.length ? v : ["local"]))
466
+ .catch(() => ["local"]),
340
467
  // The org of every repo comes from the catalog (/api/orgs?repos=1):
341
- // /api/health rows never carry one, so reading r.org put every
342
- // service under "no org" (0.3.0-dev.14).
468
+ // /api/health rows never carry one (0.3.0-dev.14).
343
469
  fetch("/api/orgs?repos=1")
344
470
  .then((r) => r.json())
345
471
  .catch(() => ({ orgs: [], repos: {} })),
346
472
  fetch("/api/release/config")
347
473
  .then((r) => r.json())
348
474
  .catch(() => ({ environments: [] })),
475
+ // The rows, before any check: the View's services.
476
+ fetch(`/api/services?env=local${q}`)
477
+ .then((r) => (r.ok ? r.json() : []))
478
+ .catch(() => []),
349
479
  ]);
350
- // Deployed commit per repo for the release environments (beta, prod).
351
- const relEnvs = envs.filter((e) =>
352
- (relCfg.environments || []).includes(e),
353
- );
354
- const release = {};
355
- await Promise.all(
356
- relEnvs.map(async (e) => {
357
- const r = await fetch(
358
- `/api/release/status?env=${encodeURIComponent(e)}${refresh ? "&refresh=1" : ""}`,
359
- )
360
- .then((x) => (x.ok ? x.json() : null))
361
- .catch(() => null);
362
- for (const row of r?.rows || [])
363
- (release[row.repo] ??= {})[e] = row;
364
- }),
365
- );
366
- const orgInfo = Object.fromEntries(
480
+ if (gen !== st.gen) return;
481
+ st.envs = envs;
482
+ st.orgInfo = Object.fromEntries(
367
483
  (Array.isArray(orgs?.orgs) ? orgs.orgs : []).map((o) => [o.id, o]),
368
484
  );
369
485
  const orgOfRepo = orgs?.repos ?? {};
370
- const filterSel = document.getElementById("org-filter");
371
- const saved = (() => {
372
- try {
373
- return localStorage.getItem("__DEN_CLI__-env-org") || "";
374
- } catch {
375
- return "";
376
- }
377
- })();
378
- filterSel.innerHTML =
379
- '<option value="">all</option>' +
380
- Object.values(orgInfo)
381
- .map((o) => `<option value="${esc(o.id)}">${esc(o.label)}</option>`)
382
- .join("");
383
- filterSel.value = orgInfo[filterSel.dataset.value ?? saved]
384
- ? (filterSel.dataset.value ?? saved)
385
- : "";
386
- const orgFilter = filterSel.value;
387
-
388
- // One row per service, in the dependency-graph order the server ranks
389
- // (idx); grouped under its repo.
390
- const rows = new Map();
391
- results.forEach((list, i) => {
392
- for (const r of Array.isArray(list) ? list : []) {
393
- const key = `${r.repo}/${r.service}`;
394
- const row = rows.get(key) ?? {
395
- repo: r.repo,
396
- service: r.service,
397
- label: r.label,
398
- org: orgOfRepo[r.repo] || "",
399
- isEntrypoint: r.isEntrypoint,
400
- idx: r.idx ?? 1e9,
401
- byEnv: {},
402
- };
403
- row.idx = Math.min(row.idx, r.idx ?? 1e9);
404
- row.byEnv[envs[i]] = r;
405
- rows.set(key, row);
486
+ // Every cell checks again; the rows stay.
487
+ for (const row of st.rows.values()) row.byEnv = {};
488
+ (Array.isArray(seed) ? seed : []).forEach((s, i) => {
489
+ if (!s?.repo || !s?.service) return;
490
+ const row = upsert(
491
+ { repo: s.repo, service: s.service, isEntrypoint: s.isEntrypoint },
492
+ null,
493
+ orgOfRepo,
494
+ );
495
+ if (!row.seeded) {
496
+ row.seeded = true;
497
+ row.idx = i;
406
498
  }
407
499
  });
408
- // Org first (configured order), then the graph order within it.
409
- const orgRank = (o) => {
410
- const i = Object.keys(orgInfo).indexOf(o);
411
- return i < 0 ? 1e6 : i;
412
- };
413
- const sorted = [...rows.values()]
414
- .filter((r) => !orgFilter || r.org === orgFilter)
415
- .sort(
416
- (a, b) =>
417
- orgRank(a.org) - orgRank(b.org) ||
418
- a.idx - b.idx ||
419
- a.service.localeCompare(b.service),
500
+ st.pending = new Set(envs);
501
+ render();
502
+ paintStatus();
503
+ for (const env of envs) {
504
+ const es = new EventSource(
505
+ `/api/health/stream?env=${encodeURIComponent(env)}${q}${refresh ? "&refresh=1" : ""}`,
420
506
  );
421
-
422
- document.getElementById("head").innerHTML =
423
- `<tr><th>Service</th>${envs.map((e) => `<th>${esc(e)}</th>`).join("")}</tr>`;
424
- const body = document.getElementById("body");
425
- if (!sorted.length) {
426
- body.innerHTML = `<tr><td class="empty" colspan="${envs.length + 1}">No services in the active workspace have a __DEN_MANIFEST_YML__ yet.</td></tr>`;
427
- } else {
428
- let html = "",
429
- lastRepo = null,
430
- lastOrg = null;
431
- for (const row of sorted) {
432
- if (row.org !== lastOrg) {
433
- const o = orgInfo[row.org];
434
- html += `<tr class="org" style="${o?.color ? `--org:${esc(o.color)}` : ""}"><td colspan="${envs.length + 1}"><span class="swatch"></span>${esc(o?.label || row.org || "no org")}</td></tr>`;
435
- lastOrg = row.org;
436
- }
437
- if (row.repo !== lastRepo) {
438
- const entry =
439
- sorted.find((r) => r.repo === row.repo && r.isEntrypoint) ||
440
- row;
441
- html += `<tr class="repo"><td>${esc(row.repo)}</td>${envs
442
- .map(
443
- (e) =>
444
- `<td class="rel">${relCell(release[row.repo]?.[e])}${openLink(entry.byEnv[e]?.openUrl, `${row.repo} on ${e}`)}</td>`,
445
- )
446
- .join("")}</tr>`;
447
- lastRepo = row.repo;
507
+ st.streams.push(es);
508
+ const finish = () => {
509
+ es.close();
510
+ if (gen !== st.gen) return;
511
+ st.pending.delete(env);
512
+ if (!st.pending.size)
513
+ // Seeded rows no check reached (a module, a library): gone.
514
+ for (const [k, row] of st.rows)
515
+ if (!Object.keys(row.byEnv).length) st.rows.delete(k);
516
+ paintSoon();
517
+ };
518
+ es.onmessage = (e) => {
519
+ if (gen !== st.gen) return es.close();
520
+ let m = null;
521
+ try {
522
+ m = JSON.parse(e.data);
523
+ } catch {
524
+ return;
448
525
  }
449
- html += `<tr><td class="svc">${esc(row.service)}</td>${envs.map((e) => `<td>${cell(row.byEnv[e])}</td>`).join("")}</tr>`;
450
- }
451
- body.innerHTML = html;
526
+ if (m?.type === "result" && m.result) {
527
+ upsert(m.result, env, orgOfRepo);
528
+ paintSoon();
529
+ } else if (m?.type === "done" || m?.type === "error") finish();
530
+ };
531
+ es.onerror = finish;
452
532
  }
453
- const down = sorted
454
- .flatMap((r) => Object.values(r.byEnv))
455
- .filter((r) => r.ok === false).length;
456
- status.textContent = `checked ${new Date().toLocaleTimeString()} · ${down ? down + " failing" : "nothing failing"}`;
533
+ // Deployed commit per repo for the release environments, as each
534
+ // lands; nothing waits on it.
535
+ for (const e of envs.filter((x) =>
536
+ (relCfg.environments || []).includes(x),
537
+ ))
538
+ fetch(
539
+ `/api/release/status?env=${encodeURIComponent(e)}${refresh ? "&refresh=1" : ""}`,
540
+ )
541
+ .then((x) => (x.ok ? x.json() : null))
542
+ .catch(() => null)
543
+ .then((r) => {
544
+ if (gen !== st.gen) return;
545
+ for (const row of r?.rows || [])
546
+ (st.release[row.repo] ??= {})[e] = row;
547
+ paintSoon();
548
+ });
457
549
  }
458
550
 
459
551
  document
460
552
  .getElementById("refresh")
461
553
  .addEventListener("click", () => load(true));
462
- document.getElementById("org-filter").addEventListener("change", (e) => {
463
- e.target.dataset.value = e.target.value;
464
- try {
465
- localStorage.setItem("__DEN_CLI__-env-org", e.target.value);
466
- } catch {
467
- /* private window */
468
- }
469
- load();
470
- });
471
554
  load();
472
555
  setInterval(() => {
473
- if (!document.hidden) load();
556
+ if (!document.hidden && !st.pending.size) load();
474
557
  }, POLL_MS);
475
558
  </script>
476
559
  </main>
@@ -226,30 +226,84 @@ export function landingAfterSignIn(loc) {
226
226
  }
227
227
 
228
228
  /**
229
- * Sign in with GitHub (SEC-2a): the main button, shown only when a client
230
- * id is configured. The server runs GitHub's device flow; the page shows
231
- * the code and github.com/login/device, polls the server, and goes on once
232
- * the owner approved it (the server sets the cookie). Another account is
233
- * refused by name. With no owner linked, it says what to run on the server.
229
+ * Sign in with GitHub (SEC-2a): shown only when a client id is configured.
230
+ * The server runs GitHub's device flow; the page shows the code and
231
+ * github.com/login/device, polls the server, and goes on once the owner
232
+ * approved it (the server sets the cookie). Another account is refused by
233
+ * name.
234
+ *
235
+ * SEC-2a-UX: the same three steps in every state (link the owner on the
236
+ * machine, enter the code on GitHub, come back and press the button).
237
+ * Once an owner is linked, step 1 is done and names it, and the button
238
+ * follows the steps. Below them: only the owner's account signs in this
239
+ * way, and Learn more.
234
240
  */
235
241
  function githubBlock(s, t) {
236
242
  const g = s.github;
237
243
  if (!g || s.localhost || g.state === "no_client_id") return "";
238
244
  const host = s.machine || "";
239
- if (g.state === "no_owner")
240
- return (
241
- `<div id="den-gh"><p>${withHost(t, "err.signin.githubNoOwner", { name: s.name, host })}</p>` +
242
- cmdLine("den-gh-owner-cmd", g.owner, t) +
243
- `</div>`
244
- );
245
+ const ready = g.state === "ready";
246
+ const step1 = withHost(t, "err.signin.ghStep1", {
247
+ host,
248
+ cmd: "\u0000",
249
+ }).replace("\u0000", `<code class="mono">${esc(g.owner)}</code>`);
245
250
  return (
246
- `<div id="den-gh"><div class="acts" style="margin-top:0">` +
247
- `<button type="button" class="btn primary" id="den-gh-start">${esc(t("err.signin.github"))}</button>` +
248
- `</div><p class="den-gh-note" style="margin-top:8px">${esc(t("err.signin.githubNote", { name: s.name }))}</p>` +
249
- `<div id="den-gh-flow" aria-live="polite"></div></div>`
251
+ `<div id="den-gh">` +
252
+ `<ol class="den-steps" id="den-gh-steps">` +
253
+ `<li id="den-gh-step1"${ready ? ' class="done"' : ""}><p>${step1}</p>` +
254
+ (ready && g.ownerLogin
255
+ ? `<p class="den-gh-owner" id="den-gh-owner">${esc(t("err.signin.ghOwner", { login: g.ownerLogin }))}</p>`
256
+ : "") +
257
+ `</li>` +
258
+ `<li id="den-gh-step2"><p>${esc(t("err.signin.ghStep2"))}</p></li>` +
259
+ `<li id="den-gh-step3"><p>${esc(t("err.signin.ghStep3"))}</p></li>` +
260
+ `</ol>` +
261
+ (ready
262
+ ? `<div class="acts" style="margin-top:0">` +
263
+ `<button type="button" class="btn primary" id="den-gh-start">${esc(t("err.signin.github"))}</button>` +
264
+ `</div><div id="den-gh-flow" aria-live="polite"></div>`
265
+ : "") +
266
+ `<p class="den-gh-note" id="den-gh-only">${esc(t("err.signin.ghOnly"))}` +
267
+ (g.learnMore
268
+ ? ` <a id="den-gh-learn" href="${esc(g.learnMore)}" target="_blank" rel="noopener noreferrer">${esc(t("err.signin.ghLearn"))}</a>`
269
+ : "") +
270
+ `</p></div>`
250
271
  );
251
272
  }
252
273
 
274
+ /** How often, and how long, the page watches for an owner (SEC-2a-UX). */
275
+ export const OWNER_POLL_MS = 5000;
276
+ const OWNER_WATCH_MS = 30 * 60 * 1000;
277
+
278
+ /**
279
+ * SEC-2a-UX: with no owner linked yet, watch the session probe; once the
280
+ * owner is linked on the machine, step 1 shows done and Sign in with
281
+ * GitHub appears, with no reload.
282
+ */
283
+ function watchOwner(root, t, s, pollMs = OWNER_POLL_MS) {
284
+ if (s.github?.state !== "no_owner" || !root.querySelector("#den-gh")) return;
285
+ const until = Date.now() + OWNER_WATCH_MS;
286
+ const tick = () => {
287
+ if (Date.now() > until || !root.querySelector("#den-gh")) return;
288
+ fetch("/api/session", { cache: "no-store", credentials: "same-origin" })
289
+ .then((r) => r.json().catch(() => ({})))
290
+ .then((b) => {
291
+ const g = b?.github;
292
+ if (g?.state === "ready" && g.ownerLogin) {
293
+ s.github = { ...s.github, state: "ready", ownerLogin: g.ownerLogin };
294
+ const old = root.querySelector("#den-gh");
295
+ if (!old) return;
296
+ old.outerHTML = githubBlock(s, t);
297
+ wireGithub(root, t, s);
298
+ return;
299
+ }
300
+ setTimeout(tick, pollMs);
301
+ })
302
+ .catch(() => setTimeout(tick, pollMs));
303
+ };
304
+ setTimeout(tick, pollMs);
305
+ }
306
+
253
307
  /** The device flow on the page: start, show the code, poll, go on. */
254
308
  function wireGithub(root, t, s) {
255
309
  const start = root.querySelector("#den-gh-start");
@@ -604,6 +658,7 @@ function mountSignedOut(root, sh, d) {
604
658
  `</div>`;
605
659
  wireCopy(root, t);
606
660
  wireGithub(root, t, s);
661
+ watchOwner(root, t, s, window.__denOwnerPollMs || OWNER_POLL_MS);
607
662
  wirePair(root, t, s);
608
663
  if (s.expired) return;
609
664
  let tried = false;
@@ -265,6 +265,24 @@
265
265
  color: var(--text);
266
266
  font-weight: 600;
267
267
  }
268
+ /* SEC-2a-UX: a step already done (the owner is linked). */
269
+ ol.den-steps li.done::marker {
270
+ content: "\2713 ";
271
+ color: var(--ok, var(--text));
272
+ }
273
+ ol.den-steps li.done > p:first-child {
274
+ color: var(--muted);
275
+ }
276
+ ol.den-steps code {
277
+ overflow-wrap: anywhere;
278
+ }
279
+ p.den-gh-note {
280
+ margin-top: 8px;
281
+ }
282
+ p.den-gh-owner {
283
+ color: var(--text);
284
+ font-weight: 600;
285
+ }
268
286
  details.den-other {
269
287
  margin: 0 0 16px;
270
288
  }
@@ -331,6 +331,24 @@
331
331
  .dg-card .r1 .dg-dot {
332
332
  margin-top: 5px;
333
333
  }
334
+ /* The favourite star (0.4.17): top-right, the shell's pins. */
335
+ .dg-star {
336
+ margin-left: auto;
337
+ flex: none;
338
+ background: none;
339
+ border: 1px solid transparent;
340
+ border-radius: 6px;
341
+ color: var(--faint);
342
+ cursor: pointer;
343
+ font-size: 13px;
344
+ line-height: 1.2;
345
+ padding: 0 3px;
346
+ }
347
+ .dg-star:hover,
348
+ .dg-star[aria-pressed="true"] {
349
+ color: var(--accent-text);
350
+ background: var(--sunken);
351
+ }
334
352
  .dg-dot {
335
353
  display: inline-block;
336
354
  width: 9px;
@@ -501,15 +501,31 @@ function repoGroupEl(r, x, y, w, h) {
501
501
  el.innerHTML = `<div class="dg-ghead"><button class="dg-gname" data-goto-repo-panel="${esc(r)}" title="Repo details">${esc(r)}</button><span class="meta">${esc(branch)}${svcs.length} services</span><button class="dg-lnk" data-collapse="${esc(r)}">Collapse</button></div>`;
502
502
  return place(el, x, y, w, h);
503
503
  }
504
+ /** The favourite star (0.4.17): the shell's pins, the same store as Services. */
505
+ function starBtn(id) {
506
+ const sh = window.denShell;
507
+ if (!sh?.isPinned) return "";
508
+ const on = sh.isPinned(id);
509
+ const label = sh.t
510
+ ? sh.t(on ? "act.unpinX" : "act.pinX", { s: id })
511
+ : `${on ? "Unpin" : "Pin"} ${id}`;
512
+ return `<button type="button" class="dg-star" data-pin-toggle="${esc(id)}" aria-pressed="${on}" aria-label="${esc(label)}" title="${esc(label)}">${on ? "★" : "☆"}</button>`;
513
+ }
514
+ /** A card's actions: the first three, and Visit ↗ always when it has a URL. */
515
+ function cardActs(s) {
516
+ const acts = actionsFor(s);
517
+ const list = [acts.primary, ...acts.common].filter(Boolean);
518
+ const top = list.slice(0, 3);
519
+ const visit = list.find((a) => a.id === "open");
520
+ if (visit && !top.includes(visit)) top[top.length - 1] = visit;
521
+ return top;
522
+ }
504
523
  function cardInner(s) {
505
524
  const showPlace = s.runs === "remote" || M.mixed;
506
- const acts = actionsFor(s);
507
- return `<div class="r1"><span class="dg-dot ${status(s)}" title="${statusWord(s)}"></span><span class="nm" title="${esc(s.name)}">${esc(s.name)}</span>${showPlace ? `<span class="dg-badge ${s.runs}">${esc(placementText(s))}</span>` : ""}</div>
525
+ return `<div class="r1"><span class="dg-dot ${status(s)}" title="${statusWord(s)}"></span><span class="nm" title="${esc(s.name)}">${esc(s.name)}</span>${showPlace ? `<span class="dg-badge ${s.runs}">${esc(placementText(s))}</span>` : ""}${starBtn(s.id)}</div>
508
526
  <div class="r2" title="${esc(s.id)}${s.inWorkspaces.length > 1 ? `: in ${esc(s.inWorkspaces.join(", "))}` : ""}">${esc(s.repo + ownersLabel(s.inWorkspaces))}</div>
509
527
  <div class="r3">${s.entry ? '<span class="dg-tag entry">entry</span>' : ""}${s.infra ? '<span class="dg-tag">infra</span>' : ""}${s.missing.length ? `<span class="dg-tag warn" title="Missing: ${esc(s.missing.join(", "))}">⚠ missing</span>` : ""}${!s.cloned ? '<span class="dg-tag warn">not cloned</span>' : ""}<span class="h ${status(s)}">${esc(healthLine(s))}</span></div>
510
- <div class="dg-acts">${[acts.primary, ...acts.common]
511
- .filter(Boolean)
512
- .slice(0, 3)
528
+ <div class="dg-acts">${cardActs(s)
513
529
  .map((a) => actBtn(a, s, "sm"))
514
530
  .join("")}</div>`;
515
531
  }
@@ -915,7 +931,8 @@ function onClick(e) {
915
931
  const id = pinT.dataset.pinToggle;
916
932
  if (window.denShell.isPinned(id)) window.denShell.unpin(id);
917
933
  else window.denShell.pin(id);
918
- openDetail(st.sel, true);
934
+ // A card's star (0.4.17) only pins: it doesn't open the card.
935
+ if (!pinT.closest(".dg-card")) openDetail(st.sel, true);
919
936
  return;
920
937
  }
921
938
  if (t.closest("[data-popout]")) {