@popoverinstall/cli 0.9.0 → 0.10.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.
@@ -0,0 +1,517 @@
1
+ #!/usr/bin/env node
2
+ // Says out loud that a task just landed, at the moment it lands.
3
+ //
4
+ // The skill file carries the disposition — when you finish, vault before you report
5
+ // completion (docs/vaults.md §3, and the standing rule in skills/popover/SKILL.md). Doctrine
6
+ // alone loses to attention: an agent that has just spent forty turns building something is
7
+ // thinking about the report it owes the user, not about an archive, and the moment §3 is
8
+ // about is precisely the one you are too deep in the work to notice. This hook is the
9
+ // reinforcement that arrives *at* the moment rather than in a paragraph read an hour earlier.
10
+ //
11
+ // It is registered synchronously on Stop and answers with
12
+ // `hookSpecificOutput.additionalContext`, which Claude Code delivers as non-error feedback
13
+ // that continues the conversation. That timing is the whole reason the script has this shape:
14
+ //
15
+ // - `async: true` delivers `additionalContext` a model call late (measured). A nudge that
16
+ // lands one turn after completion arrives while the user is already asking about
17
+ // something else, which is where a signal goes to become noise. So this one is
18
+ // synchronous, and the existing async emit-event.mjs entry on Stop is left alone.
19
+ // - `Stop` does not fire when the turn was interrupted by the user — official behaviour,
20
+ // verified on 2.1.252. So the interrupted-turn exclusion is free, and there is
21
+ // deliberately no attempt here to detect interruption from the payload: there is no field
22
+ // for it, and inventing one would only produce a guess.
23
+ //
24
+ // What qualifies as "a task landed" is deliberately narrow: a machine-verifiable fact about
25
+ // work that git has already recorded. Two tiers, and nothing else — a push, and behind it a
26
+ // commit. Neither is inferred from the shape of the conversation, because the shape of a
27
+ // conversation cannot carry that weight.
28
+ //
29
+ // A third tier was written, tested and cut, and it is worth knowing why before anyone adds it
30
+ // back: files changed earlier in the session, and none changed this turn. That reads like a
31
+ // task which has just finished, and it is exactly as much like the user stopping the agent to
32
+ // ask a question — the turn after a burst of editing looks identical either way, and the
33
+ // difference lives in the user's head where no hook can reach it. `Stop` firing tells you the
34
+ // *turn* completed. It does not tell you the *task* did, and that gap is the whole distance
35
+ // between the two tiers here and the one that is not. Cut outright rather than left behind a
36
+ // flag: a branch nobody exercises rots, and git remembers the code.
37
+ //
38
+ // What it must never do, in the order the damage would be worst:
39
+ //
40
+ // - Nudge on nothing. A line that fires when no task finished teaches the user, in one
41
+ // sitting, that the line means nothing — and after that the turn where it was right looks
42
+ // exactly like the turns where it was wrong. announce-roster.mjs records the same failure
43
+ // for the roster line and the same rule follows: silence beats a false positive, every
44
+ // time. Everything below that looks over-cautious is this.
45
+ // - Nudge twice for the same work. Deduped per session by what landed, not by time.
46
+ // - Break a session. Every path fails open: no git, no daemon, a wedged spawn, a malformed
47
+ // payload — exit 0, print nothing.
48
+ // - Spin the agent. `stop_hook_active` is true when Claude Code is already continuing
49
+ // because of a stop hook. Feeding it more context then is how you get a loop, so that
50
+ // case exits before doing any work at all.
51
+ // - Talk over a colleague. A message waiting for this session wins the turn
52
+ // (docs/messages.md §7) and this hook yields to it — without spending its dedupe key, so
53
+ // the nudge it swallowed comes back on the next turn.
54
+ //
55
+ // Every timeout inside is sized so that the worst case — slow stdin, a slow `git status`, a
56
+ // tip lookup, a commit count and a degraded daemon, all in one turn — still adds up to less
57
+ // than the 5-second timeout hooks.json gives it. Being killed by that timeout would only cost
58
+ // a nudge, which is survivable, but it could also truncate the state file mid-write and cost
59
+ // the session its baseline. The ordinary turn is one `git status` and nothing else.
60
+ //
61
+ // It does not vault anything and it holds no opinion about whether this conversation deserves
62
+ // to be vaulted. That judgement is the agent's — §3 gives it the authority deliberately, and
63
+ // a hook that decided for it would be an automatic disclosure wearing a nudge's clothes. The
64
+ // job here is to put an accurate factual statement in front of the model and stop.
65
+
66
+ import { spawnSync } from "node:child_process";
67
+ import {
68
+ mkdirSync,
69
+ readFileSync,
70
+ readdirSync,
71
+ rmSync,
72
+ statSync,
73
+ writeFileSync,
74
+ } from "node:fs";
75
+ import path from "node:path";
76
+ import {
77
+ callerSessionId,
78
+ inboxWaitingSince,
79
+ popoverHome,
80
+ readStdin,
81
+ request,
82
+ } from "./_ipc.mjs";
83
+
84
+ // A fork answering a teammate's question must not be nudged to publish anything. It is a
85
+ // throwaway session whose output goes back to whoever asked, its "completion" is answering
86
+ // one question, and the repo it happens to run in is not its own — see the header of
87
+ // emit-event.mjs for what forks running the plugin's hooks unguarded did to the roster.
88
+ if (process.env.CLAUDE_CODE_ENTRYPOINT === "popover-fork") process.exit(0);
89
+
90
+ /** At most this many nudges in one session, whatever lands. The wallpaper ceiling. */
91
+ const MAX_NUDGES = 3;
92
+
93
+ try {
94
+ // The payload is not optional here the way it is in deliver-messages.mjs:
95
+ // `stop_hook_active` exists only in it, and without that flag there is no loop guard. An
96
+ // unreadable payload therefore means silence.
97
+ const payload = JSON.parse(await readStdin(400));
98
+ const sessionId = payload?.session_id || callerSessionId();
99
+ const cwd = payload?.cwd || process.cwd();
100
+
101
+ if (sessionId) {
102
+ if (payload?.hook_event_name === "SessionStart") {
103
+ recordBaseline(sessionId, cwd);
104
+ } else if (!payload?.stop_hook_active) {
105
+ await onStop(sessionId, cwd);
106
+ }
107
+ }
108
+ } catch {
109
+ // Fail open, always. A wedged hook at the end of every turn would be worse than no hook.
110
+ }
111
+
112
+ process.exit(0);
113
+
114
+ /**
115
+ * Everything a Stop needs to decide, and then the state for the next one.
116
+ *
117
+ * Ordering matters: the tier is judged against the *previous* stop's state, and the new state
118
+ * is written afterwards whether or not anything was said. Two things have to be recorded even
119
+ * by a silent stop: that this session was once ahead of its upstream, which is one of the two
120
+ * routes to recognising a push, and which keys have already been nudged. Writing also keeps
121
+ * the file's mtime fresh, which is what the week-old sweep below prunes on.
122
+ */
123
+ async function onStop(sessionId, cwd) {
124
+ const snap = snapshot(cwd);
125
+ if (!snap) return; // Not a git repo, no commits yet, or git took too long.
126
+
127
+ const state = readState(sessionId);
128
+ if (!state) {
129
+ // No baseline: the plugin was installed mid-session, the SessionStart hook failed, or
130
+ // POPOVER_HOME moved. Start the clock now and say nothing — a session whose beginning is
131
+ // unknown cannot honestly claim that anything advanced during it.
132
+ writeState(sessionId, freshState(snap, cwd));
133
+ return;
134
+ }
135
+
136
+ const next = {
137
+ ...state,
138
+ // Sticky: once this session has held an unpushed commit, the later disappearance of that
139
+ // gap is a push even if HEAD never moved — see classify().
140
+ wasAhead: state.wasAhead || snap.ahead > 0,
141
+ };
142
+
143
+ try {
144
+ // A waiting message outranks anything this hook has to say — docs/messages.md §7. The two
145
+ // do not share a budget (that hook injects on PostToolBatch, this one continues a Stop)
146
+ // but they compete for one turn, and the tie-break is not close: a vault nudge can wait,
147
+ // because its dedupe key is keyed on the commit and will fire again next turn, while a
148
+ // colleague blocked on a reply cannot.
149
+ //
150
+ // Note where this sits — inside the try, so the `finally` below still records `wasAhead`,
151
+ // and before classify(), so `nudged` is never appended to. Yielding must not cost the
152
+ // nudge its key; a yield that silently consumed it would turn "wait a turn" into "never".
153
+ if (inboxWaitingSince(sessionId) !== null) return;
154
+
155
+ const tier = classify(state, snap, cwd);
156
+ const already = state.nudged ?? [];
157
+ if (tier && !already.includes(tier.key) && already.length < MAX_NUDGES) {
158
+ if (await popoverUsable(sessionId, cwd)) {
159
+ emit(tier.text);
160
+ next.nudged = [...already, tier.key];
161
+ }
162
+ }
163
+ } finally {
164
+ writeState(sessionId, next);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Which of the two signals fired, stronger first, or null for silence.
170
+ *
171
+ * The order is not a preference, it is what the signals actually mean:
172
+ *
173
+ * 1. **A push landed.** The work has left the machine and become the team's, irreversibly,
174
+ * and it is the moment teammates start meeting this code without this conversation
175
+ * attached to it. Nothing else available to a hook is that unambiguous.
176
+ * 2. **A commit landed.** Real, but private and revertible, and as often a checkpoint as a
177
+ * finish. Worth stating; not worth stating as completion.
178
+ *
179
+ * Both are facts git can be asked for and will answer the same way twice. There is no weaker
180
+ * rung under them, and silence is the default — the header records what used to be there and
181
+ * why it is not.
182
+ *
183
+ * The identity and timestamp tests exist to keep a `git pull` from reading as a push: a
184
+ * fast-forward advances HEAD and closes the gap to upstream exactly as a push does, and the
185
+ * only cheap thing that separates them is whose commit is at the tip and when it was made.
186
+ */
187
+ function classify(state, snap, cwd) {
188
+ const advanced = Boolean(snap.head) && snap.head !== state.baseHead;
189
+
190
+ // Only spawn for the tip commit when a tier is actually in play. On an ordinary turn this
191
+ // whole function costs nothing beyond the one `git status` already taken.
192
+ const tip = advanced || state.wasAhead ? tipCommit(cwd) : null;
193
+
194
+ // Unknown identity on either side degrades to the timestamp test rather than blocking: a
195
+ // repo can be configured without user.email, and a hook that silently stops working
196
+ // because of a config gap is the failure announce-roster.mjs took a release to notice.
197
+ const identity =
198
+ tip?.email && state.email ? tip.email.toLowerCase() === state.email.toLowerCase() : null;
199
+ const ours = Boolean(tip) && identity !== false;
200
+ // 120s of slack for skew between the committer's clock and this process's.
201
+ const madeThisSession = ours && tip.at >= (state.startedAt ?? 0) - 120;
202
+
203
+ if (
204
+ snap.upstream &&
205
+ snap.ahead === 0 &&
206
+ snap.behind === 0 &&
207
+ ((advanced && madeThisSession) || (state.wasAhead && ours))
208
+ ) {
209
+ return { key: `push:${snap.head}`, text: pushText(state, snap, cwd) };
210
+ }
211
+
212
+ // A commit that is still local. `ahead > 0` is what keeps this from firing a second time on
213
+ // the same work after the push tier already has — and a repo with no upstream at all can
214
+ // never reach the push tier, so it qualifies here on its own.
215
+ if (advanced && madeThisSession && (!snap.upstream || snap.ahead > 0)) {
216
+ return { key: `commit:${snap.head}`, text: commitText(state, snap, cwd) };
217
+ }
218
+
219
+ return null;
220
+ }
221
+
222
+ /*
223
+ * The two statements.
224
+ *
225
+ * Written as statements on purpose. The hook docs warn that text reading as an injected
226
+ * system instruction can trip Claude's prompt-injection defences and be surfaced to the user
227
+ * as a suspicious string instead of acted on — which would turn a nudge into a support
228
+ * question. So each of these says what just happened and what exists, names the judgement as
229
+ * the agent's, and never issues an order. `additionalContext` is capped at 10,000 characters;
230
+ * both of these sit well under a thousand, and the shortness is also deliberate — the model
231
+ * has to read this at the end of a long turn.
232
+ */
233
+
234
+ /**
235
+ * Shared closing. Both tiers need the same three facts: what the tool is, that the call is a
236
+ * judgement rather than a reflex, and that a conversation already vaulted needs nothing. The
237
+ * last sentence is the one announce-roster.mjs learned the hard way: without it the model
238
+ * reports the notice to the user as news.
239
+ *
240
+ * Deliberately short, and deliberately does not restate what is worth vaulting. That rule
241
+ * lives in the popover skill's `description`, which is in context on every turn of every
242
+ * session — so a second copy here would not be reaching an agent that lacked it, it would be
243
+ * a second place the rule has to be edited, and the two would drift the first time one of
244
+ * them changed. The disclosure that a vault is readable by the service is likewise already
245
+ * on `vault_create` itself, which an agent has necessarily read by the time it can call it.
246
+ * What this text is for is the part the skill cannot know: that it is being said now, and
247
+ * why now.
248
+ *
249
+ * A function and not a `const`, which is not a style choice. The top-level `await` that runs
250
+ * this script sits above every declaration down here, and a `const` read from inside it is in
251
+ * its temporal dead zone — so the first nudge threw a ReferenceError straight into the
252
+ * fail-open catch and the hook printed nothing, forever, in silence. Function declarations
253
+ * hoist; that is the whole difference, and it is the exact failure this file's fail-open
254
+ * posture is worst at showing you.
255
+ */
256
+ function tail() {
257
+ return (
258
+ "popover's vault_create tool publishes this conversation to the team's archive, where " +
259
+ "anyone on the team can ask it once this session no longer exists. Whether this " +
260
+ "particular conversation is one of those is a judgement that is yours to make, on the " +
261
+ "standing rule you already carry, and a conversation already vaulted needs nothing " +
262
+ "further. This notice is only worth mentioning to the user if it turns out to be " +
263
+ "relevant."
264
+ );
265
+ }
266
+
267
+ function pushText(state, snap, cwd) {
268
+ const made = commitsSince(cwd, state.baseHead);
269
+ const what =
270
+ made > 0
271
+ ? `${made} commit${made === 1 ? "" : "s"} made in this session ` +
272
+ `${made === 1 ? "is" : "are"} now on ${snap.upstream}`
273
+ : `the commits this branch was holding are now on ${snap.upstream}`;
274
+
275
+ return (
276
+ `[popover] A push landed during this session: ${what}, with HEAD at ` +
277
+ `${short(snap.head)}. The work has left this machine and teammates encounter this code ` +
278
+ `from here on, while the reasoning behind it exists only in this conversation, which is ` +
279
+ `deleted when the session ends. docs/vaults.md §3 names this — the completion of a task ` +
280
+ `— as the moment a vault belongs to, and the standing rule in the popover skill is to ` +
281
+ `vault before reporting completion rather than after it. ${tail()}`
282
+ );
283
+ }
284
+
285
+ /**
286
+ * Two numbers, not one, and the second is why: the branch's ahead count alone was
287
+ * ambiguous. A branch can be carrying unpushed commits this session never made — a test run
288
+ * caught it reading "6 commits ahead" at a session that had made exactly one — and a nudge
289
+ * whose first factual clause overstates what just happened is the kind of thing an agent
290
+ * repeats to the user. So the session's own count leads and the branch's gap follows.
291
+ */
292
+ function commitText(state, snap, cwd) {
293
+ const made = commitsSince(cwd, state.baseHead);
294
+ const mine =
295
+ made > 0
296
+ ? `${made} commit${made === 1 ? "" : "s"} made in this session`
297
+ : "a commit made in this session";
298
+ const where = snap.upstream
299
+ ? `${mine}, with the branch now ${snap.ahead} ahead of ${snap.upstream}`
300
+ : `${mine}, on a branch with no upstream to push to`;
301
+
302
+ return (
303
+ `[popover] A commit landed during this session: ${short(snap.head)} — ${where}. This ` +
304
+ `is a weaker signal than a push — a commit is private, revertible, and as often a ` +
305
+ `checkpoint as a finish — so it is stated as a fact and not as a claim that anything ` +
306
+ `is done. If the work is in fact done, this is the moment docs/vaults.md §3 is about: ` +
307
+ `your grasp of it is as good as it is going to get, and the user is about to move on. ` +
308
+ `${tail()}`
309
+ );
310
+ }
311
+
312
+ /** The one line of stdout this script is allowed to write. Anything else here is a bug. */
313
+ function emit(additionalContext) {
314
+ process.stdout.write(
315
+ `${JSON.stringify({
316
+ hookSpecificOutput: { hookEventName: "Stop", additionalContext },
317
+ })}\n`,
318
+ );
319
+ }
320
+
321
+ /**
322
+ * Is popover actually in a position to accept a vault right now?
323
+ *
324
+ * Checked because the alternative is telling an agent a tool is available when calling it
325
+ * will fail — and a nudge that leads straight into an error is worse than no nudge, since the
326
+ * agent then reports the failure to a user who never asked for any of this. `roster.ok`
327
+ * coming back means the daemon is up and the machine is signed in; anything else means it is
328
+ * not, or the backend is unreachable, and both are reasons to stay quiet. `local` was the
329
+ * cheaper candidate and is the wrong one: it answers without credentials by design.
330
+ *
331
+ * Paid only on a turn where a tier has already matched, which is at most three turns a
332
+ * session.
333
+ */
334
+ async function popoverUsable(sessionId, cwd) {
335
+ const reply = await request(
336
+ { t: "roster", id: "hook", refresh: false, fromSessionId: sessionId, cwd },
337
+ { timeoutMs: 1200 },
338
+ );
339
+ return reply?.t === "roster.ok";
340
+ }
341
+
342
+ /**
343
+ * One `git status --porcelain=v2 --branch --untracked-files=no`, and the three facts both
344
+ * tiers need are all in its header: the HEAD sha, the upstream name, and the ahead/behind
345
+ * counts. `rev-parse HEAD`, `rev-parse @{u}` and a `rev-list` would cost three process spawns
346
+ * for the same thing, and on Windows a spawn is tens of milliseconds out of a budget that is
347
+ * spent at the end of every single turn.
348
+ *
349
+ * `--untracked-files=no` is what keeps that one spawn cheap. Hunting for untracked files is
350
+ * the expensive half of `git status` — a full walk of the working tree — and it was only ever
351
+ * here to fingerprint the dirty set for the tier that was cut. Nothing reads the file entries
352
+ * now, so they are not asked for. The call is still bounded and still gives up rather than
353
+ * holding up the end of a turn.
354
+ */
355
+ function snapshot(cwd) {
356
+ const args = ["status", "--porcelain=v2", "--branch", "--untracked-files=no"];
357
+ const out = git(args, cwd, 1200);
358
+ if (out === null) return null;
359
+
360
+ const snap = { head: "", upstream: "", ahead: 0, behind: 0 };
361
+
362
+ for (const line of out.split("\n")) {
363
+ if (line.startsWith("# branch.oid ")) snap.head = line.slice(13).trim();
364
+ else if (line.startsWith("# branch.upstream ")) snap.upstream = line.slice(18).trim();
365
+ else if (line.startsWith("# branch.ab ")) {
366
+ const [a, b] = line.slice(12).trim().split(/\s+/);
367
+ snap.ahead = Math.abs(Number.parseInt(a, 10) || 0);
368
+ snap.behind = Math.abs(Number.parseInt(b, 10) || 0);
369
+ }
370
+ }
371
+
372
+ // `(initial)` before the first commit. Nothing here can advance from nothing.
373
+ if (!snap.head || snap.head === "(initial)") return null;
374
+
375
+ return snap;
376
+ }
377
+
378
+ /** Committer email and commit time of the tip, for the "is this ours, and is it new" test. */
379
+ function tipCommit(cwd) {
380
+ const out = git(["log", "-1", "--format=%ce%n%ct"], cwd, 800);
381
+ if (out === null) return null;
382
+ const [email, at] = out.split("\n");
383
+ const seconds = Number.parseInt((at ?? "").trim(), 10);
384
+ if (!Number.isFinite(seconds)) return null;
385
+ return { email: (email ?? "").trim(), at: seconds };
386
+ }
387
+
388
+ /** How many commits this session added, for the push line. Zero is a legitimate answer. */
389
+ function commitsSince(cwd, baseHead) {
390
+ if (!baseHead) return 0;
391
+ const out = git(["rev-list", "--count", `${baseHead}..HEAD`], cwd, 800);
392
+ const n = Number.parseInt((out ?? "").trim(), 10);
393
+ return Number.isFinite(n) ? n : 0;
394
+ }
395
+
396
+ function localEmail(cwd) {
397
+ return (git(["config", "--get", "user.email"], cwd, 800) ?? "").trim();
398
+ }
399
+
400
+ /**
401
+ * git, with every way it can hurt a session closed off.
402
+ *
403
+ * `timeout` kills a spawn that hangs — a network-mounted repo or a stale index lock must not
404
+ * hold up the end of a turn. stderr is discarded rather than inherited, because a hook that
405
+ * prints git's complaints puts them in front of the user as if they were popover's.
406
+ */
407
+ function git(args, cwd, timeoutMs) {
408
+ try {
409
+ const r = spawnSync("git", args, {
410
+ cwd,
411
+ timeout: timeoutMs,
412
+ encoding: "utf8",
413
+ windowsHide: true,
414
+ stdio: ["ignore", "pipe", "ignore"],
415
+ });
416
+ if (r.error || r.status !== 0 || typeof r.stdout !== "string") return null;
417
+ return r.stdout;
418
+ } catch {
419
+ return null;
420
+ }
421
+ }
422
+
423
+ function short(sha) {
424
+ return (sha ?? "").slice(0, 7);
425
+ }
426
+
427
+ /*
428
+ * Per-session state.
429
+ *
430
+ * Same shape as announce-roster.mjs's signature file and in the same directory, so there is
431
+ * one place to look and one thing to sweep. Per session rather than per machine because the
432
+ * whole question is "did this land during *this* session", and because a new session should
433
+ * get its own single nudge.
434
+ */
435
+ function stateFile(id) {
436
+ return path.join(popoverHome(), "announced", `${id}.vault.json`);
437
+ }
438
+
439
+ function readState(id) {
440
+ try {
441
+ const state = JSON.parse(readFileSync(stateFile(id), "utf8"));
442
+ return state?.v === 1 ? state : null; // A future shape is ignored, not guessed at.
443
+ } catch {
444
+ return null;
445
+ }
446
+ }
447
+
448
+ function writeState(id, state) {
449
+ try {
450
+ mkdirSync(path.join(popoverHome(), "announced"), { recursive: true });
451
+ writeFileSync(stateFile(id), JSON.stringify(state), "utf8");
452
+ } catch {
453
+ // An unwritable state file means this session never nudges: every stop reads no baseline
454
+ // and starts the clock again. Degraded into silence, which is the right direction.
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Six fields, and every one of them is read by classify() or by the dedupe. Kept that way
460
+ * deliberately: this file briefly carried a stop counter, two tree fingerprints and a
461
+ * `lastHead` that nothing consulted any more, and a state field which is written but never
462
+ * read is a claim about how the detector works that has quietly stopped being true.
463
+ */
464
+ function freshState(snap, cwd) {
465
+ return {
466
+ v: 1,
467
+ startedAt: Math.floor(Date.now() / 1000),
468
+ email: localEmail(cwd),
469
+ baseHead: snap.head,
470
+ wasAhead: snap.ahead > 0,
471
+ nudged: [],
472
+ };
473
+ }
474
+
475
+ /**
476
+ * The session's starting line, taken at SessionStart so that a task finished in the very
477
+ * first turn is still detectable. Doing this lazily on the first Stop instead would make
478
+ * "commit and push this fix" — a whole task in one turn — the one shape that could never
479
+ * fire.
480
+ *
481
+ * An existing file is never overwritten. SessionStart also fires on resume and on compaction,
482
+ * and re-baselining there would move the starting line forward past the very commits it
483
+ * exists to notice.
484
+ */
485
+ function recordBaseline(sessionId, cwd) {
486
+ if (readState(sessionId)) return;
487
+ const snap = snapshot(cwd);
488
+ if (!snap) return;
489
+ writeState(sessionId, freshState(snap, cwd));
490
+ sweepOldState();
491
+ }
492
+
493
+ /**
494
+ * Drop state files older than a week.
495
+ *
496
+ * announce-roster.mjs left this as a loose end — a session id is a uuid, nothing prunes the
497
+ * directory, and a heavy user accretes an entry per session forever. This sweeps both kinds,
498
+ * since they share the directory, once per session, on the one event already paying for a
499
+ * process spawn. It is still the second-best fix: the daemon knows when a session ends and
500
+ * could drop the file then, where this can only guess with a clock.
501
+ */
502
+ function sweepOldState() {
503
+ const week = 7 * 24 * 60 * 60 * 1000;
504
+ try {
505
+ const dir = path.join(popoverHome(), "announced");
506
+ for (const name of readdirSync(dir)) {
507
+ const file = path.join(dir, name);
508
+ try {
509
+ if (Date.now() - statSync(file).mtimeMs > week) rmSync(file, { force: true });
510
+ } catch {
511
+ // Raced with another session, or not ours to delete. Leave it.
512
+ }
513
+ }
514
+ } catch {
515
+ // No directory yet, which is the common case on a first run.
516
+ }
517
+ }