/** * DshRoleSwitcher — in-session "switch active role" capability for the dsh * (DeepSeek Harness) platform. * * dsh is a multi-session, web-driven harness with no built-in agent picker on * the session surface. rolebox already resolves every role into an * {@link AgentDefinition} and registers them on the {@link DshAgentRegistrar} * (as `SubagentProvider`s into `ctx.subagents`). This module turns that * registry into a per-session role switcher consumed structurally through the * dsh seam: * * - {@link DshRoleSwitcher.listRoles} — the switchable targets (primary * roles only, sorted by id) * - {@link DshRoleSwitcher.activate} — switch to a role / clear it * - {@link DshRoleSwitcher.getActive} — the active role for a session * * What "switching" does on dsh (mirrors the Pi adapter's role switcher, with * platform-specific differences): * * 1. **Per-session state** — the chosen role id is recorded immediately in * a per-session holder (an exposed {@link ActiveRoleRef}, the * session-aware sibling of Pi's `ActiveAgentRef`). The holder is the * single shared source of truth for the switch: `DshAgentRegistrar` * reads it at spawn time (`buildProvider().start()` consults * `request.sessionId`) to apply the active role's system prompt and * model override to spawned agents, and the web role-switch surface * reads/writes it for the UI. * 2. **Persistence (sidecar remedy)** — durability lives in a rolebox-owned * sidecar under `.rolebox/state` (see {@link ActiveRolePersistence}, * backed by `active-role-store.ts`), NOT in the dsh session event log. * The former `rolebox/active-role` session event was removed: rc.6 * persistence refuses to reload an unknown event type that lacks the * `ignorable: true` marker, and there is no public `append()` option to * set it (). The holder is * hydrated synchronously from the sidecar at construction, every mutation * is written back asynchronously (best-effort), and the harness * `session/flush` checkpoint drains the sidecar durably * ({@link ActiveRoleRef.flush}, bounded + non-throwing). The plugin fiber * disposer additionally performs a final synchronous write * (`ActiveRoleStore.saveSync`) on shutdown. * 3. **Restore** — a `ctx.on("session/created")` listener resolves the new * session's selection with a fixed precedence: (1) its own sidecar entry * (including an explicit clear); (2) a read-only scan of its own log for * a previous `rolebox/active-role` event, adopted into the sidecar; (3) a * fork with neither inherits its parent's selection through the durable * `header.parentSession`. The session log is never written. A * stale selection (role no longer registered, or no longer primary) is * cleared rather than restored. * * The dsh platform has no per-turn system-prompt hook (`system-transform` * is a documented no-op at the hook level in hook-provider.ts: dsh composes * the model-facing system prompt from its mounted `systemPrompt` service, * §3.1). Session-level injection now flows through {@link DshSystemPromptAdapter} * (system-prompt.ts — the `rolebox:role` section + `rolebox:context` entry, * resolved per-session via `context.agent.id`). Spawn-time application for * subagents lives in {@link DshAgentRegistrar} (shared {@link ActiveRoleRef}, * wired in `src/dsh-plugin.ts`): the switcher owns write/restore; the * registrar and the prompt adapter own the read side. * * The dsh surface is consumed structurally (duck typing). This module does * NOT import `@deepseek-ai/*` (or `@opencode-ai/*`). * * @module */ import { type Result } from "../../../utils/result.ts"; import type { AgentDefinition } from "../../types.ts"; import type { ActiveRoleEntry } from "./active-role-store.ts"; import type { DshAgentRegistrar } from "./agent-registrar.ts"; import type { DshCordisContext } from "./event-bridge.ts"; import type { DshSessionStoreLike } from "./session.ts"; /** * Upper bound (ms) on a `session/flush` sidecar drain. The harness flush must * never block on a slow/unresponsive disk, so the flush handler races the * persist against this timeout and resolves regardless. */ export declare const ACTIVE_ROLE_FLUSH_TIMEOUT_MS = 2000; /** * Persistence seam for the active-role sidecar. * * Declared here (rather than importing the concrete {@link ActiveRoleStore}) * so the switcher depends on an injected interface; the file-backed store in * `active-role-store.ts` satisfies it structurally, and the concrete wiring * lives in `src/dsh-plugin.ts`. All methods are synchronous on read and * best-effort on write — persistence must never fail a role switch. */ export interface ActiveRolePersistence { /** * Synchronously load the persisted session→role selections. * * @returns A session-keyed map, or `null` when nothing usable was read. */ load(): Map | null; /** * Persist the full session-keyed map. Async implementations serialize their * own writes; the caller does not await (the in-memory holder is * authoritative for the running process). * * @param sessions - The holder's current state (mutated in place thereafter). */ save(sessions: Map): Promise | void; } /** * Session-aware mutable holder for the currently active role — the dsh * sibling of the Pi adapter's {@link ActiveAgentRef} pattern. * * `get` is a synchronous in-memory read (safe to call from spawn/prompt hot * paths). `null` (or an absent session key) means "base agent": no rolebox * role is active for that session. `set` updates memory synchronously and * schedules an asynchronous sidecar write when the holder was created with a * {@link ActiveRolePersistence}. */ export interface ActiveRoleRef { /** Return the active role id for a session, or `null` for the base agent. */ get(sessionId: string): string | null; /** * True when the holder has an explicit entry for `sessionId` — including an * explicit clear (`get` returns `null`). Distinguishes "a record says base * agent" from "no record at all", which the restore precedence needs in * order to fall back to a legacy event only when the sidecar is silent. */ has(sessionId: string): boolean; /** Set the active role id for a session, or `null` to clear back to base. */ set(sessionId: string, id: string | null): void; /** * Snapshot the full session→entry map (a shallow copy). The shutdown path * hands this to a synchronous store write; the copy keeps the holder's live * map private from the caller. */ snapshot(): Map; /** * Await a durable persist of the current map. Bounded (races * {@link ACTIVE_ROLE_FLUSH_TIMEOUT_MS}) and non-throwing — a slow or failing * sidecar write resolves normally so the harness `session/flush` never blocks * or errors on it. */ flush(): Promise; } /** * Create an {@link ActiveRoleRef} backed by a per-session Map. * * When `persistence` is supplied the map is hydrated synchronously from * `persistence.load()` at construction (so readers see the restored selection * immediately), and each `set` writes the map back through `persistence.save` * fire-and-forget. A load failure degrades to an empty in-memory map. * * @param persistence - Optional sidecar persistence seam. * @returns A fresh, independent holder. */ export declare function createActiveRoleRef(persistence?: ActiveRolePersistence): ActiveRoleRef; /** * Options for constructing a {@link DshRoleSwitcher}. */ export interface DshRoleSwitcherOptions { /** Registry holding all resolved agent definitions (roles + subagents). */ registrar: DshAgentRegistrar; /** * The dsh SessionStore (`ctx.sessions`), used by the `session/created` * restore listener to resolve a session (and its `header.parentSession`) from * an id-only payload. */ store: DshSessionStoreLike; /** Structural cordis context (`ctx.on` / `ctx.emit`) for lifecycle listeners. */ ctx: DshCordisContext; /** * Shared per-session active-role holder (ActiveAgentRef-style). When * omitted, a private in-memory holder is created. The holder is always * exposed via {@link DshRoleSwitcher.activeRole}, so external consumers * (e.g. a web role-switch server) can read the current state and keep it in * sync. Pass the SAME holder (created with the sidecar persistence) that the * registrar and prompt adapter read. */ activeRole?: ActiveRoleRef; /** * Optional refresh hook invoked once after every APPLIED active-role change: * a switch ({@link DshRoleSwitcher.activate}), an explicit clear, or a * restore on `session/created` (which changes the candidate role set just as * a switch does). The dsh plugin wires this to the lazy skill provider's * `invalidate()` so the `ctx.skills` catalog is refreshed whenever the active * role set changes. * * Best-effort: a throwing hook is contained by a debug log and never fails * the switch. Invoked exactly once per applied change. */ onActiveRoleChanged?: (sessionId: string, roleId: string | null) => void; } /** * In-session active-role switcher for the dsh platform. * * Keeps the currently active role per session in the shared holder, persists * each switch through the holder's sidecar seam, and re-validates the restored * selection when a session is created (seeds / forks / resume). * * All state mutations are defensive: an unknown session in the store or a * failed sidecar write degrades to a debug log — validation only rejects an * unknown or non-primary role id, per {@link activate}. */ export declare class DshRoleSwitcher { /** Exposed per-session active-role holder (backed by a per-session Map). */ readonly activeRole: ActiveRoleRef; private readonly registrar; private readonly store; /** Optional refresh hook fired once per applied active-role change. */ private readonly onActiveRoleChanged; /** Cordis disposers returned by `ctx.on` — released by `dispose()`. */ private readonly disposers; private readonly _log; /** * @param options - See {@link DshRoleSwitcherOptions}. */ constructor(options: DshRoleSwitcherOptions); /** * List the switchable roles — primary-mode roles only, sorted by id. * * Subagent-mode roles are deliberately excluded: switching targets are the * top-level roles, matching the Pi adapter's switcher. * * @returns The switchable agent definitions, sorted by id ascending. */ listRoles(): AgentDefinition[]; /** * Return the active role id for a session, or `null` when no role is active * (base agent). * * @param sessionId - The dsh session id. * @returns The active role id, or `null`. */ getActive(sessionId: string): string | null; /** * Activate a role for a session, or clear the active role when `roleId` is * `null`. * * Validates that the role exists in the current catalog and is a primary * role. On success the per-session holder is updated (memory synchronously, * sidecar asynchronously via the holder's persistence seam). The write is * best-effort: a failed sidecar save is logged and does not fail the switch. * * @param roleId - Role id to activate, or `null` to clear. * @param sessionId - The dsh session id the switch applies to. * @returns `ok()` on success, or `err(...)` with the reason when the role * is unknown or not primary. */ activate(roleId: string | null, sessionId: string): Promise>; /** * Unsubscribe every cordis listener registered by this switcher. * Idempotent — safe to call multiple times. */ dispose(): void; /** * Fire the optional {@link DshRoleSwitcherOptions.onActiveRoleChanged} hook. * * Called exactly once after each applied active-role change (switch, clear, * restore). Best-effort: a throwing hook is logged at debug and swallowed so * a refresh concern can never fail the role change itself. */ private notifyActiveRoleChanged; /** * Subscribe `session/created` and restore the active role for the new * session, honoring the three-source precedence: * * 1. **sidecar entry** — an explicit record for this session (a role id, * or an explicit `null` clear) always wins. * 2. **read-only legacy-event adoption** — when the sidecar is silent, the * session's own log is scanned for a legacy `rolebox/active-role` event * and the last selection is adopted into the sidecar. The log itself is * never written (rc.6 would refuse to reload it — ). * 3. **fork inheritance** — a session with neither source inherits its * parent's selection through the durable `header.parentSession`; the * parent is resolved from the store and read with the same 1→2 order. * * A restored role is kept only when it still exists in the current catalog * and is primary; a stale selection (or an explicit clear) resolves to the * base agent. An adopted or inherited selection is recorded under the new * session id via the holder's sidecar seam — never in the session log. */ private wireRestore; /** * Subscribe `session/flush` and drain the active-role sidecar. * * The harness emits `session/flush` when it flushes a session's log * (`dsh-plugin-contract.md` §4.1: "Persistence is a plugin concern: ... * drain on `session/flush`"), so it is the durable checkpoint for the * workspace sidecar. The whole session-keyed map is persisted (not just the * flushed session) because the sidecar is workspace-scoped. The drain is * bounded and non-throwing — a slow or failing write must never block or * break the harness flush. */ private wireFlush; /** * Read a session's persisted selection, honoring the restore source order: * the sidecar entry (including an explicit `null` clear) wins; otherwise the * session's own legacy `rolebox/active-role` events are scanned read-only. * * @param session - The session whose selection to read. * @returns The persisted role id, `null` for an explicit clear, or * `undefined` when neither source has a record. */ private readPersistedRole; } //# sourceMappingURL=role-switcher.d.ts.map