# Known issues — found by testing the plugin in a real DSH instance
Everything below was found by running the plugin in a live harness, not by
reading code. Each entry says what you observe, why it happens, and what fixes
it. Three are fixed; two are product warts that remain.
The headline: **an approval that appears not to work is usually not the gate.**
In every case observed here the gate was either never loaded at all, or loaded
and correctly refused for a reason that had nothing to do with the approval UI.
---
## Fixed
### 0. The plugin was installed but composed nothing
**Observed:** the `web` profile declared `@freepeak/dsh-feature-loop` as a
dependency and booted 191 rows — none of them from this package. No Feature Loop
page, no gate, no ceiling. The plugin was present in `package.json` and absent
from `--dump-config`.
**Why:** `dsh plugin add` installs the dependency and does not add the package to
the profile's `dsh.profile.bundles`. A plugin composes **only** when it is listed
there. This is the same shape as the peer-resolution failure below, and it is
worse: the peers resolved, so every existing check passed.
**Fix:** `make install` now runs `scripts/add-bundle.mjs`, which appends the
package to that array — idempotently, and touching only that one array so the diff
is the change rather than a reformat. Verified on the `web` profile: 191 → 194
rows, all three `feature-loop*` rows composing, zero incompatible-row warnings.
### 1. The stylesheet restyled the whole host UI
**Observed:** after the dashboard was folded into the DSH UI, the host's `
`
background, ``, every `` and every `` were restyled by the
plugin.
**Why:** `web/shell.css` was written when the dashboard owned its own origin,
where `body`/`header`/`main`/bare `button` rules were correct. It rode along
inside the client bundle into the harness's `` unchanged.
**Fix:** every rule is anchored to `.fl-page` (the page inside the DSH UI) or
`.fl-standalone` (the standalone page, which now marks its own ``).
`:where()` is the scope prefix so specificity is unchanged. See PR #23 and
`test/css-scope.test.ts`, which fails if a bare type selector returns.
### 2. "Start a loop" listed sessions, not the opened workspace
**Observed:** the picker listed every session in every project and fell back to
the most recently touched one — routinely a different repository.
**Why:** a run's blast radius is a *directory*, and the control asked the user to
reason about transcripts to decide which checkout gets edited.
**Fix:** the target is the Workspace the user has open, derived the way the
sidebar derives it (`retainedBy.mainView`, then the owning workspace), and
workspace → session is resolved by the host's `uiWorkspace.connectWorkspace`.
With several workspaces and none open, submit is blocked and says why. PR #23.
### 3. The injected stylesheet was never removed
**Observed:** a profile with `patchReload: live` accumulated one copy of the
plugin's stylesheet per reload in the host's ``.
**Why:** `apply()` appended the tag and returned a disposer that only tore down
the remote mount.
**Fix:** the tag is owned by an `ctx.effect` with a `remove()` teardown, the
same ownership the host's own theme sheets use. PR #23.
### 5. `POST /api/approvals/:id` ignored the query token
**Observed:** a scripted approval using `?token=…` got
`401 missing or invalid dashboard token` while every read route accepted the
same query token.
**Why:** the settle route called `authorized(req, new URL('/', 'http://x'), false)`
— a placeholder URL with `allowQuery: false` — so the real `?token=` was discarded
and only `x-dashboard-token` worked.
**Fix:** `approve` now receives the request URL and passes `allowQuery: true`.
Header still wins when present; `sameOrigin` still blocks cross-origin browsers.
Covered by `test/dashboard.test.ts` ("POST settle accepts ?token= without the
header").
### 7. The dashboard page was only a watcher half the time
**Observed:** an approval raised while the Feature Loop page was open and
polling appeared in the composer instead — from the page you were watching,
which reads as a flaky gate.
**Why:** `web/app.tsx` re-read live state every 30s as a safety net, and that
read is exactly what calls `noteWatcher`. The registry only counts a front end as
watching for 15s (`WATCHER_TTL_MS`). So the page was a watcher for 15s out of
every 30, and a gate firing in the gap lost the claim to the composer panel.
**Fix:** the interval is now derived from the TTL (`WATCHER_POLL_MS`), and the
TTL moved to `src/watcher-ttl.ts` so the two cannot drift — it could not live in
`approvals.ts`, which imports `node:crypto` and cannot enter a browser bundle.
`test/dashboard.test.ts` asserts the interval the page actually schedules.
---
## Environment — the one that breaks everything silently
### 4. The plugin's peer dependencies were unresolved, so its module never imported
**Observed:** the plugin appeared installed — it was in the boot graph, its
`cordis.patch.yml` composed, `--dump-config` showed all three rows — and it did
absolutely nothing. No gate, no review, no approval. The loop just ran with
default permissions.
**Why:** the built plugin imports two packages at runtime:
```
@deepseek-ai/dsh-llm (boundContextSummary, createUserMessage)
@deepseek-ai/dsh-typert-protocol (Remote)
```
Both are `peerDependencies`, both optional, and `.npmrc` sets
`auto-install-peers: false` — so pnpm never installs them. In a profile where
nothing else supplies them:
```
ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-llm'
```
The loader creates the row, the import throws, the fiber never constructs, and
`apply()` never runs.
**The trap:** the client bundle still appears in the boot graph, because
`client-modules` reads `dsh.client` from `package.json` **on disk** and never
imports the module. A graph row proves an entry *name* exists — it is not proof
the plugin works.
**Fix (profile-level):** something in the profile must supply the harness
packages. The main `web` profile does this by also depending on
`@deepseek-ai/dsh-experimental-agent-team-profile`, which pulls the harness
packages into the shared pnpm store. You can see it in the store key:
```
@freepeak+dsh-feature-loop@file+…dsh-feature-loop # peers NOT resolved
@freepeak+dsh-feature-loop@file+…dsh-feature-loop_@deepseek-ai+c_q7p3s2fiohg… # peers resolved
```
Two further gotchas when building such a profile by hand:
- **Use the same pnpm major as the lockfile.** `pnpm@11` silently re-resolved a
v9 lockfile and dropped the peer wiring. `npx pnpm@9.15.9 install --frozen-lockfile`
reproduced the working install.
- **Boot a named profile without an app argument.** `bin.js --profile X --port N`
boots X's bundle tree. `bin.js web --port N` boots the default web app and
ignores the profile's bundles — the plugin composes in `--dump-config` and is
still never mounted.
**How to check in one second:** if the plugin's client row is in the boot graph
but nothing behaves differently, the module is not importing. Compare the pnpm
store key for a `_@deepseek-ai+…` suffix.
---
## Open — product warts
### 6. Only an SSE client counts as a "watcher"
**Observed:** polling `GET /api/state` every second never let the registry claim
an ask, so in a headless run the gate's `ask` was refused with
`tool "write" requires approval, but no approval channel is available` — the
page was right there, polling, and still invisible to the claim.
**Why:** `noteWatcher()` is called only from the `/api/events` SSE handler
(`src/dashboard.ts:833`). A `/api/state` poll is not a watcher.
**Why it matters:** the in-UI page is safe (it calls the host remote's `live()`,
which notes the watcher), but the standalone page and any scripted client must
hold the stream. The source comment describes exactly this failure, which is
good, but the behaviour is a trap: "I am polling the state endpoint, surely that
counts."
**Suggested fix:** have `/api/state` note the watcher too, or return an explicit
`watching: true/false` so a client can tell whether it is eligible to answer.
Not changed here — it is a deliberate precedence rule (claim only while a real
surface is connected) and widening it risks stranding asks.
---
## Verified working (2026-09-29, live instance on :3188)
### The human input path, in a real browser
Driven over the Chrome DevTools Protocol (headless `--dump-dom` never returns
on this app: it holds a stream open, so the load event never settles).
| Check | Result |
|---|---|
| Feature Loop page renders | `.fl-page` present, "START A LOOP" |
| Task input + submit | input and "Start loop" button present |
| Target is the **opened workspace** | `Runs in "default-workspace" — /Users/.../default-workspace` |
| Dashboard renders | workspaces / approval thread / runs, with the pending-ask panel |
| Host chrome untouched | host `button` radius `0px`, host `h2` normal size |
| Plugin stylesheet mounted | `