# AGENTS.md — dock-flash Development Rules > This file documents known requirements, development constraints, testing conventions, and hard-won lessons for the dock-flash plugin. Read it before modifying `lib/client.js` or `src/index.ts`. ## What belongs in this file, and what does not `AGENTS.md` is injected into **every request**, so its length is a cost paid on every turn. It holds the **rules** — contracts, invariants, and the traps that cost real defects. Everything else belongs in `docs/` and is read when it is relevant: | Content | Where | Why | |---|---|---| | Contracts, invariants, Critical Rules | here | needed while writing code | | Procedures (release steps, the test checklist) | `docs/` | needed when performing that task | | The full integration guide | `INTEGRATION.md` | already published — must not drift | | The "why" behind a rule, and measurements | `docs/architecture-notes.md` | read when the rule is questioned | **The budget is 65536 bytes, and it is not advisory.** `dsh-base` mounts `@deepseek-ai/dsh-agent-instructions` with `maxBytes: 65536`, and the cap applies to the complete rendered baseline. Exceeding it does not error — it **truncates this file mid-sentence**, so the failure mode is silently losing rules. `pnpm run check:docs` reports the headroom and fails when the budget is exceeded or a link here stops resolving. **Splitting into another project file does not help.** Only `AGENTS.md`, `CLAUDE.md` and their `.local` overlays are discovered as candidates, and every candidate shares the one budget — a second file buys nothing. `docs/` is not a candidate name, which is why relocation works. --- ## Project Structure ``` dock-flash/ ├── src/index.ts HOST half — settings namespace + proxy toggle (tsc → dist/) ├── dist/index.js Compiled host half ├── lib/client.js BROWSER half — quickControl registry + panel + skin system + i18n (single file, NO build step, organized by #region markers) ├── cordis.patch.yml Bundle layer — inserts host rows into profile ├── package.json Plugin manifest + dsh.client.inject ├── README.md English docs (canonical) ├── README.zh-CN.md Chinese docs (mirrors README.md) ├── INTEGRATION.md Third-party integration guide (English) ├── INTEGRATION.zh-CN.md Third-party integration guide (Chinese) ├── CHANGELOG.md Release-by-release history (narrative; not rules) ├── AGENTS.md This file — rules, contracts, invariants ├── docs/ Long-form notes and procedures — NOT injected, read on demand │ ├── architecture-notes.md the "why" behind rules, and measurements │ ├── testing-checklist.md the per-change verification procedure │ └── releasing.md the release and mirror-sync procedure └── scripts/ Repo tooling (not published — see `files` in package.json) ├── check-docs-size.mjs `pnpm run check:docs` └── check-overlay-mount.mjs `pnpm run check:overlay` ``` - **Host half** (`src/index.ts`): compiled via `pnpm run build` (tsc). Touch only this file for host-side changes. - **Client half** (`lib/client.js`): single monolithic file, edited directly — no build, no bundler, no TypeScript. Changes take effect on page refresh (symlinked in profile). - **`pnpm run check:docs`**: verifies this file still fits its budget and that every link below resolves. Run it before committing a change to `AGENTS.md` or `docs/`. --- ## Build & Install ```sh pnpm install pnpm run build # tsc → dist/index.js (host half only) pnpm run typecheck # type check without emitting ``` - Install into profile: `dsh plugin --profile web add ./dock-flash` - dock-flash is **symlinked** in the profile — edits to `lib/client.js` appear on refresh without reinstalling - Host half changes require `pnpm run build` then restart DSH **`dist/` is tracked on purpose — never add it back to `.gitignore`.** A git install fetches sources, not built artifacts: nothing runs the `build` script, so a repo without `dist/` arrives missing the host-half entry point (`package.json` `main` and `exports["."]` both point at `./dist/index.js`) and fails to load. Shipping the compiled file lets `dsh plugin --profile

add github:tcgbp/dock-flash` work with no build step and **no `allowBuilds` permission**. Do **not** add a `prepare` script alongside it: declaring one makes pnpm ≥10 demand an explicit build allowance before the first `add` succeeds, which would defeat the purpose. Consequence: every `src/index.ts` change must be followed by `pnpm run build` and a commit of `dist/` in the same change. --- ## Publishing & Repository Sync **Gitee is authoritative; GitHub is a mirror.** The full procedure — remotes, the mirror workflow, and the API route that still works while `github.com` is unreachable — is in **[docs/releasing.md](docs/releasing.md)**. Three things must not be got wrong, so they stay here: - **Always commit and push to Gitee.** No `github` remote is configured, deliberately: github.com is intermittently unreachable, so a dual-push succeeds unpredictably and the two repositories then sit silently divergent. `.github/workflows/sync-from-gitee.yml` is what updates GitHub. - **Do not re-enable Gitee's 仓库镜像管理 push mirror.** It was tried and never delivered a single commit; a second, unverified mirror racing the workflow is how the two drift apart again. - **`github.com` being unreachable is not the mirror being broken.** Measured in one sitting: eight consecutive `git push` attempts to `github.com:443` failed while `api.github.com` answered HTTP 200 in 0.55 s. Reach for the API, not `git`. --- ## Architecture ### Plugin Contract dock-flash can run in two modes: **Workbench mode** (dock-base installed) — follows the [dock-base plugin contract](https://github.com/AKS1st/dock/blob/main/src/client/contract.ts). All workbench interaction goes through `ctx.workbench`: | Registration | API | Purpose | |---|---|---| | Sidebar Panel | `ctx.workbench.registerPanel()` | Quick control panel (sideBar area) | | Plugin Entry | `ctx.workbench.registerPlugin()` | Settings panel card — title "Flash" (visibility toggle + Open button) | | Activity Bar Item | `ctx.workbench.registerActivityBarItem()` | ⚡ icon | | Editor View | `ctx.workbench.registerEditorView()` | Quick control panel (draggable to floating) | | Command | `ctx.workbench.registerCommand()` | `dock-flash:openQuickControl` | | Service | `ctx.provide('quickControl', registry)` | Pub/sub switch registry for other plugins | **Standalone mode** (no dock-base) — injects a ⚡ trigger button through `ctx.slots.inject(, ...)`, where the slot is chosen by the `trigger-position` switch (`dock-flash:trigger-position`, default `conversation.input.right`). Clicking the trigger toggles a floating QuickControlPanel anchored to the button. The floating panel has a drag-to-move title bar (⠿ grip + ⚡ + the localized `title` string + close-on-blur toggle + × close) and uses `react-dom/client`'s `createRoot`. That toggle and the outside-click handler read the same `localStorage` key, so close-on-blur here behaves exactly as it does in workbench mode. **`sidebar.footer.action` is deliberately not offered as a trigger position.** It is a shared slot that CordisPanel and other plugins also occupy, and a second occupant produces visual conflicts with them. Do not re-add it to `TRIGGER_POSITIONS`, and keep the fallback in `loadTriggerPosition()` pointing at a conversation slot. Note the trade-off: every slot-based position lives inside the conversation UI, so with no session open the trigger is not rendered at all — that is accepted. ### The overlay trigger: the one position that is not a slot `conversation.overlay` floats a draggable ⚡ inside the conversation's top-right corner. **Its `TRIGGER_POSITIONS` entry carries no `slot`, and that absence is the mechanism** — `injectTrigger()` takes the overlay path instead of registering into a slot. A slot is a place in DSH's layout, and this position exists precisely to sit *over* the conversation rather than be laid out by it, so `conversation.session.header.corner` — the nearest thing DSH offers — is not usable even though it looks right: it is `kind: "single"` (the renderer keeps only `entriesOfSlot[0]`), so a second occupant does not queue, it **disappears**, and `@deepseek-ai/dsh-client-ui-sidebar-right` already ships there with its expand button. Six things must hold together: - **The anchor is found structurally, never by class name.** `conversationViewport()` matches `div[class*="_scrollBody"]` whose computed `overflow-y` is auto/scroll, then requires a non-zero box inside the viewport and prefers the largest. `wSkVaW_scrollBody` is a CSS-module hash that changes on any DSH rebuild, so matching it would break silently on an unrelated upgrade — the same reasoning as the turn rail, and it carries the same "in the DOM is not on screen" trap. - **The scrollbar is cleared by arithmetic, not by a guessed width.** That element declares `scrollbar-gutter: stable`, so the gutter is reserved whether or not a scrollbar is showing, and `rect.width - el.clientWidth` reads it exactly. A guessed constant would be wrong on any platform with a different scrollbar, and would make the button jump when content crossed the scroll threshold. - **The turn rail is cleared through `turnRailProbe()`,** not by measuring `nav[class*="_frame"]` again — that function already owns "is the rail visible", "has another plugin taken the surface over", and "which of several candidates is the on-screen one". **Class tests use `*=` and never `$=`**, because DSH joins class lists: the rail's scroller is `[scroller, fadeTop?, fadeBottom?].join(' ')`, so a rail that merely grew stopped matching a suffix test — which hid the `turn-rail-left` switch from the panel and stopped this button giving way to the rail. `_preview` is the one token that must stay `$=`, since `_previewPrompt` and `_previewResponse` are its siblings. Only a rail on the RIGHT competes with this corner; the `turn-rail-left` switch moves it away from the same place, so a left-side rail must not shift the button. - **The offset is relative to the conversation corner, and the button is clamped, not the offset.** Storing `{dx, dy}` inward from that corner is what makes the button follow the corner when the right sidebar opens, the sash moves or the window resizes — which is why a `ResizeObserver` on the viewport (not a window `resize` listener) is what keeps it in place, since two of those three never fire one. A drag adjusts the offset; the clamp then holds the RESULT inside the viewport, so a stored offset survives a shrink that the position does not. - **The glyph is real DOM, not a React element.** `LightningIcon()` returns `h('svg', …)` — a React element *descriptor*, a plain object — and the hand-built button's `appendChild` needs a `Node`, so it threw `TypeError: parameter 1 is not of type 'Node'` before `overlayEl = el`: the button was never in the DOM, and because the mount precedes `ctx.inject(['slots'], …)`, **no** trigger position worked at all. `LightningIconNode()` builds the same `svg`/`path` via `createElementNS`. - **Acquisition is retried, never attempted once.** `apply()` runs before any conversation exists, so positioning at mount finds no anchor, leaves the button hidden, and attaches no `ResizeObserver` — it is attached to an element that does not exist yet. A subtree `MutationObserver` (armed while no anchor is adopted, and **not** disconnected once one is found, because a new session builds a new scroller) plus a bounded retry for a viewport that exists but is not yet laid out. The mount is wrapped in a named, non-fatal catch, because it precedes `ctx.inject(['slots'])`. > The defects in full, with the measurements and the harness that proves them: > [docs/architecture-notes.md](docs/architecture-notes.md). **Drag reuses the panel's machinery rather than adding a second one.** `beginOverlayDrag()` sets the shared `dragging`/`dragSource`/`dragMoved` state, so `ensureGlobalListeners()`'s existing move and end handlers apply unchanged — including the ±3px threshold that `dragMoved` records, which the overlay's own click handler reads to swallow the click ending a drag. Without that, releasing a drag would toggle the panel. The offset is written on release, not per move: a drag is one intent, and a host round trip per pixel is not. **Swallowing the drag's click is the FIRST half of that handler, not the whole of it.** `QuickTriggerIconButton` is a React button and gets its toggle from its own `onClick`; a hand-built element has to call `openPanel()`/`closePanel()` itself. A handler that stopped at the swallow left a button that mounted, positioned itself correctly and did nothing when pressed — so if you touch this handler, keep the toggle in it. `handleOutsideClick` already exempts `[data-dock-flash-trigger]`, so the closing half is not racing it, and `dragMoved` is cleared by the next `mousedown` rather than by the drag's own end — which is why a click straight after a drag still works. ### Mode Detection ```js // In apply(ctx): const wb = ctx.get ? ctx.get('workbench') : undefined if (wb) { // Workbench mode: register panel, activity bar, editor view, command // Register the dock-flash-owned switches — but NOT close-on-blur, which in // this mode exists only as the panel-header toggle } else { // Standalone mode: inject the trigger button into the configured slot, and // register trigger-position + close-on-blur as Layout switches } ``` The `inject` array is empty (`inject: []`) — workbench is resolved lazily via `ctx.get('workbench')` rather than declared as a hard dependency. This ensures `apply()` runs even when dock-base is not installed. **Critical**: `dsh.client.inject` in `package.json` MUST include `"dock-base"` (the base package name, NOT `"dock-base/client"`). This is NOT a hard dependency — it's a **load-order hint** for the DSH ModuleLoader. When dock-base is installed, `arriveGraphRow()` ensures it loads before dock-flash, so `ctx.get('workbench')` finds the service already registered at `apply()` time. When dock-base is absent, the entry is silently skipped (`graphRows.get('dock-base')` returns `undefined`), and dock-flash enters standalone mode. Without this load-order hint, dock-flash may load before dock-base, causing `ctx.get('workbench')` to return `undefined` even when dock-base IS installed. **Why the base name**: `arriveGraphRow()` looks up `inject` entries with `graphRows.get(packageName)` and never strips the `/client` suffix, while graph-row keys are base package names — so `graphRows.get("dock-base/client")` returns `undefined` and the hint is silently ignored. (The `external` path *does* strip it first; `inject` does not.) ### Two-Half Model ``` ┌─────────────────────────────────────────┐ │ HOST (src/index.ts → dist/index.js) │ │ - Register 'dock-flash' settings │ │ - Fine-grained proxy mode + NO_PROXY │ │ - Owns testUrl (never hardcoded) │ │ - HTTP API routes (webServer): │ │ GET /proxy-status → proxy mode/state │ │ POST /test-connection → diagnostics │ │ - Runs in Node.js via Cordis │ └──────────────────┬──────────────────────┘ │ cordis.patch.yml (bundle layer) ┌──────────────────▼──────────────────────┐ │ CLIENT (lib/client.js) │ │ - QuickControlRegistry (pub/sub) │ │ - React panel UI │ │ - Skin system (5-layer scan) │ │ - i18n (zh/en) │ │ - Runs in browser via ModuleLoader │ └─────────────────────────────────────────┘ ``` ### System proxy `testUrl` is a **setting**, never a constant in `src/index.ts` (default `https://www.google.com/generate_204`; the presets are deliberately generic public endpoints). Three properties must survive refactoring: **`redirect: 'manual'` with a hand-rolled hop loop** (`MAX_REDIRECTS`) — following redirects conflates "302 to somewhere unreachable" with "connection refused"; **failures are returned as data, never thrown** — the route cannot 500; and **the nested undici `cause` is unpacked** into `causeName`/`causeMessage`/`causeCode`/`causeErrno`, because `fetch()` alone only ever says `TypeError: fetch failed`. `proxyRouteForUrl()` probes the **configured** test target, not a hardcoded host, and the client sends the URL it is displaying in the request body — so the probe targets exactly what the user sees and cannot lag an async `settings.update`. Every client-side field passes through `_oneLine()` before entering the log: error messages are not single-line in general, and one injected newline destroys the one-fact-per-line layout. The log holds the **latest run only** and hides itself entirely until the first test. The proxy controls are one `cluster`; see "Panel ordering" above and the QuickControl API section. #### Talking to `@deepseek-ai/dsh-http-proxy` Four rules, or the mode switch changes nothing: load it through the **one cached handle** (`loadProxyModule()`, resolving DSH's own copy) and make sure it is **the instance DSH booted with** (a second copy answers `DIRECT_ROUTE` forever); pass a **`URL`**, never a string (handed a string it does not throw, it silently reports "direct"); **keep and release the returned disposer** — ignoring it leaks one `ProxyAgent` and its socket pool per mode change; and resolve the policy from the **`launchEnvironment`** service, not `process.env`, overriding only `NO_PROXY`/`no_proxy`. This plugin owns the **bypass list**, not the proxy address, and installing replaces the process-global dispatcher for the whole DSH process. `custom` values are **validated on the way in** against the grammar the matcher actually implements; a rejection changes neither the mode nor the stored list, and a blank value makes `resolveNoProxy()` return `undefined` so `NO_PROXY` is removed rather than published empty (the host enforces that blank rule too, since a value edited into `settings.yaml` never passes through the prompt). > The four defects that made the proxy a silent no-op for the life of the feature, the exact > accept/reject grammar, and the reasoning behind each rule above: > [docs/architecture-notes.md](docs/architecture-notes.md). ### Module Loading Client plugin is loaded via `window.__ModuleLoader__.load({ id, factory })`. The factory receives `require` and must use `require('react')` (not import). All React usage goes through `h = React.createElement`. ### Close-on-blur: one key, one writer, two controls Exposed twice — the panel-header toggle (workbench `headerComponent` and the standalone title bar) and, in **standalone mode only**, a `buttongroup` switch in the Layout subgroup. Everything goes through the module-level helpers next to `LIGHTNING_ICON`: `readCloseOnBlur()`, the single writer `writeCloseOnBlur(on, registry)`, and `subscribeCloseOnBlur(fn)`. The writer repaints every subscriber (that is how a switch change reaches the imperatively-painted header toggles) and calls `registry.notifyChange` (that is how a header toggle reaches the switch — the registry `version` bump is what re-renders the panel). **Never write `localStorage` for this key directly, and never add a third control without routing it through `writeCloseOnBlur`** — otherwise one of the others silently stops tracking. Workbench mode registers no such switch, so it renders no Layout category at all; the workbench header button paints its state imperatively rather than with `useState`, because dock-base may call `headerComponent` as a plain render function, which would make hooks illegal. Do not "tidy this up" into a hook. > The full linkage chain, and why the Layout group disappears in workbench mode: > [docs/architecture-notes.md](docs/architecture-notes.md). ### Panel ordering and visibility: two modes, one writer Users can reorder the panel's groups and switches, and hide individual rows, from icons in the header of every page that has something to rearrange (not the Changes page), immediately left of the collapse chevron, icon-only, each handler calling `stopPropagation()` because that header is itself the collapse control. **Entering either mode opens its tab.** The header is reachable while a tab is folded (`isOpen` gates the body only), so both mode buttons call `ensureTabOpen(page.id)` — open if collapsed, no-op if open, never closing — and do so AFTER `stopPropagation()`, or the header’s own `toggleTab` re-collapses it in the same click. **Two modes, mutually exclusive by construction, not by a guard**: `◉` opens visibility, `⇅` reordering, and while either is open **the other's entry button is not rendered** — header `◉ ⇅ ▶` idle, `✓ ↺ ▶` in either mode, no state where a row has both a ▲▼ pair and a hide box. Do not merge them: two adjacent small controls, one of which makes a row vanish, is a mis-click that removes the row you were about to move. `↺` is a single button whose action follows the open mode, and **each mode resets only its own half** — order and visibility are independent intents, so there is deliberately no combined "restore everything". Seven invariants: - **One key, one writer, and the writer is the preference bridge.** Order AND visibility both live in the host settings namespace (`panelOrder`, as `switches` and `hidden`); `writePanelOrder()`, `clearPanelOrder()` and `clearPanelHidden()` are the only writers and all go through `savePrefs()` — memory + localStorage + host in one call. Group keys are scope-qualified: `builtin:` for the Workbench tab, `ext:` for an Extensions group, because the two tabs name groups from different namespaces. Each clear carries the OTHER field through untouched, and neither `localStorage.removeItem`s the key: removing it would drop the half it preserves. - **Two layers of "not shown", ANDed — but only in the normal view.** A switch's `visible()` is the PLUGIN saying "I do not apply right now"; `panelOrder.hidden` is the USER saying "I do not want it". Neither can override the other: a user cannot force back a row the plugin has stood down, and a plugin cannot drag back one the user put away. **Both editing modes are INVENTORIES and list every registered unit**, stood-down and user-hidden ones included, because a row that is not drawn cannot be ordered or hidden — a stood-down switch used to be unreachable in every mode at once, which is how the turn-rail switch was lost. A row the normal view would filter is dimmed in the editing modes and its tooltip names the layer that removed it. - **A unit, not a switch, is what moves.** `groupUnits()` splits a group into units: a plain switch is a unit of one, and switches sharing a `cluster` label are ONE unit keyed by `\0cluster: