@agentprojectcontext/apx 1.33.0 → 1.34.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 (172) hide show
  1. package/package.json +1 -1
  2. package/skills/apc-context/SKILL.md +2 -5
  3. package/skills/apx/SKILL.md +49 -61
  4. package/src/core/agent/a2a/reply.js +48 -0
  5. package/src/core/agent/build-agent-system.js +4 -3
  6. package/src/core/agent/channels/voice-context.js +98 -0
  7. package/src/core/agent/memory.js +2 -1
  8. package/src/core/agent/prompt-builder.js +2 -1
  9. package/src/core/agent/prompts/modes/code-build.md +1 -0
  10. package/src/core/agent/prompts/modes/code-plan.md +1 -0
  11. package/src/core/agent/prompts/modes/index.js +28 -0
  12. package/src/core/agent/skills/loader.js +22 -18
  13. package/src/core/agent/stream/turn-accumulator.js +73 -0
  14. package/src/core/agent/suggestions.js +37 -0
  15. package/src/core/agent/tools/handlers/add-project.js +5 -2
  16. package/src/core/agent/tools/handlers/call-runtime.js +3 -2
  17. package/src/core/agent/tools/handlers/transcribe-audio.js +1 -1
  18. package/src/core/agent/tools/helpers.js +2 -2
  19. package/src/core/agent/tools/names.js +138 -0
  20. package/src/core/agent/tools/registry-bridge.js +6 -14
  21. package/src/core/agent/tools/registry.js +68 -65
  22. package/src/core/apc/context-copy.js +27 -0
  23. package/src/core/apc/notes.js +19 -0
  24. package/src/core/apc/parser.js +13 -6
  25. package/src/core/apc/paths.js +87 -0
  26. package/src/core/apc/scaffold.js +82 -74
  27. package/src/core/apc/skill-sync.js +13 -1
  28. package/src/core/channels/telegram/dispatch.js +595 -0
  29. package/src/core/channels/telegram/helpers.js +130 -0
  30. package/src/core/config/index.js +3 -2
  31. package/src/core/config/redact.js +95 -0
  32. package/src/core/constants/channels.js +2 -0
  33. package/src/core/constants/code-modes.js +10 -0
  34. package/src/core/constants/index.js +1 -0
  35. package/src/core/deck/manifest.js +186 -0
  36. package/src/core/engines/catalog.js +83 -0
  37. package/src/core/engines/gemini.js +28 -11
  38. package/src/core/engines/index.js +11 -1
  39. package/src/core/{tools → http-tools}/browser.js +0 -1
  40. package/src/core/{tools → http-tools}/fetch.js +0 -1
  41. package/src/core/{tools → http-tools}/glob.js +0 -1
  42. package/src/core/{tools → http-tools}/grep.js +0 -1
  43. package/src/core/{tools → http-tools}/registry.js +0 -1
  44. package/src/core/{tools → http-tools}/search.js +0 -1
  45. package/src/core/i18n/en.js +9 -0
  46. package/src/core/i18n/es.js +12 -0
  47. package/src/core/i18n/index.js +54 -0
  48. package/src/core/i18n/pt.js +9 -0
  49. package/src/core/identity/telegram.js +2 -1
  50. package/src/core/mcp/runner.js +272 -14
  51. package/src/core/mcp/sources.js +3 -2
  52. package/src/core/routines/index.js +16 -0
  53. package/src/{host/daemon/routines.js → core/routines/runner.js} +36 -103
  54. package/src/core/runtime-skills/apc-context/SKILL.md +159 -0
  55. package/src/core/runtime-skills/apx/SKILL.md +95 -0
  56. package/src/core/runtime-skills/apx-mcp/SKILL.md +116 -0
  57. package/src/core/runtime-skills/{claude-code.md → claude-code/SKILL.md} +1 -0
  58. package/src/core/runtime-skills/{codex-cli.md → codex-cli/SKILL.md} +1 -0
  59. package/src/core/runtime-skills/{opencode-cli.md → opencode-cli/SKILL.md} +1 -0
  60. package/src/core/runtime-skills/{openrouter.md → openrouter/SKILL.md} +1 -0
  61. package/src/{host/daemon/env-detect.js → core/runtimes/detect.js} +1 -1
  62. package/src/core/stores/code-sessions.js +50 -2
  63. package/src/core/stores/routine-memory.js +1 -1
  64. package/src/core/stores/sessions-search.js +121 -0
  65. package/src/core/stores/sessions.js +38 -0
  66. package/src/core/vars/index.js +14 -0
  67. package/src/core/vars/interpolate.js +86 -0
  68. package/src/core/vars/sources.js +151 -0
  69. package/src/core/voice/audio-decode.js +38 -0
  70. package/src/core/voice/transcription.js +225 -0
  71. package/src/host/daemon/api/admin-config.js +5 -82
  72. package/src/host/daemon/api/agents.js +5 -5
  73. package/src/host/daemon/api/code.js +17 -169
  74. package/src/host/daemon/api/config.js +3 -4
  75. package/src/host/daemon/api/conversations.js +8 -29
  76. package/src/host/daemon/api/deck.js +37 -404
  77. package/src/host/daemon/api/engines.js +1 -50
  78. package/src/host/daemon/api/exec.js +1 -1
  79. package/src/host/daemon/api/mcps.js +32 -0
  80. package/src/host/daemon/api/routines.js +1 -1
  81. package/src/host/daemon/api/runtimes.js +4 -3
  82. package/src/host/daemon/api/sessions-search.js +24 -140
  83. package/src/host/daemon/api/sessions.js +12 -30
  84. package/src/host/daemon/api/shared.js +2 -1
  85. package/src/host/daemon/api/telegram.js +1 -11
  86. package/src/host/daemon/api/tools.js +6 -6
  87. package/src/host/daemon/api/transcribe.js +2 -2
  88. package/src/host/daemon/api/vars.js +137 -0
  89. package/src/host/daemon/api/voice.js +13 -290
  90. package/src/host/daemon/api.js +2 -0
  91. package/src/host/daemon/db.js +6 -6
  92. package/src/host/daemon/deck-exec.js +148 -0
  93. package/src/host/daemon/index.js +3 -3
  94. package/src/host/daemon/plugins/telegram/index.js +24 -687
  95. package/src/host/daemon/routines-scheduler.js +64 -0
  96. package/src/host/daemon/smoke.js +3 -2
  97. package/src/host/daemon/whisper-server.js +225 -0
  98. package/src/interfaces/cli/commands/agent.js +3 -2
  99. package/src/interfaces/cli/commands/command.js +2 -3
  100. package/src/interfaces/cli/commands/messages.js +6 -2
  101. package/src/interfaces/cli/commands/pair.js +5 -4
  102. package/src/interfaces/cli/commands/search.js +1 -1
  103. package/src/interfaces/cli/commands/sessions.js +3 -2
  104. package/src/interfaces/cli/commands/skills.js +36 -55
  105. package/src/interfaces/web/dist/assets/index-DdmSRtsz.css +1 -0
  106. package/src/interfaces/web/dist/assets/index-M4FspaCH.js +613 -0
  107. package/src/interfaces/web/dist/assets/index-M4FspaCH.js.map +1 -0
  108. package/src/interfaces/web/dist/index.html +2 -2
  109. package/src/interfaces/web/package-lock.json +182 -182
  110. package/src/interfaces/web/src/components/ModelCombobox.tsx +44 -8
  111. package/src/interfaces/web/src/components/TelegramChannelDialog.tsx +1 -1
  112. package/src/interfaces/web/src/components/chat/AskAnswersCard.tsx +76 -0
  113. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +16 -3
  114. package/src/interfaces/web/src/components/chat/MessageList.tsx +23 -1
  115. package/src/interfaces/web/src/components/chat/ModelPicker.tsx +3 -1
  116. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +4 -4
  117. package/src/interfaces/web/src/components/code/CodeChangesTab.tsx +1 -1
  118. package/src/interfaces/web/src/components/code/CodeFileTree.tsx +3 -2
  119. package/src/interfaces/web/src/components/code/CodeFileViewer.tsx +3 -2
  120. package/src/interfaces/web/src/components/code/CodeTerminal.tsx +3 -2
  121. package/src/interfaces/web/src/components/config/GlobalConfigEditor.tsx +2 -1
  122. package/src/interfaces/web/src/components/deck/WidgetRow.tsx +2 -1
  123. package/src/interfaces/web/src/components/inputs/KeyValueList.tsx +93 -0
  124. package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +449 -0
  125. package/src/interfaces/web/src/components/settings/DefaultRouterCard.tsx +2 -1
  126. package/src/interfaces/web/src/components/settings/EnginesPanel.tsx +2 -2
  127. package/src/interfaces/web/src/components/settings/MemoryPanel.tsx +5 -4
  128. package/src/interfaces/web/src/components/settings/providers/ProviderCard.tsx +3 -2
  129. package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +3 -2
  130. package/src/interfaces/web/src/components/ui/chat-input.tsx +5 -4
  131. package/src/interfaces/web/src/components/ui/sidebar.tsx +3 -2
  132. package/src/interfaces/web/src/components/voice/VoiceProviderModal.tsx +2 -1
  133. package/src/interfaces/web/src/constants/index.ts +1 -1
  134. package/src/interfaces/web/src/i18n/en.ts +174 -7
  135. package/src/interfaces/web/src/i18n/es.ts +179 -15
  136. package/src/interfaces/web/src/lib/api/mcps.ts +25 -0
  137. package/src/interfaces/web/src/lib/api/vars.ts +38 -0
  138. package/src/interfaces/web/src/lib/api.ts +1 -0
  139. package/src/interfaces/web/src/screens/ProjectScreen.tsx +8 -31
  140. package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +1 -1
  141. package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +4 -3
  142. package/src/interfaces/web/src/screens/modules/DesktopScreen.tsx +7 -6
  143. package/src/interfaces/web/src/screens/modules/VoiceScreen.tsx +4 -3
  144. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +1 -1
  145. package/src/interfaces/web/src/screens/project/ConfigTab.tsx +132 -1
  146. package/src/interfaces/web/src/screens/project/McpsTab.tsx +549 -104
  147. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +1 -1
  148. package/src/interfaces/web/src/screens/project/VarsTab.tsx +300 -0
  149. package/src/interfaces/web/src/types/daemon.ts +5 -0
  150. package/src/host/daemon/transcription.js +0 -538
  151. package/src/host/daemon/whisper-transcribe.py +0 -73
  152. package/src/interfaces/web/dist/assets/index-7dVT2O1S.css +0 -1
  153. package/src/interfaces/web/dist/assets/index-DWsE_8Nz.js +0 -602
  154. package/src/interfaces/web/dist/assets/index-DWsE_8Nz.js.map +0 -1
  155. /package/src/{host/daemon → core/apc}/projects-helpers.js +0 -0
  156. /package/src/{host/daemon/plugins → core/channels}/telegram/ask.js +0 -0
  157. /package/src/{host/daemon/plugins → core/channels}/telegram/media.js +0 -0
  158. /package/src/core/{tools → http-tools}/index.js +0 -0
  159. /package/{skills → src/core/runtime-skills}/apx-agency-agents/SKILL.md +0 -0
  160. /package/{skills → src/core/runtime-skills}/apx-agent/SKILL.md +0 -0
  161. /package/{skills → src/core/runtime-skills}/apx-mcp-builder/SKILL.md +0 -0
  162. /package/{skills → src/core/runtime-skills}/apx-project/SKILL.md +0 -0
  163. /package/{skills → src/core/runtime-skills}/apx-routine/SKILL.md +0 -0
  164. /package/{skills → src/core/runtime-skills}/apx-runtime/SKILL.md +0 -0
  165. /package/{skills → src/core/runtime-skills}/apx-sessions/SKILL.md +0 -0
  166. /package/{skills → src/core/runtime-skills}/apx-skill-builder/SKILL.md +0 -0
  167. /package/{skills → src/core/runtime-skills}/apx-task/SKILL.md +0 -0
  168. /package/{skills → src/core/runtime-skills}/apx-telegram/SKILL.md +0 -0
  169. /package/{skills → src/core/runtime-skills}/apx-voice/SKILL.md +0 -0
  170. /package/src/{host/daemon/compact.js → core/stores/conversations-compactor.js} +0 -0
  171. /package/src/{host/daemon → core/stores}/conversations.js +0 -0
  172. /package/src/{host/daemon → core/util}/thinking.js +0 -0
@@ -1,148 +1,593 @@
1
- import { useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import useSWR from "swr";
3
- import { Plus, Trash2 } from "lucide-react";
4
- import { Mcps } from "../../lib/api";
3
+ import {
4
+ CheckCircle2, FlaskConical, Pencil, Plus, ScrollText, Terminal, Trash2, X, XCircle,
5
+ } from "lucide-react";
6
+ import { Mcps, Vars, type McpAddBody, type McpScope, type McpTestResult, type McpLogsResult, type VarsList } from "../../lib/api";
7
+ import type { McpEntry } from "../../types/daemon";
5
8
  import { Section } from "../../components/Section";
6
- import { Badge, Button, Dialog, Empty, Field, Input, Loading, Switch, Textarea } from "../../components/ui";
9
+ import { Badge, Button, Dialog, Empty, Field, Input, Loading, Switch } from "../../components/ui";
7
10
  import { UiSelect } from "../../components/UiSelect";
11
+ import { VarTokenInput } from "../../components/inputs/VarTokenInput";
12
+ import { KeyValueList, recordFromRows, rowsFromRecord, type KvRow } from "../../components/inputs/KeyValueList";
8
13
  import { useToast } from "../../components/Toast";
9
14
  import { t } from "../../i18n";
10
15
 
16
+ type DialogMode =
17
+ | null
18
+ | { kind: "new" }
19
+ | { kind: "edit"; entry: McpEntry };
20
+
21
+ const SOURCE_LABEL: Record<string, string> = {
22
+ apc: "Shared",
23
+ runtime: "Runtime",
24
+ global: "Global",
25
+ };
26
+
27
+ const SOURCE_TONE: Record<string, "info" | "muted" | "success"> = {
28
+ apc: "info",
29
+ runtime: "success",
30
+ global: "muted",
31
+ };
32
+
33
+ function sourceToScope(source: string): McpScope {
34
+ if (source === "runtime") return "runtime";
35
+ if (source === "global") return "global";
36
+ return "shared";
37
+ }
38
+
39
+ function sourceLabel(source: string): string {
40
+ return SOURCE_LABEL[source] ?? source;
41
+ }
42
+
11
43
  export function McpsTab({ pid }: { pid: string }) {
12
44
  const toast = useToast();
13
45
  const list = useSWR(`/projects/${pid}/mcps`, () => Mcps.list(pid));
14
46
  const conflicts = useSWR(`/projects/${pid}/mcps/check`, () => Mcps.check(pid));
15
- const [open, setOpen] = useState(false);
47
+ const vars = useSWR<VarsList>(`/projects/${pid}/vars`, () => Vars.list(pid));
48
+ const [dialog, setDialog] = useState<DialogMode>(null);
49
+ const [activeMcp, setActiveMcp] = useState<string | null>(null);
50
+ const [testFor, setTestFor] = useState<{ name: string; result?: McpTestResult; busy?: boolean } | null>(null);
16
51
 
17
- const remove = async (name: string, scope: any) => {
52
+ const varNames = useMemo(
53
+ () => (vars.data ? Object.keys(vars.data.effective).sort() : []),
54
+ [vars.data],
55
+ );
56
+
57
+ const remove = async (name: string, scope: McpScope) => {
18
58
  if (!confirm(t("project.mcps.delete_confirm", { name, scope }))) return;
19
- try { await Mcps.remove(pid, name, scope); toast.success(t("project.mcps.removed")); list.mutate(); }
59
+ try { await Mcps.remove(pid, name, scope); toast.success(t("project.mcps.removed")); list.mutate(); if (activeMcp === name) setActiveMcp(null); }
20
60
  catch (e: any) { toast.error(e?.message || t("common.error_generic")); }
21
61
  };
22
62
 
63
+ const toggleEnabled = async (m: McpEntry) => {
64
+ try {
65
+ await Mcps.add(pid, sourceToScope(m.source), { name: m.name, enabled: !m.enabled });
66
+ list.mutate();
67
+ } catch (e: any) {
68
+ toast.error(e?.message || t("common.error_generic"));
69
+ }
70
+ };
71
+
72
+ const runTest = async (name: string) => {
73
+ setActiveMcp(name);
74
+ setTestFor({ name, busy: true });
75
+ try {
76
+ const result = await Mcps.test(pid, name);
77
+ setTestFor({ name, result });
78
+ } catch (e: any) {
79
+ setTestFor({ name, result: { ok: false, error: e?.message || "error" } });
80
+ }
81
+ };
82
+
23
83
  return (
24
- <Section
25
- title={t("project.mcps.title")}
26
- description={t("project.mcps.subtitle")}
27
- action={<Button size="sm" variant="primary" onClick={() => setOpen(true)}><Plus size={14} /> {t("project.mcps.new")}</Button>}
28
- >
29
- {conflicts.data?.conflicts?.length ? (
30
- <div className="mb-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs">
31
- {t("project.mcps.conflicts", { names: conflicts.data.conflicts.map((c: any) => c.name).join(", ") })}
32
- </div>
33
- ) : null}
34
-
35
- {list.isLoading && <Loading />}
36
- {!list.isLoading && (list.data?.length ?? 0) === 0 && <Empty>{t("project.mcps.empty")}</Empty>}
37
-
38
- <ul className="space-y-2 text-sm">
39
- {(list.data || []).map((m) => (
40
- <li key={`${m.source}-${m.name}`} className="flex items-center gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
41
- <span className="font-medium">{m.name}</span>
42
- <Badge tone="info">{m.source}</Badge>
43
- <span className="ml-auto text-xs text-muted-fg">
44
- {m.transport} · {m.enabled === false ? "disabled" : "enabled"}
45
- </span>
46
- <Button size="sm" variant="destructive" onClick={() => remove(m.name, m.source)}>
47
- <Trash2 size={13} />
48
- </Button>
49
- </li>
50
- ))}
51
- </ul>
84
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
85
+ <div className="lg:col-span-3">
86
+ <Section
87
+ title={t("project.mcps.title")}
88
+ description={t("project.mcps.subtitle")}
89
+ action={<Button size="sm" variant="primary" onClick={() => setDialog({ kind: "new" })}><Plus size={14} /> {t("project.mcps.new")}</Button>}
90
+ >
91
+ {conflicts.data?.conflicts?.length ? (
92
+ <div className="mb-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs">
93
+ {t("project.mcps.conflicts", { names: conflicts.data.conflicts.map((c: any) => c.name).join(", ") })}
94
+ </div>
95
+ ) : null}
96
+
97
+ {list.isLoading && <Loading />}
98
+ {!list.isLoading && (list.data?.length ?? 0) === 0 && <Empty>{t("project.mcps.empty")}</Empty>}
99
+
100
+ <ul className="space-y-2 text-sm">
101
+ {(list.data || []).map((m) => {
102
+ const writable = m.source === "apc" || m.source === "runtime" || m.source === "global";
103
+ const scopeForRemove: McpScope = sourceToScope(m.source);
104
+ const isActive = activeMcp === m.name;
105
+ return (
106
+ <li
107
+ key={`${m.source}-${m.name}`}
108
+ className={
109
+ "rounded-md border px-3 py-2 transition-colors " +
110
+ (isActive ? "border-primary/50 bg-primary/5" : "border-border bg-muted/30 hover:bg-muted/50")
111
+ }
112
+ onClick={() => setActiveMcp(m.name)}
113
+ role="button"
114
+ >
115
+ <div className="flex flex-wrap items-center gap-3">
116
+ <span className="font-medium">{m.name}</span>
117
+ <Badge tone={SOURCE_TONE[m.source] ?? "muted"} >{sourceLabel(m.source)}</Badge>
118
+ <span className="ml-auto text-xs text-muted-fg">
119
+ {(m.transport || "stdio").toUpperCase()}
120
+ </span>
121
+ <div onClick={(e) => e.stopPropagation()}>
122
+ <Switch
123
+ checked={m.enabled !== false}
124
+ onChange={() => toggleEnabled(m)}
125
+ label=""
126
+ />
127
+ </div>
128
+ <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); runTest(m.name); }} aria-label={t("project.mcps.test_btn")} title={t("project.mcps.test_btn")}>
129
+ <FlaskConical size={13} />
130
+ </Button>
131
+ <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); setActiveMcp(m.name); }} aria-label={t("project.mcps.logs_btn")} title={t("project.mcps.logs_btn")}>
132
+ <ScrollText size={13} />
133
+ </Button>
134
+ {writable && (
135
+ <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); setDialog({ kind: "edit", entry: m }); }} aria-label={t("project.mcps.edit_btn")} title={t("project.mcps.edit_btn")}>
136
+ <Pencil size={13} />
137
+ </Button>
138
+ )}
139
+ {writable && (
140
+ <Button size="sm" variant="destructive" onClick={(e) => { e.stopPropagation(); remove(m.name, scopeForRemove); }}>
141
+ <Trash2 size={13} />
142
+ </Button>
143
+ )}
144
+ </div>
145
+ {testFor?.name === m.name && (
146
+ <TestResultRow result={testFor.result} busy={!!testFor.busy} onClose={() => setTestFor(null)} />
147
+ )}
148
+ </li>
149
+ );
150
+ })}
151
+ </ul>
52
152
 
53
- <CreateMcpDialog open={open} onClose={() => setOpen(false)} pid={pid} onCreated={() => { setOpen(false); list.mutate(); }} />
54
- </Section>
153
+ {dialog && (
154
+ <UpsertMcpDialog
155
+ mode={dialog}
156
+ pid={pid}
157
+ varNames={varNames}
158
+ onClose={() => setDialog(null)}
159
+ onSaved={() => { setDialog(null); list.mutate(); }}
160
+ onVarsChanged={() => vars.mutate()}
161
+ />
162
+ )}
163
+ </Section>
164
+ </div>
165
+
166
+ <div className="lg:col-span-1">
167
+ <LogsPanel pid={pid} mcpName={activeMcp} runningTest={!!testFor?.busy} />
168
+ </div>
169
+ </div>
55
170
  );
56
171
  }
57
172
 
58
- function CreateMcpDialog({
59
- open,
173
+ function TestResultRow({
174
+ result,
175
+ busy,
60
176
  onClose,
177
+ }: { result?: McpTestResult; busy: boolean; onClose: () => void }) {
178
+ return (
179
+ <div className="mt-2 rounded border border-border/60 bg-background/40 p-2 text-xs">
180
+ <div className="mb-1 flex items-center gap-2">
181
+ {busy && <span className="text-muted-fg">{t("project.mcps.testing")}</span>}
182
+ {!busy && result?.ok && (
183
+ <span className="flex items-center gap-1 text-emerald-400">
184
+ <CheckCircle2 size={12} /> {t("project.mcps.test_ok", { n: String(result.tool_count ?? 0) })}
185
+ </span>
186
+ )}
187
+ {!busy && result && !result.ok && (
188
+ <span className="flex items-center gap-1 text-red-400">
189
+ <XCircle size={12} /> {result.error}
190
+ </span>
191
+ )}
192
+ <button className="ml-auto text-muted-fg hover:text-fg" onClick={onClose}>×</button>
193
+ </div>
194
+ {!busy && result?.ok && result.tools && result.tools.length > 0 && (
195
+ <ul className="space-y-0.5 font-mono">
196
+ {result.tools.slice(0, 10).map((tool) => (
197
+ <li key={tool.name} className="truncate">
198
+ <span className="text-primary">{tool.name}</span>
199
+ {tool.description && <span className="ml-2 text-muted-fg">— {tool.description}</span>}
200
+ </li>
201
+ ))}
202
+ {result.tools.length > 10 && (
203
+ <li className="text-muted-fg">… +{result.tools.length - 10}</li>
204
+ )}
205
+ </ul>
206
+ )}
207
+ </div>
208
+ );
209
+ }
210
+
211
+ // Right-side terminal-style live logs panel. Polls the daemon every 1.5s
212
+ // while a test is running (or every 4s when idle and an MCP is pinned) so
213
+ // the user can see fetch/exit events as they happen without clicking Logs.
214
+ function LogsPanel({
61
215
  pid,
62
- onCreated,
63
- }: { open: boolean; onClose: () => void; pid: string; onCreated: () => void }) {
216
+ mcpName,
217
+ runningTest,
218
+ }: { pid: string; mcpName: string | null; runningTest: boolean }) {
219
+ const [data, setData] = useState<McpLogsResult | null>(null);
220
+ const [err, setErr] = useState<string | null>(null);
221
+ const lastNameRef = useRef<string | null>(null);
222
+
223
+ useEffect(() => {
224
+ if (!mcpName) { setData(null); setErr(null); return; }
225
+ if (lastNameRef.current !== mcpName) {
226
+ setData(null);
227
+ setErr(null);
228
+ lastNameRef.current = mcpName;
229
+ }
230
+ let cancelled = false;
231
+ const fetchOnce = async () => {
232
+ try {
233
+ const d = await Mcps.logs(pid, mcpName);
234
+ if (!cancelled) { setData(d); setErr(null); }
235
+ } catch (e: any) {
236
+ if (!cancelled) setErr(e?.message || "error");
237
+ }
238
+ };
239
+ fetchOnce();
240
+ const interval = runningTest ? 1200 : 4000;
241
+ const handle = setInterval(fetchOnce, interval);
242
+ return () => { cancelled = true; clearInterval(handle); };
243
+ }, [pid, mcpName, runningTest]);
244
+
245
+ // Auto-scroll the terminal to the bottom when new events arrive.
246
+ const termRef = useRef<HTMLDivElement>(null);
247
+ useEffect(() => {
248
+ if (termRef.current) termRef.current.scrollTop = termRef.current.scrollHeight;
249
+ }, [data?.events?.length, data?.stderr_tail]);
250
+
251
+ return (
252
+ <div className="sticky top-3 flex h-[calc(100vh-7rem)] min-h-[24rem] flex-col rounded-xl border border-border bg-card">
253
+ <div className="flex items-center gap-2 border-b border-border px-3 py-2 text-xs">
254
+ <Terminal size={13} className="text-muted-fg" />
255
+ <span className="font-medium">{t("project.mcps.logs_panel_title")}</span>
256
+ {mcpName ? (
257
+ <Badge tone="info">{mcpName}</Badge>
258
+ ) : (
259
+ <span className="text-muted-fg">— {t("project.mcps.logs_panel_pick")}</span>
260
+ )}
261
+ </div>
262
+ <div ref={termRef} className="flex-1 overflow-auto bg-background/60 px-3 py-2 font-mono text-[11px]">
263
+ {!mcpName && (
264
+ <p className="text-muted-fg">{t("project.mcps.logs_panel_hint")}</p>
265
+ )}
266
+ {mcpName && err && (
267
+ <p className="text-red-400">{err}</p>
268
+ )}
269
+ {mcpName && !err && data && (
270
+ <>
271
+ <div className="mb-2 text-muted-fg">
272
+ {data.transport.toUpperCase()}{data.url ? ` · ${data.url}` : data.command ? ` · ${data.command}` : ""}
273
+ {data.last_error ? ` · last_error: ${data.last_error}` : ""}
274
+ </div>
275
+ {data.note && <p className="text-muted-fg">{data.note}</p>}
276
+ {(!data.events || data.events.length === 0) && !data.stderr_tail && !data.note && (
277
+ <p className="text-muted-fg">{t("project.mcps.logs_panel_idle")}</p>
278
+ )}
279
+ {data.events?.map((e, i) => (
280
+ <div key={i} className="flex gap-2">
281
+ <span className="text-muted-fg">{e.ts.slice(11, 19)}</span>
282
+ <span className={
283
+ e.level === "error" ? "text-red-400"
284
+ : e.level === "stderr" ? "text-amber-400"
285
+ : "text-emerald-400"
286
+ }>{e.level}</span>
287
+ <span className="flex-1 break-all">{e.msg}</span>
288
+ </div>
289
+ ))}
290
+ {data.stderr_tail && (
291
+ <div className="mt-2 border-t border-border/60 pt-2">
292
+ <div className="mb-1 text-muted-fg">stderr</div>
293
+ <pre className="whitespace-pre-wrap break-all text-amber-300/80">{data.stderr_tail}</pre>
294
+ </div>
295
+ )}
296
+ </>
297
+ )}
298
+ </div>
299
+ </div>
300
+ );
301
+ }
302
+
303
+ // -- Upsert dialog -----------------------------------------------------------
304
+
305
+ interface UpsertProps {
306
+ mode: { kind: "new" } | { kind: "edit"; entry: McpEntry };
307
+ pid: string;
308
+ varNames: string[];
309
+ onClose: () => void;
310
+ onSaved: () => void;
311
+ onVarsChanged: () => void;
312
+ }
313
+
314
+ function UpsertMcpDialog({ mode, pid, varNames, onClose, onSaved, onVarsChanged }: UpsertProps) {
64
315
  const toast = useToast();
316
+ const isEdit = mode.kind === "edit";
317
+ const initial = isEdit ? mode.entry : null;
318
+
65
319
  const [busy, setBusy] = useState(false);
66
- const [scope, setScope] = useState<"shared" | "runtime" | "global">("shared");
67
- const [name, setName] = useState("");
68
- const [transport, setTransport] = useState<"stdio" | "http">("stdio");
69
- const [command, setCommand] = useState("");
70
- const [args, setArgs] = useState("");
71
- const [url, setUrl] = useState("");
72
- const [env, setEnv] = useState("");
73
- const [enabled, setEnabled] = useState(true);
320
+ const [scope, setScope] = useState<McpScope>(initial ? sourceToScope(initial.source) : "runtime");
321
+ const [name, setName] = useState(initial?.name || "");
322
+ const [transport, setTransport] = useState<"stdio" | "http">(
323
+ (initial?.transport === "http" || !!initial?.url) ? "http" : "stdio",
324
+ );
325
+ const [command, setCommand] = useState(initial?.command || "");
326
+ const [args, setArgs] = useState<string[]>(initial?.args && initial.args.length ? initial.args : [""]);
327
+ const [envRows, setEnvRows] = useState<KvRow[]>(rowsFromRecord(initial?.env));
328
+ const [url, setUrl] = useState(initial?.url || "");
329
+ const [headerRows, setHeaderRows] = useState<KvRow[]>(rowsFromRecord(initial?.headers));
330
+ const [enabled, setEnabled] = useState(initial?.enabled !== false);
331
+ const [createVarOpen, setCreateVarOpen] = useState(false);
74
332
 
75
333
  const submit = async () => {
76
- if (!name) { toast.error(t("project.mcps.name_required")); return; }
334
+ if (!name.trim()) { toast.error(t("project.mcps.name_required")); return; }
77
335
  setBusy(true);
78
336
  try {
79
- let parsedEnv: Record<string, string> | undefined;
80
- if (env.trim()) {
81
- try { parsedEnv = JSON.parse(env); }
82
- catch { toast.error(t("project.mcps.env_invalid")); setBusy(false); return; }
83
- }
84
- const body =
337
+ const cleanArgs = args.map((a) => a.trim()).filter(Boolean);
338
+ const body: McpAddBody =
85
339
  transport === "stdio"
86
- ? { name, command, args: args ? args.split(/\s+/) : undefined, env: parsedEnv, enabled }
87
- : { name, url, enabled };
340
+ ? {
341
+ name: name.trim(),
342
+ command: command.trim(),
343
+ args: cleanArgs.length ? cleanArgs : undefined,
344
+ env: envRows.length ? recordFromRows(envRows) : undefined,
345
+ enabled,
346
+ }
347
+ : {
348
+ name: name.trim(),
349
+ url: url.trim(),
350
+ headers: headerRows.length ? recordFromRows(headerRows) : undefined,
351
+ enabled,
352
+ };
88
353
  await Mcps.add(pid, scope, body);
89
- toast.success(t("project.mcps.added"));
90
- setName(""); setCommand(""); setArgs(""); setUrl(""); setEnv("");
91
- onCreated();
92
- } catch (e: any) { toast.error(e?.message || t("common.error_generic")); }
93
- finally { setBusy(false); }
354
+ toast.success(isEdit ? t("project.mcps.updated") : t("project.mcps.added"));
355
+ onSaved();
356
+ } catch (e: any) {
357
+ toast.error(e?.message || t("common.error_generic"));
358
+ } finally {
359
+ setBusy(false);
360
+ }
94
361
  };
95
362
 
363
+ return (
364
+ <>
365
+ <Dialog
366
+ open
367
+ onClose={() => (busy ? null : onClose())}
368
+ title={isEdit ? t("project.mcps.edit_title") : t("project.mcps.new_title")}
369
+ description={isEdit ? initial?.name : t("project.mcps.new_desc")}
370
+ size="lg"
371
+ footer={
372
+ <>
373
+ <Button variant="ghost" onClick={onClose} disabled={busy}>{t("common.cancel")}</Button>
374
+ <Button variant="primary" onClick={submit} loading={busy}>
375
+ {isEdit ? t("project.mcps.save_btn") : t("project.mcps.add_btn")}
376
+ </Button>
377
+ </>
378
+ }
379
+ >
380
+ <div className="space-y-4">
381
+ <div className="grid grid-cols-2 gap-3">
382
+ <Field label={t("project.mcps.scope_label")}>
383
+ <UiSelect
384
+ value={scope}
385
+ onChange={(v) => setScope(v as McpScope)}
386
+ options={[
387
+ { value: "runtime", label: t("project.mcps.scope_runtime"), description: t("project.mcps.scope_runtime_desc") },
388
+ { value: "shared", label: t("project.mcps.scope_shared"), description: t("project.mcps.scope_shared_desc") },
389
+ { value: "global", label: t("project.mcps.scope_global"), description: t("project.mcps.scope_global_desc") },
390
+ ]}
391
+ />
392
+ </Field>
393
+ <Field label={t("project.mcps.transport_label")}>
394
+ <UiSelect
395
+ value={transport}
396
+ onChange={(v) => setTransport(v as "stdio" | "http")}
397
+ options={[
398
+ { value: "stdio", label: t("project.mcps.transport_stdio"), description: t("project.mcps.transport_stdio_desc") },
399
+ { value: "http", label: t("project.mcps.transport_http"), description: t("project.mcps.transport_http_desc") },
400
+ ]}
401
+ />
402
+ </Field>
403
+ </div>
404
+
405
+ <Field label={t("project.mcps.name_label")}>
406
+ <Input
407
+ value={name}
408
+ onChange={(e) => setName(e.target.value)}
409
+ placeholder={t("project.mcps.name_ph")}
410
+ disabled={isEdit}
411
+ />
412
+ </Field>
413
+
414
+ {transport === "stdio" ? (
415
+ <>
416
+ <Field label={t("project.mcps.cmd_label")}>
417
+ <Input value={command} onChange={(e) => setCommand(e.target.value)} placeholder={t("project.mcps.cmd_ph")} />
418
+ </Field>
419
+ <Field label={t("project.mcps.args_label")} hint={t("project.mcps.args_hint_tokens")}>
420
+ <ArgsList
421
+ args={args}
422
+ onChange={setArgs}
423
+ varNames={varNames}
424
+ onCreateVar={() => setCreateVarOpen(true)}
425
+ />
426
+ </Field>
427
+ <Field label={t("project.mcps.env_label")} hint={t("project.mcps.env_hint_tokens")}>
428
+ <KeyValueList
429
+ rows={envRows}
430
+ onChange={setEnvRows}
431
+ keyPlaceholder="API_KEY"
432
+ valuePlaceholder="${var.MY_TOKEN}"
433
+ varNames={varNames}
434
+ onCreateVar={() => setCreateVarOpen(true)}
435
+ emptyLabel={t("project.mcps.env_empty")}
436
+ />
437
+ </Field>
438
+ </>
439
+ ) : (
440
+ <>
441
+ <Field label={t("project.mcps.url_label")}>
442
+ <VarTokenInput
443
+ value={url}
444
+ onChange={setUrl}
445
+ placeholder={t("project.mcps.url_ph")}
446
+ varNames={varNames}
447
+ onCreateVar={() => setCreateVarOpen(true)}
448
+ />
449
+ </Field>
450
+ <Field label={t("project.mcps.headers_label")} hint={t("project.mcps.headers_hint")}>
451
+ <KeyValueList
452
+ rows={headerRows}
453
+ onChange={setHeaderRows}
454
+ keyPlaceholder="Authorization"
455
+ valuePlaceholder="Bearer ${var.TOKEN}"
456
+ varNames={varNames}
457
+ onCreateVar={() => setCreateVarOpen(true)}
458
+ emptyLabel={t("project.mcps.headers_empty")}
459
+ />
460
+ </Field>
461
+ </>
462
+ )}
463
+
464
+ <Switch checked={enabled} onChange={setEnabled} label={t("project.mcps.enabled_label")} />
465
+ </div>
466
+ </Dialog>
467
+
468
+ {createVarOpen && (
469
+ <QuickCreateVarDialog
470
+ pid={pid}
471
+ onClose={() => setCreateVarOpen(false)}
472
+ onCreated={() => {
473
+ setCreateVarOpen(false);
474
+ onVarsChanged();
475
+ }}
476
+ />
477
+ )}
478
+ </>
479
+ );
480
+ }
481
+
482
+ function ArgsList({
483
+ args,
484
+ onChange,
485
+ varNames,
486
+ onCreateVar,
487
+ }: {
488
+ args: string[];
489
+ onChange: (next: string[]) => void;
490
+ varNames: string[];
491
+ onCreateVar: () => void;
492
+ }) {
493
+ const update = (i: number, v: string) => {
494
+ const next = args.slice();
495
+ next[i] = v;
496
+ onChange(next);
497
+ };
498
+ const remove = (i: number) => onChange(args.filter((_, j) => j !== i));
499
+ const add = () => onChange([...args, ""]);
500
+ return (
501
+ <div className="space-y-2">
502
+ {args.map((a, i) => (
503
+ <div key={i} className="flex items-start gap-2">
504
+ <div className="flex-1">
505
+ <VarTokenInput
506
+ value={a}
507
+ onChange={(v) => update(i, v)}
508
+ placeholder="--flag o valor"
509
+ varNames={varNames}
510
+ onCreateVar={onCreateVar}
511
+ />
512
+ </div>
513
+ <Button type="button" size="sm" variant="ghost" onClick={() => remove(i)} aria-label="quitar arg">
514
+ <Trash2 size={13} />
515
+ </Button>
516
+ </div>
517
+ ))}
518
+ <Button type="button" size="sm" variant="ghost" onClick={add}>
519
+ <Plus size={12} /> {t("project.mcps.add_arg")}
520
+ </Button>
521
+ </div>
522
+ );
523
+ }
524
+
525
+ function QuickCreateVarDialog({
526
+ pid,
527
+ onClose,
528
+ onCreated,
529
+ }: { pid: string; onClose: () => void; onCreated: () => void }) {
530
+ const toast = useToast();
531
+ const isBase = String(pid) === "0";
532
+ const [name, setName] = useState("");
533
+ const [value, setValue] = useState("");
534
+ const [busy, setBusy] = useState(false);
535
+ const [scope, setScope] = useState<"project" | "global">(isBase ? "global" : "project");
536
+ const submit = async () => {
537
+ if (!name.trim()) { toast.error(t("project.vars.name_required")); return; }
538
+ if (!value) { toast.error(t("project.vars.value_required")); return; }
539
+ setBusy(true);
540
+ try {
541
+ await Vars.upsert(pid, { name: name.trim(), value, scope });
542
+ toast.success(t("project.vars.added"));
543
+ onCreated();
544
+ } catch (e: any) {
545
+ toast.error(e?.message || t("common.error_generic"));
546
+ } finally {
547
+ setBusy(false);
548
+ }
549
+ };
96
550
  return (
97
551
  <Dialog
98
- open={open}
99
- onClose={onClose}
100
- title={t("project.mcps.new_title")}
101
- description={t("project.mcps.new_desc")}
552
+ open
553
+ onClose={() => (busy ? null : onClose())}
554
+ title={t("project.vars.new_title")}
555
+ description={t("project.vars.new_desc")}
556
+ size="sm"
102
557
  footer={
103
558
  <>
104
559
  <Button variant="ghost" onClick={onClose} disabled={busy}>{t("common.cancel")}</Button>
105
- <Button variant="primary" onClick={submit} loading={busy}>{t("project.mcps.add_btn")}</Button>
560
+ <Button variant="primary" onClick={submit} loading={busy}>{t("project.vars.add_btn")}</Button>
106
561
  </>
107
562
  }
108
563
  >
109
564
  <div className="space-y-3">
110
- <div className="grid grid-cols-2 gap-3">
111
- <Field label={t("project.mcps.scope_label")}>
112
- <UiSelect
113
- value={scope}
114
- onChange={(v) => setScope(v as any)}
115
- options={[
116
- { value: "shared", label: "shared", description: ".apc/mcps.json" },
117
- { value: "runtime", label: "runtime", description: "~/.apx, con secrets" },
118
- { value: "global", label: "global", description: "estilo ~/.claude/mcp.json" },
119
- ]}
120
- />
121
- </Field>
122
- <Field label={t("project.mcps.transport_label")}>
123
- <UiSelect
124
- value={transport}
125
- onChange={(v) => setTransport(v as any)}
126
- options={[
127
- { value: "stdio", label: "stdio", description: "command" },
128
- { value: "http", label: "http", description: "url" },
129
- ]}
130
- />
131
- </Field>
132
- </div>
133
- <Field label={t("project.mcps.name_label")}><Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("project.mcps.name_ph")} /></Field>
134
- {transport === "stdio" ? (
135
- <>
136
- <Field label={t("project.mcps.cmd_label")}><Input value={command} onChange={(e) => setCommand(e.target.value)} placeholder={t("project.mcps.cmd_ph")} /></Field>
137
- <Field label={t("project.mcps.args_label")} hint={t("project.mcps.args_hint")}><Input value={args} onChange={(e) => setArgs(e.target.value)} placeholder={t("project.mcps.args_ph")} /></Field>
138
- <Field label={t("project.mcps.env_label")} hint='{"FOO":"bar"}'>
139
- <Textarea rows={3} className="font-mono text-xs" value={env} onChange={(e) => setEnv(e.target.value)} />
140
- </Field>
141
- </>
142
- ) : (
143
- <Field label={t("project.mcps.url_label")}><Input value={url} onChange={(e) => setUrl(e.target.value)} placeholder={t("project.mcps.url_ph")} /></Field>
144
- )}
145
- <Switch checked={enabled} onChange={setEnabled} label={t("project.mcps.enabled_label")} />
565
+ <Field label={t("project.vars.scope_label")}>
566
+ <UiSelect
567
+ value={scope}
568
+ onChange={(v) => setScope(v as "project" | "global")}
569
+ options={[
570
+ ...(isBase ? [] : [{ value: "project", label: t("project.vars.scope_project"), description: t("project.vars.scope_project_desc") }]),
571
+ { value: "global", label: t("project.vars.scope_global"), description: t("project.vars.scope_global_desc") },
572
+ ]}
573
+ />
574
+ </Field>
575
+ <Field label={t("project.vars.name_label")} hint={t("project.vars.name_hint")}>
576
+ <Input
577
+ value={name}
578
+ onChange={(e) => setName(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, "_"))}
579
+ placeholder="MY_API_KEY"
580
+ autoFocus
581
+ />
582
+ </Field>
583
+ <Field label={t("project.vars.value_label")} hint={t("project.vars.value_hint")}>
584
+ <Input
585
+ type="password"
586
+ value={value}
587
+ onChange={(e) => setValue(e.target.value)}
588
+ className="font-mono text-xs"
589
+ />
590
+ </Field>
146
591
  </div>
147
592
  </Dialog>
148
593
  );