@agentprojectcontext/apx 1.60.1 → 1.62.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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/super-agent.js +7 -1
  3. package/src/core/agent/tools/handlers/_github.js +22 -0
  4. package/src/core/agent/tools/handlers/call-runtime.js +185 -76
  5. package/src/core/agent/tools/handlers/github-create-issue.js +30 -0
  6. package/src/core/agent/tools/handlers/github-list-repos.js +20 -0
  7. package/src/core/agent/tools/names.js +6 -0
  8. package/src/core/agent/tools/registry.js +4 -0
  9. package/src/core/channels/telegram/dispatch.js +24 -1
  10. package/src/core/channels/telegram/reply.js +79 -2
  11. package/src/core/integrations/catalog.js +17 -27
  12. package/src/core/integrations/plugins/asana.js +22 -0
  13. package/src/core/integrations/plugins/github.js +160 -0
  14. package/src/core/stores/runtime-callbacks.js +107 -0
  15. package/src/host/daemon/callback-reconciler.js +87 -0
  16. package/src/host/daemon/index.js +7 -0
  17. package/src/interfaces/web/dist/assets/index-BuII-tAi.css +1 -0
  18. package/src/interfaces/web/dist/assets/index-CFcs16SV.js +778 -0
  19. package/src/interfaces/web/dist/assets/index-CFcs16SV.js.map +1 -0
  20. package/src/interfaces/web/dist/index.html +2 -2
  21. package/src/interfaces/web/package-lock.json +3 -3
  22. package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +3 -6
  23. package/src/interfaces/web/src/components/integrations/PluginConnect.tsx +300 -0
  24. package/src/interfaces/web/src/components/integrations/PluginToolsSection.tsx +7 -8
  25. package/src/interfaces/web/src/components/settings/SkillsInspectorPanel.tsx +28 -26
  26. package/src/interfaces/web/src/i18n/en.ts +57 -0
  27. package/src/interfaces/web/src/i18n/es.ts +57 -0
  28. package/src/interfaces/web/src/lib/api/integrations.ts +28 -5
  29. package/src/interfaces/web/src/screens/project/IntegrationsTab.tsx +28 -38
  30. package/src/interfaces/web/src/screens/project/McpsTab.tsx +113 -81
  31. package/src/interfaces/web/dist/assets/index-DFNV6BWh.js +0 -761
  32. package/src/interfaces/web/dist/assets/index-DFNV6BWh.js.map +0 -1
  33. package/src/interfaces/web/dist/assets/index-HU-Wt2l9.css +0 -1
  34. package/src/interfaces/web/src/components/integrations/AsanaPlugin.tsx +0 -275
@@ -18,8 +18,8 @@
18
18
  <link rel="apple-touch-icon" href="/favicon/dark/apple-touch-icon.png" media="(prefers-color-scheme: dark)" />
19
19
  <link rel="manifest" href="/favicon/white/site.webmanifest" media="(prefers-color-scheme: light)" />
20
20
  <link rel="manifest" href="/favicon/dark/site.webmanifest" media="(prefers-color-scheme: dark)" />
21
- <script type="module" crossorigin src="/assets/index-DFNV6BWh.js"></script>
22
- <link rel="stylesheet" crossorigin href="/assets/index-HU-Wt2l9.css">
21
+ <script type="module" crossorigin src="/assets/index-CFcs16SV.js"></script>
22
+ <link rel="stylesheet" crossorigin href="/assets/index-BuII-tAi.css">
23
23
  </head>
24
24
  <body class="bg-background text-foreground antialiased">
25
25
  <div id="root"></div>
@@ -2218,9 +2218,9 @@
2218
2218
  }
2219
2219
  },
2220
2220
  "node_modules/caniuse-lite": {
2221
- "version": "1.0.30001800",
2222
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
2223
- "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
2221
+ "version": "1.0.30001802",
2222
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001802.tgz",
2223
+ "integrity": "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==",
2224
2224
  "dev": true,
2225
2225
  "funding": [
2226
2226
  {
@@ -1,6 +1,7 @@
1
1
  import { useState } from "react";
2
2
  import { Github, MessageCircle, Mic, Puzzle } from "lucide-react";
3
3
  import type { CatalogEntry } from "../../lib/api";
4
+ import { t } from "../../i18n";
4
5
  import { PluginCard } from "./PluginCard";
5
6
 
6
7
  const ICONS: Record<string, { icon: typeof Puzzle; className: string; wrap: string }> = {
@@ -28,17 +29,13 @@ export function ComingSoonPlugin({ entry }: { entry: CatalogEntry }) {
28
29
  description={entry.description}
29
30
  badges={
30
31
  <span className="rounded-full border border-border bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
31
- Próximamente
32
+ {t("integrations.coming_soon")}
32
33
  </span>
33
34
  }
34
35
  expanded={expanded}
35
36
  onToggle={() => setExpanded((v) => !v)}
36
37
  >
37
- <div className="p-4 text-xs text-muted-foreground">
38
- Este plugin está declarado en el catálogo pero todavía no está conectable en APX. La
39
- infraestructura de <span className="font-mono">{entry.slug}</span> se va a portar de forma
40
- nativa en una próxima iteración.
41
- </div>
38
+ <div className="p-4 text-xs text-muted-foreground">{t("integrations.coming_soon_body")}</div>
42
39
  </PluginCard>
43
40
  );
44
41
  }
@@ -0,0 +1,300 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import useSWR from "swr";
3
+ import { AlertCircle, CheckCircle2, ChevronDown, ExternalLink, Eye, EyeOff, Github, Loader2, WifiOff, X } from "lucide-react";
4
+ import { cn } from "../../lib/cn";
5
+ import { Integrations, type CatalogEntry, type IntegrationScope, type IntegrationStatus } from "../../lib/api";
6
+ import { t } from "../../i18n";
7
+ import type { TKey } from "../../i18n";
8
+ import { PluginCard } from "./PluginCard";
9
+ import { PluginToolsSection } from "./PluginToolsSection";
10
+
11
+ // One generic component that renders any token-based plugin's config form from
12
+ // its `ui` descriptor (structure) + i18n (all display text, keyed by slug). Asana
13
+ // and GitHub share it; each keeps its own independent config + credentials.
14
+
15
+ // Dynamic i18n lookup for computed per-slug keys (t is typed to known keys).
16
+ const tk = (key: string, vars?: Record<string, string | number>) => t(key as TKey, vars);
17
+
18
+ function AsanaLogo({ className }: { className?: string }) {
19
+ return (
20
+ <svg className={className} viewBox="0 0 24 24" fill="currentColor">
21
+ <path d="M18.833 9.637a4.167 4.167 0 1 1 0 8.333 4.167 4.167 0 0 1 0-8.333zm-13.666 0a4.167 4.167 0 1 1 0 8.333 4.167 4.167 0 0 1 0-8.333zM12 2a4.167 4.167 0 1 1 0 8.333A4.167 4.167 0 0 1 12 2z" />
22
+ </svg>
23
+ );
24
+ }
25
+
26
+ type Accent = { text: string; border: string; hover: string; ring: string; wrap: string };
27
+ const ACCENTS: Record<string, Accent> = {
28
+ rose: { text: "text-rose-400", border: "border-rose-700/50", hover: "hover:bg-rose-900/20", ring: "focus:border-rose-500/50", wrap: "border-rose-500/30 from-rose-500/20 to-pink-500/20" },
29
+ slate: { text: "text-slate-200", border: "border-slate-600/60", hover: "hover:bg-slate-700/30", ring: "focus:border-slate-400/60", wrap: "border-slate-500/30 from-slate-500/20 to-slate-700/20" },
30
+ };
31
+
32
+ function iconFor(slug: string, accent: Accent): ReactNode {
33
+ if (slug === "github") return <Github className={cn("h-6 w-6", accent.text)} />;
34
+ if (slug === "asana") return <AsanaLogo className={cn("h-6 w-6", accent.text)} />;
35
+ return <span className={cn("text-lg", accent.text)}>◆</span>;
36
+ }
37
+
38
+ type Step = "idle" | "saving" | "validating" | "done";
39
+ type Opt = { value: string; label: string };
40
+
41
+ export function PluginConnect({ pid, scope, entry }: { pid: string; scope: IntegrationScope; entry: CatalogEntry }) {
42
+ const ui = entry.ui!;
43
+ const accent = ACCENTS[ui.accent || "rose"] || ACCENTS.rose;
44
+
45
+ const [expanded, setExpanded] = useState(false);
46
+ const [values, setValues] = useState<Record<string, string>>({});
47
+ const [reveal, setReveal] = useState<Record<string, boolean>>({});
48
+ const [showHelp, setShowHelp] = useState<string | null>(null);
49
+ const [step, setStep] = useState<Step>("idle");
50
+ const [error, setError] = useState<string | null>(null);
51
+ const [selectOptions, setSelectOptions] = useState<Opt[]>([]);
52
+ const [selectValue, setSelectValue] = useState("");
53
+
54
+ const { data: status, mutate, isLoading } = useSWR<IntegrationStatus>(
55
+ `integration-status-${entry.slug}-${pid}-${scope}`,
56
+ () => Integrations.status(pid, entry.slug, scope),
57
+ { shouldRetryOnError: false },
58
+ );
59
+
60
+ const isActive = status?.status === "active" && status.is_enabled === true;
61
+ const busy = step === "saving" || step === "validating";
62
+ const showForm = !isActive && selectOptions.length === 0;
63
+
64
+ async function handleConnect() {
65
+ if (ui.configFields.some((f) => !values[f.key]?.trim())) return;
66
+ setStep("saving");
67
+ setError(null);
68
+ try {
69
+ await Integrations.configure(pid, entry.slug, scope, { ...values });
70
+ setStep("validating");
71
+ const result = (await Integrations.validate(pid, entry.slug, scope)) as unknown as Record<string, unknown>;
72
+ await mutate();
73
+ if (ui.select && !result[ui.select.key]) {
74
+ const data = (await Integrations.action(pid, entry.slug, ui.select.action, scope)) as Record<string, unknown>;
75
+ const list = (data[ui.select.listKey] as Record<string, unknown>[]) || [];
76
+ if (list.length > 1) {
77
+ setSelectOptions(list.map((o) => ({ value: String(o[ui.select!.valueKey]), label: String(o[ui.select!.labelKey]) })));
78
+ }
79
+ }
80
+ setStep("done");
81
+ setValues({});
82
+ } catch (err) {
83
+ setError(err instanceof Error ? err.message : t("integrations.err_connect"));
84
+ setStep("idle");
85
+ }
86
+ }
87
+
88
+ async function handleSelect() {
89
+ if (!selectValue || !ui.select) return;
90
+ setStep("saving");
91
+ setError(null);
92
+ try {
93
+ await Integrations.configure(pid, entry.slug, scope, { [ui.select.key]: selectValue });
94
+ await Integrations.validate(pid, entry.slug, scope);
95
+ await mutate();
96
+ setSelectOptions([]);
97
+ setStep("done");
98
+ } catch (err) {
99
+ setError(err instanceof Error ? err.message : t("integrations.err_generic"));
100
+ setStep("idle");
101
+ }
102
+ }
103
+
104
+ async function handleDeactivate() {
105
+ setError(null);
106
+ try {
107
+ await Integrations.deactivate(pid, entry.slug, scope);
108
+ await mutate();
109
+ } catch (err) {
110
+ setError(err instanceof Error ? err.message : t("integrations.err_generic"));
111
+ }
112
+ }
113
+
114
+ const badgeLabel = isLoading
115
+ ? "…"
116
+ : isActive
117
+ ? t("integrations.status_active")
118
+ : status?.status === "error"
119
+ ? t("integrations.status_error")
120
+ : t("integrations.status_unconfigured");
121
+
122
+ return (
123
+ <PluginCard
124
+ icon={
125
+ <div className={cn("flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl border bg-gradient-to-br", accent.wrap)}>
126
+ {iconFor(entry.slug, accent)}
127
+ </div>
128
+ }
129
+ title={entry.name}
130
+ description={entry.description}
131
+ hasTools={(entry.tools?.length ?? 0) > 0}
132
+ badges={
133
+ <span
134
+ className={cn(
135
+ "flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px]",
136
+ isActive ? "border-emerald-700 bg-emerald-900/20 text-emerald-400" : "border-border bg-muted text-muted-foreground",
137
+ )}
138
+ >
139
+ <span className={cn("h-1.5 w-1.5 rounded-full", isActive ? "bg-emerald-400" : "bg-muted-foreground")} />
140
+ {badgeLabel}
141
+ </span>
142
+ }
143
+ expanded={expanded}
144
+ onToggle={() => setExpanded((v) => !v)}
145
+ >
146
+ <div className="space-y-4 p-4">
147
+ {error && (
148
+ <div className="flex items-center gap-2 rounded-lg border border-red-700/30 bg-red-900/20 px-3 py-2.5 text-xs text-red-300">
149
+ <AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
150
+ <span className="flex-1">{error}</span>
151
+ <button onClick={() => setError(null)}><X className="h-3.5 w-3.5" /></button>
152
+ </div>
153
+ )}
154
+
155
+ {/* Connected summary */}
156
+ {isActive && selectOptions.length === 0 && (
157
+ <div className="space-y-1 rounded-xl border border-emerald-700/30 bg-emerald-900/10 p-3">
158
+ <div className="flex items-center gap-2">
159
+ <CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
160
+ <span className="text-xs font-medium text-emerald-300">{t("integrations.connected")}</span>
161
+ </div>
162
+ {(ui.connectedFields || []).map((key) => {
163
+ const v = status?.[key];
164
+ if (!v) return null;
165
+ return (
166
+ <p key={key} className="pl-5 text-[10px] text-muted-foreground">
167
+ {tk(`integrations.${entry.slug}.connected.${key}`)}: {String(v)}
168
+ </p>
169
+ );
170
+ })}
171
+ </div>
172
+ )}
173
+
174
+ {/* Post-validate selection (e.g. Asana workspace) */}
175
+ {selectOptions.length > 1 && ui.select && (
176
+ <div className="space-y-2">
177
+ <p className="text-[11px] text-muted-foreground">{tk(`integrations.${entry.slug}.select_label`)}:</p>
178
+ <div className="flex gap-2">
179
+ <select
180
+ value={selectValue}
181
+ onChange={(e) => setSelectValue(e.target.value)}
182
+ className={cn("flex-1 rounded-lg border border-border bg-background px-2 py-1.5 text-xs outline-none", accent.ring)}
183
+ >
184
+ <option value="">{t("integrations.select_placeholder")}</option>
185
+ {selectOptions.map((o) => (
186
+ <option key={o.value} value={o.value}>{o.label}</option>
187
+ ))}
188
+ </select>
189
+ <button
190
+ onClick={handleSelect}
191
+ disabled={!selectValue || busy}
192
+ className={cn("rounded-lg border px-3 py-1.5 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50", accent.border, accent.text, accent.hover)}
193
+ >
194
+ {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t("integrations.confirm")}
195
+ </button>
196
+ </div>
197
+ </div>
198
+ )}
199
+
200
+ {/* Config form */}
201
+ {showForm && (
202
+ <div className="space-y-3">
203
+ <p className="text-xs font-semibold text-foreground">{t("integrations.credentials", { name: entry.name })}</p>
204
+ {ui.configFields.map((field) => (
205
+ <div key={field.key} className="space-y-2">
206
+ {field.help_url && (
207
+ <div className="overflow-hidden rounded-lg border border-border">
208
+ <button
209
+ type="button"
210
+ onClick={() => setShowHelp((v) => (v === field.key ? null : field.key))}
211
+ className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/40"
212
+ >
213
+ <span className="text-[11px] text-muted-foreground">
214
+ {tk(`integrations.${entry.slug}.fields.${field.key}.help_label`)} ·{" "}
215
+ <a
216
+ href={field.help_url}
217
+ target="_blank"
218
+ rel="noreferrer"
219
+ onClick={(e) => e.stopPropagation()}
220
+ className={cn("inline-flex items-center gap-0.5 hover:underline", accent.text)}
221
+ >
222
+ {field.help_url_label} <ExternalLink className="h-2.5 w-2.5" />
223
+ </a>
224
+ </span>
225
+ <ChevronDown className={cn("h-3.5 w-3.5 flex-shrink-0 text-muted-foreground transition-transform", showHelp === field.key && "rotate-180")} />
226
+ </button>
227
+ {showHelp === field.key && (
228
+ <div className="space-y-1.5 border-t border-border px-3 pb-3 pt-2.5">
229
+ {tk(`integrations.${entry.slug}.fields.${field.key}.help_steps`).split("\n").map((s, i) => (
230
+ <div key={i} className="flex items-start gap-2">
231
+ <span className={cn("mt-0.5 flex-shrink-0 font-mono text-[10px]", accent.text)}>{i + 1}.</span>
232
+ <p className="text-[11px] text-muted-foreground">{s}</p>
233
+ </div>
234
+ ))}
235
+ </div>
236
+ )}
237
+ </div>
238
+ )}
239
+ <div>
240
+ <label className="mb-1 block text-[10px] text-muted-foreground">{tk(`integrations.${entry.slug}.fields.${field.key}.label`)}</label>
241
+ <div className="relative">
242
+ <input
243
+ type={field.type === "password" && !reveal[field.key] ? "password" : "text"}
244
+ placeholder={field.placeholder}
245
+ value={values[field.key] || ""}
246
+ onChange={(e) => setValues((v) => ({ ...v, [field.key]: e.target.value }))}
247
+ onKeyDown={(e) => e.key === "Enter" && handleConnect()}
248
+ className={cn("w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none placeholder:text-muted-foreground/60", field.type === "password" && "pr-14", accent.ring)}
249
+ />
250
+ {field.type === "password" && (
251
+ <button
252
+ type="button"
253
+ onClick={() => setReveal((r) => ({ ...r, [field.key]: !r[field.key] }))}
254
+ className="absolute right-2.5 top-1/2 flex -translate-y-1/2 items-center gap-0.5 text-[10px] text-muted-foreground transition-colors hover:text-foreground"
255
+ >
256
+ {reveal[field.key] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
257
+ {reveal[field.key] ? t("integrations.hide") : t("integrations.reveal")}
258
+ </button>
259
+ )}
260
+ </div>
261
+ </div>
262
+ </div>
263
+ ))}
264
+
265
+ {step === "validating" && (
266
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
267
+ <Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("integrations.verifying")}
268
+ </div>
269
+ )}
270
+
271
+ <button
272
+ onClick={handleConnect}
273
+ disabled={ui.configFields.some((f) => !values[f.key]?.trim()) || busy}
274
+ className={cn("flex w-full items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50", accent.border, accent.text, accent.hover)}
275
+ >
276
+ {busy ? (
277
+ <><Loader2 className="h-3.5 w-3.5 animate-spin" />{step === "saving" ? t("integrations.saving") : t("integrations.validating")}</>
278
+ ) : t("integrations.connect")}
279
+ </button>
280
+ </div>
281
+ )}
282
+
283
+ {entry.tools && entry.tools.length > 0 && (
284
+ <PluginToolsSection pid={pid} tools={entry.tools} isActive={isActive} />
285
+ )}
286
+
287
+ {isActive && (
288
+ <div className="flex justify-end border-t border-border pt-2">
289
+ <button
290
+ onClick={handleDeactivate}
291
+ className="flex items-center gap-1.5 rounded-lg border border-red-700/50 px-3 py-1.5 text-xs text-red-400 transition-all hover:bg-red-900/20"
292
+ >
293
+ <WifiOff className="h-3.5 w-3.5" /> {t("integrations.deactivate")}
294
+ </button>
295
+ </div>
296
+ )}
297
+ </div>
298
+ </PluginCard>
299
+ );
300
+ }
@@ -1,4 +1,5 @@
1
1
  import { Wrench } from "lucide-react";
2
+ import { t } from "../../i18n";
2
3
 
3
4
  export interface PluginTool {
4
5
  slug: string;
@@ -15,25 +16,23 @@ export function PluginToolsSection({ tools, isActive }: { pid: string; tools: Pl
15
16
  <div className="space-y-2.5 rounded-xl border border-border bg-muted/30 p-3">
16
17
  <div className="flex items-center justify-between">
17
18
  <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
18
- Tools para agentes
19
+ {t("integrations.tools_for_agents")}
19
20
  </p>
20
21
  </div>
21
22
  <div className="flex flex-wrap gap-1.5">
22
- {tools.map((t) => (
23
+ {tools.map((tool) => (
23
24
  <div
24
- key={t.slug}
25
+ key={tool.slug}
25
26
  className="flex items-center gap-1.5 rounded-lg border border-border bg-background px-2 py-1"
26
27
  >
27
28
  <Wrench className="h-2.5 w-2.5 flex-shrink-0 text-muted-foreground" />
28
- <span className="font-mono text-[10px] text-foreground">{t.slug}</span>
29
+ <span className="font-mono text-[10px] text-foreground">{tool.slug}</span>
29
30
  <span className="text-[10px] text-muted-foreground/60">·</span>
30
- <span className="text-[10px] text-muted-foreground">{t.desc}</span>
31
+ <span className="text-[10px] text-muted-foreground">{tool.desc}</span>
31
32
  </div>
32
33
  ))}
33
34
  </div>
34
- <p className="text-[10px] text-muted-foreground/70">
35
- Disponibles para los agentes que las tengan permitidas, o vía <span className="font-mono">discover_tools</span>.
36
- </p>
35
+ <p className="text-[10px] text-muted-foreground/70">{t("integrations.tools_available_note")}</p>
37
36
  </div>
38
37
  );
39
38
  }
@@ -97,7 +97,8 @@ export function SkillsInspectorPanel() {
97
97
  };
98
98
 
99
99
  return (
100
- <div className="grid gap-6 xl:grid-cols-2 xl:items-start">
100
+ <div className="space-y-6">
101
+ <div className="grid gap-6 lg:grid-cols-2 lg:items-start">
101
102
  <Section
102
103
  title={t("settings_ui.inspector_title")}
103
104
  description={t("settings_ui.inspector_desc")}
@@ -142,31 +143,6 @@ export function SkillsInspectorPanel() {
142
143
  </div>
143
144
  </Section>
144
145
 
145
- <Section
146
- title={t("settings_ui.thresholds_title")}
147
- description={t("settings_ui.thresholds_desc")}
148
- >
149
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
150
- {knobs().map((k) => (
151
- <Field key={k.key} label={k.label} hint={k.hint}>
152
- <Input
153
- type="number"
154
- step={k.step}
155
- min={k.min}
156
- max={k.max}
157
- defaultValue={String(cfg[k.key])}
158
- disabled={busy}
159
- onBlur={(ev) => {
160
- const n = Number(ev.target.value);
161
- if (Number.isFinite(n) && n !== cfg[k.key]) apply({ [k.key]: n });
162
- }}
163
- className="max-w-[12rem]"
164
- />
165
- </Field>
166
- ))}
167
- </div>
168
- </Section>
169
-
170
146
  <Section
171
147
  title={t("settings_ui.test_title")}
172
148
  description={t("settings_ui.test_desc")}
@@ -226,6 +202,32 @@ export function SkillsInspectorPanel() {
226
202
  )}
227
203
  </div>
228
204
  </Section>
205
+ </div>
206
+
207
+ <Section
208
+ title={t("settings_ui.thresholds_title")}
209
+ description={t("settings_ui.thresholds_desc")}
210
+ >
211
+ <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-4">
212
+ {knobs().map((k) => (
213
+ <Field key={k.key} label={k.label} hint={k.hint}>
214
+ <Input
215
+ type="number"
216
+ step={k.step}
217
+ min={k.min}
218
+ max={k.max}
219
+ defaultValue={String(cfg[k.key])}
220
+ disabled={busy}
221
+ onBlur={(ev) => {
222
+ const n = Number(ev.target.value);
223
+ if (Number.isFinite(n) && n !== cfg[k.key]) apply({ [k.key]: n });
224
+ }}
225
+ className="max-w-[12rem]"
226
+ />
227
+ </Field>
228
+ ))}
229
+ </div>
230
+ </Section>
229
231
  </div>
230
232
  );
231
233
  }
@@ -602,6 +602,7 @@ export const en = {
602
602
  logs_btn: "Logs",
603
603
  testing: "Testing…",
604
604
  test_ok: "OK · {n} tools available",
605
+ tools_count: "{n} tools",
605
606
  logs_title: "Logs · {name}",
606
607
  logs_empty: "No logs yet. Start the MCP by calling a tool or running Test.",
607
608
  logs_events: "Recent events",
@@ -1789,4 +1790,60 @@ export const en = {
1789
1790
  err_load_conversation: "Could not load conversation.",
1790
1791
  err_stream: "Stream error.",
1791
1792
  },
1793
+ integrations: {
1794
+ title: "Integrations",
1795
+ description: "Plugins and tools available for this project",
1796
+ tab_plugins: "Plugins",
1797
+ tab_tools: "Tools",
1798
+ scope_label: "Scope:",
1799
+ scope_project: "This project",
1800
+ scope_global: "Global (default)",
1801
+ plugins_hint: "Channel & service plugins installable per project. Saved in the scope selected above.",
1802
+ more_soon: "More plugins coming soon…",
1803
+ tools_hint: "Tools that connected plugins expose to this project's agents.",
1804
+ tools_empty: "No integration tools yet. Connect a plugin to enable them.",
1805
+ tool_active: "active",
1806
+ tool_inactive: "inactive",
1807
+ status_active: "Active",
1808
+ status_error: "Error",
1809
+ status_unconfigured: "Not configured",
1810
+ connected: "Connected",
1811
+ connect: "Connect",
1812
+ deactivate: "Deactivate",
1813
+ saving: "Saving...",
1814
+ validating: "Validating...",
1815
+ verifying: "Verifying token...",
1816
+ confirm: "Confirm",
1817
+ select_placeholder: "Select...",
1818
+ reveal: "Show",
1819
+ hide: "Hide",
1820
+ credentials: "{name} credentials",
1821
+ coming_soon: "Coming soon",
1822
+ coming_soon_body: "This plugin is declared in the catalog but isn't connectable in APX yet. It will be ported natively in a future iteration.",
1823
+ tools_for_agents: "Agent tools",
1824
+ tools_available_note: "Available to agents that allow them, or via discover_tools.",
1825
+ err_connect: "Failed to connect",
1826
+ err_generic: "Something went wrong",
1827
+ asana: {
1828
+ select_label: "Select the workspace to use",
1829
+ connected: { user_name: "Connected as", user_email: "Email", workspace_name: "Workspace" },
1830
+ fields: {
1831
+ personal_access_token: {
1832
+ label: "Personal Access Token",
1833
+ help_label: "How to get the token?",
1834
+ help_steps: "Open app.asana.com/0/my-apps in your browser.\nScroll to the \"Personal access tokens\" section (not your OAuth apps).\nClick \"+ New access token\".\nGive it a name and confirm.\nCopy the full token — it starts with \"1/...\" and has a \":\" in the middle.\nPaste it in the field below.",
1835
+ },
1836
+ },
1837
+ },
1838
+ github: {
1839
+ connected: { user_login: "Connected as", user_name: "Name" },
1840
+ fields: {
1841
+ token: {
1842
+ label: "Personal Access Token",
1843
+ help_label: "How to get the token?",
1844
+ help_steps: "Open github.com/settings/tokens.\nGenerate a token (classic or fine-grained) with the \"repo\" scope.\nCopy the token — it starts with ghp_ or github_pat_.\nPaste it in the field below.",
1845
+ },
1846
+ },
1847
+ },
1848
+ },
1792
1849
  } as const;
@@ -620,6 +620,7 @@ export const es = {
620
620
  logs_btn: "Logs",
621
621
  testing: "Probando…",
622
622
  test_ok: "OK · {n} tools disponibles",
623
+ tools_count: "{n} tools",
623
624
  logs_title: "Logs · {name}",
624
625
  logs_empty: "Sin logs todavía. Arrancá el MCP llamando un tool o probando.",
625
626
  logs_events: "Eventos recientes",
@@ -1787,6 +1788,62 @@ export const es = {
1787
1788
  err_load_conversation: "No se pudo cargar la conversación.",
1788
1789
  err_stream: "Error de stream.",
1789
1790
  },
1791
+ integrations: {
1792
+ title: "Integrations",
1793
+ description: "Plugins y tools disponibles para este proyecto",
1794
+ tab_plugins: "Plugins",
1795
+ tab_tools: "Tools",
1796
+ scope_label: "Ámbito:",
1797
+ scope_project: "Este proyecto",
1798
+ scope_global: "Global (default)",
1799
+ plugins_hint: "Plugins de canal y servicio instalables por proyecto. Se guardan en el ámbito seleccionado arriba.",
1800
+ more_soon: "Más plugins próximamente…",
1801
+ tools_hint: "Tools que los plugins conectados exponen a los agentes de este proyecto.",
1802
+ tools_empty: "No hay tools de integraciones. Conectá un plugin para habilitarlas.",
1803
+ tool_active: "activo",
1804
+ tool_inactive: "inactivo",
1805
+ status_active: "Activo",
1806
+ status_error: "Error",
1807
+ status_unconfigured: "No configurado",
1808
+ connected: "Conectado",
1809
+ connect: "Conectar",
1810
+ deactivate: "Desactivar",
1811
+ saving: "Guardando...",
1812
+ validating: "Validando...",
1813
+ verifying: "Verificando token...",
1814
+ confirm: "Confirmar",
1815
+ select_placeholder: "Seleccionar...",
1816
+ reveal: "Ver",
1817
+ hide: "Ocultar",
1818
+ credentials: "Credenciales {name}",
1819
+ coming_soon: "Próximamente",
1820
+ coming_soon_body: "Este plugin está declarado en el catálogo pero todavía no está conectable en APX. Se va a portar de forma nativa en una próxima iteración.",
1821
+ tools_for_agents: "Tools para agentes",
1822
+ tools_available_note: "Disponibles para los agentes que las tengan permitidas, o vía discover_tools.",
1823
+ err_connect: "Error al conectar",
1824
+ err_generic: "Ocurrió un error",
1825
+ asana: {
1826
+ select_label: "Seleccioná el workspace a usar",
1827
+ connected: { user_name: "Conectado como", user_email: "Email", workspace_name: "Workspace" },
1828
+ fields: {
1829
+ personal_access_token: {
1830
+ label: "Personal Access Token",
1831
+ help_label: "¿Cómo obtener el token?",
1832
+ help_steps: "Abrí app.asana.com/0/my-apps en el navegador.\nBajá hasta la sección \"Personal access tokens\" (no tus apps OAuth).\nHacé clic en \"+ New access token\".\nDale un nombre y confirmá.\nCopiá el token completo — empieza con \"1/...\" y tiene un \":\" en el medio.\nPegalo en el campo de abajo.",
1833
+ },
1834
+ },
1835
+ },
1836
+ github: {
1837
+ connected: { user_login: "Conectado como", user_name: "Nombre" },
1838
+ fields: {
1839
+ token: {
1840
+ label: "Personal Access Token",
1841
+ help_label: "¿Cómo obtener el token?",
1842
+ help_steps: "Abrí github.com/settings/tokens.\nGenerá un token (classic o fine-grained) con scope \"repo\".\nCopiá el token — empieza con ghp_ o github_pat_.\nPegalo en el campo de abajo.",
1843
+ },
1844
+ },
1845
+ },
1846
+ },
1790
1847
  } as const;
1791
1848
 
1792
1849
  export type EsStrings = typeof es;
@@ -6,15 +6,13 @@ import { http } from "../http";
6
6
  export type IntegrationScope = "project" | "global";
7
7
 
8
8
  // Status returned by a plugin's status endpoint. Common fields plus
9
- // plugin-specific extras (Asana adds user/workspace metadata).
9
+ // plugin-specific extras (Asana adds user/workspace, GitHub adds user_login…),
10
+ // so it carries an index signature for the generic component to read.
10
11
  export interface IntegrationStatus {
11
12
  slug: string;
12
13
  status: string;
13
14
  is_enabled: boolean;
14
- user_name?: string | null;
15
- user_email?: string | null;
16
- workspace_gid?: string | null;
17
- workspace_name?: string | null;
15
+ [key: string]: unknown;
18
16
  }
19
17
 
20
18
  export interface PluginTool {
@@ -22,6 +20,30 @@ export interface PluginTool {
22
20
  desc: string;
23
21
  }
24
22
 
23
+ // Declarative UI descriptor (mirrors the plugin's `ui` in core). STRUCTURE ONLY
24
+ // — display text is resolved from i18n keys (integrations.<slug>.*) by the
25
+ // generic PluginConnect component. help_url/help_url_label are non-translatable.
26
+ export interface PluginConfigField {
27
+ key: string;
28
+ type: "password" | "text";
29
+ placeholder?: string;
30
+ help_url?: string;
31
+ help_url_label?: string;
32
+ }
33
+ export interface PluginSelect {
34
+ key: string;
35
+ action: string;
36
+ listKey: string;
37
+ valueKey: string;
38
+ labelKey: string;
39
+ }
40
+ export interface PluginUi {
41
+ accent?: string;
42
+ configFields: PluginConfigField[];
43
+ select?: PluginSelect;
44
+ connectedFields?: string[];
45
+ }
46
+
25
47
  // One entry of the plugin catalog with its resolved status for this project.
26
48
  export interface CatalogEntry {
27
49
  slug: string;
@@ -30,6 +52,7 @@ export interface CatalogEntry {
30
52
  description: string;
31
53
  auth: string;
32
54
  tools?: PluginTool[];
55
+ ui?: PluginUi | null;
33
56
  coming_soon: boolean;
34
57
  status: IntegrationStatus;
35
58
  resolved_scope: IntegrationScope | null;