dsh-tower 2.0.3 → 2.1.0-beta.1

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 (193) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +59 -16
  5. package/README.md +57 -16
  6. package/SECURITY.md +31 -1
  7. package/adapters/default.js +10 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/batch.js +79 -0
  11. package/app/board.js +25 -0
  12. package/app/checkCompletion.js +76 -0
  13. package/app/enrichedMissions.js +26 -0
  14. package/app/findings.js +78 -0
  15. package/app/gate.js +108 -0
  16. package/app/init.js +249 -0
  17. package/app/merge.js +567 -0
  18. package/app/messages.js +66 -0
  19. package/app/mission.js +35 -0
  20. package/app/missionContext.js +14 -0
  21. package/app/plan.js +42 -0
  22. package/app/provision.js +193 -0
  23. package/app/read.js +57 -0
  24. package/app/rebase.js +302 -0
  25. package/app/recording.js +72 -0
  26. package/app/review.js +145 -0
  27. package/app/roster.js +123 -0
  28. package/app/spawn.js +125 -0
  29. package/app/teardown.js +184 -0
  30. package/app/tower.js +524 -0
  31. package/cordis.patch.yml +61 -53
  32. package/docs/README.md +33 -1
  33. package/docs/architecture/overview.md +114 -14
  34. package/docs/board.md +72 -0
  35. package/docs/examples/first-mission.md +2 -1
  36. package/docs/faq.md +11 -7
  37. package/docs/guide/hot-reload.md +1 -1
  38. package/docs/guide/install-upgrade-migration.md +22 -6
  39. package/docs/guide/quickstart.md +12 -7
  40. package/docs/guide/release.md +125 -0
  41. package/docs/guide/scenarios-and-philosophy.md +86 -69
  42. package/docs/operations/security.md +65 -14
  43. package/docs/reference/blockers.md +11 -10
  44. package/docs/reference/config.generated.md +66 -0
  45. package/docs/reference/config.md +11 -7
  46. package/docs/reference/errors.generated.md +39 -0
  47. package/docs/reference/errors.md +10 -2
  48. package/docs/reference/events.md +7 -4
  49. package/docs/reference/testing.md +2 -1
  50. package/docs/reference/tools.generated.md +33 -0
  51. package/docs/reference/tools.md +8 -7
  52. package/docs/zh/README.md +4 -3
  53. package/docs/zh/faq.md +9 -6
  54. package/docs/zh/guide/hot-reload.md +1 -1
  55. package/docs/zh/guide/install-upgrade-migration.md +17 -5
  56. package/docs/zh/guide/quickstart.md +13 -8
  57. package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
  58. package/docs/zh/operations/security.md +43 -7
  59. package/docs/zh/reference/blockers.md +10 -10
  60. package/docs/zh/reference/config.md +11 -7
  61. package/docs/zh/reference/errors.md +4 -1
  62. package/docs/zh/reference/events.md +6 -3
  63. package/docs/zh/reference/testing.md +3 -2
  64. package/docs/zh/reference/tools.md +8 -7
  65. package/domain/collab/batch.js +112 -0
  66. package/domain/collab/messages.js +127 -0
  67. package/domain/collab/roster.js +158 -0
  68. package/domain/collab/spawn.js +188 -0
  69. package/domain/config/schema.js +404 -0
  70. package/domain/contracts/outputs.js +279 -0
  71. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  72. package/domain/contracts/tool-inputs/findings.js +29 -0
  73. package/domain/contracts/tool-inputs/index.js +94 -0
  74. package/domain/contracts/tool-inputs/integration.js +37 -0
  75. package/domain/contracts/tool-inputs/missions.js +94 -0
  76. package/domain/contracts/tool-inputs/ops.js +75 -0
  77. package/domain/contracts/tool-inputs/shared.js +25 -0
  78. package/domain/errors.js +96 -0
  79. package/domain/gate.js +125 -0
  80. package/domain/integration/gate.js +176 -0
  81. package/domain/integration/init.js +225 -0
  82. package/domain/integration/liveness.js +31 -0
  83. package/domain/integration/merge.js +377 -0
  84. package/domain/integration/provision.js +124 -0
  85. package/domain/integration/rebase.js +236 -0
  86. package/domain/integration/review.js +52 -0
  87. package/domain/integration/teardown.js +118 -0
  88. package/domain/mission.js +68 -0
  89. package/domain/missions/completion.js +114 -0
  90. package/domain/missions/findings.js +201 -0
  91. package/domain/missions/lifecycle.js +185 -0
  92. package/domain/missions/plan.js +149 -0
  93. package/domain/missions/review.js +179 -0
  94. package/domain/planning.js +118 -0
  95. package/domain/read/enrichedMissions.js +58 -0
  96. package/domain/read/missionContext.js +53 -0
  97. package/domain/read/views.js +128 -0
  98. package/domain/report.js +59 -0
  99. package/domain/roster.js +40 -0
  100. package/domain/scope.js +105 -0
  101. package/domain/state/schema.js +233 -0
  102. package/domain/state/semantics.js +117 -0
  103. package/domain/state.js +33 -0
  104. package/domain/summary.js +114 -0
  105. package/domain/vocabulary.js +179 -0
  106. package/index.js +86 -34
  107. package/lib/briefing.js +176 -0
  108. package/lib/compatibility.js +108 -0
  109. package/lib/config.js +54 -302
  110. package/lib/doctor.js +108 -9
  111. package/lib/git.js +93 -15
  112. package/lib/hmr.js +4 -3
  113. package/lib/leases.js +3 -2
  114. package/lib/legacy.js +20 -9
  115. package/lib/modeState.js +39 -0
  116. package/lib/modeStore.js +35 -33
  117. package/lib/persistence.js +349 -0
  118. package/lib/presets.js +35 -0
  119. package/lib/spawn-prechecks.js +168 -0
  120. package/lib/spawn-support.js +79 -0
  121. package/lib/store.js +57 -85
  122. package/lib/tests.js +46 -11
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +46 -22
  125. package/lib/worktreeOwnership.js +5 -4
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +33 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +44 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +203 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/skills/tower-recovery.md +7 -5
  152. package/tools/adopt.js +72 -0
  153. package/tools/batch.js +78 -0
  154. package/tools/complete.js +53 -0
  155. package/tools/diff.js +39 -0
  156. package/tools/doctor.js +50 -0
  157. package/tools/finding.js +40 -0
  158. package/tools/findings.js +39 -0
  159. package/tools/import-legacy.js +42 -0
  160. package/tools/inbox.js +39 -0
  161. package/tools/index.js +39 -0
  162. package/tools/init.js +63 -0
  163. package/tools/interrupt.js +42 -0
  164. package/tools/legacy-map.js +44 -0
  165. package/tools/merge.js +39 -0
  166. package/tools/mission.js +41 -0
  167. package/tools/plan.js +39 -0
  168. package/tools/preflight.js +44 -0
  169. package/tools/rebase.js +39 -0
  170. package/tools/recover.js +45 -0
  171. package/tools/reload.js +52 -0
  172. package/tools/resource.js +54 -0
  173. package/tools/review.js +45 -0
  174. package/tools/roster.js +60 -0
  175. package/tools/send.js +147 -0
  176. package/tools/spawn.js +196 -0
  177. package/tools/status.js +94 -0
  178. package/tools/teardown.js +42 -0
  179. package/tools/test.js +61 -0
  180. package/types/cordis.d.ts +6 -0
  181. package/types/index.d.ts +339 -0
  182. package/ui-bundle/README.md +111 -0
  183. package/ui-bundle/client.js +1980 -0
  184. package/ui-bundle/cordis.patch.yml +3 -0
  185. package/ui-bundle/icon.svg +6 -0
  186. package/ui-bundle/index.js +2 -0
  187. package/ui-bundle/locale/en.json +6 -0
  188. package/ui-bundle/locale/zh.json +6 -0
  189. package/ui-bundle/package.json +35 -0
  190. package/lib/errors.js +0 -111
  191. package/lib/tools.js +0 -2250
  192. package/lib/tower.js +0 -2689
  193. /package/{lib → domain}/batch.js +0 -0
@@ -0,0 +1,1980 @@
1
+ // dsh-tower-ui 0.2.2 — optional Web Client surfaces for the Tower plugin.
2
+ //
3
+ // Registrations, all plain React (no iframe, no DOM writes, no Host RPC, no
4
+ // Harness Client package imports):
5
+ // 1. `tool.call.toolview` keyed cards for the twelve Tower tools.
6
+ // 2. `conversation.composer.dock` — a history-derived Tower-mode chip while
7
+ // the displayed session is in Tower mode; otherwise the static `/tower`
8
+ // hint when the conversation has no Tower result, or a compact
9
+ // latest-status summary derived from the `useChat` standard prop.
10
+ // 3. `shell.overlay` — a transient, non-blocking status strip for Tower tool
11
+ // start/result. The overlay slot is root-scoped, so it cannot read chat
12
+ // state itself; the chat-driven keyed toolview components publish the
13
+ // transition into a module-local observable that the overlay injects.
14
+ // 4. Dynamic `tool.call.toolview` entries for the tools Tower mode denies:
15
+ // registered at a lower priority than the native views while the
16
+ // displayed session is in Tower mode, disposed when it leaves the mode so
17
+ // the native toolviews return. Denial results render as a distinct
18
+ // `Tower mode · disabled` row instead of a generic tool error.
19
+ // 5. locale dictionaries for the `tower-ui` namespace (en/zh).
20
+ //
21
+ // Tower mode has no Client RPC. The dock derives it from the durable session
22
+ // history projected into the `useChat` legacy slice: the `/tower on|off`
23
+ // command node and the `tower_mode` / `tower_control` result blocks. A page
24
+ // refresh therefore re-derives the same stance from the loaded history window;
25
+ // history older than the loaded window yields the unknown/off fallback.
26
+ //
27
+ // The bundle is a plain-JS artifact loaded by `window.__ModuleLoader__`; React
28
+ // comes from the browser module table. Theme references are restricted to
29
+ // `--dsw-alias-*` tokens. `window.__TOWER_UI_DIAG__` is a read-only diagnostic
30
+ // object used by tests (it is not DOM and changes nothing).
31
+ window.__ModuleLoader__.load({
32
+ id: 'dsh-tower-ui',
33
+ factory(require) {
34
+ const React = require('react')
35
+ const h = React.createElement
36
+
37
+ const NS = 'tower-ui'
38
+ const VERSION = '0.2.2'
39
+ const DIAG_KEY = '__TOWER_UI_DIAG__'
40
+ const NOTICE_MS = 4500
41
+ const MAX_TEXT = 150
42
+ // Native toolviews register at priority 0; the slot core renders the
43
+ // lowest priority, so a negative priority shadows them only while this
44
+ // registration is alive.
45
+ const DENIED_PRIORITY = -10
46
+
47
+ const TOOL_NAMES = [
48
+ 'tower_status',
49
+ 'tower_roster',
50
+ 'tower_spawn',
51
+ 'tower_mission',
52
+ 'tower_complete',
53
+ 'tower_review',
54
+ 'tower_preflight',
55
+ 'tower_merge',
56
+ 'tower_teardown',
57
+ 'tower_interrupt',
58
+ 'tower_doctor',
59
+ 'tower_batch',
60
+ ]
61
+ const TOOL_SET = new Set(TOOL_NAMES)
62
+ // Mirrors lib/config.js DEFAULT_DENY_TOOLS; overridden by the
63
+ // `deniedWhileActive` list of the latest `tower_mode` output when present.
64
+ const DEFAULT_DENIED_TOOLS = [
65
+ 'create_goal',
66
+ 'todo_write',
67
+ 'team_task_create',
68
+ 'team_task_update',
69
+ 'team_task_reopen',
70
+ 'team_task_list',
71
+ 'spawn_teammate',
72
+ 'wait_agent',
73
+ 'workflow',
74
+ 'ralph',
75
+ 'subagent',
76
+ 'subagent_fork',
77
+ ]
78
+ const TOOL_LABEL_KEYS = {
79
+ tower_status: 'tool.status',
80
+ tower_roster: 'tool.roster',
81
+ tower_spawn: 'tool.spawn',
82
+ tower_mission: 'tool.mission',
83
+ tower_complete: 'tool.complete',
84
+ tower_review: 'tool.review',
85
+ tower_preflight: 'tool.preflight',
86
+ tower_merge: 'tool.merge',
87
+ tower_teardown: 'tool.teardown',
88
+ tower_interrupt: 'tool.interrupt',
89
+ tower_doctor: 'tool.doctor',
90
+ tower_batch: 'tool.batch',
91
+ }
92
+
93
+ // ---------------------------------------------------------------------
94
+ // Locale dictionaries (the Client locale service owns activation/language;
95
+ // the slot registration's `locale` option supplies the bound `t` prop).
96
+ // ---------------------------------------------------------------------
97
+ const EN = {
98
+ 'card.title': 'Tower',
99
+ 'card.running': 'running…',
100
+ 'card.done': 'done',
101
+ 'card.failed': 'failed',
102
+ 'card.error': 'Error',
103
+ 'card.blockers': 'Blockers',
104
+ 'card.noBlockers': 'no blockers',
105
+ 'card.checks': 'Checks',
106
+ 'card.warnings': 'Warnings',
107
+ 'card.checkOk': '{name} ✓',
108
+ 'card.checkFail': '{name} (exit {code})',
109
+ 'card.checksCount': '{passed}/{total} checks passed',
110
+ 'card.tasks': 'tasks {done}/{total}',
111
+ 'card.openTasks': '{count} open task(s)',
112
+ 'card.noOpenTasks': 'no open tasks',
113
+ 'card.statusHeadline': '{total} missions · {active} active',
114
+ 'card.statusDetail': 'roster {roster} · findings {findings} · rev {revision}',
115
+ 'card.rosterHeadline': '{live} live / {total} agents',
116
+ 'card.staleCount': '{count} stale',
117
+ 'card.otherCount': '{count} inactive',
118
+ 'card.agentLine': '{name} · {role} · {mission} · {activity}',
119
+ 'card.reviewLine': '{status} · merge {merge} · round {round}',
120
+ 'card.mergeReady': 'ready to merge',
121
+ 'card.mergeBlocked': '{count} blocker(s)',
122
+ 'card.merged': 'merged {commit}',
123
+ 'card.alreadyMerged': 'already merged',
124
+ 'card.spawnLine': '{role} {name} → {mission}',
125
+ 'card.worktreeLine': 'worktree {branch}',
126
+ 'card.completeLine': '{mission} completed · {commits} commit(s)',
127
+ 'card.unread': '{count} unread',
128
+ 'card.interruptYes': 'interrupted {name}',
129
+ 'card.interruptNo': '{name} is not running',
130
+ 'card.previousActivity': 'previous activity {activity}',
131
+ 'card.teardownLine': '{removed} removed · {kept} kept',
132
+ 'card.dryRun': 'dry run',
133
+ 'card.doctorLine': 'doctor {status}',
134
+ 'card.doctorChecks': '{pass} pass · {warn} warn · {fail} fail',
135
+ 'card.batchLine': 'batch {status}',
136
+ 'card.batchMembers': '{count} member(s) · batch {status}',
137
+ 'card.batchMissing': '{count} missing check(s)',
138
+ 'card.batchFailed': '{count} failed check(s)',
139
+ 'card.missionLine': '{id} {status}',
140
+ 'card.missionMissing': 'mission',
141
+ 'card.reviewPolicy': 'review {policy}',
142
+ 'card.owner': 'owner {owner}',
143
+ 'card.unknown': 'unknown',
144
+ 'card.summary': '{text}',
145
+ 'card.sep': ' · ',
146
+ 'tool.status': 'Status',
147
+ 'tool.roster': 'Roster',
148
+ 'tool.spawn': 'Spawn',
149
+ 'tool.mission': 'Mission',
150
+ 'tool.complete': 'Complete',
151
+ 'tool.review': 'Review',
152
+ 'tool.preflight': 'Preflight',
153
+ 'tool.merge': 'Merge',
154
+ 'tool.teardown': 'Teardown',
155
+ 'tool.interrupt': 'Interrupt',
156
+ 'tool.doctor': 'Doctor',
157
+ 'tool.batch': 'Batch',
158
+ 'dock.hint.title': '/tower · /tower on [message] · /tower off [message]',
159
+ 'dock.running': '{tool} running…',
160
+ 'dock.statusCounts': '{total} missions · {active} active · {open} open',
161
+ 'dock.rosterCounts': '{live} live / {total} agents',
162
+ 'dock.review': 'review {status} · {merge}',
163
+ 'dock.ready': 'ready to merge',
164
+ 'dock.blocked': '{count} blocker(s)',
165
+ 'dock.merged': 'merged {commit}',
166
+ 'dock.complete': '{mission} completed · {commits} commit(s)',
167
+ 'dock.spawn': '{role} {name} → {mission}',
168
+ 'dock.removed': '{count} worktree(s) removed',
169
+ 'dock.interrupt': '{name} interrupted',
170
+ 'dock.mission': '{id} {status}',
171
+ 'dock.preflight': 'preflight {status}',
172
+ 'dock.batch': 'batch {status}',
173
+ 'dock.doctor': 'doctor {status}',
174
+ 'dock.generic': 'done',
175
+ 'dock.mode.onLabel': 'on',
176
+ 'dock.mode.unknownLabel': 'mode unknown',
177
+ 'dock.mode.unknownTooltip': 'The loaded history has Tower activity but no mode event. Run /tower or tower_mode to confirm the current stance.',
178
+ 'dock.mode.tooltip': 'Tower mode is on for this session. Run /tower off in the composer to leave Tower mode.',
179
+ 'dock.mode.lastKnown': 'last known from this browser session; run /tower or tower_mode to confirm',
180
+ 'dock.mode.repo': 'repo {repo}',
181
+ 'dock.mode.lockHeld': 'workspace lock held',
182
+ 'dock.mode.lockFree': 'workspace lock free',
183
+ 'dock.mode.lastBy': 'last change by {by}',
184
+ 'deny.label': 'Tower mode · disabled',
185
+ 'deny.hint': 'Run /tower off in the composer to use this tool again.',
186
+ 'deny.running': 'Tower mode blocks this tool while it is active',
187
+ 'deny.generic': '{tool} (Tower mode active)',
188
+ 'deny.error': 'Tool error while Tower mode is active',
189
+ 'strip.started': 'Tower · {tool} started',
190
+ 'strip.finished': 'Tower · {tool} finished',
191
+ 'strip.failed': 'Tower · {tool} failed',
192
+ }
193
+ const ZH = {
194
+ 'card.title': 'Tower',
195
+ 'card.running': '运行中…',
196
+ 'card.done': '已完成',
197
+ 'card.failed': '失败',
198
+ 'card.error': '错误',
199
+ 'card.blockers': '阻塞项',
200
+ 'card.noBlockers': '无阻塞',
201
+ 'card.checks': '检查',
202
+ 'card.warnings': '警告',
203
+ 'card.checkOk': '{name} ✓',
204
+ 'card.checkFail': '{name}(退出码 {code})',
205
+ 'card.checksCount': '通过 {passed}/{total} 项检查',
206
+ 'card.tasks': '任务 {done}/{total}',
207
+ 'card.openTasks': '{count} 个未完成任务',
208
+ 'card.noOpenTasks': '无未完成任务',
209
+ 'card.statusHeadline': '{total} 个任务线 · {active} 活跃',
210
+ 'card.statusDetail': '名册 {roster} · 发现 {findings} · 修订 {revision}',
211
+ 'card.rosterHeadline': '{live} 个运行中 / 共 {total} 个',
212
+ 'card.staleCount': '{count} 个疑似停滞',
213
+ 'card.otherCount': '{count} 个非活跃',
214
+ 'card.agentLine': '{name} · {role} · {mission} · {activity}',
215
+ 'card.reviewLine': '{status} · 合并建议 {merge} · 第 {round} 轮',
216
+ 'card.mergeReady': '可以合并',
217
+ 'card.mergeBlocked': '{count} 个阻塞',
218
+ 'card.merged': '已合并 {commit}',
219
+ 'card.alreadyMerged': '已合并(补记录)',
220
+ 'card.spawnLine': '{role} {name} → {mission}',
221
+ 'card.worktreeLine': '工作树 {branch}',
222
+ 'card.completeLine': '{mission} 已完成 · {commits} 个提交',
223
+ 'card.unread': '{count} 条未读',
224
+ 'card.interruptYes': '已中断 {name}',
225
+ 'card.interruptNo': '{name} 未在运行',
226
+ 'card.previousActivity': '先前活动 {activity}',
227
+ 'card.teardownLine': '移除 {removed} · 保留 {kept}',
228
+ 'card.dryRun': '演练',
229
+ 'card.doctorLine': '体检 {status}',
230
+ 'card.doctorChecks': '通过 {pass} · 警告 {warn} · 失败 {fail}',
231
+ 'card.batchLine': '批量 {status}',
232
+ 'card.batchMembers': '{count} 个成员 · 批次 {status}',
233
+ 'card.batchMissing': '{count} 项缺失检查',
234
+ 'card.batchFailed': '{count} 项失败检查',
235
+ 'card.missionLine': '{id} {status}',
236
+ 'card.missionMissing': '任务线',
237
+ 'card.reviewPolicy': '评审 {policy}',
238
+ 'card.owner': '负责人 {owner}',
239
+ 'card.unknown': '未知',
240
+ 'card.summary': '{text}',
241
+ 'card.sep': ' · ',
242
+ 'tool.status': '状态',
243
+ 'tool.roster': '名册',
244
+ 'tool.spawn': '派发',
245
+ 'tool.mission': '任务线',
246
+ 'tool.complete': '完成',
247
+ 'tool.review': '评审',
248
+ 'tool.preflight': '预检',
249
+ 'tool.merge': '合并',
250
+ 'tool.teardown': '清理',
251
+ 'tool.interrupt': '中断',
252
+ 'tool.doctor': '体检',
253
+ 'tool.batch': '批量',
254
+ 'dock.hint.title': '/tower · /tower on [message] · /tower off [message]',
255
+ 'dock.running': '{tool} 运行中…',
256
+ 'dock.statusCounts': '{total} 个任务线 · {active} 活跃 · {open} 未完成',
257
+ 'dock.rosterCounts': '{live} 个运行中 / 共 {total} 个',
258
+ 'dock.review': '评审 {status} · {merge}',
259
+ 'dock.ready': '可以合并',
260
+ 'dock.blocked': '{count} 个阻塞',
261
+ 'dock.merged': '已合并 {commit}',
262
+ 'dock.complete': '{mission} 已完成 · {commits} 个提交',
263
+ 'dock.spawn': '{role} {name} → {mission}',
264
+ 'dock.removed': '移除 {count} 个工作树',
265
+ 'dock.interrupt': '已中断 {name}',
266
+ 'dock.mission': '{id} {status}',
267
+ 'dock.preflight': '预检 {status}',
268
+ 'dock.batch': '批量 {status}',
269
+ 'dock.doctor': '体检 {status}',
270
+ 'dock.generic': '已完成',
271
+ 'dock.mode.onLabel': '已开启',
272
+ 'dock.mode.unknownLabel': '模式未知',
273
+ 'dock.mode.unknownTooltip': '已加载的历史中有 Tower 活动,但没有模式事件。执行 /tower 或 tower_mode 以确认当前状态。',
274
+ 'dock.mode.tooltip': '此会话已开启 Tower 模式。在输入框中执行 /tower off 可退出。',
275
+ 'dock.mode.lastKnown': '来自此浏览器会话的最后已知状态;请执行 /tower 或 tower_mode 确认',
276
+ 'dock.mode.repo': '仓库 {repo}',
277
+ 'dock.mode.lockHeld': '工作区锁已持有',
278
+ 'dock.mode.lockFree': '工作区锁空闲',
279
+ 'dock.mode.lastBy': '最近变更者 {by}',
280
+ 'deny.label': 'Tower 模式 · 已禁用',
281
+ 'deny.hint': '在输入框中执行 /tower off 后即可重新使用该工具。',
282
+ 'deny.running': 'Tower 模式启用期间该工具被禁用',
283
+ 'deny.generic': '{tool}(Tower 模式启用中)',
284
+ 'deny.error': 'Tower 模式启用期间的工具错误',
285
+ 'strip.started': 'Tower · {tool} 开始',
286
+ 'strip.finished': 'Tower · {tool} 完成',
287
+ 'strip.failed': 'Tower · {tool} 失败',
288
+ }
289
+
290
+ // ---------------------------------------------------------------------
291
+ // Diagnostics (read-only test seam; not DOM, not Host state).
292
+ // ---------------------------------------------------------------------
293
+ const diagnostics = {
294
+ plugin: 'dsh-tower-ui',
295
+ version: VERSION,
296
+ namespace: NS,
297
+ applied: false,
298
+ slots: [],
299
+ toolviews: [],
300
+ denied: { active: false, keys: [] },
301
+ mode: { known: false, on: null, denied: null },
302
+ errors: [],
303
+ }
304
+ if (typeof window !== 'undefined') window[DIAG_KEY] = diagnostics
305
+ function recordSlot(slot, detail) {
306
+ const duplicate = diagnostics.slots.some(
307
+ (entry) => entry.slot === slot && entry.id === detail.id && entry.key === detail.key,
308
+ )
309
+ if (!duplicate) diagnostics.slots.push(Object.assign({ slot }, detail))
310
+ }
311
+ function guard(where, fn) {
312
+ try {
313
+ return fn()
314
+ } catch (error) {
315
+ diagnostics.errors.push({
316
+ where,
317
+ message: String(error && error.message ? error.message : error),
318
+ })
319
+ return undefined
320
+ }
321
+ }
322
+ // A slot declaration can collapse and later return; its inject callback may
323
+ // therefore run more than once. Guarding the callback body records a failed
324
+ // registration instead of letting the declaration-lifetime effect throw
325
+ // asynchronously into the page.
326
+ function guardedCallback(where, fn) {
327
+ return function () {
328
+ try {
329
+ return fn()
330
+ } catch (error) {
331
+ diagnostics.errors.push({
332
+ where,
333
+ message: String(error && error.message ? error.message : error),
334
+ })
335
+ return function () {}
336
+ }
337
+ }
338
+ }
339
+
340
+ // ---------------------------------------------------------------------
341
+ // Theme: only `--dsw-alias-*` tokens are referenced.
342
+ // ---------------------------------------------------------------------
343
+ const TONE = {
344
+ ok: 'var(--dsw-alias-state-success-primary)',
345
+ warn: 'var(--dsw-alias-state-warn-primary)',
346
+ error: 'var(--dsw-alias-state-error-primary)',
347
+ idle: 'var(--dsw-alias-state-idle-primary)',
348
+ none: 'var(--dsw-alias-label-tertiary)',
349
+ }
350
+ const ST = {
351
+ card: {
352
+ display: 'flex',
353
+ flexDirection: 'column',
354
+ gap: '4px',
355
+ minWidth: 0,
356
+ maxWidth: '100%',
357
+ boxSizing: 'border-box',
358
+ margin: '2px 0 2px 4px',
359
+ padding: '8px 10px',
360
+ border: '0.5px solid var(--dsw-alias-border-l1)',
361
+ borderRadius: '8px',
362
+ background: 'var(--dsw-alias-bg-layer-1)',
363
+ color: 'var(--dsw-alias-label-secondary)',
364
+ fontSize: '12px',
365
+ lineHeight: '18px',
366
+ },
367
+ header: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 },
368
+ title: { color: 'var(--dsw-alias-label-primary)', flex: 'none' },
369
+ dim: {
370
+ color: 'var(--dsw-alias-label-tertiary)',
371
+ minWidth: 0,
372
+ overflow: 'hidden',
373
+ textOverflow: 'ellipsis',
374
+ whiteSpace: 'nowrap',
375
+ },
376
+ row: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
377
+ rowError: { color: 'var(--dsw-alias-state-error-primary)' },
378
+ rowWarn: { color: 'var(--dsw-alias-state-warn-primary)' },
379
+ rowOk: { color: 'var(--dsw-alias-state-success-primary)' },
380
+ hint: {
381
+ display: 'inline-flex',
382
+ alignItems: 'center',
383
+ gap: '4px',
384
+ padding: '0 6px',
385
+ fontSize: '11px',
386
+ lineHeight: '18px',
387
+ color: 'inherit',
388
+ opacity: 0.55,
389
+ userSelect: 'none',
390
+ whiteSpace: 'nowrap',
391
+ },
392
+ dock: {
393
+ display: 'inline-flex',
394
+ alignItems: 'center',
395
+ gap: '6px',
396
+ minWidth: 0,
397
+ maxWidth: '100%',
398
+ padding: '1px 8px',
399
+ fontSize: '11px',
400
+ lineHeight: '18px',
401
+ color: 'var(--dsw-alias-label-tertiary)',
402
+ whiteSpace: 'nowrap',
403
+ },
404
+ dockTool: { color: 'var(--dsw-alias-label-secondary)', flex: 'none' },
405
+ dockText: {
406
+ minWidth: 0,
407
+ overflow: 'hidden',
408
+ textOverflow: 'ellipsis',
409
+ color: 'var(--dsw-alias-label-tertiary)',
410
+ },
411
+ stripWrap: {
412
+ position: 'fixed',
413
+ left: '50%',
414
+ bottom: '16px',
415
+ transform: 'translateX(-50%)',
416
+ display: 'flex',
417
+ justifyContent: 'center',
418
+ pointerEvents: 'none',
419
+ zIndex: 9100,
420
+ maxWidth: 'calc(100vw - 32px)',
421
+ },
422
+ stripCard: {
423
+ pointerEvents: 'none',
424
+ display: 'inline-flex',
425
+ alignItems: 'center',
426
+ gap: '6px',
427
+ maxWidth: '100%',
428
+ padding: '4px 10px',
429
+ border: '0.5px solid var(--dsw-alias-border-l1)',
430
+ borderRadius: '999px',
431
+ background: 'var(--dsw-alias-bg-overlay)',
432
+ color: 'var(--dsw-alias-label-secondary)',
433
+ fontSize: '12px',
434
+ lineHeight: '18px',
435
+ whiteSpace: 'nowrap',
436
+ overflow: 'hidden',
437
+ textOverflow: 'ellipsis',
438
+ },
439
+ modeChip: {
440
+ display: 'inline-flex',
441
+ alignItems: 'center',
442
+ gap: '6px',
443
+ padding: '0 8px',
444
+ border: '0.5px solid var(--dsw-alias-border-l2)',
445
+ borderRadius: '999px',
446
+ background: 'var(--dsw-alias-bg-layer-1)',
447
+ color: 'var(--dsw-alias-label-secondary)',
448
+ fontSize: '11px',
449
+ lineHeight: '18px',
450
+ whiteSpace: 'nowrap',
451
+ userSelect: 'none',
452
+ },
453
+ modeChipLabel: { color: 'var(--dsw-alias-label-primary)' },
454
+ modeChipOn: { color: 'var(--dsw-alias-state-success-primary)' },
455
+ denyCard: {
456
+ display: 'flex',
457
+ flexDirection: 'column',
458
+ gap: '4px',
459
+ minWidth: 0,
460
+ maxWidth: '100%',
461
+ boxSizing: 'border-box',
462
+ margin: '2px 0 2px 4px',
463
+ padding: '8px 10px',
464
+ border: '0.5px solid var(--dsw-alias-border-l2)',
465
+ borderRadius: '8px',
466
+ background: 'var(--dsw-alias-bg-layer-1)',
467
+ color: 'var(--dsw-alias-label-secondary)',
468
+ fontSize: '12px',
469
+ lineHeight: '18px',
470
+ },
471
+ denyLabel: { color: 'var(--dsw-alias-state-warn-primary)', flex: 'none' },
472
+ denyHint: { color: 'var(--dsw-alias-label-tertiary)' },
473
+ }
474
+ function dotStyle(tone) {
475
+ return {
476
+ width: '6px',
477
+ height: '6px',
478
+ flex: 'none',
479
+ borderRadius: '999px',
480
+ background: TONE[tone] || TONE.none,
481
+ }
482
+ }
483
+
484
+ // ---------------------------------------------------------------------
485
+ // Small pure helpers. Every one of them is defensive: a tool result is
486
+ // durable user-facing data and may be an older shape, a partial stream, an
487
+ // error, or plain text instead of JSON.
488
+ // ---------------------------------------------------------------------
489
+ function fill(template, params) {
490
+ if (typeof template !== 'string') return String(template)
491
+ if (params === undefined || params === null) return template
492
+ return template.replace(/\{(\w+)\}/g, (match, name) =>
493
+ Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match,
494
+ )
495
+ }
496
+ function translate(t, key, params) {
497
+ if (typeof t === 'function') {
498
+ try {
499
+ const value = t(key, params)
500
+ if (typeof value === 'string') return value
501
+ } catch (error) {
502
+ /* fall through to the bundled English dictionary */
503
+ }
504
+ }
505
+ return fill(EN[key] !== undefined ? EN[key] : key, params)
506
+ }
507
+ function seatT(props) {
508
+ return props && typeof props.t === 'function' ? props.t : null
509
+ }
510
+ function toolLabel(t, name) {
511
+ if (typeof name !== 'string' || name === '') return translate(t, 'card.title')
512
+ return translate(t, TOOL_LABEL_KEYS[name] || 'card.title')
513
+ }
514
+ function isTowerTool(name) {
515
+ return typeof name === 'string' && TOOL_SET.has(name)
516
+ }
517
+ function truncate(value, max) {
518
+ const text = String(value === undefined || value === null ? '' : value)
519
+ .replace(/\s+/g, ' ')
520
+ .trim()
521
+ const limit = max === undefined ? MAX_TEXT : max
522
+ return text.length > limit ? text.slice(0, limit - 1) + '…' : text
523
+ }
524
+ function firstLine(value, max) {
525
+ const text = String(value === undefined || value === null ? '' : value)
526
+ const line = text.split('\n')[0]
527
+ return truncate(line, max)
528
+ }
529
+ function shortCommit(value) {
530
+ if (typeof value !== 'string' || value === '') return ''
531
+ return value.slice(0, 7)
532
+ }
533
+ function isPlainObject(value) {
534
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
535
+ }
536
+ function textOfBlock(block) {
537
+ if (!isPlainObject(block)) return ''
538
+ const parts = []
539
+ if (Array.isArray(block.content)) {
540
+ for (const item of block.content) {
541
+ if (!isPlainObject(item)) continue
542
+ if (typeof item.text === 'string') parts.push(item.text)
543
+ }
544
+ }
545
+ if (parts.length === 0 && typeof block.text === 'string') parts.push(block.text)
546
+ return parts.join('\n')
547
+ }
548
+ function errorOfBlock(block) {
549
+ if (!isPlainObject(block)) return null
550
+ if (isPlainObject(block.error)) return block.error
551
+ if (block.isError === true) return { name: 'ToolError', code: 'error' }
552
+ return null
553
+ }
554
+ function parsePayload(block) {
555
+ if (!isPlainObject(block)) return null
556
+ const error = errorOfBlock(block)
557
+ if (error !== null) return { __error: true, error }
558
+ const text = textOfBlock(block).trim()
559
+ if (text === '') return null
560
+ if (text[0] === '{' || text[0] === '[') {
561
+ try {
562
+ const parsed = JSON.parse(text)
563
+ if (isPlainObject(parsed)) return parsed
564
+ if (Array.isArray(parsed)) return { items: parsed }
565
+ } catch (error) {
566
+ /* fall through to text */
567
+ }
568
+ }
569
+ return { text }
570
+ }
571
+ function blockersOf(value) {
572
+ if (!Array.isArray(value)) return []
573
+ const out = []
574
+ for (const entry of value) {
575
+ if (typeof entry === 'string' && entry.trim() !== '') out.push(entry.trim())
576
+ else if (isPlainObject(entry)) {
577
+ const code = typeof entry.code === 'string' ? entry.code : 'blocker'
578
+ const detail = typeof entry.detail === 'string' ? entry.detail : ''
579
+ out.push(detail === '' ? code : code + ': ' + detail)
580
+ }
581
+ }
582
+ return out
583
+ }
584
+ function missionBlocksOf(value) {
585
+ if (!Array.isArray(value)) return []
586
+ return value.filter((entry) => typeof entry === 'string' && entry.trim() !== '').map((entry) => entry.trim())
587
+ }
588
+ function countsOf(payload) {
589
+ if (!isPlainObject(payload) || !isPlainObject(payload.counts)) return null
590
+ const counts = payload.counts
591
+ let total = 0
592
+ const byStatus = {}
593
+ let hasAny = false
594
+ for (const key of Object.keys(counts)) {
595
+ const value = counts[key]
596
+ if (typeof value !== 'number' || !Number.isFinite(value)) continue
597
+ hasAny = true
598
+ byStatus[key] = value
599
+ total += value
600
+ }
601
+ if (!hasAny) return null
602
+ return { total, byStatus, active: typeof byStatus.active === 'number' ? byStatus.active : 0 }
603
+ }
604
+ function taskCountsOf(tasks) {
605
+ if (Array.isArray(tasks)) {
606
+ const counts = { total: tasks.length, done: 0, open: 0, dropped: 0 }
607
+ for (const task of tasks) {
608
+ const status = isPlainObject(task) ? task.status : undefined
609
+ if (status === 'done') counts.done += 1
610
+ else if (status === 'dropped') counts.dropped += 1
611
+ else counts.open += 1
612
+ }
613
+ return counts
614
+ }
615
+ if (isPlainObject(tasks)) {
616
+ const total = typeof tasks.total === 'number' ? tasks.total : null
617
+ if (total === null) return null
618
+ const done = typeof tasks.done === 'number' ? tasks.done : 0
619
+ const open = typeof tasks.open === 'number' ? tasks.open : Math.max(0, total - done - (typeof tasks.dropped === 'number' ? tasks.dropped : 0))
620
+ return { total, done, open, dropped: typeof tasks.dropped === 'number' ? tasks.dropped : 0 }
621
+ }
622
+ return null
623
+ }
624
+ function toneForStatus(status) {
625
+ if (typeof status !== 'string') return 'none'
626
+ if (/^(merged|completed|clean|done|fixed|ok)$/.test(status)) return 'ok'
627
+ if (/^(blocked|abandoned|failed)$/.test(status) || /^p1-/.test(status)) return 'error'
628
+ if (/^(paused|stale|missing|p2-)/.test(status)) return 'warn'
629
+ return 'idle'
630
+ }
631
+ function toneForPayload(payload) {
632
+ if (!isPlainObject(payload)) return 'none'
633
+ if (payload.ok === false) return 'error'
634
+ if (blockersOf(payload.blockers).length > 0) return 'error'
635
+ if (payload.ok === true) return 'ok'
636
+ if (isPlainObject(payload.review)) return toneForStatus(payload.review.status)
637
+ if (isPlainObject(payload.mission)) return toneForStatus(payload.mission.status)
638
+ if (isPlainObject(payload.review) === false && payload.review === null) return 'idle'
639
+ return 'idle'
640
+ }
641
+ function checkEntries(checks) {
642
+ if (!Array.isArray(checks)) return []
643
+ const out = []
644
+ for (const entry of checks) {
645
+ if (typeof entry === 'string') {
646
+ const text = entry.trim()
647
+ if (text !== '') out.push({ name: truncate(text, 80), ok: null, code: null, detail: '' })
648
+ continue
649
+ }
650
+ if (!isPlainObject(entry)) continue
651
+ const name =
652
+ typeof entry.name === 'string' && entry.name !== ''
653
+ ? entry.name
654
+ : typeof entry.id === 'string' && entry.id !== ''
655
+ ? entry.id
656
+ : 'check'
657
+ const code = typeof entry.exitCode === 'number' ? entry.exitCode : null
658
+ const ok =
659
+ code !== null
660
+ ? code === 0
661
+ : entry.status === 'pass'
662
+ ? true
663
+ : entry.status === 'fail'
664
+ ? false
665
+ : entry.ok === true
666
+ ? true
667
+ : entry.ok === false
668
+ ? false
669
+ : null
670
+ out.push({
671
+ name: truncate(name, 80),
672
+ ok,
673
+ code,
674
+ detail: typeof entry.detail === 'string' ? entry.detail : typeof entry.evidence === 'string' ? entry.evidence : '',
675
+ })
676
+ }
677
+ return out
678
+ }
679
+ function checkText(t, entry) {
680
+ const base =
681
+ entry.code !== null
682
+ ? translate(t, entry.ok ? 'card.checkOk' : 'card.checkFail', {
683
+ name: entry.name,
684
+ code: entry.code,
685
+ })
686
+ : entry.name
687
+ return entry.detail ? truncate(base + ': ' + entry.detail, 120) : base
688
+ }
689
+ function checkSummary(t, checks) {
690
+ const entries = checkEntries(checks)
691
+ if (entries.length === 0) return ''
692
+ const passed = entries.filter((entry) => entry.ok === true).length
693
+ const known = entries.filter((entry) => entry.ok !== null).length
694
+ if (known > 0) return translate(t, 'card.checksCount', { passed, total: known })
695
+ return String(entries.length)
696
+ }
697
+ function missionLine(t, mission) {
698
+ if (!isPlainObject(mission)) return ''
699
+ const id = typeof mission.id === 'string' ? mission.id : translate(t, 'card.missionMissing')
700
+ const status = typeof mission.status === 'string' ? mission.status : translate(t, 'card.unknown')
701
+ const parts = [translate(t, 'card.missionLine', { id, status })]
702
+ const tasks = taskCountsOf(mission.tasks)
703
+ if (tasks !== null && tasks.total > 0) {
704
+ parts.push(translate(t, 'card.tasks', { done: tasks.done, total: tasks.total }))
705
+ }
706
+ if (missionBlocksOf(mission.blockers).length > 0) {
707
+ parts.push(String(missionBlocksOf(mission.blockers).length) + ' ⚠')
708
+ }
709
+ return parts.join(translate(t, 'card.sep'))
710
+ }
711
+
712
+ // ---------------------------------------------------------------------
713
+ // Inline style helpers for generated rows.
714
+ // ---------------------------------------------------------------------
715
+ function rowElement(t, key, row) {
716
+ const style =
717
+ row.tone === 'error'
718
+ ? Object.assign({}, ST.row, ST.rowError)
719
+ : row.tone === 'warn'
720
+ ? Object.assign({}, ST.row, ST.rowWarn)
721
+ : row.tone === 'ok'
722
+ ? Object.assign({}, ST.row, ST.rowOk)
723
+ : ST.row
724
+ return h('div', { key, style }, row.text)
725
+ }
726
+
727
+ // ---------------------------------------------------------------------
728
+ // Tool cards. Owner props supplied by the Chat tool-call tree:
729
+ // callId, toolName, phase ('preparing' | 'start' | 'result'), block,
730
+ // cwd, home, openFile, loadImage, inspect, useDisclosure — plus the
731
+ // session-scope standard kit (useSession/useSessions/useChat/…).
732
+ // `phase === 'result'` means `block` is the settled `tool/result` block.
733
+ // ---------------------------------------------------------------------
734
+ function cardModel(toolName, payload, t) {
735
+ const rows = []
736
+ if (!isPlainObject(payload)) {
737
+ return { tone: 'ok', headline: translate(t, 'card.done'), rows }
738
+ }
739
+ if (payload.__error === true) {
740
+ const error = isPlainObject(payload.error) ? payload.error : {}
741
+ const name = typeof error.name === 'string' ? error.name : translate(t, 'card.error')
742
+ const code = typeof error.code === 'string' ? error.code : ''
743
+ const message = typeof error.message === 'string' ? error.message : ''
744
+ const line = truncate([name, code, message].filter((part) => part !== '').join(': '))
745
+ return { tone: 'error', headline: translate(t, 'card.failed'), rows: [{ text: line, tone: 'error' }] }
746
+ }
747
+ if (typeof payload.text === 'string' && payload.text.trim() !== '') {
748
+ return { tone: 'ok', headline: firstLine(payload.text), rows }
749
+ }
750
+ const blockers = blockersOf(payload.blockers)
751
+ let tone = toneForPayload(payload)
752
+ let headline = translate(t, 'card.done')
753
+
754
+ if (toolName === 'tower_status') {
755
+ const counts = countsOf(payload)
756
+ const openTasks = typeof payload.openTasks === 'number' ? payload.openTasks : null
757
+ if (counts !== null) {
758
+ headline = translate(t, 'card.statusHeadline', { total: counts.total, active: counts.active })
759
+ }
760
+ if (openTasks !== null) {
761
+ rows.push({
762
+ text: openTasks > 0 ? translate(t, 'card.openTasks', { count: openTasks }) : translate(t, 'card.noOpenTasks'),
763
+ tone: openTasks > 0 ? 'warn' : 'ok',
764
+ })
765
+ }
766
+ if (typeof payload.roster === 'number' || typeof payload.findings === 'number') {
767
+ rows.push({
768
+ text: translate(t, 'card.statusDetail', {
769
+ roster: typeof payload.roster === 'number' ? payload.roster : '?',
770
+ findings: typeof payload.findings === 'number' ? payload.findings : '?',
771
+ revision: typeof payload.revision === 'number' ? payload.revision : '?',
772
+ }),
773
+ })
774
+ }
775
+ if (Array.isArray(payload.missions)) {
776
+ for (const mission of payload.missions.slice(0, 3)) {
777
+ const line = missionLine(t, mission)
778
+ if (line !== '') rows.push({ text: line, tone: toneForStatus(isPlainObject(mission) ? mission.status : undefined) })
779
+ }
780
+ }
781
+ if (isPlainObject(payload.detail) && isPlainObject(payload.detail.mission)) {
782
+ const line = missionLine(t, payload.detail.mission)
783
+ if (line !== '') rows.push({ text: line, tone: toneForStatus(payload.detail.mission.status) })
784
+ }
785
+ if (headline === translate(t, 'card.done')) {
786
+ headline = translate(t, 'card.statusHeadline', { total: 0, active: 0 })
787
+ }
788
+ } else if (toolName === 'tower_roster') {
789
+ const roster = Array.isArray(payload.roster) ? payload.roster : []
790
+ let live = 0
791
+ let stale = 0
792
+ let missing = 0
793
+ let other = 0
794
+ for (const entry of roster) {
795
+ if (!isPlainObject(entry)) continue
796
+ const activity = entry.activity
797
+ if (activity === 'running') live += 1
798
+ else if (entry.stale === true) stale += 1
799
+ else if (activity === 'missing' || activity === 'unknown') missing += 1
800
+ else other += 1
801
+ }
802
+ headline = translate(t, 'card.rosterHeadline', { live, total: roster.length })
803
+ tone = live > 0 ? 'ok' : stale > 0 ? 'warn' : 'idle'
804
+ if (stale > 0) rows.push({ text: translate(t, 'card.staleCount', { count: stale }), tone: 'warn' })
805
+ if (missing + other > 0) rows.push({ text: translate(t, 'card.otherCount', { count: missing + other }) })
806
+ for (const entry of roster.slice(0, 4)) {
807
+ if (!isPlainObject(entry)) continue
808
+ rows.push({
809
+ text: translate(t, 'card.agentLine', {
810
+ name: typeof entry.name === 'string' ? entry.name : '?',
811
+ role: typeof entry.role === 'string' ? entry.role : '?',
812
+ mission: typeof entry.missionId === 'string' ? entry.missionId : '?',
813
+ activity: typeof entry.activity === 'string' ? entry.activity : '?',
814
+ }),
815
+ tone: entry.stale === true ? 'warn' : entry.activity === 'running' ? 'ok' : undefined,
816
+ })
817
+ }
818
+ if (isPlainObject(payload.parentStatus)) {
819
+ const unavailable = Object.keys(payload.parentStatus).filter((key) => payload.parentStatus[key] !== 'ok')
820
+ if (unavailable.length > 0) {
821
+ rows.push({ text: 'parent catalog unavailable: ' + unavailable.length, tone: 'warn' })
822
+ }
823
+ }
824
+ } else if (toolName === 'tower_spawn') {
825
+ headline = translate(t, 'card.spawnLine', {
826
+ role: typeof payload.role === 'string' ? payload.role : '?',
827
+ name: typeof payload.name === 'string' ? payload.name : '?',
828
+ mission: typeof payload.missionId === 'string' ? payload.missionId : '?',
829
+ })
830
+ tone = 'ok'
831
+ if (isPlainObject(payload.worktree) && typeof payload.worktree.branch === 'string') {
832
+ rows.push({ text: translate(t, 'card.worktreeLine', { branch: payload.worktree.branch }) })
833
+ }
834
+ if (payload.created === false) rows.push({ text: 'reused worktree' })
835
+ if (typeof payload.agentId === 'string') rows.push({ text: truncate(payload.agentId, 60) })
836
+ if (payload.timedOut === true) rows.push({ text: 'timed out', tone: 'warn' })
837
+ } else if (toolName === 'tower_mission') {
838
+ const mission = isPlainObject(payload.mission) ? payload.mission : null
839
+ headline = mission !== null ? missionLine(t, mission) : translate(t, 'card.done')
840
+ tone = mission !== null ? toneForStatus(mission.status) : 'ok'
841
+ if (mission !== null) {
842
+ const tasks = taskCountsOf(mission.tasks)
843
+ if (tasks !== null) rows.push({ text: translate(t, 'card.tasks', { done: tasks.done, total: tasks.total }) })
844
+ for (const blocker of missionBlocksOf(mission.blockers).slice(0, 3)) {
845
+ rows.push({ text: truncate(blocker), tone: 'error' })
846
+ }
847
+ if (typeof mission.reviewPolicy === 'string') {
848
+ rows.push({ text: translate(t, 'card.reviewPolicy', { policy: mission.reviewPolicy }) })
849
+ }
850
+ if (isPlainObject(mission.owner) && typeof mission.owner.name === 'string') {
851
+ rows.push({ text: translate(t, 'card.owner', { owner: mission.owner.name }) })
852
+ }
853
+ }
854
+ } else if (toolName === 'tower_complete') {
855
+ const mission = isPlainObject(payload.mission) ? payload.mission : null
856
+ headline = translate(t, 'card.completeLine', {
857
+ mission: mission !== null && typeof mission.id === 'string' ? mission.id : '?',
858
+ commits: typeof payload.commits === 'number' ? payload.commits : 0,
859
+ })
860
+ tone = 'ok'
861
+ if (typeof payload.unread === 'number' && payload.unread > 0) {
862
+ rows.push({ text: translate(t, 'card.unread', { count: payload.unread }), tone: 'warn' })
863
+ }
864
+ if (mission !== null && isPlainObject(mission.completionReport)) {
865
+ const checks = checkEntries(mission.completionReport.checks)
866
+ if (checks.length > 0) {
867
+ rows.push({ text: translate(t, 'card.checks') + ': ' + checkSummary(t, checks) })
868
+ for (const entry of checks.slice(0, 3)) rows.push({ text: checkText(t, entry), tone: entry.ok === false ? 'error' : 'ok' })
869
+ }
870
+ if (typeof mission.completionReport.report === 'string') {
871
+ rows.push({ text: firstLine(mission.completionReport.report) })
872
+ }
873
+ }
874
+ } else if (toolName === 'tower_review') {
875
+ const review = isPlainObject(payload.review) ? payload.review : {}
876
+ headline = translate(t, 'card.reviewLine', {
877
+ status: typeof review.status === 'string' ? review.status : '?',
878
+ merge: typeof review.mergeVerdict === 'string' ? review.mergeVerdict : '?',
879
+ round: typeof payload.round === 'number' ? payload.round : typeof review.round === 'number' ? review.round : '?',
880
+ })
881
+ tone = toneForStatus(review.status)
882
+ const checks = checkEntries(review.checks)
883
+ if (checks.length > 0) {
884
+ rows.push({ text: translate(t, 'card.checks') + ': ' + checks.slice(0, 4).map((entry) => checkText(t, entry)).join(translate(t, 'card.sep')) })
885
+ }
886
+ if (typeof review.findings === 'string' && review.findings.trim() !== '' && review.findings.trim().toLowerCase() !== 'none') {
887
+ rows.push({ text: firstLine(review.findings), tone: 'warn' })
888
+ }
889
+ if (typeof review.decision === 'string' && review.decision.trim() !== '') {
890
+ rows.push({ text: firstLine(review.decision) })
891
+ }
892
+ if (typeof payload.branchTip === 'string') rows.push({ text: 'tip ' + shortCommit(payload.branchTip) })
893
+ } else if (toolName === 'tower_preflight') {
894
+ headline =
895
+ payload.ok === true
896
+ ? translate(t, 'card.mergeReady')
897
+ : translate(t, 'card.mergeBlocked', { count: blockers.length })
898
+ tone = payload.ok === true ? 'ok' : 'error'
899
+ if (Array.isArray(payload.changedFiles)) {
900
+ rows.push({ text: 'changed files ' + payload.changedFiles.length })
901
+ }
902
+ if (isPlainObject(payload.review)) {
903
+ rows.push({
904
+ text: translate(t, 'card.reviewLine', {
905
+ status: typeof payload.review.status === 'string' ? payload.review.status : '?',
906
+ merge: typeof payload.review.mergeVerdict === 'string' ? payload.review.mergeVerdict : '?',
907
+ round: typeof payload.review.round === 'number' ? payload.review.round : '?',
908
+ }),
909
+ tone: toneForStatus(payload.review.status),
910
+ })
911
+ }
912
+ } else if (toolName === 'tower_merge') {
913
+ if (payload.ok === true) {
914
+ headline = payload.alreadyMerged === true
915
+ ? translate(t, 'card.alreadyMerged')
916
+ : translate(t, 'card.merged', { commit: shortCommit(payload.mergedCommit) })
917
+ tone = 'ok'
918
+ } else {
919
+ headline = translate(t, 'card.mergeBlocked', { count: blockers.length })
920
+ tone = 'error'
921
+ }
922
+ if (payload.recoveredFromFailure === true) rows.push({ text: 'recorded after a failed merge attempt', tone: 'warn' })
923
+ if (Array.isArray(payload.warnings)) {
924
+ for (const warning of payload.warnings.slice(0, 2)) rows.push({ text: truncate(warning), tone: 'warn' })
925
+ }
926
+ if (typeof payload.baseTip === 'string') rows.push({ text: 'base ' + shortCommit(payload.baseTip) })
927
+ } else if (toolName === 'tower_teardown') {
928
+ const removed = Array.isArray(payload.removed) ? payload.removed.length : 0
929
+ const decisions = Array.isArray(payload.decisions) ? payload.decisions : []
930
+ const kept = decisions.filter((entry) => isPlainObject(entry) && entry.action === 'keep').length
931
+ headline = translate(t, 'card.teardownLine', { removed, kept })
932
+ tone = payload.ok === false ? 'error' : 'ok'
933
+ if (payload.dryRun === true) rows.push({ text: translate(t, 'card.dryRun') })
934
+ for (const decision of decisions.slice(0, 3)) {
935
+ if (!isPlainObject(decision)) continue
936
+ const reason = typeof decision.reason === 'string' ? ' · ' + truncate(decision.reason, 60) : ''
937
+ rows.push({
938
+ text: (typeof decision.action === 'string' ? decision.action : '?') + ' ' + (typeof decision.missionId === 'string' ? decision.missionId : '?') + reason,
939
+ tone: decision.action === 'keep' ? 'warn' : 'ok',
940
+ })
941
+ }
942
+ } else if (toolName === 'tower_interrupt') {
943
+ tone = payload.interrupted === true ? 'warn' : 'idle'
944
+ headline =
945
+ payload.interrupted === true
946
+ ? translate(t, 'card.interruptYes', { name: isPlainObject(payload.entry) && typeof payload.entry.name === 'string' ? payload.entry.name : '?' })
947
+ : translate(t, 'card.interruptNo', { name: isPlainObject(payload.entry) && typeof payload.entry.name === 'string' ? payload.entry.name : '?' })
948
+ if (typeof payload.previousActivity === 'string') {
949
+ rows.push({ text: translate(t, 'card.previousActivity', { activity: payload.previousActivity }) })
950
+ }
951
+ if (typeof payload.reason === 'string' && payload.reason !== '') rows.push({ text: truncate(payload.reason) })
952
+ } else if (toolName === 'tower_doctor') {
953
+ const checks = checkEntries(payload.checks)
954
+ const passed = checks.filter((entry) => entry.ok === true).length
955
+ const warned = checks.filter((entry) => entry.ok === null).length
956
+ const failed = checks.filter((entry) => entry.ok === false).length
957
+ headline = translate(t, 'card.doctorLine', { status: payload.ok === true ? 'ok' : 'issues' })
958
+ tone = payload.ok === true ? 'ok' : failed > 0 ? 'error' : 'warn'
959
+ if (checks.length > 0) {
960
+ rows.push({
961
+ text: translate(t, 'card.doctorChecks', { pass: passed, warn: warned, fail: failed }),
962
+ tone: failed > 0 ? 'error' : warned > 0 ? 'warn' : 'ok',
963
+ })
964
+ for (const entry of checks.filter((candidate) => candidate.ok !== true).slice(0, 4)) {
965
+ rows.push({ text: checkText(t, entry), tone: entry.ok === false ? 'error' : 'warn' })
966
+ }
967
+ }
968
+ } else if (toolName === 'tower_batch') {
969
+ const summary = isPlainObject(payload.summary)
970
+ ? payload.summary
971
+ : isPlainObject(payload.batch)
972
+ ? payload.batch
973
+ : null
974
+ const status =
975
+ summary !== null && typeof summary.status === 'string'
976
+ ? summary.status
977
+ : payload.ok === true
978
+ ? 'ok'
979
+ : 'issues'
980
+ headline = translate(t, 'card.batchLine', { status })
981
+ tone = payload.ok === true ? 'ok' : 'warn'
982
+ if (summary !== null) {
983
+ if (Array.isArray(summary.members)) {
984
+ rows.push({
985
+ text: translate(t, 'card.batchMembers', {
986
+ count: summary.members.length,
987
+ status: typeof summary.status === 'string' ? summary.status : '?',
988
+ }),
989
+ })
990
+ }
991
+ if (Array.isArray(summary.missingChecks) && summary.missingChecks.length > 0) {
992
+ rows.push({ text: translate(t, 'card.batchMissing', { count: summary.missingChecks.length }), tone: 'warn' })
993
+ }
994
+ if (Array.isArray(summary.pendingChecks) && summary.pendingChecks.length > 0) {
995
+ rows.push({ text: translate(t, 'card.batchMissing', { count: summary.pendingChecks.length }), tone: 'warn' })
996
+ }
997
+ if (Array.isArray(summary.failedChecks) && summary.failedChecks.length > 0) {
998
+ rows.push({ text: translate(t, 'card.batchFailed', { count: summary.failedChecks.length }), tone: 'error' })
999
+ }
1000
+ const checks = checkEntries(summary.checks)
1001
+ if (checks.length > 0) {
1002
+ rows.push({
1003
+ text: translate(t, 'card.checks') + ': ' + checkSummary(t, checks),
1004
+ tone: checks.some((entry) => entry.ok === false) ? 'error' : 'ok',
1005
+ })
1006
+ }
1007
+ } else if (Array.isArray(payload.batches)) {
1008
+ rows.push({ text: 'batches ' + payload.batches.length })
1009
+ }
1010
+ } else {
1011
+ tone = toneForPayload(payload)
1012
+ }
1013
+
1014
+ if (!TOOL_SET.has(toolName)) {
1015
+ const checks = checkEntries(payload.checks)
1016
+ if (checks.length > 0) {
1017
+ rows.push({ text: translate(t, 'card.checks') + ': ' + checkSummary(t, checks), tone: checks.some((entry) => entry.ok === false) ? 'error' : 'ok' })
1018
+ }
1019
+ if (Array.isArray(payload.warnings) && payload.warnings.length > 0) {
1020
+ rows.push({ text: translate(t, 'card.warnings') + ': ' + payload.warnings.slice(0, 2).map((item) => truncate(item, 60)).join(translate(t, 'card.sep')), tone: 'warn' })
1021
+ }
1022
+ const counts = countsOf(payload)
1023
+ if (counts !== null) {
1024
+ rows.push({ text: translate(t, 'card.statusHeadline', { total: counts.total, active: counts.active }) })
1025
+ }
1026
+ if (Array.isArray(payload.missions)) {
1027
+ for (const mission of payload.missions.slice(0, 3)) {
1028
+ const line = missionLine(t, mission)
1029
+ if (line !== '') rows.push({ text: line, tone: toneForStatus(isPlainObject(mission) ? mission.status : undefined) })
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ for (const blocker of blockers.slice(0, 3)) {
1035
+ rows.push({ text: truncate(blocker), tone: 'error' })
1036
+ }
1037
+ return { tone, headline, rows }
1038
+ }
1039
+
1040
+ // Publish a transient overlay notice when this chat-driven Tool row changes
1041
+ // lifecycle. Mounting an already-settled result (history) stays silent; a
1042
+ // live preparing/start row and a start→result transition publish.
1043
+ let publishNotice = function () {}
1044
+ function useToolviewLifecycle(props) {
1045
+ const phase = props ? props.phase : undefined
1046
+ const toolName = props ? props.toolName : undefined
1047
+ const callId = props ? props.callId : undefined
1048
+ const failed = Boolean(props && props.block && (props.block.isError === true || isPlainObject(props.block.error)))
1049
+ const previous = React.useRef ? React.useRef(null) : { current: null }
1050
+ React.useEffect(
1051
+ function () {
1052
+ const before = previous.current
1053
+ previous.current = { phase, callId }
1054
+ if (!isTowerTool(toolName)) return
1055
+ const sameCall = before !== null && before.callId === callId
1056
+ if (phase !== 'result') {
1057
+ if (!sameCall || before.phase !== phase) publishNotice({ tool: toolName, running: true })
1058
+ } else if (sameCall && before.phase !== 'result') {
1059
+ publishNotice({ tool: toolName, running: false, ok: failed !== true })
1060
+ }
1061
+ },
1062
+ [phase, callId, toolName, failed],
1063
+ )
1064
+ }
1065
+
1066
+ function TowerToolCard(props) {
1067
+ const t = seatT(props)
1068
+ const toolName = props && typeof props.toolName === 'string' ? props.toolName : ''
1069
+ const phase = props && typeof props.phase === 'string' ? props.phase : 'start'
1070
+ const running = phase !== 'result'
1071
+ useToolviewLifecycle(props)
1072
+ let payload = null
1073
+ if (!running) payload = parsePayload(props ? props.block : undefined)
1074
+ const model = running
1075
+ ? { tone: 'idle', headline: translate(t, 'card.running'), rows: [] }
1076
+ : cardModel(toolName, payload, t)
1077
+ const title =
1078
+ toolLabel(t, toolName) +
1079
+ ' · ' +
1080
+ (running ? translate(t, 'card.running') : model.headline)
1081
+ return h(
1082
+ 'div',
1083
+ {
1084
+ 'data-tower-toolview': toolName === '' ? 'tower_unknown' : toolName,
1085
+ 'data-tower-phase': phase,
1086
+ style: ST.card,
1087
+ title,
1088
+ },
1089
+ h(
1090
+ 'div',
1091
+ { style: ST.header },
1092
+ h('span', { 'aria-hidden': true, style: dotStyle(running ? 'idle' : model.tone) }),
1093
+ h('span', { style: ST.title }, translate(t, 'card.title')),
1094
+ h('span', { style: ST.dim }, '·'),
1095
+ h('span', { style: ST.title }, toolLabel(t, toolName)),
1096
+ h('span', { style: running ? ST.dim : model.tone === 'error' ? ST.rowError : ST.dim }, truncate(model.headline, 100)),
1097
+ ),
1098
+ model.rows.slice(0, 6).map(function (row, index) {
1099
+ return rowElement(t, index, row)
1100
+ }),
1101
+ )
1102
+ }
1103
+
1104
+ // ---------------------------------------------------------------------
1105
+ // Dynamic denied-tool rows (#12). These entries exist only while the
1106
+ // displayed session is in Tower mode, registered at DENIED_PRIORITY so the
1107
+ // native toolviews (priority 0) return the moment the mode turns off. A
1108
+ // real guard denial renders the distinct `Tower mode · disabled` row;
1109
+ // anything else landing in these slots while the mode is on gets a
1110
+ // defensive generic row instead of the native view.
1111
+ // ---------------------------------------------------------------------
1112
+ function denialMessageOf(block) {
1113
+ if (!isPlainObject(block)) return null
1114
+ const candidates = []
1115
+ if (isPlainObject(block.error) && typeof block.error.message === 'string') candidates.push(block.error.message)
1116
+ const text = textOfBlock(block)
1117
+ if (text !== '') candidates.push(text)
1118
+ if (typeof block.text === 'string') candidates.push(block.text)
1119
+ for (const raw of candidates) {
1120
+ const message = normalizeDenialText(raw)
1121
+ if (message !== null) return message
1122
+ }
1123
+ return null
1124
+ }
1125
+ function normalizeDenialText(raw) {
1126
+ let value = String(raw === undefined || raw === null ? '' : raw).trim()
1127
+ if (value === '') return null
1128
+ if (value[0] === '{') {
1129
+ try {
1130
+ const parsed = JSON.parse(value)
1131
+ if (isPlainObject(parsed) && typeof parsed.message === 'string') value = parsed.message.trim()
1132
+ } catch (error) {
1133
+ /* keep the raw text */
1134
+ }
1135
+ }
1136
+ value = value.replace(/^Error:\s*/i, '')
1137
+ const lower = value.toLowerCase()
1138
+ if (!lower.includes('tower mode')) return null
1139
+ const specific =
1140
+ lower.includes('is disabled while tower mode is active') ||
1141
+ lower.includes('goals cannot be resumed while tower mode is active')
1142
+ if (!specific && !lower.includes('/tower off')) return null
1143
+ return truncate(value, 400)
1144
+ }
1145
+ function deniedNames(configured) {
1146
+ const source = Array.isArray(configured) && configured.length > 0 ? configured : DEFAULT_DENIED_TOOLS
1147
+ const names = []
1148
+ for (const entry of source) {
1149
+ if (typeof entry !== 'string') continue
1150
+ const name = entry.trim()
1151
+ if (name === '' || names.includes(name)) continue
1152
+ names.push(name)
1153
+ }
1154
+ // update_goal is denied only for action:"resume", but the guard special
1155
+ // case exists regardless of the configured denyTools list.
1156
+ if (!names.includes('update_goal')) names.push('update_goal')
1157
+ return names
1158
+ }
1159
+ function DeniedToolRow(props) {
1160
+ const t = seatT(props)
1161
+ const toolName = props && typeof props.toolName === 'string' ? props.toolName : ''
1162
+ const phase = props && typeof props.phase === 'string' ? props.phase : 'start'
1163
+ if (phase !== 'result') {
1164
+ return h(
1165
+ 'div',
1166
+ { 'data-tower-toolview': toolName, 'data-tower-denied-view': 'running', style: ST.denyCard },
1167
+ h(
1168
+ 'div',
1169
+ { style: ST.header },
1170
+ h('span', { 'aria-hidden': true, style: dotStyle('idle') }),
1171
+ h('span', { style: ST.denyLabel }, translate(t, 'deny.label')),
1172
+ h('span', { style: ST.dim }, '·'),
1173
+ h('span', { style: ST.title }, toolName),
1174
+ h('span', { style: ST.dim }, translate(t, 'deny.running')),
1175
+ ),
1176
+ )
1177
+ }
1178
+ const denial = denialMessageOf(props ? props.block : undefined)
1179
+ if (denial !== null) {
1180
+ return h(
1181
+ 'div',
1182
+ {
1183
+ 'data-tower-toolview': toolName,
1184
+ 'data-tower-denied-view': 'denied',
1185
+ role: 'note',
1186
+ style: ST.denyCard,
1187
+ title: denial,
1188
+ },
1189
+ h(
1190
+ 'div',
1191
+ { style: ST.header },
1192
+ h('span', { 'aria-hidden': true, style: dotStyle('warn') }),
1193
+ h('span', { style: ST.denyLabel }, translate(t, 'deny.label')),
1194
+ h('span', { style: ST.dim }, '·'),
1195
+ h('span', { style: ST.title }, toolName),
1196
+ ),
1197
+ h('div', { style: ST.row }, denial),
1198
+ h('div', { style: ST.denyHint }, translate(t, 'deny.hint')),
1199
+ )
1200
+ }
1201
+ // Defensive fallback: the slot is ours only while Tower mode is on, so a
1202
+ // non-denial result here has no native view to delegate to.
1203
+ const payload = parsePayload(props ? props.block : undefined)
1204
+ const error =
1205
+ isPlainObject(props) && isPlainObject(props.block) && isPlainObject(props.block.error) ? props.block.error : null
1206
+ let text = translate(t, 'deny.generic', { tool: toolName })
1207
+ let tone = 'ok'
1208
+ if (isPlainObject(payload) && payload.__error === true) {
1209
+ tone = 'error'
1210
+ const reason =
1211
+ error !== null && typeof error.message === 'string'
1212
+ ? error.message
1213
+ : error !== null && typeof error.code === 'string'
1214
+ ? error.code
1215
+ : 'error'
1216
+ text = translate(t, 'deny.error') + ': ' + firstLine(reason)
1217
+ } else if (isPlainObject(payload) && typeof payload.text === 'string' && payload.text.trim() !== '') {
1218
+ text = translate(t, 'deny.generic', { tool: toolName }) + translate(t, 'card.sep') + firstLine(payload.text)
1219
+ }
1220
+ return h(
1221
+ 'div',
1222
+ { 'data-tower-toolview': toolName, 'data-tower-denied-view': 'generic', style: ST.denyCard },
1223
+ h(
1224
+ 'div',
1225
+ { style: ST.header },
1226
+ h('span', { 'aria-hidden': true, style: dotStyle(tone) }),
1227
+ h('span', { style: ST.title }, translate(t, 'card.title')),
1228
+ h('span', { style: ST.dim }, '·'),
1229
+ h('span', { style: ST.title }, toolName),
1230
+ h('span', { style: tone === 'error' ? ST.rowError : ST.dim }, truncate(text, 120)),
1231
+ ),
1232
+ )
1233
+ }
1234
+
1235
+ // ---------------------------------------------------------------------
1236
+ // Composer dock: a history-derived Tower-mode chip while the displayed
1237
+ // session is in Tower mode; otherwise the static `/tower` hint until this
1238
+ // conversation has a Tower tool result or a running Tower call, then a
1239
+ // compact latest summary. Everything is derived only from the `useChat`
1240
+ // session-standard selector hook (the durable Chat legacy projection).
1241
+ // ---------------------------------------------------------------------
1242
+ function towerCommandAction(args) {
1243
+ const raw = String(args === undefined || args === null ? '' : args).trim()
1244
+ if (/^reload$/i.test(raw)) return null
1245
+ if (/^base\s+\S+\s*$/i.test(raw)) return null
1246
+ const match = /^(on|off)(?:\s+[\s\S]*)?$/i.exec(raw)
1247
+ if (match) return match[1].toLowerCase()
1248
+ // Bare `/tower` and `/tower <message>` both mean "ensure on".
1249
+ return 'on'
1250
+ }
1251
+ function repoNameOf(value) {
1252
+ if (typeof value !== 'string' || value === '') return null
1253
+ let cleaned = value.replace(/[\\/]+$/, '')
1254
+ if (/[\\/]\.git$/i.test(cleaned)) cleaned = cleaned.replace(/[\\/]\.git$/i, '')
1255
+ const index = Math.max(cleaned.lastIndexOf('/'), cleaned.lastIndexOf('\\'))
1256
+ const name = index >= 0 ? cleaned.slice(index + 1) : cleaned
1257
+ return name === '' ? null : name
1258
+ }
1259
+ // Fold the durable history the dock can see into one Tower-mode model. The
1260
+ // last recognized event wins; `/tower reload|base` and failed commands do
1261
+ // not change the stance. This is a loaded-history-window inference, not a
1262
+ // Host read: entries older than the window yield `known: false`.
1263
+ function modeModelFromLegacy(legacy) {
1264
+ const model = {
1265
+ known: false,
1266
+ on: null,
1267
+ denied: null,
1268
+ repo: null,
1269
+ lock: null,
1270
+ lastBy: null,
1271
+ lastReason: null,
1272
+ source: null,
1273
+ }
1274
+ const nodes = isPlainObject(legacy) && Array.isArray(legacy.nodes) ? legacy.nodes : []
1275
+ for (const node of nodes) {
1276
+ if (!isPlainObject(node)) continue
1277
+ if (node.kind === 'command') {
1278
+ if (node.name !== 'tower') continue
1279
+ const outcome = isPlainObject(node.outcome) ? node.outcome : null
1280
+ if (outcome !== null && outcome.kind === 'error') continue
1281
+ const action = towerCommandAction(node.args)
1282
+ if (action === null) continue
1283
+ model.known = true
1284
+ model.on = action === 'on'
1285
+ model.source = 'command'
1286
+ model.lastBy = 'user'
1287
+ model.lastReason = null
1288
+ continue
1289
+ }
1290
+ const toolName = isPlainObject(node.call) ? node.call.name : null
1291
+ if (toolName === 'tower_mode') {
1292
+ const payload = parsePayload(node)
1293
+ if (!isPlainObject(payload) || payload.__error === true) continue
1294
+ if (payload.mode === 'on' || payload.mode === 'off') {
1295
+ model.known = true
1296
+ model.on = payload.mode === 'on'
1297
+ model.source = 'tower_mode'
1298
+ }
1299
+ if (Array.isArray(payload.deniedWhileActive)) {
1300
+ const names = []
1301
+ for (const entry of payload.deniedWhileActive) {
1302
+ if (typeof entry !== 'string') continue
1303
+ const name = entry.trim()
1304
+ if (name !== '' && !names.includes(name)) names.push(name)
1305
+ }
1306
+ if (names.length > 0) model.denied = names
1307
+ }
1308
+ const workspace = isPlainObject(payload.workspaceLock) ? payload.workspaceLock : null
1309
+ if (workspace !== null) {
1310
+ model.lock = { enabled: workspace.enabled === true, held: workspace.held === true }
1311
+ const name = repoNameOf(workspace.commonDir)
1312
+ if (name !== null) model.repo = name
1313
+ }
1314
+ if (isPlainObject(payload.lastChange)) {
1315
+ if (typeof payload.lastChange.by === 'string') model.lastBy = payload.lastChange.by
1316
+ if (typeof payload.lastChange.reason === 'string' && payload.lastChange.reason.trim() !== '') {
1317
+ model.lastReason = payload.lastChange.reason.trim()
1318
+ }
1319
+ }
1320
+ continue
1321
+ }
1322
+ if (toolName === 'tower_control') {
1323
+ const payload = parsePayload(node)
1324
+ if (!isPlainObject(payload) || payload.__error === true || payload.ok === false) continue
1325
+ const mode =
1326
+ payload.mode === 'on' || payload.mode === 'off'
1327
+ ? payload.mode
1328
+ : payload.action === 'enter'
1329
+ ? 'on'
1330
+ : payload.action === 'exit'
1331
+ ? 'off'
1332
+ : null
1333
+ if (mode === null) continue
1334
+ model.known = true
1335
+ model.on = mode === 'on'
1336
+ model.source = 'tower_control'
1337
+ if (typeof payload.changedBy === 'string') model.lastBy = payload.changedBy
1338
+ if (typeof payload.reason === 'string' && payload.reason.trim() !== '') model.lastReason = payload.reason.trim()
1339
+ continue
1340
+ }
1341
+ if (toolName === 'tower_status') {
1342
+ const payload = parsePayload(node)
1343
+ if (isPlainObject(payload)) {
1344
+ const name = repoNameOf(payload.repoRoot)
1345
+ if (name !== null) model.repo = name
1346
+ }
1347
+ }
1348
+ }
1349
+ return model
1350
+ }
1351
+ const EMPTY_MODE = {
1352
+ writer: null,
1353
+ known: false,
1354
+ on: null,
1355
+ denied: null,
1356
+ repo: null,
1357
+ lock: null,
1358
+ lastBy: null,
1359
+ lastReason: null,
1360
+ source: null,
1361
+ }
1362
+ // Module-local mode observable. The dock (session scope) publishes the
1363
+ // stance it derived from history; the plugin uses it only to add/remove
1364
+ // the dynamic denied-tool toolviews. It never holds or fetches Host state.
1365
+ let modeValue = Object.assign({}, EMPTY_MODE)
1366
+ const modeListeners = new Set()
1367
+ let modeWriterSeq = 0
1368
+ function nextDockWriter() {
1369
+ modeWriterSeq += 1
1370
+ return 'dock-' + modeWriterSeq
1371
+ }
1372
+ function modeSnapshot() {
1373
+ return modeValue
1374
+ }
1375
+ function subscribeMode(listener) {
1376
+ modeListeners.add(listener)
1377
+ return function () {
1378
+ modeListeners.delete(listener)
1379
+ }
1380
+ }
1381
+ function sameMode(left, right) {
1382
+ return (
1383
+ left.known === right.known &&
1384
+ left.on === right.on &&
1385
+ JSON.stringify(left.denied) === JSON.stringify(right.denied) &&
1386
+ left.repo === right.repo &&
1387
+ JSON.stringify(left.lock) === JSON.stringify(right.lock) &&
1388
+ left.lastBy === right.lastBy &&
1389
+ left.source === right.source
1390
+ )
1391
+ }
1392
+ function notifyMode() {
1393
+ for (const listener of Array.from(modeListeners)) {
1394
+ try {
1395
+ listener()
1396
+ } catch (error) {
1397
+ /* a broken registration reconciler must not break the dock */
1398
+ }
1399
+ }
1400
+ }
1401
+ function publishMode(writer, next) {
1402
+ const value = {
1403
+ writer,
1404
+ known: next.known === true,
1405
+ on: next.known === true && next.on === true,
1406
+ denied: Array.isArray(next.denied) ? next.denied.slice() : null,
1407
+ repo: typeof next.repo === 'string' && next.repo !== '' ? next.repo : null,
1408
+ lock: isPlainObject(next.lock) ? { enabled: next.lock.enabled === true, held: next.lock.held === true } : null,
1409
+ lastBy: typeof next.lastBy === 'string' && next.lastBy !== '' ? next.lastBy : null,
1410
+ lastReason: typeof next.lastReason === 'string' && next.lastReason !== '' ? next.lastReason : null,
1411
+ source: typeof next.source === 'string' ? next.source : null,
1412
+ }
1413
+ diagnostics.mode = { known: value.known, on: value.on, denied: value.denied }
1414
+ if (sameMode(value, modeValue)) {
1415
+ modeValue = value
1416
+ return
1417
+ }
1418
+ modeValue = value
1419
+ notifyMode()
1420
+ }
1421
+ function clearMode(writer) {
1422
+ if (modeValue.writer !== writer) return
1423
+ const empty = Object.assign({}, EMPTY_MODE, { writer })
1424
+ if (sameMode(empty, modeValue)) {
1425
+ modeValue = empty
1426
+ return
1427
+ }
1428
+ modeValue = empty
1429
+ diagnostics.mode = { known: false, on: null, denied: null }
1430
+ notifyMode()
1431
+ }
1432
+ function renderDockHint(t) {
1433
+ return h(
1434
+ 'div',
1435
+ {
1436
+ 'data-tower-ui': 'dock-hint',
1437
+ title: translate(t, 'dock.hint.title'),
1438
+ style: ST.hint,
1439
+ },
1440
+ h('span', { 'aria-hidden': true }, '⌘'),
1441
+ h('span', null, '/tower'),
1442
+ )
1443
+ }
1444
+ function latestTowerSignal(legacy) {
1445
+ if (!isPlainObject(legacy)) return null
1446
+ const running = Array.isArray(legacy.runningCalls) ? legacy.runningCalls : []
1447
+ for (let index = running.length - 1; index >= 0; index -= 1) {
1448
+ const block = running[index]
1449
+ if (isPlainObject(block) && isTowerTool(block.name)) return { name: block.name, phase: 'start', block }
1450
+ }
1451
+ const nodes = Array.isArray(legacy.nodes) ? legacy.nodes : []
1452
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
1453
+ const block = nodes[index]
1454
+ const name = isPlainObject(block) && isPlainObject(block.call) ? block.call.name : undefined
1455
+ if (isTowerTool(name)) return { name, phase: 'result', block }
1456
+ }
1457
+ return null
1458
+ }
1459
+ function dockResultModel(toolName, payload) {
1460
+ if (!isPlainObject(payload)) return { tool: toolName, running: false, tone: 'none', key: 'dock.generic' }
1461
+ if (payload.__error === true) return { tool: toolName, running: false, tone: 'error', key: 'dock.generic' }
1462
+ if (typeof payload.text === 'string') {
1463
+ return { tool: toolName, running: false, tone: 'idle', key: 'dock.generic' }
1464
+ }
1465
+ const blockers = blockersOf(payload.blockers)
1466
+ if (toolName === 'tower_status') {
1467
+ const counts = countsOf(payload)
1468
+ const open = typeof payload.openTasks === 'number' ? payload.openTasks : 0
1469
+ if (counts !== null) {
1470
+ return { tool: toolName, running: false, tone: open > 0 ? 'warn' : 'ok', key: 'dock.statusCounts', params: { total: counts.total, active: counts.active, open } }
1471
+ }
1472
+ return { tool: toolName, running: false, tone: 'ok', key: 'dock.generic' }
1473
+ }
1474
+ if (toolName === 'tower_roster') {
1475
+ const roster = Array.isArray(payload.roster) ? payload.roster : []
1476
+ let live = 0
1477
+ for (const entry of roster) if (isPlainObject(entry) && entry.activity === 'running') live += 1
1478
+ return { tool: toolName, running: false, tone: live > 0 ? 'ok' : 'idle', key: 'dock.rosterCounts', params: { live, total: roster.length } }
1479
+ }
1480
+ if (toolName === 'tower_review' && isPlainObject(payload.review)) {
1481
+ return {
1482
+ tool: toolName,
1483
+ running: false,
1484
+ tone: toneForStatus(payload.review.status),
1485
+ key: 'dock.review',
1486
+ params: {
1487
+ status: typeof payload.review.status === 'string' ? payload.review.status : '?',
1488
+ merge: typeof payload.review.mergeVerdict === 'string' ? payload.review.mergeVerdict : '?',
1489
+ },
1490
+ }
1491
+ }
1492
+ if (toolName === 'tower_preflight') {
1493
+ return payload.ok === true
1494
+ ? { tool: toolName, running: false, tone: 'ok', key: 'dock.ready' }
1495
+ : { tool: toolName, running: false, tone: 'error', key: 'dock.blocked', params: { count: blockers.length } }
1496
+ }
1497
+ if (toolName === 'tower_merge') {
1498
+ return payload.ok === true
1499
+ ? { tool: toolName, running: false, tone: 'ok', key: 'dock.merged', params: { commit: shortCommit(payload.mergedCommit) } }
1500
+ : { tool: toolName, running: false, tone: 'error', key: 'dock.blocked', params: { count: blockers.length } }
1501
+ }
1502
+ if (toolName === 'tower_complete' && isPlainObject(payload.mission)) {
1503
+ return {
1504
+ tool: toolName,
1505
+ running: false,
1506
+ tone: 'ok',
1507
+ key: 'dock.complete',
1508
+ params: { mission: typeof payload.mission.id === 'string' ? payload.mission.id : '?', commits: typeof payload.commits === 'number' ? payload.commits : 0 },
1509
+ }
1510
+ }
1511
+ if (toolName === 'tower_mission' && isPlainObject(payload.mission)) {
1512
+ return {
1513
+ tool: toolName,
1514
+ running: false,
1515
+ tone: toneForStatus(payload.mission.status),
1516
+ key: 'dock.mission',
1517
+ params: { id: typeof payload.mission.id === 'string' ? payload.mission.id : '?', status: typeof payload.mission.status === 'string' ? payload.mission.status : '?' },
1518
+ }
1519
+ }
1520
+ if (toolName === 'tower_spawn') {
1521
+ return {
1522
+ tool: toolName,
1523
+ running: false,
1524
+ tone: 'ok',
1525
+ key: 'dock.spawn',
1526
+ params: {
1527
+ role: typeof payload.role === 'string' ? payload.role : '?',
1528
+ name: typeof payload.name === 'string' ? payload.name : '?',
1529
+ mission: typeof payload.missionId === 'string' ? payload.missionId : '?',
1530
+ },
1531
+ }
1532
+ }
1533
+ if (toolName === 'tower_teardown') {
1534
+ return {
1535
+ tool: toolName,
1536
+ running: false,
1537
+ tone: 'ok',
1538
+ key: 'dock.removed',
1539
+ params: { count: Array.isArray(payload.removed) ? payload.removed.length : 0 },
1540
+ }
1541
+ }
1542
+ if (toolName === 'tower_interrupt') {
1543
+ const name = isPlainObject(payload.entry) && typeof payload.entry.name === 'string' ? payload.entry.name : '?'
1544
+ return payload.interrupted === true
1545
+ ? { tool: toolName, running: false, tone: 'warn', key: 'dock.interrupt', params: { name } }
1546
+ : { tool: toolName, running: false, tone: 'idle', key: 'dock.generic' }
1547
+ }
1548
+ if (toolName === 'tower_doctor') {
1549
+ return {
1550
+ tool: toolName,
1551
+ running: false,
1552
+ tone: payload.ok === true ? 'ok' : 'warn',
1553
+ key: 'dock.doctor',
1554
+ params: { status: payload.ok === true ? 'ok' : 'issues' },
1555
+ }
1556
+ }
1557
+ if (toolName === 'tower_batch') {
1558
+ const summary = isPlainObject(payload.summary) ? payload.summary : isPlainObject(payload.batch) ? payload.batch : null
1559
+ const failed = summary !== null && Array.isArray(summary.failedChecks) ? summary.failedChecks.length : 0
1560
+ const pending = summary !== null && Array.isArray(summary.pendingChecks) ? summary.pendingChecks.length : 0
1561
+ const tone = payload.ok === true && failed === 0 && pending === 0 ? 'ok' : failed > 0 ? 'error' : 'warn'
1562
+ return {
1563
+ tool: toolName,
1564
+ running: false,
1565
+ tone,
1566
+ key: 'dock.batch',
1567
+ params: { status: summary !== null && typeof summary.status === 'string' ? summary.status : payload.ok === true ? 'ok' : 'issues' },
1568
+ }
1569
+ }
1570
+ return { tool: toolName, running: false, tone: 'idle', key: 'dock.generic' }
1571
+ }
1572
+ function dockSelector(snapshot) {
1573
+ try {
1574
+ const legacy = snapshot ? snapshot.legacy : undefined
1575
+ const mode = modeModelFromLegacy(legacy)
1576
+ let summary = null
1577
+ // While Tower mode is on the chip is the dock's message; the latest
1578
+ // tool summary stays available again after /tower off.
1579
+ if (!(mode.known === true && mode.on === true)) {
1580
+ const signal = latestTowerSignal(legacy)
1581
+ if (signal !== null) {
1582
+ summary =
1583
+ signal.phase !== 'result'
1584
+ ? { tool: signal.name, running: true, tone: 'idle' }
1585
+ : dockResultModel(signal.name, parsePayload(signal.block))
1586
+ }
1587
+ }
1588
+ return JSON.stringify({
1589
+ mode,
1590
+ summary,
1591
+ // A Tower tool result with no recognized mode event means the loaded
1592
+ // history window cannot prove the stance. Surface that explicitly
1593
+ // instead of pretending the session is off.
1594
+ uncertain: mode.known !== true && summary !== null && summary.running !== true,
1595
+ })
1596
+ } catch (error) {
1597
+ return ''
1598
+ }
1599
+ }
1600
+ function modeStorageKey(sessionId) {
1601
+ return 'dsh-tower-ui:mode:' + String(sessionId)
1602
+ }
1603
+ function readStoredMode(sessionId) {
1604
+ if (typeof sessionId !== 'string' || sessionId === '') return null
1605
+ try {
1606
+ const raw = globalThis.localStorage && globalThis.localStorage.getItem(modeStorageKey(sessionId))
1607
+ if (!raw) return null
1608
+ const parsed = JSON.parse(raw)
1609
+ if (!isPlainObject(parsed) || typeof parsed.at !== 'number') return null
1610
+ if (Date.now() - parsed.at > 7 * 24 * 60 * 60 * 1000) return null
1611
+ return parsed
1612
+ } catch {
1613
+ return null
1614
+ }
1615
+ }
1616
+ function writeStoredMode(sessionId, mode) {
1617
+ if (typeof sessionId !== 'string' || sessionId === '') return
1618
+ try {
1619
+ if (!globalThis.localStorage) return
1620
+ if (mode.known === true && (mode.on === true || mode.on === false)) {
1621
+ globalThis.localStorage.setItem(modeStorageKey(sessionId), JSON.stringify({
1622
+ on: mode.on,
1623
+ denied: mode.denied ?? null,
1624
+ repo: mode.repo ?? null,
1625
+ lock: mode.lock ?? null,
1626
+ lastBy: mode.lastBy ?? null,
1627
+ lastReason: mode.lastReason ?? null,
1628
+ at: Date.now(),
1629
+ }))
1630
+ }
1631
+ } catch {
1632
+ // storage is best effort; the history-derived model remains the source
1633
+ }
1634
+ }
1635
+ function modeTooltip(t, mode) {
1636
+ const parts = [translate(t, 'dock.mode.tooltip')]
1637
+ if (mode.source === 'stored') parts.push(translate(t, 'dock.mode.lastKnown'))
1638
+ if (typeof mode.repo === 'string' && mode.repo !== '') parts.push(translate(t, 'dock.mode.repo', { repo: mode.repo }))
1639
+ if (isPlainObject(mode.lock)) {
1640
+ parts.push(translate(t, mode.lock.held === true ? 'dock.mode.lockHeld' : 'dock.mode.lockFree'))
1641
+ }
1642
+ if (typeof mode.lastBy === 'string' && mode.lastBy !== '') parts.push(translate(t, 'dock.mode.lastBy', { by: mode.lastBy }))
1643
+ return parts.join(translate(t, 'card.sep'))
1644
+ }
1645
+ function renderModeChip(t, mode) {
1646
+ // Non-interactive on purpose: the existing inputActions.submit() submits
1647
+ // the composer draft, so a programmatic /tower off would clobber what the
1648
+ // user is typing. The tooltip is the actionable affordance instead.
1649
+ return h(
1650
+ 'div',
1651
+ {
1652
+ 'data-tower-ui': 'dock-mode',
1653
+ 'data-tower-mode': mode.on === true ? 'on' : 'off',
1654
+ role: 'status',
1655
+ 'aria-live': 'polite',
1656
+ title: modeTooltip(t, mode),
1657
+ style: ST.modeChip,
1658
+ },
1659
+ h('span', { 'aria-hidden': true, style: dotStyle('ok') }),
1660
+ h('span', { style: ST.modeChipLabel }, translate(t, 'card.title')),
1661
+ h('span', { style: ST.dim }, '·'),
1662
+ h('span', { style: ST.modeChipOn }, translate(t, 'dock.mode.onLabel')),
1663
+ )
1664
+ }
1665
+ function renderModeUnknown(t) {
1666
+ return h(
1667
+ 'div',
1668
+ {
1669
+ 'data-tower-ui': 'dock-mode',
1670
+ 'data-tower-mode': 'unknown',
1671
+ role: 'status',
1672
+ 'aria-live': 'polite',
1673
+ title: translate(t, 'dock.mode.unknownTooltip'),
1674
+ style: ST.modeChip,
1675
+ },
1676
+ h('span', { 'aria-hidden': true, style: dotStyle('idle') }),
1677
+ h('span', { style: ST.modeChipLabel }, translate(t, 'card.title')),
1678
+ h('span', { style: ST.dim }, '·'),
1679
+ h('span', { style: ST.modeChipOn }, translate(t, 'dock.mode.unknownLabel')),
1680
+ )
1681
+ }
1682
+ function TowerDock(props) {
1683
+ const t = seatT(props)
1684
+ if (!props || typeof props.useChat !== 'function') return renderDockHint(t)
1685
+ return h(TowerDockLive, Object.assign({}, props, { t }))
1686
+ }
1687
+ function TowerDockLive(props) {
1688
+ const t = seatT(props)
1689
+ const raw = props.useChat(dockSelector)
1690
+ let model = null
1691
+ if (typeof raw === 'string' && raw !== '') {
1692
+ try {
1693
+ model = JSON.parse(raw)
1694
+ } catch (error) {
1695
+ model = null
1696
+ }
1697
+ }
1698
+ const modeFromHistory = isPlainObject(model) && isPlainObject(model.mode) ? model.mode : { known: false, on: null }
1699
+ const stored = modeFromHistory.known !== true ? readStoredMode(props.sessionId) : null
1700
+ const mode = stored
1701
+ ? Object.assign({}, modeFromHistory, stored, { known: true, source: 'stored' })
1702
+ : modeFromHistory
1703
+ writeStoredMode(props.sessionId, modeFromHistory)
1704
+ const writerState = React.useState(function () {
1705
+ return nextDockWriter()
1706
+ })
1707
+ const writer = writerState[0]
1708
+ const modeKey = JSON.stringify(mode)
1709
+ React.useEffect(
1710
+ function () {
1711
+ // Publish only the history-derived stance. A stored last-known value
1712
+ // is display-only: it must not shadow native toolviews when the Host
1713
+ // stance is actually unknown.
1714
+ publishMode(writer, modeFromHistory)
1715
+ return function () {
1716
+ clearMode(writer)
1717
+ }
1718
+ },
1719
+ [JSON.stringify(modeFromHistory)],
1720
+ )
1721
+ if (mode.known === true && mode.on === true) return renderModeChip(t, mode)
1722
+ if (isPlainObject(model) && model.uncertain === true) return renderModeUnknown(t)
1723
+ const summary = isPlainObject(model) ? model.summary : null
1724
+ if (!isPlainObject(summary)) return renderDockHint(t)
1725
+ const text = summary.running === true
1726
+ ? translate(t, 'dock.running', { tool: toolLabel(t, summary.tool) })
1727
+ : summary.key
1728
+ ? translate(t, summary.key, summary.params)
1729
+ : translate(t, 'dock.generic')
1730
+ const tone = summary.running === true ? 'idle' : summary.tone
1731
+ return h(
1732
+ 'div',
1733
+ { 'data-tower-ui': 'dock', style: ST.dock, title: toolLabel(t, summary.tool) + translate(t, 'card.sep') + text },
1734
+ h('span', { 'aria-hidden': true, style: dotStyle(tone) }),
1735
+ h('span', { style: ST.dockTool }, toolLabel(t, summary.tool)),
1736
+ h('span', { style: ST.dim }, '·'),
1737
+ h('span', { style: ST.dockText }, text),
1738
+ )
1739
+ }
1740
+
1741
+ // ---------------------------------------------------------------------
1742
+ // shell.overlay: transient status strip. Root-scoped, so it receives no
1743
+ // chat hooks; the chat-driven toolview publishes into this local
1744
+ // observable and the strip entry injects it. Auto-dismiss is a single
1745
+ // timer owned by `apply`'s ctx.effect.
1746
+ // ---------------------------------------------------------------------
1747
+ let noticeValue = null
1748
+ const noticeListeners = new Set()
1749
+ function noticeSnapshot() {
1750
+ return noticeValue
1751
+ }
1752
+ function subscribeNotice(listener) {
1753
+ noticeListeners.add(listener)
1754
+ return function () {
1755
+ noticeListeners.delete(listener)
1756
+ }
1757
+ }
1758
+ function setNotice(next) {
1759
+ if (Object.is(next, noticeValue)) return
1760
+ noticeValue = next
1761
+ for (const listener of Array.from(noticeListeners)) {
1762
+ try {
1763
+ listener()
1764
+ } catch (error) {
1765
+ /* a broken subscriber must not break the strip */
1766
+ }
1767
+ }
1768
+ }
1769
+ const noticeSource = { getSnapshot: noticeSnapshot, subscribe: subscribeNotice }
1770
+
1771
+ function TowerStatusStrip(props) {
1772
+ const t = seatT(props)
1773
+ if (!props || typeof props.useTowerNotice !== 'function') return null
1774
+ const notice = props.useTowerNotice(function (value) {
1775
+ return value
1776
+ })
1777
+ if (!isPlainObject(notice)) return null
1778
+ const running = notice.running === true
1779
+ const failed = notice.ok === false
1780
+ const tone = running ? 'idle' : failed ? 'error' : 'ok'
1781
+ const text = translate(t, running ? 'strip.started' : failed ? 'strip.failed' : 'strip.finished', {
1782
+ tool: toolLabel(t, notice.tool),
1783
+ })
1784
+ return h(
1785
+ 'div',
1786
+ { 'data-tower-ui': 'status-strip', role: 'status', 'aria-live': 'polite', style: ST.stripWrap },
1787
+ h('div', { style: ST.stripCard }, h('span', { 'aria-hidden': true, style: dotStyle(tone) }), h('span', null, text)),
1788
+ )
1789
+ }
1790
+
1791
+ // ---------------------------------------------------------------------
1792
+ // Plugin body.
1793
+ // ---------------------------------------------------------------------
1794
+ return {
1795
+ inject: ['slots', 'locale'],
1796
+ apply(ctx) {
1797
+ diagnostics.applied = true
1798
+
1799
+ // Locale dictionaries for the slot entries' bound `t` seat.
1800
+ guard('locale', function () {
1801
+ return ctx.effect(
1802
+ function () {
1803
+ return ctx.locale.register(NS, { en: EN, zh: ZH })
1804
+ },
1805
+ 'tower-ui: dictionaries',
1806
+ )
1807
+ })
1808
+
1809
+ // One auto-dismiss timer for the overlay strip, owned by ctx.effect.
1810
+ let dismissTimer = null
1811
+ publishNotice = function (notice) {
1812
+ if (!isPlainObject(notice)) return
1813
+ setNotice(Object.assign({}, notice, { seq: Date.now() }))
1814
+ if (dismissTimer !== null) clearTimeout(dismissTimer)
1815
+ dismissTimer = setTimeout(function () {
1816
+ dismissTimer = null
1817
+ setNotice(null)
1818
+ }, NOTICE_MS)
1819
+ }
1820
+ guard('timer', function () {
1821
+ return ctx.effect(
1822
+ function () {
1823
+ return function () {
1824
+ if (dismissTimer !== null) {
1825
+ clearTimeout(dismissTimer)
1826
+ dismissTimer = null
1827
+ }
1828
+ }
1829
+ },
1830
+ 'tower-ui: status strip timer',
1831
+ )
1832
+ })
1833
+
1834
+ // 1. Keyed per-tool cards. `key` is the wire tool name selected by the
1835
+ // Chat tool-call tree through `entryKey`.
1836
+ for (const tool of TOOL_NAMES) {
1837
+ guard('toolview:' + tool, function () {
1838
+ return ctx.slots.inject(
1839
+ 'tool.call.toolview',
1840
+ guardedCallback('toolview:' + tool, function () {
1841
+ const dispose = ctx.slots.register(
1842
+ {
1843
+ name: 'tool.call.toolview',
1844
+ key: tool,
1845
+ locale: NS,
1846
+ },
1847
+ TowerToolCard,
1848
+ )
1849
+ if (!diagnostics.toolviews.includes(tool)) diagnostics.toolviews.push(tool)
1850
+ recordSlot('tool.call.toolview', { key: tool })
1851
+ return dispose
1852
+ }),
1853
+ )
1854
+ })
1855
+ }
1856
+
1857
+ // 2. Composer dock: hint until this conversation has a Tower tool result.
1858
+ guard('dock', function () {
1859
+ return ctx.slots.inject(
1860
+ 'conversation.composer.dock',
1861
+ guardedCallback('dock', function () {
1862
+ const dispose = ctx.slots.register(
1863
+ {
1864
+ name: 'conversation.composer.dock',
1865
+ id: 'tower-hint',
1866
+ order: 6,
1867
+ locale: NS,
1868
+ },
1869
+ TowerDock,
1870
+ )
1871
+ recordSlot('conversation.composer.dock', { id: 'tower-hint', order: 6 })
1872
+ return dispose
1873
+ }),
1874
+ )
1875
+ })
1876
+
1877
+ // 3. Shell overlay: transient start/result strip, pointer-transparent.
1878
+ guard('overlay', function () {
1879
+ return ctx.slots.inject(
1880
+ 'shell.overlay',
1881
+ guardedCallback('overlay', function () {
1882
+ const dispose = ctx.slots.register(
1883
+ {
1884
+ name: 'shell.overlay',
1885
+ id: 'tower-status-strip',
1886
+ order: 20,
1887
+ locale: NS,
1888
+ inject: function () {
1889
+ return { hooks: { towerNotice: noticeSource } }
1890
+ },
1891
+ },
1892
+ TowerStatusStrip,
1893
+ )
1894
+ recordSlot('shell.overlay', { id: 'tower-status-strip', order: 20 })
1895
+ return dispose
1896
+ }),
1897
+ )
1898
+ })
1899
+
1900
+ // 4. Mode-aware denied-tool rows (#12). The dock publishes the Tower
1901
+ // stance it derives from the displayed session's history; while it
1902
+ // is on, shadow the denied tools' native keyed views at a lower
1903
+ // priority (the slot core renders the lowest priority). Disposing
1904
+ // these entries restores the native views exactly when mode is off.
1905
+ guard('denied-toolviews', function () {
1906
+ return ctx.slots.inject(
1907
+ 'tool.call.toolview',
1908
+ guardedCallback('denied-toolviews', function () {
1909
+ const disposers = new Map()
1910
+ let currentKey = null
1911
+ const reconcile = function () {
1912
+ const mode = modeSnapshot()
1913
+ const active = mode.known === true && mode.on === true
1914
+ const names = active ? deniedNames(mode.denied) : []
1915
+ const nextKey = names.join('\n')
1916
+ if (nextKey === currentKey) return
1917
+ currentKey = nextKey
1918
+ for (const name of Array.from(disposers.keys())) {
1919
+ if (names.includes(name)) continue
1920
+ const dispose = disposers.get(name)
1921
+ disposers.delete(name)
1922
+ try {
1923
+ if (typeof dispose === 'function') dispose()
1924
+ } catch (error) {
1925
+ diagnostics.errors.push({
1926
+ where: 'denied-toolview-dispose:' + name,
1927
+ message: String(error && error.message ? error.message : error),
1928
+ })
1929
+ }
1930
+ }
1931
+ for (const name of names) {
1932
+ if (disposers.has(name)) continue
1933
+ try {
1934
+ disposers.set(
1935
+ name,
1936
+ ctx.slots.register(
1937
+ {
1938
+ name: 'tool.call.toolview',
1939
+ key: name,
1940
+ priority: DENIED_PRIORITY,
1941
+ locale: NS,
1942
+ },
1943
+ DeniedToolRow,
1944
+ ),
1945
+ )
1946
+ } catch (error) {
1947
+ diagnostics.errors.push({
1948
+ where: 'denied-toolview:' + name,
1949
+ message: String(error && error.message ? error.message : error),
1950
+ })
1951
+ }
1952
+ }
1953
+ diagnostics.denied = { active, keys: names.slice() }
1954
+ recordSlot('tool.call.toolview', { denied: names.slice(), priority: DENIED_PRIORITY })
1955
+ }
1956
+ const unsubscribe = subscribeMode(reconcile)
1957
+ reconcile()
1958
+ return function () {
1959
+ try {
1960
+ unsubscribe()
1961
+ } catch (error) {
1962
+ /* already disposed */
1963
+ }
1964
+ for (const dispose of Array.from(disposers.values())) {
1965
+ try {
1966
+ if (typeof dispose === 'function') dispose()
1967
+ } catch (error) {
1968
+ /* registration already gone */
1969
+ }
1970
+ }
1971
+ disposers.clear()
1972
+ currentKey = null
1973
+ }
1974
+ }),
1975
+ )
1976
+ })
1977
+ },
1978
+ }
1979
+ },
1980
+ })