# dsh-llm-codebuddy-power — Current State ## Introduction A Tencent CodeBuddy LLM provider plugin for DeepSeek Harness (`dsh`). The browser OAuth login replaces an API key; the model list comes from CodeBuddy's own `/v3/config` catalog and chat runs over the OpenAI-compatible `/v2/chat/completions` route. Built against `dsh` `0.1.7-rc.2`. The port from `0.1.5-rc.2` (message model, request-image target, volatile settings config, RPC peer parameter) is recorded in change doc [`2026-09-25-1`](2026-09-25-1-feat-port-plugin-to-dsh-0-1-7.md); the configuration page's move from a Settings section to the sidebar Plugins panel is recorded in [`2026-09-26-1`](2026-09-26-1-refactor-move-the-settings-entry-to-the-plugins-page.md). ## Macro description - Host half: registers the `codebuddy` route on the `llm` service ([src/index.ts](../../src/index.ts)); owns the OAuth session ([src/session.ts](../../src/session.ts)), the account set ([src/storage.ts](../../src/storage.ts)), the per-session info (the account it selected and its per-turn credits — [src/session-store.ts](../../src/session-store.ts)), the atomic-and-serialised JSON write base ([src/atomic-file.ts](../../src/atomic-file.ts)), the transport ([src/codebuddy.ts](../../src/codebuddy.ts), [src/adapter.ts](../../src/adapter.ts)), the usage meter ([src/usage.ts](../../src/usage.ts)), the image-draw tool ([src/image-tool.ts](../../src/image-tool.ts)), and conversation activity reporting ([src/report.ts](../../src/report.ts)). - Display metadata: the icon in the plugin list comes from the `icon` declared at the top level of this package's `package.json`, [icon.svg](../../icon.svg) — the same drawing as the CodeBuddy mark on the composer's model button; the host reads it into a data URL for the interface without activating the plugin, and the interface scales it to its own sizes (36px on cards and component rows, 30px on list rows). - Web half: the plugin's configuration page on the sidebar Plugins panel (`plugins.bundle.config`, keyed by this package's name, root scope) for multi-account sign-in, sign-out, and preferences, one composer control group (`conversation.input.right`, session scope: the allowance ring, the account button, and the CodeBuddy model logo in a single container), and an in-conversation per-turn credit line (`conversation.chat.assistant-actions`); the account store hangs on the configuration page alone, every other session-scoped entry fetches what it needs per session over the Connection RPC channel, and the interface preferences are volatile fields of this plugin's own entry config, read through `ctx.configForms` and handed to both entries through the inject face's `hooks` compartment ([src/client/](../../src/client/), [src/prefs.ts](../../src/prefs.ts)). ## Features ### 1. LLM provider route - **Feature description**: registers adapter `codebuddy`, serving every chat-capable model the CodeBuddy catalog discloses. - **Implementation file locations**: [src/index.ts](../../src/index.ts), [src/adapter.ts](../../src/adapter.ts), [src/codebuddy.ts](../../src/codebuddy.ts), [src/session.ts](../../src/session.ts). - **Solution**: the model catalog is CodeBuddy's non-OpenAI `/v3/config` (capacity and capability flags come from there); the chat plane is OpenAI-compatible, mapped onto the harness `StreamChunk` protocol by [src/translate.ts](../../src/translate.ts). - **Approach**: the catalog depends on the signed-in identity, so a credential-generation change emits `llm/adapters-updated` (`onAuthChange` in [src/session.ts](../../src/session.ts)), refreshing the picker, model commands, and the models settings page with the new login state. ### 2. Browser OAuth sign-in - **Feature description**: sign in through the Web Plugins page or the CLI, never asking for an API key; several accounts can stay signed in at once. - **Implementation file locations**: [src/auth-service.ts](../../src/auth-service.ts), [src/login.ts](../../src/login.ts), [src/rpc-route.ts](../../src/rpc-route.ts) (channel route), [src/client/](../../src/client/) (the configuration page). - **Solution**: the `/codebuddy` RPC channel joins the browser flow (`startLogin` + `pollLogin` + `pendingLogin`) to the host handshake; [src/rpc-route.ts](../../src/rpc-route.ts) mounts the channel as a prefix route registered directly on `webServer` (see change doc [`2026-09-11-1`](2026-09-11-1-fix-restore-dsh-0-1-5-rpc-channel-and-client-inject.md)). - **Appends rather than replaces**: a completed sign-in **appends** an account to the list; signing in again with the same uid counts as refreshing that account's credential and replaces it rather than leaving a second badge with the same name. Signing out (one account or several selected ones) only affects the accounts selected. - **Copying the sign-in link**: the authorization URL is minted per handshake (carrying a one-time `state`), so "copy the link" means starting a handshake and swapping the browser for the clipboard. It and "Sign in" are the left and right halves of **one joined button** ([src/client/CodeBuddySection.tsx](../../src/client/CodeBuddySection.tsx)): the left half opens the browser, the right half only writes the link to the clipboard; while a sign-in is in flight the left half shows "Signing in…" and is disabled while the right half becomes a cancel icon — clicking it abandons the handshake (the `cancelLogin` RPC aborts the host's token poll and drops the handshake), which is exactly the moment a user discovers they do not want to sign in. One control is what shows them as several exits from the same handshake. A successful copy enters the signing-in state too, so completing sign-in in another browser is still picked up and the account appended; a refused clipboard does not enter that state but raises an error instead — holding no link while showing "waiting for the browser sign-in to complete" would sit there until the ten-minute deadline. See change doc [`2026-09-14-4`](2026-09-14-4-feat-a-copy-sign-in-link-button-beside-sign-in.md). - **A failed poll cannot hang it**: the "signing in" deadline is the **handshake**'s start instant, so it is stored alongside the handshake in the store (remounting the component does not reset it); when the poll's single RPC throws, it keeps retrying at the same cadence and lets the deadline end it — uncaught, the poll chain breaks, the deadline check never runs again, and the UI sticks on "Signing in…" forever. The plugin's configuration page likewise catches a transport failure and shows the error instead of staying on "Loading…". See change doc [`2026-09-14-6`](2026-09-14-6-fix-the-sign-in-poll-no-longer-sticks-forever.md). - **The host owns the handshake fact**: whether a sign-in is in flight is authoritatively the host's `pending` table, and the browser's `loginState` is only its mirror. On mount the page aligns through `pendingLogin`, so reloading the page — or opening the configuration page in another tab — still shows that sign-in and resumes polling it, instead of showing "ready to sign in" and minting a second handshake; `startedAt` also comes from the host with `startLogin`, so the timeout judgement shares its origin with the moment the host reclaims the handshake. That endpoint reports only handshakes that have **not yet settled**: a settled entry lingers in the table so the client can read its result, and counting it as "in flight" would make a reload adopt a sign-in that has already ended. - **Polling answers on the spot and holds no connection**: `pollLogin` returns at once whether that handshake has settled and never waits on the host's ten-minute token loop — waiting would leave one browser request pending for the whole sign-in window, occupying a same-origin connection and pushing the account and usage requests into the queue behind it. The background `runLogin` is therefore started **detached** by `startLogin`, writes its result into the entry, and is reclaimed after `LOGIN_RESULT_RETENTION_MS` (60 s): the client asks a little later, so deleting at once would make a successful sign-in answer "done" never again. On the client side `authCall` accepts a `signal`, and the component **aborts the in-flight request** on cancel, timeout and unmount; together the two ensure the connection comes back promptly. See change doc [`2026-09-24-2`](2026-09-24-2-fix-polling-for-sign-in-answers-immediately.md). - **Neither the timeout nor cancel depends on the poll chain**: an independent `setTimeout` carries the deadline and `tick` only asks "is it done" — before that change the host's `pollLogin` waited out the entire sign-in window, so a deadline check behind it would never run, leaving the page on "Signing in…" with the cancel button attached. Cancelling **clears local state first and sends the RPC second**: cancelling is immediate for the user while the host round trip can be long, and once the state is cleared the poll still in flight is stopped by the effect's teardown. Clicking "Sign in" again clears the previous error (the timeout notice included) up front rather than waiting for that handshake's outcome. See change doc [`2026-09-24-1`](2026-09-24-1-fix-fix-the-sign-in-button-state-after-a-reload-and-the-timeout.md). - **Approach**: the CLI reuses exactly the same login flow as the Web page ([src/cli/login.ts](../../src/cli/login.ts)) — one handshake implementation, two entry points, identical behavior, and no second login path for multiple accounts. ### 3. Account set and per-session choice - **Feature description**: several accounts' tokens and identities persist under the dsh home and refresh single-flight per account; the CLI and the Web page share one account file; each conversation can select one of them independently. - **Implementation file locations**: [src/storage.ts](../../src/storage.ts), [src/session-store.ts](../../src/session-store.ts), [src/session.ts](../../src/session.ts), [src/atomic-file.ts](../../src/atomic-file.ts). - **Solution**: the account set — each account carries its own `auth` and `account`, plus a `lastUid`, plus the `machineId` used for activity reporting — is written atomically with owner-only permissions to `$DSH_HOME/data/plugins/llm-codebuddy-power/codebuddy-auth.json`; a legacy single-account file (`{ auth, account }`) migrates to a one-element list. [src/atomic-file.ts](../../src/atomic-file.ts) provides the atomic replace and per-path serialisation — several accounts' token refreshes, the configuration page's add/remove, and CLI calls all rewrite the same file, and without serialisation two concurrent writes each overwrite the other from stale content. - **Resolution order**: the session's explicit choice → the last manually switched account (`lastUid`) → the first entry. Only "which account this session uses" is a session-level fact, and it lives in that session's own file ([src/session-store.ts](../../src/session-store.ts)); `lastUid` is an account-level fact and lives in the credential file. A choice pointing at a signed-out account reads as "no choice", so the on-disk records only grow. - **Pinned on the first request**: a session writes the account it resolved into its own file the first time it really issues a request, and from then on follows neither `lastUid` nor the list order — switching accounts elsewhere does not reach it. The pin hangs on the authenticated-request entry point ([src/session.ts](../../src/session.ts) `require`), so merely opening an empty conversation (the UI reading usage and the catalog) leaves no record behind, and a conversation that never sends a message leaves no file at all. Switching accounts in a session that has never been written only updates `lastUid`: that session may never be used, and creating a file for it would leave an unclaimed remnant on disk. - **Derived sessions inherit at the moment they pin**: the session choice walks up `Session.header.parentSession`, so subagent and forked sessions take the parent's account as of **their own first request** and pin it. This is not permanent inheritance: a fork has its own session id and can be switched on its own, and once pinned the two no longer affect each other. Without the inheritance they would fall through to the account-level fallback and bill a dispatch to a different account, invisibly. - **A failed read is not an empty account set**: `loadStorage` gives the same empty answer for a missing file, a corrupt file, unsafe permissions, and a failed read (its deliberate contract), so whether that empty answer may be treated as settled is the caller's call — the session does not memoize an empty result under the file fingerprint (`storageMemoValid`) and always rereads when no fingerprint was obtained, and the configuration page reads once more before concluding "not signed in" from an empty list. See change doc [`2026-09-14-5`](2026-09-14-5-fix-the-account-list-does-not-report-signed-out-after-one-failed-read.md). - **Approach**: the file is the single source of truth and its fingerprint (`storageFreshness`) triggers the re-read, so a running harness picks up CLI sign-in/sign-out without a restart (the login flow itself is feature 2); whether an **account-set** change occurred is judged separately by the uid list (the fingerprint changes on every write, and a token refresh or recording `lastUid` both write, neither being an account add or removal). An account-set change invalidates the per-account meter cache, emits `llm/adapters-updated`, and rebuilds the model union from the accounts that remain (feature 4); changing which account one session uses does not — it changes no account's own catalog. **`machineId` sits alongside `account` rather than inside it**: that object maps the account fields the service returns, whereas this is a local fact this plugin generates and owns, and mixing them would make "what the service said" indistinguishable from "what we made up"; it is preserved when the same account signs in again, since discarding it would turn one device into a new one on every re-login. ### 4. Model catalog - **Feature description**: the catalog offers only models CodeBuddy discloses with a declared capacity; the display name stays CodeBuddy's own name with no credit multiplier or image marker appended; the `default` alias (in fact `deepseek-v4-flash`) is blacklisted and not offered. - **Implementation file locations**: [src/adapter.ts](../../src/adapter.ts) (`modelInfo`), [src/types.ts](../../src/types.ts) (`hasDisclosedCapacity`, `BLOCKED_MODEL_IDS`). - **Solution**: both the display name and the routing `id` keep CodeBuddy's original spelling; `listModels` drops blacklisted ids exactly like entries without disclosed capacity. Credit multiplier, image capability, promotions, and subscription tiers are presented by the model panel (see feature 5). - **Catalog order**: entries render in the order the service returns. - **The catalog is read and cached per account; the official list is served from the union**: different accounts can genuinely offer different models, so the catalog is cached and single-flighted per account uid. The CodeBuddy model panel reads it for the session's account, while the official model selector — which has no session context — takes its **entries from the union of every account read so far**, so a model exclusive to the session's account does appear in the official list. - **Session-aware reads fill the union, and any change broadcasts**: each account's catalog merges into a process-local union keyed by model id (first writer wins), and both `listModels` and `resolveModel` take their data from it. Each keeps one session-less read of its own (that is, by the fallback account, per feature 3's resolution order) — `listModels` reads before taking the union (at cold start the union is empty and the official list would be empty, leaving the user no way to trigger that read; that same read merges the fallback account in), and `resolveModel` reads after a union miss (`prepareCall` runs earlier than the adapter's session-aware read inside `stream()`, so a headless first request, where nothing has read the catalog yet, still gets real capabilities instead of a text-only declaration on which the harness would project its images away). A change to the union emits `llm/adapters-updated`, without which the browser-cached official list would never follow. - **Signing an account out rebuilds the union from the accounts that remain**: an account-set change drops the departed account's catalog cache and meter snapshot by uid, then **rebuilds** the merged catalog from the remaining accounts' catalogs rather than clearing it — clearing would collapse the official list back to the fallback account, while the other signed-in accounts' catalogs are already in hand. The test is uid membership rather than the file fingerprint: the fingerprint changes on every write (a token refresh or recording `lastUid`), so clearing on fingerprint would make other accounts' models vanish, whereas membership is idempotent. `invalidate()` does not clear the catalog caches — their content (models, promotions, tiers) does not vary with credentials or with per-model overrides, and overrides are reread from disk on every resolution. - **Known limitation**: the official list shows the models contributed by the accounts that have **been read**, not those of every account that could ever sign in; and where two accounts declare different capabilities for the same model id, the union keeps the declaration of whichever account was read first. - **Approach**: the display layer (name only) and the routing layer (wire id) stay separate so presentation does not pollute routing; the blacklist sits where the capacity filter sits, keeping the listing and the status view consistent about which entries are offered. ### 5. Model panel - **Feature description**: a CodeBuddy logo button beside the official model selector opens a model panel; each row shows promotion badges (peak-hour discount / night discount / limited free) and a subscription badge (subscriber priority), the credit multiplier, and any discounted rate; hovering (mouse) or tapping (touch) a row shows a flyout (name, description, available features, subscription hint, spend rate, campaign text, and Use / Upgrade buttons); the panel bottom overrides the context window and max output tokens per model. - **Implementation file locations**: [src/client/ModelPanel.tsx](../../src/client/ModelPanel.tsx), [src/client/icons.tsx](../../src/client/icons.tsx), [src/client/ModelPanel.module.css](../../src/client/ModelPanel.module.css) (touch media query), [src/model-enrich.ts](../../src/model-enrich.ts) (promotion and tier resolution, override storage), [src/types.ts](../../src/types.ts) (`CodeBuddyModelPromotion`/`CodeBuddyModelTier`), [src/session.ts](../../src/session.ts) (catalog and promotions cached in one read), [src/auth-service.ts](../../src/auth-service.ts) (`models`/`setOverride`), [src/adapter.ts](../../src/adapter.ts) (`resolveModel` reads the overrides). - **Solution**: the panel registers on `conversation.input.right` without shadowing the official `conversation.input.model`; selection still writes the shared `modelDirectories`, staying in sync with the official seat and the slash command. Enrichment arrives over the `/codebuddy` RPC: the host resolves promotions (timezone-aware activity windows, highest priority wins among concurrent matches) and the subscription tier, sending only display facts to the client. Overrides persist per model at `data/plugins/llm-codebuddy-power/model-overrides.json`; `resolveModel` returns the overridden `contextWindow`/`defaultMaxTokens` and leaves context management and output caps to dsh. Enrichment lookup is limited to the `codebuddy` group, so same-id models from other providers are never decorated. - **The discounted rate follows the promotion's declaration**: whether the original rate is kept is decided by the service's `displayMode` — `strikethrough` strikes the original and follows it with the discounted rate, while `replace` shows the discounted rate alone (e.g. the `x0` of a free night); rate labels are normalised with `x` first (the service is itself inconsistent: the catalog says `x0.79` and a promotion says `0.50x`); and the discounted rate takes its promotion's tag colour. See change doc [`2026-09-13-2`](2026-09-13-2-feat-present-the-discounted-rate-as-declared.md). - **Upgrade is a text link**: that action leaves the panel for the pricing page, so it renders as a text link with a trailing chevron (`›`, decorative and `aria-hidden`) rather than a button, so it cannot be mistaken for the in-panel Use action; it uses the official `--dsw-alias-link` colour and keeps a minimum tap height on touch. - **The current rate shows on the trigger**: inside the button and to the logo's right it shows the current model's rate (the discounted rate when an active promotion provides one, otherwise the catalog rate); **the promotion's tag colour is applied only when the shown value really is a discounted one** — an active promotion with no `discount` (badge and copy only) leaves the list rate on screen, and a fallback to the list rate carries no colour, matching the panel rows. When there is no value the block is skipped and the button stays square. Catalog data is reread every 60 seconds by this component, because which promotion is active depends on the current time and a long-open page has to refresh itself. See change doc [`2026-09-13-8`](2026-09-13-8-feat-show-the-current-rate-on-the-model-button.md). - **The hover shows only the model name and the rate**: `aria-label` and the hover bubble are both `model name rate` (e.g. `deepseek-v4.1-flash x0.79`) with no explanatory words — both values are already visible on the button, and the tooltip's job is to spare the user squinting at them, not to re-explain what the button is. The bubble uses dsh's `Tooltip` primitive, matching the command/attachment icons in the same row; a model outside the CodeBuddy catalog, or an unresolved catalog, falls back to the slot's name. See change doc [`2026-09-14-7`](2026-09-14-7-fix-the-model-buttons-hover-shows-only-the-model-name-and-the-rate.md). - **Closing the panel returns focus only for keyboard operations**: a keyboard-activated operation (click event `detail` is 0) gives focus back to the trigger — not doing so drops it to `body` and restarts the next Tab from the top of the page; a pointer click does not, because a mouse user's focus is already on the button and another `focus()` buys nothing while immediately raising `Tooltip`'s bubble over the button just acted on. See change doc [`2026-09-14-8`](2026-09-14-8-fix-no-hover-bubble-after-switching-models.md). - **Capacity figures use one notation**: the flyout's context window and max output tokens both round to one decimal before choosing a unit (`32k`, `393.2k`, `1M`), carrying a decimal rather than falling back to the raw number when the division is uneven — two unit systems in one column force the user to count digits to see which is larger, and the raw number also drags in a locale-varying grouping separator. See change doc [`2026-09-14-9`](2026-09-14-9-fix-max-output-no-longer-converts-only-evenly-divisible-values.md). - **The flyout portals to the body**: the row flyout mounts on `document.body` through `createPortal`. The composer seat is `position: sticky` (establishing its own stacking context), so the flyout's `z-index` only orders elements inside that seat and the seat as a whole is worth its 7; the conversation width handle hangs off `.body`, spans full height, and sets `z-index: 8`, so it covers a flyout living inside the seat's subtree. The outside-click test also checks `tipRef` — after portaling, the flyout's edit input is not inside `rootRef`. The panel body stays where it is and is covered where it overlaps the handle. See change doc [`2026-09-14-10`](2026-09-14-10-fix-the-model-flyout-is-no-longer-covered-by-the-width-handle.md). - **Overrides edit as a pair**: the context window and max output tokens share one edit switch (outside edit mode they show static values and the action row holds only "Edit"; inside it both cells become inputs and the row holds Reset to default / Cancel / Save). Cancel discards the drafts and falls back to the effective values; Reset to default clears this model's override (back to the catalog values). The inputs accept digits plus one optional `k`/`m` suffix (a minus sign and `e` are refused, the controlled input snapping back), an out-of-range value collapses to the ceiling, and legal input is kept verbatim. Enter saves. Static values are read from the most recent catalog read by id, so they refresh immediately after saving. See change docs [`2026-09-15-1`](2026-09-15-1-fix-the-flyouts-override-editing-becomes-paired.md) and [`2026-09-15-3`](2026-09-15-3-feat-the-override-inputs-accept-k-m-suffixes-and-cap-at-the-catalog-capacity.md). - **The ceiling comes from the catalog capacity, and input accepts k/m suffixes**: the editable ceiling uses the catalog's `maxAllowedSize`/`maxOutputTokens` (`CodeBuddyModelRow.contextWindowCap`/`maxOutputTokensCap`, which do not vary with the override), while display and drafting keep the effective value — using the effective value as the ceiling would make a once-lowered override a ceiling that can never be raised again. The inputs are text, accepting digits plus one optional `k`/`m` suffix (a minus sign and `e` are refused, the controlled input snapping back), with the suffix converted at save time (`5k`→5000, `1m`→1000000); legal in-range text is kept verbatim and only an out-of-range value collapses to the ceiling. Moving the pointer out of the flyout does not close it while editing. See change doc [`2026-09-15-3`](2026-09-15-3-feat-the-override-inputs-accept-k-m-suffixes-and-cap-at-the-catalog-capacity.md). - **Reasoning-effort selection**: the flyout carries a "Reasoning effort" row above the context window, with a dropdown trigger showing the current level on its right (ui-primitives' `Menu` primitive, portal-rendered), a check on the effective entry, and submission on click. The data comes from the **shared model catalog** (the reasoning metadata the harness obtains by calling `resolveModelInfo` per model), and the write goes through the **same** `ModelDirectory.select` as the official selector, so switching a level in the panel shows the same result in the official selector and vice versa. **The plugin stores nothing itself**: persistence is the harness's — the choice is written as the session event `model/selection` (durable in the session log, so reopening that same session keeps it) and best-effort into the global default-model settings (for new sessions; a failed write only logs a warning and does not affect this switch). A model that publishes no levels renders no row; both surfaces judge the "Default" entry and "is this the current route" identically. See change doc [`2026-09-15-4`](2026-09-15-4-feat-the-flyout-gains-a-reasoning-effort-selector.md). - **The trigger fades with the current provider**: when another provider owns the current model the logo inside the button drops to `opacity: 0.36` (0.6 on hover) while keeping its brand colour, so it cannot be misread as the control for the model in use. It **stays clickable** — this panel is how the user returns to CodeBuddy, and disabling it would block the way back. A directory that has not resolved yet (`current` is `null`) is left alone rather than faded on a guess. It **must be opacity**: the logo's brand colours are hard-coded `fill`s (purple tile plus white glyph) with no `currentColor`, and the rate text carries its own colour, so no `color` declaration can reach either. Opacity sits on the `svg` rather than the button so the hover background keeps full strength. - **Two ways to open it**: a mouse opens the flyout on `pointerenter` and closes it 500ms after leaving both row and bubble (**except while editing: a pointer leaving does not close it**, since the user would otherwise lose what they had just typed the moment the pointer strayed); touch opens or collapses it on `touchend` when the movement stayed under 10px, so a drag counts as scrolling the list and cannot trigger it. A tap-opened flyout **always shows Use** (touch has no hover to select with) while the mouse side shows it only when a promotion carries an action; tapping the row that already owns the flyout collapses it, so tapping only inspects and selecting goes through Use. Synthesized mouse events (`pointerenter`/`click`) within 700ms of a touch are ignored, so the flyout cannot fall back to a hover source or turn an inspection into a selection. When the viewport cannot fit the bubble beside the list (phones), it is anchored under the row and narrowed to the viewport. - **Placement and stacking**: the panel and the row flyout are both `position: fixed`. Their ancestors (AppFrame's columns, the conversation root) clip with `overflow: hidden`, so an absolutely-positioned overlay that leaves a column is cropped away — which reads as "hidden behind the sidebar" and cannot be fixed by raising `z-index`; the official `ModelSelect` `.menu` comment says its body portal exists for exactly this. Both clamp on **both axes**, not one edge: horizontally `clamp(value, margin, viewportWidth - width - margin)` and likewise vertically, and the panel flips below the trigger when the space above cannot hold it (`top`/`bottom` are mutually exclusive, with the opposite one set to `auto` on a direction change). Width caps are `min(360px, 100vw-24px)` (panel) and `min(280px, 100vw-24px)` (flyout); the panel's height cap takes the larger of the space above and below the trigger. Until it is positioned the panel is `visibility: hidden`, so a fixed element cannot flash at its static spot. `z-index`: the panel is 1100 and the flyout **1099** — the effort dropdown inside the flyout is rendered by the `Menu` primitive in portal mode, fixed at 1100, and since both portal to the body and compare in the root stacking context, a larger flyout value would cover the dropdown opened from inside it; 1099 still sits far above everything in the composer (the seat's stacking context caps at 7, the width handle at 8). **A scroll repositions and never closes** (matching `dsh-ui-fortifier`'s `ModelPicker`, whose scroll listener only calls `place`), because the trigger rides the `position: sticky` composer in the active conversation: scrolling the transcript must not hide a panel the user is reading. The only close is the trigger leaving the viewport entirely (the hero phase); the flyout dismisses when its row is clipped out of the list or leaves the viewport. **The panel's own scroll** (`scroll` also reaches `document` in the capture phase) does not participate — it does not move the panel. The reflow scroll a **window resize** produces is recognized by a component-ref timestamp plus a viewport-size snapshot: a match repositions and extends the grace, and nothing closes while reflow lasts; that state must live in refs rather than the effect's closure, because repositioning changes `tip` and rebuilds the effect that depends on it. Sizes are owned by the component: widths are `min(280/360px, viewportWidth - margin)` set inline (so a JS clamp constant can never disagree with a CSS width and overflow), and heights are clamped against the measured value. On open the panel scrolls the selected model into view with `scrollIntoView({ block: 'nearest' })`. - **List row tail**: the check mark and the rates sit in an always-present tail group (check first) that carries the right alignment — the element holding the alignment must always exist: giving it to the check strips right alignment from unselected rows, and giving it to the rates strands the check next to the model name. See change doc [`2026-09-13-3`](2026-09-13-3-fix-keep-a-rows-check-and-rates-together-at-the-right-edge.md). - **Approach**: the official seat is never shadowed, avoiding the upstream "replace the whole selector" approach; selection state has exactly one home (the shared directory) while display state rides the plugin's own channel. Overrides land on dsh's LLM contract rather than the request body; both caps may only be lowered below the catalog value. Touch and mouse share one positioning and rendering path and diverge only in what opens the flyout and when it closes. ### 6. Image input - **Feature description**: only models flagged `supportsImages` in the catalog offer the attachment affordance and receive images; the rest reject image content. An image the user attached is **no longer inlined** into the request; the request instead carries a read-only absolute path and guides the model to read it with `read_image`, so the pixels enter context through that tool's result. Three forms follow the request's tool catalog: with `read_image` present the prompt guides a direct call; in PTC mode (only `run_code`) it guides a call from the generated SDK; in minimal mode, which has neither, it falls back to inlining. - **Implementation file locations**: [src/adapter.ts](../../src/adapter.ts) (`prepareRequestImages`/`requestImageVersion`/`resolveImageAccess`), [src/serialize.ts](../../src/serialize.ts) (`readImageRoute`/`userImageParts`/`imageReadPrompt`, `image_url` content parts), [src/index.ts](../../src/index.ts) (`resolveAttachments`, and wiring the attachment service through `fs.processPathFromHostPath`), [src/constants.ts](../../src/constants.ts) (byte target and long-edge ladder). - **Solution**: durable harness attachments resolve through the mounted `AttachmentStore`, then serialize to OpenAI-compatible `data:;base64` `image_url` parts — the same wire shape the official CLI sends. **The only hard constraint is a 1 MiB byte target**: an image within it is resolved at its original size, hitting the attachment service's passthrough branch so the original bytes enter the request (no re-encode, no resolution loss); only an image above it walks the long-edge ladder (1920 → 1440 → 1080) until the target is met. The attachment service's request policy carries only a total-pixel cap and a byte target with no long-edge field, so the long edge is converted into an equivalent pixel budget from each image's own dimensions; its encoding ladder lowers quality but never dimensions, so an oversized image can only be retried at a tighter long edge. See change docs [`2026-09-13-4`](2026-09-13-4-feat-byte-target-and-long-edge-ladder-for-request-images.md) and [`2026-09-13-5`](2026-09-13-5-fix-stop-converting-images-within-the-byte-target.md). - **User-message images go through the read prompt**: the path comes from the attachment service — `resolveImageAttachmentAccess` maps the attachment's host object path into the current execution world through `fs.processPathFromHostPath`, the same wiring the official DeepSeek and pi-ai adapters use. Routing reads the request's wire tool catalog rather than configuration: a catalog with `read_image` takes the direct wording, one with only `run_code` takes the PTC wording, and one with neither inlines. When no path resolves it also falls back to inlining, so the model is never left without both the image and a usable path. Only images the user attached themselves take the prompt; injected user messages (including the image a `read_image` dispatched inside `run_code` returns in PTC mode) are always inlined. See change doc [`2026-09-23-1`](2026-09-23-1-feat-guide-the-model-to-read-images-itself.md). - **A tool result's image is sent by producing tool**: a read-image result sends its image alongside its text envelope — seeing the image is that tool's entire purpose — while an image-generation result keeps a text-only envelope (the model only needs to know an image was produced). See change doc [`2026-09-13-1`](2026-09-13-1-fix-send-a-read-image-result-image-to-the-model.md). - **Approach**: the composer's image attachment affordance is driven by the harness `inputModalities` (declared via `supportsImages`), so attachments appear only for models that accept images, avoiding useless uploads. The constraint falls on size, not edge length: the official IDE sends an image unchanged at or below 1 MiB without looking at dimensions, and **the service imposes no long-edge limit, confirmed by measurement** (images up to long edge 8192 and 16.78 M pixels were accepted with the model reading their content — see change doc [`2026-09-13-7`](2026-09-13-7-docs-the-service-imposes-no-image-long-edge-limit.md)). The long-edge ladder is therefore only a means of meeting the byte target and is used only above it. **Why a user-message image is not inlined but routed through the read prompt**: the gateway serves **non-native-multimodal** models through a compatibility path whose method is **converting the image into a text description** — which is why the model answers with the on-image text exactly, while what it receives is not pixels; images arrive as usual, yet the same path attaches the false "the current model does not support images, content filtered" claim. **The pixels travel identically on both routes** (the same base64 handed to the gateway, the same gateway-side conversion into a description), and that claim **is attached on both** (in `session-e691d503` the model itself accounts in the second turn for seeing it inside the `read_image` tool result). **The only material difference is whether the model believes the claim: it does not when the image arrives through a tool, and it does when the image arrives with the user message.** The counter-warning and the text handle both failed because each wrote itself as **a statement ranking equally with the claim** — on the user-message route the model has already settled that this is "something handed to me that may have been filtered", so one more equally-ranked assertion is merely listed beside it, decided by formatting and by attention, which a workspace's injection can crowd out. The tool route changes the model's relation to the image: the picture is the return value of a tool it called itself and the path is the one it passed in, so the claim does not fit. **Three forms are necessary rather than defensive**: minimal mode has no `read_image`, so sending the prompt there would point the model at a tool that does not exist, worse than inlining — and minimal mode does not need the prompt, because it has not formed the idea that it cannot see images (that idea comes from standard mode's long system prompt plus `Requires the current model to accept image input.` in the `read_image` description), which live sessions confirm by reading inlined images correctly. PTC needs its own wording because that mode exposes only `run_code`, with the remaining tools in the generated SDK where a direct name does not exist. The two earlier failed routes (counter-warning, text handle) are recorded in [`2026-09-22-1`](2026-09-22-1-fix-warn-that-the-upstream-image-filter-claim-is-wrong.md). ### 7. Allowance ring - **Feature description**: a ring in the composer, to the left of the account button, showing the used-allowance percentage of the **session's current account**; the main and gifted allowances merge into that one ring (used and limits each summed), and hovering it shows the total, each pool's own figures, the reset time directly after the main allowance, and the soonest-expiring gifts. The ring's centre shows the remaining gifted allowance while any is left, and the used percentage once it is spent. It renders nothing without an account, when the meter plane fails, or when the combined limit is zero. - **Implementation file locations**: [src/usage.ts](../../src/usage.ts), [src/client/AccountSwitcher.tsx](../../src/client/AccountSwitcher.tsx), [src/session.ts](../../src/session.ts), [src/auth-service.ts](../../src/auth-service.ts) (`usage`). - **Solution**: each window takes its gift/main attribution once at parse time from the billing plane's sub-product code, and the windows then aggregate into those two pools with exhausted gifts left out of the sum; the two pools are summed in the ring and the percentage is computed from that combined value, with the fill colour driven by the low-allowance alert percentage. Refresh is two-layered — the browser polls the host every 5 s, and the host serves a per-account meter snapshot cached 60 s (`METER_TTL_MS`) before asking CodeBuddy again. - **The ring centre prefers the remaining gift**: it shows the remaining gifted allowance (`limit - used`, floored at zero) whenever that is positive, and the used percentage otherwise. Only the centre text changes — the arc length and its colour still track the merged percentage, and the tooltip still carries every figure. The compact form `formatCompact` keeps three significant digits and steps up through `k`/`m`/`g`, because the ring's inner width holds about five characters and a raw six-digit allowance would not fit; the unit is chosen by magnitude and the rounding happens once, so `1_234_567` reads `1.23m` rather than carrying a `k`-stage rounding into `1.24m`. See change doc [`2026-09-26-2`](2026-09-26-2-feat-the-ring-centre-shows-the-remaining-gift.md). - **Approach**: the ring belongs to the session's account because allowance is a property of an account rather than of the process: with two conversations on different accounts, a fixed sidebar position could not say whose allowance it was reporting. The state-perception layer (fast frontend poll) and the real-request layer (per-account host cache) stay separate, so changes surface within one poll tick while real requests stay throttled; an account-set change (`storageFreshness`) drops the host cache. Whether the ring renders at all is decided by the "show allowance remaining" switch, which arrives through the inject face immediately (see feature 13), so the ring appears or disappears on the frame the switch flips; while it is off the usage is not polled, and turning it on re-reads once at once. - **Why one merged ring**: the main and gifted pools are both allowance this account can still spend, and showing them apart reads as if the ring tracked only the main pool while what is actually spendable is their sum. The configuration page therefore carries no "custom quota cap" override: after the merge no one total is worth replacing with a hand-typed number. ### 8. CLI tool - **Feature description**: `codebuddy-power` with `--status`, `--logout `, `--logout --all`, and `--no-open`. - **Implementation file locations**: [src/cli/login.ts](../../src/cli/login.ts). - **Solution**: implements the same browser login flow as the Web page, appending an account on success; `--status` lists every account by index; `--logout` resolves `` against uid, index, or nickname in that fixed order, and a nickname matching several accounts is an error asking for one of the first two forms. - **Approach**: when it cannot open a browser it prints the link only; the login action happens in the user's browser and the CLI just polls the handshake result. Account selection belongs to the session (feature 3), and there is no account-level "global default account", so the CLI offers no command to set one. ### 9. Image-generation tool - **Feature description**: a `codebuddy-draw` tool that a chat model calls with a prompt; the tool drives CodeBuddy's text-to-image model (`hunyuan-image-alpha`), persists the image through the standard attachment service, and shows it inline; clicking the image opens a full-size preview. - **Implementation file locations**: [src/image-tool.ts](../../src/image-tool.ts) (tool definition and execute), [src/codebuddy.ts](../../src/codebuddy.ts) (`generateImage` transport), [src/serialize.ts](../../src/serialize.ts) (strips tool-result images from the model request), [src/client/DrawToolCard.tsx](../../src/client/DrawToolCard.tsx) (conversation card and full-size preview), [src/client/DrawToolCard.module.css](../../src/client/DrawToolCard.module.css) (image and preview styles). - **Solution**: registers `codebuddy-draw` so the image model never enters the chat route; persistence/display run entirely over the harness standard channels (attachment service + ImageBlock + `uiConversation.imageUrl`), with no self-built route; clicking a thumbnail opens the original image in the built-in `Modal`; requests carry the full IDE identity headers. - **Approach**: shaped like the official IDE's `image_gen` — a chat model calls the tool with a prompt and the image persists and displays through the standard attachment channel; the full-size preview reuses the static UI primitive `Modal` and the same authorized image URL, adding no read or storage path; the IDE identity headers let the Tencent console bill under the client and conversation request id. See change docs [`2026-09-05-1`](2026-09-05-1-feat-expose-image-generation-as-a-tool.md) and [`2026-09-07-3`](2026-09-07-3-feat-open-generated-images-in-a-full-size-preview.md). ### 10. Minimal-mode draw tool switch - **Feature description**: a switch on the configuration page, "draw tool in minimal mode" (default off): minimal-mode (agent preset `minimal`) sessions do not see the draw tool unless the switch is on. - **Implementation file locations**: [src/client/CodeBuddySection.tsx](../../src/client/CodeBuddySection.tsx) (switch row), [src/index.ts](../../src/index.ts) (the volatile `Config` fields; `agent/created` and `agent-preset/selected` trigger restrict), [src/settings.ts](../../src/settings.ts) (the volatile field schemas), [src/prefs.ts](../../src/prefs.ts) (fields and defaults), [src/client/index.ts](../../src/client/index.ts) (reads the form and hands the value out through `hooks`). - **Solution**: the draw tool is globally registered, so a minimal agent still sees it through the global layer; `agent.ctx.tools.restrict({ deny })` removes it from the inherited surface; triggers cover "created minimal" (`agent/created`) and "switched later" (`agent-preset/selected`). The switch value is read at agent creation through the volatile config reference, so it applies to sessions created afterwards and never retroactively. - **Approach**: scoped-event listeners need `{ global: true }` for a host-level listener to receive them, and the host must inject `agents` to resolve the agent by session id before restricting. The switch value has to live in the host's own entry config: it is the input the host decides tool visibility from, and a browser-held switch would make one deployment differ per browser. See change docs [`2026-09-07-2`](2026-09-07-2-feat-minimal-mode-draw-tool-switch-and-visibility-restriction.md) and [`2026-09-16-1`](2026-09-16-1-refactor-interface-preferences-move-to-the-user-settings-document.md). ### 11. Per-turn credit pill - **Feature description**: a short pill at the right end of the message tail action row (the `assistant-actions` seat, after other contributions) shows the credits spent that turn (e.g. "12 credits"); any CodeBuddy model turn shows it (zero included); draw charges inside a turn count into that turn's total. Hovering it names the accounts that turn was billed to (nickname plus the first 8 uid characters, one line each when the turn crossed accounts) and that turn's request ID (first 6 characters). When a request ID is present, clicking the pill copies its **full value**, and the pill shows "Copied" briefly afterwards. The model is not stored — dsh's own usage display carries it. - **Implementation file locations**: [src/client/TurnCreditPill.tsx](../../src/client/TurnCreditPill.tsx) (assistant-actions pill), [src/session.ts](../../src/session.ts) (per-turn `{credit, accounts, requestId}` record, account-table resolution, `requestIdentity`), [src/session-store.ts](../../src/session-store.ts) (the session info file's account table and its one-line-per-entry layout), [src/atomic-file.ts](../../src/atomic-file.ts) (the atomic-replace base), [src/adapter.ts](../../src/adapter.ts)/[src/image-tool.ts](../../src/image-tool.ts) (identity resolved at request start, account and request ID written with the charge), [src/auth-service.ts](../../src/auth-service.ts) (`creditOf` RPC). - **Solution**: credits live in host memory plus the session info file (sidestepping the dsh session-log whitelist); the client registers a pill on the `conversation.chat.assistant-actions` seat (`order: 30`, rightmost in the slot), whose owner carries only the durable `messageId`, resolves the owning turn through the `useChat` snapshot, then queries per session+turn over RPC; draw and chat share one additive record. The account and the request ID are captured in that same identity resolution and written with the charge. - **Approach**: "show zero" is guaranteed by record presence — a record is written only after a CodeBuddy request completes, so existence proves a CodeBuddy model was used; the shared additive record keeps a turn's total correct regardless of call order; the model reuses dsh's own usage display instead of being stored. The account must land in the record together with the charge rather than being looked up on hover: the account button stays usable while a turn runs and the account is resolved once per model request, so a turn can genuinely cross accounts and a hover-time lookup would report the whole turn as the last one. The request ID follows the same rule and more strictly: it is minted per turn, so a hover-time lookup would report only the **current** turn's value and make every historical turn wrong, so it too is written at the moment of the charge. Accounts live in one table in the session file (`{index: {uid, nick}}`) with each turn keeping only the index: the same account recurs across dozens of turns, and inlining its nickname per turn duplicates one fact. The key is an index the file allocates itself rather than a uid truncation — a truncated key lets two accounts sharing a prefix share one record, reporting historical turns as the other account. The request ID is inlined per turn rather than tabled: it differs every turn, so there is no repetition to deduplicate. Both the uid and the request ID are truncated only where they are actually displayed, and storage keeps the full values (the request ID especially must stay whole across the RPC, or what gets copied is an id that resolves to nothing). See change docs [`2026-09-05-2`](2026-09-05-2-feat-persist-per-turn-credits-in-plugin-files.md), [`2026-09-07-1`](2026-09-07-1-feat-show-model-and-credits-per-turn-including-draw-charges.md), [`2026-09-16-2`](2026-09-16-2-feat-turn-credit-tooltip-names-the-account-used.md), and [`2026-09-27-3`](2026-09-27-3-feat-the-turn-credit-tooltip-shows-and-copies-the-request-id.md). - **Known limitation**: credits come only from the stream's usage block, and recording runs in a `finally` (usage delivered before a failure or abort still counts). A charge caused by a **transport-layer failure (TRANSPORT/TIMEOUT) is invisible to the plugin**, however — the service may have completed and billed the request while the client received no usage at all, so matching it requires reconciling against the service's billing (see change doc [`2026-09-12-2`](2026-09-12-2-fix-record-credits-seen-before-a-stream-failure.md)). ### 12. Account management UI - **Feature description**: the configuration page lists every signed-in account as a badge (wrapping onto the next line when a row runs out of room): a badge carries only the nickname (hover the whole info region for the uid/sign-in expiry/enterprise/department details, not just the nickname characters). Every other button sits in the account area's first row: the joined sign-in (copy / cancel on its right half), multi-select, and batch sign-out, left-aligned with no heading of its own (the plugin card already carries a title). Turning on "multi-select" gives every badge a checkbox and adds a trash button to the first row for signing out the selected accounts. Signing out always asks for confirmation first. The composer carries one control group instead: the allowance ring, the account button (showing the current account's first and last name characters joined by `~`), and the CodeBuddy model logo button, all inside a single container. - **Implementation file locations**: [src/client/CodeBuddySection.tsx](../../src/client/CodeBuddySection.tsx), [src/client/CodeBuddySection.module.css](../../src/client/CodeBuddySection.module.css), [src/client/ComposerControls.tsx](../../src/client/ComposerControls.tsx) (the control group's container), [src/client/AccountSwitcher.tsx](../../src/client/AccountSwitcher.tsx), [src/client/ModelPanel.tsx](../../src/client/ModelPanel.tsx), [src/client/store.ts](../../src/client/store.ts) (the account snapshot), [src/client/wire.ts](../../src/client/wire.ts) (the account view carries the sign-in expiry), [src/auth-service.ts](../../src/auth-service.ts) (`accounts`/`activate`/`logout`/`reorder`/`sessionAccount`), [src/session.ts](../../src/session.ts) (`accountInfo` projects the sign-in expiry), [src/storage.ts](../../src/storage.ts) (`reorderAccounts`). - **Solution**: the two surfaces sit in **different scopes** (`plugins.bundle.config` is root, `conversation.input.right` is session), and one store handle can be bound to only one scope (mounting it twice throws `one handle, one scope` at load), so the **account store hangs on the configuration page alone**; the composer control group fetches the account set and usage per session over RPC. **Interface preferences use no store seat**: they are volatile fields of the plugin's own entry config, read through `ctx.configForms.get(entryId)` and derived into an always-present bare snapshot that both entries receive through their own inject face's `hooks` compartment — so the ring changes on the very frame the switch flips, not at the next poll. The ring, the account button, and the model logo are **one entry** of `conversation.input.right` (`id: codebuddy-input`, wrapped into a single container element by [src/client/ComposerControls.tsx](../../src/client/ComposerControls.tsx)); as two entries they were merely two sibling elements under the slot owner's container, each with its own root and the owner's gap between them. Switching accounts writes that session's own session info file ([src/session-store.ts](../../src/session-store.ts)) and affects only that session. - **Drag reordering and a divider**: badges are draggable (native HTML5 drag), the pointer's left or right half decides the drop, and the insertion rule is drawn in the gap just outside a badge; multi-select mode offers no dragging. The order is written into the credentials file's account array, so it is at once the display order, the CLI `--status` numbering, and the fallback account used when no manual switch has been recorded — confirmed as "order is priority". A divider also separates the account area from the interface-preference rows below. See change document [`2026-09-17-1`](2026-09-17-1-feat-account-badges-become-drag-reorderable-and-a-divider.md). - **Badges are display-only and the management actions live in the first row**: a badge only shows the account, ordering goes through dragging, and signing an account out goes through "multi-select → check → batch sign-out". See change document [`2026-09-18-1`](2026-09-18-1-feat-badges-only-show-the-account-and-the-default-account-moves-to-the-top.md). - **Approach**: the badge list uses `flex-wrap` rather than clipping overflow — how many accounts exist is entirely the user's choice, and hiding them would make the accounts beyond the first row both invisible and impossible to sign out. Signing out is destructive (it deletes credentials from disk), so one account and several alike go through a confirmation. The nickname/UID/enterprise details live in the badge tooltip, since a badge has no body area to hold them; that tooltip is anchored to the whole info region (its left padding included, filling the badge's height) rather than to the nickname text alone, because a nickname is often very short and anchoring on the text leaves the badge's whitespace inert. **The sign-in expiry is the refresh token's expiry** (`auth.refreshExpiresAt`): the access token is refreshed automatically by this plugin as it nears expiry and the user never notices, so treating it as the sign-in expiry would make the value move on every refresh, whereas the refresh token's expiry is the one moment that requires signing in through the browser again. The nickname abbreviation slices by Unicode code point rather than UTF-16 unit, or an emoji or astral-plane character would be cut in half. After an account change on the configuration page the composer control group catches up within the next poll tick (5 s) — the account set holds no shared handle to subscribe to, so it reads on a timeline instead of waiting for a notification, which also lets it work when the configuration page is not mounted at all (a session-only view). Preferences are not in that set: they arrive through the inject face and need no polling. ### 13. Interface preferences - **Feature description**: four interface preferences — "show allowance remaining" (default on), the "low-allowance alert percentage" (default 90), "draw tool in minimal mode" (default off), and "report conversation activity" (default on) — are volatile fields of this plugin's own entry config, so they survive a browser change and a harness restart. The first three are ordered with the two allowance rows adjacent and the draw tool last: the alert percentage acts only on the ring's fill colour, so it belongs beside "whether the ring shows at all", while the draw tool is a different matter; activity reporting comes last because it acts on the conversation rather than the interface. - **Implementation file locations**: [src/prefs.ts](../../src/prefs.ts) (fields, defaults, and bounds shared by both faces), [src/settings.ts](../../src/settings.ts) (the volatile field schemas), [src/index.ts](../../src/index.ts) (the `Config` schema and `resolvePrefs`), [src/client/index.ts](../../src/client/index.ts) (reads the form and derives the snapshot), [src/client/CodeBuddySection.tsx](../../src/client/CodeBuddySection.tsx) (the switch row). - **Solution**: `Config` declares the four fields with `.volatile()`, so the configuration page's form projects them under this entry's id and writes land back in the profile patch. The browser reads that entry through `ctx.configForms.get(...)` and writes through one `set`, whose answer the settings mirror folds back synchronously (a local round trip, no polling). The derived bare snapshot reaches both entries through their own inject face's `hooks` compartment, so the switch on the configuration page and the composer ring agree on the same frame. - **Approach**: the preferences live in the plugin's own entry config, so they do not vary per browser and take no polling — flipping a switch and the ring changing happen on the same frame. "Draw tool in minimal mode" in particular must live host-side: it is the input the host decides `codebuddy-draw` visibility from. "Report conversation activity" must live host-side too: reporting happens when the adapter sends a request, with no browser involved. Validation stays host-side only: a browser face that also imported schemastery would bundle the validator into `lib/client.js`; the browser reads the already-resolved `ConfigForm` values. `resolvePrefs` reads each volatile reference at call time, so a form write reaches a running plugin without a remount. See change doc [`2026-09-16-1`](2026-09-16-1-refactor-interface-preferences-move-to-the-user-settings-document.md). ### 14. Conversation activity reporting - **Feature description**: with the switch on (the default), every CodeBuddy conversation in this plugin is accompanied by a report to CodeBuddy's `POST /v2/report`, so that conversation counts toward the official daily-activity map — equivalent to chatting in the official IDE; with it off, no report requests are produced at all. Contents are the turn's model, token counts, time, and outcome — never the prompt or answer text. - **Implementation file locations**: [src/report.ts](../../src/report.ts) (the `CodeBuddyReporter` and event assembly), [src/adapter.ts](../../src/adapter.ts) (opens a step at request start and settles it at the end, reusing the chat request's ids and trace), [src/index.ts](../../src/index.ts) (`turn/end` → `finishTurn`, plus instantiation and injection), [src/storage.ts](../../src/storage.ts) (the `machineId` in the account file), [src/constants.ts](../../src/constants.ts) (endpoint path, batch window, timeout). - **Solution**: mirrors the four lifecycle events of the official `AgentReporter` (`chat_request_send` / `chat_message_send` / `chat_message_response` + `chat_message_status` / `chat_request_response`), with fields and values exactly as captured. The turn-start event is emitted at the turn's **first model call**: the official event carries `requestModelId`, and the harness does not yet know which model a turn will route to when it opens it. The turn-end event is sent only at `turn/end`, because only there is whole-turn aggregate usage available while the adapter sees one step at a time. Events are batched per account (a 2 s window, matching the official `StandardEventService`), one POST per batch, with headers taken from that account's identity resolution for the call. Except for `turn/end` (sent on arrival), batches fire on a timer. - **Device identity is per account**: `machineId` and `sessionId` are each per account and stored in the account file (see feature 3); the former is persisted, the latter lives only in-process. This diverges from the official, whose two identifiers are orthogonal to accounts (one per machine, one per process); here they are keyed by account as the user requested, so switching accounts also changes the reported device. - **Reporting never affects the conversation**: all three conversation-path entry points (`stepStart`, the handle's `finish`, and `finishTurn`) each wrap themselves in `try/catch` and only log, and `warn` no longer throws outward either; those entry points only assemble and enqueue in memory, while the actual `fetch` is triggered by the timer or the turn end and is **never awaited by the conversation path** (the three entry points measured 0.7 ms combined with `fetch` not yet called). With the switch off no event is even assembled. The adapter settles each step exactly once (the success path after the stream ends, the failure path before throwing), so neither retries nor exceptions double-report. - **The step boundary is dsh's `step/end`**: dsh's retry happens above the adapter (it calls `stream()` again) but emits only one `step/start`..`step/end`, so the adapter merely records each attempt's context and outcome as the current step's candidate, overwritten by a retry, and the three message events are sent once each at `step/end`. The turn outcome takes the last attempt (so a failure that recovered via retry is not reported as a failed turn), while tokens accumulate across attempts (matching dsh's own `token-meter`). - **Known limitations**: `qimei36` (Tencent's device fingerprint) is not implemented — it comes from a native SDK the official IDE bundles, and it overlaps `machineId` functionally (both are device identifiers). The service tolerating its absence is confirmed by experiment (reports without the field score the same). `reportDelay` is computed faithfully to the official semantics (the delay between an event's creation and its sending). - **Approach**: this is the official client's fifth channel, alongside chat, and its absence was the sole reason this plugin's conversations never counted as activity. The extension-side `StandardEventService` is the one mirrored rather than the main process's `ChannelTelemetry` (which sends only the WeChat-channel `claw_*` events), because the activity events are in fact emitted by the extension side (confirmed by capture). ## TODO - The gift/main split keys on the billing plane's sub-product code, with the literal 裂变/赠送 words in a name kept only as a fallback (in [src/usage.ts](../../src/usage.ts)); a CodeBuddy change of sub-product code would need to follow. - The session info files (`sessions/.json`, holding the selected account and the per-turn credits) only grow: a disposed session (which every one-shot subagent is) is not a user-deleted session. dsh also has no delete/close-session command, only archive (`archiveSession` moves a session into the archive set and never fires `session/disposed`), so both kinds of record grow with the number of sessions. If dsh ever gains real deletion that fires `session/disposed`, the cleanup can be attached there.