/** * The profile patch that mounts the agent-preset roster `/mode` needs, plus the * repair planner `/doctor` uses to fix a profile patch. * * A terminal profile built on `dsh-base` composes no preset roster (only the * Web bundle does), and this plugin's own bundle patch may not mount one: DSH * STORE accepts additive rows with plugin-owned ids and no `@deepseek-ai/*` * module names. The profile's user layer is the supported home for the row, so * both the install script and the running TUI write the same block here — the * TUI needs it because the in-app update (`dsh plugin add`, at whatever version) * never runs `scripts/`, which npm installs do not ship. * * The roster is not cosmetic: without it `/mode` cannot switch, and the tools * the shipped presets own (`ask_user_question`, `present`, PTC's presentation) * are absent from the agent's catalog. * * Everything in this module is either a pure text function or a file write that * takes a backup first: `/doctor` may repair a user's profile patch, and that * must never lose the previous content or touch a row the user wrote. * @module dsh-ssh-tui/preset-rows */ import type { SettingsGeneration } from './dsh-compat.js'; /** Which host line the profile being repaired boots on. */ export type HostGeneration = SettingsGeneration; /** One host row the roster block mounts. */ export interface RosterRow { id: string; name: string; /** Lines below `config:`, written one level deeper than `config:`. */ config?: readonly string[]; } /** * The rows a 0.1.5 terminal profile has to mount itself: the roster `/mode` * lists, the TypeScript runtime the PTC preset needs, and the host-owned * subagent delegation setting. */ export declare const ROSTER_ROWS: readonly RosterRow[]; /** * The rows a 0.1.7 terminal profile mounts for itself. * * 0.1.7 deleted the roster this plugin used to list and switch: presets became * per-session declarations a surface mounts (`@deepseek-ai/dsh-agent-preset` * rows over the `agent-preset-registry` service), and `dsh-base` keeps the * agent-plane rows enabled for the TUI, which is single-session and composes * its agent process-wide. Beyond the base, upstream's standard preset declares a * persona and these two tools. Only the tools are mounted here: a *preset* is an * agent scope, but a profile is not one, and `@deepseek-ai/dsh-persona` * registers the two prompt sections `dsh-system-prompt` already owns at this * layer — the loader rejects the row ("prompt section * \"deployment:persona-prefix\" is already registered") and one entry then * silently never activates. The deployment persona this line ships with is * `dsh-system-prompt`'s own empty default, which is what a profile is meant to * use unless it deliberately sets one. */ export declare const FORMS_ROWS: readonly RosterRow[]; /** The rows one host line's profile has to mount. */ export declare function rosterRows(generation: HostGeneration): readonly RosterRow[]; /** Every row either line knows about, for name-to-row lookups. */ export declare const ALL_ROSTER_ROWS: readonly RosterRow[]; /** The comment header the block introduces itself with, in both writers. */ export declare const ROSTER_PATCH_HEADER = "# dsh-ssh-tui /mode: the agent-preset roster (standard / minimal / PTC /\n# cordis, plus every preset under $DSH_HOME/.agent-presets) and the two host\n# services the shipped presets need. dsh-base composes no roster in a terminal\n# profile, and a third-party bundle patch may not mount an @deepseek-ai row, so\n# the profile's user layer owns them.\n"; /** The same header on a host that composes its agent process-wide. */ export declare const FORMS_PATCH_HEADER = "# dsh-ssh-tui: the agent-plane rows a 0.1.7 terminal profile mounts for itself.\n# That line composes the agent process-wide (presets are a per-session Web\n# feature now), and dsh-base already carries the rest of what the standard\n# preset declares: dsh-system-prompt owns the persona sections at this layer, so\n# only the two tools are left to mount. @deepseek-ai/dsh-persona is deliberately\n# absent \u2014 mounting it here collides with those sections and never activates.\n"; /** The header for one host line. */ export declare function rosterPatchHeader(generation: HostGeneration): string; /** * The entries one host line's rows need: a single `- insert:` list. * @param rows - the rows to write. * @returns the YAML text, ending in a newline. */ export declare function rosterEntriesText(rows: readonly RosterRow[]): string; /** * The exact profile patch block that mounts the roster and the two host * services the shipped presets need. `scripts/ensure-profile-rows.sh` carries * the same text; a test compares the two so they cannot drift. */ export declare const ROSTER_PATCH_BLOCK: string; /** The 0.1.7 block, the same way. */ export declare const FORMS_PATCH_BLOCK: string; /** The block for one host line. */ export declare function rosterPatchBlock(generation: HostGeneration): string; /** The profile patch file the roster block belongs in. */ export declare function rosterPatchPath(home: string, profile: string): string; /** * Whether a profile patch already declares every 0.1.7 agent-plane row. * * Synchronous on purpose: the footer chip is rendered from the main loop and * cannot await a read. The file is small and this runs at startup and after a * repair, not per frame. * @param home - the harness home carrying `profiles/`. * @param profile - the profile to inspect. * @returns whether all three rows are declared (or the row is unreachable). */ export declare function formsRowsDeclared(home: string, profile: string): boolean; /** One row a patch file declares, with enough position to point at it. */ export interface PatchRowRef { /** Insert id, or the target of an override/disable entry. */ id: string; /** Module name for an insert that mounts one. */ name?: string; kind: 'insert' | 'override' | 'disable' | 'other'; /** 0-based top-level entry index. */ entry: number; /** 0-based index inside the entry's insert list (inserts only). */ item?: number; /** 1-based line the id is written on, when the text can be scanned. */ line: number; } export interface PatchAnalysis { /** The YAML parses into a top-level array of patch entries. */ parseable: boolean; /** What the loader would reject, when it does not parse. */ error?: string; rows: PatchRowRef[]; } /** * Read one patch file into the rows it declares. * * Unparseable YAML is reported instead of thrown: `/doctor` has to *describe* * a broken patch, which is exactly when the loader would refuse to boot. * @param text - the patch file's content. * @returns the rows, and whether the text parsed at all. */ export declare function analyzePatch(text: string): PatchAnalysis; /** * Insert rows that appear more than once, keeping the first occurrence. * * A row is duplicated when an earlier insert declares the same id, or mounts * the same module name — the two ways a second mount reaches the loader and * fails with "service … has been registered". Overrides and disables are never * duplicates of an insert: they target a row instead of mounting it. * @param rows - every row the patch declares. * @returns the later occurrences, in file order. */ export declare function duplicatePatchRows(rows: readonly PatchRowRef[]): PatchRowRef[]; /** * Whether a patch already addresses one roster row, by id or by module name. * * Every kind counts: an override or a disable targets the row just as an insert * does. A repair must leave an intentional `- id: present` / `disabled: true` * alone instead of mounting the same id a second time; the later entry would * either collide with it or silently undo the user's choice. */ export declare function patchNamesRow(rows: readonly PatchRowRef[], row: RosterRow): boolean; /** The roster rows a patch does not declare yet. */ export declare function missingRosterRows(rows: readonly PatchRowRef[], candidates?: readonly RosterRow[]): RosterRow[]; export interface PatchRepair { text: string; /** Roster rows this repair adds, by id. */ added: string[]; /** Duplicate rows this repair removes, by id, with the line they sat on. */ removed: Array<{ id: string; line: number; }>; } /** * The patch text with the given roster rows mounted, or `undefined` when the * file already declares every one of them. * * A row is matched by id or by module name, so a profile that composes the * roster through its own bundle is left alone. A file that is exactly `[]` * (the profile template) is replaced; anything else keeps its content and gains * a new `- insert:` entry after a blank line. * @param existing - the patch file's current text. * @param missing - the roster rows to mount; defaults to the 0.1.5 set. * @param generation - which host line the profile boots on. * @returns the new text, or `undefined` when nothing has to change. * @throws when `existing` is not valid YAML; appending to a broken patch would * make it longer without making it bootable. */ export declare function planRosterRepair(existing: string, missing?: readonly RosterRow[], generation?: HostGeneration): PatchRepair | undefined; /** * The patch text with every repeated insert removed, or `undefined` when the * file has no duplicate. * * Only the later occurrence is cut, and only its own item when the enclosing * `- insert:` entry mounts other rows too. Comments directly above a removed * entry go with it; anything the user wrote around it stays. * @param existing - the patch file's current text. * @returns the new text and what was removed, or `undefined` when nothing is. */ export declare function planDuplicateRepair(existing: string): PatchRepair | undefined; /** * Mount the roster in one profile, unless it is already composed. * * Idempotent: a profile whose patch already addresses the roster row (one * bundling `@deepseek-ai/dsh-web-app`, for example) is left untouched. An * existing file is backed up before the small whole-file replace; a * half-written patch would only be seen by the next launch. * @param home - the harness home carrying `profiles/`. * @param profile - the profile to patch. * @param missing - the rows to mount; defaults to the 0.1.5 set. * @param generation - which host line the profile boots on. * @param stamp - filename-safe backups stamp, injectable for tests. * @returns `present` when the row already exists, else `written`. * @throws when the existing patch cannot be read, is not valid YAML, or cannot * be backed up; a file that cannot be preserved is not overwritten. */ export declare function ensureRosterRows(home: string, profile: string, missing?: readonly RosterRow[], generation?: HostGeneration, stamp?: string): Promise<'present' | 'written'>; /** * Write a repaired patch next to a copy of the previous content. * * `/doctor` and the automatic repair change a file the user owns, so the old * text is kept beside it before anything is replaced. A source that vanished * between the read and the copy needs no backup; any other backup failure * aborts before the write, leaving the original untouched. * @param path - the patch file to replace. * @param text - the new content. * @param stamp - filename-safe timestamp for the backup, injectable for tests. * @returns the backup path, or `undefined` when there was nothing to back up. * @throws when the backup cannot be written; the destination is not touched. */ export declare function writePatchWithBackup(path: string, text: string, stamp?: string): Promise; /** The patch text the roster block would produce on its own, for callers that report it. */ export declare function rosterPatchText(existing: string, generation?: HostGeneration): string | undefined;