@banbolee/dsh-lsp-diagnostics 0.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.
package/README.md ADDED
@@ -0,0 +1,297 @@
1
+ # @banbolee/dsh-lsp-diagnostics
2
+
3
+ Host-plane Cordis bundle plugin for DeepSeek Harness: after an official
4
+ `write`/`edit`/`str_replace_editor` mutation lands inside the session
5
+ workspace, the tool result still succeeds and the next model inference carries
6
+ a single persistent LSP diagnostics plugin notice. It also registers the
7
+ model-callable `lsp_diagnostics(file_path)` tool for explicit diagnosis.
8
+
9
+ The plugin is a named namespace function plugin: it exports `name`, `inject`,
10
+ `Config`, and `apply`, and has no default export. The real Loader reads the
11
+ module namespace (`exports.default ?? exports`) and Cordis reads
12
+ `inject`/`Config` from the plugin object, so every public metadata field
13
+ survives installation. The plugin never modifies `deepseek-harness`, never
14
+ extends `ctx.lsp`, never imports `@deepseek-ai/*/src/*`, and never
15
+ monkey-patches official objects.
16
+
17
+ ## Usage
18
+
19
+ Install the bundle into any DSH profile from the repository root. The
20
+ workspace-root flag `-w` is required: without it pnpm cannot resolve the local
21
+ package inside the profile's generated workspace.
22
+
23
+ ```bash
24
+ dsh plugin --profile <profile> add -w ./plugins/dsh-lsp-diagnostics
25
+ ```
26
+
27
+ To install into an isolated profile with a single command:
28
+
29
+ ```bash
30
+ DSH_HOME="$(mktemp -d)" scripts/sync-lsp-diagnostics-to-profile.sh <profile>
31
+ ```
32
+
33
+ The sync helper requires `DSH_HOME` so tests and manual QA always target
34
+ isolated profile state, never the user's default Harness home.
35
+
36
+ The plugin registers one real three-parameter `tools/post-execute` waterfall
37
+ listener `(exec, _result, next)`: it calls `await next()` exactly once,
38
+ outside any plugin catch, and never consumes the `_result` placeholder.
39
+
40
+ ## Config
41
+
42
+ All knobs ride through the plugin's `Config` schema with a strict validator.
43
+ Unknown keys, invalid JSON-representable values, invalid timers/caps, or any
44
+ deviation from the closed extension route fail loud at load time.
45
+
46
+ | Key | Default | Meaning |
47
+ | --- | --- | --- |
48
+ | `enabled` | `true` | With `enabled=false` the plugin creates zero collector, runtime, coordinator, or tool, registers zero tools/listeners/effects, and spawns zero processes. |
49
+ | `timeoutMs` | `5000` | Single non-extendable deadline for one post-execute aggregate. |
50
+ | `settleMs` | `200` | Quiet window; must be `< timeoutMs`. |
51
+ | `shutdownTimeoutMs` | `1000` | Graceful `shutdown`/`exit` budget. |
52
+ | `killGraceMs` | `500` | Process-tree TERM→KILL grace. |
53
+ | `maxDocumentBytes` | `2097152` | Hard UTF-8 byte cap for the document sent via `didOpen`. |
54
+ | `maxMessageBytes` | `4194304` | LSP message body cap. |
55
+ | `maxStderrBytes` | `16384` | Bounded stderr tail cap. |
56
+ | `maxDiagnostics` | `50` | Global cap on automatic diagnostic lines and per-call cap on the canonical direct-tool diagnostics array. |
57
+ | `maxResultChars` | `8000` | Unicode code-point cap on the final aggregate text. |
58
+ | `reportClean` | `true` | Whether explicit empty results render `Status: clean`. |
59
+ | `servers` | TypeScript + Go | Closed provider catalog; TypeScript and Go are enabled by default, while `clangd`, `rust`, and `python` are opt-in. |
60
+
61
+ The extension route is closed: `.ts` → typescript/typescript, `.tsx` →
62
+ typescript/typescriptreact, `.go` → go/go, `.c` → clangd/c,
63
+ `.cc`/`.cpp`/`.cxx` → clangd/cpp, `.h`/`.hh`/`.hpp`/`.hxx` → clangd/cpp,
64
+ `.rs` → rust/rust, and `.py`/`.pyi` → python/python; any other extension key,
65
+ missing canonical provider entry, cross-provider collision, or rewritten
66
+ language id fails loud at load time, while other extensions at runtime are
67
+ silently ignored.
68
+
69
+ The `servers` block is a provider-level partial overlay: omitting `servers` or
70
+ supplying `servers: {}` keeps exactly the TypeScript and Go defaults, while
71
+ supplying a known optional `clangd`, `rust`, or `python` key activates that
72
+ provider. For example, `servers: { go: { command: '/path/trae-gopls' } }`
73
+ changes only Go without requiring a TypeScript block. The legacy
74
+ `extensionToLanguage` field is accepted only when it exactly equals that
75
+ provider's canonical mapping; it never defines new routes.
76
+
77
+ Default commands are `typescript-language-server --stdio`, `gopls`, `clangd`,
78
+ `rust-analyzer`, and `pyright-langserver --stdio`. clangd, rust-analyzer, and
79
+ gopls receive no default arguments. Per-server `env`, `configuration`, and
80
+ `initializationOptions` may be overridden and must be JSON-representable.
81
+
82
+ ## Behavior
83
+
84
+ Only a non-empty `session.header.cwd` that canonicalizes to a directory and
85
+ contains the target is eligible; missing or empty cwd, non-directory
86
+ canonicalization, and outside-workspace targets are ineligible and silently
87
+ ignored — never a `workspace unavailable` or `outside workspace` notice.
88
+ Workspace, cwd, and URI are always derived through the public `ctx.fs` API
89
+ (`resolve`/`stat`/`contains`/`targetKey`/`processPath`/`fileUrl`), never from
90
+ `displayPath` or the host `process.cwd()`.
91
+
92
+ Fail-open is plugin-owned post-processing failure only: after the downstream
93
+ `await next()` decision succeeded, a diagnostics error, missing or crashed
94
+ server, protocol failure, read error, or timeout never changes the decision or
95
+ result and never returns `kind: 'block'`. Caller abort is decided by the
96
+ ToolRuntime and downstream listeners; a caller abort is never swallowed,
97
+ rewritten, or promised to become a success by this plugin.
98
+
99
+ Freshness uses the post-write `FsVersion` plus an independent per-target
100
+ monotonic generation counter whose values are never reused within one plugin lifetime.
101
+ `retireIfCurrent` removes only the matching active marker (generation + version)
102
+ and never touches the counter. When a generation counter is exhausted the
103
+ plugin fails safe and every older candidate stays stale.
104
+
105
+ Eligible targets are ordered by the single shared comparator
106
+ `(renderPath, String(targetKey), canonicalUri)` compared by Unicode
107
+ code points, and the same order drives both diagnosis scheduling and
108
+ rendered sections — raw `displayPath` tuples, `localeCompare`, and
109
+ default UTF-16 sorts are never used.
110
+
111
+ One exec produces at most one aggregate notice with exactly one title
112
+ `[LSP diagnostics after write]`, one section per retained file, exactly one
113
+ blank line between sections, and no leading or trailing newline. After the
114
+ global `maxDiagnostics` count cap, a diagnostics file with zero retained lines
115
+ is omitted entirely, never leaving an empty section; clean and unavailable
116
+ sections do not consume the cap. Exactly one global advisory is appended,
117
+ separated by one blank line, only when at least one diagnostic line is
118
+ retained. The `maxResultChars` cap is applied only after the complete
119
+ canonical aggregate is built: the fixed marker `…(truncated)` replaces the
120
+ truncated suffix, and the output never exceeds the cap.
121
+
122
+ Documents are read with a bounded `ctx.fs.readBytes(target, signal,
123
+ maxDocumentBytes)` after a `stat.size` preflight, with strict UTF-8 decoding;
124
+ an unbounded `readText` is never used. A known size above the cap, or an unknown-size read that throws `FsError`
125
+ with `code === 'FS_TOO_LARGE'`, maps to `document too large`; any other read
126
+ error maps to `diagnostics unavailable`. The public seam guarantees a
127
+ successful `readBytes` is complete with `bytes.length <= maxDocumentBytes`;
128
+ the plugin never relies on or returns over-cap bytes.
129
+
130
+ `publishDiagnostics` is correlated exact-URI-first, then by document version;
131
+ only the consumed fields `range`, `severity`, `code`, `source`, and `message`
132
+ are validated, while standard optionals `tags`, `relatedInformation`,
133
+ `codeDescription`, `data` and unknown extension fields are safely ignored and
134
+ never enter the normalized schema or renderer. Positions are 0-based UTF-16
135
+ and render as 1-based start-end coordinates.
136
+
137
+ The coordinator owns both the active augment-operation registry and the
138
+ `retiredIo` late-final-stat registry. Unload quiesces in the exact order:
139
+ stop direct-tool admission → stop coordinator admission (which closes runtime
140
+ admission) → offTool → offPost → offObserved → abort coordinator operations →
141
+ abort direct-tool operations → await all active augment promises → await all
142
+ active direct-tool promises → await all `retiredIo` → `runtime.dispose()`.
143
+ Cleanup continues after individual failures and aggregates them; therefore no
144
+ direct call can race runtime disposal. Every operation's outermost `finally` clears the deadline timer and removes
145
+ the caller/cleanup relay listeners, leaving zero residue.
146
+
147
+ `stopAdmission()` only closes the gate and the runtime's own admission; it
148
+ never aborts operations. Active operations are aborted at the later abort
149
+ step, after both listener disposers have run, exactly as the documented order
150
+ states. A listener that already entered but is still blocked in downstream
151
+ `next()` has no plugin-owned cancellation seam, so cleanup tracks every such
152
+ invocation in a pending-invocation registry and awaits its settlement (it
153
+ takes and retires the exec's candidates before the downstream result or error
154
+ returns, including when `next()` throws synchronously). The deadline uses an
155
+ injected monotonic clock, never `Date.now()`, so wall-clock rollback cannot
156
+ extend a hard deadline.
157
+
158
+ Session teardown also owns the protocol write quiescence: every in-flight
159
+ server-request handler and the serialized write tail settle before cleanup
160
+ returns, and no new server-request handlers start while closing — so no queued
161
+ frame (responses, `shutdown`, `exit`) can be written after cleanup resolves.
162
+ A matching `publishDiagnostics` that races the `didOpen` write is buffered
163
+ until the open write succeeds; only a successfully written open generation
164
+ accepts notifications, and a failed `didOpen` write still allows the allowed
165
+ fresh-instance retry. The shared runtime applies a generic correctness-over-performance policy: it
166
+ still pools one provider/workspace session across distinct canonical URIs, but
167
+ before a URI already opened by that session is diagnosed again it evicts and
168
+ asynchronously retires the session, then opens the URI at version 1 in a fresh
169
+ process; this avoids provider VFS caches returning stale same-URI diagnostics,
170
+ and retired teardown remains tracked and drained on unload.
171
+
172
+ `timeoutMs` is a single non-extendable deadline for the whole post-execute;
173
+ the final freshness round is a one-shot final stat/deadline/generation gate.
174
+ The direct tool likewise captures one absolute deadline from its injected
175
+ monotonic clock, and every gate transitions to timeout when `now() >= deadlineAt`
176
+ even if event-loop starvation has not run the scheduled timer callback yet.
177
+ A deadline/caller/cleanup loss never waits for late final stats: unsettled
178
+ records are retired into `retiredIo` before the decision returns. Session
179
+ teardown is the single order: `shutdown` request → `exit` notification/
180
+ natural-close wait → conditional `handle.terminate()` (only while still alive)
181
+ → await `handle.done` and `waitForExit()` → `processLifetimeController.abort()`;
182
+ `terminate()` is the only hard stop.
183
+
184
+ ## Model Experience
185
+
186
+ `lsp_diagnostics(file_path)` is a model-callable, read-only tool for one
187
+ existing file that `ctx.fs` permits the session to read. It accepts a
188
+ workspace-relative or absolute path and uses every configured provider and the
189
+ closed extension route documented above. It has the same path authority as the
190
+ official `read` tool: the session cwd is only the LSP project root, so an
191
+ explicit direct call may diagnose a readable file outside that root. It never
192
+ creates, edits, or deletes a file. Invalid requests fail explicitly with
193
+ `file_path must be a non-empty string`, `session workspace cwd`, `session
194
+ workspace is not an existing directory`, `target does not exist`, `target is
195
+ not a regular file`, `no configured diagnostics provider`, or `target changed
196
+ during diagnosis`.
197
+
198
+ The direct tool has exactly three canonical outcomes: `diagnostics` with the
199
+ normalized list sorted by the same comparator as automatic rendering and sliced
200
+ to `maxDiagnostics` before ToolRuntime/PTC receives it, plus an always-present
201
+ nonnegative integer `omitted_diagnostics`; `no_diagnostics`, rendered as `No
202
+ diagnostics reported for this file snapshot.`; and `unavailable` with one closed
203
+ reason. The renderer preserves the omission marker from that canonical count.
204
+ Call it to
205
+ diagnose an existing file explicitly, especially after a shell command,
206
+ formatter, or generator bypasses automatic `fs/observed` feedback; avoid
207
+ redundant calls when fresh automatic feedback already exists. Automatic
208
+ post-write feedback remains the default after supported write/edit mutations,
209
+ while the direct tool is deliberate and reports path/workspace errors instead
210
+ of silently treating them as ineligible.
211
+
212
+ From the model's point of view each mutation tool (`write`, `edit`,
213
+ `str_replace_editor` create/str_replace/insert) returns its original result
214
+ unchanged; the plugin appends one bounded aggregate notice to
215
+ `decision.additionalContexts` for the next inference. A notice contains:
216
+
217
+ - one `File: <renderPath>` section per retained file — `- <severity>
218
+ <startLine>:<startCharacter>-<endLine>:<endCharacter> source=... code=...
219
+ message` lines for diagnostics, `Status: clean`, or `Status: diagnostics
220
+ unavailable (<reason>)` — in the shared code-point order;
221
+ - exactly one global advisory when at least one diagnostic line is retained;
222
+ - nothing for unsupported extensions, ineligible workspaces, `enabled=false`,
223
+ or sessions without a workspace.
224
+
225
+ Unavailable reasons are closed: `server not found`, `server crashed`,
226
+ `timeout`, `malformed response`, `document too large`, `diagnostics
227
+ unavailable`.
228
+
229
+ ## Known Limitations and Deferred Work
230
+
231
+ - shell redirection, `sed -i`, scripts, formatters, generators, and external
232
+ editors that bypass `ctx.fs`/`fs/observed` are out of scope for automatic
233
+ feedback; the model may call `lsp_diagnostics(file_path)` afterward. There is no
234
+ filesystem watcher and no project-root probing — the session cwd is the
235
+ workspace root.
236
+ - For automatic feedback, agentless tool execution, missing/empty or
237
+ non-directory cwd, and outside-workspace targets are silently out of scope
238
+ and never produce a notice. Direct calls use official `read`-equivalent path
239
+ authority instead: a readable file outside the session cwd is eligible, while
240
+ that cwd remains the LSP project root.
241
+ - The plugin does not install or download `typescript-language-server`,
242
+ `gopls`, `clangd`, `rust-analyzer`, or `pyright-langserver`; install requested
243
+ executables yourself and configure their paths. A missing server fails open
244
+ with `diagnostics unavailable (server not found)`.
245
+ - Portable deterministic tests use the repository fixture server and never use
246
+ a real server or the network. The separate explicit real-server lane is
247
+ opt-in, local-filesystem/process-only, and never installs or downloads tools.
248
+ - Implementation and tests use the `0.1.5-rc.1` dependency family; the peers
249
+ `@deepseek-ai/dsh-fs`, `@deepseek-ai/dsh-llm`, `@deepseek-ai/dsh-subprocess`,
250
+ and `@deepseek-ai/dsh-tools` are `^0.1.5-rc.1`.
251
+
252
+ ## Verification
253
+
254
+ Deterministic package-local tests (fixture server, no network, no real LSP
255
+ server, isolated temp workspaces/profiles):
256
+
257
+ ```bash
258
+ pnpm exec vitest run plugins/dsh-lsp-diagnostics tests/package-shape.spec.ts
259
+ ```
260
+
261
+ Documentation shape test (required sections and contract strings):
262
+
263
+ ```bash
264
+ pnpm exec vitest run tests/docs-shape-lsp-diagnostics.spec.ts
265
+ ```
266
+
267
+ Type check and sync script help:
268
+
269
+ ```bash
270
+ pnpm dlx --package=typescript@6.0.3 tsc -p plugins/dsh-lsp-diagnostics/tsconfig.json --noEmit
271
+ bash scripts/sync-lsp-diagnostics-to-profile.sh --help
272
+ ```
273
+
274
+ Explicit real-server verification is separate from portable coverage. It
275
+ requires `RUN_REAL_LSP_SERVERS=1`, a non-empty `REAL_LSP_PROVIDERS` list, an
276
+ explicit executable path for every requested provider, and an evidence path:
277
+
278
+ ```bash
279
+ RUN_REAL_LSP_SERVERS=1 \
280
+ REAL_LSP_PROVIDERS=rust \
281
+ REAL_LSP_RUST_COMMAND=/data00/home/lixingxin/.cargo/bin/rust-analyzer \
282
+ REAL_LSP_EVIDENCE_PATH=/tmp/dsh-real-rust.json \
283
+ pnpm exec vitest run tests/composition/lsp-real-servers.spec.ts
284
+ ```
285
+
286
+ The operator wrapper performs the same check:
287
+
288
+ ```bash
289
+ node scripts/qa/run-lsp-real-servers.mjs \
290
+ --providers rust \
291
+ --rust-command /data00/home/lixingxin/.cargo/bin/rust-analyzer \
292
+ --evidence /tmp/dsh-real-rust.json
293
+ ```
294
+
295
+ Each requested provider must complete bad → diagnostic → repair → clean. The
296
+ JSON file is machine-readable evidence; a missing requested executable records
297
+ `status: "blocked"` and exits nonzero rather than skipping or passing.
package/collector.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Mutation collector for `fs/observed` write/edit observations.
3
+ *
4
+ * Three responsibility-separated structures:
5
+ *
6
+ * - `pendingByExec`: `WeakMap<exec, Map<targetKey, Candidate>>` — the latest
7
+ * candidate observed for each exec/target pair. A replaced candidate is
8
+ * immediately stale.
9
+ * - `nextGenerationByTarget`: `Map<targetKey, number | 'exhausted'>` — the
10
+ * next generation to allocate for a target (missing entry means `1`). It
11
+ * only allocates monotonically and never regresses or reuses a generation
12
+ * within a plugin lifetime; it is never cleaned per target.
13
+ * - `latestObserved`: `Map<targetKey, { generation, version }>` — the current
14
+ * active marker only, never a counter. `retireIfCurrent` reclaims it.
15
+ *
16
+ * Only `write`, `edit`, and `str_replace_editor` with a mutating command
17
+ * (`create`/`str_replace`/`insert`) are accepted; `view`, unsupported tools
18
+ * and absent observations are silently ignored. The observer never throws:
19
+ * hostile observations and internal counter anomalies are contained.
20
+ *
21
+ * @module @banbolee/dsh-lsp-diagnostics/collector
22
+ */
23
+
24
+ /**
25
+ * @typedef {object} CollectorExec
26
+ * @property {unknown} [name] - the registered tool name.
27
+ * @property {{ command?: unknown }} [arguments] - parsed tool arguments.
28
+ */
29
+
30
+ /**
31
+ * @typedef {object} CollectorTargetLike
32
+ * @property {unknown} [targetKey] - opaque stable target identity.
33
+ * @property {unknown} [displayPath] - model-facing path.
34
+ */
35
+
36
+ /**
37
+ * @typedef {object} MutationCandidate
38
+ * @property {CollectorTargetLike} target - the mutated target.
39
+ * @property {string} version - the observed `FsVersion` token.
40
+ * @property {number} generation - the per-target monotonic generation.
41
+ */
42
+
43
+ /**
44
+ * @typedef {object} CollectorOptions
45
+ * @property {Map<string, number | 'exhausted'>} [initialNextGenerationByTarget]
46
+ * Test seam: pre-seeds the per-target generation counter so the
47
+ * `Number.MAX_SAFE_INTEGER` boundary and the permanent exhausted fail-safe
48
+ * can be exercised without 2^53 observations. Production wiring never
49
+ * supplies it.
50
+ */
51
+
52
+ /** @type {ReadonlySet<string>} */
53
+ const SUPPORTED_TOOLS = new Set(['write', 'edit', 'str_replace_editor'])
54
+
55
+ /** @type {ReadonlySet<string>} */
56
+ const SUPPORTED_EDITOR_COMMANDS = new Set(['create', 'str_replace', 'insert'])
57
+
58
+ /**
59
+ * Create a mutation collector.
60
+ * @param {CollectorOptions} [options] - optional test seam options.
61
+ * @returns {{
62
+ * observe(exec: unknown, target: unknown, observation: unknown): boolean
63
+ * take(exec: unknown): MutationCandidate[]
64
+ * isCurrent(candidate: unknown): boolean
65
+ * retireIfCurrent(candidate: unknown): boolean
66
+ * }}
67
+ */
68
+ export function createMutationCollector(options = {}) {
69
+ /** @type {Map<string, number | 'exhausted'>} */
70
+ const nextGenerationByTarget =
71
+ options.initialNextGenerationByTarget === undefined
72
+ ? new Map()
73
+ : new Map(options.initialNextGenerationByTarget)
74
+ /** @type {Map<string, { generation: number, version: string }>} */
75
+ const latestObserved = new Map()
76
+ /** @type {WeakMap<object, Map<string, MutationCandidate>>} */
77
+ const pendingByExec = new WeakMap()
78
+
79
+ /**
80
+ * Check whether the observation comes from a supported mutating tool.
81
+ * @param {unknown} exec - the tool-execution context (actor).
82
+ * @returns {boolean}
83
+ */
84
+ function isSupportedMutation(exec) {
85
+ if (typeof exec !== 'object' || exec === null) return false
86
+ const name = /** @type {CollectorExec} */ (exec).name
87
+ if (typeof name !== 'string' || !SUPPORTED_TOOLS.has(name)) return false
88
+ if (name !== 'str_replace_editor') return true
89
+ const command = /** @type {CollectorExec} */ (exec).arguments?.command
90
+ return typeof command === 'string' && SUPPORTED_EDITOR_COMMANDS.has(command)
91
+ }
92
+
93
+ /**
94
+ * Record a successful mutation observation for the exec/target pair.
95
+ *
96
+ * Per target: reads the next counter, allocates it as the generation
97
+ * (advancing to `generation + 1`, or to the permanent `'exhausted'` state
98
+ * at `Number.MAX_SAFE_INTEGER`), replaces the active marker, and stores the
99
+ * latest candidate. A later observation that reads `'exhausted'` deletes
100
+ * the active marker and silently suppresses the candidate, so all prior
101
+ * candidates (including the max-generation one) are stale forever. Any
102
+ * internal error is contained: never throws, never records.
103
+ *
104
+ * @param {unknown} exec - the tool-execution context (actor).
105
+ * @param {unknown} target - the mutated target.
106
+ * @param {unknown} observation - the fs/observed payload.
107
+ * @returns {boolean} true only when a supported present mutation was recorded.
108
+ */
109
+ function observe(exec, target, observation) {
110
+ try {
111
+ if (!isSupportedMutation(exec)) return false
112
+ if (typeof observation !== 'object' || observation === null) return false
113
+ const kind = /** @type {{ kind?: unknown }} */ (observation).kind
114
+ if (kind !== 'present') return false
115
+ const version = /** @type {{ version?: unknown }} */ (observation).version
116
+ if (typeof version !== 'string') return false
117
+ if (typeof target !== 'object' || target === null) return false
118
+ const targetKey = /** @type {CollectorTargetLike} */ (target).targetKey
119
+ if (typeof targetKey !== 'string' || targetKey.length === 0) return false
120
+
121
+ const next = nextGenerationByTarget.get(targetKey)
122
+ let generation
123
+ if (next === undefined) {
124
+ generation = 1
125
+ } else if (next === 'exhausted') {
126
+ // Permanent exhaustion: retire the active marker and suppress the
127
+ // candidate; never wrap or reset the counter.
128
+ latestObserved.delete(targetKey)
129
+ return false
130
+ } else if (typeof next === 'number' && Number.isSafeInteger(next) && next > 0) {
131
+ generation = next
132
+ } else {
133
+ // Internal counter anomaly: contain it silently, record nothing.
134
+ return false
135
+ }
136
+
137
+ const following = generation < Number.MAX_SAFE_INTEGER ? generation + 1 : 'exhausted'
138
+ nextGenerationByTarget.set(targetKey, following)
139
+ latestObserved.set(targetKey, { generation, version })
140
+
141
+ const key = /** @type {object} */ (exec)
142
+ let byTarget = pendingByExec.get(key)
143
+ if (byTarget === undefined) {
144
+ byTarget = new Map()
145
+ pendingByExec.set(key, byTarget)
146
+ }
147
+ // Replacing the candidate for this exec/target makes any prior one stale.
148
+ byTarget.set(targetKey, {
149
+ target: /** @type {CollectorTargetLike} */ (target),
150
+ version,
151
+ generation,
152
+ })
153
+ return true
154
+ } catch {
155
+ // Containment: a hostile observation must never throw.
156
+ return false
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Return and clear the pending candidates for one exec.
162
+ * @param {unknown} exec - the tool-execution context (actor).
163
+ * @returns {MutationCandidate[]}
164
+ */
165
+ function take(exec) {
166
+ if (typeof exec !== 'object' || exec === null) return []
167
+ const byTarget = pendingByExec.get(exec)
168
+ if (byTarget === undefined) return []
169
+ pendingByExec.delete(exec)
170
+ return [...byTarget.values()]
171
+ }
172
+
173
+ /**
174
+ * Extract a candidate's target key defensively.
175
+ * @param {unknown} candidate - the candidate to inspect.
176
+ * @returns {string | undefined}
177
+ */
178
+ function candidateTargetKey(candidate) {
179
+ if (typeof candidate !== 'object' || candidate === null) return undefined
180
+ const targetKey = /** @type {{ target?: { targetKey?: unknown } }} */ (candidate).target?.targetKey
181
+ return typeof targetKey === 'string' ? targetKey : undefined
182
+ }
183
+
184
+ /**
185
+ * Whether the candidate is the current active marker: the marker must exist
186
+ * and both `generation` and `version` must match exactly.
187
+ * @param {unknown} candidate - the candidate to check.
188
+ * @returns {boolean}
189
+ */
190
+ function isCurrent(candidate) {
191
+ const targetKey = candidateTargetKey(candidate)
192
+ if (targetKey === undefined) return false
193
+ const marker = latestObserved.get(targetKey)
194
+ if (marker === undefined) return false
195
+ if (typeof candidate !== 'object' || candidate === null) return false
196
+ const { generation, version } = /** @type {{ generation?: unknown, version?: unknown }} */ (candidate)
197
+ return marker.generation === generation && marker.version === version
198
+ }
199
+
200
+ /**
201
+ * Retire the active marker only when the candidate matches it exactly
202
+ * (generation and version). Never touches the generation counter, so the
203
+ * next observation for the target allocates the next generation.
204
+ * @param {unknown} candidate - the candidate to retire.
205
+ * @returns {boolean} whether the active marker was removed.
206
+ */
207
+ function retireIfCurrent(candidate) {
208
+ const targetKey = candidateTargetKey(candidate)
209
+ if (targetKey === undefined) return false
210
+ const marker = latestObserved.get(targetKey)
211
+ if (marker === undefined) return false
212
+ if (typeof candidate !== 'object' || candidate === null) return false
213
+ const { generation, version } = /** @type {{ generation?: unknown, version?: unknown }} */ (candidate)
214
+ if (marker.generation !== generation || marker.version !== version) return false
215
+ latestObserved.delete(targetKey)
216
+ return true
217
+ }
218
+
219
+ return { observe, take, isCurrent, retireIfCurrent }
220
+ }