# dsh-session-title [简体中文](README.md) | English A DeepSeek Harness plugin that gives the **model** a tool to set the session title using **rules you define**. Once installed, the model can rename the current session to whatever format you want (for example `0924 | v0.1.0 | fix login redirect`) and **pin** it — later automatic title generation will no longer overwrite it. > **Disabled by default.** The plugin adds a tool to the model's tool catalogue, which changes the model's context, so installing it does not take effect immediately. After installing, go to **Settings → 会话标题** and tick "启用" (enable), then save. See [Install](#install). > > **Note on language:** the settings page labels, validation messages, and the bundled example prompt are currently **Simplified Chinese only**. The plugin itself works regardless of language; only its UI copy is Chinese. Contributions for localization are welcome. ## Install ```bash dsh plugin --profile web add dsh-session-title ``` Then **restart** the `dsh` process — a changed bundle set is not hot-reloaded. After that: 1. Open the Web UI at **Settings → 会话标题**; 2. Adjust the "标题正则" (title regex) field as needed — see [Don't write regex by hand?](#dont-write-regex-by-hand-let-an-llm-do-it); 3. **Tick "启用" (enable) and save.** To skip step 3 and have it active right after install, put `enabled: true` in the profile's `config:` (see [Presetting via the config file](#presetting-via-the-config-file)). ### Manual install If `dsh` is not on your `PATH`, or you run DSH Desktop / a source checkout, edit the profile's `~/.dsh/profiles/web/package.json` directly: ```jsonc { "dsh": { "profile": { "bundles": [ /* …, */ "dsh-session-title" ] } }, "dependencies": { "dsh-session-title": "^0.1.0" } } ``` Then run `pnpm install` (or `npm install`) in the profile directory and restart. No build step is required: the client-side artifact `lib/client.js` ships with the package. ## What it solves DSH already implements session renaming as a Host capability (the `ctx.sessionTitle` service / the `session/rename` Remote), but **there is no rename tool in the model-facing tool catalogue**, so until now the title could only be changed by hand from the sidebar. This plugin adds exactly that one tool: `set_session_title`. The naming rule is not hard-coded — you configure it in settings, so it can fit any repository or team convention. ## Behavior | Item | Description | |---|---| | Tool name | `set_session_title`, with a `title` parameter (the full title) | | Effect | Calls `ctx.sessionTitle.rename(agent.session, title)`; a passing title is written and **pinned** | | Side effect | Once pinned, that session no longer generates automatic titles | | Not done | Does not read or write session files, touch the workspace, or archive/delete anything | A non-conforming title comes back as a tool error with a clear reason, and the model rewrites it and retries. ## Configuration There are two ways to configure this: the **settings page** (recommended; changes apply immediately) or the profile's **`config:`**. Both write the same configuration and neither needs a restart. | Option | Type | Default | Description | |---|---|---|---| | `enabled` | boolean | **`false`** | Whether the tool is active; when off the model cannot see it (toggling applies live) | | `pattern` | string | `^(\d{4}) \| ([^\|]+) \| ([^\|]+)$` | Regex the title must match (JS syntax, no delimiters) | | `patternMode` | `match` / `search` / `none` | `match` | `match` = whole-string; `search` = substring allowed; `none` = no regex check | | `patternHint` | string | `""` | Message shown to the model when the regex does not match; empty uses the default | | `maxTitleBytes` | number | `80` | Maximum UTF-8 bytes for the whole title | | `forbidTrailingPunctuation` | boolean | `true` | Reject titles ending in sentence punctuation | | `forbidControlCharacters` | boolean | `true` | Reject newlines, tabs, and other control characters | | `normalizeSeparatorSpacing` | boolean | `true` | Normalize whitespace around `\|` to a single space | | `caseInsensitive` | boolean | `false` | Case-insensitive regex matching | The default rule is `MMDD | version or tag | description`. It is only a ready-made example — replace it with your own convention. When the plugin is disabled the settings page says so explicitly ("当前未启用:模型看不到 set_session_title 工具"), so nobody mistakes it for broken. The **regex field is always pre-filled with the current value**, so you can get the rule right first and flip the switch afterwards. Changing the rule **rewrites the tool description automatically**: the description the model sees carries the currently effective regex and byte limit, so it never has to guess the format. ### Don't write regex by hand? Let an LLM do it Below the description text on the settings page there is a collapsible block, "**示例提示词:让 LLM 帮你写一条正则**" (example prompt: let an LLM write a regex for you). It expands into a copy-ready prompt with a blank "我的命名需求" (my naming requirements) for you to fill in. Copy it, send it to any LLM, and paste the returned regex into the "标题正则" field. The prompt deliberately pins down the output shape (no `/` delimiters, no flags, no code block, no explanation, anchor with `^`/`$`, use `[^|]+` for a segment that must not contain a pipe). Without those constraints models routinely return something you cannot paste directly. It also states plainly that sentence punctuation, byte length, and newlines are **not** the regex's job — those are separate switches in this plugin. The full prompt (identical to the one bundled in the plugin, in Chinese): ```text 我在用 DeepSeek Harness 的 dsh-session-title 插件管理会话标题,需要你帮我写一条校验标题的正则。 请只输出正则本身:一行、不带 / 定界符、不带 flags、不要代码块、不要解释。并且: 1. 用 ^ 和 $ 锚定整串; 2. 需要单独校验的分段用括号 () 捕获; 3. 某段不允许出现竖线 | 时,用 [^|]+ 表示(| 是分段分隔符); 4. 不要处理句末标点、字节长度、换行 —— 这些插件会另外校验。 我的命名需求:<在这里描述你的规则,例如「以 BUG- 或 FEAT- 开头,接一个数字,然后一个空格加简短中文描述」> 参考:插件当前的默认正则是 ^(\d{4}) \| ([^|]+) \| ([^|]+)$ ,含义是「四位日期 | 版本或 tag | 描述」。 ``` ### Presetting via the config file To skip the settings page and preset values, edit the profile's `cordis.patch.yml` (this overrides the schema defaults): ```yaml - insert: - id: session-title-tool name: dsh-session-title config: enabled: true # [BUG]/[FEAT] prefix pattern: '^\[(BUG|FEAT)\] .+$' maxTitleBytes: 60 ``` ```yaml config: enabled: true # no format check at all; keep only the byte cap and punctuation rules patternMode: none maxTitleBytes: 120 ``` ### About `maxTitleBytes` The Host's `session-title` service treats its own `maxTitleBytes` as a **hard cap**: `rename()` normalizes and truncates against it. This plugin's `maxTitleBytes` is a **pre-check**, so set it **less than or equal to** the Host value — otherwise the model can produce a title that passes this plugin's check but is then truncated by the Host. The base bundle sets the Host value to `80` (`packages/bundle/base/cordis.patch.yml`), which is why the default here is also 80. ## Settings page The plugin has two parts, and its settings page registers as its own section in the Web settings panel: **Settings → 会话标题**. | Side | Entry | Role | |---|---|---| | host | `index.js` | Registers the `set_session_title` tool, validates rules, joins the settings namespace | | client | `lib/client.js` | Registers the `settings.section` entry and renders the rule form | How it works: - The **namespace** is simply the plugin's **entry id** in the profile, i.e. `session-title-tool` (see `cordis.patch.yml`). The client side cannot read the host's entry id, so the same string is repeated in `lib/client.js` — **renaming means changing both places** (a test locks this down). - **Read/write channel**: `ctx.configForms` (the client service provided by ui-settings, backed by the settings Remote) — the same approach as official pages such as `ui-settings-subagent`, so no custom HTTP route is needed. - **Gating**: `configForms.whileServed([ns], …)` registers the section only once the Host actually serves that namespace, which avoids an empty page. - **Live**: both sides write the same config reference, so no restart is needed; the tool description refreshes too. ### The client side is a hand-written bundle (zero build) `lib/client.js` is a **hand-written CJS bundle** that deliberately avoids a TypeScript / tsdown / JSX build chain: - Its format matches the banner/footer produced by DSH's own `packages/client/tsdown.client.ts`: `window.__ModuleLoader__.load({ id, factory })`; - At runtime it only `require('react')` (from the platform module table in `packages/client/web/src/platform.ts`), and the component uses `React.createElement` with inline styles; - So with a local `link:` install, edits take effect immediately — **no build needed**. The declarations in `package.json` are the loader's discovery entry point. If any of them is wrong the symptom is a **silently missing settings page**: ```jsonc "exports": { "./client": "./lib/client.js" }, "dsh": { "client": { "platform": "web", "inject": [ /* … */ ] } } ``` ## Uninstall ```bash dsh plugin --profile web remove dsh-session-title ``` Or remove it from the profile's `dependencies` and `dsh.profile.bundles`, reinstall, and restart. Titles that were already pinned are unaffected. ## Tests ```bash npm test ``` **Host side** (`test/smoke.test.mjs`): Config fields and volatile markers; behavior of the configurable rules (regex / caps / switches); load-time failure on an invalid regex; the registration contract; `execute` success and failure paths; **disabled by default**; live add/remove of the tool via `enabled`; the tool description refreshing with the rule; unregistering on plugin unload; minimal-host compatibility. **Client side** (`test/client.test.mjs`): bundle shape conforming to the `__ModuleLoader__` contract; the `dsh.client` declaration satisfying the loader's scan rules; section registration under the right namespace; the component rendering every field with **real React**; the example prompt and the "not enabled" notice actually rendering; and **real DOM interaction** (edit a field → save → assert the write received the right field and type). A few regression tests worth calling out: - Validating `execute`'s return value with `output.schema` — the one thing tests that avoid Harness packages most easily miss, and the only assertion that catches a return value violating the output contract; - The client-side form field names mapping **one-to-one** onto the host-side `Config` fields — a single mismatch is rejected by the host-side validation; - `enabled`'s schema default and `resolveRule()`'s fallback **must agree**, otherwise "disabled by default" has two different answers; - One test verifies the settings form really is generated, using the **real** `volatileForm` projection from a DSH checkout. It skips automatically when no checkout is found (`DSH_CHECKOUT` overrides the path), so cloning this repository alone still passes. ## Implementation notes **Host side** (`index.js`) - At runtime it depends on only three host interfaces: `ctx.tools.register`, `ctx.sessionTitle.rename`, and `ctx.on('loader/volatile-update')`. Its only dependency is `@deepseek-ai/schemastery`, required to declare `Config`. - `parameters` uses **raw JSON Schema**, matching how DSH's own MCP client bridges tools: `ctx.tools.register()` validates only `output.schema`, while `parameters` is projected to the model as-is. - `output.schema` is `{ type: 'string' }` and `execute` returns the **title string** (`.title` off the snapshot returned by `rename()`). The registry validates the return value against that schema, so returning the snapshot object throws `ToolOutputError` — the side effect would already have been written while the call reports failure. - The disposer returned by registration **must** be attached to `ctx.effect` by the plugin: `ctx.tools.register()` internally uses the ToolRuntime's own context, so unloading the plugin does not clean up this registration on its own. - Rules are read through `Volatile.get()`; changes to `enabled` and `pattern` arrive via `loader/volatile-update`, which triggers a synchronous re-registration. That is why they apply live to a running instance and why the tool description always reflects the current rule. **Client side** (`lib/client.js`) - A hand-written CJS bundle with zero build; it only `require('react')`, and the component uses `React.createElement` with inline styles. - It subscribes to `ctx.configForms`' `ConfigForm` with `useSyncExternalStore`, passing a third `getServerSnapshot` argument so the component also holds up on SSR / prerender paths. - A local edit layer sits on top of the host-side value (`{...hostValue, ...edits}`), so a write from elsewhere does not clobber what the user is currently editing; saving calls `form.set()` field by field, with the host-side `Config` validating the whole document.