@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.
@@ -0,0 +1,104 @@
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 type { BackstopHandle, BackstopOptions } from './types.ts';
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 declare const APPEND_GUARD_MARK: unique symbol;
62
+ /**
63
+ * The reason text for a row whose reason carried nothing at all.
64
+ *
65
+ * Named for what the operator has to know: the cancellation did not pass the
66
+ * entry guard, so its cause was never seen and cannot be named.
67
+ */
68
+ export declare const UNSEEN_CAUSE_REASON = "a cancellation cause the entry guard never saw";
69
+ /**
70
+ * Whether a value is a reason a reader can dereference.
71
+ *
72
+ * The whole consequence of the defect is one expression — `reason.kind` — so the
73
+ * property this asks about is exactly that one: an object, not null, whose `kind`
74
+ * is non-empty text. Membership in `TurnEndReasonMap` is deliberately **not**
75
+ * asked, because the map is merge-extensible and a plugin's own variant would
76
+ * then be rewritten by a plugin that had never heard of it.
77
+ * @param reason - the value the loop is about to commit as `turn/end.reason`.
78
+ * @returns whether a reader may dereference `reason.kind`.
79
+ */
80
+ export declare function isReadableTurnEndReason(reason: unknown): boolean;
81
+ /**
82
+ * The reason to commit for a row whose own reason is unreadable.
83
+ *
84
+ * The configured reason wins when the operator set one; otherwise the text says
85
+ * the one thing that is certainly true — no entry guard saw this cancellation.
86
+ * @param options - the configured reason and its bound.
87
+ * @returns the reason text for the replacement row.
88
+ */
89
+ export declare function unseenCauseReason(options: {
90
+ reason?: string | undefined;
91
+ maxReasonChars: number;
92
+ }): string;
93
+ /**
94
+ * Shadow one session's `append` so an unreadable `turn/end` reason never lands.
95
+ *
96
+ * Every refusal is reported as an outcome rather than thrown: a session this
97
+ * plugin could not guard is a session that keeps the defect, not a reason to fail
98
+ * the lifecycle edge that delivered it.
99
+ * @param session - the live session the agent appends to.
100
+ * @param options - the mode, the reason override, the bound, and the report callback.
101
+ * @returns the outcome and an undo.
102
+ */
103
+ export declare function guardSessionAppend(session: unknown, options: BackstopOptions): BackstopHandle;
104
+ //# sourceMappingURL=backstop.d.ts.map
@@ -0,0 +1,122 @@
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
+ /**
115
+ * Bound one reason so a hostile value cannot write a wall of text into the log.
116
+ * Shared with the durable backstop, which mints a reason of its own.
117
+ * @param text - the reason text.
118
+ * @param maxChars - the bound, in characters.
119
+ * @returns the text, cut to the bound with a trailing ellipsis when it had to be.
120
+ */
121
+ export declare function truncate(text: string, maxChars: number): string;
122
+ //# sourceMappingURL=cause.d.ts.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Configuration resolution for the cancellation-cause guard.
3
+ *
4
+ * The surface is deliberately four fields. A guard that rewrites a value on
5
+ * paths as hot as cancellation and the session append has to be readable in one
6
+ * sitting, and every knob it does not have is a way for an operator to be
7
+ * surprised by what it did.
8
+ *
9
+ * An unknown key is refused rather than ignored: a misspelled `mode` that mounts
10
+ * and guards nothing is how an operator believes a guard is in force when it is
11
+ * not — the same refusal `dsh-tool-output-budget` makes, for the same reason.
12
+ *
13
+ * @module @argszero/dsh-cancel-cause-guard/config
14
+ */
15
+ import type { Config, ResolvedConfig } from './types.ts';
16
+ /** Defaults for a guard whose only job is to keep a session readable. */
17
+ export declare const DEFAULTS: ResolvedConfig;
18
+ /**
19
+ * Resolve, validate and detach the guard configuration.
20
+ * @param config - raw plugin configuration, possibly empty.
21
+ * @returns a detached, frozen configuration.
22
+ * @throws {Error} when a key is unknown, a mode is not one this plugin has, a
23
+ * reason is not non-empty text, or the bound is not a positive integer.
24
+ */
25
+ export declare function resolveConfig(config?: Config): ResolvedConfig;
26
+ //# 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,115 @@
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 closes the defect at two depths, and the second one exists for the callers
37
+ * the first cannot see.
38
+ *
39
+ * **At the entry.** It listens to `agent/created` — the serial, awaited lifecycle
40
+ * edge that hands over the **live agent instance** — and shadows that instance's
41
+ * `cancel` with a wrapper that normalizes anything outside the union into
42
+ * `{ kind: 'hook', reason: <the value's own name> }` before the loop ever sees
43
+ * it. `hook` is the union member the harness reserves for plugins, so the
44
+ * rewrite is not a lie: a plugin did cancel the turn, and the reason names which
45
+ * one. One property write covers every instance-level caller — the loop's own
46
+ * `agent.cancel({ kind: 'user' })`, the client's stop button, and any
47
+ * third-party plugin — because `ReactLoopAgent.cancel` is a prototype method and
48
+ * the shadow is an own property.
49
+ *
50
+ * **At the write.** The same edge hands over the **live session**, and the
51
+ * plugin shadows that session's own `append` too, so a `turn/end` row whose
52
+ * reason no reader can dereference is committed instead as the row the loop meant
53
+ * to write: `{ kind: 'aborted', reason: { kind: 'hook', reason: <text> } }`. That
54
+ * arm is what covers the callers the entry guard cannot reach — one that goes
55
+ * through the class rather than the instance
56
+ * (`ReactLoopAgent.prototype.cancel.call(agent, …)`, or a second copy of the loop
57
+ * in the process), and an instance that refuses the own `cancel` property, where
58
+ * the entry guard simply reports that it is not in force. Set `backstop: false`
59
+ * to leave the append path exactly as the harness's.
60
+ *
61
+ * Every rewrite and every repaired row is logged, with the offending value
62
+ * rendered as JSON, so the producer can be found and fixed rather than merely
63
+ * tolerated.
64
+ *
65
+ * ## What it does not do
66
+ *
67
+ * It does not refuse a cancellation, does not touch the options, does not rewrite
68
+ * a cause that is already a member (that one is forwarded by reference), and
69
+ * cannot repair a session that already carries a `null` row — those rows are on
70
+ * disk, and this plugin reads no history. Mounting it bounds the defect from that
71
+ * point on.
72
+ *
73
+ * `mode: 'observe'` exists for the operator who wants to see the failure happen:
74
+ * both arms report and neither rewrites, and the log names what arrived.
75
+ *
76
+ * @module @argszero/dsh-cancel-cause-guard
77
+ */
78
+ import type { Context } from '@deepseek-ai/cordis';
79
+ import type { Config } from './types.ts';
80
+ export { DEFAULT_MAX_REASON_CHARS, describeValue, isCancelCause, normalizeCancelCause, reasonText, UNSPECIFIED_REASON } from './cause.ts';
81
+ export { APPEND_GUARD_MARK, UNSEEN_CAUSE_REASON, guardSessionAppend, isReadableTurnEndReason, unseenCauseReason } from './backstop.ts';
82
+ export { DEFAULTS, resolveConfig } from './config.ts';
83
+ export { GUARD_MARK, guardCancel } from './guard.ts';
84
+ export type { GuardOptions } from './guard.ts';
85
+ export type { BackstopHandle, BackstopOptions, BackstopOutcome, Config, GuardHandle, GuardMode, GuardOutcome, NormalizedCause, RepairedTurnEnd, ResolvedConfig, } from './types.ts';
86
+ /** Cordis plugin name, used by loader diagnostics. */
87
+ export declare const name = "cancel-cause-guard";
88
+ /**
89
+ * No service is required.
90
+ *
91
+ * The whole seam is the agent instance the lifecycle edge hands over, and asking
92
+ * for a service this plugin does not use would only be a way for it to fail to
93
+ * mount — so the list is empty rather than absent, which keeps the declaration
94
+ * explicit for a reader and identical between the two export forms.
95
+ */
96
+ export declare const inject: readonly string[];
97
+ /**
98
+ * Install the guard.
99
+ * @param ctx - the plugin context; the listener and the restore are disposed
100
+ * with it.
101
+ * @param config - the configuration as mounted, possibly empty. Defaults are
102
+ * applied inside `resolveConfig` rather than in a plain object exported under
103
+ * the name `Config`, because the Loader reads that export as a schema and the
104
+ * plugin would then fail to mount.
105
+ * @throws when the configuration names an unknown key or an unusable value; the
106
+ * plugin refuses to mount rather than guarding with rules it cannot honour.
107
+ */
108
+ export declare function apply(ctx: Context, config?: Config): void;
109
+ declare const _default: {
110
+ name: string;
111
+ inject: readonly string[];
112
+ apply: typeof apply;
113
+ };
114
+ export default _default;
115
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,116 @@
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
+ * Whether the durable backstop is installed as well. Defaults to `true`.
32
+ *
33
+ * Set `false` to leave the session's own `append` untouched — for an operator
34
+ * who wants the write path to be exactly the harness's, and accepts that a
35
+ * cancellation which bypasses the entry guard can then blank a conversation.
36
+ */
37
+ readonly backstop?: boolean | undefined;
38
+ }
39
+ /** The configuration after defaults, validation and detaching. */
40
+ export interface ResolvedConfig {
41
+ readonly mode: GuardMode;
42
+ readonly reason: string | undefined;
43
+ readonly maxReasonChars: number;
44
+ readonly backstop: boolean;
45
+ }
46
+ /** What {@link guardCancel} found when it reached an agent. */
47
+ export type GuardOutcome =
48
+ /** The instance's own `cancel` now normalizes out-of-union causes. */
49
+ 'patched'
50
+ /** Somebody's guard — this plugin's, mounted twice — is already in place. */
51
+ | 'already-guarded'
52
+ /** The instance carries no callable `cancel`; there is nothing to shadow. */
53
+ | 'no-cancel'
54
+ /** The instance refuses an own property (`cancel` is non-writable, or the object is sealed). */
55
+ | 'not-writable';
56
+ /** One cause the guard rewrote, as it was reported. */
57
+ export interface NormalizedCause {
58
+ /** The value the caller passed. */
59
+ readonly original: unknown;
60
+ /** The union member it was carried as. */
61
+ readonly normalized: {
62
+ readonly kind: 'hook';
63
+ readonly reason: string;
64
+ };
65
+ /** How the original was described for the log. */
66
+ readonly described: string;
67
+ }
68
+ /** The handle {@link guardCancel} returns: how it went, and an undo. */
69
+ export interface GuardHandle {
70
+ /** What happened when the guard was installed. */
71
+ readonly outcome: GuardOutcome;
72
+ /** Restore the instance's own `cancel`, when this guard installed one. */
73
+ readonly restore: () => void;
74
+ }
75
+ /**
76
+ * What {@link guardSessionAppend} found when it reached a session.
77
+ *
78
+ * The four names mean what {@link GuardOutcome}'s do, for the other property: the
79
+ * session's `append` now refuses to commit an unreadable `turn/end` reason, some
80
+ * copy of this plugin is already in place, the session has no callable `append`,
81
+ * or it refused the own property.
82
+ */
83
+ export type BackstopOutcome = 'patched' | 'already-guarded' | 'no-append' | 'not-writable';
84
+ /** How one durable backstop is told to behave. */
85
+ export interface BackstopOptions {
86
+ /** What to do with an unreadable `turn/end` reason. */
87
+ readonly mode: GuardMode;
88
+ /** Reason text to record in place of a cause that was never seen. */
89
+ readonly reason?: string | undefined;
90
+ /** Bound on the reason text the backstop mints. */
91
+ readonly maxReasonChars: number;
92
+ /** Called once per repaired (or, in `observe` mode, merely reported) row. */
93
+ readonly onRepaired: (report: RepairedTurnEnd) => void;
94
+ }
95
+ /** One `turn/end` row the backstop repaired, as it was reported. */
96
+ export interface RepairedTurnEnd {
97
+ /** The turn number the row carried, for the log line. */
98
+ readonly turn: unknown;
99
+ /** The reason as it arrived — `null`, absent, or a value no reader can dereference. */
100
+ readonly original: unknown;
101
+ /** The `hook` member the replacement row carries. */
102
+ readonly normalized: {
103
+ readonly kind: 'hook';
104
+ readonly reason: string;
105
+ };
106
+ /** How the original was described for the log. */
107
+ readonly described: string;
108
+ }
109
+ /** The handle {@link guardSessionAppend} returns: how it went, and an undo. */
110
+ export interface BackstopHandle {
111
+ /** What happened when the backstop was installed. */
112
+ readonly outcome: BackstopOutcome;
113
+ /** Restore the session's own `append`, when this backstop installed one. */
114
+ readonly restore: () => void;
115
+ }
116
+ //# 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 keeps one abusive cancellation from making a whole conversation view unreadable. ReactLoopAgent.cancel hands its argument 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 — the non-null assertion on that 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, newly created ones included. Reported four times (#8832, #9156, #9646, #9699). This plugin closes the defect at two depths. At the entry: 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, and one property write covers every instance-level caller because ReactLoopAgent.cancel is a prototype method and the shadow is an own property. At the write: the same edge hands over the live session, and the plugin shadows that session's append too, so a turn/end row whose reason no reader can dereference is committed as { kind: 'aborted', reason: { kind: 'hook', reason: <text> } } instead — the arm that covers a caller which reaches the class rather than the instance, or an instance that refuses the own cancel property. Every rewrite and every repaired row 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, leaves any reason a reader can dereference untouched (a plugin's own TurnEndReasonMap variant included), and cannot repair rows already on disk. Zero runtime dependencies beyond the harness packages it declares.",
4
+ "version": "0.2.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
+ }