dsh-tower 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.en.md +22 -16
  3. package/README.md +22 -16
  4. package/SECURITY.md +4 -1
  5. package/docs/README.md +30 -0
  6. package/docs/architecture/overview.md +3 -2
  7. package/docs/examples/first-mission.md +2 -1
  8. package/docs/faq.md +15 -6
  9. package/docs/guide/install-upgrade-migration.md +14 -5
  10. package/docs/guide/quickstart.md +12 -7
  11. package/docs/guide/scenarios-and-philosophy.md +150 -62
  12. package/docs/operations/security.md +23 -12
  13. package/docs/reference/blockers.md +11 -10
  14. package/docs/reference/config.md +8 -6
  15. package/docs/reference/errors.md +7 -1
  16. package/docs/reference/events.md +7 -4
  17. package/docs/reference/testing.md +2 -1
  18. package/docs/reference/tools.md +7 -6
  19. package/docs/zh/README.md +1 -1
  20. package/docs/zh/faq.md +12 -4
  21. package/docs/zh/guide/install-upgrade-migration.md +11 -4
  22. package/docs/zh/guide/quickstart.md +13 -8
  23. package/docs/zh/guide/scenarios-and-philosophy.md +108 -36
  24. package/docs/zh/operations/security.md +16 -6
  25. package/docs/zh/reference/blockers.md +10 -10
  26. package/docs/zh/reference/config.md +8 -6
  27. package/docs/zh/reference/errors.md +2 -1
  28. package/docs/zh/reference/events.md +6 -3
  29. package/docs/zh/reference/testing.md +3 -2
  30. package/docs/zh/reference/tools.md +7 -6
  31. package/index.js +26 -17
  32. package/lib/config.js +103 -6
  33. package/lib/doctor.js +11 -5
  34. package/lib/errors.js +14 -2
  35. package/lib/git.js +36 -2
  36. package/lib/modeStore.js +54 -20
  37. package/lib/store.js +65 -10
  38. package/lib/tests.js +35 -1
  39. package/lib/tools.js +257 -47
  40. package/lib/tower.js +281 -55
  41. package/lib/workspaceLock.js +44 -20
  42. package/package.json +3 -2
  43. package/scripts/check-docs.mjs +35 -0
  44. package/skills/tower-recovery.md +7 -5
  45. package/ui-bundle/README.md +111 -0
  46. package/ui-bundle/client.js +1980 -0
  47. package/ui-bundle/cordis.patch.yml +3 -0
  48. package/ui-bundle/icon.svg +6 -0
  49. package/ui-bundle/index.js +2 -0
  50. package/ui-bundle/locale/en.json +6 -0
  51. package/ui-bundle/locale/zh.json +6 -0
  52. package/ui-bundle/package.json +35 -0
@@ -16,28 +16,53 @@ export function workspaceLockToken() {
16
16
 
17
17
  async function readRecord(commonDir) {
18
18
  const file = workspaceLockPath(commonDir)
19
+ let text = null
19
20
  try {
20
- const text = await fsp.readFile(file, 'utf8')
21
- const parsed = JSON.parse(text)
22
- if (!parsed || typeof parsed !== 'object') return { file, record: null, corrupt: true }
23
- return { file, record: parsed, corrupt: false }
21
+ text = await fsp.readFile(file, 'utf8')
24
22
  } catch (error) {
25
- if (error?.code === 'ENOENT') return { file, record: null, corrupt: false }
26
- return { file, record: null, corrupt: true }
23
+ if (error?.code === 'ENOENT') return { file, record: null, corrupt: false, raw: null }
24
+ return { file, record: null, corrupt: true, raw: null }
25
+ }
26
+ try {
27
+ const parsed = JSON.parse(text)
28
+ if (!parsed || typeof parsed !== 'object') return { file, record: null, corrupt: true, raw: text }
29
+ return { file, record: parsed, corrupt: false, raw: text }
30
+ } catch {
31
+ return { file, record: null, corrupt: true, raw: text }
27
32
  }
28
33
  }
29
34
 
30
- async function removeLock(file) {
31
- for (let attempt = 0; attempt < 5; attempt += 1) {
35
+ /**
36
+ * Remove the lock only if it is still the record we inspected. A concurrent
37
+ * release/re-acquire must not be deleted by the loser of the race.
38
+ */
39
+ async function removeLockIfMatches(file, expectedToken, expectedRaw) {
40
+ const quarantine = `${file}.stale-${process.pid}-${crypto.randomUUID()}`
41
+ try {
42
+ await fsp.rename(file, quarantine)
43
+ } catch (error) {
44
+ if (error?.code === 'ENOENT') return true
45
+ throw error
46
+ }
47
+ let moved = null
48
+ try {
49
+ moved = await fsp.readFile(quarantine, 'utf8')
50
+ } catch {
51
+ moved = null
52
+ }
53
+ const same = expectedToken ? (() => {
32
54
  try {
33
- await fsp.unlink(file)
34
- return
35
- } catch (error) {
36
- if (error?.code === 'ENOENT') return
37
- if (!['EPERM', 'EBUSY', 'EACCES'].includes(error?.code) || attempt === 4) throw error
38
- await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1)))
55
+ return JSON.parse(moved ?? '')?.token === expectedToken
56
+ } catch {
57
+ return false
39
58
  }
59
+ })() : moved !== null && moved === expectedRaw
60
+ if (!same && moved !== null) {
61
+ await fsp.rename(quarantine, file).catch(() => {})
62
+ return false
40
63
  }
64
+ await fsp.unlink(quarantine).catch(() => {})
65
+ return true
41
66
  }
42
67
 
43
68
  /**
@@ -54,7 +79,7 @@ export async function acquireWorkspaceLock(commonDir, options) {
54
79
  return { acquired: true, record, tookOver: attempt > 0 }
55
80
  } catch (error) {
56
81
  if (error?.code !== 'EEXIST') throw error
57
- const { record: current, corrupt, file } = await readRecord(commonDir)
82
+ const { record: current, corrupt, file, raw } = await readRecord(commonDir)
58
83
  if (corrupt || !current) {
59
84
  // A corrupt lock is treated like an unknown holder; only a stale one
60
85
  // is removed, so a concurrent writer is never silently overwritten.
@@ -66,7 +91,7 @@ export async function acquireWorkspaceLock(commonDir, options) {
66
91
  stale = true
67
92
  }
68
93
  if (stale) {
69
- await removeLock(file)
94
+ await removeLockIfMatches(file, null, raw)
70
95
  continue
71
96
  }
72
97
  return { acquired: false, holder: null, corrupt: true }
@@ -76,7 +101,7 @@ export async function acquireWorkspaceLock(commonDir, options) {
76
101
  return { acquired: true, record: { ...record, token: current.token }, tookOver: false }
77
102
  }
78
103
  if (now - Number(current.at ?? 0) > staleMs) {
79
- await removeLock(file)
104
+ await removeLockIfMatches(file, current.token, raw)
80
105
  continue
81
106
  }
82
107
  return { acquired: false, holder: current }
@@ -97,10 +122,9 @@ export async function touchWorkspaceLock(commonDir, options) {
97
122
  /** Release the lock only when this session owns it. */
98
123
  export async function releaseWorkspaceLock(commonDir, options) {
99
124
  const { token } = options
100
- const { record, file } = await readRecord(commonDir)
125
+ const { record, file, raw } = await readRecord(commonDir)
101
126
  if (!record || (token && record.token !== token)) return false
102
- await removeLock(file)
103
- return true
127
+ return removeLockIfMatches(file, record.token, raw)
104
128
  }
105
129
 
106
130
  export async function inspectWorkspaceLock(commonDir, options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tower",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "type": "module",
5
5
  "description": "DSH-native Tower: mission planning, worktree isolation, review-gated merges, and a human-owned Tower mode.",
6
6
  "license": "MIT",
@@ -51,7 +51,8 @@
51
51
  "LICENSE",
52
52
  "CONTRIBUTING.md",
53
53
  "SECURITY.md",
54
- "docs/"
54
+ "docs/",
55
+ "ui-bundle/"
55
56
  ],
56
57
  "dsh": {
57
58
  "bundle": {
@@ -77,11 +77,15 @@ const eventNames = [
77
77
  'status',
78
78
  'preflight',
79
79
  'doctor',
80
+ 'recover',
80
81
  'test',
81
82
  'batch',
82
83
  'resource',
83
84
  'import_legacy',
84
85
  'legacy_map',
86
+ 'mode',
87
+ 'control',
88
+ 'reload',
85
89
  ]
86
90
  const documentedEvents = read('docs/reference/events.md')
87
91
  const missingEvents = eventNames.filter((name) => !documentedEvents.includes(`\`tower/${name}\``))
@@ -102,6 +106,37 @@ if (missingErrors.length > 0) problems.push(`docs/reference/errors.md misses cod
102
106
  if (missingEvents.length > 0) problems.push(`docs/reference/events.md misses events: ${missingEvents.join(', ')}`)
103
107
  if (missingChangelog) problems.push(`CHANGELOG.md misses version ${packageJson.version}`)
104
108
 
109
+ // Counts and high-risk defaults are part of the contract, not just names.
110
+ const toolsDoc = read('docs/reference/tools.md')
111
+ const blockersDoc = read('docs/reference/blockers.md')
112
+ const errorsDoc = read('docs/reference/errors.md')
113
+ const eventsDoc = read('docs/reference/events.md')
114
+ const configDoc = read('docs/reference/config.md')
115
+ const zhConfigDoc = read('docs/zh/reference/config.md')
116
+ if (!toolsDoc.includes(`up to ${toolNames.length} tools`)) {
117
+ problems.push(`docs/reference/tools.md must state "up to ${toolNames.length} tools"`)
118
+ }
119
+ if (!blockersDoc.includes(`${blockerCodes.size} stable blocker/failure codes`)) {
120
+ problems.push(`docs/reference/blockers.md must state ${blockerCodes.size} stable blocker/failure codes`)
121
+ }
122
+ if (!errorsDoc.includes(`${errorCodes.length} tool error codes`)) {
123
+ problems.push(`docs/reference/errors.md must state ${errorCodes.length} tool error codes`)
124
+ }
125
+ if (!eventsDoc.includes(`${eventNames.length} process-local events`)) {
126
+ problems.push(`docs/reference/events.md must state ${eventNames.length} process-local events`)
127
+ }
128
+ for (const required of [
129
+ '| `tests.admission` | `off` |',
130
+ '| `review.requireReview` | `false` |',
131
+ '| `mode.allowAgentControl` | `true` |',
132
+ '| `git.timeoutMs` | `15000` |',
133
+ ]) {
134
+ if (!configDoc.includes(required)) problems.push(`docs/reference/config.md misses required default: ${required}`)
135
+ }
136
+ if (!zhConfigDoc.includes('| `tests.admission` | `off` |')) {
137
+ problems.push('docs/zh/reference/config.md misses the tests.admission default')
138
+ }
139
+
105
140
  // Chinese parity: when the translation tree exists it must cover the same
106
141
  // reference names, so a tool or code cannot be added only in English.
107
142
  if (exists('docs/zh')) {
@@ -24,8 +24,9 @@ untouched — call `tower_merge` to record it.
24
24
 
25
25
  ## `state.lock` reports another writer
26
26
 
27
- Wait, then retry. Never delete the lock while a writer may be active; if the process is gone,
28
- remove `<git-common-dir>/dsh-tower/state.lock` deliberately and retry.
27
+ Wait and retry; a live writer must never be interrupted. If the holder is provably dead, use a
28
+ supported recovery path (or ask the human to run one) that verifies the PID is gone and takes a
29
+ backup first. **Do not delete `state.lock` or the state directory from this playbook.**
29
30
 
30
31
  ## Worktree missing or dirty
31
32
 
@@ -35,6 +36,7 @@ explicit teardown `force` decision.
35
36
 
36
37
  ## Reset a repository's Tower state
37
38
 
38
- Only when no mission is in flight and you accept losing mission history: stop all agents, remove
39
- `<git-common-dir>/dsh-tower/`, then `tower_init` again. Worktrees and branches must be cleaned
40
- manually.
39
+ **Do not delete `<git-common-dir>/dsh-tower/` from this playbook.** That directory contains mission,
40
+ review, roster, finding and activity history; deleting it can lose unrecoverable work. Stop and ask
41
+ the human to inspect `tower_doctor`, take a backup, and decide on a controlled repair. Worktrees and
42
+ branches must be cleaned only after that decision.
@@ -0,0 +1,111 @@
1
+ # Tower UI (optional Client bundle)
2
+
3
+ An **optional** Web Client bundle for the Host `dsh-tower` plugin. It never touches Tower state directly: everything it renders is derived from the conversation the user is already looking at, or from the tool results already in that conversation. There is no Host → Client remote channel, no `host.call`, no DOM manipulation, no iframe, and no import of Harness Client packages (the only module it requires is `react` from the browser module table).
4
+
5
+ Version: **0.2.2** (private, not published). The Host plugin version is unchanged.
6
+
7
+ This revision fixes GitHub **#11** (persistent, history-derived Tower-mode indicator in the composer dock) and **#12** (mode-aware `Tower mode · disabled` rows for guard-denied tools), see below.
8
+
9
+ ## What it registers
10
+
11
+ | Surface | Slot | Registration | Data source |
12
+ |---|---|---|---|
13
+ | Tower tool cards | `tool.call.toolview` (keyed, session) | 12 entries, one per `key`: `tower_status`, `tower_roster`, `tower_spawn`, `tower_mission`, `tower_complete`, `tower_review`, `tower_preflight`, `tower_merge`, `tower_teardown`, `tower_interrupt`, `tower_doctor`, `tower_batch` | The Chat tool-call tree's owner props: `toolName`, `phase` (`preparing` / `start` / `result`), `block` (streaming start block or settled `tool/result` block), `callId`. |
14
+ | Composer dock | `conversation.composer.dock` (list, session) | `id: "tower-hint"`, `order: 6` | `useChat` standard selector hook (the durable Chat legacy slice). In Tower mode: a `Tower · on` chip. Otherwise, a Tower tool result in the conversation: a compact latest-status line. Otherwise: the static `/tower` hint. |
15
+ | Denied-tool rows | `tool.call.toolview` (keyed, session) | dynamic entries for the Tower-mode deny list, `priority: -10`, plus `update_goal` | The same history-derived Tower mode. Registered only while the displayed session is in Tower mode and disposed when it leaves, so the native toolviews (priority 0) return. |
16
+ | Overlay status strip | `shell.overlay` (list, root) | `id: "tower-status-strip"`, `order: 20` | The overlay slot is root-scoped, so it receives no chat hooks. The chat-driven toolview components publish a start/result notice into a module-local observable that this entry injects as `useTowerNotice`; a single timer auto-dismisses it after 4.5 s and is disposed by `ctx.effect`. |
17
+ | Locale | Client locale service | namespace `tower-ui` (`en`, `zh`) | Bound `t` seats come from each registration's `locale` option. |
18
+
19
+ The cards are deliberately compact: mission status/counts, task checklist progress, blockers, roster live/stale counts, review verdict, merge/preflight state, and check/test evidence (exit codes included). All of it is rendered as React elements with inline styles; **theme references are restricted to `--dsw-alias-*` tokens**, so light/dark and token renames degrade gracefully.
20
+
21
+ The overlay strip is `pointer-events: none` end-to-end and uses `role="status"` / `aria-live="polite"`; it never blocks the composer or the page.
22
+
23
+ ## Persistent Tower-mode indicator (#11)
24
+
25
+ Tower mode is a Host-side session stance and there is no Client RPC for it. The dock derives it from the durable session history that the Chat projection exposes in `useChat(snapshot.legacy)`:
26
+
27
+ - the `/tower on|off` command node (`kind: "command"`, `name: "tower"`, `args`, `outcome.kind`); bare `/tower` and `/tower <message>` mean "ensure on", `/tower reload` and `/tower base <branch>` leave the stance unchanged, and a failed command is ignored;
28
+ - `tower_mode` results (`mode: "on" | "off" | "unknown"`, `deniedWhileActive`, `workspaceLock`, `lastChange`);
29
+ - `tower_control` results (`action: enter|exit`, `mode`, `changedBy`, `reason`).
30
+
31
+ The last recognized event wins. A page refresh re-derives the same stance from the loaded history; when that history window no longer contains a mode event, the dock falls back to a browser-local last-known value for the same session and labels it as such. An old value expires after seven days and never drives the denied-tool shadowing by itself.
32
+
33
+ While the mode is on the dock shows a `Tower · on` chip with `role="status"` / `aria-live="polite"`, and its tooltip carries the actionable hint plus, when known, the repository basename, workspace-lock state (`held`/`free`), and who changed the mode last. The chip is **deliberately non-interactive**: the only existing standard affordance is `inputActions.setDraft` + `inputActions.submit`, which submits the composer draft and would clobber what the user is typing. The tooltip tells the user to run `/tower off`; the bundle itself does not toggle the mode (the model's supported path is the Host `tower_control` tool when `allowAgentControl` is enabled).
34
+
35
+ ## Mode-aware denied-tool rows (#12)
36
+
37
+ The Host guard returns a plain tool error like `tower mode: <tool> is disabled while Tower mode is active; use the Tower tools or /tower off` (and `tower mode: goals cannot be resumed while Tower mode is active; use /tower off first` for `update_goal` resume).
38
+
39
+ While the displayed session is in Tower mode the bundle dynamically registers `tool.call.toolview` entries for:
40
+
41
+ - `tower_mode.deniedWhileActive` when the latest `tower_mode` output lists it, otherwise `lib/config.js` `DEFAULT_DENY_TOOLS` (`create_goal`, `todo_write`, `team_task_create`, `team_task_update`, `team_task_reopen`, `team_task_list`, `spawn_teammate`, `wait_agent`, `workflow`, `ralph`, `subagent`, `subagent_fork`);
42
+ - `update_goal` unconditionally, because the guard special-cases its `action: "resume"`.
43
+
44
+ Those entries register with `priority: -10`. The slot core renders the lowest priority for a key, so this shadows the native keyed views without replacing them; disposing the entries (mode off) restores the native views exactly. A denial result renders as a distinct **`Tower mode · disabled`** row (warn marker, label, the preserved denial message, and the `/tower off` hint) — not the generic error text styling. `update_goal` resume denials get the same row. A non-denial result that lands in one of those slots while the mode is on renders a defensive generic row (there is no native view to delegate to while the registration shadows it); ordinary tool errors are not misclassified as Tower-mode denials.
45
+
46
+ ## Verification status
47
+
48
+ Verified **statically** against the installed DSH **0.2.1-rc.2** sources:
49
+
50
+ - `tool.call.toolview`: declared by `@deepseek-ai/dsh-client-ui-tool` as `{ kind: "keyed", scope: "session" }`; the Chat tool-call tree dispatches with `entryKey = toolName` and passes `{ callId, toolName, ...call, openFile, cwd, home, loadImage, useDisclosure, inspect }`, where `call` is `{ phase, block }`. A keyed hit replaces the generic tool row; a miss falls back to it.
51
+ - Keyed priority/shadowing: the Client slot core (in the web shell bundle) allows a second keyed registration only at a different `priority` and resolves a key with the **lowest** priority first — `register at a different priority to shadow it (lowest renders)`. Native views use the default priority 0.
52
+ - `conversation.composer.dock`: declared by `@deepseek-ai/dsh-client-ui-conversation` as `{ kind: "list", scope: "session" }`, rendered as `div[data-composer-dock]`. Owner props are empty; session-scope standard props (including `useSession`, `useChat`, `useConversation`) are supplied by the renderer.
53
+ - `shell.overlay`: declared by `@deepseek-ai/dsh-client-ui-layout` as `{ kind: "list", scope: "root" }`, rendered inside `[data-shell-overlay]`. Root standard props are `useSessions`, `useSessionStatus`, `useWorkspaces`, `usePanelInfo` and keyed access hooks — **no chat hook**; hence the toolview-published notice design above.
54
+ - `/tower` history: DSH logs `command/run` with `{ name, args: parsed.rawInput, source }` and `command/done` with `{ kind, text }`, which the Chat command node exposes as `{ kind: "command", name, args, outcome }` in the legacy slice.
55
+ - Guard denial shape: the Host tools pipeline materializes a denial as `{ content: [{ type: "text", text: "Error: <reason>" }], isError: true, error: { message: <reason> } }`.
56
+ - Locale: `ctx.locale.register(ns, { en, zh })`, dictionaries are flat `key → template` maps with `{param}` interpolation; a slot declaration's `locale` option supplies the bound `t` seat.
57
+ - The bundle's `dsh.client.inject` orders activation after `ui-conversation` and `ui-tool`, the two packages that declare the slots it targets. `ctx.slots.inject` only installs a registration while its owner declaration exists; each injection is guarded and reported in diagnostics instead of crashing the whole Client half.
58
+
59
+ Automated offline coverage: `tests/ui-bundle.test.mjs` loads `client.js` through a fake `window.__ModuleLoader__`, asserts the 12 keys / dock id / overlay id, en–zh dictionary parity, theme-token discipline, and renders every card plus the dock/overlay components with fake hooks. It also drives fake chat history through the dock to assert the off hint, the on chip (including refresh re-derivation), `tower_mode`/`tower_control`/command updates, dynamic registration for default and configured deny lists, disposal on mode off, `update_goal`, denial-row treatment, generic fallbacks, and locale parity — no browser required.
60
+
61
+ Live/browser verification is **opt-in** (`tests/live/ui-slots.cjs`) because it needs an isolated profile with the bundle installed. From a teammate/subagent session the Client `Slots` inspect query times out by design (the page answers only the agent whose session it displays), so this phase did not run a live inspection; the source-level contract above is the evidence. When the script runs against an installed profile it binds a session with a bare `/tower` command and asserts, through the read-only `window.__TOWER_UI_DIAG__` report, that apply() ran without errors, that all 12 toolview keys and the dock/overlay entries registered, that the dock outlet is visible, and — since the bind command turns the Host mode on — that the history-derived `Tower · on` chip rendered (#11). The chip can be inspected visually in the same run (`data-tower-ui="dock-mode"`).
62
+
63
+ ## Tests
64
+
65
+ ```powershell
66
+ # offline, no browser
67
+ node --check ui-bundle/client.js
68
+ node --test tests/ui-bundle.test.mjs
69
+
70
+ # opt-in live check (isolated profile only)
71
+ node tests/live/ui-slots.cjs
72
+ ```
73
+
74
+ `tests/live/ui-slots.cjs` prints `SKIP` and exits 0 when `DSH_TEST_URL` (or Playwright) is missing; it never installs anything and never opens the desktop profile. Set `DSH_TEST_EXPECT_HINT=1` when the bundle is installed so a missing client module or missing dock entry fails instead of skipping the assertion, and `DSH_TEST_EXPECT_MODE=1` to require the history-derived `Tower · on` chip after the script's `/tower` bind step (otherwise that check is a warning, since the bind step is best-effort).
75
+
76
+ ## Why it is a separate bundle
77
+
78
+ It is not installed by default and does not affect installing or running the Host `dsh-tower` plugin. The Host plugin is fully functional without it; the UI half only decorates the conversation that is already on screen.
79
+
80
+ ## Install (isolated profile only)
81
+
82
+ ```text
83
+ # throwaway profile, not the desktop profile
84
+ dsh --profile towertest --from-default-profile web --dump-config
85
+ dsh plugin --profile towertest add <absolute path to this ui-bundle>
86
+ dsh --profile towertest --port 59001 --no-open
87
+ # open the tokenized URL printed at boot, then verify:
88
+ $env:DSH_TEST_URL='http://127.0.0.1:59001/?token=<token>'
89
+ $env:DSH_TEST_EXPECT_HINT='1'; node tests/live/ui-slots.cjs
90
+ ```
91
+
92
+ From a session in that profile, `plugin_manager({ action: "install_bundle", target: "<absolute path to this ui-bundle>" })` performs the same add + enable. Do not install it into the live desktop profile without the user's explicit request.
93
+
94
+ ## Remove
95
+
96
+ ```text
97
+ dsh plugin --profile towertest remove dsh-tower-ui
98
+ ```
99
+
100
+ Without HMR a bundle that has been running since process start may need a DSH restart for removal; with HMR a reconcile can unload it. A page refresh (or hard refresh) is enough to update the Client half after a linked-directory change.
101
+
102
+ ## Known limitations
103
+
104
+ - **No live Host state / no new RPC.** The mode chip is inferred from the **loaded history window** of the displayed session. If the `/tower on` command (or a `tower_mode` result) sits outside that window, the dock falls back to the static hint and the mode-aware rows are not registered; a later `/tower`, `tower_control`, or `tower_mode` result corrects it. The dock summary is likewise the latest visible Tower tool result, not a dashboard, and does not poll.
105
+ - **Mode-driven registration is slot-global.** The dock follows the displayed session. While that session is in Tower mode, the denied-tool shadow entries also apply to other sessions' rows for those tools (they render the defensive generic row); leaving the mode disposes them globally and restores the native views.
106
+ - **A custom `mode.denyTools` list needs a `tower_mode` result in the visible history.** The dock learns `deniedWhileActive` only from a `tower_mode` output; if the mode was inferred from `/tower on` alone (or the `tower_mode` result is outside the loaded window), the bundle shadows the default list instead, and a custom denied tool falls back to the native error row until a `tower_mode` output appears.
107
+ - **The on-chip is not a button.** `inputActions.submit()` submits the composer draft, so programmatically sending `/tower off` would overwrite the user's draft; the actionable tooltip asks the user to run it. There is no click handler and no automatic toggle from the bundle; the Host `tower_control` tool remains the model's supported path.
108
+ - **The overlay is root-scoped.** It cannot read chat state directly, so a result that lands while every Tower toolview is unmounted (for example the user switches sessions before the result renders) shows no strip. The strip itself carries no actionable content by design.
109
+ - A tool result whose payload is not JSON (older/foreign shape) renders as a plain one-line summary rather than a card; errors render as an error card.
110
+ - Client bundle updates are not auto-rebuilt: edit → reconcile/link → **refresh the page**. Profile patch or manifest changes may need a restart.
111
+ - `window.__TOWER_UI_DIAG__` is a read-only diagnostic seam for tests. It contains no session content — only plugin version, applied flag, slot/toolview names, current mode summary, dynamic deny keys, and guarded registration errors.