pi-minimal 0.4.0 → 0.5.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.
package/dist/tracker.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"];
2
- function readSnapshot(ctx) {
2
+ export function readGauntletProjection(ctx) {
3
3
  let phases;
4
4
  let tasks;
5
5
  for (const entry of ctx.sessionManager.getBranch()) {
@@ -21,7 +21,7 @@ function phaseActive(phases) {
21
21
  return PHASES.some((p) => phases[p]?.status && phases[p].status !== "pending");
22
22
  }
23
23
  export function formatTrackerLines(ctx, theme) {
24
- const { phases, tasks } = readSnapshot(ctx);
24
+ const { phases, tasks } = readGauntletProjection(ctx);
25
25
  const lines = [];
26
26
  if (phases && phaseActive(phases)) {
27
27
  const parts = PHASES.map((p) => {
@@ -184,8 +184,9 @@ export function installWorkingStatus(pi) {
184
184
  stop();
185
185
  });
186
186
  pi.on("session_shutdown", () => {
187
- ctx = undefined;
187
+ // Clear the message while ctx is still known; dropping ctx first left it on screen.
188
188
  stop();
189
+ ctx = undefined;
189
190
  });
190
191
  return stop;
191
192
  }
@@ -0,0 +1,3 @@
1
+ # Archive
2
+
3
+ The spec and plan here describe the 0.1 package (September 2026) and are superseded by [README.md](../../README.md) and [CONTEXT.md](../../CONTEXT.md).
@@ -4,14 +4,14 @@ Pi-minimal remains the single footer owner. A trusted companion extension can pu
4
4
 
5
5
  Order: **input → existing hints → plan summary → activity rows last**. When either contribution is visible, a blank row separates the footer from input and separates each nonempty group. Agent rows stay compact, and empty groups leave no extra gaps. Legacy-only output is unchanged.
6
6
 
7
- Two independent slots are supported: `activity` and `summary`, with one owner per slot. An explicit plan summary replaces the legacy tracker projection while present, avoiding duplicate plan bars; clearing it restores the legacy summary. This is display composition, not a task tracker, subagent launcher, or PR/workspace display.
7
+ Two independent slots are supported: `activity` and `plan`, with one owner per slot. (`summary` is the legacy wire name of the plan slot and remains accepted.) A rendered plan line replaces the legacy pi-gauntlet projection, avoiding duplicate plan bars; a plan snapshot that omits `plan`, or clearing the slot, lets the legacy projection show again. This is display composition, not a task tracker, subagent launcher, or PR/workspace display.
8
8
 
9
9
  ## Events
10
10
 
11
11
  | Event | Direction | Payload |
12
12
  |---|---|---|
13
13
  | `pi-minimal:footer:v1:register` | consumer → host | Mutable registration request; host assigns `result` synchronously |
14
- | `pi-minimal:footer:v1:ready` | host → consumers | `{ version: 1, sessionId, slots: ["activity", "summary"] }` |
14
+ | `pi-minimal:footer:v1:ready` | host → consumers | `{ version: 1, sessionId, slots: ["activity", "plan", "summary"] }` |
15
15
  | `pi-minimal:footer:v1:unavailable` | host → consumers | `{ version: 1, sessionId }` |
16
16
 
17
17
  Use `ctx.sessionManager.getSessionId()`, not a file path. Events are in-process only, not a cross-process API. One consumer owns each slot at a time; a tracker and activity extension can register independently. Names/keys are display identity, not permission or process-control tokens.
@@ -51,7 +51,7 @@ type RegisterRequest = {
51
51
  version: 1;
52
52
  sessionId: string;
53
53
  owner: string;
54
- slot: "activity" | "summary";
54
+ slot: "activity" | "plan" | "summary"; // "summary" is the legacy alias of "plan"
55
55
  result?:
56
56
  | { ok: true; registration: {
57
57
  publish(snapshot: unknown): { ok: true } | { ok: false; code: string };
@@ -63,7 +63,7 @@ type RegisterRequest = {
63
63
 
64
64
  No result means the host is absent/not ready. Registration errors include `invalid`, `wrong-session`, `occupied`, and `not-tui`. Publish errors include `invalid`, `stale`, and `disposed`.
65
65
 
66
- ## Plan summary snapshot (`summary` slot)
66
+ ## Plan snapshot (`plan` slot; legacy name `summary`)
67
67
 
68
68
  ```ts
69
69
  {
@@ -74,8 +74,8 @@ No result means the host is absent/not ready. Registration errors include `inval
74
74
  ```
75
75
 
76
76
  The plan ID follows row-key bounds. Counts/revision are nonnegative safe integers;
77
- accepted cannot exceed total; review and blocked counts cannot individually exceed
78
- remaining tasks. Counts are supplied by the tracker, never inferred from agent
77
+ accepted cannot exceed total; `inReview` and `blocked` are each bounded by
78
+ `total - accepted`, but their sum is not checked (a task may be both). Counts are supplied by the tracker, never inferred from agent
79
79
  runs. Omit `plan` on a newer revision to clear the contribution without releasing
80
80
  ownership. Snapshot objects are cloned/frozen, and revision/disposal rules match
81
81
  the activity slot. Each slot has independent ownership and revision tracking.
@@ -109,7 +109,7 @@ Metrics are right-aligned only if the full activity text still fits with at leas
109
109
 
110
110
  Task IDs appear beside activity. In-progress tasks need no extra badge; blocked/review/accepted/cancelled tasks get a distinct badge, and plain mode retains textual status. A completed agent with an `in_review` task shows a green run check plus an accent review diamond. This optional data does not create a tracker or automatically accept a task.
111
111
 
112
- Owner uses the same character/length rules as row keys. Display text has ANSI/OSC, C0/C1, and bidi controls removed, with whitespace collapsed. The host clones and freezes validated snapshots. Invalid/stale publishes preserve the previous valid snapshot. Consumers must handle publish failure and send an explicit unknown/error observation when appropriate; an absent update does not prove a run ended.
112
+ Owner uses the same character/length rules as row keys. Display text has ANSI/OSC sequences stripped; remaining C0/C1 and bidi control characters are replaced with spaces, then whitespace is collapsed and trimmed. The host clones and freezes validated snapshots. Invalid/stale publishes preserve the previous valid snapshot. Consumers must handle publish failure and send an explicit unknown/error observation when appropriate; an absent update does not prove a run ended.
113
113
 
114
114
  ## Presentation and responsibility boundaries
115
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-minimal",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
package/release-notes.md CHANGED
@@ -1,23 +1,57 @@
1
+ Session lifecycle, one tool-row lifecycle, and a domain glossary. `hideGauntletWidgets`
2
+ is now opt-in and the 200 ms chrome steal interval is gone.
3
+
1
4
  ### Added
2
5
 
3
- - `editDiff` setting in `pi-minimal.json`: controls whether an `edit` row shows the
4
- change itself. `"expanded"` (default) shows the unified diff in the `│`-ruled
5
- block when the row is expanded with `ctrl+o`, where the bare "Successfully
6
- replaced" text used to be. `"always"` shows it inline as soon as the edit lands,
7
- capped at 40 lines with `… N more lines (ctrl+o)` for the rest. `"never"` keeps
8
- the stats-only row. Lines use the theme's `toolDiffAdded` / `toolDiffRemoved` /
9
- `toolDiffContext` colours and keep pi's line-number gutter; when one line
10
- replaces one line, only the changed tokens are inverse-highlighted, as in pi's
11
- own edit renderer. Failed edits and results without a diff are unchanged.
6
+ - `write` rows show the written content (first 400 lines) when expanded with `ctrl+o`.
7
+ - With `editDiff: "expanded"` (the default), diffs of up to 8 lines are shown inline;
8
+ hiding them saved nothing and cost a keypress. Longer diffs still wait for `ctrl+o`.
9
+ - `CONTEXT.md`: a glossary of the domain (chrome, reclaim, slots, owner token, the snapshot
10
+ kinds, plan vs the legacy pi-gauntlet projection, activity rows, hints, turn rule, working
11
+ status). The 0.1 spec and plan moved to `doc/archive/`.
12
+ - The plan slot can be addressed as `slot: "plan"`; `summary` stays accepted as its legacy
13
+ name and the ready event now lists `["activity", "plan", "summary"]`.
14
+
15
+ ### Changed
16
+
17
+ - `hideGauntletWidgets` now defaults to `false`. Hiding another extension's widgets is
18
+ opt-in; set it to `true` to keep the previous behaviour.
19
+ - Chrome is re-asserted only on events (session start and its two settle ticks, turn
20
+ end, tool end). The 200 ms interval that cleared pi-gauntlet's widgets five times a
21
+ second is gone; it never reinstalled the header or footer anyway.
22
+ - All session-scoped state (config, cwd, the footer contribution registry, the git and
23
+ Herdr pollers, the chrome) now lives on one `Session` object created at session start
24
+ and disposed at shutdown or when the next session starts. Late async results from a
25
+ disposed session are dropped instead of drawing into the new one.
26
+ - The compact, generic and codemode tool rows are now three small strategies over one
27
+ row lifecycle (`createRowRenderers`): running state, live spinner, status colours,
28
+ the error line and expansion are implemented once. Edit-diff colouring moved to
29
+ `src/edit-diff.ts`. No visible change beyond the two items above.
30
+ - Internal renames for clarity: `TrackerSnapshot` → `GauntletProjection`,
31
+ `readSnapshot` → `readGauntletProjection`, the tracker's `TaskStatus` →
32
+ `GauntletTaskStatus`, and the slot's authority symbol is `ownerToken`. No wire change.
33
+ - `doc/footer-contributions.md` now states precisely when the legacy projection is replaced
34
+ (only while a plan line renders), how control characters are sanitised (VT stripped,
35
+ C0/C1/bidi replaced with spaces), and that `inReview`/`blocked` are bounded individually.
36
+
37
+ ### Fixed
38
+
39
+ - The working message is cleared on session shutdown; it used to drop the context
40
+ before clearing and leave the last status on screen.
41
+ - Git state reads the working directory once per refresh, so branch and dirty count
42
+ always describe the same tree, and one throwing change listener no longer blocks
43
+ the others.
12
44
 
13
45
  ## What's Changed
14
- * feat: editDiff setting — show edit diffs always, never, or behind ctrl+o (0.4.0) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/18
46
+ * refactor: per-session lifecycle, no chrome steal interval, hideGauntletWidgets opt-in by @raviy06 in https://github.com/raviy06/pi-minimal/pull/19
47
+ * refactor(tools): one row lifecycle + strategies; write content on expand; small diffs inline by @raviy06 in https://github.com/raviy06/pi-minimal/pull/20
48
+ * docs: CONTEXT.md glossary, clearer internal names, 'plan' slot alias (0.5.0) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/21
15
49
 
16
50
 
17
- **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.3.1...v0.4.0
51
+ **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.4.0...v0.5.0
18
52
 
19
53
  ---
20
54
  **Install / upgrade**
21
55
  ```bash
22
- pi install npm:pi-minimal@0.4.0 # or: pi update --extensions
56
+ pi install npm:pi-minimal@0.5.0 # or: pi update --extensions
23
57
  ```
package/src/config.ts CHANGED
@@ -12,7 +12,8 @@ export type PiMinimalConfig = {
12
12
  }
13
13
 
14
14
  const DEFAULTS: PiMinimalConfig = {
15
- hideGauntletWidgets: true,
15
+ // Hiding another extension's widgets is opt-in.
16
+ hideGauntletWidgets: false,
16
17
  editDiff: "expanded",
17
18
  }
18
19
 
@@ -0,0 +1,73 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent"
2
+ import { diffWords } from "diff"
3
+ import type { ToolResultLike } from "./tool-rows.js"
4
+
5
+ /** The edit tool's `details.diff`, when present and non-empty. */
6
+ export function editDiffText(result: ToolResultLike | undefined): string | undefined {
7
+ const details = result?.details as { diff?: unknown } | undefined
8
+ return typeof details?.diff === "string" && details.diff.trim() ? details.diff : undefined
9
+ }
10
+
11
+ type DiffLine = { prefix: "+" | "-" | " "; lineNum: string; content: string }
12
+
13
+ /** pi's edit diff lines: "+123 content", "-123 content", " 123 context"; anything else (e.g. "...") is a separator. */
14
+ function parseDiffLine(line: string): DiffLine | undefined {
15
+ const match = /^([+\- ])(\s*\d*) (.*)$/.exec(line)
16
+ if (!match) return undefined
17
+ return { prefix: match[1] as DiffLine["prefix"], lineNum: match[2]!, content: match[3]!.replace(/\t/g, " ") }
18
+ }
19
+
20
+ /** Word-level diff of a 1:1 replacement; changed tokens are inverted, leading indentation is left alone, as in pi. */
21
+ function highlightPair(removed: string, added: string, theme: Theme): { removed: string; added: string } {
22
+ let out = { removed: "", added: "" }
23
+ let firstRemoved = true
24
+ let firstAdded = true
25
+ for (const part of diffWords(removed, added)) {
26
+ if (part.removed || part.added) {
27
+ const key = part.removed ? "removed" : "added"
28
+ let value = part.value
29
+ if (part.removed ? firstRemoved : firstAdded) {
30
+ const ws = /^(\s*)/.exec(value)?.[1] ?? ""
31
+ value = value.slice(ws.length)
32
+ out = { ...out, [key]: out[key] + ws }
33
+ if (part.removed) firstRemoved = false
34
+ else firstAdded = false
35
+ }
36
+ if (value) out = { ...out, [key]: out[key] + theme.inverse(value) }
37
+ } else {
38
+ out = { removed: out.removed + part.value, added: out.added + part.value }
39
+ }
40
+ }
41
+ return out
42
+ }
43
+
44
+ /** Colours pi's edit diff like pi does: theme diff colours per line, inverse on changed tokens of single-line replacements. */
45
+ export function colorDiffLines(lines: string[], theme: Theme): string[] {
46
+ const out: string[] = []
47
+ for (let i = 0; i < lines.length; ) {
48
+ const parsed = parseDiffLine(lines[i]!)
49
+ if (!parsed) {
50
+ out.push(theme.fg("toolDiffContext", lines[i]!))
51
+ i++
52
+ continue
53
+ }
54
+ if (parsed.prefix === " ") {
55
+ out.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${parsed.content}`))
56
+ i++
57
+ continue
58
+ }
59
+ const removed: DiffLine[] = []
60
+ const added: DiffLine[] = []
61
+ for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "-"; p = parseDiffLine(lines[++i] ?? "")) removed.push(p)
62
+ for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "+"; p = parseDiffLine(lines[++i] ?? "")) added.push(p)
63
+ if (removed.length === 1 && added.length === 1) {
64
+ const pair = highlightPair(removed[0]!.content, added[0]!.content, theme)
65
+ out.push(theme.fg("toolDiffRemoved", `-${removed[0]!.lineNum} ${pair.removed}`))
66
+ out.push(theme.fg("toolDiffAdded", `+${added[0]!.lineNum} ${pair.added}`))
67
+ continue
68
+ }
69
+ for (const line of removed) out.push(theme.fg("toolDiffRemoved", `-${line.lineNum} ${line.content}`))
70
+ for (const line of added) out.push(theme.fg("toolDiffAdded", `+${line.lineNum} ${line.content}`))
71
+ }
72
+ return out
73
+ }
@@ -5,8 +5,11 @@ export const FOOTER_EVENTS = {
5
5
  ready: "pi-minimal:footer:v1:ready",
6
6
  unavailable: "pi-minimal:footer:v1:unavailable",
7
7
  } as const
8
+ /** Slot names on the wire. `summary` is the legacy name of the plan slot and stays accepted. */
9
+ export const FOOTER_SLOTS = ["activity", "plan", "summary"] as const
10
+ export type FooterSlot = typeof FOOTER_SLOTS[number]
8
11
  export type RegisterRequest = {
9
- version: 1; sessionId: string; owner: string; slot: "activity" | "summary"
12
+ version: 1; sessionId: string; owner: string; slot: FooterSlot
10
13
  result?: RegistrationResult | { ok: false; code: "not-tui" }
11
14
  }
12
15
  type Events = {
@@ -22,11 +25,11 @@ export function bindFooterContributionEvents(events: Events, registry: FooterCon
22
25
  const request = value as Partial<RegisterRequest>
23
26
  // A different host may have answered first. Never overwrite its result.
24
27
  if (request.result !== undefined) return
25
- if (request.version !== 1 || (request.slot !== "activity" && request.slot !== "summary")) request.result = { ok: false, code: "invalid" }
28
+ if (request.version !== 1 || !FOOTER_SLOTS.includes(request.slot as FooterSlot)) request.result = { ok: false, code: "invalid" }
26
29
  else if (mode !== "tui") request.result = { ok: false, code: "not-tui" }
27
- else request.result = request.slot === "summary" ? registry.registerPlan(request) : registry.register(request)
30
+ else request.result = request.slot === "activity" ? registry.register(request) : registry.registerPlan(request)
28
31
  })
29
- if (mode === "tui") events.emit(FOOTER_EVENTS.ready, { version: 1, sessionId, slots: ["activity", "summary"] })
32
+ if (mode === "tui") events.emit(FOOTER_EVENTS.ready, { version: 1, sessionId, slots: [...FOOTER_SLOTS] })
30
33
  let disposed = false
31
34
  return () => {
32
35
  if (disposed) return
@@ -77,7 +77,8 @@ function normalizeSnapshot(input: unknown): ActivitySnapshot | undefined {
77
77
 
78
78
  function createFooterSlot<T extends { revision: number }>(normalize: (input: unknown) => T | undefined, initialSessionId: string | null) {
79
79
  let sessionId = initialSessionId
80
- let owner: symbol | undefined
80
+ // The registrant's `owner` string is a display identity; this symbol is the actual authority to publish.
81
+ let ownerToken: symbol | undefined
81
82
  let snapshot: T | undefined
82
83
  const observers = new Set<() => void>()
83
84
  const notify = () => {
@@ -89,17 +90,17 @@ function createFooterSlot<T extends { revision: number }>(normalize: (input: unk
89
90
  read: () => snapshot,
90
91
  subscribe(observer: () => void) { observers.add(observer); return () => { observers.delete(observer) } },
91
92
  reset(nextSessionId: string | null) {
92
- sessionId = nextSessionId; owner = undefined; snapshot = undefined; notify()
93
+ sessionId = nextSessionId; ownerToken = undefined; snapshot = undefined; notify()
93
94
  },
94
95
  register(input: unknown): RegistrationResult {
95
96
  if (!record(input) || !identity(input.owner) || typeof input.sessionId !== "string" || !input.sessionId) return { ok: false, code: "invalid" }
96
97
  if (sessionId === null || input.sessionId !== sessionId) return { ok: false, code: "wrong-session" }
97
- if (owner) return { ok: false, code: "occupied" }
98
+ if (ownerToken) return { ok: false, code: "occupied" }
98
99
  const token = Symbol(input.owner)
99
- owner = token
100
+ ownerToken = token
100
101
  return { ok: true, registration: {
101
102
  publish(input: unknown) {
102
- if (owner !== token) return { ok: false, code: "disposed" }
103
+ if (ownerToken !== token) return { ok: false, code: "disposed" }
103
104
  const next = normalize(input)
104
105
  if (!next) return { ok: false, code: "invalid" }
105
106
  if (snapshot && next.revision <= snapshot.revision) return { ok: false, code: "stale" }
@@ -107,8 +108,8 @@ function createFooterSlot<T extends { revision: number }>(normalize: (input: unk
107
108
  return { ok: true }
108
109
  },
109
110
  dispose() {
110
- if (owner !== token) return
111
- owner = undefined; snapshot = undefined; notify()
111
+ if (ownerToken !== token) return
112
+ ownerToken = undefined; snapshot = undefined; notify()
112
113
  },
113
114
  } }
114
115
  },
@@ -116,17 +117,18 @@ function createFooterSlot<T extends { revision: number }>(normalize: (input: unk
116
117
  }
117
118
  export function createFooterContributions(initialSessionId: string | null = null) {
118
119
  const activity = createFooterSlot(normalizeSnapshot, initialSessionId)
119
- const summary = createFooterSlot(normalizePlanSnapshot, initialSessionId)
120
+ // The plan slot; "summary" is its legacy wire name.
121
+ const plan = createFooterSlot(normalizePlanSnapshot, initialSessionId)
120
122
  return {
121
123
  read: activity.read,
122
- readPlan: summary.read,
124
+ readPlan: plan.read,
123
125
  register: activity.register,
124
- registerPlan: summary.register,
126
+ registerPlan: plan.register,
125
127
  subscribe(observer: () => void) {
126
- const offActivity = activity.subscribe(observer), offSummary = summary.subscribe(observer)
127
- return () => { offActivity(); offSummary() }
128
+ const offActivity = activity.subscribe(observer), offPlan = plan.subscribe(observer)
129
+ return () => { offActivity(); offPlan() }
128
130
  },
129
- reset(sessionId: string | null) { activity.reset(sessionId); summary.reset(sessionId) },
131
+ reset(sessionId: string | null) { activity.reset(sessionId); plan.reset(sessionId) },
130
132
  }
131
133
  }
132
134
  export type FooterContributions = ReturnType<typeof createFooterContributions>
package/src/git-state.ts CHANGED
@@ -30,30 +30,45 @@ export function createGitState(pi: ExtensionAPI, cwd: () => string, minIntervalM
30
30
  let state: GitState | undefined
31
31
  let lastRun = 0
32
32
  let inFlight: Promise<void> | undefined
33
+ let disposed = false
33
34
  const listeners = new Set<() => void>()
34
35
 
36
+ const notify = () => {
37
+ for (const listener of listeners) {
38
+ try {
39
+ listener()
40
+ } catch {
41
+ // One listener's failure must not starve the others.
42
+ }
43
+ }
44
+ }
45
+
35
46
  const refresh = (force = false): Promise<void> => {
47
+ if (disposed) return Promise.resolve()
36
48
  const now = Date.now()
37
49
  if (inFlight) return inFlight
38
50
  if (!force && now - lastRun < minIntervalMs) return Promise.resolve()
39
51
  lastRun = now
52
+ // Read the directory once so both commands describe the same tree even if cwd changes mid-refresh.
53
+ const dir = cwd()
40
54
  inFlight = (async () => {
55
+ let next: GitState | undefined
41
56
  try {
42
- const branchRun = await pi.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: cwd(), timeout: 2000 })
43
- if (branchRun.code !== 0) {
44
- state = undefined
45
- return
57
+ const branchRun = await pi.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 2000 })
58
+ if (branchRun.code === 0) {
59
+ const branch = parseBranch(branchRun.stdout)
60
+ const statusRun = await pi.exec("git", ["status", "--porcelain"], { cwd: dir, timeout: 3000 })
61
+ next = { branch, dirty: statusRun.code === 0 ? parsePorcelain(statusRun.stdout) : undefined }
46
62
  }
47
- const branch = parseBranch(branchRun.stdout)
48
- const statusRun = await pi.exec("git", ["status", "--porcelain"], { cwd: cwd(), timeout: 3000 })
49
- const dirty = statusRun.code === 0 ? parsePorcelain(statusRun.stdout) : undefined
50
- state = { branch, dirty }
51
63
  } catch {
52
- state = undefined
64
+ next = undefined
53
65
  } finally {
54
66
  inFlight = undefined
55
- for (const listener of listeners) listener()
56
67
  }
68
+ // A result that lands after dispose belongs to a session that no longer exists.
69
+ if (disposed) return
70
+ state = next
71
+ notify()
57
72
  })()
58
73
  return inFlight
59
74
  }
@@ -65,6 +80,10 @@ export function createGitState(pi: ExtensionAPI, cwd: () => string, minIntervalM
65
80
  listeners.add(listener)
66
81
  return () => listeners.delete(listener)
67
82
  },
83
+ dispose() {
84
+ disposed = true
85
+ listeners.clear()
86
+ },
68
87
  }
69
88
  }
70
89