SLOPSHOPPER

tenantree

Connects Claude Code to tenantree: administration of SAP BTP landscapes with approval and audit. Shows the active target (organization, landscape, subaccount)…

bandrowsguardcommandprompttimer
v0.8.27LicenseRef-Proprietaryupdated 2026-10-21tenantree-de/claude-plugin/plugins/tenantree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tenantree
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /tenantree ⎿ tenantree: tenantree is not connected in this session. Run /mcp, select the server tenantree and sign in. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

tenantree für Claude Code

tenantree verbindet Claude Code mit der SAP Business Technology Platform (SAP BTP) Ihres Unternehmens: Landschaften wählen, Anmeldung am eigenen SAP Cloud Identity Services (IAS), Administration der BTP-Dienste mit Freigabe durch Menschen und lückenlosem Protokoll.

Dieses Repository ist der Plugin-Marketplace von tenantree. Es enthält keine kundenspezifische Konfiguration; welche Landschaften Sie sehen, legt Ihr Unternehmen im Tenantree-Portal fest.

Voraussetzungen

Installation

claude plugin marketplace add tenantree-de/claude-plugin
claude plugin install tenantree@tenantree

Alternativ in einer laufenden Claude-Code-Sitzung:

/plugin marketplace add tenantree-de/claude-plugin
/plugin install tenantree@tenantree

Danach Claude Code neu starten, /mcp aufrufen, den Server tenantree auswählen und die Anmeldung starten.

Aktualisieren

claude plugin marketplace update tenantree
claude plugin update tenantree@tenantree

Einrichtung

Die vollständige Einrichtung (SAP Cloud Identity Services, Anwendung „tenantree“, Gruppen, Portal) beschreibt die Anleitung im Portal. Eine Kontextdatei, mit der ein KI-Assistent Sie Schritt für Schritt durch die Einrichtung führt: https://portal.tenantree.de/tenantree-einrichtung-kontext.md

Was das Plugin enthält

  • MCP-Server tenantree (https://portal.tenantree.de/mcp) mit Anmeldung per OAuth
  • Skill mit den Arbeitsregeln (genau ein aktives Ziel, Bestätigung vor Änderungen an Produktionssystemen, keine Geheimnisse im Chat)
  • Anzeige des aktiven Ziels (Organisation, Landschaft, System, Region, Berechtigung) über der Eingabezeile

Kontakt und Rechtliches

Sandner Consulting, Inh. Jonathan Sandner · kontakt@tenantree.de · Impressum · Datenschutz

Die Nutzung von tenantree setzt einen Vertrag mit Sandner Consulting voraus. Siehe LICENSE.

Source 2 files
hooks/register.tsx 1184 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AccountState, ActiveTarget, Attached, CostSection, CostTile, DiagramTile, PlanTile, Row, Target, TargetState } from '../types'
5
6/**
7 * tenantree in Claude Code (operator, 15./16.10.2026):
8 *
9 * THE BAND above the prompt shows organization -> landscape -> subaccount of the active target, a
10 * picker to switch it, the sign-in at the IAS and the open approvals; the tenantree mark sits on
11 * the right.
12 *
13 * THE TILES come AFTER the reply, never in the middle of it -- IN THE TERMINAL ONLY. Since about
14 * 21.10.2026 the Claude desktop app (and VS Code) draws the MCP Apps views of a plugin's server again,
15 * inline at the tool call (observed 21.10.2026 for tenantree_cost_query and a plan preview, Claude
16 * Code 2.1.296; not documented by Anthropic). There the original views are shown and the mod draws
17 * nothing twice. The terminal cannot draw them, so there the mod keeps drawing its tiles.
18 *
19 * NO CALLS OF ITS OWN IN THE BACKGROUND: auto mode refuses a plugin's calls nobody asked for
20 * (measured 16.10.2026). The tiles read the plan from the text of the tool answer; the plugin calls
21 * tenantree only when the person presses a button, and the press is passed on as their request.
22 */
23const target = atom({ plugin: 'tenantree', key: 'target' } as const, { status: 'unknown' } as TargetState)
24const server = atom({ plugin: 'tenantree', key: 'server' } as const, null as string | null)
25const message = atom({ plugin: 'tenantree', key: 'message' } as const, null as string | null)
26const tiles = atom({ plugin: 'tenantree', key: 'tiles' } as const, {} as Record<string, PlanTile>)
27const turn = atom({ plugin: 'tenantree', key: 'turn' } as const, [] as string[])
28const attached = atom({ plugin: 'tenantree', key: 'attached' } as const, [] as Attached[])
29const diagrams = atom({ plugin: 'tenantree', key: 'diagrams' } as const, {} as Record<string, DiagramTile>)
30const costs = atom({ plugin: 'tenantree', key: 'costs' } as const, {} as Record<string, CostTile>)
31const expanded = atom({ plugin: 'tenantree', key: 'expanded' } as const, {} as Record<string, boolean>)
32const signin = atom({ plugin: 'tenantree', key: 'signin' } as const, null as { url: string; system: string | null; at: number } | null)
33// After signing out (17.10.2026): the one-time link that ends the IAS session in the browser.
34const logout = atom({ plugin: 'tenantree', key: 'logout' } as const, null as { url: string; at: number } | null)
35const LOGOUT_LINK = /https:\/\/[^\s)]+\/sso\/signout\/[A-Za-z0-9_-]+/
36/** TEPLU-12: the tenantree account of this connection; signedOut after Sign out or when the server asks for a sign-in. */
37const account = atom({ plugin: 'tenantree', key: 'account' } as const, { status: 'unknown' } as AccountState)
38
39const PREFIX = 'mcp__plugin_tenantree_tenantree__'
40const PORTAL = 'https://portal.tenantree.de'
41const TENANTREE_TOOL = /^mcp__plugin_tenantree_tenantree__([a-z0-9_]+)$/
42const PLAN_LINK = /https:\/\/[^\s"<>\\]+\/plan\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/
43const HANDOVER_LINK = /https:\/\/[^\s"<>\\]+\/secret\/[A-Za-z0-9_-]+/
44const SIGNIN_LINK = /https:\/\/[^\s"<>\\]+\/sso\/start\/[A-Za-z0-9_-]+/
45const SIGNIN_MS = 10 * 60 * 1000
46/** The last line of every tenantree answer names the active target (server, 16.10.2026). */
47const TARGET_LINE = /tenantree target: (.+?) → (.+?) → (.+?) \((production|quality|development|other)\)\s*$/m
48const STORE_KEY = 'lastTarget'
49const PLAN_MS = 15 * 60 * 1000
50const KIND: Record<ActiveTarget['kind'], string> = { production: 'Production', quality: 'Quality', development: 'Development', other: 'Other' }
51const LEVEL: Record<string, string> = { structure: 'Structure', landscape: 'Landscape', account: 'Account' }
52// The tenantree mark (Logo E) for dark surfaces.
53const MARK =
54  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-84 -38 780 780"><g transform="matrix(1.05 0 0 1.05 -0.7 -36)"><path fill="#05A4A0" d="M320 470 Q300 214 556 190 Q576 446 320 470Z"/><rect fill="#FFFFFF" x="224" y="80" width="137" height="640"/><rect fill="#0078CF" x="14" y="20" width="556" height="125"/></g></svg>'
55
56const textOf = (r: unknown): string => {
57  if (typeof r === 'string') return r
58  if (Array.isArray(r)) return r.map(textOf).join('\n')
59  if (r && typeof r === 'object') {
60    const o = r as Record<string, unknown>
61    if (typeof o.text === 'string') return o.text
62    if ('content' in o) return textOf(o.content)
63    if ('result' in o) return textOf(o.result)
64  }
65  return ''
66}
67
68/** A field of the structured content, which Claude Code hands a plugin as JSON text (measured 15.10.2026). */
69function fieldOf(value: unknown, name: string, depth = 0): unknown {
70  if (typeof value === 'string' && value.startsWith('{') && depth <= 4) {
71    try {
72      return fieldOf(JSON.parse(value), name, depth + 1)
73    } catch {
74      return undefined
75    }
76  }
77  if (!value || typeof value !== 'object' || depth > 4) return undefined
78  const v = value as Record<string, unknown>
79  if (name in v) return v[name]
80  for (const child of Object.values(v)) {
81    const found = fieldOf(child, name, depth + 1)
82    if (found !== undefined) return found
83  }
84  return undefined
85}
86
87// --- The plan text ----------------------------------------------------------
88
89const FACT = /^([A-Z][A-Za-z ()/-]{1,28}):\s+(\S.*)$/
90const FOR_THE_MODEL = /^(Repeat the same call|APPROVAL REQUIRED|On this installation|Approval page|Give the person|Do not call again|COST CONFIRMATION)/
91// THE COST NOTICE (operator, 21.10.2026): the marker of a plan with costs or unchecked costs, and the head of its statement.
92const COST_MARK = /^Cost confirmation required \((paid|unclear)\):/
93const COST_HEAD = /^(Cost \(cost calculator\):|Cost:|Cost of |Cost afterwards|Cost while running|Afterwards:|From then on)/
94const COST_TEXT = {
95  title: 'Cost notice',
96  lead: {
97    paid: 'This plan can incur costs. Please review the cost assessment before you approve it.',
98    unclear: 'The costs of this plan have not been checked. tenantree cannot rule out that costs arise.',
99  },
100  confirm: {
101    paid: 'I have read the cost notice and confirm that costs may arise.',
102    unclear: 'I have read the cost notice and confirm that the costs have not been checked and may arise.',
103  },
104  required: 'Approval is possible only after you have confirmed the cost notice.',
105} as const
106
107/** The cost notice of a plan text: its kind (unclear wins) and the statements above its markers. */
108function costOfPlan(text: string): PlanTile['cost'] {
109  const lines = text.split('\n')
110  let kind: 'paid' | 'unclear' | null = null
111  const parts: string[] = []
112  for (let i = 0; i < lines.length; i++) {
113    const m = COST_MARK.exec((lines[i] ?? '').trim())
114    if (!m) continue
115    kind = kind === 'unclear' || m[1] === 'unclear' ? 'unclear' : 'paid'
116    const block: string[] = []
117    for (let j = i - 1; j >= 0; j--) {
118      const t = (lines[j] ?? '').trim()
119      if (!t || /^Plan:/.test(t) || /^Step \d+:/.test(t) || COST_MARK.test(t)) break
120      block.unshift(t)
121    }
122    const head = block.findIndex(l => COST_HEAD.test(l))
123    parts.push((head >= 0 ? block.slice(head) : block.filter(l => /EUR|cost/i.test(l))).join('\n'))
124  }
125  return kind ? { kind, text: parts.filter(Boolean).join('\n\n') } : null
126}
127const LEFT_OUT = new Set(['Plan', 'Tool', 'Enabled because'])
128
129/** The plan as its text states it: what the tile shows. */
130function planOf(text: string): Omit<PlanTile, 'planId' | 'url' | 'expiresAt' | 'status'> {
131  const lines = text.split('\n')
132  const facts: Row[] = []
133  const values: Row[] = []
134  const warnings: string[] = []
135  let title = ''
136  let level: string | null = null
137  let target: string | null = null
138  let headline = ''
139  for (let i = 0; i < lines.length; i++) {
140    const t = (lines[i] ?? '').trim()
141    if (!t || FOR_THE_MODEL.test(t) || COST_MARK.test(t)) continue
142    if (!headline) {
143      headline = t
144      continue
145    }
146    if (t === 'With:') {
147      const block: string[] = []
148      for (i++; i < lines.length && (lines[i] ?? '').trim(); i++) block.push(lines[i] ?? '')
149      try {
150        for (const [k, v] of Object.entries(JSON.parse(block.join('\n')) as Record<string, unknown>)) values.push({ label: k, value: typeof v === 'string' ? v : JSON.stringify(v) })
151      } catch {
152        // Not JSON: left out of the tile; the reply names it.
153      }
154      continue
155    }
156    const plan = /^Plan:\s+(.*?)(?:\s+\(write level (\w+)\))?$/.exec(t)
157    if (plan) {
158      title = plan[1] ?? ''
159      level = plan[2] ?? null
160      continue
161    }
162    if (/^(WARNING|Warning|TRAP)[:\s]/.test(t)) {
163      warnings.push(t.replace(/^(WARNING|Warning|TRAP):?\s*/, ''))
164      continue
165    }
166    const fact = FACT.exec(t)
167    if (fact && fact[1] && fact[2]) {
168      if (fact[1] === 'Tenant') target = fact[2]
169      else if (!LEFT_OUT.has(fact[1])) facts.push({ label: fact[1], value: fact[2] })
170    }
171  }
172  return { title: title || 'Plan', level, target, headline, facts, values, warnings, cost: costOfPlan(text) }
173}
174
175// --- The cost text -----------------------------------------------------------
176
177const VIEW_LINK = /Graphical view in the Tenantree Dashboard: (https:\/\/\S+)/
178const HEADING = /^[A-Z][A-Z ()/-]{2,}$/
179
180/** The cost assessment as its text states it, by section (THE ACCOUNT NOW, COST RISKS, NOT CHECKED, ...). */
181function costOf(text: string): CostTile {
182  const sections: CostSection[] = []
183  const risks: string[] = []
184  const unchecked: string[] = []
185  let current: CostSection | null = null
186  for (const raw of text.split('\n')) {
187    const t = raw.trim()
188    if (!t || TARGET_LINE.test(t) || VIEW_LINK.test(t) || /^Prices:/.test(t)) continue
189    if (HEADING.test(t)) {
190      current = { name: t.charAt(0) + t.slice(1).toLowerCase(), rows: [], lines: [] }
191      if (!/^COST RISKS$|^NOT CHECKED$/.test(t)) sections.push(current)
192      continue
193    }
194    if (!current) continue
195    if (/^Cost risks$/.test(current.name)) risks.push(t)
196    else if (/^Not checked$/.test(current.name)) unchecked.push(t)
197    else {
198      const m = /^([^:]{2,60}):\s+(.+)$/.exec(t)
199      if (m && m[1] && m[2] && !/^https?$/.test(m[1])) current.rows.push({ label: m[1], value: m[2] })
200      else current.lines.push(t)
201    }
202  }
203  return { sections, risks, unchecked, url: VIEW_LINK.exec(text)?.[1] ?? null }
204}
205
206// --- The diagram ---------------------------------------------------------------
207
208/** The diagram as its text states it: layers with their nodes, the connections, findings and notes. */
209function diagramOf(text: string): DiagramTile {
210  const lines = text.split('\n')
211  const tile: DiagramTile = { title: 'Landscape', layers: [], edges: [], findings: [], notes: [], url: VIEW_LINK.exec(text)?.[1] ?? null }
212  let mode: 'none' | 'layers' | 'edges' | 'findings' | 'notes' = 'none'
213  for (const raw of lines) {
214    const t = raw.trim()
215    if (/^```/.test(t) || /^Mermaid:/.test(t)) { mode = 'none'; if (/^Mermaid:/.test(t)) break; continue }
216    const title = /^Architecture diagram:\s*(.+)$/.exec(t)
217    if (title?.[1]) { tile.title = title[1]; continue }
218    if (/^Layers/.test(t)) { mode = 'layers'; continue }
219    if (/^Connections/.test(t)) { mode = 'edges'; continue }
220    if (/^Findings/.test(t)) { mode = 'findings'; continue }
221    if (/^Notes/.test(t)) { mode = 'notes'; continue }
222    if (!t || TARGET_LINE.test(t) || VIEW_LINK.test(t)) continue
223    if (mode === 'layers') {
224      const layer = /^\d+\.\s+(.+)$/.exec(t)
225      if (layer?.[1]) { tile.layers.push({ name: layer[1], nodes: [] }); continue }
226      const node = /^-\s+(.+)$/.exec(t)
227      const current = tile.layers[tile.layers.length - 1]
228      if (node?.[1] && current) {
229        const [name, ...rest] = node[1].split(' -- ')
230        current.nodes.push({ name: (name ?? '').trim(), detail: rest.length ? [rest.join(' -- ').trim()] : [] })
231        continue
232      }
233      const last = current?.nodes[current.nodes.length - 1]
234      if (last) last.detail.push(t)
235    } else if (mode === 'edges') {
236      const m = /^(.+?)\s*-(?:&gt;|>)\s*(.+?)(?:\s*\(([^)]*)\))?$/.exec(t)
237      if (m?.[1] && m[2]) tile.edges.push({ from: m[1].trim(), to: m[2].trim(), label: m[3] ?? null })
238    } else if (mode === 'findings') {
239      if (!/^None\.?$/.test(t)) tile.findings.push(t.replace(/^-\s*/, ''))
240    } else if (mode === 'notes') tile.notes.push(t.replace(/^-\s*/, ''))
241  }
242  return tile
243}
244
245const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
246const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s)
247
248/** The blocks of the second layer of a tenant diagram, in drawing order. */
249const GROUPS = ['SAP subscriptions', 'tenantree access', 'Other instances'] as const
250type Group = (typeof GROUPS)[number]
251
252/**
253 * Sorts the nodes of a layer into blocks: SAP subscriptions (reached by a connection labelled
254 * 'subscribed'), tenantree's own access (named tenantree-… or tenantree_…) and other instances.
255 * Null when no node is a subscription or a tenantree access: the layer keeps one box per node.
256 */
257function groupsOf(nodes: DiagramTile['layers'][number]['nodes'], edges: DiagramTile['edges']): Map<Group, typeof nodes> | null {
258  const subscribed = new Set(edges.filter(e => /^subscribed$/i.test((e.label ?? '').trim())).map(e => e.to))
259  const groupOf = (name: string): Group => (subscribed.has(name) ? 'SAP subscriptions' : /^tenantree[-_]/i.test(name) ? 'tenantree access' : 'Other instances')
260  if (!nodes.some(n => groupOf(n.name) !== 'Other instances')) return null
261  const out = new Map<Group, typeof nodes>()
262  for (const g of GROUPS) {
263    const members = nodes.filter(n => groupOf(n.name) === g)
264    if (members.length) out.set(g, members)
265  }
266  return out
267}
268
269/**
270 * The diagram as SVG: one column per layer, a box per node (in blocks where the layer groups),
271 * curved connections in the tenantree colours.
272 *
273 * Colour scheme: a render hook learns the surface (desktop, mobile, editor) and its size, not the
274 * person's light or dark theme ($.config's 'theme' row is the terminal's setting, not the app's).
275 * The SVG therefore adapts itself: presentation attributes carry the light palette, and an embedded
276 * <style> overrides them under @media (prefers-color-scheme: dark), which an SVG drawn as an image
277 * evaluates against the app's own appearance. Should a surface ever drop the <style>, the light
278 * palette remains complete and legible on its own background. 'light' and 'dark' force one scheme.
279 */
280function diagramSvg(d: DiagramTile, scheme: 'auto' | 'light' | 'dark' = 'auto'): { source: string; width: number; height: number } {
281  const MAX_WIDTH = 1100, GAP_X = 64, TOP = 34, PAD = 16, INSET = 10, HEAD = 22, GROUP_GAP = 14
282  const NAME_PX = 7.2, DETAIL_PX = 6.4, TEXT_PAD = 24, MIN_W = 200, MAX_W = 320
283  const layers = d.layers.map((layer, li) => ({ layer, groups: li > 0 ? groupsOf(layer.nodes, d.edges) : null }))
284
285  // Column widths from the longest text; past the overall cap the widest columns give way first.
286  const wanted = layers.map(({ layer, groups }) => {
287    const text = Math.max(0, ...layer.nodes.map(n => Math.max(n.name.length * NAME_PX, (n.detail[0] ?? '').length * DETAIL_PX)))
288    return Math.min(MAX_W, Math.max(MIN_W, Math.ceil(text + TEXT_PAD))) + (groups ? 2 * INSET : 0)
289  })
290  const room = MAX_WIDTH - 2 * PAD - Math.max(0, layers.length - 1) * GAP_X
291  let ceiling = Math.max(...wanted, 0)
292  while (ceiling > 120 && wanted.reduce((s, w) => s + Math.min(w, ceiling), 0) > room) ceiling -= 4
293  const widths = wanted.map(w => Math.min(w, ceiling))
294
295  // Vertical extent of each column: plain boxes, or blocks of compact boxes.
296  const H = 50, GAP_Y = 12, HG = 44, GAP_G = 8
297  const extent = layers.map(({ layer, groups }) =>
298    groups
299      ? [...groups.values()].reduce((s, ns) => s + HEAD + ns.length * (HG + GAP_G) - GAP_G + 2 * INSET, 0) + (groups.size - 1) * GROUP_GAP
300      : layer.nodes.length * (H + GAP_Y) - GAP_Y,
301  )
302  const inner = Math.max(H, ...extent)
303  const height = TOP + inner + PAD + 4
304  const width = PAD * 2 + widths.reduce((s, w) => s + w, 0) + Math.max(0, layers.length - 1) * GAP_X
305
306  const anchor = new Map<string, { left: number; right: number; mid: number }>()
307  const block = new Map<string, { left: number; mid: number }>()
308  const parts: string[] = []
309  const box = (x: number, y: number, w: number, h: number, n: DiagramTile['layers'][number]['nodes'][number], first: boolean) => {
310    const chars = (px: number) => Math.max(4, Math.floor((w - TEXT_PAD) / px))
311    const nameY = h >= 50 ? 21 : 19, detailY = h >= 50 ? 38 : 34
312    parts.push(
313      `<rect class="${first ? 'box1' : 'box'}" x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="${first ? '#e1f4ff' : '#ffffff'}" stroke="${first ? '#0078cf' : '#05a4a0'}" stroke-width="1.5"/>`,
314      `<text class="name" x="${x + 12}" y="${y + nameY}" font-size="13" font-weight="600" fill="#1d2d3e">${esc(cut(n.name, chars(NAME_PX)))}</text>`,
315      `<text class="detail" x="${x + 12}" y="${y + detailY}" font-size="11" fill="#556b82">${esc(cut(n.detail[0] ?? '', chars(DETAIL_PX)))}</text>`,
316    )
317    anchor.set(n.name, { left: x, right: x + w, mid: y + h / 2 })
318  }
319
320  let x = PAD
321  layers.forEach(({ layer, groups }, li) => {
322    const w = widths[li] ?? MIN_W
323    let y = TOP + (inner - (extent[li] ?? 0)) / 2
324    parts.push(`<text class="head" x="${x}" y="20" font-size="12" font-weight="600" fill="#758ca4">${esc(cut(layer.name.toUpperCase(), Math.floor(w / 7.6)))}</text>`)
325    if (groups) {
326      for (const [g, ns] of groups) {
327        const h = HEAD + ns.length * (HG + GAP_G) - GAP_G + 2 * INSET
328        parts.push(
329          `<rect class="group" x="${x}" y="${y}" width="${w}" height="${h}" rx="10" fill="#eef1f4" stroke="#c9d2db" stroke-width="1"/>`,
330          `<text class="ghead" x="${x + INSET + 2}" y="${y + INSET + 11}" font-size="11" font-weight="600" fill="#556b82">${esc(g)} (${ns.length})</text>`,
331        )
332        ns.forEach((n, ni) => box(x + INSET, y + INSET + HEAD + ni * (HG + GAP_G), w - 2 * INSET, HG, n, false))
333        for (const n of ns) block.set(n.name, { left: x, mid: y + h / 2 })
334        y += h + GROUP_GAP
335      }
336    } else {
337      layer.nodes.forEach((n, ni) => box(x, y + ni * (H + GAP_Y), w, H, n, li === 0))
338    }
339    x += w + GAP_X
340  })
341
342  // One connection per source and block where the target sits in a block, otherwise one per connection.
343  const drawn = new Set<string>()
344  const lines = d.edges
345    .map(e => {
346      const a = anchor.get(e.from), grouped = block.get(e.to), b = grouped ?? anchor.get(e.to)
347      if (!a || !b) return ''
348      const key = `${e.from}\u0000${grouped ? `${b.left}:${b.mid}` : e.to}`
349      if (drawn.has(key)) return ''
350      drawn.add(key)
351      const x1 = a.right, y1 = a.mid, x2 = b.left, y2 = b.mid, mx = (x1 + x2) / 2
352      return `<path class="edge" d="M${x1} ${y1} C${mx} ${y1} ${mx} ${y2} ${x2 - 6} ${y2}" fill="none" stroke="#758ca4" stroke-width="1.2" marker-end="url(#arrow)"/>`
353    })
354    .join('')
355
356  const dark = '.bg{fill:#1d232a}.box{fill:#28313a;stroke:#05a4a0}.box1{fill:#28313a;stroke:#4db1ff}.name{fill:#eaecee}.detail,.head,.ghead{fill:#a9b4be}.group{fill:#222a32;stroke:#3a4652}.edge{stroke:#758ca4}.arrow{fill:#758ca4}'
357  const style = scheme === 'light' ? '' : `<style>${scheme === 'dark' ? dark : `@media (prefers-color-scheme: dark){${dark}}`}</style>`
358  const source =
359    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" font-family="Inter, Segoe UI, Arial, sans-serif">` +
360    style +
361    `<defs><marker id="arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path class="arrow" d="M0 0 L8 4 L0 8 z" fill="#758ca4"/></marker></defs>` +
362    `<rect class="bg" width="${width}" height="${height}" rx="10" fill="#f5f6f7"/>${lines}${parts.join('')}</svg>`
363  return { source, width, height }
364}
365
366/** What the diagram shows, for a reader that cannot see it: layers, their blocks and nodes, and the findings. */
367function diagramAlt(d: DiagramTile): string {
368  const layers = d.layers.map((l, li) => {
369    const groups = li > 0 ? groupsOf(l.nodes, d.edges) : null
370    const content = groups ? [...groups].map(([g, ns]) => `${g}: ${ns.map(n => n.name).join(', ')}`).join('; ') : l.nodes.map(n => n.name).join(', ')
371    return `${l.name}: ${content}`
372  })
373  const findings = d.findings.length ? `${d.findings.length} finding${d.findings.length === 1 ? '' : 's'}` : 'No findings'
374  return `Architecture diagram ${d.title}. ${layers.join('. ')}. ${findings}.`
375}
376
377// --- Target and sign-in -----------------------------------------------------
378
379/**
380 * The picker labels come from the list of targets, which only the landscape query sends. When an
381 * answer names the active target with other names (a landscape was renamed in the portal), the
382 * active entry of the list follows, so that the band does not keep showing the old name.
383 */
384function withActive(targets: Target[] | undefined, t: Pick<ActiveTarget, 'organization' | 'landscape' | 'system'>): Target[] | undefined {
385  if (!targets?.length) return targets
386  const i = targets.findIndex(x => x.active)
387  if (i < 0) return targets
388  const a = targets[i]!
389  if (a.organization === t.organization && a.landscape === t.landscape && a.system === t.system) return targets
390  return targets.map((x, k) => (k === i ? { ...x, organization: t.organization, landscape: t.landscape, system: t.system } : x))
391}
392
393async function remember($: EngineInterface, result: unknown) {
394  const zone = fieldOf(result, 'timeZone')
395  if (typeof zone === 'string' && zone) personZone = zone
396  const found = fieldOf(result, 'activeTarget') as ActiveTarget | null | undefined
397  if (found === undefined) {
398    // An SAP tool's answer names the target in its last line: enough for the band, without a call.
399    const m = TARGET_LINE.exec(textOf(result))
400    if (!m) return
401    const [, organization = '', landscape = '', system = '', kind = 'other'] = m
402    const at = await $.clock.now()
403    await update($, target, (prev): TargetState => {
404      const before = prev.status === 'known' ? prev : undefined
405      const same = before?.target && before.target.organization === organization && before.target.landscape === landscape && before.target.system === system
406      // The line comes only with an answer that went through: the person is admitted there.
407      if (same && before?.target) return before.target.right === 'none' || !before.target.signedIn ? { ...before, target: { ...before.target, right: 'user', signedIn: true } } : before
408      return { status: 'known', target: { organization, landscape, system, kind: kind as ActiveTarget['kind'], region: '', subaccountId: null, signedIn: true, right: 'user' }, account: before?.account, targets: withActive(before?.targets, { organization, landscape, system }), at }
409    })
410    await update($, message, m => (m && !m.endsWith('…') ? null : m))
411    await update($, signin, () => null)
412    await keep($)
413    return
414  }
415  const signedInAs = fieldOf(result, 'signedInAs') as string | undefined
416  const organizations = fieldOf(result, 'organizations') as Array<{ name: string; role: 'admin' | 'user' }> | undefined
417  if (signedInAs) {
418    await update($, account, (): AccountState => ({ status: 'signedIn', email: signedInAs, organizations: organizations ?? [] }))
419    await update($, logout, () => null)
420    // Signed in (again): a sign-in hint in the band is out of date (operator, 17.10.2026).
421    await update($, message, m => (m && SIGNIN_NOTES.some(n => m.startsWith(n)) ? null : m))
422  }
423  // NOTICES LEAVE WHEN THINGS WORKED (operator, 17.10.2026): an answer that went through clears the band's error
424  // or hint line (progress lines ending in '…' stay until their action ends); signed in at the IAS of the active
425  // target clears the pending IAS sign-in.
426  await update($, message, m => (m && !m.endsWith('…') ? null : m))
427  if (found?.signedIn) await update($, signin, () => null)
428  const targets = fieldOf(result, 'targets') as Target[] | undefined
429  const at = await $.clock.now()
430  await update($, target, (prev): TargetState => {
431    const before = prev.status === 'known' ? prev : undefined
432    return { status: 'known', target: found, account: signedInAs ?? before?.account, targets: targets ?? (found ? withActive(before?.targets, found) : before?.targets), at }
433  })
434  await keep($)
435}
436
437/** The target outlives the session (it lives per person on the server anyway): the band stands at once. */
438async function keep($: EngineInterface) {
439  const now = await read($, target)
440  if (now.status === 'known') await $.store.set(STORE_KEY, now).catch(() => {})
441}
442
443async function serverOf($: EngineInterface): Promise<string | null> {
444  const known = await read($, server)
445  if (known) return known
446  const connected = await $.mcp.connect('tenantree')
447  if (!connected.isConnected) {
448    // The server asks for a sign-in: the band offers it (TEPLU-12).
449    if (connected.reason === 'auth') {
450      await update($, account, (): AccountState => ({ status: 'signedOut' }))
451      void watchSignIn($)
452    }
453    return null
454  }
455  await update($, server, () => connected.server)
456  return connected.server
457}
458
459/** Reads the target (read-only tools; at session start and when the person comes back). */
460async function refresh($: EngineInterface) {
461  const name = await serverOf($)
462  if (!name) return
463  const profile = await $.mcp.call(name, 'tenantree_profile_read', {})
464  if (profile.isError && NEEDS_SIGNIN.test(textOf(profile))) return signedOut($)
465  if (!profile.isError) await remember($, profile)
466  const landscapes = await $.mcp.call(name, 'tenantree_landscape_query', {})
467  if (!landscapes.isError) await remember($, landscapes)
468}
469
470/** Reads the list of subaccounts for the picker (read-only), at most every 30 seconds. */
471let lastTargets = 0
472async function loadTargets($: EngineInterface) {
473  const now = await $.clock.now()
474  if (now - lastTargets < 30_000) return
475  lastTargets = now
476  const name = await serverOf($)
477  if (!name) return
478  const landscapes = await $.mcp.call(name, 'tenantree_landscape_query', {})
479  if (!landscapes.isError) await remember($, landscapes)
480}
481
482/** A call the person asked for with a button: the press goes along as their request. */
483async function pressed($: EngineInterface, tool: string, args: Record<string, unknown>, what: string, consent?: string) {
484  const r = (await $.tool.call({ tool: `${PREFIX}${tool}`, ...args, consent: consent ?? `The user pressed "${what}" in the tenantree band.` } as never)) as unknown
485  if (r && typeof r === 'object' && 'deny' in r) throw new Error(String((r as { deny: unknown }).deny))
486  return r as { text?: string; isError?: boolean; result?: unknown }
487}
488
489/**
490 * THE TILE FOLLOWS ITS PLAN BY ITSELF (operator, 16.10.2026): while a plan is open, the tile asks every
491 * few seconds whether it was decided -- on the approval page, in another tile, or not at all. The read
492 * tool is marked read-only, so auto mode lets it through without a request of the person.
493 */
494let ticking: { cancel: () => void } | null = null
495let lastTick = 0
496const POLL_MS = 5000
497
498async function check($: EngineInterface, id: string) {
499  const tile = (await read($, tiles))[id]
500  if (!tile || tile.status !== 'open') return
501  const name = await serverOf($)
502  if (!name) return
503  const r = await $.mcp.call(name, 'tenantree_plan_read', { planId: tile.planId })
504  if (r.isError) return
505  const status = String(fieldOf(r, 'status') ?? '')
506  if (status === 'open' || !status) return
507  const get = <T,>(k: string) => fieldOf(r, k) as T | undefined
508  await update($, tiles, all => ({
509    ...all,
510    [id]: {
511      ...tile,
512      code: undefined,
513      status: (status === 'decided' ? 'decided' : 'closed') as PlanTile['status'],
514      decision: get<'approve' | 'reject'>('decision'),
515      by: get<string | null>('by'),
516      ok: get<boolean>('ok'),
517      at: get<number>('at'),
518      first: get<string>('first'),
519    },
520  }))
521}
522
523async function tick($: EngineInterface) {
524  const now = await $.clock.now()
525  lastTick = now
526  const open = Object.entries(await read($, tiles)).filter(([, p]) => p.status === 'open' && now <= p.expiresAt + POLL_MS)
527  if (!open.length) {
528    ticking?.cancel()
529    ticking = null
530    return
531  }
532  for (const [id] of open) await check($, id).catch(() => {})
533}
534
535async function follow($: EngineInterface) {
536  // A refused period ends an interval: a timer that has not ticked for two periods is started anew.
537  const now = await $.clock.now()
538  if (ticking && now - lastTick < 2 * POLL_MS) return
539  ticking?.cancel()
540  lastTick = now
541  ticking = $.clock.every(POLL_MS, () => void tick($).catch(() => {}))
542}
543
544/** The approval code of a call, where Claude Code hands _meta "claude/plugins" to plugins (way B, 16.10.2026). */
545function codeOf(meta: unknown): string | undefined {
546  const plugins = (meta as Record<string, unknown> | undefined)?.['claude/plugins'] as Record<string, unknown> | undefined
547  const plan = (plugins?.tenantree as { plan?: { code?: unknown } } | undefined)?.plan
548  return typeof plan?.code === 'string' ? plan.code : undefined
549}
550
551/** Approve or reject on the person's press, with the code only the tile holds. */
552async function decide($: EngineInterface, id: string, decision: 'approve' | 'reject') {
553  const tile = (await read($, tiles))[id]
554  if (!tile?.code) return
555  await update($, tiles, all => ({ ...all, [id]: { ...tile, busy: decision } }))
556  try {
557    // A plan with costs is approved only with the confirmed cost notice; the server checks it again (21.10.2026).
558    if (decision === 'approve' && tile.cost && !tile.costAck) {
559      await update($, tiles, all => ({ ...all, [id]: { ...tile, busy: undefined } }))
560      return
561    }
562    const args: Record<string, unknown> = { planId: tile.planId, code: tile.code, decision }
563    if (decision === 'approve' && tile.cost && tile.costAck) args.costConfirmed = true
564    const r = await pressed($, 'tenantree_plan_decide', args, decision === 'approve' ? 'Approve' : 'Reject')
565    const body = r.text ?? textOf(r)
566    const at = await $.clock.now()
567    const closed = Boolean(r.isError) && /no longer open/.test(body)
568    await update($, tiles, all => ({
569      ...all,
570      [id]: {
571        ...tile,
572        busy: undefined,
573        code: undefined,
574        // "Not carried out: ..." is the decide tool's own refusal; any other error is the result of the approved operation.
575        status: (closed ? 'closed' : r.isError && /^Not carried out:/.test(body) ? 'unknown' : 'decided') as PlanTile['status'],
576        decision,
577        ok: !r.isError,
578        at,
579        first: body.split('\n')[0],
580        handover: HANDOVER_LINK.exec(body)?.[0] ?? null,
581        error: r.isError ? body.split('\n')[0] : undefined,
582      },
583    }))
584  } catch (err) {
585    await update($, tiles, all => ({ ...all, [id]: { ...tile, busy: undefined, status: 'unknown' as const, error: String((err as Error)?.message ?? err) } }))
586  }
587}
588
589/**
590 * THE PERSON SIGNS OUT IN THE PORTAL (TEPLU-12, TEN-537, 17.10.2026): the auto mode of Claude Code gives no
591 * verdict for a sign-out the plugin calls itself, so Sign out opens the connections of the profile in the
592 * portal. The band notices the sign-out with the next answer that asks for a sign-in.
593 */
594const NEEDS_SIGNIN = /Sign-in required|\b401\b|Unauthorized|needs? (?:a )?(?:new )?(?:sign-in|authentication)/i
595
596/**
597 * SIGN OUT AS THE PERSON'S OWN REQUEST (operator, 17.10.2026). The button and "/tenantree signout" put
598 * "Sign me out of tenantree." into the prompt box; the person sends it with Enter, and Claude signs the
599 * connection out because the person asked for it. The guard lets the model's call through only after the
600 * person themselves sent such a request.
601 */
602let signOutRequestedAt = 0
603const SIGNOUT_WINDOW_MS = 3 * 60 * 1000
604/** A button's call that Claude Code's Auto permission mode refused (operator, 17.10.2026): the band says so. */
605const AUTO_BLOCKED = 'The Auto permission mode blocks this action. Please switch the permission mode and press the button again.'
606const blockedByAuto = (err: unknown) => /auto mode|classifier|no verdict|permission/i.test(String((err as Error)?.message ?? err))
607
608const LOGIN_CONTEXT =
609  'The person wrote "tenantree login". 1) Call tenantree_landscape_query. 2) If there is no active target and exactly one system is listed, set it with tenantree_landscape_use (the person agreed to this for the single-system case); if several are listed, ask which one. 3) If the person is not signed in at the IAS of the active target, call tenantree_landscape_signin and give the link. Answer briefly: account, active target, IAS sign-in state. If the tenantree tools are not available, tell the person to run /mcp, select tenantree and choose Authenticate, then to write "tenantree login" again.'
610const SIGNOUT_PROMPT = 'Sign me out of tenantree.'
611const ASKS_SIGNOUT = /\bsign (?:me )?out\b.*\btenantree\b|\btenantree\b.*\bsign (?:me )?out\b/i
612
613async function signOut($: EngineInterface) {
614  await update($, message, () => 'Signing out …')
615  signOutRequestedAt = await $.clock.now()
616  try {
617    const r = await pressed($, 'tenantree_session_delete', {}, 'Sign out', 'The user pressed the "Sign out" button in the tenantree band and asks to sign this connection out of tenantree.')
618    if (r.isError) throw new Error(r.text ?? textOf(r))
619    await signedOut($)
620    await showLogout($, r.text ?? textOf(r))
621    await update($, message, () => null)
622  } catch (err) {
623    signOutRequestedAt = 0
624    await update($, message, () => (blockedByAuto(err) ? AUTO_BLOCKED : 'The sign-out was not carried out.'))
625  }
626}
627
628/**
629 * THE BAND FOLLOWS THE SIGN-IN BY ITSELF (operator, 17.10.2026): while signed out, the plugin asks Claude Code
630 * every 5 seconds whether the tenantree connection is back (no tool call); once it is, it reads the profile.
631 */
632let watching: { cancel: () => void } | null = null
633let lastWatch = 0
634const WATCH_MS = 5000
635
636/**
637 * THE CONNECTION STAYS (operator, 17.10.2026): Claude Code drops an MCP server whose connection broke (for
638 * example while tenantree is redeployed) and does not dial it again by itself. The plugin dials it on a timer;
639 * when it comes back, or the person signed in meanwhile, the band reads the state again. Connecting calls no tool.
640 */
641let wasConnected = true
642async function checkSignIn($: EngineInterface) {
643  lastWatch = await $.clock.now()
644  const c = await $.mcp.connect('tenantree').catch(() => null)
645  const before = wasConnected
646  wasConnected = Boolean(c?.isConnected)
647  if (!c?.isConnected) return
648  if (!before || (await read($, account)).status !== 'signedIn') {
649    await update($, server, () => c.server)
650    await refresh($).catch(() => {})
651  }
652}
653
654/**
655 * Started whenever the band shows "Not signed in" (also after a reload of the plugin, which ends timers), and
656 * started anew when Claude Code let a period pass without a tick.
657 */
658async function watchSignIn($: EngineInterface) {
659  const now = await $.clock.now()
660  if (watching && now - lastWatch < 2 * WATCH_MS) return
661  watching?.cancel()
662  lastWatch = now
663  watching = $.clock.every(WATCH_MS, () => void checkSignIn($).catch(() => {}))
664}
665
666async function showLogout($: EngineInterface, text: string) {
667  const url = LOGOUT_LINK.exec(text)?.[0]
668  const at = await $.clock.now()
669  await update($, logout, () => (url ? { url, at } : null))
670}
671
672async function signedOut($: EngineInterface) {
673  void watchSignIn($)
674  await update($, account, (): AccountState => ({ status: 'signedOut' }))
675  await update($, target, (): TargetState => ({ status: 'unknown' }))
676  await update($, server, () => null)
677  await update($, signin, () => null)
678  await $.store.set(STORE_KEY, null).catch(() => {})
679}
680
681/** THE PERSON SIGNS IN: Claude Code holds the OAuth sign-in; connecting starts it where the host can. */
682async function signIn($: EngineInterface) {
683  await update($, message, () => 'Connecting to tenantree …')
684  const connected = await $.mcp.connect('tenantree').catch(() => null)
685  if (connected?.isConnected) {
686    await update($, server, () => connected.server)
687    await refresh($).catch(() => {})
688  }
689  const now = await read($, account)
690  if (now.status === 'signedIn') return update($, message, () => null)
691  // Claude Code starts the OAuth sign-in only from /mcp (measured 17.10.2026): the band puts the command into
692  // the prompt box, so one Enter opens it. The person keeps the choice; nothing is sent by itself.
693  const filled = await $.prompt.fill({ text: '/mcp' }).catch(() => null)
694  await update($, message, () => (filled?.isFilled ? SIGNIN_READY : SIGNIN_HELP))
695}
696const SIGNIN_NOTES = ['/mcp is in the prompt box', 'To sign in,', 'Connecting to tenantree']
697const SIGNIN_READY = '/mcp is in the prompt box: press Enter, select tenantree and choose Authenticate. Afterwards write: tenantree login'
698let lastStatus = 0
699const STATUS_MS = 30_000
700const SIGNIN_HELP = 'To sign in, run /mcp, select tenantree and choose Authenticate. Afterwards write: tenantree login'
701
702const path = (t: { organization: string; landscape: string; system: string }) => `${t.organization} → ${t.landscape} → ${t.system}`
703/**
704 * TEN-375 / TEPLU-6: SAP BTP time (UTC) and the person's time zone from the profile (the server
705 * names it in its answers), one time when both are equal. Without a known zone, UTC alone.
706 */
707let personZone: string | null = null
708const time = (ms?: number) => {
709  if (!ms) return ''
710  const hm = (tz: string) => new Date(ms).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: tz })
711  const utc = hm('UTC')
712  try {
713    const local = personZone ? hm(personZone) : utc
714    return local === utc ? `${utc} UTC` : `${utc} UTC (${local} ${personZone})`
715  } catch {
716    return `${utc} UTC`
717  }
718}
719const normal = (s: string) => s.replace(/\s+/g, ' ').trim()
720
721export const register: Register = on => {
722  on('session.start', async ($, e, next) => {
723    const result = await next(e)
724    const last = (await $.store.get(STORE_KEY).catch(() => null)) as TargetState | null
725    if (last && last.status === 'known') await update($, target, prev => (prev.status === 'unknown' ? last : prev)).catch(() => {})
726    if (Object.values(await read($, tiles).catch(() => ({}))).some(p => p.status === 'open')) await follow($)
727    await $.command.register({ name: 'tenantree', description: 'tenantree account: show it, sign in or sign out', argumentHint: '[signin | signout]' })
728    void refresh($).catch(() => {})
729    void watchSignIn($).catch(() => {})
730    return result
731  })
732
733  // THE STATUS ON EVERY INPUT (operator, 17.10.2026): one read-only call tells whether this connection is
734  // signed in and as whom; the band offers Sign out or Sign in accordingly. At most every 30 seconds.
735  on('prompt.submit', async ($, e, next) => {
736    await update($, turn, () => []).catch(() => {})
737    // Only a message the person sent themselves counts as a sign-out request.
738    const origin = (e as { origin?: { kind?: string } }).origin?.kind
739    const said = String((e as { text?: unknown }).text ?? '').trim()
740    if (origin !== 'plugin' && (ASKS_SIGNOUT.test(said) || /^tenantree\s+log\s?out\.?$/i.test(said))) signOutRequestedAt = await $.clock.now()
741    // "tenantree login" (operator, 17.10.2026): one read-only call that connects tenantree and fills the band.
742    if (origin !== 'plugin' && /^tenantree\s+log\s?in\.?$/i.test(said)) {
743      return next({ ...e, context: [...((e as { context?: readonly string[] }).context ?? []), LOGIN_CONTEXT] } as typeof e)
744    }
745    const at = await $.clock.now()
746    if (at - lastStatus > STATUS_MS) {
747      lastStatus = at
748      void refresh($).catch(() => {})
749    }
750    return next(e)
751  })
752
753  // Only the Sign out button signs the connection out (TEN-537): never the model on its own.
754  // Signing out is the person's own step in the portal (TEN-537): never the model's.
755  on('tool.call', { tool: `${PREFIX}tenantree_session_delete` }, async ($, e, next) => {
756    const at = await $.clock.now()
757    if (at - signOutRequestedAt > SIGNOUT_WINDOW_MS) return { deny: 'Sign out only when the person has asked for it in their own message.' }
758    signOutRequestedAt = 0
759    const result = await next(e)
760    const failed = Boolean((result as { isError?: boolean } | null)?.isError) || 'deny' in (result as object)
761    if (!failed) {
762      await signedOut($)
763      await showLogout($, textOf(result))
764    }
765    await update($, message, () => (failed ? 'The sign-out was not carried out.' : null))
766    return result
767  })
768
769  on('tool.call', async ($, e, next) => {
770    const result = await next(e)
771    const m = typeof e.tool === 'string' ? TENANTREE_TOOL.exec(e.tool) : null
772    const tool = m?.[1]
773    if (!tool) return result
774    // The band and the tiles must never disturb the tool call itself.
775    try {
776      await remember($, result)
777      const text = textOf(result)
778      const failed = Boolean((result as { isError?: boolean } | null)?.isError)
779      if (failed && NEEDS_SIGNIN.test(text)) {
780        await signedOut($)
781        return result
782      }
783      if (!failed && (await read($, account)).status === 'signedOut') void refresh($).catch(() => {})
784      const at = await $.clock.now()
785      if (tool === 'tenantree_landscape_signin') {
786        const url = SIGNIN_LINK.exec(text)?.[0]
787        const t = await read($, target)
788        // A link while already signed in (a renewal) is no pending sign-in for the band.
789        const already = t.status === 'known' && t.target?.signedIn
790        if (url && !already) await update($, signin, () => ({ url, system: t.status === 'known' && t.target ? t.target.system : null, at }))
791      } else if (!failed && !tool.startsWith('tenantree_')) await update($, signin, () => null)
792      const id = e.tool_use_id
793      // THE DIAGRAM TILE (16.10.2026): the landscape drawn by the plugin after the reply.
794      if (tool === 'tenantree_diagram_read' && id && !failed) {
795        const d = diagramOf(text)
796        if (d.layers.length) {
797          await update($, diagrams, all => ({ ...all, [id]: d }))
798          await update($, turn, ids => [...ids, id])
799        }
800      }
801      // THE COST TILE (16.10.2026): the assessment as a tile after the reply.
802      if (tool === 'tenantree_cost_query' && id && !failed) {
803        await update($, costs, all => ({ ...all, [id]: costOf(text) }))
804        await update($, turn, ids => [...ids, id])
805      }
806      const link = tool === 'tenantree_plan_read' ? null : PLAN_LINK.exec(text)
807      if (link && link[1] && id) {
808        const tile: PlanTile = { ...planOf(text), planId: link[1], url: link[0], expiresAt: at + PLAN_MS, status: 'open' }
809        await update($, tiles, all => ({ ...all, [id]: tile }))
810        await update($, turn, ids => [...ids, id])
811        await follow($)
812      }
813    } catch {
814      // Nothing of the band is worth a failed tool call.
815    }
816    return result
817  })
818
819  // The tiles of a turn go under the text of its reply.
820  on('turn.complete', async ($, e, next) => {
821    const ids = await read($, turn).catch(() => [] as string[])
822    if (ids.length) {
823      // The codes, where Claude Code hands them to plugins; the model never reads this part.
824      try {
825        const messages = await $.session.messages()
826        const codes = new Map<string, string>()
827        for (const m of messages) for (const u of m.toolUses ?? []) {
828          const code = codeOf((u as { meta?: unknown }).meta)
829          if (code && ids.includes(u.tool_use_id)) codes.set(u.tool_use_id, code)
830        }
831        if (codes.size) await update($, tiles, all => Object.fromEntries(Object.entries(all).map(([k, v]) => [k, codes.has(k) ? { ...v, code: codes.get(k) } : v])))
832      } catch {
833        // No codes: the tile offers the approval page.
834      }
835    }
836    if (ids.length && e.answer) {
837      const tail = normal(e.answer).slice(-160)
838      await update($, attached, list => [...list.slice(-50), { tail, ids }]).catch(() => {})
839      await update($, turn, () => []).catch(() => {})
840    }
841    return next(e)
842  })
843
844  on('command.run', { command: 'tenantree' }, async ($, e) => {
845    const verb = String((e as { args?: unknown }).args ?? '').trim().toLowerCase().replace(/[^a-z]/g, '')
846    if (verb === 'signout' || verb === 'logout') {
847      await signOut($)
848      return { text: (await read($, message)) ?? '' }
849    }
850    if (verb === 'signin' || verb === 'login') {
851      await signIn($)
852      return { text: (await read($, message)) ?? 'Signed in to tenantree.' }
853    }
854    await refresh($).catch(() => {})
855    const state = await read($, target)
856    const acc = await read($, account)
857    if (acc.status === 'signedOut') return { text: `Not signed in to tenantree. ${SIGNIN_HELP}` }
858    if (state.status === 'unknown') return { text: 'tenantree is not connected in this session. Run /mcp, select the server tenantree and sign in.' }
859    const lines = [`Signed in to tenantree as ${state.account ?? 'unknown'}.`]
860    if (acc.status === 'signedIn' && acc.organizations.length) lines.push('', 'Organizations:', ...acc.organizations.map(o => `  ${o.name} (${o.role === 'admin' ? 'administrator' : 'user'})`))
861    lines.push('', 'Available subaccounts:')
862    for (const t of state.targets ?? []) lines.push(`${t.active ? '▶' : ' '} ${path(t)} (${KIND[t.kind]})`)
863    if (!state.targets?.length) lines.push('  none assigned')
864    return { text: lines.join('\n') }
865  })
866
867  // THE TILES AFTER THE REPLY (operator, 16.10.2026).
868  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
869    // On every surface (operator, 21.10.2026): tenantree announces no MCP Apps views any more; plan, cost and
870    // diagram are shown by this tile alone.
871    const list = await read($, attached)
872    if (!list.length) return next(e)
873    const text = normal(e.props.text)
874    const hit = list.find(a => a.tail && text.endsWith(a.tail))
875    if (!hit) return next(e)
876    const all = await read($, tiles)
877    const allCosts = await read($, costs)
878    const allDiagrams = await read($, diagrams)
879    const shownDiagrams = hit.ids.map(id => [id, allDiagrams[id]] as const).filter((x): x is readonly [string, DiagramTile] => Boolean(x[1]))
880    const shown = hit.ids.map(id => [id, all[id]] as const).filter((x): x is readonly [string, PlanTile] => Boolean(x[1]))
881    const shownCosts = hit.ids.map(id => [id, allCosts[id]] as const).filter((x): x is readonly [string, CostTile] => Boolean(x[1]))
882    if (!shown.length && !shownCosts.length && !shownDiagrams.length) return next(e)
883    const reply = await next(e)
884    const opened = await read($, expanded)
885    const els = $.ui.resolve(e)
886    const { Box, Text, Button, Link, Markdown } = els
887    const Svg = 'Svg' in els ? els.Svg : null
888    const now = await $.clock.now()
889    const cell = (s: string) => s.replace(/\|/g, '\\|').replace(/\n/g, ' ')
890    const rows = (rs: Row[], width = 22) =>
891      rs.map(f => (
892        <Box key={f.label}>
893          <Box width={width}><Text dimColor>{f.label}</Text></Box>
894          <Text>{f.value}</Text>
895        </Box>
896      ))
897    return (
898      <Box flexDirection="column" gap={1}>
899        {reply}
900        {shownDiagrams.map(([id, d]) => {
901          const svg = diagramSvg(d)
902          return (
903            <Box key={id} flexDirection="column" borderStyle="round" paddingX={1} paddingY={1} gap={1}>
904              <Box justifyContent="space-between">
905                <Markdown text={`### ${d.title}`} />
906                {d.url ? <Link href={d.url} label="Open in the Tenantree Dashboard" /> : null}
907              </Box>
908              {Svg ? (
909                <Svg source={svg.source} alt={diagramAlt(d)} width={Math.min(svg.width, 1100)} />
910              ) : (
911                <Markdown text={d.layers.map(l => `**${l.name}**\n${l.nodes.map(n => `- ${n.name}${n.detail[0] ? ` — ${n.detail[0]}` : ''}`).join('\n')}`).join('\n\n')} />
912              )}
913              {d.findings.length ? <Markdown text={`**Findings**\n${d.findings.map(f => `- ${f}`).join('\n')}`} /> : <Text color="success">No findings.</Text>}
914              {d.notes.length ? <Markdown dimColor text={d.notes.map(n => `- ${n}`).join('\n')} /> : null}
915            </Box>
916          )
917        })}
918        {shownCosts.map(([id, c]) => (
919          <Box key={id} flexDirection="column" borderStyle="round" paddingX={1} paddingY={1} gap={1}>
920            <Box justifyContent="space-between">
921              <Markdown text="### Costs" />
922              {c.url ? <Link href={c.url} label="Open in the Tenantree Dashboard" /> : null}
923            </Box>
924            {c.risks.length ? (
925              c.risks.map(r => {
926                const [head, ...rest] = r.split(': ')
927                const days = /(\d+) days remaining/.exec(r)?.[1]
928                const ends = /ends on approximately (\d{4}-\d{2}-\d{2})/.exec(r)?.[1]
929                return (
930                  <Box key={r} flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
931                    <Box justifyContent="space-between">
932                      <Text color="warning" bold>◆ Cost risk · {rest.length ? head : 'Account'}</Text>
933                      {days ? <Text color="warning" bold>{days} days left{ends ? ` · until ${ends}` : ''}</Text> : null}
934                    </Box>
935                    <Text>{rest.length ? rest.join(': ') : r}</Text>
936                  </Box>
937                )
938              })
939            ) : (
940              <Text color="success">◆ No cost risk found.</Text>
941            )}
942            {c.sections.map(sec => (
943              <Box key={sec.name} flexDirection="column">
944                <Markdown text={`#### ${sec.name}`} />
945                {sec.rows.length ? <Markdown text={`| Item | Value |\n|:--|:--|\n${sec.rows.map(r => `| ${cell(r.label)} | ${cell(r.value)} |`).join('\n')}`} /> : null}
946                {sec.lines.map(l => <Markdown key={l} dimColor text={l} />)}
947              </Box>
948            ))}
949            {c.unchecked.length ? (
950              <Box flexDirection="column">
951                <Markdown text="#### Not checked" />
952                <Markdown dimColor text={c.unchecked.map(u => `- ${u}`).join('\n')} />
953              </Box>
954            ) : null}
955          </Box>
956        ))}
957        {shown.map(([id, p]) => {
958          const expired = p.status === 'open' && now > p.expiresAt
959          const open = p.status === 'open' && !expired
960          const isOpen = Boolean(opened[id])
961          const decided =
962            p.status === 'decided'
963              ? `${p.decision === 'approve' ? (p.ok === false ? 'Approved, but not carried out' : 'Approved and carried out') : 'Rejected. Nothing was changed.'}${p.by ? ` by ${p.by}` : ''}${p.at ? ` at ${time(p.at)}` : ''}.`
964              : null
965          return (
966            <Box key={id} flexDirection="column" borderStyle="round" borderColor={p.status === 'open' && !expired ? 'suggestion' : undefined} paddingX={1}>
967              <Box justifyContent="space-between">
968                <Text bold>{p.title}</Text>
969                {p.level ? <Text dimColor>Write level: {LEVEL[p.level] ?? p.level}</Text> : null}
970              </Box>
971              {p.target ? <Text dimColor>Target: {p.target}</Text> : null}
972              {p.cost && (open || p.status === 'checking') ? (
973                <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor="warning" paddingX={1}>
974                  <Text color="warning" bold>{COST_TEXT.title}</Text>
975                  <Text>{COST_TEXT.lead[p.cost.kind]}</Text>
976                  {p.cost.text ? <Text dimColor>{p.cost.text}</Text> : null}
977                  {p.code ? (
978                    <Box marginTop={1}>
979                      <Button
980                        key={`cost-${id}`}
981                        label={`${p.costAck ? '☑' : '☐'} ${COST_TEXT.confirm[p.cost.kind]}`}
982                        onPress={() => void update($, tiles, all => { const cur = all[id]; return cur ? { ...all, [id]: { ...cur, costAck: !cur.costAck } } : all })}
983                      />
984                    </Box>
985                  ) : null}
986                </Box>
987              ) : null}
988              {open && p.code && p.cost && !p.costAck ? (
989                <Box marginTop={1} gap={2} alignItems="center">
990                  <Button key={`reject-${id}`} label={p.busy === 'reject' ? 'Rejecting …' : 'Reject'} onPress={() => void (p.busy ? null : decide($, id, 'reject'))} />
991                  <Text dimColor>{COST_TEXT.required}</Text>
992                </Box>
993              ) : open && p.code ? (
994                <Box marginTop={1} gap={2} alignItems="center">
995                  <Button key={`approve-${id}`} label={p.busy === 'approve' ? 'Carrying out …' : 'Approve'} variant="primary" onPress={() => void (p.busy ? null : decide($, id, 'approve'))} />
996                  <Button key={`reject-${id}`} label={p.busy === 'reject' ? 'Rejecting …' : 'Reject'} onPress={() => void (p.busy ? null : decide($, id, 'reject'))} />
997                  <Text dimColor>Valid until {time(p.expiresAt)}. Nothing is changed until you approve.</Text>
998                </Box>
999              ) : open ? (
1000                <Box marginTop={1} flexDirection="column">
1001                  <Text dimColor>Valid until {time(p.expiresAt)}. Nothing is changed until you approve.</Text>
1002                  <Text dimColor>Approve and reject in this tile need the plugin channel of Claude Code, which is not enabled yet; please decide on the approval page.</Text>
1003                </Box>
1004              ) : null}
1005              {expired ? <Text dimColor>Expired at {time(p.expiresAt)} unless decided before.</Text> : null}
1006              {decided ? <Text color={p.decision === 'approve' && p.ok !== false ? 'success' : p.ok === false ? 'error' : undefined} bold>{decided}</Text> : null}
1007              {p.handover ? <Link href={p.handover} label="Open the secure one-time link" /> : null}
1008              {p.status === 'closed' ? <Text dimColor>This plan is no longer open (expired or superseded).</Text> : null}
1009              {p.status === 'checking' ? <Text dimColor>Reading the status …</Text> : null}
1010              {p.status === 'unknown' ? <Text color="error">The status could not be read{p.error ? `: ${p.error}` : '.'}</Text> : null}
1011              {isOpen ? (
1012                <Box flexDirection="column" marginTop={1}>
1013                  {p.facts.length ? <Box flexDirection="column">{rows(p.facts)}</Box> : null}
1014                  {p.values.length ? <Box flexDirection="column" marginTop={1}><Text dimColor>Values</Text>{rows(p.values)}</Box> : null}
1015                  {p.warnings.map(w => <Text key={w} color="warning">{w}</Text>)}
1016                </Box>
1017              ) : null}
1018              <Box marginTop={1} gap={2} alignItems="center">
1019                <Button key={`plan-${id}`} label={isOpen ? 'Hide plan' : 'Show plan'} onPress={() => void update($, expanded, all => ({ ...all, [id]: !isOpen }))} />
1020                {p.url ? <Link href={p.url} label="Open the approval page" /> : null}
1021              </Box>
1022              {decided && p.first ? <Text dimColor>{p.first}</Text> : null}
1023            </Box>
1024          )
1025        })}
1026      </Box>
1027    )
1028  })
1029
1030  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1031    if (e.props.hasSurvey) return next(e)
1032    const state = await read($, target)
1033    const now = await $.clock.now()
1034    const all = await read($, tiles)
1035    const open = Object.entries(all).filter(([, p]) => p.status === 'open' && now <= p.expiresAt)
1036    const loggedOut = await read($, logout)
1037    const logoutOpen = loggedOut && now - loggedOut.at < SIGNIN_MS ? loggedOut : null
1038    const pending = await read($, signin)
1039    const signinOpen = pending && now - pending.at < SIGNIN_MS ? pending : null
1040    const acc = await read($, account)
1041    void watchSignIn($).catch(() => {})
1042    // The picker needs the list of subaccounts; after a sign-in only the active target may be known.
1043    if (acc.status !== 'signedOut' && state.status === 'known' && !state.targets?.length) void loadTargets($).catch(() => {})
1044    if (state.status === 'unknown' && !open.length && !signinOpen && acc.status !== 'signedOut') return next(e)
1045    const els = $.ui.resolve(e)
1046    const { Box, Text, Link, Button } = els
1047    // The picker and the mark where the surface draws them (terminal: no Svg; mobile: no Select).
1048    const Select = 'Select' in els ? els.Select : null
1049    const Svg = 'Svg' in els ? els.Svg : null
1050    const t = state.status === 'known' ? state.target : null
1051    const targets = state.status === 'known' ? state.targets ?? [] : []
1052    const active = targets.find(x => x.active)
1053    const note = await read($, message)
1054    const choose = async (id: string) => {
1055      if (id === active?.id) return
1056      await update($, message, () => 'Switching the subaccount …')
1057      try {
1058        const r = await pressed($, 'tenantree_landscape_use', { systemId: id }, 'Switch subaccount')
1059        await remember($, r.text ?? r)
1060        await refresh($).catch(() => {})
1061        await update($, message, () => (r.isError ? 'The subaccount could not be switched.' : null))
1062      } catch (err) {
1063        await update($, message, () => (blockedByAuto(err) ? AUTO_BLOCKED : 'The subaccount could not be switched.'))
1064      }
1065    }
1066    const askSignin = async () => {
1067      try {
1068        const r = await pressed($, 'tenantree_landscape_signin', {}, 'Sign in')
1069        const url = SIGNIN_LINK.exec(r.text ?? textOf(r))?.[0]
1070        const at = await $.clock.now()
1071        if (url) await update($, signin, () => ({ url, system: t?.system ?? null, at }))
1072      } catch (err) {
1073        await update($, message, () => (blockedByAuto(err) ? AUTO_BLOCKED : 'The sign-in link could not be created.'))
1074      }
1075    }
1076    const isProduction = t?.kind === 'production'
1077    const picker =
1078      state.status === 'unknown' ? (
1079        <Text dimColor>Reading the subaccount …</Text>
1080      ) : Select && targets.length > 0 ? (
1081        // Without an active target the picker says so instead of showing the first entry as if it were chosen.
1082        <Select
1083          key="target"
1084          options={[...(active ? [] : [{ value: '', label: 'Choose a subaccount' }]), ...targets.map(x => ({ value: x.id, label: path(x) }))]}
1085          value={active?.id ?? ''}
1086          onSelect={(v: string) => void (v ? choose(v) : null)}
1087        />
1088      ) : (
1089        <Text bold>{t ? path(t) : 'No subaccount chosen'}</Text>
1090      )
1091    const notAdmitted = state.status === 'known' && t && (!t.signedIn || t.right === 'none')
1092    // The mark always opens the Tenantree Dashboard of the chosen subaccount (operator, 17.10.2026); the server's
1093    // address when it names one, else built from the chosen entry of the picker, else the portal.
1094    const dashboard = t?.dashboardUrl ?? (active ? `${PORTAL}/t/${active.id}/dashboard/` : `${PORTAL}/`)
1095    const email = acc.status === 'signedIn' ? acc.email : state.status === 'known' ? state.account ?? null : null
1096    // LAYOUT (operator, 17.10.2026, after his sketch): a header of two rows -- the subaccount left and the
1097    // mark right, below it the account left and Sign out right under the mark -- then one row per notice
1098    // (IAS sign-in, approvals, errors). Fixed widths for labels and the right column keep everything aligned.
1099    const ROW = { flexDirection: 'row' as const, alignItems: 'center' as const, gap: 2 }
1100    const LABEL = 14
1101    const RIGHT = 18
1102    return (
1103      <Box flexDirection="column" gap={1} paddingY={1} paddingX={1}>
1104        <Box {...ROW} justifyContent="space-between">
1105          <Box {...ROW}>
1106            {acc.status === 'signedOut' ? (
1107              <Text color="warning" bold>◆ Not signed in to tenantree</Text>
1108            ) : (
1109              <Box {...ROW}>
1110                {isProduction ? <Text color="error" bold>● Production</Text> : null}
1111                {picker}
1112              </Box>
1113            )}
1114          </Box>
1115          <Box width={RIGHT} justifyContent="flex-end">
1116            <Box {...ROW} gap={1}>
1117              {Svg ? <Svg source={MARK} alt="tenantree" width={16} height={16} /> : null}
1118              {/* The mark opens the Tenantree Dashboard of the subaccount in the browser (operator, 16.10.2026). */}
1119              <Link href={dashboard} label="tenantree" />
1120            </Box>
1121          </Box>
1122        </Box>
1123        <Box {...ROW} justifyContent="space-between">
1124          <Box {...ROW}>
1125            {acc.status === 'signedOut' ? (
1126              <Text dimColor>Sign in with /mcp → tenantree → Authenticate, then write: tenantree login</Text>
1127            ) : (
1128              <Box {...ROW} gap={0}>
1129                <Box width={LABEL}>
1130                  <Text dimColor>Signed in as</Text>
1131                </Box>
1132                <Text>{email ?? 'Reading the account …'}</Text>
1133              </Box>
1134            )}
1135          </Box>
1136          <Box width={RIGHT} justifyContent="flex-end">
1137            {acc.status === 'signedOut' ? (
1138              <Button key="signin-tenantree" label="Sign in" variant="primary" onPress={() => void signIn($)} />
1139            ) : email ? (
1140              <Button key="signout" label="Sign out" onPress={() => void signOut($)} />
1141            ) : null}
1142          </Box>
1143        </Box>
1144        {note ? (
1145          <Box {...ROW}>
1146            <Text dimColor>{note}</Text>
1147          </Box>
1148        ) : null}
1149        {logoutOpen ? (
1150          <Box {...ROW}>
1151            <Text>Signed out of tenantree. End the IAS session in this browser as well:</Text>
1152            <Link href={logoutOpen.url} label="End IAS session" />
1153            <Text dimColor>valid until {time(logoutOpen.at + SIGNIN_MS)}, once</Text>
1154          </Box>
1155        ) : null}
1156        {signinOpen ? (
1157          <Box {...ROW}>
1158            <Text color="warning">◆ Sign-in required{signinOpen.system ? ` at the IAS of ${signinOpen.system}` : ''}:</Text>
1159            <Link href={signinOpen.url} label="Sign in" />
1160            <Text dimColor>valid until {time(signinOpen.at + SIGNIN_MS)}, once</Text>
1161          </Box>
1162        ) : notAdmitted ? (
1163          <Box {...ROW}>
1164            <Text color="warning">◆ Not signed in at the IAS of {t.system}.</Text>
1165            <Button key="signin" label="Sign in" onPress={() => void askSignin()} />
1166          </Box>
1167        ) : null}
1168        {open.length ? (
1169          <Box flexDirection="column" gap={1}>
1170            {open.map(([id, p]) => (
1171              <Box key={id} {...ROW}>
1172                <Text color="warning">◆ Approval pending:</Text>
1173                <Text bold>{p.title}</Text>
1174                <Text dimColor>until {time(p.expiresAt)}</Text>
1175                {p.url ? <Link href={p.url} label="Approve or reject" /> : null}
1176              </Box>
1177            ))}
1178          </Box>
1179        ) : null}
1180      </Box>
1181    )
1182  })
1183}
1184
types/index.d.ts 105 lines
1/** The active target as the tenantree server reports it with every tool answer. */
2export type ActiveTarget = {
3  organization: string
4  landscape: string
5  system: string
6  kind: 'production' | 'quality' | 'development' | 'other'
7  region: string
8  subaccountId: string | null
9  signedIn: boolean
10  right: 'none' | 'user' | 'admin'
11  dashboardUrl?: string
12}
13
14/** A system the person may choose (tenantree_landscape_query). */
15export type Target = {
16  id: string
17  organization: string
18  landscape: string
19  system: string
20  kind: ActiveTarget['kind']
21  active: boolean
22}
23
24/** unknown: not read yet or tenantree not connected; null target: connected, no active target. */
25export type TargetState =
26  | { status: 'unknown' }
27  | { status: 'known'; target: ActiveTarget | null; account?: string; targets?: Target[]; at: number }
28
29/** TEPLU-12: the tenantree account of this connection. */
30export type AccountState =
31  | { status: 'unknown' }
32  | { status: 'signedIn'; email: string; organizations: Array<{ name: string; role: 'admin' | 'user' }> }
33  | { status: 'signedOut' }
34
35/** One row of a plan: a label and its value. */
36export type Row = { label: string; value: string }
37
38/** A plan tile: read from the plan text of the tool answer (the plugin calls nothing for it). */
39export type PlanTile = {
40  planId: string
41  title: string
42  level: string | null
43  target: string | null
44  headline: string
45  facts: Row[]
46  values: Row[]
47  warnings: string[]
48  url: string | null
49  expiresAt: number
50  status: 'open' | 'decided' | 'closed' | 'checking' | 'unknown'
51  decision?: 'approve' | 'reject'
52  by?: string | null
53  ok?: boolean
54  at?: number
55  first?: string
56  error?: string
57  /** The approval code from _meta "claude/plugins", where Claude Code hands it over; never shown. */
58  code?: string
59  /** The cost notice of a plan with costs or unchecked costs (21.10.2026): approved only together with its confirmation. */
60  cost?: { kind: 'paid' | 'unclear'; text: string } | null
61  /** The person confirmed the cost notice in the tile. */
62  costAck?: boolean
63  handover?: string | null
64  busy?: 'approve' | 'reject'
65}
66
67/** One section of the cost tile: rows (label and value) and lines of text. */
68export type CostSection = { name: string; rows: Row[]; lines: string[] }
69
70/** The cost tile: read from the text of tenantree_cost_query (the plugin calls nothing for it). */
71export type CostTile = { sections: CostSection[]; risks: string[]; unchecked: string[]; url: string | null }
72
73/** The landscape diagram tile: read from the text of tenantree_diagram_read, drawn as SVG by the plugin. */
74export type DiagramNode = { name: string; detail: string[] }
75export type DiagramTile = {
76  title: string
77  layers: Array<{ name: string; nodes: DiagramNode[] }>
78  edges: Array<{ from: string; to: string; label: string | null }>
79  findings: string[]
80  notes: string[]
81  url: string | null
82}
83
84/** The tiles a reply shows after its text: the end of that text and the tiles' tool use ids. */
85export type Attached = { tail: string; ids: string[] }
86
87declare module 'claude-code' {
88  interface PluginState {
89    tenantree: {
90      target: TargetState
91      server: string | null
92      message: string | null
93      tiles: Record<string, PlanTile>
94      turn: string[]
95      attached: Attached[]
96      signin: { url: string; system: string | null; at: number } | null
97      logout: { url: string; at: number } | null
98      account: AccountState
99      expanded: Record<string, boolean>
100      costs: Record<string, CostTile>
101      diagrams: Record<string, DiagramTile>
102    }
103  }
104}
105