loopengine 0.1.98 → 0.1.99

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 (35) hide show
  1. package/dist/bin/ability-manager.d.ts +35 -2
  2. package/dist/bin/ability-manager.js +168 -35
  3. package/dist/core/ability-details.js +11 -0
  4. package/dist/core/mcp-abilities.d.ts +91 -0
  5. package/dist/core/mcp-abilities.js +400 -0
  6. package/dist/core/mcp-ability-generator.d.ts +59 -0
  7. package/dist/core/mcp-ability-generator.js +193 -0
  8. package/dist/core/mcp-apps.d.ts +95 -0
  9. package/dist/core/mcp-apps.js +254 -0
  10. package/dist/core/mcp-directory.d.ts +32 -0
  11. package/dist/core/mcp-directory.js +191 -0
  12. package/dist/core/run-agent.js +5 -2
  13. package/dist/core/system-agents/interviewer.d.ts +1 -1
  14. package/dist/core/system-agents/interviewer.js +1 -1
  15. package/dist/core/system-agents/templates.d.ts +11 -0
  16. package/dist/core/system-agents/templates.js +22 -16
  17. package/dist/core/system-agents/workflow-planner.d.ts +36 -3
  18. package/dist/core/system-agents/workflow-planner.js +23 -6
  19. package/dist/core/workspace-abilities.js +1 -1
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +3 -0
  22. package/dist/web/abilities-admin.d.ts +1 -1
  23. package/dist/web/abilities-admin.js +1 -1
  24. package/dist/web/agents-config-page.js +187 -11
  25. package/dist/web/console-shell.js +7 -5
  26. package/dist/web/dev-ui-styles.js +1 -1
  27. package/dist/web/global-config-page.js +293 -13
  28. package/dist/web/interview-admin.js +2 -0
  29. package/dist/web/templates-admin.d.ts +10 -2
  30. package/dist/web/templates-admin.js +36 -3
  31. package/dist/web/templates-page.js +1 -0
  32. package/dist/web/workflow-admin.d.ts +46 -0
  33. package/dist/web/workflow-admin.js +195 -3
  34. package/dist/web/workflow-page.js +33 -3
  35. package/package.json +10 -1
@@ -1,7 +1,7 @@
1
1
  import { nextTimeOfDay, parseTimeOfDay, workspaceTimeZone } from '../time-zone.js';
2
2
  const GMAIL_KEYS = ['GMAIL_CLIENT_ID', 'GMAIL_CLIENT_SECRET', 'GMAIL_REFRESH_TOKEN'];
3
3
  const SHOPIFY_KEYS = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_ADMIN_TOKEN'];
4
- const GITHUB_KEYS = ['GITHUB_TOKEN', 'GITHUB_DEFAULT_REPO'];
4
+ const GITHUB_APP = 'io.github.github/github-mcp-server';
5
5
  const SLACK_KEYS = ['SLACK_BOT_TOKEN', 'SLACK_DEFAULT_CHANNEL'];
6
6
  const WEB_SEARCH_KEYS = [['TAVILY_API_KEY', 'BRAVE_API_KEY', 'FIRECRAWL_API_KEY']];
7
7
  const DOCS_NOTE = 'Put your policies and help articles in the project\'s docs folder, as Markdown or text files (or point DOCS_SEARCH_PATH at another folder)';
@@ -128,20 +128,22 @@ Rules:
128
128
  risk: 'medium',
129
129
  asksFirst: ['opening or commenting on issues'],
130
130
  needs: [
131
- { kind: 'ability', name: 'lp-github', description: 'your repository\'s commits, deploys and issues' },
131
+ { kind: 'app', name: 'GitHub', description: 'your repository\'s commits, deploys and issues' },
132
132
  { kind: 'ability', name: 'lp-slack-notifier', description: 'posting the summary to Slack' },
133
133
  ],
134
134
  agent: {
135
135
  name: 'incident-triage',
136
136
  systemPrompt: `You help the on-call engineer triage incidents.
137
137
 
138
+ Our repository on GitHub: [owner/name] (edit this). Pass its owner and repo to every GitHub tool.
139
+
138
140
  When you get an alert, error or bug report:
139
141
  1. Summarise it in two sentences: what is failing and who is affected.
140
142
  2. Rate the severity: SEV1 (customers can't use the product), SEV2 (a major feature is broken), SEV3 (degraded or a workaround exists), SEV4 (minor).
141
- 3. Find what changed shortly before it started: github_list_workflow_runs for recent deploys and failed builds, github_list_pull_requests for recently merged changes, and github_list_commits (narrowed with since_hours and path) for detail. Match times carefully.
142
- 4. Check whether it's already known with github_search_issues; read a match with github_get_issue.
143
+ 3. Find what changed shortly before it started: github_actions_list (method list_workflow_runs) for recent deploys and failed builds, github_list_pull_requests (state closed) for recently merged changes, and github_list_commits (narrowed with since and path) for detail. Match times carefully.
144
+ 4. Check whether it's already known with github_search_issues; read a match with github_issue_read (method get).
143
145
  5. Post a short summary to Slack with post_to_slack: what's broken, severity, the most likely cause with links, and one next step (who should look, what to check, or whether to roll back).
144
- 6. If an issue already tracks it, add your findings with github_comment_issue; otherwise, for SEV1 or SEV2, open one with github_create_issue. Both ask a person first.
146
+ 6. If an issue already tracks it, add your findings with github_add_issue_comment; otherwise, for SEV1 or SEV2, open one with github_issue_write (method create). Both ask a person first.
145
147
 
146
148
  Rules:
147
149
  - Only read on your own; never roll anything back yourself.
@@ -151,12 +153,13 @@ Rules:
151
153
  },
152
154
  },
153
155
  setup: {
154
- abilities: ['lp-github', 'lp-slack-notifier'],
155
- keys: [...GITHUB_KEYS, ...SLACK_KEYS],
156
+ abilities: ['lp-slack-notifier'],
157
+ apps: [{ id: GITHUB_APP, title: 'GitHub', tools: ['list_commits', 'list_pull_requests', 'actions_list', 'search_issues', 'issue_read', 'issue_write', 'add_issue_comment'] }],
158
+ keys: [...SLACK_KEYS],
156
159
  rules: [
157
160
  { name: 'template-slack-summary-allowed', tool: 'post_to_slack', decision: 'allow' },
158
- { name: 'template-new-issues-need-approval', tool: 'github_create_issue', decision: 'ask' },
159
- { name: 'template-comments-need-approval', tool: 'github_comment_issue', decision: 'ask' },
161
+ { name: 'template-new-issues-need-approval', tool: 'github_issue_write', decision: 'ask' },
162
+ { name: 'template-comments-need-approval', tool: 'github_add_issue_comment', decision: 'ask' },
160
163
  ],
161
164
  needsApprovals: true,
162
165
  notes: ['Send it alerts: paste one into Chat, have your alerting tool call it over HTTP, or reply to it in Slack'],
@@ -280,12 +283,14 @@ Rules:
280
283
  asksFirst: ['replying to the sender'],
281
284
  needs: [
282
285
  { kind: 'ability', name: 'lp-gmail', description: 'the shared inbox' },
283
- { kind: 'ability', name: 'lp-github', description: 'tasks, as GitHub issues' },
286
+ { kind: 'app', name: 'GitHub', description: 'tasks, as GitHub issues' },
284
287
  ],
285
288
  agent: {
286
289
  name: 'inbox-to-tasks',
287
290
  systemPrompt: `You turn requests in our shared inbox into tasks, kept as GitHub issues.
288
291
 
292
+ Tasks live in this GitHub repository: [owner/name] (edit this). Pass its owner and repo to every GitHub tool.
293
+
289
294
  Who handles what (edit this; GitHub usernames):
290
295
  - Billing and invoices: [username]
291
296
  - Technical problems: [username]
@@ -294,8 +299,8 @@ Who handles what (edit this; GitHub usernames):
294
299
  When asked to handle new emails:
295
300
  1. Find them with gmail_search, query "is:unread in:inbox". Read each with gmail_get_thread.
296
301
  2. Decide whether it's a request someone needs to act on. Newsletters, receipts, out-of-office replies and spam are not: mark them read and move on.
297
- 3. Check whether a task already exists with github_search_issues (the sender and subject). If so, add the new details with github_comment_issue instead.
298
- 4. Otherwise open one with github_create_issue: a short title starting with a verb, a two-line summary, the sender, the due date (the date they asked for, or 2 working days if they didn't say), and the owner from the list above as the assignee.
302
+ 3. Check whether a task already exists with github_search_issues (the sender and subject). If so, add the new details with github_add_issue_comment instead.
303
+ 4. Otherwise open one with github_issue_write (method create): a short title starting with a verb, a two-line summary, the sender, the due date (the date they asked for, or 2 working days if they didn't say), and the owner from the list above as the assignee.
299
304
  5. Reply to the sender with gmail_send (reply_to_message_id set): who is handling it and by when. A person approves it first.
300
305
  6. Mark the email read with gmail_modify_labels.
301
306
 
@@ -307,14 +312,15 @@ Rules:
307
312
  },
308
313
  },
309
314
  setup: {
310
- abilities: ['lp-gmail', 'lp-github'],
311
- keys: [...GMAIL_KEYS, ...GITHUB_KEYS],
315
+ abilities: ['lp-gmail'],
316
+ apps: [{ id: GITHUB_APP, title: 'GitHub', tools: ['search_issues', 'issue_write', 'add_issue_comment'] }],
317
+ keys: [...GMAIL_KEYS],
312
318
  rules: [
313
319
  { name: 'template-read-mail-allowed', tool: 'gmail_search', decision: 'allow' },
314
320
  { name: 'template-read-thread-allowed', tool: 'gmail_get_thread', decision: 'allow' },
315
321
  { name: 'template-labels-allowed', tool: 'gmail_modify_labels', decision: 'allow' },
316
- { name: 'template-tasks-allowed', tool: 'github_create_issue', decision: 'allow' },
317
- { name: 'template-task-updates-allowed', tool: 'github_comment_issue', decision: 'allow' },
322
+ { name: 'template-tasks-allowed', tool: 'github_issue_write', decision: 'allow' },
323
+ { name: 'template-task-updates-allowed', tool: 'github_add_issue_comment', decision: 'allow' },
318
324
  { name: 'template-replies-need-approval', tool: 'gmail_send', decision: 'ask' },
319
325
  ],
320
326
  schedule: { every: '10m', name: 'Check the shared inbox', message: 'Turn the new emails in the shared inbox into tasks.' },
@@ -1,6 +1,6 @@
1
1
  import type { ToolSchema } from '#core/agent-config.js';
2
2
  import type { ModelCall } from '#core/run-agent.js';
3
- import type { StarterTemplate } from './templates.js';
3
+ import type { StarterTemplate, TemplateApp } from './templates.js';
4
4
  export type Decision = 'allow' | 'ask' | 'deny';
5
5
  export interface WorkflowStep {
6
6
  title: string;
@@ -18,6 +18,30 @@ export interface WorkflowStep {
18
18
  * an ability): the source's name. It's copied to the agent that runs the
19
19
  * workflow, not installed. */
20
20
  connection?: string;
21
+ /** For a step done by an MCP server's tool, when no ability has it: the
22
+ * server's id (search_mcp_servers). `tool` is then the server's own
23
+ * tool name, if the planner could see them. On Create the server
24
+ * becomes a private ability (core/mcp-ability-generator.ts) and the
25
+ * step points at its tool instead. */
26
+ mcp?: string;
27
+ }
28
+ /** An MCP server a plan would set up as a private ability. */
29
+ export interface PlannedMcpServer {
30
+ id: string;
31
+ title: string;
32
+ url: string;
33
+ description: string;
34
+ /** Saved by the workspace, or on its publisher's verified domain. */
35
+ verified: boolean;
36
+ source: 'saved' | 'registry';
37
+ /** The private ability it becomes, e.g. mcp-linear. */
38
+ ability: string;
39
+ /** The setting its key is saved as, when it takes one. */
40
+ keyEnv: string;
41
+ /** Whether that setting already has a value. */
42
+ keySet: boolean;
43
+ /** The workspace already has an ability made from this server. */
44
+ existing?: string;
21
45
  }
22
46
  export interface WorkflowPlan {
23
47
  name: string;
@@ -64,6 +88,11 @@ export interface WorkflowPlan {
64
88
  };
65
89
  /** What the workspace can't do yet, in plain words. */
66
90
  gaps: string[];
91
+ /** MCP servers its steps use, set up on Create (filled in after planning). */
92
+ mcpServers?: PlannedMcpServer[];
93
+ /** Connected apps it adds to the agent, with just the tools its steps
94
+ * use (filled in on Create, from mcpServers). */
95
+ apps?: TemplateApp[];
67
96
  }
68
97
  export interface WorkflowPlannerTools {
69
98
  /** Every agent: its tools, installed abilities, rules and messaging. */
@@ -81,9 +110,13 @@ export interface WorkflowPlannerTools {
81
110
  /** An ability's tools (with the decision each ships with) and settings. */
82
111
  abilityDetails(spec: string): Promise<unknown>;
83
112
  listConnections(): Promise<Record<string, unknown>>;
113
+ /** MCP servers matching a query (core/mcp-directory.ts). Optional: without it the planner only plans with abilities. */
114
+ searchMcpServers?(query: string): Promise<unknown>;
115
+ /** A server's tools, when it can be listed without a key (or with one already set). */
116
+ mcpServerTools?(id: string): Promise<unknown>;
84
117
  }
85
- export declare const WORKFLOW_PLANNER_PROMPT = "You turn a workflow a person describes into a plan for a LoopEngine agent.\n\nLoopEngine agents call tools. Tools come from abilities (installable packages: public ones on npm, or the workspace's own private ones) or are built in. Each tool has a permission: allow (runs on its own), ask (waits for a person to approve, edit or deny it) or deny. When nobody is in Chat, questions and approvals go to the agent's messaging app (Slack, Telegram, Lark or email) and the run waits, for days if needed, then carries on.\n\nHow to plan:\n1. Look before you plan: list_agents (what exists, and what each agent's tools and rules are), list_workspace_abilities, search_public_abilities for each kind of step, ability_details for any ability you might use (its exact tool names and the settings it needs), and list_connections.\n2. Split the workflow into steps, in order. For each step:\n - something to fetch or do: kind \"tool\", with the exact tool name and where it comes from. Pick the source in this order:\n a. A tool the target agent already has (from list_agents): use it, nothing to install.\n b. An ability: a workspace ability (list_workspace_abilities) or a public one (search_public_abilities, e.g. \"firecrawl\" or \"web search\" for searching and reading the web). Set \"ability\" to its install spec (the npm name, or a workspace ability's spec) and use the exact tool name from ability_details.\n - the person chooses or answers (\"ask me which\", \"let me pick\"): kind \"ask\", tool \"system_ask_user\" (built in, no ability). Say what options are offered. It reaches them through the agent's messaging app by itself, so don't add a separate step to post to Slack or another chat first, and don't install a chat ability just to ask.\n - the person reviews or approves before something happens (\"let me review it\", \"before it's sent\"): kind \"approval\", on the tool that does that thing; its rule becomes \"ask\".\n - the agent's own work (writing, summarising, choosing): kind \"other\".\n - nothing found can do it: set \"missing\" to what's needed, and add it to gaps.\n3. Rules: every tool the workflow uses gets one. Reading and searching: allow. Anything a person should see first (sending, publishing, paying, creating something others see): ask. Never set deny unless asked.\n4. Skill: the steps in order, as numbered instructions the agent follows, naming the tools and saying exactly when to stop and ask. Write it so the agent keeps going after each answer or decision.\n5. Trigger: a schedule (\"every Monday at 9\" is every \"7d\", at \"09:00\", weekday 1) or on_request (started from Chat or a message). startMessage is what the agent receives when it starts.\n6. Target: an existing agent only when it already has (or can install) the tools, none of the workflow's rules would change a decision it already has, and the same people would approve. Otherwise a new agent, with a short lowercase-hyphenated name and a system prompt saying who it is and what it's for. Give the reason either way.\n7. keys: the setting names its abilities need that list_connections or the agents don't show as set (from ability_details settings).\n\nThen call save_plan once, with the whole plan. Don't ask the person questions: if something is unclear, plan the most reasonable version and note the assumption in gaps.";
86
- export declare function workflowPlannerTools(): ToolSchema[];
118
+ export declare const WORKFLOW_PLANNER_PROMPT = "You turn a workflow a person describes into a plan for a LoopEngine agent.\n\nLoopEngine agents call tools. Tools come from abilities (installable packages: public ones on npm, or the workspace's own private ones) or are built in. Each tool has a permission: allow (runs on its own), ask (waits for a person to approve, edit or deny it) or deny. When nobody is in Chat, questions and approvals go to the agent's messaging app (Slack, Telegram, Lark or email) and the run waits, for days if needed, then carries on.\n\nHow to plan:\n1. Look before you plan: list_agents (what exists, and what each agent's tools and rules are), list_workspace_abilities, search_public_abilities for each kind of step, ability_details for any ability you might use (its exact tool names and the settings it needs), and list_connections.\n2. Split the workflow into steps, in order. For each step:\n - something to fetch or do: kind \"tool\", with the exact tool name and where it comes from. Pick the source in this order:\n a. A tool the target agent already has (from list_agents): use it, nothing to install.\n b. An ability: a workspace ability (list_workspace_abilities) or a public one (search_public_abilities, e.g. \"firecrawl\" or \"web search\" for searching and reading the web). Set \"ability\" to its install spec (the npm name, or a workspace ability's spec) and use the exact tool name from ability_details.\n c. Only when no ability can do it, and search_mcp_servers is available: an app's MCP server. Search for the app (e.g. \"linear\", \"stripe\"), pick the app's own server (official), else a saved or verified one, then call mcp_server_tools. Set \"mcp\" to the server's id, and \"tool\" to its exact tool name when mcp_server_tools lists them (leave \"tool\" empty when it needs a key first, and say in \"detail\" what the step does). It's set up as a private ability when the workflow is created, which picks its tools and their permissions, so add no rules for MCP tools.\n - the person chooses or answers (\"ask me which\", \"let me pick\"): kind \"ask\", tool \"system_ask_user\" (built in, no ability). Say what options are offered. It reaches them through the agent's messaging app by itself, so don't add a separate step to post to Slack or another chat first, and don't install a chat ability just to ask.\n - the person reviews or approves before something happens (\"let me review it\", \"before it's sent\"): kind \"approval\", on the tool that does that thing; its rule becomes \"ask\".\n - the agent's own work (writing, summarising, choosing): kind \"other\".\n - nothing found can do it: set \"missing\" to what's needed, and add it to gaps.\n3. Rules: every tool the workflow uses gets one. Reading and searching: allow. Anything a person should see first (sending, publishing, paying, creating something others see): ask. Never set deny unless asked.\n4. Skill: the steps in order, as numbered instructions the agent follows, naming the tools and saying exactly when to stop and ask. Write it so the agent keeps going after each answer or decision.\n5. Trigger: a schedule (\"every Monday at 9\" is every \"7d\", at \"09:00\", weekday 1) or on_request (started from Chat or a message). startMessage is what the agent receives when it starts.\n6. Target: an existing agent only when it already has (or can install) the tools, none of the workflow's rules would change a decision it already has, and the same people would approve. Otherwise a new agent, with a short lowercase-hyphenated name and a system prompt saying who it is and what it's for. Give the reason either way.\n7. keys: the setting names its abilities need that list_connections or the agents don't show as set (from ability_details settings). Not for MCP servers: their keys are asked for on the plan.\n\nThen call save_plan once, with the whole plan. Don't ask the person questions: if something is unclear, plan the most reasonable version and note the assumption in gaps.";
119
+ export declare function workflowPlannerTools(withMcp?: boolean): ToolSchema[];
87
120
  /** lowercase-hyphenated, 2–40 characters. */
88
121
  export declare function slugify(value: string, fallback?: string): string;
89
122
  /** A plan from the model's save_plan input, or undefined when it lacks a
@@ -9,6 +9,7 @@ How to plan:
9
9
  - something to fetch or do: kind "tool", with the exact tool name and where it comes from. Pick the source in this order:
10
10
  a. A tool the target agent already has (from list_agents): use it, nothing to install.
11
11
  b. An ability: a workspace ability (list_workspace_abilities) or a public one (search_public_abilities, e.g. "firecrawl" or "web search" for searching and reading the web). Set "ability" to its install spec (the npm name, or a workspace ability's spec) and use the exact tool name from ability_details.
12
+ c. Only when no ability can do it, and search_mcp_servers is available: an app's MCP server. Search for the app (e.g. "linear", "stripe"), pick the app's own server (official), else a saved or verified one, then call mcp_server_tools. Set "mcp" to the server's id, and "tool" to its exact tool name when mcp_server_tools lists them (leave "tool" empty when it needs a key first, and say in "detail" what the step does). It's set up as a private ability when the workflow is created, which picks its tools and their permissions, so add no rules for MCP tools.
12
13
  - the person chooses or answers ("ask me which", "let me pick"): kind "ask", tool "system_ask_user" (built in, no ability). Say what options are offered. It reaches them through the agent's messaging app by itself, so don't add a separate step to post to Slack or another chat first, and don't install a chat ability just to ask.
13
14
  - the person reviews or approves before something happens ("let me review it", "before it's sent"): kind "approval", on the tool that does that thing; its rule becomes "ask".
14
15
  - the agent's own work (writing, summarising, choosing): kind "other".
@@ -17,10 +18,10 @@ How to plan:
17
18
  4. Skill: the steps in order, as numbered instructions the agent follows, naming the tools and saying exactly when to stop and ask. Write it so the agent keeps going after each answer or decision.
18
19
  5. Trigger: a schedule ("every Monday at 9" is every "7d", at "09:00", weekday 1) or on_request (started from Chat or a message). startMessage is what the agent receives when it starts.
19
20
  6. Target: an existing agent only when it already has (or can install) the tools, none of the workflow's rules would change a decision it already has, and the same people would approve. Otherwise a new agent, with a short lowercase-hyphenated name and a system prompt saying who it is and what it's for. Give the reason either way.
20
- 7. keys: the setting names its abilities need that list_connections or the agents don't show as set (from ability_details settings).
21
+ 7. keys: the setting names its abilities need that list_connections or the agents don't show as set (from ability_details settings). Not for MCP servers: their keys are asked for on the plan.
21
22
 
22
23
  Then call save_plan once, with the whole plan. Don't ask the person questions: if something is unclear, plan the most reasonable version and note the assumption in gaps.`;
23
- export function workflowPlannerTools() {
24
+ export function workflowPlannerTools(withMcp = false) {
24
25
  const step = {
25
26
  type: 'object',
26
27
  properties: {
@@ -28,6 +29,7 @@ export function workflowPlannerTools() {
28
29
  kind: { type: 'string', enum: ['tool', 'ask', 'approval', 'other'] },
29
30
  tool: { type: 'string' },
30
31
  ability: { type: 'string' },
32
+ mcp: { type: 'string', description: "an MCP server's id, for a step no ability can do" },
31
33
  detail: { type: 'string' },
32
34
  missing: { type: 'string' },
33
35
  },
@@ -39,6 +41,10 @@ export function workflowPlannerTools() {
39
41
  { name: 'search_public_abilities', description: 'Search public LoopEngine abilities on npm, e.g. "web search", "wordpress", "slack".', input_schema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } },
40
42
  { name: 'ability_details', description: "An ability's exact tool names, what each does, the permission each ships with, and the settings it needs. spec: its npm name or workspace spec.", input_schema: { type: 'object', properties: { spec: { type: 'string' } }, required: ['spec'] } },
41
43
  { name: 'list_connections', description: 'What is set up: model providers with keys, messaging apps per agent.', input_schema: { type: 'object', properties: {} } },
44
+ ...(withMcp ? [
45
+ { name: 'search_mcp_servers', description: "Search apps' MCP servers (the workspace's saved ones, then the public MCP Registry), for a step no ability can do. query: the app's name, e.g. \"linear\".", input_schema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } },
46
+ { name: 'mcp_server_tools', description: "An MCP server's tools (name, description, read-only or not), or a note that it needs a key before they can be seen. id: from search_mcp_servers.", input_schema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
47
+ ] : []),
42
48
  {
43
49
  name: 'save_plan',
44
50
  description: 'Save the workflow plan. Call once, at the end.',
@@ -110,6 +116,7 @@ export function validateWorkflowPlan(raw) {
110
116
  ...(str(step.detail, 600) ? { detail: str(step.detail, 600) } : {}),
111
117
  ...(str(step.missing, 300) ? { missing: str(step.missing, 300) } : {}),
112
118
  ...(str(step.connection, 120) ? { connection: str(step.connection, 120) } : {}),
119
+ ...(str(step.mcp, 200) && !str(step.ability, 300) ? { mcp: str(step.mcp, 200) } : {}),
113
120
  }];
114
121
  });
115
122
  if (!steps.length)
@@ -125,14 +132,17 @@ export function validateWorkflowPlan(raw) {
125
132
  ...(typeof triggerRaw.weekday === 'number' && triggerRaw.weekday >= 0 && triggerRaw.weekday <= 7 ? { weekday: triggerRaw.weekday } : {}),
126
133
  }
127
134
  : { kind: 'on_request', description: str(triggerRaw.description, 200) ?? 'when asked' };
135
+ // An MCP step's permissions are set when its server becomes an ability.
136
+ const mcpTools = new Set(steps.flatMap((s) => (s.mcp && s.tool ? [s.tool] : [])));
128
137
  const rules = (Array.isArray(r.rules) ? r.rules : []).flatMap((x) => {
129
138
  const rule = (x ?? {});
130
139
  const tool = str(rule.tool, 120);
131
140
  return tool && isDecision(rule.decision) && tool !== 'system_ask_user' ? [{ tool, decision: rule.decision }] : [];
132
- });
133
- // An approval step's tool always asks first.
141
+ }).filter((rule) => !mcpTools.has(rule.tool));
142
+ // An approval step's tool always asks first (an MCP step's tool gets
143
+ // that when its ability is made).
134
144
  for (const step of steps) {
135
- if (step.kind !== 'approval' || !step.tool)
145
+ if (step.kind !== 'approval' || !step.tool || step.mcp)
136
146
  continue;
137
147
  const rule = rules.find((x) => x.tool === step.tool);
138
148
  if (rule)
@@ -175,7 +185,7 @@ export async function planWorkflowWithModel(modelCall, description, tools) {
175
185
  for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
176
186
  let response;
177
187
  try {
178
- response = await modelCall(messages, WORKFLOW_PLANNER_PROMPT, workflowPlannerTools());
188
+ response = await modelCall(messages, WORKFLOW_PLANNER_PROMPT, workflowPlannerTools(Boolean(tools.searchMcpServers)));
179
189
  }
180
190
  catch (err) {
181
191
  // A reply cut off part-way (its tool arguments end mid-JSON) usually
@@ -226,6 +236,12 @@ export async function planWorkflowWithModel(modelCall, description, tools) {
226
236
  case 'list_connections':
227
237
  output = await tools.listConnections();
228
238
  break;
239
+ case 'search_mcp_servers':
240
+ output = tools.searchMcpServers ? await tools.searchMcpServers(String(input.query ?? '')) : 'not available';
241
+ break;
242
+ case 'mcp_server_tools':
243
+ output = tools.mcpServerTools ? await tools.mcpServerTools(String(input.id ?? '')) : 'not available';
244
+ break;
229
245
  case 'save_plan':
230
246
  plan = validateWorkflowPlan(input);
231
247
  output = plan ? 'saved' : 'that plan needs a name, summary, at least one step, a skill body and newAgent.systemPrompt; fix and call again';
@@ -263,6 +279,7 @@ export function workflowTemplate(plan) {
263
279
  },
264
280
  setup: {
265
281
  abilities: plan.abilities,
282
+ ...(plan.apps?.length ? { apps: plan.apps } : {}),
266
283
  keys: plan.keys,
267
284
  skills: [{ id: plan.id, description: plan.skill.description, body: plan.skill.body }],
268
285
  rules: plan.rules.map((r) => ({ name: slugify(`${plan.id}-${r.tool}`, `${plan.id}-rule`), tool: r.tool, decision: r.decision })),
@@ -1,6 +1,6 @@
1
1
  // The workspace's own copies of private abilities: installed once from a
2
2
  // private source (core/ability-sources.ts) on the console's Abilities
3
- // page, then added to any agent from its Abilities tab. Each copy is a
3
+ // page, then added to any agent from its Apps tab. Each copy is a
4
4
  // folder under abilities/ in the project root (or the folder
5
5
  // LOOPENGINE_ABILITIES_DIR names), and abilities/workspace.json says
6
6
  // where each came from:
package/dist/index.d.ts CHANGED
@@ -99,6 +99,9 @@ export type { PublicAbilitySearchResult } from './web/abilities-admin.js';
99
99
  export { AbilitySourceError, abilityOrigin, readAbilitySources, saveAbilitySource, removeAbilitySource, listSourceAbilities, sourceTokenVar, type AbilitySource, type SourceAbility, type AbilityOrigin } from './core/ability-sources.js';
100
100
  export { describeAbilitySources, listPrivateAbilities, saveAbilitySourceFromConsole, removeAbilitySourceFromConsole } from './web/ability-sources-admin.js';
101
101
  export { readAbilityDetails, readToolDefinitions, type AbilityDetails } from './core/ability-details.js';
102
+ export { loadMcpAbilityTools, mcpAbilityLoadError, closeMcpConnections, resolveMcpManifest, listMcpServerTools, isMcpAuthError, type McpServerManifest, type McpAuth, type InstalledMcpRecord, type McpToolInfo } from './core/mcp-abilities.js';
103
+ export { searchMcpServers, getMcpServer, savedMcpServers, type McpDirectoryServer } from './core/mcp-directory.js';
104
+ export { generateMcpAbility, mcpAbilityNames, McpAbilityGenerationError, type GeneratedMcpAbility, type McpAbilityRequest } from './core/mcp-ability-generator.js';
102
105
  export { abilityDetails } from './web/ability-details-admin.js';
103
106
  export { WorkspaceAbilityError, listWorkspaceAbilities, addWorkspaceAbility, removeWorkspaceAbility, workspaceAbilitiesDir, type WorkspaceAbility } from './core/workspace-abilities.js';
104
107
  export { installWorkspaceAbility, installUploadedAbility, MAX_UPLOAD_BYTES, type AbilityUpload } from './web/workspace-abilities-admin.js';
package/dist/index.js CHANGED
@@ -89,6 +89,9 @@ export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilitie
89
89
  export { AbilitySourceError, abilityOrigin, readAbilitySources, saveAbilitySource, removeAbilitySource, listSourceAbilities, sourceTokenVar } from './core/ability-sources.js';
90
90
  export { describeAbilitySources, listPrivateAbilities, saveAbilitySourceFromConsole, removeAbilitySourceFromConsole } from './web/ability-sources-admin.js';
91
91
  export { readAbilityDetails, readToolDefinitions } from './core/ability-details.js';
92
+ export { loadMcpAbilityTools, mcpAbilityLoadError, closeMcpConnections, resolveMcpManifest, listMcpServerTools, isMcpAuthError } from './core/mcp-abilities.js';
93
+ export { searchMcpServers, getMcpServer, savedMcpServers } from './core/mcp-directory.js';
94
+ export { generateMcpAbility, mcpAbilityNames, McpAbilityGenerationError } from './core/mcp-ability-generator.js';
92
95
  export { abilityDetails } from './web/ability-details-admin.js';
93
96
  export { WorkspaceAbilityError, listWorkspaceAbilities, addWorkspaceAbility, removeWorkspaceAbility, workspaceAbilitiesDir } from './core/workspace-abilities.js';
94
97
  export { installWorkspaceAbility, installUploadedAbility, MAX_UPLOAD_BYTES } from './web/workspace-abilities-admin.js';
@@ -8,7 +8,7 @@ export declare function searchPublicAbilities(query?: string): Promise<PublicAbi
8
8
  * `packageName`, or null if it can't be determined — not published
9
9
  * there at all (a private/file:/git-installed ability, the common case
10
10
  * during local development), a network hiccup, or a timeout. Best-effort
11
- * only: the Abilities tab uses this to decide whether an installed
11
+ * only: the Apps tab uses this to decide whether an installed
12
12
  * ability's own "Upgrade" button is worth showing, not to block
13
13
  * anything, so a failure here surfaces as "can't tell, show the button
14
14
  * anyway" rather than an error the operator has to deal with. */
@@ -43,7 +43,7 @@ const NPM_LATEST_VERSION_TIMEOUT_MS = 5000;
43
43
  * `packageName`, or null if it can't be determined — not published
44
44
  * there at all (a private/file:/git-installed ability, the common case
45
45
  * during local development), a network hiccup, or a timeout. Best-effort
46
- * only: the Abilities tab uses this to decide whether an installed
46
+ * only: the Apps tab uses this to decide whether an installed
47
47
  * ability's own "Upgrade" button is worth showing, not to block
48
48
  * anything, so a failure here surfaces as "can't tell, show the button
49
49
  * anyway" rather than an error the operator has to deal with. */
@@ -335,6 +335,16 @@ export const agentsConfigPageHtml = `<!doctype html>
335
335
  #httpToolAddFieldBtn, #httpToolAddHeaderBtn {
336
336
  padding: 6px 12px;
337
337
  }
338
+ .app-pick { border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; margin: 6px 0 12px; display: grid; gap: 8px; background: var(--surface); }
339
+ .app-pick-bar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
340
+ .app-pick-bar input[type=search] { font: inherit; font-size: 12px; padding: 5px 7px; max-width: 240px; }
341
+ .app-pick-list { display: grid; gap: 2px; max-height: 380px; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); padding: 4px; background: var(--bg); }
342
+ .app-pick-tool { display: flex; gap: 8px; align-items: flex-start; padding: 5px 6px; border-radius: 6px; font-size: 12.5px; }
343
+ .app-pick-tool:hover { background: var(--surface); }
344
+ .app-pick-tool input { margin-top: 3px; }
345
+ .app-pick-tool code { overflow-wrap: anywhere; }
346
+ .app-pick-tool select { font: inherit; font-size: 11.5px; padding: 1px 4px; }
347
+ .app-pick-desc { display: block; }
338
348
  </style>
339
349
  </head>
340
350
  <body data-le-page="agent-config">
@@ -361,7 +371,7 @@ ${consoleShellScript}
361
371
  var actauthLoadedFor = null;
362
372
  // Same idea again, for the Environment tab's own GET .../env.
363
373
  var envLoadedFor = null;
364
- // Same idea again, for the Abilities tab's own GET .../abilities.
374
+ // Same idea again, for the Apps tab's own GET .../abilities.
365
375
  var abilitiesLoadedFor = null;
366
376
  // The full /agents/:name/config response the currently-open agent was
367
377
  // last rendered from — kept around so refreshActauthDependentPanels/
@@ -637,8 +647,8 @@ ${consoleShellScript}
637
647
  return '<div class="le-cards" style="margin-bottom:28px">' +
638
648
  card(null, 'Model', escapeHtml(modelValue), escapeHtml(modelDetail)) +
639
649
  (cfg.abilityCount != null
640
- ? card('abilities', 'Abilities', String(cfg.abilityCount), cfg.abilityCount ? 'Installed for this agent' : 'Add tools, skills and rules in one install')
641
- : card('abilities', 'Abilities', 'Browse', 'Add tools, skills and rules in one install')) +
650
+ ? card('abilities', 'Apps', String(cfg.abilityCount), cfg.abilityCount ? 'Apps and abilities added to this agent' : 'Add apps and abilities, with the tools you choose')
651
+ : card('abilities', 'Apps', 'Browse', 'Add apps and abilities, with the tools you choose')) +
642
652
  card('tools', 'Tools', String(cfg.tools.length), escapeHtml(toolNames.slice(0, 3).join(', ') + (toolNames.length > 3 ? ', \u2026' : '')) || 'None yet') +
643
653
  card('skills', 'Skills', String(cfg.skills.length), escapeHtml(cfg.skills.map(function (k) { return k.name; }).join(', ')) || 'None yet') +
644
654
  card('actauth', 'Permissions', rules.length + (rules.length === 1 ? ' rule' : ' rules'),
@@ -2308,7 +2318,7 @@ ${consoleShellScript}
2308
2318
  });
2309
2319
  }
2310
2320
 
2311
- // ---- Abilities tab: what's already installed for this agent, a
2321
+ // ---- Apps tab: what's already installed for this agent, a
2312
2322
  // search box over public (npm, tagged loopengine-ability) packages,
2313
2323
  // and a plain spec field for a private ability (a git URL, a local
2314
2324
  // file:../path, or a private registry name) — both installs go
@@ -2349,7 +2359,7 @@ ${consoleShellScript}
2349
2359
  '<td><code>' + escapeHtml(a.name) + '</code></td>' +
2350
2360
  '<td>' + escapeHtml(a.version) + '</td>' +
2351
2361
  '<td>' + leShell.abilityOriginBadge(a.origin) + '</td>' +
2352
- '<td class="hint">' + escapeHtml((a.tools || []).join(', ')) + '</td>' +
2362
+ '<td class="hint">' + escapeHtml((a.tools || []).concat(a.mcp ? a.mcp.tools.map(function (t) { return t.name; }) : []).join(', ')) + '</td>' +
2353
2363
  '<td style="white-space:nowrap">' + detailsHtml + ' ' + actionHtml + '</td>' +
2354
2364
  '</tr>';
2355
2365
  }
@@ -2374,11 +2384,15 @@ ${consoleShellScript}
2374
2384
  var installedHtml = installed.length
2375
2385
  ? '<table><thead><tr><th>Name</th><th>Version</th><th>Source</th><th>Tools</th><th></th></tr></thead><tbody>' + installed.map(renderInstalledAbilityRow).join('') + '</tbody></table>' +
2376
2386
  '<p id="abilityUpgradeStatus" class="hint"></p>'
2377
- : '<p class="hint">No abilities installed for this agent yet.</p>';
2387
+ : '<p class="hint">Nothing added to this agent yet.</p>';
2378
2388
 
2379
- return '<h3>Installed</h3>' + installedHtml +
2389
+ // What this agent has first; then the workspace's connected apps (each
2390
+ // with the tools this agent gets), then public and private abilities.
2391
+ return '<h3 style="justify-content:flex-start;align-items:baseline;gap:8px">Installed <span class="hint" style="font-weight:400">the apps and abilities this agent has</span></h3>' + installedHtml +
2380
2392
  // Installing by name or spec is on the console's Abilities page.
2381
2393
  '<p id="abilityInstallStatus" class="hint"></p>' +
2394
+ '<h3 style="margin-top:24px;justify-content:flex-start;align-items:baseline;gap:8px">Apps <span class="hint" style="font-weight:400">connected in this workspace; choose which of their tools this agent gets, and each one' + String.fromCharCode(8217) + 's permission</span></h3>' +
2395
+ '<div id="agentApps" style="margin-bottom:32px"><p class="hint">Loading&hellip;</p></div>' +
2382
2396
  '<h3 style="margin-top:24px;justify-content:flex-start;align-items:baseline;gap:8px">Public abilities <span class="hint" style="font-weight:400">npm packages tagged loopengine-ability</span></h3>' +
2383
2397
  '<form id="abilitySearchForm" style="display:flex;gap:6px;margin:0 0 10px;max-width:560px">' +
2384
2398
  '<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search public abilities" style="flex:1">' +
@@ -2456,7 +2470,8 @@ ${consoleShellScript}
2456
2470
  .then(function (result) {
2457
2471
  if (!result.ok) throw new Error(result.body.error || 'request failed');
2458
2472
  var files = result.body.files || [];
2459
- var updated = files.filter(function (f) { return f.status === 'updated'; });
2473
+ // added/removed: new rules, and tool files a version dropped (its tools now come from the app's MCP server).
2474
+ var updated = files.filter(function (f) { return f.status === 'updated' || f.status === 'added' || f.status === 'removed'; });
2460
2475
  var conflicts = files.filter(function (f) { return f.status === 'conflict'; });
2461
2476
  var message;
2462
2477
  if (!files.length || (!updated.length && !conflicts.length)) {
@@ -2565,7 +2580,8 @@ ${consoleShellScript}
2565
2580
  .then(function (r) { return r.json(); })
2566
2581
  .then(function (d) {
2567
2582
  if (currentName !== name) return;
2568
- var abilities = d.abilities || [];
2583
+ // Connected apps are listed under Apps, above.
2584
+ var abilities = (d.abilities || []).filter(function (a) { return !a.app; });
2569
2585
  if (!abilities.length) {
2570
2586
  el.innerHTML = '<p><a href="/config?section=abilities#private-abilities">Install private abilities</a></p>';
2571
2587
  return;
@@ -2665,6 +2681,165 @@ ${consoleShellScript}
2665
2681
  runAbilitySearch(name, '', content, resultsEl, installedNames);
2666
2682
  }
2667
2683
  loadPrivateAbilitiesFor(name, content, installedNames);
2684
+ loadAgentApps(name, content);
2685
+ }
2686
+
2687
+ // ---- Apps: a connected app's tools for this agent ----
2688
+ // The workspace's connected apps (the console's Apps page). Adding one,
2689
+ // or Choose tools on one already added, opens a picker: each tool with a
2690
+ // checkbox and this agent's permission for it; Save installs the app
2691
+ // with just those tools, or changes the agent's choice.
2692
+
2693
+ var agentApps = { name: null, connected: [], pickers: {} };
2694
+ var DECISION_NAMES = { allow: 'Allow', ask: 'Ask me', deny: 'Never' };
2695
+
2696
+ function loadAgentApps(name, content) {
2697
+ var el = content.querySelector('#agentApps');
2698
+ if (!el) return;
2699
+ if (agentApps.name !== name) agentApps = { name: name, connected: [], pickers: {} };
2700
+ fetch('/config/apps', { cache: 'no-store' })
2701
+ .then(function (r) { return r.json(); })
2702
+ .then(function (d) {
2703
+ if (currentName !== name) return;
2704
+ agentApps.connected = d.connected || [];
2705
+ agentApps.canChange = Boolean(d.canConnect);
2706
+ renderAgentApps();
2707
+ })
2708
+ .catch(function (err) { el.innerHTML = '<p class="error">Could not list apps: ' + escapeHtml(err.message) + '</p>'; });
2709
+ if (!content.dataset.appsWired) {
2710
+ content.dataset.appsWired = '1';
2711
+ content.addEventListener('click', onAgentAppsClick);
2712
+ content.addEventListener('change', onAgentAppsChange);
2713
+ content.addEventListener('input', onAgentAppsInput);
2714
+ }
2715
+ }
2716
+
2717
+ function renderAgentApps() {
2718
+ var content = abilitiesContentEl();
2719
+ var el = content && content.querySelector('#agentApps');
2720
+ if (!el) return;
2721
+ var name = agentApps.name;
2722
+ if (!agentApps.connected.length) {
2723
+ el.innerHTML = '<p class="hint">No apps connected yet. <a href="/config?section=apps">Connect one on the Apps page</a>, then add it here.</p>';
2724
+ return;
2725
+ }
2726
+ el.innerHTML = '<table><thead><tr><th>App</th><th>For this agent</th><th></th></tr></thead><tbody>' + agentApps.connected.map(function (c) {
2727
+ var added = c.agents.indexOf(name) !== -1;
2728
+ var open = agentApps.pickers[c.ability];
2729
+ return '<tr><td><strong>' + escapeHtml(c.title) + '</strong> <span class="hint">' + escapeHtml(c.ability) + '</span></td>' +
2730
+ '<td class="hint">' + (added ? 'Added' : 'Not added') + '</td>' +
2731
+ '<td style="white-space:nowrap"><button type="button" class="app-pick-open" data-ability="' + escapeHtml(c.ability) + '">' + (open ? 'Close' : added ? 'Choose tools' : 'Add') + '</button></td></tr>' +
2732
+ (open ? '<tr><td colspan="3">' + pickerHtml(c.ability) + '</td></tr>' : '');
2733
+ }).join('') + '</tbody></table>' +
2734
+ '<p class="hint" style="margin:6px 0 0"><a href="/config?section=apps">Connect more apps</a></p>';
2735
+ }
2736
+
2737
+ function pickerHtml(ability) {
2738
+ var p = agentApps.pickers[ability];
2739
+ if (p.error) return '<div class="app-pick"><p class="error" style="margin:0">' + escapeHtml(p.error) + '</p></div>';
2740
+ if (!p.tools) return '<div class="app-pick"><p class="hint" style="margin:0">Asking the app for its tools' + String.fromCharCode(8230) + '</p></div>';
2741
+ var chosen = p.tools.filter(function (t) { return t.selected; }).length;
2742
+ var f = (p.filter || '').toLowerCase();
2743
+ var shown = p.tools.filter(function (t) { return !f || (t.remote + ' ' + t.description).toLowerCase().indexOf(f) !== -1; });
2744
+ var disabled = agentApps.canChange && !p.busy ? '' : ' disabled';
2745
+ return '<div class="app-pick">' +
2746
+ '<p class="hint" style="margin:0">Choose the tools this agent can use, and what each may do on its own: Allow runs it, Ask me waits for your OK, Never blocks it. Each starts at the app' + String.fromCharCode(8217) + 's default.</p>' +
2747
+ '<div class="app-pick-bar">' +
2748
+ (p.tools.length > 12 ? '<input type="search" class="app-pick-filter" data-ability="' + escapeHtml(ability) + '" placeholder="Filter tools" value="' + escapeHtml(p.filter || '') + '" aria-label="Filter tools">' : '') +
2749
+ '<button type="button" class="app-pick-readonly" data-ability="' + escapeHtml(ability) + '"' + disabled + '>Select read-only tools</button>' +
2750
+ '<button type="button" class="app-pick-none" data-ability="' + escapeHtml(ability) + '"' + disabled + '>Clear</button>' +
2751
+ '<span class="hint">' + chosen + ' of ' + p.tools.length + ' selected' + (chosen > 20 ? '. That' + String.fromCharCode(8217) + 's a lot: more tools make an agent slower to choose and likelier to pick the wrong one.' : '') + '</span>' +
2752
+ '</div>' +
2753
+ '<div class="app-pick-list">' + shown.map(function (t) {
2754
+ return '<label class="app-pick-tool"><input type="checkbox" class="app-pick-check" data-ability="' + escapeHtml(ability) + '" data-tool="' + escapeHtml(t.remote) + '"' + (t.selected ? ' checked' : '') + disabled + '>' +
2755
+ '<span><code>' + escapeHtml(t.name) + '</code> ' +
2756
+ '<select class="app-pick-decision" data-ability="' + escapeHtml(ability) + '" data-tool="' + escapeHtml(t.remote) + '" aria-label="Permission for ' + escapeHtml(t.name) + '"' + disabled + '>' +
2757
+ ['allow', 'ask', 'deny'].map(function (d) { return '<option value="' + d + '"' + (d === t.decision ? ' selected' : '') + '>' + DECISION_NAMES[d] + '</option>'; }).join('') + '</select>' +
2758
+ (t.destructive ? ' <span class="hint">destructive</span>' : t.readOnly ? ' <span class="hint">read-only</span>' : '') +
2759
+ (t.description ? '<span class="hint app-pick-desc">' + escapeHtml(t.description) + '</span>' : '') +
2760
+ '</span></label>';
2761
+ }).join('') + '</div>' +
2762
+ '<div class="app-pick-bar"><button type="button" class="app-pick-save" data-ability="' + escapeHtml(ability) + '"' + (chosen && !p.busy && agentApps.canChange ? '' : ' disabled') + '>' + (p.installed ? 'Save' : 'Add with ' + chosen + ' tool' + (chosen === 1 ? '' : 's')) + '</button>' +
2763
+ (agentApps.canChange ? '' : '<span class="error">Changing an agent' + String.fromCharCode(8217) + 's tools needs LOOPENGINE_ADMIN_AUTH set.</span>') +
2764
+ (p.message ? '<span class="' + (p.messageBad ? 'error' : 'hint') + '">' + escapeHtml(p.message) + '</span>' : '') + '</div>' +
2765
+ '</div>';
2766
+ }
2767
+
2768
+ function pickerTool(ability, remote) {
2769
+ var tools = agentApps.pickers[ability].tools;
2770
+ for (var i = 0; i < tools.length; i++) if (tools[i].remote === remote) return tools[i];
2771
+ return null;
2772
+ }
2773
+
2774
+ function openPicker(ability) {
2775
+ var name = agentApps.name;
2776
+ agentApps.pickers[ability] = { tools: null, filter: '' };
2777
+ renderAgentApps();
2778
+ fetch('/agents/' + encodeURIComponent(name) + '/apps/' + encodeURIComponent(ability), { cache: 'no-store' })
2779
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
2780
+ .then(function (r) {
2781
+ var p = agentApps.pickers[ability];
2782
+ if (!p || agentApps.name !== name) return;
2783
+ if (!r.ok) p.error = r.body.error || 'Could not list its tools.';
2784
+ else { p.tools = r.body.tools; p.installed = r.body.installed; }
2785
+ renderAgentApps();
2786
+ })
2787
+ .catch(function (err) { var p = agentApps.pickers[ability]; if (p) { p.error = err.message; renderAgentApps(); } });
2788
+ }
2789
+
2790
+ function savePicker(ability) {
2791
+ var name = agentApps.name;
2792
+ var p = agentApps.pickers[ability];
2793
+ var tools = p.tools.filter(function (t) { return t.selected; }).map(function (t) { return { remote: t.remote, decision: t.decision }; });
2794
+ p.busy = true;
2795
+ p.message = 'Saving' + String.fromCharCode(8230);
2796
+ p.messageBad = false;
2797
+ renderAgentApps();
2798
+ fetch('/agents/' + encodeURIComponent(name) + '/apps/' + encodeURIComponent(ability), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ tools: tools }) })
2799
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
2800
+ .then(function (r) {
2801
+ p.busy = false;
2802
+ if (!r.ok) { p.message = r.body.error || 'Could not save.'; p.messageBad = true; renderAgentApps(); return; }
2803
+ delete agentApps.pickers[ability];
2804
+ // The Installed table and the counts change too.
2805
+ loadAbilitiesTab(name);
2806
+ })
2807
+ .catch(function (err) { p.busy = false; p.message = err.message; p.messageBad = true; renderAgentApps(); });
2808
+ }
2809
+
2810
+ function onAgentAppsClick(ev) {
2811
+ var t = ev.target;
2812
+ if (!t.dataset || !t.dataset.ability) return;
2813
+ var ability = t.dataset.ability;
2814
+ if (t.classList.contains('app-pick-open')) {
2815
+ if (agentApps.pickers[ability]) { delete agentApps.pickers[ability]; renderAgentApps(); }
2816
+ else openPicker(ability);
2817
+ } else if (t.classList.contains('app-pick-readonly') || t.classList.contains('app-pick-none')) {
2818
+ var readOnly = t.classList.contains('app-pick-readonly');
2819
+ agentApps.pickers[ability].tools.forEach(function (tool) { tool.selected = readOnly ? tool.default === 'allow' : false; });
2820
+ renderAgentApps();
2821
+ } else if (t.classList.contains('app-pick-save')) {
2822
+ savePicker(ability);
2823
+ }
2824
+ }
2825
+
2826
+ function onAgentAppsChange(ev) {
2827
+ var t = ev.target;
2828
+ if (!t.dataset || !t.dataset.tool || !agentApps.pickers[t.dataset.ability]) return;
2829
+ var tool = pickerTool(t.dataset.ability, t.dataset.tool);
2830
+ if (!tool) return;
2831
+ if (t.classList.contains('app-pick-check')) tool.selected = t.checked;
2832
+ if (t.classList.contains('app-pick-decision')) { tool.decision = t.value; tool.selected = true; }
2833
+ renderAgentApps();
2834
+ }
2835
+
2836
+ function onAgentAppsInput(ev) {
2837
+ var t = ev.target;
2838
+ if (!t.classList.contains('app-pick-filter')) return;
2839
+ agentApps.pickers[t.dataset.ability].filter = t.value;
2840
+ renderAgentApps();
2841
+ var again = abilitiesContentEl().querySelector('.app-pick-filter[data-ability="' + t.dataset.ability + '"]');
2842
+ if (again) { again.focus(); again.setSelectionRange(again.value.length, again.value.length); }
2668
2843
  }
2669
2844
 
2670
2845
  function applyAbilitiesConfig(name, data) {
@@ -3470,7 +3645,8 @@ ${consoleShellScript}
3470
3645
  var CONFIG_TABS = ['overview', 'abilities', 'actauth', 'messaging', 'schedules', 'env', 'advanced'];
3471
3646
  // The old Tools and Skills tabs: now parts of Advanced. A link to one
3472
3647
  // opens Advanced at that part.
3473
- var TAB_ALIASES = { tools: 'advanced', skills: 'advanced' };
3648
+ // The Apps tab keeps the id 'abilities'; ?tab=apps opens it too.
3649
+ var TAB_ALIASES = { tools: 'advanced', skills: 'advanced', apps: 'abilities' };
3474
3650
 
3475
3651
  function resolveTab(tab) {
3476
3652
  tab = TAB_ALIASES[tab] || tab;
@@ -3607,7 +3783,7 @@ ${consoleShellScript}
3607
3783
  // own skills. Each part keeps its old data-tab-panel name, which the
3608
3784
  // code that redraws it looks for; only .tab-panel is shown or hidden.
3609
3785
  '<div class="tab-panel" data-tab-panel="advanced">' +
3610
- '<p class="muted" style="margin:0 0 20px">For building your own: tools and skills that don&#39;t come from an ability, Composio tools, subagents and permission rules in full. What each ability adds is in its Details on the Abilities tab.</p>' +
3786
+ '<p class="muted" style="margin:0 0 20px">For building your own: tools and skills that don&#39;t come from an ability, Composio tools, subagents and permission rules in full. What each ability adds is in its Details on the Apps tab.</p>' +
3611
3787
  '<h2 class="advanced-part" id="advanced-tools">Tools</h2>' +
3612
3788
  '<div data-tab-panel="tools">' + renderToolsTabHtml(cfg) + '</div>' +
3613
3789
  '<h2 class="advanced-part" id="advanced-skills">Skills</h2>' +