@argszero/dsh-cancel-cause-guard 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 argszero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,278 @@
1
- # Temporary Holding Version
1
+ # @argszero/dsh-cancel-cause-guard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A prevention guard for the one `turn/end` row that makes a whole conversation view
4
+ unreadable.
5
+
6
+ The harness ships an `Agent.cancel(cause)` whose argument is a **closed** union —
7
+ `{ kind: 'user' }`, `{ kind: 'parent' }`, `{ kind: 'hook', reason }`,
8
+ `{ kind: 'disposed' }` — and that union is enforced by TypeScript alone. Nothing on
9
+ the write path refuses a value outside it, and one such value does not merely log
10
+ something odd: it ends the session's usefulness. This plugin normalizes the
11
+ out-of-union value at the last moment the harness still lets a plugin see it, and
12
+ backstops the session's own `append` so that even a cancellation it never gets to
13
+ see cannot commit an unreadable row.
14
+
15
+ ## The defect
16
+
17
+ `ReactLoopAgent.cancel` passes its argument straight to `AbortController.abort()`, so
18
+ the cause becomes `signal.reason`. The turn's `catch` reads it back through
19
+ `abortedCancelCause` (`dsh-agent-loop/src/agent.ts:80`):
20
+
21
+ ```ts
22
+ const cause = signal.reason as AgentCancelCause
23
+ switch (cause.kind) {
24
+ case 'user': case 'parent': case 'disposed': return { kind: cause.kind }
25
+ case 'hook': return { kind: 'hook', reason: cause.reason }
26
+ default: return assertNever(cause)
27
+ }
28
+ ```
29
+
30
+ That function is called **inside the `catch`**. A value outside the union therefore
31
+ throws before the catch body assigns `turnEnds`, which is still at its `null`
32
+ initializer, and the `finally` appends unconditionally:
33
+
34
+ ```ts
35
+ let turnEnds: TurnEndReason | null = null // agent.ts:312
36
+ ...
37
+ } finally {
38
+ this.session.append('turn/end', { turn, reason: turnEnds! }) // agent.ts:387
39
+ }
40
+ ```
41
+
42
+ The non-null assertion on that line is what makes it compile, and the row lands as
43
+ `{"type":"turn/end","data":{"turn":N,"reason":null}}`. Nothing rejects it on the way:
44
+ the append-time validator inspects surface message slots, `request/header` and
45
+ `tool/result`, so `reason` is a TypeScript-only invariant. Every reader that
46
+ dereferences it then throws — the chat client does `location.turn.end?.data.reason.kind`
47
+ while rendering — and one such row blanks the entire conversation area, for every
48
+ session, including newly created ones.
49
+
50
+ Reported four times: discussions **#8832**, **#9156**, **#9646** and **#9699**.
51
+
52
+ ### The version boundary
53
+
54
+ A harness older than `0.1.7` had no such function. The turn's `catch` wrote
55
+ `{ kind: 'aborted', reason: signal.reason }` verbatim, so the same abuse produced a
56
+ malformed-but-readable row rather than a `null` one. From `0.1.7-rc.2` on, the same
57
+ abuse is fatal — which is why this is a **guard** and not a report: the shape of the
58
+ failure changed under callers that had not changed at all.
59
+
60
+ ### The two callers an entry guard cannot reach
61
+
62
+ `ReactLoopAgent.cancel` is the only aborter of the signals the loop owns — the
63
+ function's own comment says so — so shadowing that method covers every caller that
64
+ goes through the agent **instance**. Two do not:
65
+
66
+ - a caller that reaches the **class** rather than the instance
67
+ (`ReactLoopAgent.prototype.cancel.call(agent, cause)`, or a second copy of the
68
+ loop in the same process), which no observable hook hands over; and
69
+ - an instance that **refuses** the own `cancel` property, where the entry guard
70
+ reports that it is not in force and nothing protects the session at all.
71
+
72
+ Both end with the same unreadable row. That is what the second half of this plugin
73
+ is for.
74
+
75
+ ## What it does
76
+
77
+ ### 1. Normalize the cause at the entry
78
+
79
+ It listens to `agent/created` — the serial, awaited lifecycle edge that hands over the
80
+ **live agent instance** (`dsh-agent/src/index.ts:550`) — and shadows that instance's
81
+ `cancel` with a wrapper that normalizes anything outside the union into
82
+ `{ kind: 'hook', reason: <the value's own name> }` before the loop ever sees it.
83
+
84
+ - **`hook` is the right member, not a convenient one.** It is the member the union
85
+ reserves for plugins: a plugin did cancel the turn, and the reason names which one.
86
+ - **One property write covers every instance-level caller.** `ReactLoopAgent.cancel`
87
+ is a prototype method, so an **own** property of that name shadows it for the loop's
88
+ own `agent.cancel({ kind: 'user' })`, the chat client's stop button, and any
89
+ third-party plugin alike.
90
+ - **A valid cause is forwarded by reference.** The object the loop reads back out of
91
+ the signal is the object the caller passed; nothing is copied and nothing is
92
+ reported.
93
+ - **A cancellation is never refused.** The wrapper normalizes, forwards and reports,
94
+ in that order, and touches nothing but the cause — a plugin that stopped a stop
95
+ would be worse than the defect.
96
+ - **Two shapes are covered, and only one of them looks like a mistake.** An object
97
+ whose `kind` is missing or unknown (`{ kind: 'thinking-loop' }`), and a value that
98
+ is not an object at all — most often `undefined`, from a caller whose own
99
+ declaration made the argument optional, where `cause.kind` throws one line earlier.
100
+ - **The wrapper binds the original to the agent it came from**, so a caller holding a
101
+ detached `const cancel = agent.cancel` still reaches the method with the receiver
102
+ its body requires.
103
+
104
+ ### 2. Backstop the row at the write
105
+
106
+ The same lifecycle edge hands over the **live session**, and `Session.append` is a
107
+ prototype method of it — so one own-property write shadows the append path for every
108
+ writer, the loop's own rows included. Only one event type is inspected.
109
+
110
+ - **The row it writes is the row the loop meant to write.** When a `turn/end` arrives
111
+ whose reason no reader can dereference, the backstop commits
112
+ `{ kind: 'aborted', reason: { kind: 'hook', reason: <text> } }` instead: the turn
113
+ *was* interrupted by a cancellation, `aborted` is a member every reader knows, and
114
+ `hook` is the member that says the cancellation came from outside the union.
115
+ - **It does not invent a variant.** `TurnEndReasonMap` is merge-extensible, so a new
116
+ `kind` would be a value the readers have never seen — trading a loud failure for a
117
+ silent one.
118
+ - **The test is dereferenceability, not membership.** A reason is left alone when it
119
+ is a non-null object carrying a non-empty string `kind`, because a plugin may
120
+ legitimately extend the map with a variant this plugin knows nothing about. What is
121
+ refused is the shape no reader can read at all: `null`, absent, or not an object.
122
+ - **It never edits the caller's payload.** The replacement is a new object; the one
123
+ the loop handed over is left as it was.
124
+ - **Every other event is forwarded untouched**, whatever it carries, and a
125
+ `turn/end` whose payload is not an object is reported rather than rebuilt — the
126
+ event map promises `{ turn, reason }`, and writing the missing half would be
127
+ inventing a durable row.
128
+ - **It is switchable off** with `backstop: false`, for an operator who wants the
129
+ append path to be exactly the harness's.
130
+
131
+ Every cause rewrite and every repaired row is logged, with the offending value
132
+ rendered as JSON, so the producer can be found and fixed rather than tolerated.
133
+
134
+ ## Install
135
+
136
+ ```sh
137
+ npm install @argszero/dsh-cancel-cause-guard
138
+ ```
139
+
140
+ Then mount it from your profile's bundle patch (`dsh.bundle.patch`):
141
+
142
+ ```yaml
143
+ - insert:
144
+ - id: cancel-cause-guard
145
+ name: '@argszero/dsh-cancel-cause-guard'
146
+ ```
147
+
148
+ Or, if you compose `cordis.yml` by hand:
149
+
150
+ ```yaml
151
+ - set:
152
+ - id: cancel-cause-guard
153
+ name: '@argszero/dsh-cancel-cause-guard'
154
+ config:
155
+ mode: normalize
156
+ ```
157
+
158
+ ## Configuration
159
+
160
+ | Key | Default | Meaning |
161
+ |---|---|---|
162
+ | `mode` | `'normalize'` | `normalize` rewrites an out-of-union cause to `{ kind: 'hook', reason }` and repairs an unreadable `turn/end` reason; `observe` passes both through untouched and only records what arrived. |
163
+ | `reason` | absent | Reason text to record in place of the value's own name. Absent names the cause after the value that produced it — the only handle on whoever produced it — and names a repaired row after what it is: a cancellation the entry guard never saw. |
164
+ | `maxReasonChars` | `200` | Bound on the minted reason, in characters. |
165
+ | `backstop` | `true` | Whether the durable backstop is installed as well. `false` leaves the session's own `append` untouched. |
166
+
167
+ A configuration the plugin cannot honour is refused **at mount** rather than quietly
168
+ replaced: an unknown key, a mode this plugin does not have, a `reason` that would name
169
+ nothing, a bound that is not a positive integer, or a `backstop` that is not a boolean.
170
+ A misspelled `mode` that mounts and guards nothing is how an operator believes a guard
171
+ is in force when it is not.
172
+
173
+ ## Diagnostics
174
+
175
+ One warning per rewritten (or, in `observe` mode, merely reported) cause:
176
+
177
+ ```
178
+ cancel-cause-guard: session <id>: a caller passed "thinking-loop" as a cancellation
179
+ cause, which is not a member of AgentCancelCause; the turn was recorded as
180
+ { "kind": "aborted", "reason": { "kind": "hook", "reason": "thinking-loop" } }.
181
+ Which plugin passes it is the value itself: the reason carries its own name.
182
+ ```
183
+
184
+ One warning per repaired `turn/end` row, in a deliberately different sentence — a row
185
+ this arm repaired is a cancellation the **entry guard never saw**, and an operator has
186
+ to be able to tell the two apart:
187
+
188
+ ```
189
+ cancel-cause-guard: session <id>: a turn/end row for turn 4 reached the log with a
190
+ reason no reader can dereference (null); it was recorded as { "kind": "aborted",
191
+ "reason": { "kind": "hook", "reason": "a cancellation cause the entry guard never
192
+ saw" } }. The cancellation never passed the entry guard, so its cause is not in this
193
+ record: a caller reached the cancel method through the class rather than through the
194
+ instance, a second copy of the loop aborted a signal of its own, or the instance
195
+ refused the guard.
196
+ ```
197
+
198
+ `observe` mode says the value (or the row) was passed through instead, so the operator
199
+ sees the real failure and the log does not claim a repair.
200
+
201
+ Four refusals are warned about at the moment they matter, because a guard that is
202
+ silently absent is the thing an operator cannot see:
203
+
204
+ - an agent that carries no `cancel` method at all;
205
+ - an agent that refuses an own `cancel` property (a sealed or non-writable instance),
206
+ where the install is confirmed by **reading the property back**, not by the absence
207
+ of a throw — the durable backstop covers this agent's session all the same;
208
+ - a session that carries no `append` method;
209
+ - a session that refuses an own `append` property.
210
+
211
+ ## What it is not
212
+
213
+ - **It cannot repair a session that already carries a `null` row.** Those rows are on
214
+ disk and this plugin reads no history. Mounting it bounds the defect from that point
215
+ on; the sessions that already have one stay unreadable until the row is removed.
216
+ - **It does not patch the unions away.** Causes that are already members are neither
217
+ copied, rewritten, nor reported, and a `turn/end` reason a reader can dereference is
218
+ forwarded as it is, whatever variant it names.
219
+ - **It calls no model and needs no service.** `inject` is empty: the whole seam is the
220
+ agent instance and the session the lifecycle edge delivers.
221
+ - **It writes no history.** Both arms only affect rows that are about to be committed;
222
+ a row already in the log is never read, rewritten or deleted.
223
+
224
+ ## Evidence
225
+
226
+ - `test/lifecycle.spec.mjs` — the plugin mounted through a real Cordis context, against
227
+ the real `AgentRegistry` and the real `agent/created` dispatch, on a real
228
+ `@deepseek-ai/dsh-session` `Session`: the guard and the backstop are installed by the
229
+ time the announce dispatch settles, neither touches an agent published before the
230
+ mount, unmounting restores both properties (when the fiber disposes and when the
231
+ agent does), a sealed instance is still backstopped at the write, and
232
+ `world({ mounted: false })` is the control arm for each — including the arm that
233
+ writes the `{"turn":1,"reason":null}` row **unmounted** and reads it back out of the
234
+ log, which is the defect reproduced against the shipped class rather than described.
235
+ - `test/backstop.spec.mjs` — the second mechanism: an own property over a prototype
236
+ method, the row it writes for each unreadable shape, what it leaves alone (a readable
237
+ reason by reference, a plugin's own variant, every other event type), the payload it
238
+ does not mutate, and every refusal.
239
+ - `test/guard.spec.mjs` — the first mechanism: an own property over a prototype method,
240
+ the options handed through untouched, the original bound to its agent, the mark a
241
+ second mount recognises, the undo that reveals the prototype method again rather than
242
+ copying it in, and every refusal.
243
+ - `test/cause.spec.mjs` — the pure half: what each shape becomes, what it is called, the
244
+ bound, and the values that must never throw inside `cancel`.
245
+ - `test/config.spec.mjs` — the defaults, detaching, and the refusals.
246
+ - `scripts/witness-cancel-cause.mjs` — the consequence, proved against the **published**
247
+ `@deepseek-ai/dsh-agent-loop` sources rather than a stand-in: it extracts
248
+ `abortedCancelCause` and `assertNever` from the shipped tarball, shows a bare-string
249
+ cause throwing `unreachable variant: "thinking-loop"` unguarded and returning
250
+ `{ kind: 'hook', reason: 'thinking-loop' }` guarded, and records the `0.1.6-alpha.2`
251
+ boundary where no such function exists. Run it with
252
+ `npm run witness:cancel-cause`.
253
+ - `npm run test:inject` — mutates the source, rebuilds, and requires each mutation to be
254
+ caught by the suite. An arm whose mutation leaves the suite green proves nothing about
255
+ the test that was supposed to hold the line, and is reported as `SILENT`.
256
+ - `npm run probe:lines` — runs the suite once per released line, each with every harness
257
+ package pinned to that line. That measurement is where the supported-lines list below
258
+ comes from, and it covers the backstop's seam too: the suite's own session is the
259
+ line's published `Session`, and the unmounted control arm asserts that this line's
260
+ write path accepts `reason: null`.
261
+
262
+ ## Supported lines
263
+
264
+ The suite is measured green against each of these, with `peerDependencies` restricted to
265
+ the same three:
266
+
267
+ | Harness line | Cordis series |
268
+ |---|---|
269
+ | `0.1.7-rc.2` | `4.0.4` |
270
+ | `0.2.0-rc.2` | `4.0.4` |
271
+ | `0.2.1-alpha.2` | `~4.0.5-alpha.1` |
272
+
273
+ Earlier generations are **not** covered and are not claimed: `0.1.6-alpha.2` and below
274
+ have no `abortedCancelCause`, so there is nothing for this plugin to prevent there.
275
+
276
+ ## License
277
+
278
+ MIT.
@@ -0,0 +1,72 @@
1
+ # The @argszero/dsh-cancel-cause-guard bundle patch: mount and go.
2
+ #
3
+ # One `turn/end` row can make a whole conversation view unreadable. When a caller
4
+ # cancels a turn with a value outside the closed AgentCancelCause union — a bare
5
+ # string such as `agent.cancel('thinking-loop')`, or an object whose `kind` the
6
+ # union does not have — ReactLoopAgent.cancel hands it to `AbortController.abort()`,
7
+ # so it becomes `signal.reason`. The turn's `catch` reads it back through
8
+ # `abortedCancelCause`, whose switch ends in `assertNever` **inside the catch body**:
9
+ # the assignment never runs, `turnEnds` stays at its `null` initializer, and the
10
+ # unconditional `finally` appends `turn/end` with that `null`
11
+ # (`dsh-agent-loop/src/agent.ts:385`, and the non-null assertion on the line is what
12
+ # makes it compile).
13
+ #
14
+ # Nothing on the write path refuses the row — the append-time validator inspects
15
+ # surface message slots, `request/header` and `tool/result`, and `turn/end.reason` is
16
+ # a TypeScript-only invariant. Every reader that dereferences it then throws, and the
17
+ # chat client does exactly that while rendering, so one such row blanks the
18
+ # conversation area. Reported four times: discussions #8832, #9156, #9646 and #9699.
19
+ #
20
+ # A harness older than `0.1.7` had no such function and recorded the same abuse as a
21
+ # malformed-but-readable row, which is why this is a guard: the failure changed shape
22
+ # under callers that had not changed at all.
23
+ #
24
+ # This plugin closes the defect at two depths. **At the entry**, it listens to
25
+ # `agent/created` — the serial, awaited lifecycle edge that hands over the **live agent
26
+ # instance** (`dsh-agent/src/index.ts:550`) — and shadows that instance's `cancel` with
27
+ # a wrapper that carries anything outside the union as `{ kind: 'hook', reason: <the
28
+ # value's own name> }` before the loop ever sees it. `hook` is the member the union
29
+ # reserves for plugins, so the rewrite is not a lie: a plugin did cancel the turn, and
30
+ # the reason names which one. One own-property write covers every instance-level caller,
31
+ # because `ReactLoopAgent.cancel` is a prototype method.
32
+ #
33
+ # **At the write**, the same edge hands over the **live session**, and the plugin
34
+ # shadows that session's `append` too — so a `turn/end` row whose reason no reader can
35
+ # dereference is committed as `{ kind: 'aborted', reason: { kind: 'hook', reason: … } }`
36
+ # instead. That arm covers the callers the entry guard cannot reach: one that goes
37
+ # through the class rather than the instance
38
+ # (`ReactLoopAgent.prototype.cancel.call(agent, …)`, or a second copy of the loop in the
39
+ # process), and an instance that refuses the own `cancel` property.
40
+ #
41
+ # It never refuses a cancellation, forwards an already-valid cause by reference, leaves
42
+ # any reason a reader can dereference untouched (a plugin's own `TurnEndReasonMap`
43
+ # variant included), and cannot repair rows already on disk (it reads no history).
44
+ # Mounting it bounds the defect from that point on.
45
+ #
46
+ # Optional config:
47
+ #
48
+ # - set:
49
+ # - id: cancel-cause-guard
50
+ # config:
51
+ # # 'normalize' (default) rewrites an out-of-union cause to
52
+ # # { kind: 'hook', reason }. 'observe' passes the caller's value through
53
+ # # untouched — the turn fails exactly as it would have — and only records
54
+ # # the producer in the log.
55
+ # mode: normalize
56
+ # # Reason text to record in place of the offending value's own name.
57
+ # # Absent (the default) names the cause after the value that produced it,
58
+ # # which is the only handle on whoever produced it.
59
+ # # reason: plugin-bug
60
+ # # Bound on the minted reason, in characters. Defaults to 200.
61
+ # maxReasonChars: 200
62
+ # # Whether the durable backstop is installed as well (default true).
63
+ # # `false` leaves the session's own `append` untouched, so only the entry
64
+ # # guard is in force — set it if another plugin already owns that method.
65
+ # backstop: true
66
+ #
67
+ # An unknown key is refused rather than ignored: a misspelled `mode` that mounts and
68
+ # guards nothing is how an operator believes a guard is in force when it is not.
69
+
70
+ - insert:
71
+ - id: cancel-cause-guard
72
+ name: '@argszero/dsh-cancel-cause-guard'
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The durable backstop: the `turn/end` row is never committed with a reason the
3
+ * conversation view cannot read.
4
+ *
5
+ * ## Why the cause guard alone is not enough
6
+ *
7
+ * The entry guard (`./guard.ts`) normalizes a cause before the loop ever sees it,
8
+ * and it covers every caller that goes through the **instance** method — which is
9
+ * every caller in the shipped tree. Two callers it cannot cover remain, and both
10
+ * end with the same unreadable row:
11
+ *
12
+ * - a caller that reaches the class rather than the instance
13
+ * (`ReactLoopAgent.prototype.cancel.call(agent, cause)`, or a second copy of the
14
+ * loop in the same process), which no observable hook hands over; and
15
+ * - an agent instance that refuses the own `cancel` property, where the entry
16
+ * guard reports `not-writable` and is simply absent.
17
+ *
18
+ * In both cases the out-of-union cause reaches `abortedCancelCause`'s
19
+ * `assertNever` **inside the turn's `catch`**, `turnEnds` stays at its `null`
20
+ * initializer, and the unconditional `finally` commits
21
+ * `{"type":"turn/end","data":{"turn":N,"reason":null}}`. The write path accepts it
22
+ * — `validateSessionEventData` inspects surface message slots, `request/header`
23
+ * and `tool/result`, and `turn/end.reason` is a TypeScript-only invariant — so
24
+ * the row is durable before any reader can object. The chat client then
25
+ * dereferences `reason.kind` while rendering and throws, which blanks the whole
26
+ * conversation area, for every session, across restarts.
27
+ *
28
+ * ## The seam
29
+ *
30
+ * `Session.append` is a **prototype** method of the session the agent holds, so an
31
+ * own-property write on `agent.session` shadows it for every writer — the loop's
32
+ * own `turn/start` / `step/start` / `turn/end` and any plugin's, alike. Only one
33
+ * event type is inspected; everything else is forwarded untouched.
34
+ *
35
+ * ## What it writes
36
+ *
37
+ * It does not invent a variant. A new `kind` would be a value the readers switch
38
+ * over have never seen, which trades a loud failure for a silent one — and
39
+ * `TurnEndReasonMap` is merge-extensible, so this plugin cannot even enumerate
40
+ * the members. It writes instead **the row the loop meant to write**: the turn was
41
+ * interrupted by a cancellation, and the cause came from outside the closed union,
42
+ * so it is `{ kind: 'aborted', reason: { kind: 'hook', reason: <text> } }` —
43
+ * `aborted` is a member every reader knows, and `hook` is the member
44
+ * `AgentCancelCause` reserves for whoever outside the union cancelled the turn.
45
+ *
46
+ * The test is deliberately **dereferenceability, not membership**: a reason is
47
+ * left alone when it is a non-null object carrying a non-empty string `kind`,
48
+ * because a plugin may legitimately extend `TurnEndReasonMap` with a variant this
49
+ * plugin knows nothing about. What is refused is the shape no reader can read at
50
+ * all — `null`, absent, or not an object.
51
+ *
52
+ * @module @argszero/dsh-cancel-cause-guard/backstop
53
+ */
54
+ import { truncate } from "./cause.js";
55
+ /**
56
+ * The brand an installed append wrapper carries.
57
+ *
58
+ * `Symbol.for` for the reason {@link GUARD_MARK} uses it: two copies of this
59
+ * plugin in one process must recognise each other's work rather than stack.
60
+ */
61
+ export const APPEND_GUARD_MARK = Symbol.for('argszero.dsh-cancel-cause-guard/append-guarded');
62
+ /** The event type this backstop inspects, and the only one it rewrites. */
63
+ const TURN_END = 'turn/end';
64
+ /**
65
+ * The reason text for a row whose reason carried nothing at all.
66
+ *
67
+ * Named for what the operator has to know: the cancellation did not pass the
68
+ * entry guard, so its cause was never seen and cannot be named.
69
+ */
70
+ export const UNSEEN_CAUSE_REASON = 'a cancellation cause the entry guard never saw';
71
+ /**
72
+ * Whether a value is a reason a reader can dereference.
73
+ *
74
+ * The whole consequence of the defect is one expression — `reason.kind` — so the
75
+ * property this asks about is exactly that one: an object, not null, whose `kind`
76
+ * is non-empty text. Membership in `TurnEndReasonMap` is deliberately **not**
77
+ * asked, because the map is merge-extensible and a plugin's own variant would
78
+ * then be rewritten by a plugin that had never heard of it.
79
+ * @param reason - the value the loop is about to commit as `turn/end.reason`.
80
+ * @returns whether a reader may dereference `reason.kind`.
81
+ */
82
+ export function isReadableTurnEndReason(reason) {
83
+ if (typeof reason !== 'object' || reason === null)
84
+ return false;
85
+ const kind = readKind(reason);
86
+ return typeof kind === 'string' && kind.trim().length > 0;
87
+ }
88
+ /**
89
+ * The reason to commit for a row whose own reason is unreadable.
90
+ *
91
+ * The configured reason wins when the operator set one; otherwise the text says
92
+ * the one thing that is certainly true — no entry guard saw this cancellation.
93
+ * @param options - the configured reason and its bound.
94
+ * @returns the reason text for the replacement row.
95
+ */
96
+ export function unseenCauseReason(options) {
97
+ const text = options.reason ?? UNSEEN_CAUSE_REASON;
98
+ return truncate(text, options.maxReasonChars);
99
+ }
100
+ /**
101
+ * Shadow one session's `append` so an unreadable `turn/end` reason never lands.
102
+ *
103
+ * Every refusal is reported as an outcome rather than thrown: a session this
104
+ * plugin could not guard is a session that keeps the defect, not a reason to fail
105
+ * the lifecycle edge that delivered it.
106
+ * @param session - the live session the agent appends to.
107
+ * @param options - the mode, the reason override, the bound, and the report callback.
108
+ * @returns the outcome and an undo.
109
+ */
110
+ export function guardSessionAppend(session, options) {
111
+ if (session === undefined || session === null)
112
+ return { outcome: 'no-append', restore: () => { } };
113
+ const current = session.append;
114
+ if (typeof current !== 'function')
115
+ return { outcome: 'no-append', restore: () => { } };
116
+ if (current[APPEND_GUARD_MARK] === true) {
117
+ return { outcome: 'already-guarded', restore: () => { } };
118
+ }
119
+ const original = current;
120
+ // Whether the property was the instance's own before the guard: a prototype
121
+ // method must be revealed again by deleting the shadow, an own property by
122
+ // writing the old value back.
123
+ const hadOwn = Object.prototype.hasOwnProperty.call(session, 'append');
124
+ const wrapper = function guardedAppend(...args) {
125
+ const decided = decide(args, options);
126
+ if (decided === undefined)
127
+ return Reflect.apply(original, session, args);
128
+ options.onRepaired(decided.report);
129
+ // In `observe` mode the report is the whole contribution: the unreadable row
130
+ // is committed exactly as it would have been, so the operator sees the real
131
+ // failure, and the log names it rather than claiming a repair.
132
+ if (options.mode === 'observe')
133
+ return Reflect.apply(original, session, args);
134
+ const forwarded = args.slice();
135
+ forwarded[1] = { ...args[1], reason: decided.reason };
136
+ return Reflect.apply(original, session, forwarded);
137
+ };
138
+ wrapper[APPEND_GUARD_MARK] = true;
139
+ try {
140
+ ;
141
+ session.append = wrapper;
142
+ }
143
+ catch {
144
+ // A sealed session, or an `append` defined without a setter.
145
+ return { outcome: 'not-writable', restore: () => { } };
146
+ }
147
+ // A refusal to write is not always a throw — a frozen object in sloppy mode
148
+ // would simply ignore it — so the install is confirmed by reading it back.
149
+ if (session.append !== wrapper) {
150
+ return { outcome: 'not-writable', restore: () => { } };
151
+ }
152
+ let installed = true;
153
+ return {
154
+ outcome: 'patched',
155
+ restore: () => {
156
+ if (!installed)
157
+ return;
158
+ installed = false;
159
+ // Only unwind a shadow that is still ours: a later guard, or the session
160
+ // itself, may have replaced it, and undoing that would be a third party's
161
+ // state being written by this one.
162
+ if (session.append !== wrapper)
163
+ return;
164
+ if (hadOwn)
165
+ session.append = original;
166
+ else
167
+ delete session.append;
168
+ },
169
+ };
170
+ }
171
+ /**
172
+ * What one `append` call should become, if anything.
173
+ *
174
+ * Only `turn/end` with an object payload is eligible. A payload that is not an
175
+ * object is left alone rather than rebuilt: the event map promises `{ turn, reason }`,
176
+ * and a setter that invented the missing half would be writing a row of its own
177
+ * making into a durable log. It is reported instead.
178
+ * @param args - the arguments the caller passed to `append`.
179
+ * @param options - the configured reason and its bound.
180
+ * @returns the reason to commit and the report, or undefined when nothing changes.
181
+ */
182
+ function decide(args, options) {
183
+ if (args[0] !== TURN_END)
184
+ return undefined;
185
+ const data = args[1];
186
+ if (typeof data !== 'object' || data === null || Array.isArray(data))
187
+ return undefined;
188
+ const reason = data.reason;
189
+ if (isReadableTurnEndReason(reason))
190
+ return undefined;
191
+ return {
192
+ reason: { kind: 'aborted', reason: { kind: 'hook', reason: unseenCauseReason(options) } },
193
+ report: {
194
+ turn: data.turn,
195
+ original: reason,
196
+ normalized: { kind: 'hook', reason: unseenCauseReason(options) },
197
+ described: describeReason(reason),
198
+ },
199
+ };
200
+ }
201
+ /** A rendering of the unreadable reason, for the log line. */
202
+ function describeReason(reason) {
203
+ if (reason === undefined)
204
+ return 'absent';
205
+ if (reason === null)
206
+ return 'null';
207
+ const kind = typeof reason === 'object' ? readKind(reason) : undefined;
208
+ if (typeof kind === 'string' && kind.trim().length > 0)
209
+ return `no kind string (${String(kind)})`;
210
+ return Object.prototype.toString.call(reason);
211
+ }
212
+ /** The `kind` a value carries, read without letting a hostile accessor escape. */
213
+ function readKind(value) {
214
+ try {
215
+ return value.kind;
216
+ }
217
+ catch {
218
+ // It runs inside `append`: a guard that threw while inspecting a payload
219
+ // would abort the append in the guard rather than in its caller.
220
+ return undefined;
221
+ }
222
+ }