@argszero/dsh-cancel-cause-guard 0.0.0-stage → 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/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,207 @@
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, so the
12
+ turn ends with a readable reason instead of `null`.
13
+
14
+ ## The defect
15
+
16
+ `ReactLoopAgent.cancel` passes its argument straight to `AbortController.abort()`, so
17
+ the cause becomes `signal.reason`. The turn's `catch` reads it back through
18
+ `abortedCancelCause` (`dsh-agent-loop/src/agent.ts:80`):
19
+
20
+ ```ts
21
+ const cause = signal.reason as AgentCancelCause
22
+ switch (cause.kind) {
23
+ case 'user': case 'parent': case 'disposed': return { kind: cause.kind }
24
+ case 'hook': return { kind: 'hook', reason: cause.reason }
25
+ default: return assertNever(cause)
26
+ }
27
+ ```
28
+
29
+ That function is called **inside the `catch`**. A value outside the union therefore
30
+ throws before the catch body assigns `turnEnds`, which is still at its `null`
31
+ initializer, and the `finally` appends unconditionally:
32
+
33
+ ```ts
34
+ let turnEnds: TurnEndReason | null = null // agent.ts:310
35
+ ...
36
+ } finally {
37
+ this.session.append('turn/end', { turn, reason: turnEnds! }) // agent.ts:385
38
+ }
39
+ ```
40
+
41
+ The non-null assertion on that line is what makes it compile, and the row lands as
42
+ `{"type":"turn/end","data":{"turn":N,"reason":null}}`. Nothing rejects it on the way:
43
+ the append-time validator inspects surface message slots, `request/header` and
44
+ `tool/result`, so `reason` is a TypeScript-only invariant. Every reader that
45
+ dereferences it then throws — the chat client does `location.turn.end?.data.reason.kind`
46
+ while rendering — and one such row blanks the entire conversation area, for every
47
+ session, including newly created ones.
48
+
49
+ Reported three times: discussions **#8832**, **#9156** and **#9646**.
50
+
51
+ ### The version boundary
52
+
53
+ A harness older than `0.1.7` had no such function. The turn's `catch` wrote
54
+ `{ kind: 'aborted', reason: signal.reason }` verbatim, so the same abuse produced a
55
+ malformed-but-readable row rather than a `null` one. From `0.1.7-rc.2` on, the same
56
+ abuse is fatal — which is why this is a **guard** and not a report: the shape of the
57
+ failure changed under callers that had not changed at all.
58
+
59
+ ## What it does
60
+
61
+ It listens to `agent/created` — the serial, awaited lifecycle edge that hands over the
62
+ **live agent instance** (`dsh-agent/src/index.ts:550`) — and shadows that instance's
63
+ `cancel` with a wrapper that normalizes anything outside the union into
64
+ `{ kind: 'hook', reason: <the value's own name> }` before the loop ever sees it.
65
+
66
+ - **`hook` is the right member, not a convenient one.** It is the member the union
67
+ reserves for plugins: a plugin did cancel the turn, and the reason names which one.
68
+ - **One property write covers every instance-level caller.** `ReactLoopAgent.cancel`
69
+ is a prototype method, so an **own** property of that name shadows it for the loop's
70
+ own `agent.cancel({ kind: 'user' })`, the chat client's stop button, and any
71
+ third-party plugin alike.
72
+ - **A valid cause is forwarded by reference.** The object the loop reads back out of
73
+ the signal is the object the caller passed; nothing is copied and nothing is
74
+ reported.
75
+ - **A cancellation is never refused.** The wrapper normalizes, forwards and reports,
76
+ in that order, and touches nothing but the cause — a plugin that stopped a stop
77
+ would be worse than the defect.
78
+ - **Two shapes are covered, and only one of them looks like a mistake.** An object
79
+ whose `kind` is missing or unknown (`{ kind: 'thinking-loop' }`), and a value that
80
+ is not an object at all — most often `undefined`, from a caller whose own
81
+ declaration made the argument optional, where `cause.kind` throws one line earlier.
82
+ - **The wrapper binds the original to the agent it came from**, so a caller holding a
83
+ detached `const cancel = agent.cancel` still reaches the method with the receiver
84
+ its body requires.
85
+
86
+ Every rewrite is logged with the offending value rendered as JSON, so the producer can
87
+ be found and fixed rather than tolerated.
88
+
89
+ ## Install
90
+
91
+ ```sh
92
+ npm install @argszero/dsh-cancel-cause-guard
93
+ ```
94
+
95
+ Then mount it from your profile's bundle patch (`dsh.bundle.patch`):
96
+
97
+ ```yaml
98
+ - insert:
99
+ - id: cancel-cause-guard
100
+ name: '@argszero/dsh-cancel-cause-guard'
101
+ ```
102
+
103
+ Or, if you compose `cordis.yml` by hand:
104
+
105
+ ```yaml
106
+ - set:
107
+ - id: cancel-cause-guard
108
+ name: '@argszero/dsh-cancel-cause-guard'
109
+ config:
110
+ mode: normalize
111
+ ```
112
+
113
+ ## Configuration
114
+
115
+ | Key | Default | Meaning |
116
+ |---|---|---|
117
+ | `mode` | `'normalize'` | `normalize` rewrites an out-of-union cause to `{ kind: 'hook', reason }`; `observe` passes the caller's value through untouched and only records the producer. |
118
+ | `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. |
119
+ | `maxReasonChars` | `200` | Bound on the minted reason, in characters. |
120
+
121
+ A configuration the plugin cannot honour is refused **at mount** rather than quietly
122
+ replaced: an unknown key, a mode this plugin does not have, a `reason` that would name
123
+ nothing, or a bound that is not a positive integer. A misspelled `mode` that mounts and
124
+ guards nothing is how an operator believes a guard is in force when it is not.
125
+
126
+ ## Diagnostics
127
+
128
+ One warning per rewritten (or, in `observe` mode, merely reported) cause:
129
+
130
+ ```
131
+ cancel-cause-guard: session <id>: a caller passed "thinking-loop" as a cancellation
132
+ cause, which is not a member of AgentCancelCause; the turn was recorded as
133
+ { "kind": "aborted", "reason": { "kind": "hook", "reason": "thinking-loop" } }.
134
+ Which plugin passes it is the value itself: the reason carries its own name.
135
+ ```
136
+
137
+ `observe` mode says the value was passed through instead, so the operator sees the
138
+ real failure and the log does not claim a repair.
139
+
140
+ Two refusals are warned about at the moment they matter, because a guard that is
141
+ silently absent is the thing an operator cannot see:
142
+
143
+ - an agent that carries no `cancel` method at all;
144
+ - an agent that refuses an own `cancel` property (a sealed or non-writable instance),
145
+ where the install is confirmed by **reading the property back**, not by the absence
146
+ of a throw.
147
+
148
+ ## What it is not
149
+
150
+ - **It cannot repair a session that already carries a `null` row.** Those rows are on
151
+ disk and this plugin reads no history. Mounting it bounds the defect from that point
152
+ on; the sessions that already have one stay unreadable until the row is removed.
153
+ - **It does not cover a caller that reaches the class rather than the instance** —
154
+ `ReactLoopAgent.prototype.cancel.call(agent, …)`, or a second copy of the loop.
155
+ Nothing observable hands that path over, which is why `mode: 'observe'` exists: an
156
+ operator who wants to see the failure happen may mount this plugin as a reporter and
157
+ still get the producer named in the log.
158
+ - **It does not patch the union away.** Values that are already members are neither
159
+ copied, rewritten, nor reported.
160
+ - **It calls no model and needs no service.** `inject` is empty: the whole seam is the
161
+ agent instance the lifecycle edge delivers.
162
+
163
+ ## Evidence
164
+
165
+ - `test/lifecycle.spec.mjs` — the plugin mounted through a real Cordis context, against
166
+ the real `AgentRegistry` and the real `agent/created` dispatch: the guard is installed
167
+ by the time the announce dispatch settles, it does not touch an agent published before
168
+ the mount, unmounting restores the instance (both when the fiber disposes and when the
169
+ agent does), and `world({ mounted: false })` is the control arm for each.
170
+ - `test/guard.spec.mjs` — the mechanism: an own property over a prototype method, the
171
+ options handed through untouched, the original bound to its agent, the mark a second
172
+ mount recognises, the undo that reveals the prototype method again rather than copying
173
+ it in, and every refusal.
174
+ - `test/cause.spec.mjs` — the pure half: what each shape becomes, what it is called, the
175
+ bound, and the values that must never throw inside `cancel`.
176
+ - `test/config.spec.mjs` — the defaults, detaching, and the five refusals.
177
+ - `scripts/witness-cancel-cause.mjs` — the consequence, proved against the **published**
178
+ `@deepseek-ai/dsh-agent-loop` sources rather than a stand-in: it extracts
179
+ `abortedCancelCause` and `assertNever` from the shipped tarball, shows a bare-string
180
+ cause throwing `unreachable variant: "thinking-loop"` unguarded and returning
181
+ `{ kind: 'hook', reason: 'thinking-loop' }` guarded, and records the `0.1.6-alpha.2`
182
+ boundary where no such function exists. Run it with
183
+ `npm run witness:cancel-cause`.
184
+ - `npm run test:inject` — mutates the source, rebuilds, and requires each mutation to be
185
+ caught by the suite. An arm whose mutation leaves the suite green proves nothing about
186
+ the test that was supposed to hold the line, and is reported as `SILENT`.
187
+ - `npm run probe:lines` — runs the suite once per released line, each with every harness
188
+ package pinned to that line. That measurement is where the supported-lines list below
189
+ comes from.
190
+
191
+ ## Supported lines
192
+
193
+ The suite is measured green against each of these, with `peerDependencies` restricted to
194
+ the same three:
195
+
196
+ | Harness line | Cordis series |
197
+ |---|---|
198
+ | `0.1.7-rc.2` | `4.0.4` |
199
+ | `0.2.0-rc.2` | `4.0.4` |
200
+ | `0.2.1-alpha.2` | `~4.0.5-alpha.1` |
201
+
202
+ Earlier generations are **not** covered and are not claimed: `0.1.6-alpha.2` and below
203
+ have no `abortedCancelCause`, so there is nothing for this plugin to prevent there.
204
+
205
+ ## License
206
+
207
+ MIT.
@@ -0,0 +1,59 @@
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 three times: discussions #8832, #9156 and #9646.
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 seam that is left. It listens to `agent/created` — the
25
+ # serial, awaited lifecycle edge that hands over the **live agent instance**
26
+ # (`dsh-agent/src/index.ts:550`) — and shadows that instance's `cancel` with a wrapper
27
+ # that carries anything outside the union as `{ kind: 'hook', reason: <the value's own
28
+ # name> }` before the loop ever sees it. `hook` is the member the union reserves for
29
+ # plugins, so the rewrite is not a lie: a plugin did cancel the turn, and the reason
30
+ # names which one. One own-property write covers every instance-level caller, because
31
+ # `ReactLoopAgent.cancel` is a prototype method.
32
+ #
33
+ # It never refuses a cancellation, forwards an already-valid cause by reference, and
34
+ # cannot repair rows already on disk (it reads no history). Mounting it bounds the
35
+ # defect from that point on.
36
+ #
37
+ # Optional config:
38
+ #
39
+ # - set:
40
+ # - id: cancel-cause-guard
41
+ # config:
42
+ # # 'normalize' (default) rewrites an out-of-union cause to
43
+ # # { kind: 'hook', reason }. 'observe' passes the caller's value through
44
+ # # untouched — the turn fails exactly as it would have — and only records
45
+ # # the producer in the log.
46
+ # mode: normalize
47
+ # # Reason text to record in place of the offending value's own name.
48
+ # # Absent (the default) names the cause after the value that produced it,
49
+ # # which is the only handle on whoever produced it.
50
+ # # reason: plugin-bug
51
+ # # Bound on the minted reason, in characters. Defaults to 200.
52
+ # maxReasonChars: 200
53
+ #
54
+ # An unknown key is refused rather than ignored: a misspelled `mode` that mounts and
55
+ # guards nothing is how an operator believes a guard is in force when it is not.
56
+
57
+ - insert:
58
+ - id: cancel-cause-guard
59
+ name: '@argszero/dsh-cancel-cause-guard'
package/lib/cause.js ADDED
@@ -0,0 +1,199 @@
1
+ /**
2
+ * The closed union the harness writes into `turn/end`, and the normalization
3
+ * that makes any other value a member of it.
4
+ *
5
+ * ## Why a value outside the union is not a cosmetic problem
6
+ *
7
+ * `ReactLoopAgent`'s turn body reads the cancellation cause back out of the
8
+ * signal and switches on it (`abortedCancelCause`, `agent-loop/src/agent.ts:80`):
9
+ *
10
+ * ```ts
11
+ * const cause = signal.reason as AgentCancelCause
12
+ * switch (cause.kind) {
13
+ * case 'user': case 'parent': case 'disposed': return { kind: cause.kind }
14
+ * case 'hook': return { kind: 'hook', reason: cause.reason }
15
+ * default: return assertNever(cause)
16
+ * }
17
+ * ```
18
+ *
19
+ * That function is called **inside the turn's `catch`**. A value outside the
20
+ * union therefore throws before the catch body assigns `turnEnds`, which is
21
+ * still its `null` initializer, and the `finally` appends unconditionally:
22
+ *
23
+ * ```ts
24
+ * let turnEnds: TurnEndReason | null = null // agent.ts:310
25
+ * ...
26
+ * } finally {
27
+ * this.session.append('turn/end', { turn, reason: turnEnds! }) // agent.ts:385
28
+ * }
29
+ * ```
30
+ *
31
+ * The row lands on disk as `{"type":"turn/end","data":{"turn":N,"reason":null}}`.
32
+ * Nothing rejects it on the way: the append-time validator inspects surface
33
+ * message slots, `request/header` and `tool/result` only, so this field is a
34
+ * TypeScript-only invariant. Readers then dereference it — the chat client does
35
+ * `location.turn.end?.data.reason.kind`
36
+ * (`ui-chat/src/client/contract/turn-process.ts:72`) — and one such row throws
37
+ * while the conversation view renders, which blanks the whole chat area.
38
+ * Reported three times: discussions #8832, #9156 and #9646.
39
+ *
40
+ * ## Two shapes reach it, and only one of them looks like a mistake
41
+ *
42
+ * - an object whose `kind` is missing or unknown (`{ kind: 'thinking-loop' }`),
43
+ * which `assertNever` refuses; and
44
+ * - a value that is not an object at all — most often `undefined`, from a
45
+ * caller whose own declaration made the argument optional. `cause.kind` on
46
+ * `undefined` throws just as effectively, one line earlier.
47
+ *
48
+ * Both are normalized here. `hook` is the member the union reserves for
49
+ * plugins, so a normalized cause says what actually happened: a plugin
50
+ * cancelled the turn, and the reason names which one.
51
+ *
52
+ * @module @argszero/dsh-cancel-cause-guard/cause
53
+ */
54
+ /** Bound on a minted reason when the configuration names none. */
55
+ export const DEFAULT_MAX_REASON_CHARS = 200;
56
+ /** The reason text standing in for a cause that carried no name at all. */
57
+ export const UNSPECIFIED_REASON = 'unspecified';
58
+ /** The union member a value from outside the union is carried under. */
59
+ export const CARRIER_KIND = 'hook';
60
+ /**
61
+ * Whether a value is already exactly one of the four union members.
62
+ *
63
+ * `hook` is held to the stricter test: its `reason` is part of the member's
64
+ * contract, and a `hook` whose reason is not a string is normalized rather than
65
+ * passed through, so that everything downstream of this plugin can rely on the
66
+ * field being text.
67
+ * @param cause - the value a caller passed to `Agent.cancel`.
68
+ * @returns whether the value can be recorded as-is.
69
+ */
70
+ export function isCancelCause(cause) {
71
+ switch (readKind(cause)) {
72
+ case 'user':
73
+ case 'parent':
74
+ case 'disposed':
75
+ return true;
76
+ case 'hook':
77
+ return typeof readField(cause, 'reason') === 'string';
78
+ default:
79
+ return false;
80
+ }
81
+ }
82
+ /**
83
+ * Turn any cancellation cause into a member of the closed union.
84
+ *
85
+ * A value that is already a member is returned **unchanged** (same reference),
86
+ * so the common path allocates nothing and an `{ kind: 'user' }` a caller may
87
+ * still be holding is the object the loop reads back.
88
+ * @param cause - the value a caller passed to `Agent.cancel`.
89
+ * @param options - the reason override and the reason bound.
90
+ * @returns a member of `AgentCancelCause`.
91
+ */
92
+ export function normalizeCancelCause(cause, options = {}) {
93
+ if (isCancelCause(cause))
94
+ return cause;
95
+ const limit = options.maxReasonChars ?? DEFAULT_MAX_REASON_CHARS;
96
+ if (options.reason !== undefined && options.reason.length > 0) {
97
+ return { kind: CARRIER_KIND, reason: truncate(options.reason, limit) };
98
+ }
99
+ if (readKind(cause) === CARRIER_KIND) {
100
+ // The member is right; only its reason field is not text.
101
+ return { kind: CARRIER_KIND, reason: reasonText(readField(cause, 'reason'), limit) };
102
+ }
103
+ return { kind: CARRIER_KIND, reason: reasonText(cause, limit) };
104
+ }
105
+ /**
106
+ * The reason text for one value, preferring whatever name it carried.
107
+ *
108
+ * The preference order is deliberate: a caller that passed
109
+ * `{ kind: 'thinking-loop' }` meant the token `thinking-loop`, and a reason
110
+ * reading `thinking-loop` is more useful to the person reading the session than
111
+ * the JSON it was embedded in.
112
+ * @param value - the value to name, or the reason field of a `hook` cause.
113
+ * @param maxChars - bound on the result.
114
+ * @returns a non-empty, bounded string.
115
+ */
116
+ export function reasonText(value, maxChars = DEFAULT_MAX_REASON_CHARS) {
117
+ if (typeof value === 'string') {
118
+ return value.trim().length === 0 ? UNSPECIFIED_REASON : truncate(value, maxChars);
119
+ }
120
+ if (value === undefined || value === null)
121
+ return UNSPECIFIED_REASON;
122
+ const named = carriedKind(value);
123
+ if (named !== undefined)
124
+ return truncate(named, maxChars);
125
+ return truncate(plainText(value), maxChars);
126
+ }
127
+ /**
128
+ * A descriptive rendering of a value for a log line.
129
+ *
130
+ * Separate from {@link reasonText} because the log is read by whoever has to
131
+ * find the producer, and there the JSON of the offending value is the useful
132
+ * form — including the `undefined` that {@link reasonText} deliberately hides
133
+ * behind `unspecified`.
134
+ * @param value - the value to describe.
135
+ * @param maxChars - bound on the result.
136
+ * @returns a bounded, single-line rendering.
137
+ */
138
+ export function describeValue(value, maxChars = DEFAULT_MAX_REASON_CHARS) {
139
+ if (value === undefined)
140
+ return 'undefined';
141
+ if (value === null)
142
+ return 'null';
143
+ if (typeof value === 'string')
144
+ return truncate(JSON.stringify(value), maxChars);
145
+ return truncate(plainText(value), maxChars);
146
+ }
147
+ /** The `kind` a value carries, when that is a non-empty string. */
148
+ function carriedKind(value) {
149
+ const kind = readKind(value);
150
+ return typeof kind === 'string' && kind.trim().length > 0 ? kind : undefined;
151
+ }
152
+ /**
153
+ * One field of a value, read without letting a hostile accessor escape.
154
+ *
155
+ * It runs inside `cancel`: a guard that threw on the way to naming a cause would
156
+ * abort the turn in the guard rather than in its caller, which is strictly worse
157
+ * than the defect. A value whose `kind` cannot be read is therefore treated as a
158
+ * value with no `kind` at all.
159
+ */
160
+ function readField(value, field) {
161
+ if (typeof value !== 'object' || value === null)
162
+ return undefined;
163
+ try {
164
+ return value[field];
165
+ }
166
+ catch {
167
+ return undefined;
168
+ }
169
+ }
170
+ /** The `kind` a value carries, read through {@link readField}. */
171
+ function readKind(value) {
172
+ return readField(value, 'kind');
173
+ }
174
+ /** A string for any value, never throwing on the ones JSON refuses. */
175
+ function plainText(value) {
176
+ if (typeof value === 'function')
177
+ return `function ${value.name ?? ''}`.trim();
178
+ try {
179
+ const json = JSON.stringify(value);
180
+ if (typeof json === 'string' && json.length > 0)
181
+ return json;
182
+ }
183
+ catch {
184
+ // A circular structure, a BigInt, or a throwing `toJSON`.
185
+ }
186
+ try {
187
+ return String(value);
188
+ }
189
+ catch {
190
+ // A proxy or an object whose `toString` is itself hostile. This module runs
191
+ // inside `cancel`, and a guard that threw while naming a cause would be the
192
+ // defect it exists to prevent.
193
+ return UNSPECIFIED_REASON;
194
+ }
195
+ }
196
+ /** Bound one reason so a hostile value cannot write a wall of text into the log. */
197
+ function truncate(text, maxChars) {
198
+ return text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}…`;
199
+ }
package/lib/config.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Configuration resolution for the cancellation-cause guard.
3
+ *
4
+ * The surface is deliberately three fields. A guard that rewrites a value on a
5
+ * path as hot as cancellation has to be readable in one sitting, and every knob
6
+ * it does not have is a way for an operator to be surprised by what it did.
7
+ *
8
+ * An unknown key is refused rather than ignored: a misspelled `mode` that mounts
9
+ * and guards nothing is how an operator believes a guard is in force when it is
10
+ * not — the same refusal `dsh-tool-output-budget` makes, for the same reason.
11
+ *
12
+ * @module @argszero/dsh-cancel-cause-guard/config
13
+ */
14
+ import { DEFAULT_MAX_REASON_CHARS, UNSPECIFIED_REASON } from "./cause.js";
15
+ /** Defaults for a guard whose only job is to keep a session readable. */
16
+ export const DEFAULTS = Object.freeze({
17
+ mode: 'normalize',
18
+ reason: undefined,
19
+ maxReasonChars: DEFAULT_MAX_REASON_CHARS,
20
+ });
21
+ const CONFIG_KEYS = new Set(['mode', 'reason', 'maxReasonChars']);
22
+ const MODES = new Set(['normalize', 'observe']);
23
+ /**
24
+ * Resolve, validate and detach the guard configuration.
25
+ * @param config - raw plugin configuration, possibly empty.
26
+ * @returns a detached, frozen configuration.
27
+ * @throws {Error} when a key is unknown, a mode is not one this plugin has, a
28
+ * reason is not non-empty text, or the bound is not a positive integer.
29
+ */
30
+ export function resolveConfig(config = {}) {
31
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
32
+ throw new Error('cancel-cause-guard config: must be an object');
33
+ }
34
+ for (const key of Object.keys(config)) {
35
+ if (!CONFIG_KEYS.has(key)) {
36
+ throw new Error(`cancel-cause-guard config: unknown key "${key}" `
37
+ + '(allowed: mode, reason, maxReasonChars)');
38
+ }
39
+ }
40
+ const mode = config.mode ?? DEFAULTS.mode;
41
+ if (typeof mode !== 'string' || !MODES.has(mode)) {
42
+ throw new Error('cancel-cause-guard config: mode must be "normalize" or "observe"');
43
+ }
44
+ const maxReasonChars = config.maxReasonChars ?? DEFAULTS.maxReasonChars;
45
+ if (!Number.isSafeInteger(maxReasonChars) || maxReasonChars < 1) {
46
+ throw new Error('cancel-cause-guard config: maxReasonChars must be a positive integer');
47
+ }
48
+ // A reason override of `''` would make the normalized cause say nothing at
49
+ // all, which is the one outcome a *reason* field exists to prevent. `undefined`
50
+ // is the "name it after the value the caller passed" default; an empty string
51
+ // is a mistake, and is reported as one.
52
+ const reason = config.reason;
53
+ if (reason !== undefined && (typeof reason !== 'string' || reason.trim().length === 0)) {
54
+ throw new Error(`cancel-cause-guard config: reason must be non-empty text, or absent to name the cause `
55
+ + `after the value that produced it (the default resolves to "${UNSPECIFIED_REASON}" only `
56
+ + 'for a cause that carried no name)');
57
+ }
58
+ return Object.freeze({ mode, reason, maxReasonChars });
59
+ }
package/lib/guard.js ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * One agent's `cancel`, shadowed so that a cause from outside the closed union
3
+ * never reaches the loop's `assertNever`.
4
+ *
5
+ * ## Why the seam is the instance
6
+ *
7
+ * There is no event on the cancellation path and no hook in the append-time
8
+ * validator: `validateSessionEventData` inspects surface message slots,
9
+ * `request/header` and `tool/result`, and `turn/end.reason` is a TypeScript-only
10
+ * invariant, so nothing on the write path can refuse the row that poisons a
11
+ * session. What the plugin does have is the live agent instance, handed over by
12
+ * `agent/created` — a `ctx.serial` dispatch that `AgentRegistry.announce`
13
+ * **awaits** (`packages/core/agent/src/index.ts:550`), so a listener that
14
+ * settles before its first turn can still change how that agent behaves:
15
+ *
16
+ * ```ts
17
+ * await this.ctx.serial(entry.carrier, 'agent/created', { agent: entry.agent, source, signal })
18
+ * ```
19
+ *
20
+ * `ReactLoopAgent.cancel` is a prototype method (`agent-loop/src/agent.ts:175`),
21
+ * so an **own** property of that name shadows it for every instance-level
22
+ * caller — the loop's own `agent.cancel({ kind: 'user' })`
23
+ * (`agent/src/archive-admission.ts:36`, `agent-loop/src/index.ts:789`), the chat
24
+ * client's stop button, and any third-party plugin. All of them are covered by
25
+ * one property write, which is why this is a guard rather than a per-producer
26
+ * patch.
27
+ *
28
+ * ## What the wrapper does, and what it deliberately does not
29
+ *
30
+ * It normalizes, forwards, and reports — in that order, and nothing else. It
31
+ * does not refuse a cancellation (a plugin that stopped a stop would be worse
32
+ * than the defect), does not touch the options, and does not inspect or rewrite
33
+ * anything but the cause. A value that is already a union member is forwarded
34
+ * **by reference**, so the object the loop reads back out of the signal is the
35
+ * object the caller passed.
36
+ *
37
+ * The wrapper binds the original to the agent it came from. A caller holding a
38
+ * detached `const cancel = agent.cancel` would otherwise have reached the
39
+ * original with no receiver at all; binding it is the reading the method's own
40
+ * body requires (`this.phase`, `this.inbox`), and it makes the shadow more
41
+ * faithful than the property it replaces.
42
+ *
43
+ * @module @argszero/dsh-cancel-cause-guard/guard
44
+ */
45
+ import { describeValue, isCancelCause, normalizeCancelCause } from "./cause.js";
46
+ /**
47
+ * The brand an installed wrapper carries.
48
+ *
49
+ * `Symbol.for` rather than a module-local symbol: two copies of this plugin
50
+ * loaded into one process — the plugin mounted by two profile entries, or a
51
+ * second copy pulled in beside the first — would otherwise each install a
52
+ * wrapper, and the inner one would normalize a cause the outer had already
53
+ * normalized. With a registry-wide key the second mount recognises the first
54
+ * and leaves it alone.
55
+ */
56
+ export const GUARD_MARK = Symbol.for('argszero.dsh-cancel-cause-guard/guarded');
57
+ /**
58
+ * Shadow one agent's `cancel` with a normalizing wrapper.
59
+ *
60
+ * Every refusal is reported as an {@link GuardOutcome} rather than thrown: an
61
+ * agent this plugin could not guard is a session that keeps the defect, not a
62
+ * reason to fail the lifecycle edge that delivered it.
63
+ * @param agent - the live agent instance, as handed over by `agent/created`.
64
+ * @param options - the guard's behaviour and its report callback.
65
+ * @returns the outcome and an undo.
66
+ */
67
+ export function guardCancel(agent, options) {
68
+ const current = agent.cancel;
69
+ if (typeof current !== 'function')
70
+ return { outcome: 'no-cancel', restore: () => { } };
71
+ if (current[GUARD_MARK] === true) {
72
+ return { outcome: 'already-guarded', restore: () => { } };
73
+ }
74
+ const original = current;
75
+ // Whether the property was the instance's own before the guard: a prototype
76
+ // method must be revealed again by deleting the shadow, an own property by
77
+ // writing the old value back. Writing the prototype method back as an own
78
+ // property would look like a successful restore while changing the object.
79
+ const hadOwn = Object.prototype.hasOwnProperty.call(agent, 'cancel');
80
+ const wrapper = function guardedCancel(cause, cancelOptions) {
81
+ const decided = decide(cause, options);
82
+ Reflect.apply(original, agent, [decided, cancelOptions]);
83
+ };
84
+ wrapper[GUARD_MARK] = true;
85
+ try {
86
+ ;
87
+ agent.cancel = wrapper;
88
+ }
89
+ catch {
90
+ // A sealed instance, or a `cancel` the agent defined without a setter.
91
+ return { outcome: 'not-writable', restore: () => { } };
92
+ }
93
+ // A refusal to write is not always a throw — a frozen object in sloppy mode
94
+ // would simply ignore it — so the install is confirmed by reading it back.
95
+ if (agent.cancel !== wrapper) {
96
+ return { outcome: 'not-writable', restore: () => { } };
97
+ }
98
+ let installed = true;
99
+ return {
100
+ outcome: 'patched',
101
+ restore: () => {
102
+ if (!installed)
103
+ return;
104
+ installed = false;
105
+ // Only unwind a shadow that is still ours: a later guard, or the agent
106
+ // itself, may have replaced it, and undoing that would be a third party's
107
+ // state being written by this one.
108
+ if (agent.cancel !== wrapper)
109
+ return;
110
+ if (hadOwn)
111
+ agent.cancel = original;
112
+ else
113
+ delete agent.cancel;
114
+ },
115
+ };
116
+ }
117
+ /**
118
+ * The cause to forward, and the report that says why.
119
+ * @param cause - the value the caller passed.
120
+ * @param options - the guard's behaviour and its report callback.
121
+ * @returns the value to hand the original `cancel`.
122
+ */
123
+ function decide(cause, options) {
124
+ if (isCancelCause(cause))
125
+ return cause;
126
+ const normalized = normalizeCancelCause(cause, {
127
+ reason: options.reason,
128
+ maxReasonChars: options.maxReasonChars,
129
+ });
130
+ options.onNormalized({
131
+ original: cause,
132
+ normalized: normalized,
133
+ described: describeValue(cause, options.maxReasonChars),
134
+ });
135
+ // In `observe` mode the report is the whole contribution: the caller's value
136
+ // goes through untouched, so the turn fails exactly as it would have, and the
137
+ // only difference is that the log now names what produced it.
138
+ return options.mode === 'observe' ? cause : normalized;
139
+ }
package/lib/index.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * A prevention guard for the one `turn/end` row that makes a whole conversation
3
+ * view unreadable.
4
+ *
5
+ * ## The defect
6
+ *
7
+ * A plugin calls `agent.cancel(cause)` with a value that is not a member of the
8
+ * closed `AgentCancelCause` union — a bare string such as
9
+ * `agent.cancel('thinking-loop')`, or an object whose `kind` the union does not
10
+ * have. `ReactLoopAgent.cancel` passes it straight to
11
+ * `AbortController.abort(cause)`, so the value becomes `signal.reason`; the
12
+ * turn's `catch` then reads it back through `abortedCancelCause`, whose `switch`
13
+ * ends in `assertNever` — **inside the catch body**. The assignment never runs,
14
+ * `turnEnds` stays at its `null` initializer, and the unconditional `finally`
15
+ * appends `turn/end` with that `null` (`agent-loop/src/agent.ts:385`; the
16
+ * non-null assertion on the line is what makes it compile).
17
+ *
18
+ * Nothing on the write path refuses the row: the append-time validator inspects
19
+ * surface message slots, `request/header` and `tool/result`, and
20
+ * `turn/end.reason` is a TypeScript-only invariant. Every reader that
21
+ * dereferences it then throws — `ui-chat`'s conversation view does
22
+ * `location.turn.end?.data.reason.kind` while rendering — and one such row
23
+ * blanks the entire chat area, for every session, including newly created ones.
24
+ *
25
+ * ## The version boundary
26
+ *
27
+ * A harness older than `0.1.7` had no such function: the turn's `catch` wrote
28
+ * `{ kind: 'aborted', reason: signal.reason }` verbatim, so the same abuse
29
+ * produced a malformed-but-readable row rather than a `null` one. From
30
+ * `0.1.7-rc.2` on, the same abuse is fatal. That is why this is a guard and not
31
+ * a report: the shape of the failure changed under callers that had not changed
32
+ * at all.
33
+ *
34
+ * ## What this plugin does
35
+ *
36
+ * It listens to `agent/created` — the serial, awaited lifecycle edge that hands
37
+ * over the **live agent instance** — and shadows that instance's `cancel` with a
38
+ * wrapper that normalizes anything outside the union into
39
+ * `{ kind: 'hook', reason: <the value's own name> }` before the loop ever sees
40
+ * it. `hook` is the union member the harness reserves for plugins, so the
41
+ * rewrite is not a lie: a plugin did cancel the turn, and the reason names which
42
+ * one. One property write covers every instance-level caller — the loop's own
43
+ * `agent.cancel({ kind: 'user' })`, the client's stop button, and any
44
+ * third-party plugin — because `ReactLoopAgent.cancel` is a prototype method and
45
+ * the shadow is an own property.
46
+ *
47
+ * Every rewrite is logged, with the offending value rendered as JSON, so the
48
+ * producer can be found and fixed rather than merely tolerated.
49
+ *
50
+ * ## What it does not do
51
+ *
52
+ * It does not refuse a cancellation, does not touch the options, does not
53
+ * rewrite a cause that is already a member (that one is forwarded by reference),
54
+ * and cannot repair a session that already carries a `null` row — those rows are
55
+ * on disk, and this plugin reads no history. Mounting it bounds the defect from
56
+ * that point on.
57
+ *
58
+ * It also cannot cover the one caller that reaches the class rather than the
59
+ * instance, which is why `mode: 'observe'` exists: an operator who wants to see
60
+ * the failure happen may mount this plugin as a reporter and still get the
61
+ * producer named in the log.
62
+ *
63
+ * @module @argszero/dsh-cancel-cause-guard
64
+ */
65
+ import { resolveConfig } from "./config.js";
66
+ import { guardCancel } from "./guard.js";
67
+ export { DEFAULT_MAX_REASON_CHARS, describeValue, isCancelCause, normalizeCancelCause, reasonText, UNSPECIFIED_REASON } from "./cause.js";
68
+ export { DEFAULTS, resolveConfig } from "./config.js";
69
+ export { GUARD_MARK, guardCancel } from "./guard.js";
70
+ /** Cordis plugin name, used by loader diagnostics. */
71
+ export const name = 'cancel-cause-guard';
72
+ /**
73
+ * No service is required.
74
+ *
75
+ * The whole seam is the agent instance the lifecycle edge hands over, and asking
76
+ * for a service this plugin does not use would only be a way for it to fail to
77
+ * mount — so the list is empty rather than absent, which keeps the declaration
78
+ * explicit for a reader and identical between the two export forms.
79
+ */
80
+ export const inject = [];
81
+ /** The prefix of every diagnostic this plugin logs. */
82
+ const LOG_PREFIX = 'cancel-cause-guard:';
83
+ /**
84
+ * Install the guard.
85
+ * @param ctx - the plugin context; the listener and the restore are disposed
86
+ * with it.
87
+ * @param config - the configuration as mounted, possibly empty. Defaults are
88
+ * applied inside `resolveConfig` rather than in a plain object exported under
89
+ * the name `Config`, because the Loader reads that export as a schema and the
90
+ * plugin would then fail to mount.
91
+ * @throws when the configuration names an unknown key or an unusable value; the
92
+ * plugin refuses to mount rather than guarding with rules it cannot honour.
93
+ */
94
+ export function apply(ctx, config = {}) {
95
+ const settings = resolveConfig(config);
96
+ /** The guards this plugin installed and has not yet unwound, by agent instance. */
97
+ const guards = new Map();
98
+ // Registered before the listeners so that it is unwound *after* them: the
99
+ // fiber's effects are disposed in reverse, and unwinding a shadow while a
100
+ // listener could still install another would leave the last one behind.
101
+ ctx.effect(() => () => {
102
+ for (const handle of guards.values())
103
+ handle.restore();
104
+ guards.clear();
105
+ }, `${LOG_PREFIX}restore()`);
106
+ ctx.on('agent/created', ({ agent }) => {
107
+ const handle = guardCancel(agent, {
108
+ mode: settings.mode,
109
+ reason: settings.reason,
110
+ maxReasonChars: settings.maxReasonChars,
111
+ onNormalized: report => { reportNormalized(ctx, agent.session.id, report, settings.mode); },
112
+ });
113
+ switch (handle.outcome) {
114
+ case 'patched':
115
+ guards.set(agent, handle);
116
+ ctx.logger.debug(`${LOG_PREFIX} agent ${agent.session.id}: cancellation causes outside the union will be `
117
+ + 'recorded as { kind: "hook", reason } instead of ending the turn with reason null');
118
+ return;
119
+ case 'already-guarded':
120
+ ctx.logger.debug(`${LOG_PREFIX} agent ${agent.session.id}: already guarded by this plugin, leaving the `
121
+ + 'existing wrapper in place');
122
+ return;
123
+ case 'no-cancel':
124
+ ctx.logger.warn(`${LOG_PREFIX} agent ${agent.session.id} carries no cancel method, so an out-of-union `
125
+ + 'cause from any plugin it hands itself to will still end its turn with reason null');
126
+ return;
127
+ /* v8 ignore next 4 -- the read-back check makes both write failures one outcome. */
128
+ case 'not-writable':
129
+ ctx.logger.warn(`${LOG_PREFIX} agent ${agent.session.id} refuses an own "cancel" property, so this guard `
130
+ + 'is NOT in force for it: an out-of-union cause will end its turn with reason null');
131
+ }
132
+ });
133
+ ctx.on('agent/disposed', ({ agent }) => {
134
+ const handle = guards.get(agent);
135
+ if (handle === undefined)
136
+ return;
137
+ handle.restore();
138
+ guards.delete(agent);
139
+ });
140
+ }
141
+ /**
142
+ * Record one out-of-union cause.
143
+ *
144
+ * A warning rather than an error: what the reader needs is the *name* of the
145
+ * value — the only handle on whoever produced it — and the two modes differ
146
+ * only in what became of it.
147
+ * @param ctx - the plugin context, for logging.
148
+ * @param sessionId - the session whose agent was cancelled.
149
+ * @param report - what was seen, and what it became.
150
+ * @param mode - whether the cause was rewritten or passed through.
151
+ */
152
+ function reportNormalized(ctx, sessionId, report, mode) {
153
+ const outcome = mode === 'observe'
154
+ ? 'it was passed through unchanged (mode "observe"), so this turn ends with reason null'
155
+ : 'the turn was recorded as '
156
+ + `{ "kind": "aborted", "reason": { "kind": "hook", "reason": ${JSON.stringify(report.normalized.reason)} } }`;
157
+ ctx.logger.warn(`${LOG_PREFIX} session ${sessionId}: a caller passed ${report.described} as a cancellation `
158
+ + `cause, which is not a member of AgentCancelCause; ${outcome}. `
159
+ + 'Which plugin passes it is the value itself: the reason carries its own name.');
160
+ }
161
+ export default { name, inject, apply };
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The closed union the harness writes into `turn/end`, and the normalization
3
+ * that makes any other value a member of it.
4
+ *
5
+ * ## Why a value outside the union is not a cosmetic problem
6
+ *
7
+ * `ReactLoopAgent`'s turn body reads the cancellation cause back out of the
8
+ * signal and switches on it (`abortedCancelCause`, `agent-loop/src/agent.ts:80`):
9
+ *
10
+ * ```ts
11
+ * const cause = signal.reason as AgentCancelCause
12
+ * switch (cause.kind) {
13
+ * case 'user': case 'parent': case 'disposed': return { kind: cause.kind }
14
+ * case 'hook': return { kind: 'hook', reason: cause.reason }
15
+ * default: return assertNever(cause)
16
+ * }
17
+ * ```
18
+ *
19
+ * That function is called **inside the turn's `catch`**. A value outside the
20
+ * union therefore throws before the catch body assigns `turnEnds`, which is
21
+ * still its `null` initializer, and the `finally` appends unconditionally:
22
+ *
23
+ * ```ts
24
+ * let turnEnds: TurnEndReason | null = null // agent.ts:310
25
+ * ...
26
+ * } finally {
27
+ * this.session.append('turn/end', { turn, reason: turnEnds! }) // agent.ts:385
28
+ * }
29
+ * ```
30
+ *
31
+ * The row lands on disk as `{"type":"turn/end","data":{"turn":N,"reason":null}}`.
32
+ * Nothing rejects it on the way: the append-time validator inspects surface
33
+ * message slots, `request/header` and `tool/result` only, so this field is a
34
+ * TypeScript-only invariant. Readers then dereference it — the chat client does
35
+ * `location.turn.end?.data.reason.kind`
36
+ * (`ui-chat/src/client/contract/turn-process.ts:72`) — and one such row throws
37
+ * while the conversation view renders, which blanks the whole chat area.
38
+ * Reported three times: discussions #8832, #9156 and #9646.
39
+ *
40
+ * ## Two shapes reach it, and only one of them looks like a mistake
41
+ *
42
+ * - an object whose `kind` is missing or unknown (`{ kind: 'thinking-loop' }`),
43
+ * which `assertNever` refuses; and
44
+ * - a value that is not an object at all — most often `undefined`, from a
45
+ * caller whose own declaration made the argument optional. `cause.kind` on
46
+ * `undefined` throws just as effectively, one line earlier.
47
+ *
48
+ * Both are normalized here. `hook` is the member the union reserves for
49
+ * plugins, so a normalized cause says what actually happened: a plugin
50
+ * cancelled the turn, and the reason names which one.
51
+ *
52
+ * @module @argszero/dsh-cancel-cause-guard/cause
53
+ */
54
+ import type { AgentCancelCause } from '@deepseek-ai/dsh-agent';
55
+ /** Bound on a minted reason when the configuration names none. */
56
+ export declare const DEFAULT_MAX_REASON_CHARS = 200;
57
+ /** The reason text standing in for a cause that carried no name at all. */
58
+ export declare const UNSPECIFIED_REASON = "unspecified";
59
+ /** The union member a value from outside the union is carried under. */
60
+ export declare const CARRIER_KIND = "hook";
61
+ /** How one normalization is parameterized. */
62
+ export interface NormalizeOptions {
63
+ /** Reason text to record, in place of the value's own name. */
64
+ readonly reason?: string | undefined;
65
+ /** Bound on the minted reason text. */
66
+ readonly maxReasonChars?: number | undefined;
67
+ }
68
+ /**
69
+ * Whether a value is already exactly one of the four union members.
70
+ *
71
+ * `hook` is held to the stricter test: its `reason` is part of the member's
72
+ * contract, and a `hook` whose reason is not a string is normalized rather than
73
+ * passed through, so that everything downstream of this plugin can rely on the
74
+ * field being text.
75
+ * @param cause - the value a caller passed to `Agent.cancel`.
76
+ * @returns whether the value can be recorded as-is.
77
+ */
78
+ export declare function isCancelCause(cause: unknown): cause is AgentCancelCause;
79
+ /**
80
+ * Turn any cancellation cause into a member of the closed union.
81
+ *
82
+ * A value that is already a member is returned **unchanged** (same reference),
83
+ * so the common path allocates nothing and an `{ kind: 'user' }` a caller may
84
+ * still be holding is the object the loop reads back.
85
+ * @param cause - the value a caller passed to `Agent.cancel`.
86
+ * @param options - the reason override and the reason bound.
87
+ * @returns a member of `AgentCancelCause`.
88
+ */
89
+ export declare function normalizeCancelCause(cause: unknown, options?: NormalizeOptions): AgentCancelCause;
90
+ /**
91
+ * The reason text for one value, preferring whatever name it carried.
92
+ *
93
+ * The preference order is deliberate: a caller that passed
94
+ * `{ kind: 'thinking-loop' }` meant the token `thinking-loop`, and a reason
95
+ * reading `thinking-loop` is more useful to the person reading the session than
96
+ * the JSON it was embedded in.
97
+ * @param value - the value to name, or the reason field of a `hook` cause.
98
+ * @param maxChars - bound on the result.
99
+ * @returns a non-empty, bounded string.
100
+ */
101
+ export declare function reasonText(value: unknown, maxChars?: number): string;
102
+ /**
103
+ * A descriptive rendering of a value for a log line.
104
+ *
105
+ * Separate from {@link reasonText} because the log is read by whoever has to
106
+ * find the producer, and there the JSON of the offending value is the useful
107
+ * form — including the `undefined` that {@link reasonText} deliberately hides
108
+ * behind `unspecified`.
109
+ * @param value - the value to describe.
110
+ * @param maxChars - bound on the result.
111
+ * @returns a bounded, single-line rendering.
112
+ */
113
+ export declare function describeValue(value: unknown, maxChars?: number): string;
114
+ //# sourceMappingURL=cause.d.ts.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Configuration resolution for the cancellation-cause guard.
3
+ *
4
+ * The surface is deliberately three fields. A guard that rewrites a value on a
5
+ * path as hot as cancellation has to be readable in one sitting, and every knob
6
+ * it does not have is a way for an operator to be surprised by what it did.
7
+ *
8
+ * An unknown key is refused rather than ignored: a misspelled `mode` that mounts
9
+ * and guards nothing is how an operator believes a guard is in force when it is
10
+ * not — the same refusal `dsh-tool-output-budget` makes, for the same reason.
11
+ *
12
+ * @module @argszero/dsh-cancel-cause-guard/config
13
+ */
14
+ import type { Config, ResolvedConfig } from './types.ts';
15
+ /** Defaults for a guard whose only job is to keep a session readable. */
16
+ export declare const DEFAULTS: ResolvedConfig;
17
+ /**
18
+ * Resolve, validate and detach the guard configuration.
19
+ * @param config - raw plugin configuration, possibly empty.
20
+ * @returns a detached, frozen configuration.
21
+ * @throws {Error} when a key is unknown, a mode is not one this plugin has, a
22
+ * reason is not non-empty text, or the bound is not a positive integer.
23
+ */
24
+ export declare function resolveConfig(config?: Config): ResolvedConfig;
25
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1,80 @@
1
+ /**
2
+ * One agent's `cancel`, shadowed so that a cause from outside the closed union
3
+ * never reaches the loop's `assertNever`.
4
+ *
5
+ * ## Why the seam is the instance
6
+ *
7
+ * There is no event on the cancellation path and no hook in the append-time
8
+ * validator: `validateSessionEventData` inspects surface message slots,
9
+ * `request/header` and `tool/result`, and `turn/end.reason` is a TypeScript-only
10
+ * invariant, so nothing on the write path can refuse the row that poisons a
11
+ * session. What the plugin does have is the live agent instance, handed over by
12
+ * `agent/created` — a `ctx.serial` dispatch that `AgentRegistry.announce`
13
+ * **awaits** (`packages/core/agent/src/index.ts:550`), so a listener that
14
+ * settles before its first turn can still change how that agent behaves:
15
+ *
16
+ * ```ts
17
+ * await this.ctx.serial(entry.carrier, 'agent/created', { agent: entry.agent, source, signal })
18
+ * ```
19
+ *
20
+ * `ReactLoopAgent.cancel` is a prototype method (`agent-loop/src/agent.ts:175`),
21
+ * so an **own** property of that name shadows it for every instance-level
22
+ * caller — the loop's own `agent.cancel({ kind: 'user' })`
23
+ * (`agent/src/archive-admission.ts:36`, `agent-loop/src/index.ts:789`), the chat
24
+ * client's stop button, and any third-party plugin. All of them are covered by
25
+ * one property write, which is why this is a guard rather than a per-producer
26
+ * patch.
27
+ *
28
+ * ## What the wrapper does, and what it deliberately does not
29
+ *
30
+ * It normalizes, forwards, and reports — in that order, and nothing else. It
31
+ * does not refuse a cancellation (a plugin that stopped a stop would be worse
32
+ * than the defect), does not touch the options, and does not inspect or rewrite
33
+ * anything but the cause. A value that is already a union member is forwarded
34
+ * **by reference**, so the object the loop reads back out of the signal is the
35
+ * object the caller passed.
36
+ *
37
+ * The wrapper binds the original to the agent it came from. A caller holding a
38
+ * detached `const cancel = agent.cancel` would otherwise have reached the
39
+ * original with no receiver at all; binding it is the reading the method's own
40
+ * body requires (`this.phase`, `this.inbox`), and it makes the shadow more
41
+ * faithful than the property it replaces.
42
+ *
43
+ * @module @argszero/dsh-cancel-cause-guard/guard
44
+ */
45
+ import type { Agent } from '@deepseek-ai/dsh-agent';
46
+ import type { GuardHandle, GuardMode, NormalizedCause } from './types.ts';
47
+ /**
48
+ * The brand an installed wrapper carries.
49
+ *
50
+ * `Symbol.for` rather than a module-local symbol: two copies of this plugin
51
+ * loaded into one process — the plugin mounted by two profile entries, or a
52
+ * second copy pulled in beside the first — would otherwise each install a
53
+ * wrapper, and the inner one would normalize a cause the outer had already
54
+ * normalized. With a registry-wide key the second mount recognises the first
55
+ * and leaves it alone.
56
+ */
57
+ export declare const GUARD_MARK: unique symbol;
58
+ /** How one guard is told to behave. */
59
+ export interface GuardOptions {
60
+ /** What to do with an out-of-union cause. */
61
+ readonly mode: GuardMode;
62
+ /** Reason text to record in place of the value's own name. */
63
+ readonly reason?: string | undefined;
64
+ /** Bound on the minted reason text. */
65
+ readonly maxReasonChars: number;
66
+ /** Called once per rewritten (or, in `observe` mode, merely reported) cause. */
67
+ readonly onNormalized: (report: NormalizedCause) => void;
68
+ }
69
+ /**
70
+ * Shadow one agent's `cancel` with a normalizing wrapper.
71
+ *
72
+ * Every refusal is reported as an {@link GuardOutcome} rather than thrown: an
73
+ * agent this plugin could not guard is a session that keeps the defect, not a
74
+ * reason to fail the lifecycle edge that delivered it.
75
+ * @param agent - the live agent instance, as handed over by `agent/created`.
76
+ * @param options - the guard's behaviour and its report callback.
77
+ * @returns the outcome and an undo.
78
+ */
79
+ export declare function guardCancel(agent: Agent, options: GuardOptions): GuardHandle;
80
+ //# sourceMappingURL=guard.d.ts.map
@@ -0,0 +1,101 @@
1
+ /**
2
+ * A prevention guard for the one `turn/end` row that makes a whole conversation
3
+ * view unreadable.
4
+ *
5
+ * ## The defect
6
+ *
7
+ * A plugin calls `agent.cancel(cause)` with a value that is not a member of the
8
+ * closed `AgentCancelCause` union — a bare string such as
9
+ * `agent.cancel('thinking-loop')`, or an object whose `kind` the union does not
10
+ * have. `ReactLoopAgent.cancel` passes it straight to
11
+ * `AbortController.abort(cause)`, so the value becomes `signal.reason`; the
12
+ * turn's `catch` then reads it back through `abortedCancelCause`, whose `switch`
13
+ * ends in `assertNever` — **inside the catch body**. The assignment never runs,
14
+ * `turnEnds` stays at its `null` initializer, and the unconditional `finally`
15
+ * appends `turn/end` with that `null` (`agent-loop/src/agent.ts:385`; the
16
+ * non-null assertion on the line is what makes it compile).
17
+ *
18
+ * Nothing on the write path refuses the row: the append-time validator inspects
19
+ * surface message slots, `request/header` and `tool/result`, and
20
+ * `turn/end.reason` is a TypeScript-only invariant. Every reader that
21
+ * dereferences it then throws — `ui-chat`'s conversation view does
22
+ * `location.turn.end?.data.reason.kind` while rendering — and one such row
23
+ * blanks the entire chat area, for every session, including newly created ones.
24
+ *
25
+ * ## The version boundary
26
+ *
27
+ * A harness older than `0.1.7` had no such function: the turn's `catch` wrote
28
+ * `{ kind: 'aborted', reason: signal.reason }` verbatim, so the same abuse
29
+ * produced a malformed-but-readable row rather than a `null` one. From
30
+ * `0.1.7-rc.2` on, the same abuse is fatal. That is why this is a guard and not
31
+ * a report: the shape of the failure changed under callers that had not changed
32
+ * at all.
33
+ *
34
+ * ## What this plugin does
35
+ *
36
+ * It listens to `agent/created` — the serial, awaited lifecycle edge that hands
37
+ * over the **live agent instance** — and shadows that instance's `cancel` with a
38
+ * wrapper that normalizes anything outside the union into
39
+ * `{ kind: 'hook', reason: <the value's own name> }` before the loop ever sees
40
+ * it. `hook` is the union member the harness reserves for plugins, so the
41
+ * rewrite is not a lie: a plugin did cancel the turn, and the reason names which
42
+ * one. One property write covers every instance-level caller — the loop's own
43
+ * `agent.cancel({ kind: 'user' })`, the client's stop button, and any
44
+ * third-party plugin — because `ReactLoopAgent.cancel` is a prototype method and
45
+ * the shadow is an own property.
46
+ *
47
+ * Every rewrite is logged, with the offending value rendered as JSON, so the
48
+ * producer can be found and fixed rather than merely tolerated.
49
+ *
50
+ * ## What it does not do
51
+ *
52
+ * It does not refuse a cancellation, does not touch the options, does not
53
+ * rewrite a cause that is already a member (that one is forwarded by reference),
54
+ * and cannot repair a session that already carries a `null` row — those rows are
55
+ * on disk, and this plugin reads no history. Mounting it bounds the defect from
56
+ * that point on.
57
+ *
58
+ * It also cannot cover the one caller that reaches the class rather than the
59
+ * instance, which is why `mode: 'observe'` exists: an operator who wants to see
60
+ * the failure happen may mount this plugin as a reporter and still get the
61
+ * producer named in the log.
62
+ *
63
+ * @module @argszero/dsh-cancel-cause-guard
64
+ */
65
+ import type { Context } from '@deepseek-ai/cordis';
66
+ import type { Config } from './types.ts';
67
+ export { DEFAULT_MAX_REASON_CHARS, describeValue, isCancelCause, normalizeCancelCause, reasonText, UNSPECIFIED_REASON } from './cause.ts';
68
+ export { DEFAULTS, resolveConfig } from './config.ts';
69
+ export { GUARD_MARK, guardCancel } from './guard.ts';
70
+ export type { GuardOptions } from './guard.ts';
71
+ export type { Config, GuardHandle, GuardMode, GuardOutcome, NormalizedCause, ResolvedConfig, } from './types.ts';
72
+ /** Cordis plugin name, used by loader diagnostics. */
73
+ export declare const name = "cancel-cause-guard";
74
+ /**
75
+ * No service is required.
76
+ *
77
+ * The whole seam is the agent instance the lifecycle edge hands over, and asking
78
+ * for a service this plugin does not use would only be a way for it to fail to
79
+ * mount — so the list is empty rather than absent, which keeps the declaration
80
+ * explicit for a reader and identical between the two export forms.
81
+ */
82
+ export declare const inject: readonly string[];
83
+ /**
84
+ * Install the guard.
85
+ * @param ctx - the plugin context; the listener and the restore are disposed
86
+ * with it.
87
+ * @param config - the configuration as mounted, possibly empty. Defaults are
88
+ * applied inside `resolveConfig` rather than in a plain object exported under
89
+ * the name `Config`, because the Loader reads that export as a schema and the
90
+ * plugin would then fail to mount.
91
+ * @throws when the configuration names an unknown key or an unusable value; the
92
+ * plugin refuses to mount rather than guarding with rules it cannot honour.
93
+ */
94
+ export declare function apply(ctx: Context, config?: Config): void;
95
+ declare const _default: {
96
+ name: string;
97
+ inject: readonly string[];
98
+ apply: typeof apply;
99
+ };
100
+ export default _default;
101
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The shapes this plugin's configuration and its guard report take.
3
+ *
4
+ * @module @argszero/dsh-cancel-cause-guard/types
5
+ */
6
+ /**
7
+ * What the guard does with a cause that is not a member of the closed union.
8
+ *
9
+ * - `normalize` — the default, and the reason the plugin exists: the value is
10
+ * carried as a `{ kind: 'hook', reason }` cause, the turn ends normally, and
11
+ * the log records which value was rewritten.
12
+ * - `observe` — the value is passed through unchanged, so the turn fails exactly
13
+ * as it would without the plugin, and the log records what was seen. This is
14
+ * the arm for an operator who wants to find a producer before protecting the
15
+ * session from it: the plugin reports, and nothing else changes.
16
+ */
17
+ export type GuardMode = 'normalize' | 'observe';
18
+ /** The plugin configuration as mounted; every field is optional. */
19
+ export interface Config {
20
+ /** What to do with an out-of-union cause. Defaults to `normalize`. */
21
+ readonly mode?: GuardMode | undefined;
22
+ /**
23
+ * The reason text to record for every cause the guard rewrites, in place of
24
+ * the value's own name. Defaults to the name the value carried (`'thinking-loop'`
25
+ * for `{ kind: 'thinking-loop' }`, a JSON rendering otherwise).
26
+ */
27
+ readonly reason?: string | undefined;
28
+ /** Bound on the reason text the guard mints. Defaults to 200. */
29
+ readonly maxReasonChars?: number | undefined;
30
+ }
31
+ /** The configuration after defaults, validation and detaching. */
32
+ export interface ResolvedConfig {
33
+ readonly mode: GuardMode;
34
+ readonly reason: string | undefined;
35
+ readonly maxReasonChars: number;
36
+ }
37
+ /** What {@link guardCancel} found when it reached an agent. */
38
+ export type GuardOutcome =
39
+ /** The instance's own `cancel` now normalizes out-of-union causes. */
40
+ 'patched'
41
+ /** Somebody's guard — this plugin's, mounted twice — is already in place. */
42
+ | 'already-guarded'
43
+ /** The instance carries no callable `cancel`; there is nothing to shadow. */
44
+ | 'no-cancel'
45
+ /** The instance refuses an own property (`cancel` is non-writable, or the object is sealed). */
46
+ | 'not-writable';
47
+ /** One cause the guard rewrote, as it was reported. */
48
+ export interface NormalizedCause {
49
+ /** The value the caller passed. */
50
+ readonly original: unknown;
51
+ /** The union member it was carried as. */
52
+ readonly normalized: {
53
+ readonly kind: 'hook';
54
+ readonly reason: string;
55
+ };
56
+ /** How the original was described for the log. */
57
+ readonly described: string;
58
+ }
59
+ /** The handle {@link guardCancel} returns: how it went, and an undo. */
60
+ export interface GuardHandle {
61
+ /** What happened when the guard was installed. */
62
+ readonly outcome: GuardOutcome;
63
+ /** Restore the instance's own `cancel`, when this guard installed one. */
64
+ readonly restore: () => void;
65
+ }
66
+ //# sourceMappingURL=types.d.ts.map
package/lib/types.js ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The shapes this plugin's configuration and its guard report take.
3
+ *
4
+ * @module @argszero/dsh-cancel-cause-guard/types
5
+ */
6
+ export {};
package/package.json CHANGED
@@ -1,6 +1,74 @@
1
1
  {
2
2
  "name": "@argszero/dsh-cancel-cause-guard",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "description": "A plugin that cancels an agent's turn with a cause outside the closed AgentCancelCause union — a bare string like agent.cancel('thinking-loop'), or an object whose kind the union does not have — does not merely log something odd: it makes the whole conversation view unreadable. ReactLoopAgent.cancel hands the value to AbortController.abort(), so it becomes signal.reason; the turn's catch reads it back through abortedCancelCause, whose switch ends in assertNever *inside the catch body*, so turnEnds stays at its null initializer and the unconditional finally appends turn/end with that null (agent-loop/src/agent.ts:385, and the non-null assertion on the line is what makes it compile). Nothing on the write path refuses it — the append-time validator inspects surface slots, request/header and tool/result, and turn/end.reason is a TypeScript-only invariant. Every reader that dereferences it then throws, and the chat view does exactly that while rendering (ui-chat/src/client/contract/turn-process.ts:72), so one such row blanks the conversation area for every session. Reported three times (#8832, #9156, #9646). This plugin closes the seam that is left: it listens to agent/created — the serial, awaited lifecycle edge that hands over the live agent instance (packages/core/agent/src/index.ts:550) — and shadows that instance's cancel with a wrapper that carries anything outside the union as { kind: 'hook', reason } before the loop ever sees it. hook is the member the union reserves for plugins, so the rewrite is not a lie: a plugin did cancel the turn, and the reason names which one. One property write covers every instance-level caller, because ReactLoopAgent.cancel is a prototype method and the shadow is an own property. Every rewrite is logged with the offending value rendered as JSON, so the producer can be found and fixed rather than tolerated. It never refuses a cancellation, forwards an already-valid cause by reference, and cannot repair rows already on disk. Zero runtime dependencies beyond the harness packages it declares.",
4
+ "version": "0.1.0",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "type": "module",
9
+ "main": "lib/index.js",
10
+ "types": "lib/types/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./lib/types/index.d.ts",
14
+ "default": "./lib/index.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "lib/**/*.js",
20
+ "lib/types/**/*.d.ts",
21
+ "cordis.patch.yml",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/argszero/dsh-cancel-cause-guard.git"
29
+ },
30
+ "homepage": "https://github.com/argszero/dsh-cancel-cause-guard#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/argszero/dsh-cancel-cause-guard/issues"
33
+ },
34
+ "keywords": [
35
+ "cordis",
36
+ "deepseek-harness",
37
+ "dsh",
38
+ "plugin",
39
+ "agent-cancel",
40
+ "cancel-cause",
41
+ "turn-end",
42
+ "session-integrity",
43
+ "agent-lifecycle",
44
+ "guard"
45
+ ],
46
+ "dsh": {
47
+ "bundle": {
48
+ "patch": "./cordis.patch.yml"
49
+ }
50
+ },
51
+ "peerDependencies": {
52
+ "@deepseek-ai/cordis": "^4.0.2 || ~4.0.5-alpha.1",
53
+ "@deepseek-ai/dsh-agent": ">=0.1.7-rc.2 <0.2.0 || >=0.2.0-rc.1 <0.2.1-alpha.2 || >=0.2.1-alpha.2 <0.3.0"
54
+ },
55
+ "devDependencies": {
56
+ "@deepseek-ai/cordis": "~4.0.5-alpha.1",
57
+ "@deepseek-ai/dsh-agent": "0.2.1-alpha.2",
58
+ "@deepseek-ai/dsh-session": "0.2.1-alpha.2",
59
+ "@types/node": "^24.19.1",
60
+ "typescript": "^5.9.3"
61
+ },
62
+ "scripts": {
63
+ "build": "tsc",
64
+ "pretest": "tsc",
65
+ "test": "node --test \"test/*.spec.mjs\"",
66
+ "prepublishOnly": "tsc",
67
+ "test:inject": "node scripts/inject-defects.mjs",
68
+ "probe:lines": "node scripts/probe-lines.mjs",
69
+ "witness:cancel-cause": "node scripts/witness-cancel-cause.mjs"
70
+ },
71
+ "engines": {
72
+ "node": "^22.19 || >=24"
73
+ }
74
+ }