create-pmos 0.517.0 → 0.519.0

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.
@@ -2,16 +2,17 @@
2
2
  /* routes-next-prompt.js — the chat's suggested next prompt and its Sharper card over HTTP (batch 26 item 2, design B; lib/next-prompt.js).
3
3
  *
4
4
  * GET /api/next-prompt/prefs → { ok, next, sharpen, sharpenSet, style, rules, api }
5
- * next: suggestions after a reply are on. sharpen: Sharpen as I type is on now ('auto' is on when Claude's runs go to the API, where a
6
- * call takes about 2 s; a background `claude -p` run takes 10 s or more, too slow to follow typing). sharpenSet: 'auto'|'on'|'off'.
7
- * style: 'polish'|'engineer'|'own'. rules: whether Your own rules are written. api: a key for the Claude API is set.
5
+ * next: suggestions after a reply are on. sharpen: Sharpen as I type is on now (on unless the PM turned it off; 'auto' is off only
6
+ * while an opted-in API path is failing). sharpenSet: 'auto'|'on'|'off'. style: 'polish'|'engineer'|'own'. rules: whether Your own
7
+ * rules are written. api: the opted-in API path is in use (PMOS_NEXT_PROMPT_API=on and a key). apiDown: { why, auth } while it fails.
8
8
  * POST /api/next-prompt/prefs { key: 'next'|'sharpen'|'style'|'rules', value } → { ok } (saved in <runtime>/next-prompt/prefs.json)
9
9
  * POST /api/next-prompt/next { key, asked, reply, initiative?, stage?, product?, title? } → { ok, on, cards: [{ text, move, rec }] }
10
10
  * POST /api/next-prompt/sharpen { key, text, reply?, initiative?, title? } → { ok, on, text | null }
11
11
  *
12
12
  * Both POSTs are page presses (lib/pm-guard.js requirePagePress; an agent session header is refused) and run ONE short Claude call:
13
- * on the API with the PM's key when one is set (lib/anthropic-key.js, model PMOS_NEXT_PROMPT_MODEL or claude-sonnet-5-5), else a headless
14
- * read-only run (lib/headless.js kind 'ask', origin briefing-ask:next-prompt:…). The answer goes through the shared door, lib/ai-guard.js file():
13
+ * a background read-only `claude -p` run on the PM's own Claude login (lib/headless.js kind 'ask', origin briefing-ask:next-prompt:…), the
14
+ * default (Wawan 2026-11-04). Only with PMOS_NEXT_PROMPT_API=on does it go to the API with the PM's key (lib/anthropic-key.js, model
15
+ * PMOS_NEXT_PROMPT_MODEL or claude-sonnet-5-5), and a failed API call then falls back to the background run. The answer goes through the shared door, lib/ai-guard.js file():
15
16
  * the kill switch (`pmos ai off next-prompt`, `pmos ai off sharpen-prompt`, PMOS_AI=off), the replay gate (fixtures/next-prompt-replay.json,
16
17
  * recorded answers, never a model), the citation check and the audit (<runtime>/ai-audit.jsonl). A card with no verified source is never
17
18
  * shown. The reply and the typed text a card cites are kept in <runtime>/next-prompt/ (the newest 30 files), outside every repository.
@@ -61,14 +62,29 @@ function setPref(runtimeDir, key, value) {
61
62
  const get = (ctx, k) => { const v = readPrefs(ctx.runtimeDir)[k]; return PREFS[k](v) ? v : undefined; };
62
63
  const envOf = (ctx) => ctx.env || process.env;
63
64
 
65
+ /* Wawan 2026-11-04: "just default to normal claude session with -p". Both calls run as a background `claude -p` on the PM's own Claude login.
66
+ * The API key is used only when PMOS_NEXT_PROMPT_API=on opts in. */
64
67
  function apiKey(ctx) {
65
68
  const env = envOf(ctx);
66
- if (/^(off|0|false|no)$/i.test(String(env.PMOS_NEXT_PROMPT_API || ''))) return null;
69
+ if (!/^(on|1|true|yes)$/i.test(String(env.PMOS_NEXT_PROMPT_API || ''))) return null;
67
70
  try { return require('./anthropic-key.js').getKey(ctx.runtimeDir, env); } catch { return null; }
68
71
  }
72
+ /* The API's health, per state folder, in memory: after a failed call PMOS stops calling the API for DOWN_MS and uses the background run,
73
+ * so a spent balance or a refused key does not fail every reply again. A server restart, or a new key saved, tries the API again. */
74
+ const DOWN_MS = 15 * 60 * 1000;
75
+ const apiDown = new Map(); // runtimeDir → { until, why, auth, key }
76
+ function downOf(ctx, key) {
77
+ const d = apiDown.get(ctx.runtimeDir);
78
+ if (!d) return null;
79
+ if (d.until <= Date.now() || d.key !== keyId(key)) { apiDown.delete(ctx.runtimeDir); return null; }
80
+ return d;
81
+ }
82
+ const keyId = (k) => (k ? crypto.createHash('sha1').update(String(k)).digest('hex').slice(0, 12) : '');
69
83
  function prefs(ctx) {
70
- const set = get(ctx, 'sharpen') || 'auto', api = !!apiKey(ctx), style = get(ctx, 'style') || 'engineer', rules = String(get(ctx, 'rules') || '');
71
- return { next: get(ctx, 'next') !== false, sharpen: set === 'on' || (set === 'auto' && api), sharpenSet: set, style, rules: !!rules.trim(), rulesText: rules, api };
84
+ const set = get(ctx, 'sharpen') || 'auto', k = apiKey(ctx), down = k ? downOf(ctx, k) : null, api = !!k && !down;
85
+ const style = get(ctx, 'style') || 'engineer', rules = String(get(ctx, 'rules') || '');
86
+ return { next: get(ctx, 'next') !== false, sharpen: set !== 'off' && !(set === 'auto' && k && down), sharpenSet: set, style, rules: !!rules.trim(), rulesText: rules, api,
87
+ ...(down ? { apiDown: { why: down.why, auth: down.auth } } : {}) };
72
88
  }
73
89
  function offWhy(ctx, job) {
74
90
  const why = AG.off(job, { env: envOf(ctx), settingsDir: ctx.runtimeDir });
@@ -81,31 +97,55 @@ function offWhy(ctx, job) {
81
97
 
82
98
  /* ── the call ────────────────────────────────────────────────────────────────────────────── */
83
99
 
84
- async function viaApi(key, prompt, env) {
85
- const Anthropic = require('@anthropic-ai/sdk');
86
- const api = new Anthropic({ apiKey: key });
87
- const res = await api.messages.create({ model: String(env.PMOS_NEXT_PROMPT_MODEL || '').trim() || DEFAULT_MODEL, max_tokens: 800, messages: [{ role: 'user', content: prompt }] });
88
- return (res.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n');
100
+ // A model the account cannot use (404) tries the next one before the call counts as failed.
101
+ const MODELS = ['claude-sonnet-5-5', 'claude-sonnet-5', 'claude-haiku-5-5'];
102
+ async function viaApi(key, prompt, env, create = null) {
103
+ const make = create || ((o) => { const Anthropic = require('@anthropic-ai/sdk'); return new Anthropic({ apiKey: key }).messages.create(o); });
104
+ const want = String(env.PMOS_NEXT_PROMPT_MODEL || '').trim();
105
+ const models = want ? [want] : MODELS;
106
+ for (let i = 0; i < models.length; i++) {
107
+ try {
108
+ const res = await make({ model: models[i], max_tokens: 800, messages: [{ role: 'user', content: prompt }] });
109
+ return (res.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n');
110
+ } catch (e) { if (!(e && e.status === 404) || i === models.length - 1) throw e; }
111
+ }
112
+ return null;
89
113
  }
90
- function viaHeadless(ctx, prompt, origin) {
114
+ /** A background read-only Claude run → its answer text, or null after `fail(why)` says why it gave none. */
115
+ function viaHeadless(ctx, prompt, origin, fail = () => {}) {
91
116
  const headless = require('./headless.js');
92
- if (typeof ctx.spawnSession !== 'function' || headless.kindFor({ agent: 'claude', origin }) !== 'ask') return Promise.resolve(null);
117
+ if (typeof ctx.spawnSession !== 'function') { fail('no background run here'); return Promise.resolve(null); }
118
+ if (headless.kindFor({ agent: 'claude', origin }) !== 'ask') { fail('the run would not be read-only'); return Promise.resolve(null); }
93
119
  return new Promise((resolve) => {
94
- let settled = false; const done = (v) => { if (!settled) { settled = true; clearTimeout(t); resolve(v); } };
95
- const t = setTimeout(() => done(null), TIMEOUT_MS);
120
+ let settled = false; const done = (v, why) => { if (settled) return; settled = true; clearTimeout(t); if (v == null) fail(why); resolve(v); };
121
+ const t = setTimeout(() => done(null, 'the run took longer than ' + Math.round(TIMEOUT_MS / 1000) + ' s'), TIMEOUT_MS);
96
122
  let s = null;
97
123
  try {
98
124
  s = ctx.spawnSession({ name: 'chat: next prompt', agent: 'claude', origin, initialInput: prompt,
99
- onDone: (d) => { const r = d && d.result; done(r && r.ok && !r.isError ? String(r.text || '') : null); } });
100
- } catch { s = null; }
101
- if (!s || s.state === 'exited') done(null);
125
+ onDone: (d) => { const r = d && d.result; done(r && r.ok && !r.isError ? String(r.text || '') : null, 'the run ended without an answer' + (d && d.note ? ': ' + String(d.note).slice(0, 80) : '')); } });
126
+ } catch (e) { s = null; }
127
+ if (!s || s.state === 'exited') done(null, 'the run did not start' + (s && s.note ? ': ' + String(s.note).slice(0, 80) : ''));
102
128
  });
103
129
  }
104
- async function ask(ctx, prompt, { job, key }) {
130
+ /** Why an API call failed, in words fit for the audit: the HTTP status and the error's type, never the prompt or the answer. */
131
+ function apiWhy(e) {
132
+ const st = e && (e.status || (e.response && e.response.status));
133
+ const kind = e && ((e.error && e.error.error && e.error.error.type) || e.name || e.code);
134
+ return ['api', st ? String(st) : null, kind ? String(kind).slice(0, 60) : null].filter(Boolean).join(' ');
135
+ }
136
+ /* One short Claude call. With a key it goes to the API; when that call fails (a spent balance, a model the account cannot use, no network),
137
+ * it does not end in silence: the same prompt goes to a background read-only Claude run, and the audit records why the API failed
138
+ * (`pmos ai log next-prompt`); for 15 minutes after, PMOS goes straight to the background run. `ctx.createMessage` replaces the SDK in tests. */
139
+ async function ask(ctx, prompt, { job, key, run }) {
105
140
  if (typeof ctx.askClaude === 'function') return ctx.askClaude(prompt, { job, key });
106
141
  const k = apiKey(ctx);
107
- if (k) { try { return await viaApi(k, prompt, envOf(ctx)); } catch { return null; } }
108
- return viaHeadless(ctx, prompt, `briefing-ask:next-prompt:${job}:${crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 10)}`);
142
+ if (k && !downOf(ctx, k)) {
143
+ const failed = (why, auth) => { apiDown.set(ctx.runtimeDir, { until: Date.now() + DOWN_MS, why, auth: !!auth, key: keyId(k) }); AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'api-error', error: why }); };
144
+ try { const t = await viaApi(k, prompt, envOf(ctx), ctx.createMessage || null); if (t && String(t).trim()) return t; failed('api empty answer', false); }
145
+ catch (e) { failed(apiWhy(e), e && (e.status === 401 || e.status === 403)); }
146
+ }
147
+ return viaHeadless(ctx, prompt, `briefing-ask:next-prompt:${job}:${crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 10)}`,
148
+ (why) => AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'run-error', error: String(why).slice(0, 160) }));
109
149
  }
110
150
 
111
151
  /* ── what a card cites ───────────────────────────────────────────────────────────────────── */
@@ -182,7 +222,7 @@ async function next(ctx, b) {
182
222
  const k = 'n:' + key + ':' + crypto.createHash('sha1').update(reply + '\n' + P.style + '\n' + P.rulesText).digest('hex');
183
223
  return once(k, async () => {
184
224
  AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'start' });
185
- const answer = await ask(ctx, NP.nextPrompt({ pm: text(b.asked, 2000), reply, place, style: P.style, rules: P.rulesText, moves }), { job, key });
225
+ const answer = await ask(ctx, NP.nextPrompt({ pm: text(b.asked, 2000), reply, place, style: P.style, rules: P.rulesText, moves }), { job, key, run });
186
226
  const cards = NP.parseNext(answer, { moves, place });
187
227
  if (!cards.length) { AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'end', filed: 0, dropped: 0, ...(answer == null ? { error: 'no answer' } : {}) }); return { ok: true, on: true, cards: [] }; }
188
228
  const rel = keep(ctx.runtimeDir, 'reply', reply);
@@ -200,7 +240,7 @@ async function sharpen(ctx, b) {
200
240
  const k = 's:' + key + ':' + crypto.createHash('sha1').update(typed + '\n' + P.style + '\n' + P.rulesText).digest('hex');
201
241
  return once(k, async () => {
202
242
  AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'start' });
203
- const answer = await ask(ctx, NP.sharpenPrompt({ text: typed, reply: text(b.reply, 3000), place: placeOf(b), style: P.style, rules: P.rulesText }), { job, key });
243
+ const answer = await ask(ctx, NP.sharpenPrompt({ text: typed, reply: text(b.reply, 3000), place: placeOf(b), style: P.style, rules: P.rulesText }), { job, key, run });
204
244
  const out = NP.parseSharpen(answer, typed);
205
245
  if (!out) { AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'end', filed: 0, dropped: 0, ...(answer == null ? { error: 'no answer' } : { same: true }) }); return { ok: true, on: true, text: null }; }
206
246
  const first = typed.split('\n')[0], rel = keep(ctx.runtimeDir, 'typed', typed);
@@ -227,7 +267,7 @@ async function handle(req, res, url, ctx) {
227
267
  if (req.method !== 'GET') return send(bad(405, 'GET or POST only'));
228
268
  const P = prefs(ctx), env = envOf(ctx);
229
269
  const next = P.next && !AG.off(NP.JOB_NEXT, { env, settingsDir: ctx.runtimeDir }), sh = P.sharpen && !AG.off(NP.JOB_SHARPEN, { env, settingsDir: ctx.runtimeDir });
230
- return send({ status: 200, body: { ok: true, next, nextSet: P.next, sharpen: sh, sharpenSet: P.sharpenSet, style: P.style, rules: P.rules, rulesText: P.rulesText, api: P.api } });
270
+ return send({ status: 200, body: { ok: true, next, nextSet: P.next, sharpen: sh, sharpenSet: P.sharpenSet, style: P.style, rules: P.rules, rulesText: P.rulesText, api: P.api, ...(P.apiDown ? { apiDown: P.apiDown } : {}) } });
231
271
  }
232
272
  if (what !== 'next' && what !== 'sharpen') return send(bad(404, 'no such next-prompt route'));
233
273
  if (req.method !== 'POST') return send(bad(405, 'POST only'));
@@ -245,4 +285,4 @@ async function handle(req, res, url, ctx) {
245
285
  catch (e) { return send(bad(500, 'The suggestion failed: ' + String((e && e.message) || e).slice(0, 200))); }
246
286
  }
247
287
 
248
- module.exports = { prefixes: ['/api/next-prompt'], handle, VERSION, FIXTURE, prefs, setPref, lineRef, replayCases, next, sharpen };
288
+ module.exports = { prefixes: ['/api/next-prompt'], handle, VERSION, FIXTURE, MODELS, prefs, setPref, lineRef, replayCases, next, sharpen };
@@ -5,7 +5,8 @@
5
5
  * lib/harness-health.js of(): { level, line, issues } or null; all is the last Refresh all
6
6
  * (lib/refresh-run.js allState) or null; derived is the content built from stored reads
7
7
  * (refresh-run derived: the brief, core documents, the scan, and the reads each was built from).
8
- * Each source row has a group: 'machine' (read on this machine) or 'connector'
8
+ * Each source row has a group: 'machine' (read on this machine) or 'connector',
9
+ * and `heal` while Claude repairs it by itself or it needs the PM (lib/source-heal.js view())
9
10
  * POST /api/sources/refresh { ws, source } → { ok, source, started: 'rebuild'|'session', session? }
10
11
  * POST /api/sources/refresh-all { ws } → { ok, joined, started, already, failed, all } every source that is on, then the
11
12
  * Briefing from those reads (briefing-source-flow, slice 3); a press while one runs joins it
@@ -18,6 +19,7 @@
18
19
  */
19
20
  const guard = require('./pm-guard.js');
20
21
  const freshness = require('./freshness.js');
22
+ const heal_ = require('./source-heal.js');
21
23
 
22
24
  function wsOf(ctx, id) {
23
25
  const w = ctx.getWs(id || '');
@@ -33,7 +35,9 @@ async function handle(req, res, url, ctx) {
33
35
  if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
34
36
  const w = wsOf(ctx, url.searchParams.get('ws'));
35
37
  if (!w) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
36
- const sources = F.sources({ id: w.id, root: w.root, repoStateDir: repoStateDir(ctx, w) });
38
+ let heal = {}; try { heal = ctx.runtimeDir ? heal_.viewAll(ctx.runtimeDir) : {}; } catch { heal = {}; }
39
+ // self-heal (lib/source-heal.js): a row Claude is repairing, or that needs the PM, carries its repair
40
+ const sources = F.sources({ id: w.id, root: w.root, repoStateDir: repoStateDir(ctx, w) }).map((r) => (heal[r.id] ? { ...r, heal: heal[r.id] } : r));
37
41
  let health = null; try { health = ctx.health ? ctx.health() : null; } catch { health = null; }
38
42
  let all = null; try { all = ctx.refreshAllState ? ctx.refreshAllState() : null; } catch { all = null; }
39
43
  let derived = []; try { derived = ctx.refreshDerived ? ctx.refreshDerived(w.id) : []; } catch { derived = []; }
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+ /* source-heal.js — Claude repairs a source that is behind by itself, and asks the PM only when the PM must act
3
+ * (feedback batch 26 item 7, Wawan 2026-11-03: "any issue with connection should self diagnose and resolve unless necessary for
4
+ * the user to take action").
5
+ *
6
+ * diagnose(error, { result }) → cause why a read failed: one of CAUSES' keys
7
+ * step(x) → { h, start?, note?, tell? } one tick for one source: what to do now, and its new repair record
8
+ * view(h, name) → { state, cause, why, tried, action, attempts, next_at } | null what /api/sources shows on the row
9
+ * MAX · BACKOFF_MIN · CAUSES
10
+ *
11
+ * THE REPAIR. A source that is on and behind (stale, failed or never read) is read again by Claude, without a press. Attempt 1 starts
12
+ * at once; the next ones wait BACKOFF_MIN minutes (longer after a rate limit). After each attempt the cause is diagnosed from the
13
+ * run's end (the log's error), and a read that ended "done" while the source is still behind counts as failed ("saved nothing new"):
14
+ * a source is fresh only when a read proved it. A cause the PM must fix (a connector that is not connected, a sign-in that still
15
+ * fails after one more try) stops the repair at once; any other cause stops after MAX attempts. Then the record is `needs-you`,
16
+ * with the one action and what Claude tried. The schedule keeps running all the time, so a source the PM fixed turns fresh on its
17
+ * next read and the record becomes `repaired`.
18
+ *
19
+ * A source that is off is left alone (the PM chose it). A source that is read only on a press has no schedule to be past, so its
20
+ * one action is the press.
21
+ *
22
+ * PURE but for viewAll(dir), which reads <state>/source-heal.json for /api/sources (any server, not only the one that runs the
23
+ * schedules). lib/refresh-run.js holds the records, starts the reads, and writes each attempt to the refresh log (trigger 'repair',
24
+ * with the attempt and the last cause in its note).
25
+ */
26
+
27
+ const MAX = 5;
28
+ const BACKOFF_MIN = Object.freeze([0, 2, 10, 30, 120]); // minutes before attempt n+1, by the attempts made so far
29
+ const RATE_MIN = Object.freeze([0, 15, 60, 120, 240]); // the same after a rate limit
30
+ const KEEP_REPAIRED_MS = 24 * 3600 * 1000; // a repaired record is kept this long, then dropped
31
+
32
+ const CONNECTORS = 'https://claude.ai/settings/connectors';
33
+ /* cause → why (the words after "because"), user: the PM must act, after: attempts before that is sure (1 = the first failure tells) */
34
+ const CAUSES = Object.freeze({
35
+ 'claude-signin': { why: 'Claude is signed out on this computer', user: true, after: 2 },
36
+ 'connector-missing': { why: 'its connector is not connected', user: true, after: 1 },
37
+ 'sign-in': { why: 'its sign-in has expired', user: true, after: 2 },
38
+ 'rate-limit': { why: 'it asked Claude to slow down', user: false },
39
+ network: { why: 'the network or the proxy did not answer', user: false },
40
+ 'connector-failed': { why: 'its connector did not start', user: false },
41
+ interrupted: { why: 'the read was stopped before it ended', user: false },
42
+ empty: { why: 'the read ended but saved nothing new', user: false },
43
+ skill: { why: 'the read stopped with an error', user: false },
44
+ manual: { why: 'it is read only when you press ↻', user: true, after: 0 },
45
+ });
46
+
47
+ /** Why a read failed, from the error its run ended with. The order matters: Claude's own sign-in first, then a missing connector. */
48
+ function diagnose(error, { result } = {}) {
49
+ const e = String(error || '').toLowerCase();
50
+ if (result === 'done') return 'empty';
51
+ if (/claude is signed out|not logged in|please run \/login|type \/login|invalid api key/.test(e)) return 'claude-signin';
52
+ if (/not connected|connector (is )?(missing|not (found|installed|available|configured|enabled))|no (\w+ )?(connector|mcp server)|no such tool|tools? .{0,40}not (available|found)|mcp server .{0,40}not (found|configured)/.test(e)) return 'connector-missing';
53
+ if (/\b429\b|rate.?limit|too many requests|quota|throttl/.test(e)) return 'rate-limit';
54
+ if (/\b401\b|unauthori[sz]ed|token (has )?expired|expired (token|session|credentials)|re-?authori[sz]|re-?authenticat|sign.?in (has )?expired|reconnect|invalid[_ ]grant|oauth/.test(e)) return 'sign-in';
55
+ if (/econn|etimedout|enotfound|eai_again|network|proxy|socket hang up|tunnel|\b50[234]\b|unreachable|\bdns\b|fetch failed/.test(e)) return 'network';
56
+ if (/failed to connect|mcp.{0,40}(error|failed)|connector.{0,40}(failed|error)/.test(e)) return 'connector-failed';
57
+ if (result === 'interrupted' || /stopped by you|restarted|session was gone/.test(e)) return 'interrupted';
58
+ return 'skill';
59
+ }
60
+
61
+ const behindOf = (row) => !!row && ['stale', 'failed', 'never'].includes(row.state);
62
+ const waitMin = (cause, n) => { const L = cause === 'rate-limit' ? RATE_MIN : BACKOFF_MIN; return L[Math.min(n, L.length - 1)]; };
63
+
64
+ /**
65
+ * One tick for one source. Every field is plain data:
66
+ * x { now, row (lib/freshness.js row), on, manual, busy (a run is going, or the schedule's own retry or a press waits),
67
+ * last (the source's last run in the log: { start_at, end_at, result, error }), h (its repair record or null), canStart }
68
+ * → { h, start?: true, note?, tell?: { cause, why } } h null drops the record
69
+ */
70
+ function step(x) {
71
+ const { now, row } = x;
72
+ let h = x.h ? { ...x.h, attempts: (x.h.attempts || []).slice() } : null;
73
+ if (!row || !x.on) return { h: null };
74
+ if (!behindOf(row)) {
75
+ if (h && h.state !== 'repaired' && (h.attempts.length || h.state === 'needs-you')) return { h: { ...h, state: 'repaired', repaired_at: now, open: null, next_at: null } };
76
+ if (h && h.state === 'repaired' && now - (h.repaired_at || 0) < KEEP_REPAIRED_MS) return { h };
77
+ return { h: null };
78
+ }
79
+ if (x.manual) return { h: { state: 'needs-you', cause: 'manual', attempts: [], since: (h && h.since) || now } };
80
+ if (!h || h.state === 'repaired' || h.cause === 'manual') h = { state: 'repairing', since: now, attempts: [], open: null, cause: null, next_at: null };
81
+
82
+ // the attempt that is open ends when its run ends in the log
83
+ if (h.open) {
84
+ const l = x.last;
85
+ const ended = l && l.end_at && Date.parse(l.start_at || l.end_at) >= h.open.at - 1000;
86
+ if (!ended) {
87
+ if (now - h.open.at < 30 * 60000 || x.busy) return { h: { ...h, state: 'repairing' } };
88
+ h.attempts.push({ at: h.open.at, result: 'lost', cause: 'interrupted' }); // no run came of it in 30 minutes
89
+ } else {
90
+ const cause = diagnose(l.error, { result: l.result });
91
+ h.attempts.push({ at: h.open.at, result: l.result, cause, error: l.error ? String(l.error).slice(0, 300) : null });
92
+ }
93
+ h.open = null; h.cause = h.attempts[h.attempts.length - 1].cause;
94
+ }
95
+ if (x.busy) return { h: { ...h, state: h.state === 'needs-you' ? 'needs-you' : 'repairing' } };
96
+ if (h.state === 'needs-you') return { h }; // the schedule keeps reading; a good read makes it repaired
97
+
98
+ const n = h.attempts.length;
99
+ const c = h.cause && CAUSES[h.cause];
100
+ const same = n >= (c && c.after || 1) && h.attempts.slice(-(c && c.after || 1)).every((a) => a.cause === h.cause);
101
+ if ((c && c.user && same) || n >= MAX) {
102
+ return { h: { ...h, state: 'needs-you', next_at: null, told_at: h.told_at || now }, tell: h.told_at ? null : { cause: h.cause, why: c ? c.why : '' } };
103
+ }
104
+ const next = n ? h.attempts[n - 1].at + waitMin(h.cause, n) * 60000 : now;
105
+ if (now < next || !x.canStart) return { h: { ...h, state: 'repairing', next_at: Math.max(next, now) } };
106
+ h.open = { at: now };
107
+ return { h: { ...h, state: 'repairing', next_at: null }, start: true, note: `Repair ${n + 1} of ${MAX}` + (c ? `: the last read failed because ${c.why}` : `: ${row.state === 'never' ? 'never read' : row.state === 'failed' ? 'the last read failed' : 'past its schedule'}`) };
108
+ }
109
+
110
+ const hm = (ms) => { const d = new Date(ms); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); };
111
+
112
+ /** What the Sources row and the banner show. `name` is the source's name ('Jira'). */
113
+ function view(h, name = 'It') {
114
+ if (!h) return null;
115
+ const c = h.cause ? CAUSES[h.cause] : null;
116
+ const n = (h.attempts || []).length;
117
+ const tried = n ? `Claude read it again ${n === 1 ? 'once' : n + ' times'} since ${hm(h.attempts[0].at)}${c ? `, and ${c.why}` : ''}.` : '';
118
+ let action = null;
119
+ if (h.state === 'needs-you') {
120
+ if (h.cause === 'claude-signin') action = { label: 'Sign in to Claude', how: 'Open Terminal, run claude, type /login and sign in.', sec: 'claude' };
121
+ else if (h.cause === 'connector-missing') action = { label: 'Connect ' + name, how: 'Turn on its connector in your Claude connectors.', href: CONNECTORS };
122
+ else if (h.cause === 'sign-in') action = { label: 'Reconnect ' + name, how: 'Sign in to its connector again in your Claude connectors.', href: CONNECTORS };
123
+ else action = { label: 'Read ' + name + ' again', how: h.cause === 'manual' ? 'You chose to read it only when you press ↻.' : 'Claude stopped after ' + n + ' tries.', read: true };
124
+ }
125
+ return { state: h.state, cause: h.cause || null, why: c ? c.why : '', tried, action, attempts: n, next_at: h.next_at ? new Date(h.next_at).toISOString() : null,
126
+ ...(h.repaired_at ? { repaired_at: new Date(h.repaired_at).toISOString() } : {}) };
127
+ }
128
+
129
+ const NAME = Object.freeze({ calendar: 'your calendar', jira: 'Jira', 'my-work': 'Jira', tracker: 'the tracker files', tools: 'your tools' }); // who the PM reconnects
130
+ const FILE = 'source-heal.json';
131
+ /** Every repair record as view() shows it, keyed by its lib/freshness.js id. A missing or torn file is no repair. */
132
+ function viewAll(dir) {
133
+ let x = {};
134
+ try { x = JSON.parse(require('fs').readFileSync(require('path').join(dir, FILE), 'utf8')) || {}; } catch { return {}; }
135
+ const out = {};
136
+ for (const [id, h] of Object.entries(x)) if (h && typeof h === 'object') out[h.fresh || id] = view(h, NAME[h.fresh || id] || id);
137
+ return out;
138
+ }
139
+
140
+ module.exports = { viewAll, FILE, NAME, diagnose, step, view, MAX, BACKOFF_MIN, RATE_MIN, CAUSES, CONNECTORS };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.517.0",
3
+ "version": "0.519.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.517.0",
9
+ "version": "0.519.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.517.0",
3
+ "version": "0.519.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -524,7 +524,7 @@ function scaled(rows, M, o) {
524
524
  const msO = { sp, reach: Math.max(1, Math.round(40 / pxDay)), mode: o.msMode || 'pinned', noLabel: !!(C && C.msOn) }; // a row's milestone ◇s closer than ~40 px merge (batch 11 item 3); the Filter's milestones (batch 13 item 4)
525
525
  const ov = overlay(W, X, now, sprint, rel), pxPct = (tpx * (W.fixed ? 1 : ratio)) / 100;
526
526
  const ra = rel && day(rel.iso), sa = sprint && day(sprint.start), sb = sprint && day(sprint.end);
527
- // an undated program member's track says so beside today, where the view opens
527
+ // an undated initiative's track says so beside today, where the view opens
528
528
  const ND = `<span class="tg-nd" style="left:${P(Math.min(90, Math.max(0, X(now))))}">No dates</span>`;
529
529
  const askBtn = none.length ? `<a class="bd-chip tg-ask" id="askDates" href="${esc(o.askHref(none))}">${SV('M4 5h16v11h-9l-5 4v-4H4z')}Ask to propose dates</a>` : '';
530
530
  const ticks = W.ticks.map((k) => `<span style="left:${P(X(k.at))}" data-y="${k.y}" data-base="${esc(k.base)}"${k.plain ? ' data-plain' : ''}><b>${esc(k.label)}</b><small>${esc(k.sub)}</small></span>`).join('');
@@ -533,23 +533,19 @@ function scaled(rows, M, o) {
533
533
  const back = `<button type="button" class="tg-tback" data-today hidden aria-label="Back to today">${SV(CHL)}<span>Today</span>${SV(CHR)}</button>`;
534
534
  const attrs = `data-span="${sp}" data-from="${+W.from}" data-to="${+W.to}" data-ratio="${W.fixed ? 1 : ratio.toFixed(5)}" data-now="${+now}"${W.fixed ? ' data-fixed' : ''}`;
535
535
  const style = `--tw:${Math.round(tpx * (W.fixed ? 1 : ratio))}px`;
536
- // a program keeps its undated initiatives as rows that say "No dates" (feedback batch 8 item 4: initiatives just moved into a program
537
- // were missing under it); a product group keeps only the dated ones, and the foot names the rest
538
- // by key, not by identity: By product copies a program's row ({ ...r, inProgram }), and that copy is never in `list` (batch 11 item 12: a
539
- // program's initiatives were missing from their product on the timeline)
540
- // By product, a program's undated initiative keeps its "No dates" row under its product too (batch 11 item 19: they disappeared once grouped by product)
541
- const onList = new Set(list.map((r) => r.key));
542
- // a product with no dated initiative keeps its head and its initiatives as "No dates" rows, so the product is not missing from the Board
543
- // (batch 14 item 2: pmos-microsites, all undated outside its program, had no group at all); those rows leave the foot's names
544
- const keepOf = (R) => { const k = R.filter((r) => onList.has(r.key) || r.inProgram); return k.length ? k : R.slice(); };
545
- const shownNd = new Set(o.groups ? o.groups.filter((g) => g.kind !== 'program' && !g.rows.some((r) => onList.has(r.key) || r.inProgram)).flatMap((g) => g.rows.map((r) => r.key)) : []);
546
- const footNone = none.filter((r) => !shownNd.has(r.key));
536
+ // every undated initiative is a row that says "No dates" beside today, in every grouping and in no program too (batch 26 item 8, Wawan:
537
+ // the foot alone hid them; this replaces batch 8 item 4's program-only rule and design B6). By product copies a program's row
538
+ // ({ ...r, inProgram }), so a row is matched by key, not by identity (batch 11 item 12)
539
+ const shownKeys = new Set(o.groups ? o.groups.flatMap((g) => (g.kind === 'program' ? g.products.flatMap((x) => x.rows) : g.rows).map((r) => r.key)) : rows.map((r) => r.key));
540
+ // the foot keeps the count and "Ask to propose dates"; it names only an undated initiative no group draws (none, as the Board groups today)
541
+ const footNone = none.filter((r) => !shownKeys.has(r.key));
547
542
  // a timeline reads in time (batch 11 item 22): each group's rows by start, then end; undated ones last. The health dot still says late or at risk
548
543
  const at = (r) => { const x = extentOf(r, now); return x ? [+x.a, +x.b] : [Infinity, Infinity]; };
549
544
  // a pressed column title sorts each group by that column instead (StsA8Board, as in the List)
550
545
  const byTime = (L) => (C && C.order ? L.slice().sort(C.order) : L.map((r) => [at(r), r]).sort((p, q) => p[0][0] - q[0][0] || p[0][1] - q[0][1]).map((x) => x[1]));
551
546
  list.splice(0, list.length, ...byTime(list));
552
- const G = o.groups ? o.groups.map((g) => (g.kind === 'program' ? { ...g, products: g.products.filter((x) => x.rows.length).map((x) => ({ ...x, rows: byTime(x.rows) })) } : { ...g, rows: byTime(keepOf(g.rows)) })).filter((g) => g.rows.length || g.kind === 'program') : null;
547
+ const flat = list.concat(none);
548
+ const G = o.groups ? o.groups.map((g) => (g.kind === 'program' ? { ...g, products: g.products.filter((x) => x.rows.length).map((x) => ({ ...x, rows: byTime(x.rows) })) } : { ...g, rows: byTime(g.rows) })).filter((g) => g.rows.length || g.kind === 'program') : null;
553
549
  const empty = '<p class="tg-empty">No initiative on screen has a date on record.</p>';
554
550
  const ext = (r) => { const x = extentOf(r, now); return x ? ` data-a="${X(x.a).toFixed(3)}" data-b="${X(x.b).toFixed(3)}"` : ''; };
555
551
  // a product's head carries its lane from Quarter on (batch 11 item 17); Sprint on a phone, and a page that gives no head cell, keep the plain head
@@ -561,9 +557,9 @@ function scaled(rows, M, o) {
561
557
  if (g.kind !== 'program') return lane(g) + (o.folded(g.key) ? '' : g.rows.map(one).join(''));
562
558
  const st = o.progState(g.program), shut = o.folded(g.key), tr = shut ? rollUp(g.rows, progSpan(g), X, W, o, msO, pxPct, now) : '';
563
559
  return `<div class="tgq-r tg-pg${shut ? ' closed' : ''}" data-prow="${esc(g.program.id)}"><div class="tgq-n tg-pgn">${progName(g, st, o, shut ? offDots(g.rows, now) : '')}</div><div class="tg-p">${ov}${tr}</div></div>` + (shut ? '' : g.products.map((x) => x.rows.map(one).join('')).join(''));
564
- }).join('') : list.map(one).join('');
560
+ }).join('') : flat.map(one).join('');
565
561
  const top = `<div class="tgq-h"><div class="tg-p">${ticks}${pill}</div></div>`;
566
- const below = none.length ? `<div class="tg-none">${footNone.length ? `<span>${footNone.length} ${footNone.length === 1 ? 'has' : 'have'} no dates</span>` : ''}${askBtn}</div>` : '';
562
+ const below = none.length ? `<div class="tg-none"><span>${none.length} ${none.length === 1 ? 'has' : 'have'} no dates</span>${askBtn}</div>` : '';
567
563
  // the Deliveries lane (batch 13 item 4, XcalmA6Phone): a phone shows the next deliveries above the rows (o.lane draws it; none: nothing)
568
564
  const lanePh = o.lane ? o.lane({ W, X, rows: list, groups: G, now, phone: true, pxPct }) || '' : '';
569
565
  return { html: lanePh + `<div class="tg tg-s tgq" ${attrs}><div class="tg-scw"><div class="tg-sc" tabindex="0" role="group" aria-label="Timeline" style="${style}"><div class="tg-in">${top}<div class="tg-body">${body || empty}</div></div></div>${back}<i class="tg-fade l" aria-hidden="true"></i><i class="tg-fade r" aria-hidden="true"></i></div></div>${below}`, none };
@@ -581,11 +577,11 @@ function scaled(rows, M, o) {
581
577
  if (g.kind !== 'program') return lane(g) + (o.folded(g.key) ? '' : g.rows.map((r) => one(r, ' tg-gr')).join(''));
582
578
  const lp = g.products[g.products.length - 1], last = lp ? lp.rows[lp.rows.length - 1] : null;
583
579
  return progRow(g, o.progState(g.program), X, W, ov, o, msO, pxPct, now) + (o.folded(g.key) ? '' : g.products.map((x) => (g.products.length > 1 ? o.subHtml(x.name) : '') + x.rows.map((r) => one(r, ' tg-kid' + (r === last ? ' last' : ''))).join('')).join(''));
584
- }).join('') : list.map((r) => one(r)).join('');
580
+ }).join('') : flat.map((r) => one(r)).join('');
585
581
  // the sprint band and the release keep their labels under the axis on Quarter, where a week is wide enough to read them
586
582
  const axl = sp === 'quarter' ? `${sa && sb && sb > W.from && sa < W.to ? `<span class="tg-axl sb" style="left:${P(X(sa))}">Jira ${esc(sprint.name.toLowerCase())} · ${esc(md(sa))} – ${esc(md(sb))}</span>` : ''}${ra && ra > W.from && ra < W.to ? `<span class="tg-axl rl" style="left:${P(X(ra))}">${SV(FLAG)}${esc(rel.name)} · ${esc(md(ra))}</span>` : ''}` : '';
587
583
  const axis = axl ? `<div class="tg-ax"><span class="tg-n"></span><div class="tg-p">${axl}</div></div>` : '';
588
- const foot = none.length ? `<div class="tg-ft">${footNone.length ? `<span class="tg-none"><b>${footNone.length} ${footNone.length === 1 ? 'has' : 'have'} no dates:</b> <span>${esc(footNone.slice(0, 4).map((r) => r.title).join(', '))}${footNone.length > 4 ? ' and ' + (footNone.length - 4) + ' more' : ''}</span>${askBtn}</span>` : `<span class="tg-none">${askBtn}</span>`}</div>` : '';
584
+ const foot = none.length ? `<div class="tg-ft"><span class="tg-none"><b>${none.length} ${none.length === 1 ? 'has' : 'have'} no dates${footNone.length ? ':' : ''}</b>${footNone.length ? ` <span>${esc(footNone.slice(0, 4).map((r) => r.title).join(', '))}${footNone.length > 4 ? ' and ' + (footNone.length - 4) + ' more' : ''}</span>` : ''}${askBtn}</span></div>` : '';
589
585
  // the Deliveries lane (batch 13 item 4, XcalmA6Lane): one row under the axis, a list per column (o.lane draws it; none: nothing)
590
586
  const laneH = o.lane ? o.lane({ W, X, rows: list, groups: G, now, phone: false, pxPct }) || '' : '';
591
587
  // the columns' width is the name column's (--nw: the sticky block, the left fade and the Deliveries fold all read it)
@@ -4,6 +4,8 @@
4
4
  * ago(iso, now) → '2 min ago' · '1 hour ago' · '5 days ago' · ''
5
5
  * sourceName(row) → the name a source shows: 'Jira', 'Calendar', 'Repository', …
6
6
  * staleOf(sources, now) → { id, name, text: 'Jira 5 days old', ago, state } | null the source furthest behind (failed, then the oldest)
7
+ * bannerOf(sources, now) → { id, name, title: 'Reconnect Jira.', body, text, action } | null the one source the PM must act on
8
+ * (self-heal, feedback batch 26 item 7): null while Claude repairs a source by itself, or after it did
7
9
  * sourceRows(sources, now) → [{ id, name, sub, when, risk, act: 'read' | 'connect' | '' }] the Sources table (EvSources)
8
10
  * partsOf(id) → what may be out of date when that source is old ('Board and the Jira-linked initiatives')
9
11
  * fabLabel(here, open, stale, news) → 'Open the menu. You are in Home. 3 new for you. Jira 5 days old' (the button says both dots, FmGrill 13)
@@ -44,11 +46,26 @@ export function staleOf(sources, now = Date.now()) {
44
46
  return { id: s.id, name, text, ago: ago(s.as_of, now), state: s.state };
45
47
  }
46
48
 
49
+ /** The one source the PM must act on, or null. The server repairs a source that is behind by itself (lib/source-heal.js) and marks a
50
+ * row `heal.state: 'needs-you'` only when the PM must act; until then, and after a repair, there is no banner. The body says what is
51
+ * old and why, what it makes old, and what Claude already tried. It never says a source is fresh: only a read says that. */
52
+ export function bannerOf(sources, now = Date.now()) {
53
+ const L = (sources || []).filter((s) => s && s.heal && s.heal.state === 'needs-you' && s.heal.action);
54
+ if (!L.length) return null;
55
+ const rank = (s) => (s.heal.action.read ? 1 : 0); // a connection to fix leads a read to press
56
+ L.sort((a, b) => rank(a) - rank(b) || (Date.parse(a.as_of || '') || 0) - (Date.parse(b.as_of || '') || 0));
57
+ const s = L[0], h = s.heal, name = sourceName(s);
58
+ const when = s.as_of ? name + ' was last read ' + ago(s.as_of, now) : name + ' was never read';
59
+ const body = when + (h.why ? ' because ' + h.why : '') + '. ' + partsOf(s.id) + ' may be out of date.' + (h.tried ? ' ' + h.tried : '');
60
+ return { id: s.id, name, title: h.action.label + '.', body, text: h.action.label, action: h.action, cause: h.cause };
61
+ }
62
+
47
63
  /** The Sources table: one row per source, in the order the server lists them. */
48
64
  export function sourceRows(sources, now = Date.now()) {
49
65
  return (sources || []).filter(Boolean).map((s) => {
50
66
  const behind = s.state === 'stale' || s.state === 'failed' || s.state === 'never';
51
- const when = s.state === 'off' ? 'Not connected' : s.state === 'building' ? 'Reading now' : s.state === 'never' ? 'Never read'
67
+ const mend = behind && s.heal && s.heal.state === 'repairing';
68
+ const when = s.state === 'off' ? 'Not connected' : s.state === 'building' ? 'Reading now' : mend ? 'Reading again' : s.state === 'never' ? 'Never read'
52
69
  : s.state === 'failed' ? 'Could not be read' : s.id === 'repository' && s.state === 'fresh' ? 'Watching' : ago(s.as_of, now) || 'Never read';
53
70
  return { id: s.id, name: sourceName(s), sub: SRC_SUB[s.id] || s.label || '', when, risk: behind, act: s.state === 'off' ? 'connect' : behind && s.id !== 'repository' ? 'read' : '' };
54
71
  });
@@ -5,12 +5,12 @@
5
5
  * One button, bottom left on desktop and bottom right on a phone, opens the menu: the three places, Home, Board and Intelligence; then a
6
6
  * tray of three icons, Search (⌘K on a desk), Guide and Settings. It is the one way between places, and it holds nothing else: New for you is a fold
7
7
  * in Your day, Sources are Settings › Connectors, Theme is Settings › General, and More is gone (feedback batch 16 item 5).
8
- * The button keeps its two dots on every page: blue when something is new for you, amber when a source is stale. Open, the blue dot sits on
8
+ * The button keeps its two dots on every page: blue when something is new for you, amber when a source needs you (Claude repairs the rest by itself). Open, the blue dot sits on
9
9
  * the Home row (where Your day is) and the amber dot on the Settings icon; the button's own name says both ("Open the menu. 3 new for you.
10
10
  * Jira is stale.").
11
11
  * Search pm/cozy-search.js: across the repository and every worktree; Ctrl K (Cmd K) or "/" opens it from any page
12
12
  * Guide pm/premium-vw-guide.js: the user guide's Start here, in the Viewer of the page you are on (initiative guide-in-app)
13
- * Settings pm/cozy-settings.js; it opens at Connectors while a source is stale, so the stale one is the first thing there
13
+ * Settings pm/cozy-settings.js; it opens at Connectors while a source needs you, so that one is the first thing there
14
14
  * A page that passes opts.here() runs it when its own place is pressed, in place of a reload (Home: Menu > Home closes the page that is open).
15
15
  * The counts come from GET /api/news-for-you and GET /api/sources, read when the page opens and every two minutes while it is shown; each
16
16
  * read of New for you is passed on as the event 'cz:news-for-you' (detail: the answer), so Home's Your day and Good morning follow it.
@@ -20,7 +20,7 @@
20
20
  * call, the menu open, a desk, Settings > Adler not Full, or after Quiet after: no ears (zero is silence).
21
21
  */
22
22
  import { esc, svg, ICON, wsQ } from './cozy-evui.js';
23
- import { fabLabel, staleOf } from './cozy-evmodel.js';
23
+ import { fabLabel, bannerOf } from './cozy-evmodel.js';
24
24
  import { mountToastFaces, loadPrefs, adlerPeek, asleep } from './cozy-adler.js'; // Adler (batch 16 item 2): every toast carries his S face; Settings > Adler is read once
25
25
 
26
26
  export const PLACES = [
@@ -134,7 +134,7 @@ export function mountNav(here, doc = document, opts = {}) {
134
134
  if (doc.hidden) return;
135
135
  const [n, s] = await Promise.all([getj('/api/news-for-you' + wsQ()), getj('/api/sources' + wsQ())]);
136
136
  if (n && n.ok) { N.news = n.count || 0; doc.dispatchEvent(new CustomEvent('cz:news-for-you', { detail: n })); }
137
- if (s && s.ok) { N.stale = staleOf(s.sources); doc.dispatchEvent(new CustomEvent('cz:sources', { detail: s })); } // Your day's failed source (AdP5)
137
+ if (s && s.ok) { N.stale = bannerOf(s.sources); doc.dispatchEvent(new CustomEvent('cz:sources', { detail: s })); } // Your day's failed source (AdP5)
138
138
  if (menu.hidden || !menu.contains(doc.activeElement)) draw();
139
139
  };
140
140
  read();
@@ -143,6 +143,6 @@ export function mountNav(here, doc = document, opts = {}) {
143
143
  doc.addEventListener('cz:news', (e) => { if (e.detail && Number.isFinite(e.detail.count)) { N.news = e.detail.count; draw(); } else read(); });
144
144
  doc.addEventListener('cz:calls', (e) => { const n = e.detail && Number(e.detail.count); N.calls = Number.isFinite(n) && n > 0 ? n : 0; draw(); });
145
145
  doc.addEventListener('adler:prefs', () => { ears.innerHTML = ''; draw(); });
146
- window.addEventListener('pm:sources', (e) => { const d = e.detail; if (d && d.ok) { N.stale = staleOf(d.sources); draw(); } });
146
+ window.addEventListener('pm:sources', (e) => { const d = e.detail; if (d && d.ok) { N.stale = bannerOf(d.sources); draw(); } });
147
147
  return w;
148
148
  }