dsh-llm-fallbacks 0.5.0 → 0.5.2

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,156 @@
1
+ /**
2
+ * In-session role notice row (plan role-based-subagent-adoption Task 3).
3
+ *
4
+ * The child of a role-carrying dispatch gets ONE durable transcript row naming
5
+ * the role it was dispatched as, so the role is visible where the work is read
6
+ * — inside the child's own session — without reaching for live plugin state.
7
+ *
8
+ * Channel: an `agent/pre-step` listener that appends EXACTLY ONE message to the
9
+ * decision the loop is about to enter (`decision.messages`), which the loop
10
+ * then commits durably on the step's first attempt
11
+ * (`agent-loop/src/agent.ts:373-378` — `if (firstAttempt) for (const message of
12
+ * decision.messages)` the loop commits each message to the session log as a
13
+ * `'user/message'`). The listener is registered with `{ prepend: true }` (the
14
+ * `modelSwitchNotice` precedent in
15
+ * `core/agent/src/model-selection.ts:108-125`), so it is the OUTERMOST layer:
16
+ * `await next()` yields the FINAL decision every other listener shaped. The
17
+ * contract is EXACTLY ONE row, not its position: a plugin that registers its
18
+ * own `{ prepend: true }` `agent/pre-step` listener AFTER this one sits outside
19
+ * it and may append after the notice, and the loop commits the decision's
20
+ * messages in order — so the row is appended to whatever the inner listeners
21
+ * shaped, not guaranteed to be the last one.
22
+ *
23
+ * NOT `agent.inject()`: that queue is documented as lossy — "It may miss a
24
+ * request whose pre-step already claimed its batch. Cancellation or disposal
25
+ * may discard pending context." (`core/agent/src/runtime-types.ts:231-241`), and
26
+ * with `wakeup: false` an idle child is never woken to receive it. The pre-step
27
+ * decision has neither failure mode.
28
+ *
29
+ * Session-write contract (plan Global Constraints, hard): the row is a
30
+ * `user/message` built with `createUserMessage` and the sanctioned plugin source
31
+ * `{ kind: 'plugin', plugin: 'dsh-llm-fallbacks', form: 'notice', summary: … }`.
32
+ * `plugin` is a member of the FROZEN released source-kind set the V2→V3 edge
33
+ * classifies (`dsh-session-format-v2-to-v3/lib/index.js:14-30`: 15 kinds,
34
+ * includes `plugin`); a bespoke kind would write fine and then refuse at the
35
+ * next frozen edge with `cannot safely transform unclassified message source`
36
+ * (`:125`). NO custom session event type, NO `ignorable` marker — a mount-only
37
+ * plugin cannot publish either (`.mstar/projects/_default/references/
38
+ * subagent-role-badge-visible/explore-role-seam-report.md` §Q3). The guard test
39
+ * in `tests/role-notice.spec.ts` pins that membership against a checked-in
40
+ * mirror of the set.
41
+ *
42
+ * Once per child SESSION: the emitter consumes the seam's per-child record
43
+ * marker (`firstNoticePending`) AND a per-agent `emitted` set (mirroring the
44
+ * existing `dispatchInjected` pattern), both written only AFTER the row is built
45
+ * — so a throwing producer degrades without consuming the child's one notice.
46
+ * Never for a root agent, never for `inherit`/unresolved (those never get a
47
+ * record), never twice across steps or within-step retries.
48
+ *
49
+ * CF-6 — the emitted marker is SESSION-stable and bounded. `Agent.id` IS the
50
+ * child session id, and a continuable child's durable session outlives its
51
+ * activation, so the caller (`src/index.ts`) keeps this marker across
52
+ * `agent/disposed` and clears it only when the plugin itself is disposed; that
53
+ * is what makes the documented "exactly one notice row per child session" hold
54
+ * across a dispose + re-activate resume. Because it is never per-activation
55
+ * cleared it must be bounded: {@link markNoticeEmitted} evicts the oldest entry
56
+ * once {@link NOTICE_EMITTED_LIMIT} sessions are remembered (a child evicted
57
+ * that way can at worst be announced a second time, and the projection fold is
58
+ * last-wins, so no wrong role is ever produced).
59
+ *
60
+ * Degrade-never-crash: any throw in the path is contained with ONE debug line
61
+ * and the decision is returned UNCHANGED — a step is never broken, and no
62
+ * partial row is ever published.
63
+ *
64
+ * @module dsh-llm-fallbacks/role-notice
65
+ */
66
+ import type { Context } from '@deepseek-ai/cordis';
67
+ import { type UserMessage } from '@deepseek-ai/dsh-llm';
68
+ import type { SubagentSeamRecord } from './subagents-seam.ts';
69
+ /**
70
+ * The `plugin` field of every notice this plugin writes. Stable: the durable
71
+ * source's provenance label is read from it (`ui-conversation`
72
+ * `context-provenance.ts` — "the plugin id, or the bare source kind").
73
+ */
74
+ export declare const ROLE_NOTICE_PLUGIN = "dsh-llm-fallbacks";
75
+ /**
76
+ * The message source KIND the notice rides. `plugin` is the sanctioned kind for
77
+ * a plugin-authored notice and a member of the frozen released set (see the
78
+ * module header); the value is exported so the guard test asserts on the exact
79
+ * literal the emitter uses.
80
+ */
81
+ export declare const ROLE_NOTICE_SOURCE_KIND = "plugin";
82
+ /** Suffix appended when the role declares a persona that was NOT delivered. */
83
+ export declare const ROLE_NOTICE_PERSONA_SKIPPED_SUFFIX = " (persona not applied)";
84
+ /**
85
+ * How many child sessions whose notice was already emitted stay remembered
86
+ * (CF-6). The marker is session-stable — kept across `agent/disposed` so a
87
+ * re-activated continuable child is not announced twice — so it needs a bound:
88
+ * past this many sessions the oldest entry is evicted (worst case: a later
89
+ * re-activation of that evicted child writes a second identical row; the
90
+ * projection fold is last-wins, so the pill never names a wrong role).
91
+ */
92
+ export declare const NOTICE_EMITTED_LIMIT = 256;
93
+ /**
94
+ * Mark one child session as announced, evicting the oldest remembered session
95
+ * once {@link NOTICE_EMITTED_LIMIT} is reached (insertion-ordered `Set`
96
+ * semantics: the oldest entry is the first value). A session already marked
97
+ * keeps its position — its marker is only ever written once.
98
+ */
99
+ export declare function markNoticeEmitted(emitted: Set<string>, childSessionId: string): void;
100
+ /** One notice-message producer: role id + whether the declared persona was skipped. */
101
+ export type RoleNoticeBuilder = (role: string, personaNotApplied: boolean) => UserMessage;
102
+ /**
103
+ * Build the ONE role notice row (pure): `[role: <id>]`, with
104
+ * ` (persona not applied)` appended when the role declares a persona that Task
105
+ * 2's capability gate declined to deliver. The summary keeps `role: <id>` either
106
+ * way — it is the collapsed row's one-line account, and the durable role string
107
+ * must live there as well as in the content.
108
+ *
109
+ * @param role - the DECLARED RAW role id resolved at the dispatch seam.
110
+ * @param personaNotApplied - `true` only when a declared persona was skipped.
111
+ */
112
+ export declare function buildRoleNotice(role: string, personaNotApplied: boolean): UserMessage;
113
+ /** Options for {@link installRoleNotice}. */
114
+ export interface RoleNoticeOptions {
115
+ /**
116
+ * The dispatch seam's per-child records, keyed by child SESSION id (which IS
117
+ * `Agent.id`). Read at each pre-step — never captured — so a record written by
118
+ * a dispatch that races the child's first step is still seen.
119
+ */
120
+ records: Map<string, SubagentSeamRecord>;
121
+ /**
122
+ * Per-agent marker of children whose notice was already emitted (mirrors the
123
+ * runtime's `dispatchInjected`): the in-memory half of the once-per-child
124
+ * guarantee, independent of the record's `firstNoticePending` (which a record
125
+ * rewrite preserves deliberately) and of the loop's retry bookkeeping.
126
+ *
127
+ * SESSION-stable and bounded (CF-6): the caller keeps it across
128
+ * `agent/disposed` so a re-activated child is never announced twice, and
129
+ * {@link markNoticeEmitted} bounds it. ROOT-shared across the fibers applied
130
+ * over one root (CF-5), so a second fiber's emitter cannot announce a child
131
+ * the first already announced.
132
+ */
133
+ emitted: Set<string>;
134
+ /**
135
+ * Message producer. Defaults to {@link buildRoleNotice}; the only reason to
136
+ * replace it is to pin the contained-degrade path (a throwing producer must
137
+ * publish no row and must NOT consume the child's one notice).
138
+ */
139
+ buildNotice?: RoleNoticeBuilder;
140
+ /** Contained debug sink (at most ONE line per contained degrade). */
141
+ debug?: (message: string) => void;
142
+ }
143
+ /**
144
+ * Register the `agent/pre-step` notice emitter on `ctx`; returns its disposer
145
+ * (the owning fiber's teardown registers it, exactly like the seam's other
146
+ * listeners).
147
+ *
148
+ * Emission order per pre-step (cheapest first, all read-only until the emit):
149
+ * `next()` → reject/abort → empty decision → `origin === 'subagent'` → already
150
+ * emitted → pending record. The empty-decision guard is NOT cosmetic: the loop
151
+ * skips a step whose admitted messages are empty (`phase.step === 0 &&
152
+ * decision.messages.length === 0` → turn completed, no model call), so a notice
153
+ * pushed into an empty decision would turn a no-op step into a model call whose
154
+ * only input is our row.
155
+ */
156
+ export declare function installRoleNotice(ctx: Context, options: RoleNoticeOptions): () => void;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The session-projection contract of the subagent role pill (plan
3
+ * role-based-subagent-adoption Task 3b): the ONE key the host fold
4
+ * (`./role-projection.ts`) publishes and the session-header badge reads.
5
+ *
6
+ * PURE module — no `@deepseek-ai/*` import, no host-only dependency — because
7
+ * BOTH halves import it: the host through the seam's install point, the client
8
+ * through `SubagentRoleBadge`. Splitting the key (and the value guard) out of
9
+ * the host module is what keeps the client half free of the fold's host
10
+ * imports, and a single shared literal is what keeps the two halves from
11
+ * drifting into a silent "no pill".
12
+ *
13
+ * @module dsh-llm-fallbacks/role-projection-key
14
+ */
15
+ /**
16
+ * The projection key the role pill reads (`useProjection(ROLE_PROJECTION_KEY)`).
17
+ * Lower-camel like the host's own session-scoped keys (`modelSelection`,
18
+ * `sessionListMetadata`); the `fallbacks` prefix keeps a third-party key
19
+ * unambiguous inside the shared `SessionProjectionMap`.
20
+ */
21
+ export declare const ROLE_PROJECTION_KEY = "fallbacksSubagentRole";
22
+ /**
23
+ * The client-visible value of the role projection: the DECLARED RAW role id the
24
+ * child was dispatched as (padding included — it is the id the settings
25
+ * document declares), or `null` while the child's log carries no role notice
26
+ * row (`./role-notice.ts`).
27
+ *
28
+ * A role id, NOT a record: the route is a SEPARATE host projection
29
+ * (`modelSelection`), because the notice row is written once at the child's
30
+ * first step while the route it ends up on can change afterwards.
31
+ */
32
+ export type RoleProjectionValue = string | null;
33
+ /**
34
+ * Read a projection value defensively (degrade-never-crash): only a non-blank
35
+ * string is a role. `undefined` — the key is not registered on this host, or no
36
+ * baseline/frame has carried it yet — and every foreign value read as "no
37
+ * role", so the pill collapses to nothing instead of naming something wrong.
38
+ *
39
+ * @param value - the raw `useProjection('<key>')` value.
40
+ */
41
+ export declare function readRoleProjectionValue(value: unknown): string | undefined;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Durable role visibility for the session-header badge (plan
3
+ * role-based-subagent-adoption Task 3b) — ONE host-side session projection unit
4
+ * folding the role out of the child's OWN durable log.
5
+ *
6
+ * Data source = Task 3's notice row, the plugin's single write primitive: the
7
+ * fold reads the child's committed `user/message` events, keeps the one whose
8
+ * provenance is ours (`source.kind === 'plugin'` + `source.plugin ===
9
+ * ROLE_NOTICE_PLUGIN`), and parses the role out of its **content text**
10
+ * (`[role: <id>]`). No second write path, no new event type, no plugin-owned
11
+ * store: the projection is a pure READ of the log, and the host's projection
12
+ * cache checkpoints every registered unit at session creation, `turn/end` and
13
+ * disposal — so a settled child (served as an unpublished observation whose
14
+ * `projections` ride the follow opening snapshot) and a post-restart view both
15
+ * work with nothing of ours on disk.
16
+ *
17
+ * NEVER `source.summary`: summaries are truncated at
18
+ * `CONTEXT_SUMMARY_MAX_CHARS = 120` (`@deepseek-ai/dsh-llm` `message.ts`,
19
+ * applied by `boundContextSummary`) while a role id has no length bound in the
20
+ * settings schema — a long id would be CUT there and the pill would name a role
21
+ * that does not exist. The content blocks are unbounded, so the content text is
22
+ * the only correct source; the row's provenance is what makes the pre-filter
23
+ * cheap.
24
+ *
25
+ * Mount-only + STRUCTURAL: the registry and the unit contract are consumed as
26
+ * the local structural views below and this module value-imports no host seam,
27
+ * so the host calls INTO us. The declared
28
+ * `@deepseek-ai/dsh-session-projection` peerDependency (Task 3b L2 review M-2)
29
+ * serves the in-process Step-0 spec, which builds the REAL registry — being the
30
+ * real registry is the whole point of that proof — not a runtime import here
31
+ * (the badge reads its seat structurally for the same reason).
32
+ * The runtime shape relied on — `register({ key, stateSchema, init, apply,
33
+ * wire: { viewSchema, view }, stateVersion })` → unregister disposer — is the
34
+ * documented `ProjectionDefinition` contract of
35
+ * `@deepseek-ai/dsh-session-projection@0.1.5-rc.1`
36
+ * (`lib/types/index.d.ts:38-80,150-152`); only `.parse` is ever called on the
37
+ * schemas (`lib/index.js:255,305,433`).
38
+ *
39
+ * @module dsh-llm-fallbacks/role-projection
40
+ */
41
+ import type { Context } from '@deepseek-ai/cordis';
42
+ import type { SessionEvent } from '@deepseek-ai/dsh-session';
43
+ import { type RoleProjectionValue } from './role-projection-key.ts';
44
+ /**
45
+ * The `.parse` face the registry uses on a definition's `stateSchema` /
46
+ * `wire.viewSchema` (a structural stand-in for the peer's `ZodType`, so this
47
+ * module needs no zod import and no new peer).
48
+ */
49
+ interface ProjectionValueSchema<T> {
50
+ parse(value: unknown): T;
51
+ }
52
+ /**
53
+ * One registered projection unit, as this plugin declares it (the consumed
54
+ * subset of the host's `ProjectionDefinition`): `init` and `apply` are PURE and
55
+ * SYNCHRONOUS, `state` is plain JSON (the persisted-checkpoint precondition),
56
+ * and `wire` publishes the client-visible value.
57
+ */
58
+ export interface RoleProjectionUnit {
59
+ readonly key: string;
60
+ readonly stateSchema: ProjectionValueSchema<RoleProjectionState | null>;
61
+ init(header: unknown, inheritedEventCount: number): RoleProjectionState | null;
62
+ apply(state: RoleProjectionState | null, event: SessionEvent): RoleProjectionState | null;
63
+ readonly stateVersion: number;
64
+ readonly wire: {
65
+ readonly viewSchema: ProjectionValueSchema<RoleProjectionValue>;
66
+ view(state: RoleProjectionState | null): RoleProjectionValue;
67
+ };
68
+ }
69
+ /**
70
+ * The host fold state: the fork boundary the fold was initialized with, plus the
71
+ * resolved role once an OWN notice row has landed (`role` absent until then).
72
+ * An OBJECT, not a bare string, because the registry's publication rule is
73
+ * reference identity (`Object.is`) — the fold must be able to prove it left the
74
+ * state untouched, and that proof is only meaningful for a value the fold could
75
+ * otherwise re-allocate.
76
+ *
77
+ * `inheritedEventCount` is part of the state, not a closure constant: the
78
+ * registry hands `init` the exact fork-inherited prefix length and then folds
79
+ * the WHOLE log into `apply`, so the boundary has to survive into every `apply`
80
+ * call (and into a persisted checkpoint row) for the fold to skip an ancestor's
81
+ * events.
82
+ */
83
+ export interface RoleProjectionState {
84
+ /**
85
+ * The exact fork-inherited prefix length `init` was handed: events with
86
+ * `seq < inheritedEventCount` belong to the ANCESTOR the child was seeded
87
+ * from, never to the child.
88
+ */
89
+ readonly inheritedEventCount: number;
90
+ /** The DECLARED RAW role id, verbatim (padding included); absent until then. */
91
+ readonly role?: string;
92
+ }
93
+ /**
94
+ * The registry face this plugin uses (`ctx.sessionProjections`): the
95
+ * client-visible `register` overload plus the read faces the in-process proof
96
+ * reads. Structural on purpose — see the module header.
97
+ */
98
+ export interface RoleProjectionRegistryView {
99
+ register(unit: RoleProjectionUnit): () => void;
100
+ }
101
+ /**
102
+ * The projection state/version. Bump `stateVersion` whenever the fold changes —
103
+ * v2 added the fork boundary (`inheritedEventCount`), so a v1 checkpoint row is
104
+ * NOT re-usable and must be refolded from the log.
105
+ */
106
+ export declare const ROLE_PROJECTION_STATE_VERSION = 2;
107
+ /**
108
+ * The ONE role projection unit (plan Task 3b).
109
+ *
110
+ * `init` records the fork boundary and nothing else (a child's own log carries
111
+ * no role yet), `apply` skips every event BELOW that boundary — the seeded
112
+ * prefix is the ANCESTOR's log, and the plan pins "child whose notice never
113
+ * landed: no pill, never a wrong pill" — and otherwise folds last-wins over our
114
+ * own notice rows, returning the SAME state reference for every event it does
115
+ * not use (the registry's `Object.is` rule: an unchanged reference produces zero
116
+ * downstream work and no client publication) — including a repeated notice with
117
+ * the same role, which is what makes the fold idempotent under a replay.
118
+ *
119
+ * The boundary carries into the state because the registry calls `apply` with
120
+ * the WHOLE log (including the fork prefix) on every rung: `buildCell`,
121
+ * `drive`'s late-registration fold and `restore` all fold from `init` forward.
122
+ * The host's own sibling unit in the same tree does exactly this
123
+ * (`@deepseek-ai/dsh-subagent` `subagentCatalogProjectionDefinition`:
124
+ * `init: (_header, inheritedEventCount) => ({ inheritedEventCount })` and
125
+ * `apply: … || event.seq < state.inheritedEventCount → return state`).
126
+ */
127
+ export declare const roleProjectionUnit: RoleProjectionUnit;
128
+ /** Options for {@link installRoleProjection}. */
129
+ export interface RoleProjectionOptions {
130
+ /** Contained debug sink (at most ONE line per contained degrade). */
131
+ debug?: (message: string) => void;
132
+ }
133
+ /**
134
+ * Register {@link roleProjectionUnit} on `ctx.sessionProjections` through the
135
+ * same conditional-inject idiom the plugin already uses for `settings` / `llm` /
136
+ * `typert`: the child activates only when the registry is composed, so a
137
+ * composition without it keeps the key absent and the badge renders nothing
138
+ * (capability absence — never a dispatch failure, never a crash).
139
+ *
140
+ * Registration is an effect on the child fiber: a second apply over the same
141
+ * root registers the SAME key at the SAME `stateVersion`, which the registry
142
+ * counts (one unit, the key survives until the last holder unloads) — so this
143
+ * needs no dedupe of its own, and a reshaped registry that refuses the
144
+ * registration degrades with one contained debug line.
145
+ *
146
+ * @param ctx - the plugin fiber's context.
147
+ * @param options - contained debug sink.
148
+ * @returns the disposer that withdraws the child (and with it the key).
149
+ */
150
+ export declare function installRoleProjection(ctx: Context, options?: RoleProjectionOptions): () => void;
151
+ export {};
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Dispatch-seam role resolution + per-child record (plan
3
+ * role-based-subagent-adoption Task 1).
4
+ *
5
+ * The seam is the documented cordis service-read waterfall
6
+ * (`Events['internal/get']`, `@deepseek-ai/cordis` 4.0.2
7
+ * `lib/types/events.d.ts`): a listener wraps the `subagents` service VALUE on
8
+ * read and returns a prototype-delegating wrapper whose `start` /
9
+ * `startContinuable` resolve the dispatch role from the incoming request
10
+ * BEFORE delegating. The real service object is never mutated; readers of the
11
+ * `subagents` service transparently receive the wrapper.
12
+ *
13
+ * Role source (plan Global Constraints): the child request's prompt carries
14
+ * the Assignment header field `**Execute as**: <id>` — dsh role binding is
15
+ * prompt-only. {@link resolveDeclaredRoleFromAssignment} is the ONE pure
16
+ * resolution point; it canonicalizes exactly like the existing trimmed-id
17
+ * map (trim, drop ONE leading `@`, case-insensitive match against the
18
+ * declared trimmed ids) and returns the DECLARED RAW id. Undeclared, absent,
19
+ * `inherit`, or ambiguous declarations resolve to `undefined` = no role.
20
+ *
21
+ * The resolution reads the Assignment HEADER region only, never the whole
22
+ * prompt (Task 1 CF-2): the region ends at the first Assignment body marker,
23
+ * and when the prompt carries NO marker it is bounded to the LEADING HEADER
24
+ * BLOCK — the first paragraph plus every following paragraph that opens with
25
+ * header material, ending at the prompt's first prose paragraph. So a
26
+ * `**Execute as**: <id>` line quoted inside a body paragraph can never shape a
27
+ * resolution (it is outside the region) even though mstar's canonical dispatch
28
+ * text carries no body marker at all. Inside the region the engine's pinned
29
+ * ambiguity rule stands unchanged: the DISTINCT DECLARED ids must collapse to
30
+ * exactly one, else the dispatch is a no-op — never a first-occurrence pick.
31
+ *
32
+ * Per-child record: the wrapped start's RESULT carries the child session id —
33
+ * `start` resolves to a `SubagentRun` (`id` IS the published child session id
34
+ * for a local run) and `startContinuable` to a `ContinuableStart` (`childId`).
35
+ * The record `{ role, at, firstNoticePending }` is keyed by that id (Task 3
36
+ * reads it to emit the one role notice row). A result carrying only a
37
+ * `jobId` — a registry job id, NOT a session id — is never keyed: the child is
38
+ * correlated instead through the parent-owned `subagent/catalog` session event
39
+ * `{ childId, label }` joined on the delegating parent + request label.
40
+ *
41
+ * Persona delivery (plan Task 2): the SAME single resolution point merges the
42
+ * declared role's `roles.list[].persona` (trimmed, non-empty) into the
43
+ * request's NATIVE `persona` slot — the slot the runtime composes as the
44
+ * scoped `deployment:persona-prefix` system-prompt section on the child. The
45
+ * source is the RESOLVED ROLE only, so delivery is chain-independent: a
46
+ * `chain: []` role gets its persona exactly like a chained one (no routing
47
+ * state is read). Both native start surfaces are covered, with the gate each
48
+ * one's fail-loud contract needs (measured on the installed
49
+ * `@deepseek-ai/dsh-subagent` `0.1.5-rc.1`): the one-shot `start` REJECTS a
50
+ * request carrying `persona` for a provider whose
51
+ * `getProvider(name).capabilities.persona` is not `true` (`lib/index.js:3202-3227`,
52
+ * `assertCapabilities`), so the merge pre-checks that flag; the continuable
53
+ * surface is composed by the continuation manager, which applies
54
+ * `request.persona` unconditionally (`lib/index.js:1703-1705`) and is gated by
55
+ * the provider's `prepareContinuable` presence instead (`lib/index.js:3179-3183`).
56
+ * A gate miss skips the persona with ONE contained debug log — never a failed
57
+ * start. An explicit caller `persona` WINS (the slot is filled only when it is
58
+ * absent), and every skip path returns the caller's OWN request object, so the
59
+ * native call stays byte-identical.
60
+ *
61
+ * In-session notice row (plan Task 3): the SAME install point registers the
62
+ * `agent/pre-step` emitter (`./role-notice.ts`) over the record map it owns, so
63
+ * the role is announced once in the child's own session. Its per-agent
64
+ * `noticeEmitted` marker is exposed here for the caller's cleanup sites: the
65
+ * plugin dispose effect clears it, while `agent/disposed` deliberately KEEPS it
66
+ * (CF-6 — the marker is SESSION-stable and bounded, because a continuable
67
+ * child's durable session survives its activation).
68
+ *
69
+ * Durable role visibility (plan Task 3b): the SAME install point registers the
70
+ * host-side session projection unit (`./role-projection.ts`) that folds that
71
+ * notice row out of the child's own log, so the session-header badge can show
72
+ * the role for a settled child and after a host restart — a read of the SAME
73
+ * single write primitive, never a second one.
74
+ *
75
+ * Multi-fiber composition (CF-5): the record map and the notice marker are
76
+ * ROOT-shared, while only the `internal/get` wrapper (+ the catalog listener it
77
+ * feeds) is single-owner. Every applied fiber registers its OWN notice emitter
78
+ * and projection unit and reports its ownership structurally
79
+ * (`SubagentSeam.ownsWrapper`), so a disposing fiber cannot silently take the
80
+ * role surfaces down with it.
81
+ *
82
+ * Degrade-never-crash: an absent/reshaped service, an unexpected result
83
+ * shape, or any throwing bookkeeping degrades to the native path with at most
84
+ * ONE contained debug log — a dispatch is never affected.
85
+ *
86
+ * @module dsh-llm-fallbacks/subagents-seam
87
+ */
88
+ import type { Context } from '@deepseek-ai/cordis';
89
+ import { type FallbacksRole } from './config.ts';
90
+ /**
91
+ * The cordis service-read waterfall event the seam registers on. Referenced
92
+ * ONCE (registration) so a future cordis rename is a one-line change here.
93
+ */
94
+ export declare const SUBAGENT_SEAM_EVENT = "internal/get";
95
+ /** The service name the seam wraps (`ctx.subagents`). */
96
+ export declare const SUBAGENT_SEAM_SERVICE = "subagents";
97
+ /**
98
+ * One dispatch-resolved per-child record (Task 3 reads it to emit exactly one
99
+ * role notice row per child session).
100
+ */
101
+ export interface SubagentSeamRecord {
102
+ /**
103
+ * The DECLARED RAW role id resolved at the dispatch seam.
104
+ *
105
+ * Authoritative-role precedence (CF-10, documented rather than resolved by
106
+ * re-branding): a `startContinuable` resume re-keys the child and may resolve
107
+ * a DIFFERENT declared role, and this field is then rewritten while the
108
+ * durable notice row the child's session already carries keeps the FIRST role.
109
+ * The projection deliberately reads the notice row, so the durable role is the
110
+ * child's announced identity and this record is only the LIVE dispatch hint
111
+ * (what the next start / the resume fallback would use). The two can therefore
112
+ * disagree after a re-dispatch until the child is disposed; no consumer may
113
+ * treat the record as the durable role.
114
+ */
115
+ role: string;
116
+ /** When the record was written (epoch ms). */
117
+ at: number;
118
+ /**
119
+ * `true` until the child's role notice row has been emitted (Task 3). A
120
+ * later write for the SAME child session preserves an already-cleared
121
+ * marker, so a repeat dispatch/resume cannot produce a second notice row.
122
+ * The root-shared `noticeEmitted` set is the SESSION-stable half of that
123
+ * guarantee (it survives `agent/disposed`); this field is the per-record half
124
+ * that a resume rewrite preserves.
125
+ */
126
+ firstNoticePending: boolean;
127
+ /**
128
+ * `true` when the role DECLARES a persona and that persona was NOT delivered
129
+ * (Task 2's capability gate declined it, or no provider was registered to
130
+ * carry it). Absent when there was nothing to report: the persona was
131
+ * delivered, the caller set one (explicit intent wins), the role declares
132
+ * none, or no persona source is wired. Task 3 appends
133
+ * ` (persona not applied)` on `true` only.
134
+ *
135
+ * STICKY (Task 3 review Minor 4): a record rewrite (a `startContinuable`
136
+ * resume re-keys the child) can only ADD this verdict, never clear it. A
137
+ * resume recomputes the merge outcome on its own surface — and that surface
138
+ * legitimately returns `false` when it delivers the CALLER's persona or when
139
+ * its own merge degrades — but neither retroactively installs the role's
140
+ * DECLARED persona on the dispatch that already reported it missing. The
141
+ * child's one notice row is a claim about that dispatch, so the first `true`
142
+ * survives; a later `false` is simply not recorded.
143
+ */
144
+ personaNotApplied?: boolean;
145
+ }
146
+ /** One consumed prompt content block (`@deepseek-ai/dsh-llm` `ContentBlock` text members). */
147
+ export interface SubagentPromptBlockView {
148
+ readonly type: string;
149
+ readonly text?: string;
150
+ }
151
+ /**
152
+ * Structural view of the one-shot start request (consumed fields only): the
153
+ * `prompt` is the role-extraction source, `label` + `parent` are the
154
+ * catalog-correlation join key, and `persona` is the NATIVE per-child persona
155
+ * slot the role persona is merged into (plan Global Constraints: an explicit
156
+ * caller value wins). The wrapper forwards the request object itself, so every
157
+ * other field reaches the service unchanged.
158
+ */
159
+ export interface SubagentStartRequestView {
160
+ readonly prompt?: readonly SubagentPromptBlockView[];
161
+ readonly label?: string;
162
+ readonly parent?: {
163
+ readonly session?: {
164
+ readonly id?: string;
165
+ };
166
+ };
167
+ readonly persona?: string;
168
+ }
169
+ /**
170
+ * Structural view of the continuable start spec (consumed fields only):
171
+ * `childId` is the caller-reserved identity a resume already carries, used as
172
+ * the record fallback when the resume request has no Assignment header.
173
+ */
174
+ export interface ContinuableStartSpecView {
175
+ readonly provider: string;
176
+ readonly label?: string;
177
+ readonly childId?: string;
178
+ readonly request: SubagentStartRequestView;
179
+ }
180
+ /** Options for {@link installSubagentSeam}. */
181
+ export interface SubagentSeamOptions {
182
+ /**
183
+ * Live declared-role id map (trimmed id → DECLARED RAW id), read per start
184
+ * so a settings change is observed without a re-install.
185
+ */
186
+ roleIds: () => ReadonlyMap<string, string>;
187
+ /**
188
+ * Live declared roles (`roles.list`) — the persona source, read per start so
189
+ * a settings/persona edit is observed without a re-install. Absent ⇒ the
190
+ * seam only records roles (no persona delivery).
191
+ */
192
+ roles?: () => readonly FallbacksRole[];
193
+ /** Contained debug sink (at most one line per contained degrade). */
194
+ debug?: (message: string) => void;
195
+ }
196
+ /** The installed seam (Task 3 surface + explicit teardown). */
197
+ export interface SubagentSeam {
198
+ /**
199
+ * Per-child records keyed by the child SESSION id (the wrapped start's
200
+ * result id). ROOT-shared (CF-5): every fiber applied over the same root reads
201
+ * the same map. Mutable by design: Task 3 clears `firstNoticePending` after
202
+ * emitting the notice.
203
+ */
204
+ readonly records: Map<string, SubagentSeamRecord>;
205
+ /**
206
+ * Per-agent marker of children whose role notice row was already emitted
207
+ * (mirrors the runtime's `dispatchInjected`): the in-memory half of Task 3's
208
+ * once-per-child guarantee, also ROOT-shared. SESSION-stable (CF-6): it is
209
+ * deliberately NOT cleared on `agent/disposed` — a continuable child's durable
210
+ * session survives the activation, so clearing the marker would let a
211
+ * re-activation resume append a second notice row to the same log. It is
212
+ * bounded (`NOTICE_EMITTED_LIMIT`) and cleared only in the plugin dispose
213
+ * effect.
214
+ */
215
+ readonly noticeEmitted: Set<string>;
216
+ /**
217
+ * `true` when THIS install owns the root-scoped `internal/get` wrapper (plus
218
+ * the catalog-correlation listener it feeds) — the only part that must have a
219
+ * single owner. `false` on a later fiber over the same root, which still
220
+ * registers its OWN notice emitter and projection unit. The callers' dedupe
221
+ * branch reads THIS field (CF-5): a structured marker, never a thrown
222
+ * message.
223
+ */
224
+ readonly ownsWrapper: boolean;
225
+ /** Stop intercepting service reads (the owning fiber's teardown also does). */
226
+ dispose(): void;
227
+ }
228
+ /**
229
+ * Cap on un-correlated claims (a `jobId`-only result whose catalog event never
230
+ * arrives). Bounded insertion-order eviction — recency-ordered, because a
231
+ * re-claimed hot key is re-inserted (`beginCorrelation` deletes before it sets),
232
+ * so the busiest claim can never be the one evicted.
233
+ *
234
+ * @internal Exported for the bound test (mirrors `subagentSeamOf`): the value is
235
+ * not part of the plugin's public surface, but a test that hardcoded 64 could
236
+ * not tell the real bound from a stale copy.
237
+ */
238
+ export declare const PENDING_CORRELATION_LIMIT = 64;
239
+ /**
240
+ * PURE dispatch-role resolution (plan Global Constraints): read the Assignment
241
+ * header field `**Execute as**: <id>` from `prompt` and return the DECLARED
242
+ * RAW role id, or `undefined` when nothing resolves. Never throws.
243
+ *
244
+ * - absent / empty / body-quoted-only → `undefined`
245
+ * - `inherit` (the reserved "no specific role" id) → `undefined`
246
+ * - undeclared id → `undefined` (never invent a role)
247
+ * - several DISTINCT declared ids in the header region → `undefined`
248
+ * (ambiguous); repeats of the same declared id (e.g. `coder` and `@CODER`)
249
+ * still resolve
250
+ *
251
+ * @param prompt - the Assignment text carried by the child request's prompt.
252
+ * @param roleIds - the declared trimmed-id map (`trimmed id → declared raw id`).
253
+ */
254
+ export declare function resolveDeclaredRoleFromAssignment(prompt: string, roleIds: ReadonlyMap<string, string>): string | undefined;
255
+ /**
256
+ * PURE, chain-independent persona lookup (plan Task 2 / Global Constraints):
257
+ * the DECLARED `persona` of `roleId`, trimmed, and only when it is still
258
+ * non-empty after the trim — a blank persona is NO persona. `roleId` is the
259
+ * DECLARED RAW id the seam already resolved, so this is an exact id match, not
260
+ * a second canonicalization pass.
261
+ *
262
+ * Only `id` and `persona` are read: no `chain`, no `rootChain`, no routing
263
+ * state — a `chain: []` role's persona resolves exactly like a chained one's.
264
+ *
265
+ * @param roles - the live declared roles (`roles.list`).
266
+ * @param roleId - the DECLARED RAW role id resolved at the seam.
267
+ */
268
+ export declare function personaForRole(roles: readonly FallbacksRole[], roleId: string): string | undefined;
269
+ /**
270
+ * @internal Test seam (mirrors `chainHeads`): the seam installed on
271
+ * `ctx`, if any. Not part of the plugin's public surface — lets tests read and
272
+ * seed the per-child record map without reaching into the installation
273
+ * closure. `undefined` when no seam is installed.
274
+ */
275
+ export declare function subagentSeamOf(ctx: Context): SubagentSeam | undefined;
276
+ /**
277
+ * Install the dispatch seam on `ctx`: wrap every `subagents` service read and
278
+ * resolve + record the dispatch role per start, and register this fiber's role
279
+ * surfaces (the notice emitter + the projection unit).
280
+ *
281
+ * Never throws for a multi-fiber apply (CF-5): a fiber whose root already owns
282
+ * the `internal/get` wrapper gets a seam with `ownsWrapper: false` — it shares
283
+ * the root's record map / notice marker (so exactly one notice row is written
284
+ * per child) and still installs its OWN notice emitter and projection unit, so
285
+ * neither surface dies when another fiber of the same root disposes.
286
+ *
287
+ * Returns the seam (the shared record map Task 3 reads, the ownership marker,
288
+ * plus an idempotent `dispose` that withdraws THIS fiber's registrations). The
289
+ * `internal/get` listener is owned by this module; callers that rely on the
290
+ * fiber teardown alone may ignore `dispose`.
291
+ */
292
+ export declare function installSubagentSeam(ctx: Context, options: SubagentSeamOptions): SubagentSeam;