relay-companion 0.1.52 → 0.1.56

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.
package/bin/relay.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import os from "node:os";
3
+ import fsSync from "node:fs";
3
4
  import path from "node:path";
4
5
  import { randomUUID } from "node:crypto";
5
6
  import { fileURLToPath } from "node:url";
@@ -392,6 +393,50 @@ async function cmdPill(flags = {}) {
392
393
  console.log(`Relay pill is visible. Reopen it from ${statusAreaReopenText()} after hiding it.`);
393
394
  }
394
395
 
396
+ // Diagnose (and optionally heal) swallowed notifications: relays that are
397
+ // unread on disk but marked presented with no pending-attention entry can
398
+ // never notify again. --requeue-unseen strands them back into the queue;
399
+ // --restart-pill makes the running pill reload the healed prefs.
400
+ async function cmdDoctor(flags = {}) {
401
+ const home = process.env.RELAY_HOME || process.env.RELAY_COMPANION_HOME || path.join(os.homedir(), ".relay-companion");
402
+ const statePath = path.join(home, "state.json");
403
+ const prefsPath = path.join(home, "overlay-prefs.json");
404
+ const readJson = (p) => {
405
+ try {
406
+ return JSON.parse(fsSync.readFileSync(p, "utf8")) || {};
407
+ } catch {
408
+ return {};
409
+ }
410
+ };
411
+ const state = readJson(statePath);
412
+ const prefs = readJson(prefsPath);
413
+ const presented = new Set(Array.isArray(prefs.presentedRelayIds) ? prefs.presentedRelayIds.map(String) : []);
414
+ const pending = new Set(Object.keys(prefs.pendingAttention && typeof prefs.pendingAttention === "object" ? prefs.pendingAttention : {}));
415
+ const unread = Object.entries(state.packets || {})
416
+ .filter(([, row]) => row && row.direction === "inbound" && row.state !== "read")
417
+ .map(([id]) => id);
418
+ const swallowed = unread.filter((id) => presented.has(id) && !pending.has(id));
419
+ console.log(`relay doctor @ ${new Date().toISOString()}`);
420
+ console.log(` staged unread: ${unread.length}`);
421
+ console.log(` confirmed-seen ledger: ${presented.size}`);
422
+ console.log(` pending attention queue: ${pending.size}`);
423
+ console.log(` SWALLOWED (unread, marked seen, not queued): ${swallowed.length}`);
424
+ for (const id of swallowed.slice(0, 20)) console.log(` - ${id}`);
425
+ if (swallowed.length > 20) console.log(` … and ${swallowed.length - 20} more`);
426
+ if (flags["requeue-unseen"] && swallowed.length) {
427
+ prefs.presentedRelayIds = [...presented].filter((id) => !swallowed.includes(id));
428
+ fsSync.writeFileSync(prefsPath, `${JSON.stringify(prefs, null, 2)}\n`);
429
+ console.log(`Requeued ${swallowed.length} relay(s): removed from the confirmed-seen ledger.`);
430
+ if (flags["restart-pill"] && process.platform === "darwin") {
431
+ const uid = typeof process.getuid === "function" ? process.getuid() : 501;
432
+ spawnSync("launchctl", ["kickstart", "-k", `gui/${uid}/work.relay.companion.pill`], { stdio: "ignore" });
433
+ console.log("Pill restarted; the queue will replay them sequentially when you are present.");
434
+ } else {
435
+ console.log("Restart the pill to pick this up: launchctl kickstart -k gui/$UID/work.relay.companion.pill");
436
+ }
437
+ }
438
+ }
439
+
395
440
  async function main() {
396
441
  const [command, ...rest] = process.argv.slice(2);
397
442
  const { flags, positional } = parseFlags(rest);
@@ -433,6 +478,8 @@ async function main() {
433
478
  case "update":
434
479
  case "self-update":
435
480
  return runUpdateOnce({ mode: modeFromFlags(flags) }).then(() => {});
481
+ case "doctor":
482
+ return cmdDoctor(flags);
436
483
  default:
437
484
  console.log(
438
485
  [
@@ -0,0 +1,164 @@
1
+ // Durable per-relay attention queue: the single source of truth for "which
2
+ // relays still owe the user a visible notification".
3
+ //
4
+ // Invariants (from the 2026-08 notification reliability audit):
5
+ // - A relay enters the queue when it is staged unread and has never been
6
+ // CONFIRMED seen. It leaves only via confirmSeen() (renderer-confirmed
7
+ // visible dwell / explicit interaction) or drop() (the user acted on that
8
+ // relay elsewhere: opened, deleted, read on another surface).
9
+ // - "presented" (the legacy ledger) now means confirmed-seen, never
10
+ // fired-and-forgotten. Sending IPC to the renderer proves nothing.
11
+ // - The queue is plain serializable state persisted in overlay prefs, so a
12
+ // crash, renderer reload, or process restart replays it instead of
13
+ // dropping it. beginShow() is an in-flight marker, not an exit.
14
+ // - Presentation is sequential: one card at a time, oldest first, so a
15
+ // backlog plays one after another instead of a single folded stack.
16
+ //
17
+ // Pure logic + explicit state in/out: no Electron, no fs, unit-testable.
18
+
19
+ "use strict";
20
+
21
+ const SHOW_RETRY_LIMIT = 2; // failed dwells before a card turns sticky
22
+
23
+ function normalizeEntry(raw, id) {
24
+ if (!raw || typeof raw !== "object") return null;
25
+ const entryId = String(raw.id || id || "").trim();
26
+ if (!entryId) return null;
27
+ return {
28
+ id: entryId,
29
+ enqueuedAt: Number.isFinite(raw.enqueuedAt) ? raw.enqueuedAt : Date.now(),
30
+ attempts: Number.isFinite(raw.attempts) ? raw.attempts : 0,
31
+ showing: raw.showing === true,
32
+ sticky: raw.sticky === true,
33
+ };
34
+ }
35
+
36
+ // prefs.pendingAttention -> Map(id -> entry). Tolerates legacy shapes.
37
+ function loadQueue(prefs) {
38
+ const queue = new Map();
39
+ const raw = prefs && typeof prefs === "object" ? prefs.pendingAttention : null;
40
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
41
+ for (const [id, entry] of Object.entries(raw)) {
42
+ const normalized = normalizeEntry(entry, id);
43
+ if (normalized) queue.set(normalized.id, normalized);
44
+ }
45
+ } else if (Array.isArray(raw)) {
46
+ for (const entry of raw) {
47
+ const normalized = normalizeEntry(entry);
48
+ if (normalized) queue.set(normalized.id, normalized);
49
+ }
50
+ }
51
+ // A crash mid-show leaves showing=true with no renderer to confirm it.
52
+ // Recover by demoting to pending; the attempt already counted.
53
+ for (const entry of queue.values()) entry.showing = false;
54
+ return queue;
55
+ }
56
+
57
+ function saveQueue(queue, prefs) {
58
+ const out = {};
59
+ for (const [id, entry] of queue.entries()) {
60
+ out[id] = {
61
+ id,
62
+ enqueuedAt: entry.enqueuedAt,
63
+ attempts: entry.attempts,
64
+ showing: entry.showing === true,
65
+ sticky: entry.sticky === true,
66
+ };
67
+ }
68
+ prefs.pendingAttention = out;
69
+ return prefs;
70
+ }
71
+
72
+ // Enqueue ids that are unread, not confirmed-seen, and not already queued.
73
+ // Returns the ids actually added (for logging).
74
+ function enqueueUnseen(queue, ids, { presentedIds, now = Date.now() } = {}) {
75
+ const presented = presentedIds instanceof Set ? presentedIds : new Set(presentedIds || []);
76
+ const added = [];
77
+ for (const rawId of ids || []) {
78
+ const id = String(rawId || "").trim();
79
+ if (!id || queue.has(id) || presented.has(id)) continue;
80
+ queue.set(id, { id, enqueuedAt: now, attempts: 0, showing: false, sticky: false });
81
+ added.push(id);
82
+ }
83
+ return added;
84
+ }
85
+
86
+ // Oldest pending entry that is not mid-show. Sticky entries still surface —
87
+ // they just render latched instead of timer-folded.
88
+ function nextToShow(queue) {
89
+ let best = null;
90
+ for (const entry of queue.values()) {
91
+ if (entry.showing) return null; // a card is on stage; strict one-at-a-time
92
+ if (!best || entry.enqueuedAt < best.enqueuedAt) best = entry;
93
+ }
94
+ return best;
95
+ }
96
+
97
+ function beginShow(queue, id) {
98
+ const entry = queue.get(String(id || ""));
99
+ if (!entry) return null;
100
+ entry.showing = true;
101
+ entry.attempts += 1;
102
+ return entry;
103
+ }
104
+
105
+ // The renderer confirmed a real, user-present dwell (or an interaction).
106
+ // Returns true when the id was pending.
107
+ function confirmSeen(queue, id) {
108
+ return queue.delete(String(id || ""));
109
+ }
110
+
111
+ // The show did not count (user away, screen dark, renderer died). The entry
112
+ // stays queued; after SHOW_RETRY_LIMIT failed dwells it turns sticky so the
113
+ // next presentation latches open until interacted with instead of folding.
114
+ function abortShow(queue, id) {
115
+ const entry = queue.get(String(id || ""));
116
+ if (!entry) return null;
117
+ entry.showing = false;
118
+ if (entry.attempts >= SHOW_RETRY_LIMIT) entry.sticky = true;
119
+ return entry;
120
+ }
121
+
122
+ // The user acted on the relay through another path (opened it, deleted it,
123
+ // read it on the web/another device, mark-all-read). That is "seen".
124
+ function drop(queue, id) {
125
+ return queue.delete(String(id || ""));
126
+ }
127
+
128
+ // Remove entries whose relay is no longer unread (read elsewhere, deleted,
129
+ // tombstoned). unreadIds is the currently-staged unread set.
130
+ function reconcileWithUnread(queue, unreadIds) {
131
+ const unread = unreadIds instanceof Set ? unreadIds : new Set(unreadIds || []);
132
+ const dropped = [];
133
+ for (const id of [...queue.keys()]) {
134
+ if (!unread.has(id)) {
135
+ queue.delete(id);
136
+ dropped.push(id);
137
+ }
138
+ }
139
+ return dropped;
140
+ }
141
+
142
+ function pendingCount(queue) {
143
+ return queue.size;
144
+ }
145
+
146
+ function hasShowing(queue) {
147
+ for (const entry of queue.values()) if (entry.showing) return true;
148
+ return false;
149
+ }
150
+
151
+ module.exports = {
152
+ SHOW_RETRY_LIMIT,
153
+ loadQueue,
154
+ saveQueue,
155
+ enqueueUnseen,
156
+ nextToShow,
157
+ beginShow,
158
+ confirmSeen,
159
+ abortShow,
160
+ drop,
161
+ reconcileWithUnread,
162
+ pendingCount,
163
+ hasShowing,
164
+ };