weir-harness 1.2.3 → 2.1.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.
Files changed (117) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.capability-badge.js +2 -0
  3. package/lib/client.capability-manager.js +46 -179
  4. package/lib/client.capability-model.js +428 -3
  5. package/lib/client.file-lease-badge.js +307 -0
  6. package/lib/client.file-lease-tool-model.js +189 -0
  7. package/lib/client.file-lease-tool-view.js +434 -0
  8. package/lib/client.js +124 -36
  9. package/lib/client.mcp-manager.js +563 -0
  10. package/lib/client.settings-page.js +10 -34
  11. package/package.json +2 -2
  12. package/skills/mcp-server-setup/SKILL.md +124 -0
  13. package/src/capabilities/apply-engine.js +18 -4
  14. package/src/capabilities/capability-remote.js +296 -45
  15. package/src/capabilities/initial-selection.js +3 -2
  16. package/src/capabilities/lifecycle-preload.js +69 -11
  17. package/src/capabilities/lifecycle-snapshot.js +127 -39
  18. package/src/capabilities/mcp-admission.js +2 -2
  19. package/src/capabilities/mcp-config.js +344 -0
  20. package/src/capabilities/mcp-credentials.js +180 -0
  21. package/src/capabilities/mcp-diagnostics.js +169 -0
  22. package/src/capabilities/mcp-drain.js +18 -4
  23. package/src/capabilities/mcp-facade.js +3 -3
  24. package/src/capabilities/mcp-gate.js +51 -52
  25. package/src/capabilities/mcp-install-tool.js +276 -0
  26. package/src/capabilities/mcp-manager.js +368 -142
  27. package/src/capabilities/mcp-mount.js +219 -79
  28. package/src/capabilities/mcp-operations.js +359 -0
  29. package/src/capabilities/mcp-process.js +43 -0
  30. package/src/capabilities/mcp-registry.js +74 -60
  31. package/src/capabilities/mcp-runtime.js +103 -0
  32. package/src/capabilities/read-payloads.js +8 -3
  33. package/src/capabilities/skill-applicability.js +185 -0
  34. package/src/capabilities/skill-builtin-migration.js +2 -1
  35. package/src/capabilities/skill-selection-plugin.js +161 -56
  36. package/src/capabilities/store/checked-contract.d.ts +2 -2
  37. package/src/capabilities/store/fs-adapter.js +2 -3
  38. package/src/context-guard/index.js +2 -2
  39. package/src/delegate/supervision-mount.js +5 -5
  40. package/src/delegate/tool.js +2 -10
  41. package/src/file-lease/client.js +1299 -0
  42. package/src/file-lease/index.js +34 -0
  43. package/src/file-lease/lineage.js +207 -0
  44. package/src/file-lease/node-contracts.d.ts +42 -0
  45. package/src/file-lease/plugin.js +1013 -0
  46. package/src/file-lease/singleton.js +155 -0
  47. package/src/file-lease/spawn.js +62 -0
  48. package/src/file-lease/tools.js +847 -0
  49. package/src/file-lease/view.js +144 -0
  50. package/src/file-lease/watch.js +473 -0
  51. package/src/hashline-edit/index.js +76 -42
  52. package/src/hashline-edit/write-tool.js +90 -0
  53. package/src/intent-gate/index.js +29 -12
  54. package/src/intent-gate/matcher.js +36 -2
  55. package/src/lease-broker/entry.js +98 -0
  56. package/src/lease-broker/node-contracts.d.ts +63 -0
  57. package/src/lease-broker/protocol.js +237 -0
  58. package/src/lease-broker/server.js +402 -0
  59. package/src/lease-broker/state.js +482 -0
  60. package/src/lsp/tools.js +137 -27
  61. package/src/process/index.js +4 -2
  62. package/src/settings/index.js +2 -44
  63. package/src/settings/sections.js +2 -24
  64. package/src/shared/audit.js +9 -1
  65. package/src/shared/child-scope.js +2 -0
  66. package/src/todo-driver/index.js +1 -6
  67. package/src/worktree/cards.js +6 -21
  68. package/src/worktree/lane-core.js +1 -19
  69. package/src/worktree/lane-workflows.js +11 -32
  70. package/src/worktree/rules.js +1 -1
  71. package/lib/client.edit-lock-maintenance.js +0 -592
  72. package/lib/client.edit-lock-panel.js +0 -405
  73. package/src/edit-lock/adapter.js +0 -57
  74. package/src/edit-lock/admin-ledger.js +0 -149
  75. package/src/edit-lock/admin-recovery.js +0 -151
  76. package/src/edit-lock/admission.js +0 -82
  77. package/src/edit-lock/binding-attestation.js +0 -29
  78. package/src/edit-lock/binding-guidance.js +0 -72
  79. package/src/edit-lock/binding-machine.js +0 -312
  80. package/src/edit-lock/call-context.js +0 -65
  81. package/src/edit-lock/derived-facts.js +0 -67
  82. package/src/edit-lock/domain.js +0 -165
  83. package/src/edit-lock/domains.js +0 -170
  84. package/src/edit-lock/host.js +0 -149
  85. package/src/edit-lock/incarnations.js +0 -150
  86. package/src/edit-lock/index.js +0 -1303
  87. package/src/edit-lock/inspect.js +0 -219
  88. package/src/edit-lock/lifecycle.js +0 -579
  89. package/src/edit-lock/limits.js +0 -61
  90. package/src/edit-lock/maintenance.js +0 -310
  91. package/src/edit-lock/manager.js +0 -875
  92. package/src/edit-lock/negotiation.js +0 -132
  93. package/src/edit-lock/operation-history.js +0 -214
  94. package/src/edit-lock/peer-client.js +0 -86
  95. package/src/edit-lock/peer-transport.js +0 -76
  96. package/src/edit-lock/peer.js +0 -87
  97. package/src/edit-lock/publisher.js +0 -191
  98. package/src/edit-lock/read-authority.js +0 -101
  99. package/src/edit-lock/recovery.js +0 -139
  100. package/src/edit-lock/remote.js +0 -362
  101. package/src/edit-lock/request-binding.js +0 -42
  102. package/src/edit-lock/request-data.js +0 -40
  103. package/src/edit-lock/reservation.js +0 -301
  104. package/src/edit-lock/reserved-runtime.js +0 -45
  105. package/src/edit-lock/resource-identity.js +0 -130
  106. package/src/edit-lock/runtime.js +0 -145
  107. package/src/edit-lock/settle.js +0 -144
  108. package/src/edit-lock/snapshot.js +0 -175
  109. package/src/edit-lock/stale-sweep.js +0 -120
  110. package/src/edit-lock/state.js +0 -462
  111. package/src/edit-lock/store.js +0 -327
  112. package/src/edit-lock/tool-scope.js +0 -29
  113. package/src/edit-lock/view.js +0 -170
  114. package/src/edit-lock/write-scopes.js +0 -48
  115. package/src/edit-lock/write-tool.js +0 -45
  116. package/src/shared/delegate-edit-lifecycle.js +0 -36
  117. package/src/worktree/authority.js +0 -56
@@ -0,0 +1,144 @@
1
+ // The read-only ownership view of the status surface (design D13).
2
+ //
3
+ // This module is the ONE place that decides what a user may see about
4
+ // ownership, and it is deliberately the narrowest possible projection:
5
+ //
6
+ // - three facts and nothing else — who holds which short path, when the hold
7
+ // started, and how long it can last. No epoch, no generation, no
8
+ // reservation, no recovery record, no operation history, no fence. Those
9
+ // concepts were deleted with the durable authority (D12); a view that still
10
+ // carried them would let the panel grow a story that no longer exists.
11
+ // - short names only. An absolute path in `path` would leak the user's home
12
+ // directory into a UI string and, worse, invite someone to "fix" a row by
13
+ // path. `pathFull` is the separate, mechanically faithful field the
14
+ // technical layer may use; the panel does not render it by default.
15
+ // - purity. No IO, no clock, no arbiter, no cordis: `now` arrives as an
16
+ // argument so the expiry comparison is the caller's clock, exactly the
17
+ // "read-time comparison" the lease protocol specifies (D2). A lapsed row is
18
+ // therefore not ownership any more — showing it would invent a state whose
19
+ // only exit is somebody cleaning it up, which the rebuild forbids.
20
+ //
21
+ // The arbiter's own health is a closed three-value vocabulary (D7): a healthy
22
+ // arbiter arbitrates, a degraded one lost the connection it once had, and an
23
+ // unavailable one was never reached in this process. Anything else is reported
24
+ // as `unavailable` — an unknown state must never be presented as healthy.
25
+
26
+ import { shortName as shortPathName } from './tools.js'
27
+
28
+ /** The only shape of this view the panel understands. */
29
+ export const VIEW_SCHEMA_VERSION = 1
30
+
31
+ /** Closed arbiter vocabulary (design D7); anything else renders as unavailable. */
32
+ export const ARBITER_STATES = Object.freeze(['ok', 'degraded', 'unavailable'])
33
+
34
+ /** @returns {string | undefined} the value when it is a non-empty string */
35
+ function text(value) {
36
+ return typeof value === 'string' && value.length > 0 ? value : undefined
37
+ }
38
+
39
+ /**
40
+ * A millisecond instant, or `null` when the field is absent. `null` (not 0) is
41
+ * the view's "no such deadline" — the panel distinguishes "not retained" from
42
+ * "retained until the epoch".
43
+ * @param {unknown} value
44
+ * @returns {number | null}
45
+ */
46
+ function instant(value) {
47
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
48
+ }
49
+
50
+ /**
51
+ * When a lease stops being ownership: `max(expiresAt, holdUntil ?? 0)`, the
52
+ * protocol's one validity rule (D2). Not a second authority — the arbiter
53
+ * already applied it; this is what makes the view agree with the arbiter
54
+ * without asking it again.
55
+ * @param {any} lease
56
+ * @returns {number}
57
+ */
58
+ function validUntil(lease) {
59
+ const expiresAt = instant(lease?.expiresAt) ?? 0
60
+ const holdUntil = instant(lease?.holdUntil) ?? 0
61
+ return Math.max(expiresAt, holdUntil)
62
+ }
63
+
64
+ /**
65
+ * The short name of one lease path: relative inside the session's cwd, the
66
+ * basename outside it. Never absolute (the rule itself lives in `tools.js`, so
67
+ * the model-facing status text and this view cannot disagree).
68
+ * @param {string} path
69
+ * @param {string | undefined} cwd
70
+ * @returns {string}
71
+ */
72
+ function shortName(path, cwd) {
73
+ try {
74
+ return shortPathName(path, cwd)
75
+ } catch {
76
+ // A malformed path must degrade to something renderable, never throw in
77
+ // the endpoint: the panel is an observability surface, not a validator.
78
+ return path
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Build the read-only ownership view.
84
+ *
85
+ * @param {object} input
86
+ * @param {any[]} [input.leases] the arbiter's `status` snapshot (`LeaseView[]`)
87
+ * @param {string | undefined} [input.mineLineage] the calling session's lineage id (design D9)
88
+ * @param {string | undefined} [input.cwd] the session's working directory (short-name base and scope)
89
+ * @param {'ok' | 'degraded' | 'unavailable' | string} [input.arbiter] the client's read-only connection state
90
+ * @param {number} [input.now] read-time instant (epoch ms) used for the expiry comparison
91
+ * @returns {{
92
+ * schemaVersion: number,
93
+ * arbiter: 'ok' | 'degraded' | 'unavailable',
94
+ * mine: { path: string, pathFull: string, label: string, heldSince: number | null, holdUntil: number | null }[],
95
+ * others: { path: string, pathFull: string, holder: { label: string, session: string }, expiresAt: number | null, holdUntil: number | null }[],
96
+ * }}
97
+ */
98
+ export function buildLeaseView({ leases, mineLineage, cwd, arbiter, now } = {}) {
99
+ const at = instant(now) ?? 0
100
+ const base = text(cwd)
101
+ const mineId = text(mineLineage)
102
+ /** @type {any[]} */
103
+ const mine = []
104
+ /** @type {any[]} */
105
+ const others = []
106
+ for (const lease of Array.isArray(leases) ? leases : []) {
107
+ if (lease === null || typeof lease !== 'object') continue
108
+ const path = text(lease.path)
109
+ if (path === undefined) continue
110
+ // Lapsed rows are not ownership: ownership ends by itself (D2/D10), and the
111
+ // view says so instead of presenting a row somebody would have to clear.
112
+ if (validUntil(lease) <= at) continue
113
+ const owner = lease.owner !== null && typeof lease.owner === 'object' ? lease.owner : {}
114
+ const label = text(owner.label) ?? text(owner.session) ?? ''
115
+ const short = shortName(path, base)
116
+ const holdUntil = instant(lease.holdUntil)
117
+ if (mineId !== undefined && text(owner.id) === mineId) {
118
+ mine.push({ path: short, pathFull: path, label, heldSince: instant(lease.acquiredAt), holdUntil })
119
+ continue
120
+ }
121
+ others.push({
122
+ path: short,
123
+ pathFull: path,
124
+ holder: { label, session: text(owner.session) ?? '' },
125
+ expiresAt: instant(lease.expiresAt),
126
+ holdUntil,
127
+ })
128
+ }
129
+ // Deterministic order, independent of the snapshot's own ordering: by the
130
+ // short name the user actually reads (two basenames may collide), then by the
131
+ // full path, which is unique per lease and therefore a total order.
132
+ const byPath = (/** @type {any} */ left, /** @type {any} */ right) => {
133
+ if (left.path !== right.path) return left.path < right.path ? -1 : 1
134
+ return left.pathFull < right.pathFull ? -1 : left.pathFull > right.pathFull ? 1 : 0
135
+ }
136
+ mine.sort(byPath)
137
+ others.sort(byPath)
138
+ return {
139
+ schemaVersion: VIEW_SCHEMA_VERSION,
140
+ arbiter: ARBITER_STATES.includes(/** @type {any} */ (arbiter)) ? /** @type {any} */ (arbiter) : 'unavailable',
141
+ mine,
142
+ others,
143
+ }
144
+ }
@@ -0,0 +1,473 @@
1
+ // The in-memory release-subscription coordinator behind `edit_lock_watch`.
2
+ //
3
+ // There is ONE coordinator per lease client (design D2/D4), not one per preset
4
+ // mount. Every mount of this bundle shares one client, so it must also share one
5
+ // view of "which session is waiting for which path": a per-mount registry would
6
+ // let two mounts subscribe the same `(session, path)` twice — two entries, two
7
+ // notices for one release — and would let one mount's teardown cancel a wait
8
+ // another mount still owns.
9
+ //
10
+ // The coordinator holds three collections and nothing else:
11
+ //
12
+ // active one entry per `(session, normalized path)`, in `watching`
13
+ // pendingDelivery consumed entries (`hit` / `reset`) whose notice has not
14
+ // been delivered yet, so disposal can still cancel them
15
+ // mounts the mount references that may deliver a notice
16
+ //
17
+ // The five states are a closed set:
18
+ //
19
+ // watching — registered, waiting for a release fact
20
+ // hit — a release fact consumed this subscription; it is over
21
+ // reset — the connection that could have delivered the fact is gone; it
22
+ // is over, and NOTHING is claimed about ownership
23
+ // self — this session already owns the path (an authoritative read said
24
+ // so, or a transfer settled); it is over, and there is nothing
25
+ // left to wait for
26
+ // cancelled — the subscriber went away (agent disposal, unmount, or a read
27
+ // that found the path free); it is over
28
+ //
29
+ // Consumption is one-way and happens in the event callback, BEFORE any notice is
30
+ // scheduled, so a duplicate or repeated release observation can never deliver a
31
+ // subscription twice. The notice itself is deferred by one turn of the event
32
+ // loop, because its caller is a socket callback and a message must never be
33
+ // injected from inside the transport's publish stack.
34
+ //
35
+ // Nothing here talks to a socket or a cordis context. A mount handle supplies
36
+ // the two things only the plugin knows: how to resolve the live agent for a
37
+ // session, and how to deliver a notice to it. Everything else — entries, the
38
+ // one shared status flight per entry, the pending-delivery window, the deferred
39
+ // timer — is decided here.
40
+
41
+ /** The closed subscription-state vocabulary. */
42
+ export const WATCH_STATES = Object.freeze(['watching', 'hit', 'reset', 'self', 'cancelled'])
43
+
44
+ /**
45
+ * @typedef {'watching' | 'hit' | 'reset' | 'self' | 'cancelled'} WatchState
46
+ */
47
+
48
+ /**
49
+ * @typedef {object} WatchEntry
50
+ * @property {string} sessionId the session that subscribed
51
+ * @property {string} path normalized absolute path
52
+ * @property {WatchState} state
53
+ * @property {boolean} settledByTool true once a running tool already reported
54
+ * this episode to the model, so a deferred notice would only repeat it
55
+ * @property {any} agent the agent that subscribed: delivery re-resolves the
56
+ * session and refuses to land on a replacement agent
57
+ * @property {Promise<any> | null} statusFlight the ONE authoritative read this
58
+ * entry is currently sharing, so overlapping calls cannot disagree
59
+ * @property {Set<WatchMount>} mounts the mount references that still want it
60
+ * @property {ReturnType<typeof setTimeout> | null} timer the deferred notice
61
+ */
62
+
63
+ /**
64
+ * @typedef {object} WatchMount
65
+ * @property {boolean} disposed set by the coordinator when the mount leaves
66
+ * @property {(sessionId: string) => any} agentFor the live agent for a session
67
+ * @property {(entry: WatchEntry, agent: any) => void} notify deliver one notice
68
+ */
69
+
70
+ /**
71
+ * @typedef {object} WatchProjection
72
+ * @property {string} sessionId
73
+ * @property {string} path
74
+ * @property {WatchState} state
75
+ * @property {boolean} settledByTool
76
+ */
77
+
78
+ /**
79
+ * Do not keep a finished process alive for a notice (design D4).
80
+ * @param {ReturnType<typeof setTimeout>} timer
81
+ */
82
+ function unrefTimer(timer) {
83
+ const handle = /** @type {{ unref?: () => void }} */ (/** @type {unknown} */ (timer))
84
+ if (typeof handle.unref === 'function') handle.unref()
85
+ }
86
+
87
+ /**
88
+ * The coordinator surface: one factory is the single definition of what a
89
+ * consumer holds, so the tool surface and the plugin row never re-describe it.
90
+ * @typedef {ReturnType<typeof createWatchCoordinator>} WatchCoordinator
91
+ */
92
+ export function createWatchCoordinator() {
93
+ /** @type {Map<string, WatchEntry>} */
94
+ const active = new Map()
95
+ /** @type {Set<WatchEntry>} */
96
+ const pendingDelivery = new Set()
97
+ /** @type {Set<WatchMount>} */
98
+ const mounts = new Set()
99
+
100
+ /** @param {string} sessionId @param {string} path @returns {string} */
101
+ function keyOf(sessionId, path) {
102
+ return `${sessionId}\u0000${path}`
103
+ }
104
+
105
+ /**
106
+ * The public shape of an entry. Internals (mounts, flight, timer, agent
107
+ * identity) stay inside the coordinator.
108
+ * @param {WatchEntry} entry
109
+ * @returns {WatchProjection}
110
+ */
111
+ function project(entry) {
112
+ return { sessionId: entry.sessionId, path: entry.path, state: entry.state, settledByTool: entry.settledByTool }
113
+ }
114
+
115
+ /**
116
+ * The mount that may deliver one notice: the entry's own references first, so
117
+ * the mount that registered the wait speaks for it, then any live mount of the
118
+ * same client — a notice survives the unmount that created it (design D4).
119
+ * @param {WatchEntry} entry
120
+ * @returns {WatchMount | undefined}
121
+ */
122
+ function liveMount(entry) {
123
+ for (const mount of entry.mounts) if (!mount.disposed) return mount
124
+ for (const mount of mounts) if (!mount.disposed) return mount
125
+ return undefined
126
+ }
127
+
128
+ /** @param {WatchEntry} entry */
129
+ function cancelTimer(entry) {
130
+ if (entry.timer === null) return
131
+ clearTimeout(entry.timer)
132
+ entry.timer = null
133
+ }
134
+
135
+ /**
136
+ * Consume one entry and schedule its notice: it joins `pendingDelivery` first,
137
+ * so a disposal between now and the timer still cancels it.
138
+ *
139
+ * A notice consumed while an authoritative read is still outstanding waits for
140
+ * that read to settle. The read's awaiting callers resume as microtasks and can
141
+ * then report the same episode as a tool result (`released` / `reset`), while
142
+ * this notice needs a macrotask to be delivered; arming after the flight is
143
+ * therefore enough for the tool's own answer to win, and a zero-delay timer
144
+ * can never duplicate it.
145
+ * @param {WatchEntry} entry
146
+ */
147
+ function scheduleNotice(entry) {
148
+ pendingDelivery.add(entry)
149
+ const flight = entry.statusFlight
150
+ if (flight === null) {
151
+ armNotice(entry)
152
+ return
153
+ }
154
+ // Both outcomes arm: a rejected read is caught by the tool as well, and its
155
+ // terminal rules run before this reaction's timer can fire.
156
+ void flight.then(
157
+ () => armNotice(entry),
158
+ () => armNotice(entry),
159
+ )
160
+ }
161
+
162
+ /**
163
+ * Start the one deferred timer for a consumed entry. The pending set is the
164
+ * source of truth: a disposal, unmount or transfer may have ended the entry
165
+ * while the read was still outstanding, and that must win over the notice.
166
+ *
167
+ * The timer re-checks everything that could have ended the subscription in the
168
+ * meantime — the terminal state, the mount references, and the live agent
169
+ * identity — so a late notice can never revive one or land on a replacement
170
+ * agent (design D4/D5).
171
+ * @param {WatchEntry} entry
172
+ */
173
+ function armNotice(entry) {
174
+ if (!pendingDelivery.has(entry)) return
175
+ if (entry.state !== 'hit' && entry.state !== 'reset') {
176
+ pendingDelivery.delete(entry)
177
+ return
178
+ }
179
+ if (entry.timer !== null) return
180
+ const timer = setTimeout(() => {
181
+ entry.timer = null
182
+ pendingDelivery.delete(entry)
183
+ if (entry.state !== 'hit' && entry.state !== 'reset') return
184
+ if (entry.settledByTool === true) return
185
+ const mount = liveMount(entry)
186
+ if (mount === undefined) return
187
+ const agent = mount.agentFor(entry.sessionId)
188
+ if (agent === undefined || agent === null) return
189
+ if (entry.agent !== undefined && agent !== entry.agent) return
190
+ try {
191
+ mount.notify(entry, agent)
192
+ } catch {
193
+ // A notice is best effort: delivery must never break the transport.
194
+ }
195
+ }, 0)
196
+ entry.timer = timer
197
+ unrefTimer(timer)
198
+ }
199
+
200
+ /**
201
+ * End a PENDING entry: its notice is over and its timer must not fire.
202
+ * @param {WatchEntry} entry
203
+ * @param {WatchState} state
204
+ */
205
+ function endPending(entry, state) {
206
+ if (pendingDelivery.delete(entry)) cancelTimer(entry)
207
+ entry.state = state
208
+ }
209
+
210
+ /**
211
+ * Attach one mount reference. The first reference starts the subscription
212
+ * lifetime of this coordinator; a later mount of the same client reuses the
213
+ * same entries, flights and notices.
214
+ * @param {WatchMount} mount
215
+ */
216
+ function join(mount) {
217
+ mounts.add(mount)
218
+ }
219
+
220
+ /**
221
+ * Drop one mount reference (unmount). A single mount leaving removes only its
222
+ * own references: entries another mount still wants stay active, and a pending
223
+ * notice transfers to a surviving reference. When the LAST mount leaves, the
224
+ * whole coordinator — entries, pending notices, timers — is cleared so a later
225
+ * mount starts with no inherited wait.
226
+ * @param {WatchMount} mount
227
+ */
228
+ function leave(mount) {
229
+ if (!mounts.delete(mount)) return
230
+ mount.disposed = true
231
+ for (const [key, entry] of [...active]) {
232
+ if (!entry.mounts.delete(mount)) continue
233
+ if (entry.mounts.size > 0) continue
234
+ active.delete(key)
235
+ entry.state = 'cancelled'
236
+ }
237
+ for (const entry of [...pendingDelivery]) entry.mounts.delete(mount)
238
+ if (mounts.size === 0) clear()
239
+ }
240
+
241
+ /**
242
+ * Find or create the single entry for one session and path. Repeating a
243
+ * subscription is idempotent: the same entry comes back, so one release can
244
+ * produce at most one notice however many times or from however many mounts
245
+ * the subscription was requested.
246
+ * @param {string} sessionId
247
+ * @param {string} path
248
+ * @param {WatchMount} [mount]
249
+ * @param {any} [agent]
250
+ * @returns {WatchEntry}
251
+ */
252
+ function register(sessionId, path, mount, agent) {
253
+ const key = keyOf(sessionId, path)
254
+ const known = active.get(key)
255
+ if (known !== undefined) {
256
+ if (mount !== undefined) known.mounts.add(mount)
257
+ if (agent !== undefined && agent !== null) known.agent = agent
258
+ return known
259
+ }
260
+ /** @type {WatchEntry} */
261
+ const entry = {
262
+ sessionId,
263
+ path,
264
+ state: 'watching',
265
+ settledByTool: false,
266
+ agent,
267
+ statusFlight: null,
268
+ mounts: new Set(),
269
+ timer: null,
270
+ }
271
+ if (mount !== undefined) entry.mounts.add(mount)
272
+ active.set(key, entry)
273
+ return entry
274
+ }
275
+
276
+ /**
277
+ * @param {string} sessionId
278
+ * @param {string} path
279
+ * @returns {WatchEntry | undefined}
280
+ */
281
+ function peek(sessionId, path) {
282
+ return active.get(keyOf(sessionId, path))
283
+ }
284
+
285
+ /**
286
+ * One authoritative read per entry, shared by every overlapping caller
287
+ * (design D2). Two calls for the same `(session, path)` must not run two
288
+ * reads and then disagree about whether the entry is still active: they await
289
+ * the same answer and apply the same terminal rules to the same object.
290
+ * @param {WatchEntry} entry
291
+ * @param {() => any} run the arbitration read
292
+ * @returns {Promise<any>}
293
+ */
294
+ function shareStatus(entry, run) {
295
+ if (entry.statusFlight !== null) return entry.statusFlight
296
+ /** @type {Promise<any>} */
297
+ const flight = Promise.resolve()
298
+ .then(run)
299
+ .finally(() => {
300
+ if (entry.statusFlight === flight) entry.statusFlight = null
301
+ })
302
+ entry.statusFlight = flight
303
+ return flight
304
+ }
305
+
306
+ /**
307
+ * End one subscription because the caller no longer needs it. `expected`
308
+ * makes the removal conditional on the SAME entry object still being active:
309
+ * a late caller can then never cancel a newer episode, and can never report
310
+ * `watching` for an entry it no longer owns.
311
+ * @param {string} sessionId
312
+ * @param {string} path
313
+ * @param {WatchEntry} [expected]
314
+ * @param {WatchState} [state]
315
+ * @returns {WatchEntry | undefined}
316
+ */
317
+ function cancel(sessionId, path, expected, state = 'cancelled') {
318
+ const key = keyOf(sessionId, path)
319
+ const entry = active.get(key)
320
+ if (entry === undefined) return undefined
321
+ if (expected !== undefined && entry !== expected) return undefined
322
+ active.delete(key)
323
+ if (pendingDelivery.delete(entry)) cancelTimer(entry)
324
+ entry.state = state
325
+ return entry
326
+ }
327
+
328
+ /**
329
+ * This session acquired the path by another route (a transfer settled as
330
+ * `transferred`). The wait is over because its goal was reached, and any
331
+ * notice already consumed for that path is moot: waking the session would
332
+ * only make it compete with itself (design D4).
333
+ * @param {string} sessionId
334
+ * @param {string} path
335
+ * @returns {WatchEntry | undefined}
336
+ */
337
+ function settleSelf(sessionId, path) {
338
+ /** @type {WatchEntry | undefined} */
339
+ let ended
340
+ for (const entry of [...pendingDelivery]) {
341
+ if (entry.sessionId !== sessionId || entry.path !== path) continue
342
+ endPending(entry, 'self')
343
+ ended = ended ?? entry
344
+ }
345
+ const key = keyOf(sessionId, path)
346
+ const activeEntry = active.get(key)
347
+ if (activeEntry !== undefined) {
348
+ active.delete(key)
349
+ activeEntry.state = 'self'
350
+ ended = ended ?? activeEntry
351
+ }
352
+ return ended
353
+ }
354
+
355
+ /**
356
+ * Consume every subscription for one path (the release fact carries no
357
+ * session, and one path may be watched by several sessions of this client).
358
+ * Each returned entry is already in `hit` and out of the active map, so a
359
+ * second observation of the same release finds nothing.
360
+ * @param {string} path
361
+ * @returns {WatchEntry[]}
362
+ */
363
+ function consumePath(path) {
364
+ /** @type {WatchEntry[]} */
365
+ const consumed = []
366
+ for (const [key, entry] of [...active]) {
367
+ if (entry.path !== path) continue
368
+ active.delete(key)
369
+ entry.state = 'hit'
370
+ scheduleNotice(entry)
371
+ consumed.push(entry)
372
+ }
373
+ return consumed
374
+ }
375
+
376
+ /**
377
+ * The connection that could have delivered a release is gone. Every active
378
+ * subscription becomes `reset` — a terminal state that says nothing about
379
+ * ownership — and is scheduled for the re-check notice.
380
+ * @returns {WatchEntry[]}
381
+ */
382
+ function resetAll() {
383
+ const entries = [...active.values()]
384
+ active.clear()
385
+ for (const entry of entries) {
386
+ entry.state = 'reset'
387
+ scheduleNotice(entry)
388
+ }
389
+ return entries
390
+ }
391
+
392
+ /**
393
+ * End every subscription of one session — active waits AND consumed entries
394
+ * whose notice has not been delivered yet (agent disposal, design D4).
395
+ * @param {string} sessionId
396
+ * @returns {WatchEntry[]}
397
+ */
398
+ function cancelSession(sessionId) {
399
+ /** @type {WatchEntry[]} */
400
+ const cancelled = []
401
+ for (const [key, entry] of [...active]) {
402
+ if (entry.sessionId !== sessionId) continue
403
+ active.delete(key)
404
+ entry.state = 'cancelled'
405
+ cancelled.push(entry)
406
+ }
407
+ for (const entry of [...pendingDelivery]) {
408
+ if (entry.sessionId !== sessionId) continue
409
+ endPending(entry, 'cancelled')
410
+ cancelled.push(entry)
411
+ }
412
+ return cancelled
413
+ }
414
+
415
+ /**
416
+ * End everything: no entry can be revived afterwards, and no scheduled notice
417
+ * survives. Called when the last mount of the client leaves.
418
+ * @returns {WatchEntry[]}
419
+ */
420
+ function clear() {
421
+ /** @type {WatchEntry[]} */
422
+ const entries = [...active.values(), ...pendingDelivery]
423
+ active.clear()
424
+ for (const entry of [...pendingDelivery]) cancelTimer(entry)
425
+ pendingDelivery.clear()
426
+ for (const entry of entries) entry.state = 'cancelled'
427
+ return entries
428
+ }
429
+
430
+ /** @returns {number} how many subscriptions are still active */
431
+ function size() {
432
+ return active.size
433
+ }
434
+
435
+ /** @returns {number} how many consumed notices are still undelivered */
436
+ function pendingSize() {
437
+ return pendingDelivery.size
438
+ }
439
+
440
+ /** @returns {number} how many mount references are attached */
441
+ function mountCount() {
442
+ return mounts.size
443
+ }
444
+
445
+ /** @returns {WatchProjection[]} the active entries, as copies */
446
+ function entries() {
447
+ return [...active.values()].map(project)
448
+ }
449
+
450
+ /** @returns {WatchProjection[]} the pending notices, as copies */
451
+ function pendingEntries() {
452
+ return [...pendingDelivery].map(project)
453
+ }
454
+
455
+ return Object.freeze({
456
+ join,
457
+ leave,
458
+ register,
459
+ peek,
460
+ shareStatus,
461
+ cancel,
462
+ settleSelf,
463
+ consumePath,
464
+ resetAll,
465
+ cancelSession,
466
+ clear,
467
+ size,
468
+ pendingSize,
469
+ mountCount,
470
+ entries,
471
+ pendingEntries,
472
+ })
473
+ }