total-recall-brain 3.40.6 → 3.41.0

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 (170) hide show
  1. package/bin/total-recall-memory.mjs +0 -0
  2. package/bin/total-recall.mjs +9 -40
  3. package/docs/reference/cli-reference.md +65 -1
  4. package/frontend/dist/assets/{index-AGUes-VG.css → index-BG9NkVUy.css} +1 -1
  5. package/frontend/dist/assets/{index-Bt5iVPZj.js → index-BtuiZqwP.js} +20 -7
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/src/components/plugins/PluginPanel.tsx +31 -5
  8. package/frontend/src/components/plugins/card/CardEngine.tsx +15 -23
  9. package/frontend/src/components/plugins/card/CardScenes.tsx +52 -23
  10. package/frontend/src/components/plugins/card/card.css +2 -5
  11. package/frontend/src/components/plugins/card/contract.ts +13 -19
  12. package/frontend/src/components/plugins/card/format.ts +8 -1
  13. package/frontend/src/components/plugins/card/widgets.tsx +4 -35
  14. package/frontend/src/components/plugins/plugin-frame.ts +30 -3
  15. package/frontend/src/pages/PluginsPage.tsx +3 -2
  16. package/package.json +1 -1
  17. package/scaffold/.agent/skills/plugin/SKILL.md +110 -0
  18. package/scaffold/.agent/skills/plugin/evals/README.md +53 -0
  19. package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +42 -0
  20. package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +39 -0
  21. package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +32 -0
  22. package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +46 -0
  23. package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +55 -0
  24. package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +92 -0
  25. package/scaffold/.agent/skills/plugin/evals/run.mjs +118 -0
  26. package/scaffold/.agent/skills/plugin/prompts/01-function.md +29 -0
  27. package/scaffold/.agent/skills/plugin/prompts/02-surfaces.md +43 -0
  28. package/scaffold/.agent/skills/plugin/prompts/03-scaffold.md +31 -0
  29. package/scaffold/.agent/skills/plugin/prompts/04-cli.md +49 -0
  30. package/scaffold/.agent/skills/plugin/prompts/05-credentials.md +41 -0
  31. package/scaffold/.agent/skills/plugin/prompts/06-card.md +46 -0
  32. package/scaffold/.agent/skills/plugin/prompts/07-scaffold.md +52 -0
  33. package/scaffold/.agent/skills/plugin/prompts/08-verify.md +43 -0
  34. package/scaffold/.agent/skills/plugin/references/anti-patterns.md +123 -0
  35. package/scaffold/.agent/skills/plugin/references/card.md +90 -0
  36. package/scaffold/.agent/skills/plugin/references/scaffold.md +120 -0
  37. package/scaffold/.agent/skills/plugin/references/surfaces.md +206 -0
  38. package/scaffold/.agent/skills/plugin/scripts/record.mjs +360 -0
  39. package/scaffold/.agent/skills/plugin/subagents/card-designer.md +60 -0
  40. package/scaffold/.agent/skills/plugin/subagents/plugin-reviewer.md +55 -0
  41. package/scaffold/.agent/skills/plugin/subagents/scaffold-extractor.md +71 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +71 -1441
  43. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +7 -5
  44. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +3 -44
  45. package/scaffold/.agent/skills/total-recall/plugins/auth/scripts/agent-login-pty.py +185 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +20 -1
  47. package/scaffold/.agent/skills/total-recall/plugins/auth/src/host-secrets-store.mjs +15 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +6 -4
  49. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +1 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -24
  51. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
  52. package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +0 -21
  53. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
  54. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +0 -32
  55. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
  56. package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +11 -0
  57. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +5 -2
  58. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
  59. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +1 -28
  60. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/DESIGN.md +95 -13
  61. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +553 -340
  62. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +0 -36
  63. package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +383 -400
  64. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +4 -4
  65. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +0 -1
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +0 -28
  67. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
  68. package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +0 -32
  69. package/scaffold/.agent/skills/total-recall/references/auth.md +3 -1
  70. package/scaffold/.agent/skills/total-recall/references/memory.md +4 -0
  71. package/scaffold/.agent/skills/total-recall/references/openwiki.md +2 -0
  72. package/scaffold/.agent/skills/total-recall/references/operations.md +12 -0
  73. package/src/cli/brain.mjs +4 -2
  74. package/src/cli/context-stats.mjs +104 -0
  75. package/src/cli/context.mjs +8 -0
  76. package/src/cli/daemon.mjs +10 -1
  77. package/src/cli/export.mjs +3 -2
  78. package/src/cli/ingest-okf.mjs +12 -15
  79. package/src/cli/init.mjs +45 -85
  80. package/src/cli/lint.mjs +3 -2
  81. package/src/cli/plugin/copies.mjs +68 -0
  82. package/src/cli/plugin/create.mjs +22 -24
  83. package/src/cli/plugin/index.mjs +49 -3
  84. package/src/cli/plugin/search.mjs +19 -2
  85. package/src/cli/rebuild.mjs +2 -0
  86. package/src/cli/remember.mjs +23 -0
  87. package/src/cli/status.mjs +10 -3
  88. package/src/cli/update.mjs +3 -2
  89. package/src/core/action-predictor.mjs +161 -0
  90. package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +20 -3
  91. package/src/core/app-deploy/source.mjs +16 -9
  92. package/src/core/automatic-brain-updates.mjs +6 -3
  93. package/src/core/brain-registry.mjs +19 -2
  94. package/src/core/brain-state.mjs +23 -0
  95. package/src/core/cli-commands.mjs +1 -0
  96. package/src/core/cli-maintenance.mjs +1 -1
  97. package/src/core/cli-semantic-index.mjs +1 -1
  98. package/src/core/command-surface.mjs +3 -3
  99. package/src/core/composed-command-resolution.mjs +21 -0
  100. package/src/core/config.mjs +84 -0
  101. package/src/core/confined-memory.mjs +93 -0
  102. package/src/core/context-telemetry.mjs +214 -0
  103. package/src/core/daemon-control.mjs +14 -43
  104. package/src/core/fast-recall.mjs +1 -1
  105. package/src/core/memory-brain.mjs +2 -0
  106. package/src/core/plugin-activation.mjs +38 -6
  107. package/src/core/plugin-bundle.mjs +54 -0
  108. package/src/core/plugin-catalog.mjs +1 -1
  109. package/src/core/plugin-context.mjs +1 -1
  110. package/src/core/plugin-contracts/card.mjs +69 -37
  111. package/src/core/plugin-loader.mjs +5 -6
  112. package/src/core/plugin-runner.mjs +13 -6
  113. package/src/core/plugin-runtime.mjs +5 -4
  114. package/src/core/plugin-setup.mjs +5 -3
  115. package/src/core/plugin-state-store.mjs +12 -1
  116. package/src/core/plugin-state.mjs +46 -5
  117. package/src/core/plugin-store.mjs +78 -3
  118. package/src/core/plugin-tasks.mjs +3 -1
  119. package/src/core/plugin-updates.mjs +117 -0
  120. package/src/core/plugin-vendor.mjs +10 -4
  121. package/src/core/project-brain.mjs +14 -41
  122. package/src/core/project-id.mjs +4 -1
  123. package/src/core/required-plugins.mjs +151 -13
  124. package/src/core/skills-registry.mjs +27 -3
  125. package/src/core/ssss-kernel-bridge.mjs +3 -1
  126. package/src/core/startup-health.mjs +66 -16
  127. package/src/core/surface.mjs +51 -44
  128. package/src/core/testing/plugin-fixture.mjs +69 -0
  129. package/src/core/vault-files.mjs +13 -0
  130. package/src/core/vault.mjs +4 -30
  131. package/src/server/index.mjs +13 -1
  132. package/src/server/invoke-decision-child.mjs +49 -0
  133. package/src/server/invoke-execution.mjs +87 -0
  134. package/src/server/invoke-policy.mjs +64 -0
  135. package/src/server/invoke-read-child.mjs +26 -0
  136. package/src/server/invoke-targets.mjs +49 -0
  137. package/src/server/invoke-write-child.mjs +19 -0
  138. package/src/server/routes/invoke.mjs +314 -0
  139. package/src/server/routes/plugins.mjs +1 -1
  140. package/scaffold/.agent/skills/plugins/SKILL.md +0 -57
  141. package/scaffold/.agent/skills/plugins/references/card-design.md +0 -55
  142. package/scaffold/.agent/skills/plugins/references/distribution.md +0 -23
  143. package/scaffold/.agent/skills/plugins/references/manifest-contract.md +0 -90
  144. package/scaffold/.agent/skills/plugins/references/optimized/index.md +0 -9
  145. package/scaffold/.agent/skills/plugins/references/optimized/manifest.json +0 -35
  146. package/scaffold/.agent/skills/plugins/references/optimized/original.md +0 -58
  147. package/scaffold/.agent/skills/plugins/references/optimized/section-01.md +0 -5
  148. package/scaffold/.agent/skills/plugins/references/optimized/section-02.md +0 -25
  149. package/scaffold/.agent/skills/plugins/references/optimized/section-03.md +0 -12
  150. package/scaffold/.agent/skills/plugins/references/optimized/section-04.md +0 -7
  151. package/scaffold/.agent/skills/plugins/references/optimized/section-05.md +0 -5
  152. package/scaffold/.agent/skills/total-recall/openwiki/README.md +0 -10
  153. package/scaffold/.agent/skills/total-recall/openwiki/architecture.md +0 -5
  154. package/scaffold/.agent/skills/total-recall/openwiki/ide.md +0 -10
  155. package/scaffold/.agent/skills/total-recall/openwiki/memory.md +0 -11
  156. package/scaffold/.agent/skills/total-recall/openwiki/secrets.md +0 -7
  157. package/scaffold/.agent/skills/total-recall/openwiki/skills.md +0 -7
  158. package/scaffold/openwiki/README.md +0 -10
  159. package/scaffold/openwiki/architecture.md +0 -5
  160. package/scaffold/openwiki/ide.md +0 -10
  161. package/scaffold/openwiki/memory.md +0 -11
  162. package/scaffold/openwiki/secrets.md +0 -7
  163. package/scaffold/openwiki/skills.md +0 -7
  164. package/src/core/okf-adapter.mjs +0 -559
  165. package/templates/openwiki/README.md +0 -10
  166. package/templates/openwiki/architecture.md +0 -5
  167. package/templates/openwiki/ide.md +0 -10
  168. package/templates/openwiki/memory.md +0 -11
  169. package/templates/openwiki/secrets.md +0 -7
  170. package/templates/openwiki/skills.md +0 -7
@@ -17,8 +17,8 @@
17
17
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18
18
  <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,800&family=Cabinet+Grotesk:wght@700;800&family=Chivo+Mono:wght@500;700&family=Cinzel:wght@600;700;900&family=Cormorant+Garamond:ital,wght@0,600;0,700;1,600;1,700&family=DM+Mono:wght@500&family=DM+Sans:wght@500;700&family=Fraunces:opsz,wght@9..144,600;9..144,800&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&family=Lexend:wght@600;800&family=Newsreader:ital,opsz,wght@0,6..72,600;1,6..72,600&family=Orbitron:wght@700;900&family=Outfit:wght@500;700;800&family=Plus+Jakarta+Sans:wght@600;700;800&family=Space+Grotesk:wght@600;700&family=Syne:wght@700;800&display=swap" rel="stylesheet" />
19
19
  <title>Total Recall — Portable Memory</title>
20
- <script type="module" crossorigin src="/assets/index-Bt5iVPZj.js"></script>
21
- <link rel="stylesheet" crossorigin href="/assets/index-AGUes-VG.css">
20
+ <script type="module" crossorigin src="/assets/index-BtuiZqwP.js"></script>
21
+ <link rel="stylesheet" crossorigin href="/assets/index-BG9NkVUy.css">
22
22
  </head>
23
23
  <body>
24
24
  <div id="root"></div>
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react'
2
2
  import type { PluginInfo } from '../../api/plugins'
3
3
  import { runPluginCommand } from '../../api/plugins'
4
4
  import { apiFetch, API_BASE } from '../../api/_base'
5
- import { pluginFrameDocument, allowedFrameCommand, pluginCommandTimeouts } from './plugin-frame'
5
+ import { pluginFrameDocument, allowedFrameCommand, pluginCommandTimeouts, sanitizePluginScreens } from './plugin-frame'
6
+ import type { PluginScreen } from './plugin-frame'
6
7
 
7
8
  export interface PluginElement {
8
9
  id: string
@@ -37,10 +38,29 @@ export function PluginPanel({ plugin, elementId, readOnly = false }: { plugin: P
37
38
  const secrets = command.args?.filter(arg => arg.secret === true) || []
38
39
  return secrets.length === 1 && secrets[0].type === 'string' && secrets[0].flag === undefined
39
40
  }).map(command => command.name))
41
+ const element = elements.find(item => item.id === selected) || elements[0]
42
+ let ownerScreens: PluginScreen[] = []
43
+ const showScreen = (event: Event) => {
44
+ const detail = (event as CustomEvent).detail
45
+ if (disposed || element?.kind !== 'card' || detail?.pluginId !== plugin.id || !ownerScreens.some(screen => screen.id === detail.screen)) return
46
+ frame.current?.contentWindow?.postMessage({ channel, type: 'show-screen', screen: detail.screen }, '*')
47
+ }
40
48
  let active = 0
41
49
  const message = async (event: MessageEvent) => {
42
50
  if (disposed || event.source !== frame.current?.contentWindow || event.origin !== 'null' || event.data?.channel !== channel) return
43
51
  const data = event.data
52
+ if (data.type === 'screens') {
53
+ if (element?.kind === 'card') {
54
+ ownerScreens = sanitizePluginScreens(data.screens)
55
+ window.dispatchEvent(new CustomEvent('plugin-screens', { detail: { pluginId: plugin.id, screens: ownerScreens } }))
56
+ }
57
+ return
58
+ }
59
+ if (data.type === 'screen-changed') {
60
+ if (element?.kind === 'card' && ownerScreens.some(screen => screen.id === data.screen))
61
+ window.dispatchEvent(new CustomEvent('plugin-screen-changed', { detail: { pluginId: plugin.id, screen: data.screen } }))
62
+ return
63
+ }
44
64
  if (data.type === 'contextmenu') window.dispatchEvent(new CustomEvent('plugin-context-menu', { detail: { pluginId: plugin.id } }))
45
65
  if (data.type === 'height' && Number.isFinite(data.height)) setHeight(Math.max(128, Math.min(1000, data.height)))
46
66
  if (data.type === 'error') setError('Could not open the plugin interface')
@@ -63,8 +83,14 @@ export function PluginPanel({ plugin, elementId, readOnly = false }: { plugin: P
63
83
  }
64
84
  }
65
85
  window.addEventListener('message', message)
66
- const element = elements.find(item => item.id === selected) || elements[0]
67
- if (!element) return () => { disposed = true; window.removeEventListener('message', message) }
86
+ window.addEventListener('plugin-show-screen', showScreen)
87
+ const dispose = () => {
88
+ disposed = true
89
+ window.removeEventListener('message', message)
90
+ window.removeEventListener('plugin-show-screen', showScreen)
91
+ if (element?.kind === 'card') window.dispatchEvent(new CustomEvent('plugin-screens', { detail: { pluginId: plugin.id, screens: [] } }))
92
+ }
93
+ if (!element) return dispose
68
94
  const load = async () => {
69
95
  const response = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(plugin.id)}/ui/${encodeURIComponent(element.id)}`)
70
96
  if (!response.ok) throw new Error(`Could not open ${plugin.name} (${response.status})`)
@@ -74,10 +100,10 @@ export function PluginPanel({ plugin, elementId, readOnly = false }: { plugin: P
74
100
  const styles = getComputedStyle(window.document.documentElement)
75
101
  const theme: Record<string, string> = {}
76
102
  for (const name of ['--text-primary', '--text-secondary', '--text-tertiary', '--bg-primary', '--bg-secondary', '--border', '--accent', '--font-sans', '--color-text', '--color-surface', '--color-border', '--spacing-md', '--radius-md']) theme[name] = styles.getPropertyValue(name)
77
- setDocument(pluginFrameDocument(source, element.tag, channel, theme, plugin.id, pluginCommandTimeouts(plugin.manifest)))
103
+ setDocument(pluginFrameDocument(source, element.tag, channel, theme, plugin.id, pluginCommandTimeouts(plugin.manifest), [...allowed]))
78
104
  }
79
105
  load().catch(err => { if (!disposed) setError(err.message) })
80
- return () => { disposed = true; window.removeEventListener('message', message) }
106
+ return dispose
81
107
  }, [plugin.id, selected, plugin.sha256, elementId, readOnly])
82
108
 
83
109
  return <section>
@@ -1,12 +1,12 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react'
2
2
  import type { PluginInfo } from '../../../api/plugins'
3
3
  import { apiFetch, API_BASE } from '../../../api/_base'
4
- import { normalizeCard, type Card, type CardRegion } from './contract'
4
+ import { normalizeCard, type Card } from './contract'
5
5
  import { CardRunner } from './runner'
6
- import { ActivityWidget, CommandsWidget, DataWidget, SettingsWidget, StatusWidget } from './widgets'
6
+ import { ActivityWidget, CommandsWidget, SettingsWidget } from './widgets'
7
7
  import { brandStyle } from './theme'
8
8
  import { WorkspaceHost } from '../workspace/WorkspaceHost'
9
- import { PluginPanel, pluginElements } from '../PluginPanel'
9
+ import { PluginPanel } from '../PluginPanel'
10
10
  import './card.css'
11
11
  import { CardScenes } from './CardScenes'
12
12
 
@@ -45,17 +45,6 @@ function Header({ card, onOpenDrawer }: { card: Card; onOpenDrawer: () => void }
45
45
  </header>
46
46
  }
47
47
 
48
- function Region({ region, plugin }: { region: CardRegion; plugin: PluginInfo }) {
49
- switch (region.widget) {
50
- case 'status': return <StatusWidget />
51
- case 'live': return <PluginPanel plugin={plugin} elementId={pluginElements(plugin).find(element => element.kind === 'card')?.id} readOnly />
52
- case 'actions': case 'form': case 'keypad': return <CommandsWidget region={region.command ? { ...region, commands: [region.command] } : region} />
53
- case 'table': case 'detail': case 'list': case 'gauge': case 'log': return <DataWidget region={region} />
54
- case 'settings': case 'credentials': return <SettingsWidget plugin={plugin} />
55
- case 'activity': return <ActivityWidget />
56
- }
57
- }
58
-
59
48
  function Drawer({ plugin, card, onClose }: { plugin: PluginInfo; card: Card; onClose: () => void }) {
60
49
  const hasWorkspace = Boolean(card.workspace)
61
50
  const [tab, setTab] = useState<'workspace' | 'settings' | 'commands' | 'usage'>(hasWorkspace ? 'workspace' : 'settings')
@@ -89,26 +78,29 @@ function Drawer({ plugin, card, onClose }: { plugin: PluginInfo; card: Card; onC
89
78
  <div className="card-drawer-body">
90
79
  {tab === 'workspace' && <WorkspaceHost plugin={plugin} card={card} />}
91
80
  {tab === 'settings' && <SettingsWidget plugin={plugin} />}
92
- {tab === 'commands' && <CommandsWidget region={{ widget: 'actions' }} />}
81
+ {tab === 'commands' && <CommandsWidget />}
93
82
  {tab === 'usage' && <ActivityWidget />}
94
83
  </div>
95
84
  </aside>
96
85
  }
97
86
 
98
- /** One engine for every plugin; the manifest supplies layout, brand and commands. */
87
+ /**
88
+ * The host gives a plugin its frame, its brand tokens and its command
89
+ * plumbing. The layout is the plugin's own face: CardEngine never arranges
90
+ * a plugin's interface and there is no shared template to fall back to.
91
+ */
99
92
  export function CardEngine({ plugin }: { plugin: PluginInfo }) {
100
93
  const card = useMemo(() => normalizeCard(plugin), [plugin])
101
- const owned = pluginElements(plugin).some(element => element.kind === 'card' || element.kind === 'panel')
102
94
  const [drawer, setDrawer] = useState(false)
103
95
  return <CardRunner card={card}>
104
- <section className={`card card-${card.template}${owned ? ' card-owned' : ''}`} style={brandStyle(card.branding)} data-density={card.branding.density} aria-label={`${card.title} card`}>
96
+ <section className={`card card-bespoke${card.face ? ' card-owned' : ''}`} style={brandStyle(card.branding)} data-density={card.branding.density} aria-label={`${card.title} card`}>
105
97
  <Header card={card} onOpenDrawer={() => setDrawer(true)} />
106
98
  {card.description && <p className="card-muted card-description">{card.description}</p>}
107
- <CardScenes plugin={plugin}><div className="card-regions">
108
- {card.regions.filter(region => !['actions', 'form', 'keypad'].includes(region.widget)).map((region, i) => <section key={i} className={`card-region card-region-${region.widget}`} style={{ gridColumn: `span ${Math.min(12, Math.max(1, region.span || 12))}` }}>
109
- {region.title && <h3>{region.title}</h3>}<Region region={region} plugin={plugin} />
110
- </section>)}
111
- </div></CardScenes>
99
+ <CardScenes plugin={plugin}>
100
+ {card.face
101
+ ? <PluginPanel plugin={plugin} elementId={card.face.id} readOnly />
102
+ : <p className="card-error" role="alert">{plugin.name} has not supplied its card face.</p>}
103
+ </CardScenes>
112
104
  {card.branding.provider && <footer className="card-footer">{card.branding.provider.url ? <a href={card.branding.provider.url} target="_blank" rel="noreferrer noopener">{card.branding.provider.name}</a> : card.branding.provider.name} is a trademark of its owner. This plugin connects to the service with your own account.</footer>}
113
105
  {drawer && <Drawer plugin={plugin} card={card} onClose={() => setDrawer(false)} />}
114
106
  </section>
@@ -3,10 +3,18 @@ import type { ReactNode } from 'react'
3
3
  import type { PluginInfo } from '../../../api/plugins'
4
4
  import { API_BASE, apiFetch } from '../../../api/_base'
5
5
  import { useRunner } from './runner-context'
6
- import { CommandsWidget } from './widgets'
7
6
  import { ExecutionPicker } from './ExecutionPicker'
7
+ import { sanitizePluginScreens } from '../plugin-frame'
8
+ import type { PluginScreen } from '../plugin-frame'
9
+
10
+ /**
11
+ * The host gives a plugin a place to put its screens and a way to move between
12
+ * them. Which screens exist, what they are called and what is on them is the
13
+ * plugin's design: the face declares them and reports the active one. The host
14
+ * never derives a screen from the command list.
15
+ */
16
+ export type { PluginScreen } from '../plugin-frame'
8
17
 
9
- /** Generic navigation and activation; the overview stays the owner's bespoke interface. */
10
18
  export function CardScenes({ plugin, children }: { plugin: PluginInfo; children: ReactNode }) {
11
19
  const { card, busy } = useRunner()
12
20
  const [enabled, setEnabled] = useState(plugin.activation?.enabled !== false)
@@ -15,31 +23,53 @@ export function CardScenes({ plugin, children }: { plugin: PluginInfo; children:
15
23
  const [error, setError] = useState('')
16
24
  const [ownerBusy, setOwnerBusy] = useState<Record<string, string>>({})
17
25
  const [menu, setMenu] = useState(false)
26
+ const [screens, setScreens] = useState<PluginScreen[]>([])
27
+ const [activeScreen, setActiveScreen] = useState('')
28
+ const screensRef = useRef<PluginScreen[]>([])
18
29
  useEffect(() => {
30
+ screensRef.current = []
31
+ setScreens([]); setActiveScreen(''); setMenu(false)
19
32
  const activity = (event: Event) => {
20
33
  const detail = (event as CustomEvent).detail
21
34
  if (detail?.pluginId !== plugin.id) return
22
35
  setOwnerBusy(previous => { const next = { ...previous }; if (detail.running) next[detail.requestId] = detail.command; else delete next[detail.requestId]; return next })
23
36
  }
24
37
  const contextMenu = (event: Event) => { if ((event as CustomEvent).detail?.pluginId === plugin.id) setMenu(true) }
38
+ const declared = (event: Event) => {
39
+ const detail = (event as CustomEvent).detail
40
+ if (detail?.pluginId !== plugin.id) return
41
+ const next = sanitizePluginScreens(detail.screens)
42
+ screensRef.current = next
43
+ setScreens(next)
44
+ setActiveScreen(previous => next.some(screen => screen.id === previous) ? previous : next[0]?.id || '')
45
+ }
46
+ const changed = (event: Event) => {
47
+ const detail = (event as CustomEvent).detail
48
+ if (detail?.pluginId === plugin.id && screensRef.current.some(screen => screen.id === detail.screen)) setActiveScreen(detail.screen)
49
+ }
25
50
  window.addEventListener('plugin-command-activity', activity)
26
51
  window.addEventListener('plugin-context-menu', contextMenu)
27
- return () => { window.removeEventListener('plugin-command-activity', activity); window.removeEventListener('plugin-context-menu', contextMenu) }
52
+ window.addEventListener('plugin-screens', declared)
53
+ window.addEventListener('plugin-screen-changed', changed)
54
+ return () => {
55
+ window.removeEventListener('plugin-command-activity', activity); window.removeEventListener('plugin-context-menu', contextMenu)
56
+ window.removeEventListener('plugin-screens', declared); window.removeEventListener('plugin-screen-changed', changed)
57
+ }
28
58
  }, [plugin.id])
29
59
  const running = [...busy, ...Object.values(ownerBusy)]
30
- const track = useRef<HTMLDivElement>(null)
31
- const names = [...card.commands.keys()]
32
- const face = plugin.manifest?.card_face as { quick_actions?: Array<{ subcommand: string; label?: string }> } | undefined
33
- const declared = plugin.card_face?.quick_actions || face?.quick_actions || []
34
- const validActions = declared.filter(action => names.includes(action.subcommand))
35
- const common = validActions.length ? validActions.slice(0, 6) : names.filter(name => !['on', 'off'].includes(name)).slice(0, 6).map(subcommand => ({ subcommand, label: subcommand }))
36
- const behavior = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' as const : 'smooth' as const
37
- const openCommand = (name: string) => {
60
+ // The face's own screens collapse into a single mount point: the plugin keeps
61
+ // every screen inside its own component and reports which one is active.
62
+ /** Ask the face to show a screen it declared; it may ignore a name it does not own. */
63
+ const openScreen = (name: string) => {
64
+ if (!screens.some(screen => screen.id === name)) return
38
65
  setMenu(false)
39
- const scene = track.current?.children[names.indexOf(name) + 1] as HTMLElement | undefined
40
- if (scene && track.current) track.current.scrollTo({ left: scene.offsetLeft, behavior: behavior() })
66
+ window.dispatchEvent(new CustomEvent('plugin-show-screen', { detail: { pluginId: plugin.id, screen: name } }))
67
+ }
68
+ const move = (delta: number) => {
69
+ if (screens.length < 2) return
70
+ const current = Math.max(0, screens.findIndex(screen => screen.id === activeScreen))
71
+ openScreen(screens[(current + delta + screens.length) % screens.length].id)
41
72
  }
42
- const move = (delta: number) => track.current?.scrollBy({ left: delta * track.current.clientWidth, behavior: behavior() })
43
73
  const toggle = async () => {
44
74
  setChanging(true); setError('')
45
75
  try {
@@ -56,19 +86,18 @@ export function CardScenes({ plugin, children }: { plugin: PluginInfo; children:
56
86
  <div className="card-row card-scene-controls">
57
87
  <span role="status" className="card-pill">{running.length ? `Running ${running.join(', ')}` : enabled ? 'Active' : 'Inactive'}</span>
58
88
  <button type="button" className="card-button" role="switch" aria-checked={enabled} aria-label={`${card.title} on or off`} onClick={() => void toggle()} disabled={changing || running.length > 0}>{changing ? 'Updating…' : enabled ? 'Turn off' : 'Turn on'}</button>
59
- <button type="button" className="card-link" aria-label="Previous scene" onClick={() => move(-1)}>←</button>
60
- <button type="button" className="card-link" aria-label="Next scene" onClick={() => move(1)}>→</button>
61
- <button type="button" className="card-link" aria-haspopup="menu" aria-expanded={menu} onClick={() => setMenu(!menu)}>Quick commands</button>
89
+ <button type="button" className="card-link" aria-label="Previous screen" disabled={screens.length < 2} onClick={() => move(-1)}>←</button>
90
+ <button type="button" className="card-link" aria-label="Next screen" disabled={screens.length < 2} onClick={() => move(1)}>→</button>
91
+ {screens.length > 0 && <span className="card-pill" aria-label="Current screen">{screens.find(screen => screen.id === activeScreen)?.label}</span>}
92
+ {screens.length > 0 && <button type="button" className="card-link" aria-haspopup="menu" aria-expanded={menu} onClick={() => setMenu(!menu)}>Screens</button>}
62
93
  </div>
63
- {menu && <div role="menu" aria-label={`${card.title} common commands`} className="card-context-menu">
64
- {common.map(action => <button role="menuitem" type="button" className="card-link" key={action.subcommand} onClick={() => openCommand(action.subcommand)}>{action.label || action.subcommand}</button>)}
65
- {!common.length && <span>No commands declared.</span>}
94
+ {menu && screens.length > 0 && <div role="menu" aria-label={`${card.title} declared screens`} className="card-context-menu">
95
+ {screens.map(screen => <button role="menuitem" type="button" className="card-link" key={screen.id} aria-current={screen.id === activeScreen ? 'page' : undefined} onClick={() => openScreen(screen.id)}>{screen.label}</button>)}
66
96
  </div>}
67
97
  {error && <p role="alert" className="card-error">{error}</p>}
68
98
  <ExecutionPicker />
69
- <div className="card-scene-track" ref={track} tabIndex={0} aria-label={`${card.title} scenes`}>
70
- <section className="card-scene" aria-label="Overview">{children}</section>
71
- {names.map(name => <section className="card-scene" key={name} aria-label={`${name} command scene`}><CommandsWidget region={{ widget: 'actions', commands: [name] }} /></section>)}
99
+ <div className="card-scene-track" tabIndex={0} aria-label={`${card.title} interface`}>
100
+ <section className="card-scene" aria-label="Interface">{children}</section>
72
101
  </div>
73
102
  </div>
74
103
  }
@@ -9,11 +9,8 @@
9
9
  .card-badge{display:inline-block;margin:4px 6px 0 0;padding:1px 8px;border-radius:var(--card-radius);background:var(--card-accent);color:var(--card-on-accent);font-size:.7rem;font-weight:600}
10
10
  .card-badge-quiet{background:transparent;color:var(--card-muted);border:1px solid var(--card-line)}
11
11
  .card-description{margin:0}.card-muted{color:var(--card-muted);font-size:.85rem;margin:2px 0}
12
- .card-regions{display:grid;grid-template-columns:repeat(12,1fr);gap:var(--card-gap)}.card-region{grid-column:span 12;min-width:0}
13
- .card-console .card-region-status{grid-column:span 12}.card-dashboard .card-region,.card-monitor .card-region{grid-column:span 6}
14
- .card-workbench .card-regions,.card-inbox .card-regions{grid-template-columns:minmax(0,1fr) minmax(0,2fr)}.card-workbench .card-region,.card-inbox .card-region{grid-column:auto}
15
- .card-ledger .card-region-table{grid-column:span 12}.card-vault .card-region{grid-column:span 12;border-left:3px solid var(--card-accent);padding-left:var(--card-gap)}
16
- @media (max-width:720px){.card-regions{grid-template-columns:1fr}.card-regions>.card-region{grid-column:auto!important}}
12
+ /* The face supplies its own layout; the host only gives it a surface. */
13
+ .card-bespoke .card-face{display:block;min-width:0}
17
14
  .card-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.card-kv{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;margin:8px 0 0}.card-kv dt{font-size:.7rem;color:var(--card-muted);text-transform:uppercase}.card-kv dd{margin:0;font-weight:600;overflow-wrap:anywhere}
18
15
  .card-pill{padding:1px 8px;border-radius:999px;font-size:.75rem;font-weight:600;border:1px solid currentColor}.card-pill-ok{color:#047857}.card-pill-warn{color:#b45309}.card-pill-error{color:#b91c1c}.card-pill-neutral{color:var(--card-muted)}
19
16
  .card-button{background:var(--card-color);color:var(--card-on-color);border:0;border-radius:var(--card-radius);padding:6px 12px;font:inherit;cursor:pointer}.card-button:disabled{opacity:.5;cursor:not-allowed}.card-button-quiet{background:transparent;color:var(--card-text);border:1px solid var(--card-line)}
@@ -2,14 +2,9 @@ import type { PluginInfo } from '../../../api/plugins'
2
2
 
3
3
  export type ArgType = 'string' | 'number' | 'boolean' | 'choice'
4
4
  export type Effect = 'read' | 'write' | 'destructive' | 'send'
5
- export type Template = 'console' | 'ledger' | 'monitor' | 'inbox' | 'dashboard' | 'workbench' | 'vault'
6
- export type WidgetKind = 'status' | 'actions' | 'table' | 'detail' | 'log' | 'form' | 'keypad' | 'gauge' | 'list' | 'settings' | 'credentials' | 'activity' | 'live'
7
- export type CellFormat = 'text' | 'number' | 'money' | 'date' | 'datetime' | 'bytes' | 'percent' | 'status'
8
5
 
9
6
  export interface CardArg { name: string; flag?: string; positional?: boolean; label: string; type: ArgType; options?: Array<string | number>; required?: boolean; secret?: boolean }
10
7
  export interface CardCommand { name: string; description?: string; args: CardArg[]; effect: Effect; output: 'json' | 'text'; timeoutMs?: number }
11
- export interface CardColumn { key: string; label: string; format?: CellFormat }
12
- export interface CardRegion { widget: WidgetKind; title?: string; source?: { command: string; args?: string[] }; columns?: CardColumn[]; command?: string; commands?: string[]; span?: number }
13
8
  export interface CardBranding {
14
9
  name: string; color: string; accent: string; font: string; density: 'compact' | 'comfortable' | 'spacious'
15
10
  radius: 'none' | 'sm' | 'md' | 'lg' | 'pill'; header: 'plain' | 'ledger-rule' | 'band' | 'tab-strip' | 'badge-left'
@@ -20,14 +15,15 @@ export interface CardWorkspace {
20
15
  tabs?: string[];
21
16
  permissions?: string[];
22
17
  }
18
+ /** The plugin's own card face. Layout belongs to the plugin, never to a shared template. */
19
+ export interface CardFace { id: string; tag: string; module: string; description?: string }
23
20
  export interface Card {
24
- id: string; title: string; description: string; template: Template; regions: CardRegion[]
21
+ id: string; title: string; description: string; face?: CardFace
25
22
  commands: Map<string, CardCommand>; branding: CardBranding; status?: { command: string; refreshSeconds: number }
26
23
  distribution?: { tier: string; license: string }
27
24
  workspace?: CardWorkspace
28
25
  }
29
26
 
30
- const TEMPLATES: Template[] = ['console', 'ledger', 'monitor', 'inbox', 'dashboard', 'workbench', 'vault']
31
27
  const FONTS = ['system', 'inter', 'ibm-plex', 'space-grotesk', 'jetbrains-mono', 'dm-sans', 'outfit', 'fraunces', 'newsreader', 'chivo-mono']
32
28
  const HEX = /^#[0-9a-fA-F]{6}$/
33
29
  /** Manifest JSON is untyped input; every field is checked before it is used. */
@@ -71,17 +67,15 @@ export function normalizeCard(plugin: PluginInfo): Card {
71
67
  }
72
68
  const status = obj(manifest.status)
73
69
  const statusCommand = typeof status.command === 'string' && commands.has(status.command) ? status.command : undefined
74
- const layout = obj(manifest.card_layout)
75
- const known = (r: Loose) => r && typeof r === 'object'
76
- const regions: CardRegion[] = Array.isArray(layout.regions) ? layout.regions.filter(known).slice(0, 12) : []
77
- const template: Template = TEMPLATES.includes(layout.template) ? layout.template : 'console'
78
- if (regions.length === 0) {
79
- // No declared layout: show real state, every command with its arguments, then configuration.
80
- const elements = plugin.ui?.elements || manifest.ui?.elements || []
81
- if (elements.some((element: Loose) => element.kind === 'card' || element.kind === 'panel')) regions.push({ widget: 'live' })
82
- if (statusCommand) regions.push({ widget: 'status', title: 'Status' })
83
- regions.push({ widget: 'actions', title: 'Commands' }, { widget: 'activity', title: 'Recent activity' })
84
- }
70
+ // The face is the plugin's own component; there is no shared layout to fall back to.
71
+ // Only `kind: 'card'` is the face. A plugin may declare any number of
72
+ // `kind: 'panel'` screens, and one declared before the card is still a
73
+ // secondary screen, never the face.
74
+ const elements: Loose[] = Array.isArray(manifest.ui?.elements) ? manifest.ui.elements : (Array.isArray(plugin.ui?.elements) ? plugin.ui.elements : [])
75
+ const declared = elements.find((element: Loose) => element && typeof element === 'object' && element.kind === 'card')
76
+ const face: CardFace | undefined = declared && typeof declared.id === 'string' && typeof declared.tag === 'string' && typeof declared.module === 'string'
77
+ ? { id: declared.id, tag: declared.tag, module: declared.module, description: typeof declared.description === 'string' ? declared.description : undefined }
78
+ : undefined
85
79
  const ws = obj(manifest.workspace)
86
80
  const workspace: CardWorkspace | undefined = ws && (ws.component || Array.isArray(ws.tabs)) ? {
87
81
  component: typeof ws.component === 'string' ? ws.component : undefined,
@@ -89,7 +83,7 @@ export function normalizeCard(plugin: PluginInfo): Card {
89
83
  permissions: Array.isArray(ws.permissions) ? ws.permissions.filter((p: unknown) => typeof p === 'string') : undefined,
90
84
  } : undefined
91
85
  return {
92
- id: plugin.id, title: branding.name, description: plugin.description, template, regions, commands, branding,
86
+ id: plugin.id, title: branding.name, description: plugin.description, face, commands, branding,
93
87
  status: statusCommand ? { command: statusCommand, refreshSeconds: Number.isInteger(status.refresh_seconds) ? Math.min(86400, Math.max(5, status.refresh_seconds)) : 30 } : undefined,
94
88
  distribution: obj(manifest.distribution).tier ? { tier: manifest.distribution.tier, license: manifest.distribution.license } : undefined,
95
89
  workspace,
@@ -1,4 +1,11 @@
1
- import type { CellFormat, Loose } from './contract'
1
+ import type { Loose } from './contract'
2
+
3
+ /**
4
+ * How a scalar is rendered. This is presentation owned by the card face that
5
+ * asks for it — not a template primitive. The layout vocabulary (`CardRegion`,
6
+ * `CardColumn`, `CellFormat`) that once shared these names is deleted.
7
+ */
8
+ export type CellFormat = 'text' | 'number' | 'money' | 'percent' | 'bytes' | 'date' | 'datetime'
2
9
 
3
10
  export type Tone = 'ok' | 'warn' | 'error' | 'neutral'
4
11
  const GOOD = /^(ok|healthy|active|connected|ready|running|verified|delivered|sent|passed|pass|up|online|true|complete|completed)$/i
@@ -3,8 +3,8 @@ import type { PluginInfo } from '../../../api/plugins'
3
3
  import { PluginSettings } from '../PluginSettings'
4
4
  import { defaultValue, missingRequired, visibleArgs, type ArgValues } from './args'
5
5
  import { cliLine } from './cli-line'
6
- import type { CardColumn, CardCommand, CardRegion } from './contract'
7
- import { formatCell, rowsOf, scalarsOf, toneOf } from './format'
6
+ import type { CardCommand } from './contract'
7
+ import { scalarsOf, toneOf } from './format'
8
8
  import { useRunner, type RunResult } from './runner-context'
9
9
  import { useSource } from './use-source'
10
10
 
@@ -58,10 +58,10 @@ function ReadForm({ command }: { command: CardCommand }) {
58
58
  }
59
59
 
60
60
  /** Every declared operation, with arguments, explicit mutation confirmation and secure stdin. */
61
- export function CommandsWidget({ region }: { region: CardRegion }) {
61
+ export function CommandsWidget() {
62
62
  const { card } = useRunner()
63
63
  const cliName = card.id
64
- const names = region.commands || [...card.commands.keys()]
64
+ const names = [...card.commands.keys()]
65
65
  const [open, setOpen] = useState('')
66
66
  const [copied, setCopied] = useState('')
67
67
  return <ul className="card-commands">
@@ -83,37 +83,6 @@ export function CommandsWidget({ region }: { region: CardRegion }) {
83
83
  </ul>
84
84
  }
85
85
 
86
- function Cell({ value, column }: { value: unknown; column: CardColumn }) {
87
- return column.format === 'status' ? <Pill tone={toneOf(value)}>{formatCell(value)}</Pill> : <>{formatCell(value, column.format)}</>
88
- }
89
-
90
- /** table, list, detail, gauge and log all read one declared command and show it. */
91
- export function DataWidget({ region }: { region: CardRegion }) {
92
- const { result, loading } = useSource(region.source?.command, region.source?.args)
93
- if (!result) return <p className="card-muted">{loading ? 'Loading…' : ''}</p>
94
- if (!result.ok) return <Failure result={result} />
95
- const { widget } = region
96
- if (widget === 'log') return <pre className="card-pre">{result.text || JSON.stringify(result.data, null, 2)}</pre>
97
- if (widget === 'gauge') {
98
- const d = result.data || {}
99
- const observedValue = d.value ?? d.used ?? d.percent
100
- const observedMax = d.max ?? d.total ?? (d.percent !== undefined ? 100 : undefined)
101
- const value = Number(observedValue), max = Number(observedMax)
102
- if (observedValue == null || observedMax == null || !Number.isFinite(value) || !Number.isFinite(max) || max <= 0 || value < 0 || value > max) {
103
- return <p role="status" className="card-muted">No valid usage measurement is available.</p>
104
- }
105
- const pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0
106
- return <div><div className="card-gauge" role="meter" aria-valuenow={value} aria-valuemin={0} aria-valuemax={max}><span style={{ width: `${pct}%` }} /></div><p className="card-muted">{formatCell(value, 'number')} of {formatCell(max, 'number')}</p></div>
107
- }
108
- if (widget === 'detail') return <dl className="card-kv">{scalarsOf(result.data).map(([k, v]) => <div key={k}><dt>{k.replace(/_/g, ' ')}</dt><dd>{v}</dd></div>)}</dl>
109
- const rows = rowsOf(result.data)
110
- if (rows.length === 0) return <p className="card-muted">Nothing to show yet.</p>
111
- if (widget === 'list') return <ul className="card-list">{rows.map((row, i) => <li key={i}>{typeof row === 'object' && row ? String(row.title ?? row.name ?? row.id ?? JSON.stringify(row)) : String(row)}</li>)}</ul>
112
- const columns = region.columns || Object.keys(rows[0] || {}).slice(0, 5).map(key => ({ key, label: key }))
113
- return <div className="card-scroll"><table className="card-table"><thead><tr>{columns.map(c => <th key={c.key} scope="col">{c.label}</th>)}</tr></thead>
114
- <tbody>{rows.map((row, i) => <tr key={i}>{columns.map(c => <td key={c.key}><Cell value={row?.[c.key]} column={c} /></td>)}</tr>)}</tbody></table></div>
115
- }
116
-
117
86
  export function SettingsWidget({ plugin }: { plugin: PluginInfo }) {
118
87
  return <PluginSettings plugin={plugin} />
119
88
  }
@@ -1,7 +1,21 @@
1
1
  const inlineJson = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c')
2
2
 
3
+ export interface PluginScreen { id: string; label: string }
4
+
5
+ /** Bound untrusted screen metadata without deriving any screen from commands. */
6
+ export function sanitizePluginScreens(value: unknown): PluginScreen[] {
7
+ if (!Array.isArray(value)) return []
8
+ const seen = new Set<string>()
9
+ return value.slice(0, 128).flatMap(item => {
10
+ if (!item || typeof item !== 'object' || typeof item.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(item.id)
11
+ || typeof item.label !== 'string' || !item.label.trim() || item.label.length > 160 || /[\u0000-\u001f\u007f]/.test(item.label) || seen.has(item.id)) return []
12
+ seen.add(item.id)
13
+ return [{ id: item.id, label: item.label.trim() }]
14
+ })
15
+ }
16
+
3
17
  /** Owner code runs in an opaque origin, with no dashboard DOM/storage or direct network. */
4
- export function pluginFrameDocument(source: string, tag: string, channel: string, theme: Record<string, string> = {}, pluginId = '', commandTimeouts: Record<string, number> = {}) {
18
+ export function pluginFrameDocument(source: string, tag: string, channel: string, theme: Record<string, string> = {}, pluginId = '', commandTimeouts: Record<string, number> = {}, operations: readonly string[] = []) {
5
19
  return `<!doctype html><html><head>
6
20
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-${channel}' blob:; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; img-src data: https:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
7
21
  <meta name="referrer" content="no-referrer">
@@ -13,15 +27,27 @@ export function pluginFrameDocument(source: string, tag: string, channel: string
13
27
  const channel=${inlineJson(channel)};
14
28
  const pending=new Map();let sequence=0;
15
29
  const send=(type,extra={})=>parent.postMessage({channel,type,...extra},'*');
30
+ let ownerScreens=[];
31
+ const sanitizeScreens=${sanitizePluginScreens.toString()};
32
+ function publishScreens(element){
33
+ ownerScreens=sanitizeScreens(element.screens);
34
+ send('screens',{screens:ownerScreens});
35
+ if(ownerScreens.some(screen=>screen.id===element.screen))host.screenChanged(element.screen);
36
+ }
16
37
  window.addEventListener('contextmenu',event=>{event.preventDefault();send('contextmenu');});
17
38
  for(const [name,value] of Object.entries(${inlineJson(theme)}))document.documentElement.style.setProperty(name,value);
18
39
  window.addEventListener('message',event=>{
19
- if(event.source!==parent||event.data?.channel!==channel||event.data?.type!=='result')return;
40
+ if(event.source!==parent||event.data?.channel!==channel)return;
41
+ if(event.data.type==='show-screen'){
42
+ if(ownerScreens.some(screen=>screen.id===event.data.screen))window.dispatchEvent(new CustomEvent('plugin-show-screen',{detail:{pluginId:${inlineJson(pluginId)},screen:event.data.screen}}));
43
+ return;
44
+ }
45
+ if(event.data.type!=='result')return;
20
46
  const request=pending.get(event.data.requestId);if(!request)return;
21
47
  pending.delete(event.data.requestId);clearTimeout(request.timer);
22
48
  if(event.data.error)request.reject(new Error(event.data.error));else request.resolve(event.data.result);
23
49
  });
24
- const host=Object.freeze({pluginId:${inlineJson(pluginId)},run(subcommand,args=[],options={}){return new Promise((resolve,reject)=>{
50
+ const host=Object.freeze({pluginId:${inlineJson(pluginId)},operations:Object.freeze(${inlineJson(operations)}),screenChanged(screen){if(ownerScreens.some(item=>item.id===screen))send('screen-changed',{screen});},run(subcommand,args=[],options={}){return new Promise((resolve,reject)=>{
25
51
  const requestId=String(++sequence);
26
52
  const declared=${inlineJson(commandTimeouts)}[subcommand];
27
53
  const timeout=Number.isInteger(declared)&&declared>=1&&declared<=600000?declared:30000;
@@ -34,6 +60,7 @@ try{
34
60
  const tag=${inlineJson(tag)};
35
61
  if(!customElements.get(tag))throw new Error('The plugin did not register its declared interface');
36
62
  const element=document.createElement(tag);element.host=host;document.body.append(element);
63
+ publishScreens(element);
37
64
  if(typeof element.load==='function'&&!element.hasAttribute('data-loaded')){try{element.load();}catch(_){}}
38
65
  if('view' in element||tag==='domains-console'||tag==='stripe-panel'){
39
66
  host.run('view').then(v=>{element.view=v;}).catch(()=>{
@@ -828,7 +828,8 @@ export default function PluginsPage({ activeBrainId }: PluginsPageProps) {
828
828
  )}
829
829
  </Empty>
830
830
  ) : (
831
- <div className="card-grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 360px), 1fr))", gap: "22px" }}>
831
+ // Plugin faces are wide surfaces with screens to flip through, never narrow columns.
832
+ <div className="card-grid" style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 720px), 1fr))", gap: "22px" }}>
832
833
  {shownInstalled.map((p) => {
833
834
  return (
834
835
  <div
@@ -836,7 +837,7 @@ export default function PluginsPage({ activeBrainId }: PluginsPageProps) {
836
837
  onContextMenu={(e) => handleContextMenu(p, e)}
837
838
  onTouchStart={(e) => handleTouchStart(p, e)}
838
839
  onTouchEnd={cancelLongPress}
839
- style={{ display: "flex", flexDirection: "column", gap: "8px" }}
840
+ style={{ display: "flex", flexDirection: "column", gap: "8px", minWidth: 0 }}
840
841
  >
841
842
  <CardEngine plugin={p} />
842
843
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0 4px", fontSize: "11px", color: "var(--text-tertiary)", flexWrap: "wrap", gap: "6px" }}>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.40.6",
3
+ "version": "3.41.0",
4
4
  "description": "The Vibe Coding Brain & Autonomous Software Factory: memory, rules, secrets, and 33 visual plugins automatically managed across all your devices.",
5
5
  "type": "module",
6
6
  "bin": {