instar 1.3.1610 → 1.3.1611

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 (55) hide show
  1. package/dashboard/console/console.css +2 -0
  2. package/dashboard/console/console.js +27 -5
  3. package/dashboard/console/home-model.js +20 -0
  4. package/dashboard/console/schedule.js +4 -4
  5. package/dashboard/console/sessions-model.js +18 -0
  6. package/dashboard/console/sessions.js +55 -19
  7. package/dashboard/console/ui.js +11 -0
  8. package/dist/commands/server.d.ts.map +1 -1
  9. package/dist/commands/server.js +5 -3
  10. package/dist/commands/server.js.map +1 -1
  11. package/dist/data/standards-guard-index.json +1 -1
  12. package/dist/data/standards-guard-index.meta.json +2 -2
  13. package/dist/data/standards-registry.meta.json +1 -1
  14. package/dist/messaging/TelegramAdapter.d.ts +11 -0
  15. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  16. package/dist/messaging/TelegramAdapter.js +98 -4
  17. package/dist/messaging/TelegramAdapter.js.map +1 -1
  18. package/dist/messaging/slack/SlackAdapter.d.ts +10 -0
  19. package/dist/messaging/slack/SlackAdapter.d.ts.map +1 -1
  20. package/dist/messaging/slack/SlackAdapter.js +29 -4
  21. package/dist/messaging/slack/SlackAdapter.js.map +1 -1
  22. package/dist/messaging/slack/types.d.ts +5 -0
  23. package/dist/messaging/slack/types.d.ts.map +1 -1
  24. package/dist/messaging/slack/types.js.map +1 -1
  25. package/dist/monitoring/GreenPrAutoMerger.d.ts +4 -0
  26. package/dist/monitoring/GreenPrAutoMerger.d.ts.map +1 -1
  27. package/dist/monitoring/GreenPrAutoMerger.js +27 -9
  28. package/dist/monitoring/GreenPrAutoMerger.js.map +1 -1
  29. package/dist/monitoring/SessionMonitor.d.ts +21 -0
  30. package/dist/monitoring/SessionMonitor.d.ts.map +1 -1
  31. package/dist/monitoring/SessionMonitor.js +84 -1
  32. package/dist/monitoring/SessionMonitor.js.map +1 -1
  33. package/dist/native/instar-desktop-host +0 -0
  34. package/dist/server/routes.d.ts.map +1 -1
  35. package/dist/server/routes.js +14 -2
  36. package/dist/server/routes.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/data/builtin-manifest.json +46 -46
  39. package/src/data/standards-guard-index.json +1 -1
  40. package/src/data/standards-guard-index.meta.json +2 -2
  41. package/src/data/standards-registry.meta.json +1 -1
  42. package/src/data/state-coherence-registry.json +18 -0
  43. package/upgrades/1.3.1611.md +64 -0
  44. package/upgrades/side-effects/console-failures-link-to-problem.eli16.md +7 -0
  45. package/upgrades/side-effects/console-failures-link-to-problem.md +31 -0
  46. package/upgrades/side-effects/console-partial-read-grace.eli16.md +9 -0
  47. package/upgrades/side-effects/console-partial-read-grace.md +33 -0
  48. package/upgrades/side-effects/green-pr-skip-unactionable-head.eli16.md +7 -0
  49. package/upgrades/side-effects/green-pr-skip-unactionable-head.md +41 -0
  50. package/upgrades/side-effects/session-history-topic-backfill.eli16.md +9 -0
  51. package/upgrades/side-effects/session-history-topic-backfill.md +39 -0
  52. package/upgrades/side-effects/slack-session-stopped-once.eli16.md +28 -0
  53. package/upgrades/side-effects/slack-session-stopped-once.md +89 -0
  54. package/upgrades/side-effects/topic-probe-no-repeat-refusals.eli16.md +7 -0
  55. package/upgrades/side-effects/topic-probe-no-repeat-refusals.md +34 -0
@@ -980,6 +980,8 @@ button.run-mark:focus-visible { outline: 2px solid var(--accent); outline-offset
980
980
  .stale-banner { margin: var(--s-2) 0; padding: var(--s-2) var(--s-3); border: 1px dashed var(--unknown-line); border-radius: var(--r-md); color: var(--text-muted); font-size: 14px; }
981
981
  .machine-line { margin: var(--s-1) 0; padding: var(--s-2) var(--s-3); border: 1px solid var(--hairline); border-radius: var(--r-md); font-size: 14.5px; color: var(--text); overflow-wrap: anywhere; }
982
982
  .machine-line.dashed { border-style: dashed; border-color: var(--unknown-line); color: var(--text-muted); }
983
+ a.machine-line-link { display: block; text-decoration: none; }
984
+ a.machine-line-link:hover { border-color: var(--accent); color: var(--text); }
983
985
  .sess-pick { margin-top: var(--s-10); text-align: center; }
984
986
  .sess-open { display: flex; flex-direction: column; gap: var(--s-2); min-height: calc(100dvh - 140px); }
985
987
  .sess-bar { display: flex; align-items: center; gap: var(--s-2); }
@@ -9,6 +9,7 @@ import {
9
9
  failure, buildNeeds, buildDoing, buildPromised, buildRunning, buildAllowed, headline, ago, combineNeeds, machineNames,
10
10
  } from './home-model.js';
11
11
  import { GROUPS, legacyHref, icon, tabHref } from './nav-model.js';
12
+ import { fillPartial } from './home-model.js';
12
13
  import { renderPromises, topicLink } from './promises.js';
13
14
  import { renderMachines } from './machines.js';
14
15
  import { renderAccounts } from './accounts.js';
@@ -29,7 +30,7 @@ import { renderSessions } from './sessions.js';
29
30
  import { renderMaturation, maturationTile } from './maturation-board.js';
30
31
  import { renderIdeas, ideasTile } from './ideas.js';
31
32
  import { paceCard } from './pace.js';
32
- import { sheetIsOpen, closeSheet, unlockCheck } from './ui.js';
33
+ import { sheetIsOpen, closeSheet, unlockCheck, machineIssue, MACHINES_PAGE } from './ui.js';
33
34
 
34
35
  const TOKEN_KEY = 'instar_token'; // shared with the legacy dashboard
35
36
  const THEME_KEY = 'instar_console_theme';
@@ -419,8 +420,11 @@ function paintHeadline(models) {
419
420
  }
420
421
  const statusNote = $('#homeStatusNote');
421
422
  if (statusNote) {
422
- statusNote.textContent = models.needs?.statusNote || '';
423
- statusNote.hidden = !statusNote.textContent;
423
+ const noteText = models.needs?.statusNote || '';
424
+ statusNote.replaceChildren(document.createTextNode(noteText));
425
+ // A machine that did not answer: link to where its state is shown.
426
+ if (noteText && machineIssue(noteText)) statusNote.append(' ', el('a', { href: MACHINES_PAGE, text: 'See machines' }));
427
+ statusNote.hidden = !noteText;
424
428
  }
425
429
  const issueLink = $('#heroIssueLink');
426
430
  if (issueLink) {
@@ -430,6 +434,20 @@ function paintHeadline(models) {
430
434
  $('#heroTime').textContent = `${h.tone === 'attn' ? 'What needs you' : 'Updated'} · ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
431
435
  }
432
436
 
437
+ // A machine that misses one pooled read (slow for a moment, or restarting)
438
+ // should not turn Home into "part could not be checked". While it is retried,
439
+ // its rows from the last complete answer stand in, for up to two minutes; the
440
+ // machines that did answer always show their fresh rows.
441
+ const lastComplete = {};
442
+ const ROW_LISTS = { attention: 'items', sessions: 'sessions' };
443
+ function rememberComplete(key, result) {
444
+ if (ROW_LISTS[key]) lastComplete[key] = { result, at: Date.now() };
445
+ }
446
+ function fillFromLastComplete(key, fresh) {
447
+ const prev = lastComplete[key];
448
+ return ROW_LISTS[key] && prev ? fillPartial(fresh, prev.result, ROW_LISTS[key], Date.now(), prev.at) : null;
449
+ }
450
+
433
451
  function loadHome() {
434
452
  const run = homeRun;
435
453
  if (!run || !run.active || run.document !== document) return;
@@ -475,7 +493,9 @@ function loadHome() {
475
493
  const partial = result.ok && (['attention', 'sessions'].includes(key) && Array.isArray(result.data?.pool?.failed) && result.data.pool.failed.length > 0
476
494
  || key === 'topics' && ((result.data?.pool?.failed?.length || 0) > 0 || (result.data?.pool?.unresolved?.length || 0) > 0));
477
495
  const retryable = partial || (!result.ok && !['not signed in', 'switched off on this agent'].includes(result.reason));
478
- results[key] = partial ? { ...result, retrying: true } : result;
496
+ const filled = partial ? fillFromLastComplete(key, result) : null;
497
+ if (result.ok && !partial) rememberComplete(key, result);
498
+ results[key] = filled || (partial ? { ...result, retrying: true } : result);
479
499
  if (key === 'systemTopics' && result.ok) {
480
500
  const t = Number(result.data?.topics?.attention);
481
501
  attentionTopicId = Number.isFinite(t) && t > 1 ? t : null;
@@ -496,7 +516,9 @@ function loadHome() {
496
516
  // Every source has one in-flight request and its own bounded retry timer.
497
517
  // A slow source cannot block another card or start overlapping refreshes.
498
518
  read('pool', '/pool', POOL_TIMEOUT_MS);
499
- read('attention', '/attention?scope=pool', POOL_TIMEOUT_MS);
519
+ // Open items only: a machine's full history (closed items included) can be
520
+ // over a megabyte, enough to miss the per-machine time limit.
521
+ read('attention', '/attention?scope=pool&status=OPEN', POOL_TIMEOUT_MS);
500
522
  read('systemTopics', '/telegram/system-topics');
501
523
  read('sessions', '/sessions?scope=pool', POOL_TIMEOUT_MS);
502
524
  read('topics', '/telegram/topics?scope=pool', POOL_TIMEOUT_MS);
@@ -573,3 +573,23 @@ export function headline(allCards) {
573
573
  if (bad || otherAttn || blind) return { text: 'Nothing needs you.', tone: 'ok', sub: 'I’m handling the rest. It’s under “See agent status”.' };
574
574
  return { text: 'All quiet.', tone: 'ok', sub: 'Nothing needs you right now.' };
575
575
  }
576
+
577
+ // ── Partial pooled reads ──────────────────────────────────────────────────
578
+ /** How long a machine's last complete rows may stand in while it is retried. */
579
+ export const PARTIAL_GRACE_MS = 2 * 60 * 1000;
580
+
581
+ /**
582
+ * Merge a partial pooled answer (some machines did not answer) with the last
583
+ * complete one: fresh rows from the machines that answered, plus the missing
584
+ * machines' rows from the last complete answer, if it is recent. Returns null
585
+ * when that is not possible, so the partial answer is shown as it is.
586
+ */
587
+ export function fillPartial(fresh, previous, listKey, now, at) {
588
+ const failed = fresh?.data?.pool?.failed;
589
+ if (!previous || !Array.isArray(failed) || !failed.length || now - at > PARTIAL_GRACE_MS) return null;
590
+ const missing = new Set(failed.map((f) => f && f.machineId).filter(Boolean));
591
+ if (missing.size !== failed.length) return null; // a failure we cannot attribute to a machine stays visible
592
+ const rows = (previous.data?.[listKey] || []).filter((r) => r && missing.has(r.machineId));
593
+ return { ...fresh, retrying: true, filledFrom: at,
594
+ data: { ...fresh.data, [listKey]: [...(fresh.data?.[listKey] || []), ...rows], pool: { ...fresh.data.pool, failed: [] } } };
595
+ }
@@ -10,7 +10,7 @@
10
10
  import {
11
11
  buildSchedulePage, buildHistory, markText, storySentence, storyLines, jobActions, toggleConfirm, runStartWords, runFollowWords, saveRefusalWords, originWords, scheduleWords, ago, HISTORY_LIMIT, STRIP_LIMIT,
12
12
  } from './schedule-model.js';
13
- import { el, setKids, pageHead, section, sheet, closeSheet, sheetIsOpen, story, details, note, cantTell } from './ui.js';
13
+ import { el, setKids, pageHead, section, sheet, closeSheet, sheetIsOpen, story, details, note, noteLinked, cantTell } from './ui.js';
14
14
  import { legacyHref } from './nav-model.js';
15
15
  import { verifiedDisplayName } from './display-identity.js';
16
16
 
@@ -73,7 +73,7 @@ function paint() {
73
73
  setKids(main, el('div', { class: 'schedule-page' },
74
74
  pageHead({ eyebrow: EYEBROW, headline: page.headline, sub: page.sub, tone: page.tone, facts: page.facts }),
75
75
  liveNote(),
76
- page.notes.map((t) => note(t)),
76
+ page.notes.map((t) => noteLinked(t)),
77
77
  el('div', { class: 'page-sections' }, needs, coming, every, setupSection(page.setup, refresh)),
78
78
  legacyLink(),
79
79
  ));
@@ -150,7 +150,7 @@ function setupSection(setup, refresh) {
150
150
  }
151
151
  body.append(row);
152
152
  }
153
- for (const t of setup.notes) body.append(note(t));
153
+ for (const t of setup.notes) body.append(noteLinked(t));
154
154
  wrap.append(head, body);
155
155
  return wrap;
156
156
  }
@@ -215,7 +215,7 @@ function historyView(h) {
215
215
  strip.append(b);
216
216
  }
217
217
  const wrap = el('div', { class: 'history' }, strip, detail);
218
- for (const t of h.notes) wrap.append(note(t));
218
+ for (const t of h.notes) wrap.append(noteLinked(t));
219
219
  {
220
220
  const list = el('ol', { class: 'run-list', hidden: true });
221
221
  let day = null;
@@ -131,6 +131,24 @@ export function historyUrl(s, before = null) {
131
131
  return `/sessions/${encodeURIComponent(s.tmuxSession)}/transcript?${q}`;
132
132
  }
133
133
 
134
+ /** After a session's own record runs out, History continues into its topic's earlier
135
+ * messages (earlier sessions of the same conversation), a page at a time. */
136
+ export const TOPIC_HISTORY_PAGE = 50;
137
+ export function topicOf(s) {
138
+ const n = Number(s && s.platform === 'telegram' ? s.platformId : NaN);
139
+ return Number.isSafeInteger(n) && n > 1 ? n : null;
140
+ }
141
+ export function topicHistoryUrl(topicId, beforeMs) {
142
+ const q = new URLSearchParams({ limit: String(TOPIC_HISTORY_PAGE), scope: 'pool', before: String(Math.floor(beforeMs)) });
143
+ return `/telegram/topics/${encodeURIComponent(topicId)}/messages?${q}`;
144
+ }
145
+ /** A topic message as a History entry: who, when, what. */
146
+ export function topicHistoryEntry(m) {
147
+ if (!m || typeof m.text !== 'string' || !m.text.trim()) return null;
148
+ const at = Date.parse(m.timestamp);
149
+ return { role: m.fromUser ? 'user' : 'assistant', text: m.text, at: Number.isFinite(at) ? m.timestamp : null, atMs: Number.isFinite(at) ? at : null };
150
+ }
151
+
134
152
  /** Words for a failed History read: never "no history" when the read failed. */
135
153
  export function historyErrorWords(res) {
136
154
  const d = (res && res.data) || {};
@@ -11,9 +11,9 @@ import {
11
11
  buildSessions, sessionsHeadline, sessionsFacts, partialWords, titleLine, closeRequest, closeConfirmWords,
12
12
  closeResultWords, doorChoices, modelChoices, doorModelWords, regimeWords, machineChoices, PLATFORMS,
13
13
  platformAllowsDoor, createBody, createResultWords, defaultWords, profileSourceWords, switchLanded,
14
- canSwitchDoor, attachmentMessage, historyUrl, historyErrorWords, historyText, KEYS, STOP_KEY, frame, nextHistoryBatch, streamErrorWords, composerRows, modelWords, HISTORY_FIRST,
14
+ canSwitchDoor, attachmentMessage, historyUrl, historyErrorWords, historyText, topicOf, topicHistoryUrl, topicHistoryEntry, TOPIC_HISTORY_PAGE, KEYS, STOP_KEY, frame, nextHistoryBatch, streamErrorWords, composerRows, modelWords, HISTORY_FIRST,
15
15
  } from './sessions-model.js';
16
- import { el, setKids, pageHead, sheet, closeSheet, story, details, note, cantTell } from './ui.js';
16
+ import { el, setKids, pageHead, sheet, closeSheet, story, details, note, noteLinked, cantTell, MACHINES_PAGE } from './ui.js';
17
17
  import { legacyHref } from './nav-model.js';
18
18
  import { topicNameMap } from './topic-names.js';
19
19
 
@@ -122,13 +122,14 @@ function paintList(box, m, openKey) {
122
122
  parts.push(cantTell(m.reason));
123
123
  } else {
124
124
  const pw = partialWords(m);
125
- if (pw) parts.push(el('p', { class: 'card-note', text: pw }));
125
+ // A machine that did not answer links to the Machines page, where its state and reachability are shown.
126
+ if (pw) parts.push(noteLinked(pw));
126
127
  if (!m.rows.length) parts.push(el('p', { class: 'empty-line', text: m.multi ? 'Nothing is running on any machine.' : 'Nothing is running on this machine.' }));
127
128
  if (m.needsLook.length) parts.push(group('Needs a look', 'look', m.needsLook, openKey));
128
129
  if (m.working.length) parts.push(group('Working', 'working', m.working, openKey));
129
130
  if (m.unanswered.length || m.idleMachines.length) {
130
131
  parts.push(el('section', { class: 'list-section', id: 'sec-idle' }, el('div', { class: 'list-head' }, el('h2', { class: 'eyebrow', text: 'Other machines' })),
131
- m.unanswered.map((u) => el('p', { class: 'machine-line dashed', text: `${u.name}: ${u.words}` })),
132
+ m.unanswered.map((u) => el('a', { class: 'machine-line dashed machine-line-link', href: MACHINES_PAGE, text: `${u.name}: ${u.words} ›` })),
132
133
  m.idleMachines.map((i) => el('p', { class: `machine-line${i.ok ? '' : ' dashed'}`, text: `${i.name}: ${i.words}` }))));
133
134
  }
134
135
  }
@@ -434,11 +435,20 @@ function sendKey(key) {
434
435
 
435
436
  // ── History: the conversation record, newest at the bottom, older on scroll-up ──
436
437
  async function openHistory(view) {
437
- view.history = { before: null, done: false, loading: false, count: 0 };
438
+ view.history = { before: null, done: false, loading: false, count: 0, phase: 'session', oldestMs: null, topicBefore: null };
438
439
  view.historyBox.addEventListener('scroll', () => { if (view.historyBox.scrollTop < 200) loadHistory(view); });
439
440
  await loadHistory(view);
440
441
  }
441
442
 
443
+ function historyRow(e) {
444
+ const text = historyText(e);
445
+ if (!text) return null;
446
+ const when = e.at ? new Date(e.at).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : null;
447
+ return e.role === 'tool'
448
+ ? el('p', { class: 'sess-h sess-h-tool', text: `→ ${text}` })
449
+ : el('div', { class: `sess-h sess-h-${e.role}` }, el('div', { class: 'sess-h-who', text: [e.role === 'user' ? 'Message in' : 'Session', when].filter(Boolean).join(' · ') }), el('div', { class: 'sess-h-text', text }));
450
+ }
451
+
442
452
  async function loadHistory(view) {
443
453
  const h = view.history;
444
454
  if (!h || h.loading || h.done || st.view !== view) return;
@@ -446,33 +456,59 @@ async function loadHistory(view) {
446
456
  const box = view.historyBox;
447
457
  const marker = el('p', { class: 'sess-history-note', text: h.count ? 'Loading earlier messages…' : 'Loading the conversation…' });
448
458
  box.prepend(marker);
449
- const res = await send('GET', historyUrl(view.s, h.before));
459
+ const topicPhase = h.phase === 'topic';
460
+ const res = await send('GET', topicPhase ? topicHistoryUrl(topicOf(view.s), h.topicBefore) : historyUrl(view.s, h.before));
450
461
  marker.remove();
451
462
  h.loading = false;
452
463
  if (st.view !== view) return;
453
- if (!res.ok || !res.data || !Array.isArray(res.data.entries)) {
464
+ const list = topicPhase ? res.data && res.data.messages : res.data && res.data.entries;
465
+ if (!res.ok || !Array.isArray(list)) {
454
466
  h.done = true;
455
- box.prepend(el('p', { class: 'sess-history-note', text: historyErrorWords(res) }));
467
+ box.prepend(el('p', { class: 'sess-history-note', text: topicPhase ? `Can’t load earlier messages: ${res.reason || 'the server did not answer'}.` : historyErrorWords(res) }));
456
468
  return;
457
469
  }
458
470
  const first = !h.count;
459
471
  const prevHeight = box.scrollHeight, prevTop = box.scrollTop;
460
472
  const frag = document.createDocumentFragment();
461
- for (const e of res.data.entries) {
462
- const text = historyText(e);
463
- if (!text) continue;
464
- const when = e.at ? new Date(e.at).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : null;
465
- frag.append(e.role === 'tool'
466
- ? el('p', { class: 'sess-h sess-h-tool', text: `→ ${text}` })
467
- : el('div', { class: `sess-h sess-h-${e.role}` }, el('div', { class: 'sess-h-who', text: [e.role === 'user' ? 'Message in' : 'Session', when].filter(Boolean).join(' · ') }), el('div', { class: 'sess-h-text', text })));
473
+ // A server without scroll-back support ignores `before` and returns the newest
474
+ // messages: keep only what is strictly older than the cursor, so nothing repeats.
475
+ const entries = topicPhase
476
+ ? list.map(topicHistoryEntry).filter((e) => e && e.atMs != null && e.atMs < h.topicBefore)
477
+ : list;
478
+ for (const e of entries) {
479
+ const row = historyRow(e);
480
+ if (row) frag.append(row);
481
+ const ms = e.atMs ?? (e.at ? Date.parse(e.at) : NaN);
482
+ if (Number.isFinite(ms)) h.oldestMs = h.oldestMs == null ? ms : Math.min(h.oldestMs, ms);
483
+ }
484
+ h.count += entries.length;
485
+ if (topicPhase) {
486
+ h.topicCount += entries.length;
487
+ // Nothing earlier after all: the divider would introduce an empty section.
488
+ if (!h.topicCount && h.divider) { h.divider.remove(); h.divider = null; }
489
+ h.topicBefore = h.oldestMs;
490
+ if (list.length < TOPIC_HISTORY_PAGE || entries.length === 0 || h.topicBefore == null) { h.done = true; frag.prepend(el('p', { class: 'sess-history-note', text: 'The start of this conversation.' })); }
491
+ } else {
492
+ h.before = res.data.nextBefore;
493
+ if (h.before == null) {
494
+ // This session's own record ends here. A conversation keeps going back
495
+ // through its earlier sessions: continue with the topic's messages.
496
+ if (topicOf(view.s)) {
497
+ h.phase = 'topic';
498
+ h.topicBefore = h.oldestMs ?? Date.now();
499
+ h.divider = el('p', { class: 'sess-history-note', text: 'Earlier, from this conversation’s previous sessions:' });
500
+ h.topicCount = 0;
501
+ frag.prepend(h.divider);
502
+ } else {
503
+ h.done = true;
504
+ frag.prepend(el('p', { class: 'sess-history-note', text: h.count ? 'The start of this conversation.' : 'Nothing has been said in this session yet.' }));
505
+ }
506
+ }
468
507
  }
469
- h.count += res.data.entries.length;
470
- h.before = res.data.nextBefore;
471
- if (h.before == null) { h.done = true; frag.prepend(el('p', { class: 'sess-history-note', text: h.count ? 'The start of this conversation.' : 'Nothing has been said in this session yet.' })); }
472
508
  box.prepend(frag);
473
509
  if (first) box.scrollTop = box.scrollHeight;
474
510
  else box.scrollTop = prevTop + (box.scrollHeight - prevHeight);
475
- // A short first page may not fill the box: keep loading until it scrolls.
511
+ // A short page may not fill the box: keep loading until it scrolls.
476
512
  if (!h.done && box.scrollHeight <= box.clientHeight + 200) loadHistory(view);
477
513
  }
478
514
 
@@ -32,6 +32,17 @@ export function pageHead({ eyebrow, headline, sub, tone, facts = [] }) {
32
32
  /** A dashed honesty note (can't tell / partial / stale). */
33
33
  export function note(text) { return el('p', { class: 'card-note', text }); }
34
34
 
35
+ /** Where a machine's reachability is shown. */
36
+ export const MACHINES_PAGE = '#/capacity/machines';
37
+ /** A note about a machine that did not answer: anything that fails links to the problem. */
38
+ export function machineIssue(text) {
39
+ return /did not answer|not answering|didn[’']t answer|Couldn['’]t read other machines/i.test(String(text || ''));
40
+ }
41
+ export function noteLinked(text) {
42
+ if (!machineIssue(text)) return note(text);
43
+ return el('p', { class: 'card-note' }, document.createTextNode(`${text} `), el('a', { href: MACHINES_PAGE, text: 'See machines' }));
44
+ }
45
+
35
46
  /** 5. Can't tell, as a whole page. */
36
47
  export function cantTell(reason) {
37
48
  return el('section', { class: 'card', 'data-state': 'unknown' },
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"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"}
@@ -11834,7 +11834,7 @@ export async function startServer(options) {
11834
11834
  const slackChId = slackProxyChannelMap.get(topicId);
11835
11835
  if (slackChId && _slackAdapter) {
11836
11836
  const msgs = _slackAdapter.getChannelMessages(slackChId, limit);
11837
- return msgs.map(m => ({ text: m.text, fromUser: true, timestamp: new Date(parseFloat(m.ts) * 1000).toISOString() }));
11837
+ return msgs.map(m => ({ text: m.text, fromUser: _slackAdapter.isMessageFromUser(m), timestamp: new Date(parseFloat(m.ts) * 1000).toISOString() }));
11838
11838
  }
11839
11839
  if (telegram) {
11840
11840
  const entries = telegram.getTopicHistory(topicId, limit);
@@ -11914,7 +11914,7 @@ export async function startServer(options) {
11914
11914
  const slackChId = slackProxyChannelMap.get(topicId);
11915
11915
  if (slackChId && _slackAdapter) {
11916
11916
  const msgs = _slackAdapter.getChannelMessages(slackChId, limit);
11917
- return msgs.map(m => ({ text: m.text, fromUser: true, timestamp: new Date(parseFloat(m.ts) * 1000).toISOString() }));
11917
+ return msgs.map(m => ({ text: m.text, fromUser: _slackAdapter.isMessageFromUser(m), timestamp: new Date(parseFloat(m.ts) * 1000).toISOString() }));
11918
11918
  }
11919
11919
  if (telegram) {
11920
11920
  const entries = telegram.getTopicHistory(topicId, limit);
@@ -12394,7 +12394,7 @@ export async function startServer(options) {
12394
12394
  const slackChId = slackProxyChannelMap.get(topicId);
12395
12395
  if (slackChId && _slackAdapter) {
12396
12396
  const msgs = _slackAdapter.getChannelMessages(slackChId, limit);
12397
- return msgs.map(m => ({ text: m.text, fromUser: true, timestamp: new Date(parseFloat(m.ts) * 1000).toISOString() }));
12397
+ return msgs.map(m => ({ text: m.text, fromUser: _slackAdapter.isMessageFromUser(m), timestamp: new Date(parseFloat(m.ts) * 1000).toISOString() }));
12398
12398
  }
12399
12399
  if (telegram) {
12400
12400
  const history = telegram.getMessageLog?.();
@@ -12434,6 +12434,8 @@ export async function startServer(options) {
12434
12434
  // (update-train churn) forgot which dead sessions were already
12435
12435
  // announced and re-posted the same death once per boot.
12436
12436
  statePath: path.join(config.stateDir, 'state', 'session-monitor-ctx-notified.json'),
12437
+ // Same for "session has stopped": one notice per death, across restarts.
12438
+ deadNoticeStatePath: path.join(config.stateDir, 'state', 'session-monitor-dead-notified.json'),
12437
12439
  // A topic an ONLINE peer actively owns is not this machine's to monitor:
12438
12440
  // route() forwards its inbound to that owner, so a stale local mapping
12439
12441
  // must never produce a "session has stopped" notice or a local triage