@unoverse-platform/studio 0.1.115 → 0.1.116

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 (37) hide show
  1. package/local/catalog.ts +3 -0
  2. package/local/plugin.ts +12 -9
  3. package/package.json +1 -1
  4. package/screens/AgentChain.tsx +216 -0
  5. package/screens/AgentsView.tsx +45 -6
  6. package/screens/SkillsView.tsx +21 -3
  7. package/screens/index.ts +3 -0
  8. package/vendor/base/src/agent-mcp/discovery.ts +4 -1
  9. package/vendor/base/src/agent-mcp/invoke.ts +155 -25
  10. package/vendor/base/src/agent-mcp/tools.ts +17 -10
  11. package/vendor/base/src/agent-mcp/types.ts +8 -0
  12. package/vendor/base/src/auth/agentSwitch.ts +52 -0
  13. package/vendor/base/src/auth/agentToken.ts +142 -0
  14. package/vendor/base/src/auth/auth.ts +7 -0
  15. package/vendor/base/src/definitions/load/load.ts +6 -1
  16. package/vendor/base/src/definitions/registry/agentFolders.ts +113 -0
  17. package/vendor/base/src/definitions/registry/objects.ts +16 -0
  18. package/vendor/base/src/definitions/registry/pipelines.ts +13 -5
  19. package/vendor/base/src/definitions/registry/taskStarts.ts +76 -0
  20. package/vendor/base/src/definitions/registry/taskUri.ts +67 -0
  21. package/vendor/base/src/lint/design/index.mjs +10 -2
  22. package/vendor/base/src/lint/objects/index.d.mts +1 -0
  23. package/vendor/base/src/lint/objects/index.mjs +120 -2
  24. package/vendor/base/src/lint/objects/rules.mjs +16 -5
  25. package/vendor/base/src/manifests/executor/toolBridge.ts +10 -3
  26. package/vendor/base/src/manifests/marketplaceMcp.ts +20 -0
  27. package/vendor/base/src/manifests/runtime/http/request.ts +4 -1
  28. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +58 -23
  29. package/vendor/base/src/manifests/runtime/usage.ts +13 -5
  30. package/vendor/base/src/platform/agentBudget.ts +65 -0
  31. package/vendor/base/src/platform/agentCalendar.ts +87 -0
  32. package/vendor/base/src/platform/taskChanged.ts +25 -0
  33. package/vendor/base/src/platform/tokenCost.ts +395 -0
  34. package/vendor/base/src/security/permissions.ts +10 -2
  35. package/vendor/sdk/appEngine.ts +42 -1
  36. package/vendor/sdk/lib/dom/markdown.ts +6 -2
  37. package/vendor/sdk/pageInterfaces.ts +109 -8
package/local/catalog.ts CHANGED
@@ -28,6 +28,7 @@ type Loaders = {
28
28
  listIdentity: (org?: string) => unknown[];
29
29
  /** The Agents of one org or every org: `<org>/<name>` items with their meta and door. */
30
30
  listAgents: (org?: string) => unknown[];
31
+ listAgentFolders: (org?: string) => unknown[];
31
32
  resolveTheme: (name: string) => unknown;
32
33
  DEFAULT_ORG: string;
33
34
  /** A project's on-disk home. The writable set is built from these (see source.ts). */
@@ -112,6 +113,7 @@ export async function loadCatalog(
112
113
  const homes = await load(resolve(baseSrc, "definitions/registry/homes.ts"));
113
114
  const identity = await load(resolve(baseSrc, "definitions/registry/orgDocuments.ts"));
114
115
  const agents = await load(resolve(baseSrc, "definitions/registry/agents.ts"));
116
+ const agentFolders = await load(resolve(baseSrc, "definitions/registry/agentFolders.ts"));
115
117
 
116
118
  return {
117
119
  listDefinitions: defs.listDefinitions as Loaders["listDefinitions"],
@@ -135,6 +137,7 @@ export async function loadCatalog(
135
137
  return out;
136
138
  }) as Loaders["listIdentity"],
137
139
  listAgents: agents.listAgents as Loaders["listAgents"],
140
+ listAgentFolders: agentFolders.listAgentFolders as Loaders["listAgentFolders"],
138
141
  resolveTheme: theme.resolveTheme as Loaders["resolveTheme"],
139
142
  DEFAULT_ORG: theme.DEFAULT_ORG as string,
140
143
  projectDir: defs.projectDir as Loaders["projectDir"],
package/local/plugin.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * See docs/architecture/authoring/LOCAL_STUDIO.md.
17
17
  */
18
- import { designHome, authoringRoot, orgRepoName, lintOptions, platformHome } from "./design-home.mjs";
18
+ import { authoringRoot, orgRepoName, lintOptions, platformHome } from "./design-home.mjs";
19
19
  import { createRequire } from "node:module";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { resolve, dirname } from "path";
@@ -177,12 +177,15 @@ function readMarkdownLibrary(dir: string): MarkdownEntry[] {
177
177
  * Scoping is the server's `?org=`: an org sees the universal tier plus its own and never
178
178
  * another org's, the rule RX_ORG_MODEL.md settled for a project's assets.
179
179
  */
180
- function readLibrary(projectRoot: string, marketplace: string, folder: "skills" | "blocks", orgs: string[], org?: string): MarkdownEntry[] {
180
+ function readLibrary(projectDir: (org: string) => string, marketplace: string, folder: "skills" | "blocks", orgs: string[], org?: string): MarkdownEntry[] {
181
181
  const out = readMarkdownLibrary(resolve(marketplace, folder));
182
182
  for (const o of orgs) {
183
183
  if (org && org !== "all" && o !== org) continue;
184
- // The org's own folder: the repo root in an org repo (UNO-88), else design/<org>/.
185
- const orgHome = orgRepoName(projectRoot) ? projectRoot : resolve(designHome(projectRoot), o);
184
+ // The org's own folder, found as every other screen here finds it (base projectDir): the
185
+ // repo root in an org repo (UNO-88), else its authored folder, else the copy the universe
186
+ // installed. Working it out here alone missed the installed copy, so Studio run from the
187
+ // platform listed no org's skills while the server listed them all.
188
+ const orgHome = projectDir(o);
186
189
  for (const e of readMarkdownLibrary(resolve(orgHome, folder))) {
187
190
  // A SKILL's lookup name carries its org, because that is the name every reader
188
191
  // addresses it by and two orgs may hold one bare name. A BLOCK's does not: the
@@ -445,9 +448,9 @@ export function localStudioHost(): Plugin {
445
448
  case path === "/dev/identity":
446
449
  return json(res, { items: c.listIdentity(org) });
447
450
 
448
- // Agents: the one file authored for the map, design/<org>/agents/<name>.yaml.
451
+ // Agents: folders in the one shape (agents/<name>/agent.yaml), and the older flat files.
449
452
  case path === "/dev/agents":
450
- return json(res, { items: c.listAgents(org) });
453
+ return json(res, { items: c.listAgents(org), folders: c.listAgentFolders(org) });
451
454
 
452
455
  case path === "/dev/atoms":
453
456
  return json(res, { atoms: c.listDefinitions("atom") });
@@ -611,17 +614,17 @@ export function localStudioHost(): Plugin {
611
614
  return json(res, selection ?? { empty: true });
612
615
 
613
616
  case path === "/skills":
614
- return json(res, { skills: readLibrary(projectRoot, c.marketplace, "skills", c.listOrgs(), org) });
617
+ return json(res, { skills: readLibrary(c.projectDir, c.marketplace, "skills", c.listOrgs(), org) });
615
618
 
616
619
  case path.startsWith("/skills/"): {
617
620
  const name = decodeURIComponent(path.slice("/skills/".length).replace(/\/file$/, ""));
618
- const found = readLibrary(projectRoot, c.marketplace, "skills", c.listOrgs()).find((s) => s.name === name || s.id === name);
621
+ const found = readLibrary(c.projectDir, c.marketplace, "skills", c.listOrgs()).find((s) => s.name === name || s.id === name);
619
622
  // SkillsView reads `d.skill`, matching the server's shape. Not the bare object.
620
623
  return found ? json(res, { skill: found }) : json(res, { error: "skill not found" }, 404);
621
624
  }
622
625
 
623
626
  case path === "/prompt-blocks":
624
- return json(res, { blocks: readLibrary(projectRoot, c.marketplace, "blocks", c.listOrgs(), org) });
627
+ return json(res, { blocks: readLibrary(c.projectDir, c.marketplace, "blocks", c.listOrgs(), org) });
625
628
 
626
629
  // Registry toggles are a DEPLOYED concept: they persist to installed_plugins
627
630
  // and decide what a running universe exposes. Local Studio has no database and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.115",
3
+ "version": "0.1.116",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -0,0 +1,216 @@
1
+ /**
2
+ * AN AGENT, DRAWN (docs/architecture/signal/AGENTS.md § 3): a chain read straight off its
3
+ * agent.yaml, the way a pipeline's chain is read off its pipeline.yaml (Spatial's PipelineChain).
4
+ * Three kinds, drawn as three: its SKILLS feed in from above (what it knows: read into it before
5
+ * it starts, never called), its TRIGGERS wake it, and its TASKS are what it does (its org's own,
6
+ * then the marketplace MCPs). Below: its soul, access and settings. Nothing here is written for any
7
+ * one Agent: a new folder draws its own chain.
8
+ */
9
+ import { BookOpen, CalendarDays, ChevronDown, ChevronRight, Compass, Mail, MessageSquare, Mic, Plug, Radio, Workflow } from "lucide-react";
10
+
11
+ export interface AgentFolderItem {
12
+ ref: string;
13
+ org: string;
14
+ name: string;
15
+ title?: string;
16
+ role?: string;
17
+ description?: string;
18
+ whenToUse?: string;
19
+ category?: string;
20
+ version?: string;
21
+ /** Its picture, an image URL. */
22
+ avatar?: string;
23
+ skills: string[];
24
+ model?: { use?: string; reasoning?: string; fallback?: string };
25
+ budget?: { perDay?: string; perRun?: string };
26
+ tasks: string[];
27
+ access: string[];
28
+ triggers: { name: string; node?: string; wakes?: string }[];
29
+ checkIn?: string;
30
+ memory?: { agent?: string };
31
+ askWhenUnsure?: string;
32
+ soul: string;
33
+ path: string;
34
+ /** Each skill in words, from its SKILL.md (server: devRoutes `withAbout`). */
35
+ skillsAbout?: { name: string; title: string; description: string }[];
36
+ /** Each marketplace MCP among its tasks, by URI, as its node names itself. */
37
+ mcpsAbout?: Record<string, { title: string; description: string }>;
38
+ }
39
+
40
+ const WAY_ICONS: Record<string, typeof Radio> = { spatial: Compass, slack: MessageSquare, email: Mail, voice: Mic, calendar: CalendarDays };
41
+ const WAKES: Record<string, string> = { mentions: "wakes on a mention", replies: "wakes on a reply", events: "wakes when an event starts" };
42
+ /** What a permission lets it reach, in words; the strict ones said plainly (AGENTS.md § 7a). */
43
+ const ACCESS_WORDS: Record<string, string> = {
44
+ "signal:read-all": "Can see everyone in Signal",
45
+ "signal:write": "Can add people to Signal",
46
+ };
47
+
48
+ /** Triggers | › | the Agent | › | what it does: the skills row above shares these columns. */
49
+ const COLUMNS = "minmax(0,1fr) auto minmax(0,1.1fr) auto minmax(0,1fr)";
50
+ const LABEL = "mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-500";
51
+ const TILE = "flex items-center gap-2.5 rounded-lg border border-gray-200 bg-white px-3 py-2";
52
+ const GLYPH = "flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-white text-gray-800";
53
+
54
+ /** `unoverse://tasks/<org>/<name>` → its name, and its org when it is not the Agent's own. */
55
+ function taskLabel(uri: string, org: string): { name: string; from?: string } {
56
+ const m = /^unoverse:\/\/tasks\/([^/]+)\/(.+)$/.exec(uri);
57
+ if (!m) return { name: uri };
58
+ return m[1] === org ? { name: m[2] } : { name: m[2], from: m[1] };
59
+ }
60
+
61
+ export function AgentChain({ agent: given }: { agent: AgentFolderItem }) {
62
+ // Every list may be absent (a hand-written file, an older server): drawn as empty, never a crash.
63
+ const agent: AgentFolderItem = {
64
+ ...given,
65
+ skills: given.skills ?? [],
66
+ tasks: given.tasks ?? [],
67
+ access: given.access ?? [],
68
+ triggers: given.triggers ?? [],
69
+ soul: given.soul ?? "",
70
+ };
71
+ const ways = agent.triggers;
72
+ // Its skills in words; a server that sends none still draws each by name.
73
+ const skills = agent.skillsAbout ?? agent.skills.map((name) => ({ name, title: name, description: "" }));
74
+ // What it does, in two kinds: its org's own tasks, then the marketplace MCPs it was given.
75
+ const mcps = agent.tasks.filter((uri) => agent.mcpsAbout?.[uri]);
76
+ const own = agent.tasks.filter((uri) => !agent.mcpsAbout?.[uri]);
77
+ return (
78
+ <>
79
+ {/* WHAT IT KNOWS: its skills, feeding into it from above */}
80
+ <div className="mb-1 grid gap-3" style={{ gridTemplateColumns: COLUMNS }}>
81
+ <div />
82
+ <ChevronRight size={18} className="invisible" />
83
+ <div>
84
+ <div className={LABEL}>Skills · how it works</div>
85
+ {skills.length === 0 && <div className="text-[12px] text-gray-500">None</div>}
86
+ <div className="flex flex-col gap-2">
87
+ {skills.map((k) => (
88
+ <div key={k.name} className="flex items-start gap-2.5 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-2" title={k.name}>
89
+ <span className={GLYPH}><BookOpen size={15} strokeWidth={1.8} /></span>
90
+ <div className="min-w-0">
91
+ <div className="truncate text-[13px] font-medium text-gray-900">{k.title}</div>
92
+ {k.description && <div className="line-clamp-2 text-[11px] text-gray-500">{k.description}</div>}
93
+ </div>
94
+ </div>
95
+ ))}
96
+ </div>
97
+ <div className="flex justify-center py-1 text-gray-300"><ChevronDown size={18} /></div>
98
+ </div>
99
+ <ChevronRight size={18} className="invisible" />
100
+ <div />
101
+ </div>
102
+
103
+ {/* THE CHAIN: triggers → the Agent → what it does */}
104
+ {/* Top-aligned: the Agent sits right under its skills, however many tasks it has. */}
105
+ <div className="mb-6 grid items-start gap-3" style={{ gridTemplateColumns: COLUMNS }}>
106
+ <div className="flex flex-col gap-2">
107
+ <div className={LABEL}>Triggers · what wakes it</div>
108
+ {ways.length === 0 && <div className="text-[12px] text-gray-500">None yet</div>}
109
+ {ways.map((w) => {
110
+ const Icon = WAY_ICONS[w.name] ?? Radio;
111
+ return (
112
+ <div key={w.name} className={TILE}>
113
+ <span className={GLYPH}><Icon size={15} strokeWidth={1.8} /></span>
114
+ <div className="min-w-0">
115
+ <div className="truncate text-[13px] font-medium capitalize text-gray-900">{w.name}</div>
116
+ <div className="truncate text-[11px] text-gray-500">{w.name === "spatial" ? "found in Spatial" : [w.node, w.wakes ? WAKES[w.wakes] ?? w.wakes : null].filter(Boolean).join(", ")}</div>
117
+ </div>
118
+ </div>
119
+ );
120
+ })}
121
+ </div>
122
+
123
+ <ChevronRight size={18} className="mt-14 text-gray-300" />
124
+
125
+ <div className="mt-6 rounded-xl border border-gray-300 bg-gray-50 p-4">
126
+ <div className="mb-3 flex items-center gap-3">
127
+ {agent.avatar && <img src={agent.avatar} alt="" className="h-12 w-12 shrink-0 rounded-full border border-gray-200 object-cover" />}
128
+ <div className="min-w-0">
129
+ <div className="text-[15px] font-semibold text-gray-900">{agent.title ?? agent.name}</div>
130
+ {agent.role && <div className="text-[12px] text-gray-600">{agent.role}</div>}
131
+ </div>
132
+ </div>
133
+ <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12px]">
134
+ <dt className="text-gray-500">Model</dt>
135
+ <dd className="truncate font-mono text-gray-800">{agent.model?.use ?? "not set"}</dd>
136
+ <dt className="text-gray-500">Budget</dt>
137
+ <dd className="text-gray-800">
138
+ {agent.budget?.perDay ? `${agent.budget.perDay} a day` : "not set"}
139
+ {agent.budget?.perRun ? `, ${agent.budget.perRun} a run` : ""}
140
+ </dd>
141
+ <dt className="text-gray-500">Memory</dt>
142
+ <dd className="text-gray-800">{agent.memory?.agent === "on" ? "its own" : "off"}</dd>
143
+ </dl>
144
+ </div>
145
+
146
+ <ChevronRight size={18} className="mt-14 text-gray-300" />
147
+
148
+ <div className="flex flex-col gap-2">
149
+ <div className={LABEL}>Tasks · what it does</div>
150
+ {agent.tasks.length === 0 && <div className="text-[12px] text-gray-500">None</div>}
151
+ {own.map((uri) => {
152
+ const t = taskLabel(uri, agent.org);
153
+ return (
154
+ <div key={uri} className={TILE} title={uri}>
155
+ <span className={GLYPH}><Workflow size={15} strokeWidth={1.8} /></span>
156
+ <div className="min-w-0">
157
+ <div className="truncate text-[13px] font-medium text-gray-900">{t.name.replace(/-/g, " ")}</div>
158
+ {t.from && <div className="truncate text-[11px] text-gray-500">from {t.from}</div>}
159
+ </div>
160
+ </div>
161
+ );
162
+ })}
163
+ {mcps.length > 0 && <div className={`${LABEL} mt-2`}>From the marketplace</div>}
164
+ {mcps.map((uri) => {
165
+ const m = agent.mcpsAbout![uri];
166
+ return (
167
+ <div key={uri} className={TILE} title={uri}>
168
+ <span className={GLYPH}><Plug size={15} strokeWidth={1.8} /></span>
169
+ <div className="min-w-0">
170
+ <div className="truncate text-[13px] font-medium text-gray-900">{m.title}</div>
171
+ {m.description && <div className="line-clamp-2 text-[11px] text-gray-500">{m.description}</div>}
172
+ </div>
173
+ </div>
174
+ );
175
+ })}
176
+ </div>
177
+ </div>
178
+
179
+ {/* WHO IT IS */}
180
+ <div className={LABEL}>Soul</div>
181
+ {agent.soul.trim() ? (
182
+ <div className="mb-5 space-y-2 rounded-lg border border-gray-200 bg-gray-50 p-4 text-[13px] leading-relaxed text-gray-800">
183
+ {/* Paragraphs as written: a blank line parts them, a line break inside one is a space. */}
184
+ {agent.soul.trim().split(/\n\s*\n/).map((para, i) => (
185
+ <p key={i}>{para.replace(/\s*\n\s*/g, " ")}</p>
186
+ ))}
187
+ </div>
188
+ ) : (
189
+ <p className="mb-5 text-[12px] text-gray-500">No SOUL.md beside agent.yaml.</p>
190
+ )}
191
+
192
+ <div className={LABEL}>Access</div>
193
+ <div className="mb-5 flex flex-wrap gap-1.5">
194
+ {agent.access.length === 0 && <span className="text-[12px] text-gray-500">No one's information</span>}
195
+ {agent.access.map((a) => (
196
+ <span key={a} title={a} className="rounded-full border border-gray-200 bg-white px-2 py-0.5 text-[11px] text-gray-700">
197
+ {ACCESS_WORDS[a] ?? a}
198
+ </span>
199
+ ))}
200
+ </div>
201
+
202
+ <div className={LABEL}>Settings</div>
203
+ <dl className="mb-5 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-lg border border-gray-200 bg-gray-50 p-4 text-[12px] text-gray-800">
204
+ {agent.model?.reasoning && (<><dt className="text-gray-500">Reasoning</dt><dd>{agent.model.reasoning}</dd></>)}
205
+ {agent.model?.fallback && (<><dt className="text-gray-500">Fallback model</dt><dd className="font-mono">{agent.model.fallback}</dd></>)}
206
+ <dt className="text-gray-500">Check-in</dt>
207
+ <dd>{agent.checkIn ? `every ${agent.checkIn}` : "only when reached"}</dd>
208
+ {agent.askWhenUnsure && (<><dt className="text-gray-500">Asks when unsure</dt><dd>in {agent.askWhenUnsure}</dd></>)}
209
+ <dt className="text-gray-500">Email</dt>
210
+ <dd>its connected mailbox, set in Signal</dd>
211
+ <dt className="text-gray-500">Folder</dt>
212
+ <dd className="font-mono">{agent.path}</dd>
213
+ </dl>
214
+ </>
215
+ );
216
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Agents view — the Agents a project authored (docs.unoverse.ai/design/tasks). Left: a
3
- * navigator. Right: the selected Agent's meta, its door, and the Available switch.
2
+ * Agents view — the Agents a project authored. Left: a navigator. Right: the selected Agent.
3
+ * An Agent in the one shape (agents/<name>/agent.yaml and its soul, AGENTS.md § 3) is drawn as a
4
+ * chain read off its file (AgentChain); an older flat Agent file shows its door until converted.
4
5
  *
5
6
  * An Agent is a workflow with no interface of its own, listed on the map so a conversation
6
7
  * can reach for it: the one thing authored for the map alone (`design/<org>/agents/<name>.yaml`).
@@ -13,6 +14,7 @@ import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-hos
13
14
  import { IdentityHeader } from "./IdentityHeader";
14
15
  import { DOC, EMPTY, NAV, NAV_SECTION, PANE, SCREEN, navItem } from "./chrome";
15
16
  import { EmptyLibrary } from "./EmptyLibrary";
17
+ import { AgentChain, type AgentFolderItem } from "./AgentChain";
16
18
 
17
19
  interface AgentItem {
18
20
  /** `<org>/<name>`: the identity every registry row and toggle keys on. */
@@ -42,6 +44,8 @@ const AGENT_ICON = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="ht
42
44
 
43
45
  export function AgentsView({ org = "all" }: { org?: string } = {}) {
44
46
  const [agents, setAgents] = useState<AgentItem[]>([]);
47
+ // AGENTS IN THE ONE SHAPE (agents/<name>/agent.yaml, AGENTS.md § 3), beside the older files.
48
+ const [folders, setFolders] = useState<AgentFolderItem[]>([]);
45
49
  const available = useRegistryToggles("agent");
46
50
  const [selected, setSelected] = usePersistentState<string | null>("wb:agents:selected", null);
47
51
  const [error, setError] = useState<string | null>(null);
@@ -51,15 +55,18 @@ export function AgentsView({ org = "all" }: { org?: string } = {}) {
51
55
  .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
52
56
  .then((d) => {
53
57
  const list: AgentItem[] = d.items ?? [];
58
+ const own: AgentFolderItem[] = d.folders ?? [];
54
59
  setAgents(list);
55
- const restore = list.find((a) => a.name === selected) ?? list[0];
56
- setSelected(restore ? restore.name : null);
60
+ setFolders(own);
61
+ const keys = [...own.map((f) => f.ref), ...list.map((a) => a.name)];
62
+ setSelected(keys.includes(selected ?? "") ? selected : (keys[0] ?? null));
57
63
  })
58
64
  .catch((e) => setError(String(e?.message ?? e)));
59
65
  // eslint-disable-next-line react-hooks/exhaustive-deps
60
66
  }, [org]);
61
67
 
62
68
  const detail = agents.find((a) => a.name === selected) ?? null;
69
+ const folder = folders.find((f) => f.ref === selected) ?? null;
63
70
 
64
71
  const byCategory = Object.entries(
65
72
  agents.reduce<Record<string, AgentItem[]>>((acc, a) => {
@@ -68,7 +75,7 @@ export function AgentsView({ org = "all" }: { org?: string } = {}) {
68
75
  }, {}),
69
76
  ).sort(([a], [b]) => a.localeCompare(b));
70
77
 
71
- if (agents.length === 0) {
78
+ if (agents.length === 0 && folders.length === 0) {
72
79
  return (
73
80
  <div className={SCREEN}>
74
81
  <nav className={NAV} />
@@ -87,6 +94,24 @@ export function AgentsView({ org = "all" }: { org?: string } = {}) {
87
94
  <div className={SCREEN}>
88
95
  {/* NAVIGATOR */}
89
96
  <nav className={NAV}>
97
+ {folders.length > 0 && (
98
+ <div className="mb-3">
99
+ <div className={NAV_SECTION}>Agents</div>
100
+ {folders.map((f) => (
101
+ <button
102
+ key={f.ref}
103
+ onClick={() => setSelected(f.ref)}
104
+ className={`block w-full rounded px-2 py-1.5 text-left transition ${navItem(selected === f.ref)}`}
105
+ >
106
+ <div className="flex min-w-0 items-baseline gap-2 text-sm font-medium">
107
+ <span className="min-w-0 flex-1 truncate">{f.title ?? f.name}</span>
108
+ <span className="shrink-0 text-[10px] text-gray-500">{f.org}</span>
109
+ </div>
110
+ <div className="truncate text-[11px] text-gray-500">{f.role ?? f.description}</div>
111
+ </button>
112
+ ))}
113
+ </div>
114
+ )}
90
115
  {byCategory.map(([cat, items]) => (
91
116
  <div key={cat} className="mb-3">
92
117
  <div className={NAV_SECTION}>{cat}</div>
@@ -109,7 +134,21 @@ export function AgentsView({ org = "all" }: { org?: string } = {}) {
109
134
 
110
135
  {/* DETAIL */}
111
136
  <div className={PANE}>
112
- {!selected || !detail ? (
137
+ {selected && folder ? (
138
+ <div className={DOC}>
139
+ <IdentityHeader
140
+ right={<ToggleSwitch on={available.isEnabled(selected)} onClick={() => available.toggle(selected)} label="Available" />}
141
+ logoUrl={folder.avatar ?? AGENT_ICON}
142
+ accent={ACCENT}
143
+ name={folder.title ? `${folder.title}${folder.role ? `, ${folder.role}` : ""}` : selected}
144
+ category={folder.category}
145
+ version={folder.version}
146
+ description={folder.description}
147
+ whenToUse={folder.whenToUse}
148
+ />
149
+ <AgentChain agent={folder} />
150
+ </div>
151
+ ) : !selected || !detail ? (
113
152
  <div className={EMPTY}>Select an Agent</div>
114
153
  ) : (
115
154
  <div className={DOC}>
@@ -14,6 +14,7 @@
14
14
  * "run". So this is a neutral chrome surface, not a themed render.
15
15
  */
16
16
  import { useEffect, useState } from "react";
17
+ import Markdown from "markdown-to-jsx";
17
18
  import { fetchUnoverse } from "studio-host";
18
19
  import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
19
20
  import { IdentityHeader } from "./IdentityHeader";
@@ -182,9 +183,9 @@ export function SkillsView({ org = "all" }: { org?: string } = {}) {
182
183
  {loadingDetail && !detail ? (
183
184
  <div className="text-sm text-gray-500">Loading…</div>
184
185
  ) : (
185
- <pre className="overflow-x-auto whitespace-pre-wrap rounded-lg border border-gray-200 bg-gray-50 p-4 font-mono text-[12.5px] leading-relaxed text-gray-800">
186
- {detail?.instructions ?? ""}
187
- </pre>
186
+ <div className="rounded-lg border border-gray-200 bg-white p-6 text-sm leading-relaxed text-gray-800">
187
+ <Markdown options={{ overrides: INSTRUCTION_STYLES }}>{detail?.instructions ?? ""}</Markdown>
188
+ </div>
188
189
  )}
189
190
 
190
191
  {detail?.contentHash && (
@@ -196,3 +197,20 @@ export function SkillsView({ org = "all" }: { org?: string } = {}) {
196
197
  </div>
197
198
  );
198
199
  }
200
+
201
+ /** SKILL.md as it reads, not as it is typed: each markdown element styled for the pane. */
202
+ const INSTRUCTION_STYLES = {
203
+ h1: { props: { className: "mb-3 text-lg font-semibold text-gray-900" } },
204
+ h2: { props: { className: "mb-2 mt-6 text-base font-semibold text-gray-900" } },
205
+ h3: { props: { className: "mb-2 mt-4 text-sm font-semibold text-gray-900" } },
206
+ p: { props: { className: "mb-3" } },
207
+ ul: { props: { className: "mb-3 list-disc space-y-1 pl-5" } },
208
+ ol: { props: { className: "mb-3 list-decimal space-y-1 pl-5" } },
209
+ a: { props: { className: "text-blue-600 underline" } },
210
+ code: { props: { className: "rounded bg-gray-100 px-1 py-0.5 font-mono text-[12px]" } },
211
+ pre: { props: { className: "mb-3 overflow-x-auto rounded-lg bg-gray-50 p-3 font-mono text-[12px] [&>code]:bg-transparent [&>code]:p-0" } },
212
+ table: { props: { className: "mb-3 w-full border-collapse text-left text-[13px]" } },
213
+ th: { props: { className: "border-b border-gray-200 px-2 py-1 font-semibold" } },
214
+ td: { props: { className: "border-b border-gray-100 px-2 py-1 align-top" } },
215
+ blockquote: { props: { className: "mb-3 rounded bg-gray-50 px-3 py-2 text-gray-600" } },
216
+ };
package/screens/index.ts CHANGED
@@ -26,6 +26,9 @@ export { DesignSystemView } from "./DesignSystemView";
26
26
  export { SkillsView } from "./SkillsView";
27
27
  // Agents: the one file authored for the map (docs.unoverse.ai/design/tasks), one lane in both Studios.
28
28
  export { AgentsView } from "./AgentsView";
29
+ // An Agent drawn off its agent.yaml: Studio's lane, and Signal's Agent page beside its runs.
30
+ export { AgentChain } from "./AgentChain";
31
+ export type { AgentFolderItem } from "./AgentChain";
29
32
  export { IdentityView } from "./IdentityView";
30
33
  export { PromptBlocksView } from "./PromptBlocksView";
31
34
  export { IdentityHeader } from "./IdentityHeader";
@@ -5,6 +5,7 @@
5
5
  * kinds into one shape, so a model gets one tool per row regardless of which kind it was.
6
6
  * Also the etiquette an interactive app carries, and the anchoring a follow-up search needs.
7
7
  */
8
+ import { taskUri } from "../definitions/registry/taskUri.js";
8
9
  import type { AgentToolExchange, DiscoveredMCP, AppInvocationContext, OfferedFunction } from "./types.js";
9
10
 
10
11
  /** The discovery tools whose results can carry `object_type: "mcp"` rows. */
@@ -89,7 +90,9 @@ export function parseDiscoveredMCPs(calls: AgentToolExchange[]): DiscoveredMCP[]
89
90
  workflowId: item.metadata.workflow || item.workflow_id,
90
91
  nodeId: item.metadata.trigger,
91
92
  methodName,
92
- taskTool: String(methodName),
93
+ // The task's one URI when the map row holds it (docs/mcp/MCP_TASKS.md § 2a), else its name;
94
+ // the wire call names the tool from either by the one rule (invoke.ts).
95
+ taskTool: String(taskUri(item.metadata?.task) ?? methodName),
93
96
  schema: { methods: { [methodName]: { description: taskDescription, input } } },
94
97
  });
95
98
  } else if (item.metadata?.schema?.methods) {