From 60716e1dec20f8e1df37de6b2e8618898f7ed71e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:49:34 -0700 Subject: [PATCH 01/19] Add per-agent skills management and spawn load state Co-authored-by: kite Agent-Model: codex/gpt-6-sol/medium --- backend/__tests__/service/skills.test.js | 24 +++ backend/models/AgentRegistry.ts | 5 + backend/routes/agentsRuntime.ts | 27 +++ backend/routes/skills.ts | 41 ++++- cli/src/commands/agent.js | 16 +- frontend/src/i18n/locales/en.json | 24 +++ frontend/src/i18n/locales/zh-CN.json | 24 +++ .../src/v2/__tests__/V2AgentSkills.test.tsx | 44 +++++ frontend/src/v2/components/V2AgentSkills.tsx | 163 ++++++++++++++++++ .../src/v2/components/v2-agent-skills.css | 22 +++ 10 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 frontend/src/v2/__tests__/V2AgentSkills.test.tsx create mode 100644 frontend/src/v2/components/V2AgentSkills.tsx create mode 100644 frontend/src/v2/components/v2-agent-skills.css diff --git a/backend/__tests__/service/skills.test.js b/backend/__tests__/service/skills.test.js index 26aa31e5cc..2f24f70f0d 100644 --- a/backend/__tests__/service/skills.test.js +++ b/backend/__tests__/service/skills.test.js @@ -4,6 +4,7 @@ const jwt = require('jsonwebtoken'); const Pod = require('../../models/Pod'); const PodAsset = require('../../models/PodAsset'); const User = require('../../models/User'); +const { AgentInstallation } = require('../../models/AgentRegistry'); const skillsRoutes = require('../../routes/skills'); const { setupMongoDb, closeMongoDb } = require('../utils/testUtils'); @@ -81,4 +82,27 @@ describe('Skills Catalog Routes', () => { expect(asset).toBeTruthy(); expect(asset.metadata.license).toBe('MIT'); }); + + test('only the installer or instance admin can add and remove agent skills', async () => { + const member = await User.create({ username: 'skillmember', email: 'skillmember@example.com', password: 'password123' }); + await Pod.updateOne({ _id: podId }, { $push: { members: member._id } }); + const memberToken = jwt.sign({ id: member._id.toString() }, JWT_SECRET); + await AgentInstallation.create({ agentName: 'scout', instanceId: 'default', podId, version: '1.0.0', installedBy: userId }); + const body = { podId, name: 'Review', content: '# Review', scope: 'agent', agentName: 'scout', instanceId: 'default' }; + + const deniedAdd = await request(app).post('/api/skills/import').set('Authorization', `Bearer ${memberToken}`).send(body); + expect(deniedAdd.status).toBe(403); + expect(await PodAsset.countDocuments({ podId, type: 'skill', 'metadata.skillName': 'Review' })).toBe(0); + + const added = await request(app).post('/api/skills/import').set('Authorization', `Bearer ${authToken}`).send(body); + expect(added.status).toBe(201); + const url = `/api/skills/pods/${podId}/imported?scope=agent&agentName=scout&instanceId=default&name=Review`; + const deniedRemove = await request(app).delete(url).set('Authorization', `Bearer ${memberToken}`); + expect(deniedRemove.status).toBe(403); + expect(await PodAsset.countDocuments({ podId, status: 'active', 'metadata.skillName': 'Review' })).toBe(1); + + const removed = await request(app).delete(url).set('Authorization', `Bearer ${authToken}`); + expect(removed.status).toBe(200); + expect(await PodAsset.countDocuments({ podId, status: 'active', 'metadata.skillName': 'Review' })).toBe(0); + }); }); diff --git a/backend/models/AgentRegistry.ts b/backend/models/AgentRegistry.ts index b3829a4d08..77f411ed7b 100644 --- a/backend/models/AgentRegistry.ts +++ b/backend/models/AgentRegistry.ts @@ -211,6 +211,7 @@ export interface IAgentInstallationRegistry extends Document { errorMessage?: string; staleSince?: Date; installedBy: Types.ObjectId; + skillLoad?: { at: Date; mounted: string[]; conflicted: string[] }; usage: { lastUsedAt?: Date; totalCalls: number; @@ -248,6 +249,10 @@ const AgentInstallationSchema = new Schema( errorMessage: String, staleSince: { type: Date }, installedBy: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + skillLoad: { + type: new Schema({ at: Date, mounted: [String], conflicted: [String] }, { _id: false }), + default: undefined, + }, usage: { lastUsedAt: Date, totalCalls: { type: Number, default: 0 }, diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index 47943f758e..d13c6746f4 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -1593,6 +1593,33 @@ router.post( }, ); +// The wrapper reports what its adapter actually mounted for this spawn. Keep +// only skill names: local source paths belong to the operator's machine. +router.post('/pods/:podId/skill-load', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { + try { + const { podId } = req.params; + const installation = resolveInstallationForPod(req.agentInstallations, req.agentInstallation, podId); + if (!installation || !ensurePodMatch(req.agentInstallations || installation, podId, req.agentAuthorizedPodIds)) { + return res.status(403).json({ message: 'Agent token not authorized for this pod' }); + } + const validNames = (value: unknown): value is string[] => Array.isArray(value) + && value.length <= 50 + && value.every((name) => typeof name === 'string' && name.length > 0 && name.length <= 120 && !/[\\/]/.test(name)); + const { mounted, conflicted } = req.body || {}; + if (!validNames(mounted) || !validNames(conflicted)) { + return res.status(400).json({ message: 'mounted and conflicted must be arrays of skill names' }); + } + await AgentInstallation.updateOne( + { _id: installation._id, status: 'active' }, + { $set: { skillLoad: { at: new Date(), mounted, conflicted } } }, + ); + return res.json({ success: true }); + } catch (error) { + console.error('Error recording skill load:', error); + return res.status(500).json({ message: 'Failed to record skill load' }); + } +}); + router.get('/pods/:podId/context', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const { podId } = req.params; diff --git a/backend/routes/skills.ts b/backend/routes/skills.ts index 35ad4e4a05..dd187ea1a5 100644 --- a/backend/routes/skills.ts +++ b/backend/routes/skills.ts @@ -15,6 +15,8 @@ const Gateway = require('../models/Gateway'); // eslint-disable-next-line global-require const { AgentInstallation } = require('../models/AgentRegistry'); // eslint-disable-next-line global-require +const User = require('../models/User'); +// eslint-disable-next-line global-require const PodAssetService = require('../services/podAssetService'); // eslint-disable-next-line global-require const SkillsCatalogService = require('../services/skillsCatalogService'); @@ -111,6 +113,25 @@ const ensurePodAccess = async (podId: string, userId: unknown) => { return pod; }; +const ensureAgentSkillEditor = async (podId: string, agentName: string, instanceId: string, userId: unknown) => { + const installation = await AgentInstallation.findOne({ + podId, agentName: agentName.toLowerCase(), instanceId, status: 'active', + }).select('installedBy').lean() as { installedBy?: { toString: () => string } } | null; + if (!installation) { const e = new Error('Agent not installed in this pod') as PodError; e.code = 'AGENT_NOT_FOUND'; throw e; } + if (installation.installedBy?.toString() === userId?.toString()) return; + const user = await User.findById(userId).select('role').lean() as { role?: string } | null; + if (user?.role === 'admin') return; + const e = new Error('Only the agent installer or an instance admin can change its skills') as PodError; + e.code = 'INSTALLER_ONLY'; + throw e; +}; + +const skillWriteStatus = (error: PodError): number => { + if (error.code === 'INSTALLER_ONLY' || error.code === 'POD_ACCESS_DENIED') return 403; + if (error.code === 'AGENT_NOT_FOUND' || error.code === 'POD_NOT_FOUND') return 404; + return 500; +}; + const ensureDefaultGateway = async (userId: unknown) => { const existing = await Gateway.findOne({ slug: 'default' }); if (existing) return existing; @@ -270,7 +291,16 @@ router.get('/pods/:podId/imported', auth, async (req: AuthReq, res: Res) => { description: (asset?.metadata as Record)?.description || null, license: (asset?.metadata as Record)?.license || null, })); - return res.json({ items }); + const installation = scope === 'agent' + ? await AgentInstallation.findOne({ podId, agentName: agentName.toLowerCase(), instanceId, status: 'active' }) + .select('installedBy skillLoad').lean() as { installedBy?: { toString: () => string }; skillLoad?: unknown } | null + : null; + if (scope === 'agent' && !installation) return res.status(404).json({ error: 'Agent not installed in this pod' }); + const user = scope === 'agent' ? await User.findById(userId).select('role').lean() as { role?: string } | null : null; + const canEdit = scope === 'agent' && Boolean(installation && ( + installation.installedBy?.toString() === userId?.toString() || user?.role === 'admin' + )); + return res.json({ items, canEdit, skillLoad: installation?.skillLoad || null }); } catch (error) { console.error('Error loading imported skills:', error); return res.status(500).json({ error: 'Failed to load imported skills' }); @@ -288,6 +318,7 @@ router.delete('/pods/:podId/imported', auth, async (req: AuthReq, res: Res) => { if (!name) return res.status(400).json({ error: 'name is required' }); await ensurePodAccess(podId || '', userId); if (scope === 'agent' && (!agentName || !instanceId)) return res.status(400).json({ error: 'agentName and instanceId are required for agent scope.' }); + if (scope === 'agent') await ensureAgentSkillEditor(podId || '', agentName, instanceId, userId); const skillKey = PodAssetService.buildScopedSkillKey({ name, scope, agentName: scope === 'agent' ? agentName : undefined, instanceId: scope === 'agent' ? instanceId : undefined }); const asset = await PodAsset.findOneAndUpdate({ podId, type: 'skill', status: 'active', 'metadata.skillKey': skillKey }, { $set: { status: 'archived' } }, { new: true }) as { _id: unknown } | null; if (!asset) return res.status(404).json({ error: 'Skill not found' }); @@ -295,7 +326,7 @@ router.delete('/pods/:podId/imported', auth, async (req: AuthReq, res: Res) => { return res.json({ success: true, assetId: asset._id, sync }); } catch (error) { console.error('Error uninstalling skill:', error); - return res.status(500).json({ error: 'Failed to uninstall skill' }); + return res.status(skillWriteStatus(error as PodError)).json({ error: (error as Error).message }); } }); @@ -305,6 +336,10 @@ router.post('/import', auth, async (req: AuthReq, res: Res) => { const { podId, name, content, tags = [], sourceUrl, license, scope = 'pod', agentName, instanceId, description } = (req.body || {}) as { podId?: string; name?: string; content?: string; tags?: string[]; sourceUrl?: string; license?: string; scope?: string; agentName?: string; instanceId?: string; description?: string }; if (!podId || !name) return res.status(400).json({ error: 'podId and name are required' }); await ensurePodAccess(podId, userId); + if (scope === 'agent') { + if (!agentName || !instanceId) return res.status(400).json({ error: 'agentName and instanceId are required for agent scope.' }); + await ensureAgentSkillEditor(podId, agentName, instanceId, userId); + } let skillContent = content; let resolvedSourceUrl = sourceUrl; let extraFiles: unknown[] = []; @@ -340,7 +375,7 @@ router.post('/import', auth, async (req: AuthReq, res: Res) => { return sourceUnavailableReply(res, error as { missing: boolean; message: string }, 'import'); } console.error('Error importing skill:', error); - return res.status(e.code === 'POD_ACCESS_DENIED' ? 403 : 500).json({ error: e.message || 'Failed to import skill' }); + return res.status(skillWriteStatus(e)).json({ error: e.message || 'Failed to import skill' }); } }); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 7b7683fac0..d1fbde1b44 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -12,7 +12,7 @@ */ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, readdirSync } from 'fs'; -import { join, dirname, resolve as pathResolve } from 'path'; +import { join, dirname, basename, resolve as pathResolve } from 'path'; import { homedir, tmpdir } from 'os'; import { fileURLToPath } from 'url'; import { createHash } from 'crypto'; @@ -1643,6 +1643,7 @@ export const performRun = ({ }; } let result; + let skillMount = null; let commitAttribution = null; try { // The local CLI is a wrapper for a Commonly seat, so its Git commits @@ -1667,6 +1668,12 @@ export const performRun = ({ commitAttributionEnv: commitAttribution?.sandboxEnv || null, memoryLongTerm, environment: seatEnvironment, + onSkillsLinked: ({ mounted, conflicted }) => { + skillMount = { + mounted: mounted.map((source) => basename(source)), + conflicted: conflicted.map(({ path }) => basename(path)), + }; + }, // Runtime context the Claude/Codex adapters expose only to their // per-spawn MCP environment. Claude keeps ${COMMONLY_*} placeholders // literal on disk and lets its native MCP parser expand them, so the @@ -1682,6 +1689,13 @@ export const performRun = ({ try { commitAttribution?.cleanup(); } finally { + if (skillMount) { + try { + await client.post(`/api/agents/runtime/pods/${eventPodId}/skill-load`, skillMount); + } catch (error) { + log(`skill load status could not be recorded: ${error.message}`); + } + } // Best-effort by construction: `close` logs and returns on a failed // revoke, so a turn that already produced an answer is not failed by // cleanup. The boot sweep collects anything left behind. diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d18413b066..838f91e161 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -28,6 +28,30 @@ "text": "Checking your access…" } }, + "agentSkills": { + "title": "Skills", + "pod": "Pod", + "addSkill": "Add skill", + "closeCatalog": "Close catalog", + "loading": "Loading skills…", + "empty": "No skills installed for this agent.", + "noReport": "No spawn report yet", + "loaded": "Loaded at last spawn", + "conflicted": "Could not load at last spawn", + "notLoaded": "Not loaded at last spawn", + "reported": "Last spawn report · {{date}}", + "find": "Find a skill", + "search": "Search catalog", + "add": "Add", + "addNamed": "Add {{name}}", + "remove": "Remove", + "removeNamed": "Remove {{name}}", + "noMatches": "No matching skills.", + "loadError": "Couldn’t load skills.", + "catalogError": "Couldn’t load the skill catalog.", + "addError": "Couldn’t add this skill.", + "removeError": "Couldn’t remove this skill." + }, "settings": { "username": { "save": "Save username", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 4be56997d3..0d839dc627 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -28,6 +28,30 @@ "text": "正在检查你的访问权限…" } }, + "agentSkills": { + "title": "技能", + "pod": "Pod", + "addSkill": "添加技能", + "closeCatalog": "关闭目录", + "loading": "正在加载技能…", + "empty": "此代理尚未安装技能。", + "noReport": "暂无运行加载报告", + "loaded": "上次运行已加载", + "conflicted": "上次运行未能加载", + "notLoaded": "上次运行未加载", + "reported": "上次运行报告 · {{date}}", + "find": "查找技能", + "search": "搜索目录", + "add": "添加", + "addNamed": "添加 {{name}}", + "remove": "移除", + "removeNamed": "移除 {{name}}", + "noMatches": "没有匹配的技能。", + "loadError": "无法加载技能。", + "catalogError": "无法加载技能目录。", + "addError": "无法添加此技能。", + "removeError": "无法移除此技能。" + }, "settings": { "username": { "save": "保存用户名", diff --git a/frontend/src/v2/__tests__/V2AgentSkills.test.tsx b/frontend/src/v2/__tests__/V2AgentSkills.test.tsx new file mode 100644 index 0000000000..ea48674baa --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentSkills.test.tsx @@ -0,0 +1,44 @@ +// @ts-nocheck +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import axios from '../../utils/axiosConfig'; +import V2AgentSkills from '../components/V2AgentSkills'; + +jest.mock('../../utils/axiosConfig', () => ({ + __esModule: true, + default: { get: jest.fn(), post: jest.fn(), delete: jest.fn() }, +})); + +const installed = { + items: [{ name: 'review', description: 'Checks code' }, { name: 'draft', description: 'Writes drafts' }], + canEdit: true, + skillLoad: { at: '2026-10-23T16:00:00Z', mounted: ['review'], conflicted: ['draft'] }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/catalog') + ? { items: [{ name: 'plan', description: 'Plans work', sourceUrl: 'https://github.com/example/plan' }] } + : installed })); +}); + +test('shows the last reported spawn state and installer controls', async () => { + render(); + expect(await screen.findByText('Loaded at last spawn')).toBeInTheDocument(); + expect(screen.getByText('Could not load at last spawn')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add skill' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Add skill' })); + expect(await screen.findByRole('button', { name: 'Add plan' })).toBeInTheDocument(); + axios.post.mockResolvedValue({ data: {} }); + fireEvent.click(screen.getByRole('button', { name: 'Add plan' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/skills/import', expect.objectContaining({ + podId: 'pod', agentName: 'scout', instanceId: 'default', scope: 'agent', name: 'plan', + }))); +}); + +test('shows installed skills read-only to other pod members', async () => { + axios.get.mockResolvedValue({ data: { ...installed, canEdit: false, skillLoad: null } }); + render(); + expect(await screen.findAllByText('No spawn report yet')).toHaveLength(2); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); +}); diff --git a/frontend/src/v2/components/V2AgentSkills.tsx b/frontend/src/v2/components/V2AgentSkills.tsx new file mode 100644 index 0000000000..6940679cd0 --- /dev/null +++ b/frontend/src/v2/components/V2AgentSkills.tsx @@ -0,0 +1,163 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import axios from '../../utils/axiosConfig'; +import './v2-agent-skills.css'; + +interface SkillItem { name: string; description?: string | null; sourceUrl?: string | null } +interface SkillLoad { at: string; mounted: string[]; conflicted: string[] } +interface SkillsResponse { items: SkillItem[]; canEdit: boolean; skillLoad: SkillLoad | null } +interface CatalogItem extends SkillItem { content?: string; license?: string | { name?: string } } + +interface Props { pods: Array<{ id: string; name: string }>; agentName: string; instanceId: string } + +const requestError = (error: unknown, fallback: string): string => ( + (error as { response?: { data?: { error?: string } } })?.response?.data?.error || fallback +); + +const loadLabel = (skill: SkillItem, load: SkillLoad | null, t: (key: string) => string): string => { + if (!load) return t('agentSkills.noReport'); + const name = skill.name.toLowerCase(); + if (load.mounted.some((entry) => entry.toLowerCase() === name)) return t('agentSkills.loaded'); + if (load.conflicted.some((entry) => entry.toLowerCase() === name)) return t('agentSkills.conflicted'); + return t('agentSkills.notLoaded'); +}; + +const V2AgentSkills: React.FC = ({ pods, agentName, instanceId }) => { + const { t } = useTranslation(); + const [selectedPodId, setSelectedPodId] = useState(pods[0]?.id || ''); + const podId = pods.some((pod) => pod.id === selectedPodId) ? selectedPodId : pods[0]?.id || ''; + const [skills, setSkills] = useState([]); + const [skillLoad, setSkillLoad] = useState(null); + const [canEdit, setCanEdit] = useState(false); + const [catalog, setCatalog] = useState([]); + const [catalogOpen, setCatalogOpen] = useState(false); + const [query, setQuery] = useState(''); + const [busy, setBusy] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + const response = await axios.get(`/api/skills/pods/${encodeURIComponent(podId)}/imported`, { + params: { scope: 'agent', agentName, instanceId }, + }); + setSkills(response.data.items || []); + setSkillLoad(response.data.skillLoad || null); + setCanEdit(Boolean(response.data.canEdit)); + }, [podId, agentName, instanceId]); + + useEffect(() => { + let active = true; + setLoading(true); + setCanEdit(false); + setSkillLoad(null); + setError(null); + axios.get(`/api/skills/pods/${encodeURIComponent(podId)}/imported`, { + params: { scope: 'agent', agentName, instanceId }, + }).then((response) => { + if (!active) return; + setSkills(response.data.items || []); + setSkillLoad(response.data.skillLoad || null); + setCanEdit(Boolean(response.data.canEdit)); + }).catch((requestFailure) => { + if (active) setError(requestError(requestFailure, t('agentSkills.loadError'))); + }).finally(() => { if (active) setLoading(false); }); + return () => { active = false; }; + }, [podId, agentName, instanceId, t]); + + const openCatalog = async () => { + setCatalogOpen(true); + setError(null); + try { + const response = await axios.get<{ items: CatalogItem[] }>('/api/skills/catalog', { + params: { source: 'awesome', limit: 200 }, + }); + setCatalog(response.data.items || []); + } catch (requestFailure) { + setError(requestError(requestFailure, t('agentSkills.catalogError'))); + } + }; + + const add = async (item: CatalogItem) => { + if (!canEdit || busy) return; + setBusy(item.name); + setError(null); + try { + await axios.post('/api/skills/import', { + podId, name: item.name, content: item.content || undefined, + sourceUrl: item.sourceUrl || undefined, + description: item.description || undefined, + license: typeof item.license === 'string' ? item.license : item.license?.name, + scope: 'agent', agentName, instanceId, + }); + await refresh(); + setCatalogOpen(false); + } catch (requestFailure) { + setError(requestError(requestFailure, t('agentSkills.addError'))); + } finally { + setBusy(null); + } + }; + + const remove = async (name: string) => { + if (!canEdit || busy) return; + setBusy(name); + setError(null); + try { + await axios.delete(`/api/skills/pods/${encodeURIComponent(podId)}/imported`, { + params: { name, scope: 'agent', agentName, instanceId }, + }); + await refresh(); + } catch (requestFailure) { + setError(requestError(requestFailure, t('agentSkills.removeError'))); + } finally { + setBusy(null); + } + }; + + const installed = new Set(skills.map((skill) => skill.name.toLowerCase())); + const available = catalog.filter((item) => !installed.has(item.name.toLowerCase()) + && `${item.name} ${item.description || ''}`.toLowerCase().includes(query.toLowerCase())); + + return ( +
+
+

{t('agentSkills.title')}

+ {canEdit && } +
+ {pods.length > 1 && } + {error &&

{error}

} + {loading ?

{t('agentSkills.loading')}

: ( + skills.length ?
+ {skills.map((skill) => ( +
+
+ {skill.name} + {skill.description && {skill.description}} + {loadLabel(skill, skillLoad, t)} +
+ {canEdit && } +
+ ))} +
:

{t('agentSkills.empty')}

+ )} + {skillLoad?.at &&

{t('agentSkills.reported', { date: new Date(skillLoad.at).toLocaleString() })}

} + {catalogOpen && canEdit &&
+ + setQuery(event.target.value)} placeholder={t('agentSkills.search')} /> +
+ {available.map((item) =>
+
{item.name}{item.description && {item.description}}
+ +
)} + {available.length === 0 &&

{t('agentSkills.noMatches')}

} +
+
} +
+ ); +}; + +export default V2AgentSkills; diff --git a/frontend/src/v2/components/v2-agent-skills.css b/frontend/src/v2/components/v2-agent-skills.css new file mode 100644 index 0000000000..eca014d1d4 --- /dev/null +++ b/frontend/src/v2/components/v2-agent-skills.css @@ -0,0 +1,22 @@ +.v2-agent-skills { color: var(--v2-text-primary, #101828); } +.v2-agent-skills__head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 12px; } +.v2-agent-skills__head h2 { margin: 0; font-size: 18px; line-height: 24px; } +.v2-agent-skills__list { border: 1px solid #d0d5dd; border-radius: 4px; overflow: hidden; } +.v2-agent-skills__row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 16px; } +.v2-agent-skills__row + .v2-agent-skills__row { border-top: 1px solid #e4e7ec; } +.v2-agent-skills__copy { display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.v2-agent-skills__copy strong { font-size: 14px; line-height: 20px; } +.v2-agent-skills__copy span { font-size: 13px; line-height: 18px; color: #475467; } +.v2-agent-skills__copy small, .v2-agent-skills__reported { font: 500 11px/16px 'IBM Plex Mono', monospace; color: #667085; } +.v2-agent-skills__empty { margin: 12px 0; color: #667085; font-size: 13px; } +.v2-agent-skills__pod-label { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: #475467; font-size: 13px; } +.v2-agent-skills__pod-label select { min-width: 160px; height: 36px; padding: 0 8px; border: 1px solid #d0d5dd; border-radius: 4px; background: #fff; } +.v2-agent-skills__reported { margin: 8px 0 0; } +.v2-agent-skills__error { margin: 8px 0; font-size: 13px; color: #101828; } +.v2-agent-skills__catalog { margin-top: 20px; } +.v2-agent-skills__catalog label { display: block; margin-bottom: 6px; color: #475467; font-size: 13px; font-weight: 500; } +.v2-agent-skills__catalog input { box-sizing: border-box; width: 100%; height: 40px; margin-bottom: 12px; padding: 0 12px; border: 1px solid #d0d5dd; border-radius: 4px; } +.v2-root .v2-agent-skills button { flex: 0 0 auto; padding: 7px 12px; border: 1px solid #d0d5dd; border-radius: 4px; background: #fff; color: #101828; font: 600 13px/18px 'IBM Plex Sans', sans-serif; cursor: pointer; } +.v2-root .v2-agent-skills button:hover:not(:disabled) { background: #f9fafb; } +.v2-root .v2-agent-skills button:disabled { opacity: .5; cursor: default; } +@media (max-width: 760px) { .v2-agent-skills__row { align-items: flex-start; } } From ae313044d824987afb3fa6741c2596c807991d9d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:52:21 -0700 Subject: [PATCH 02/19] Mount Skills in agent Settings and verify runtime report Co-authored-by: kite Agent-Model: codex/gpt-6-sol/medium --- backend/__tests__/service/skills.test.js | 35 +++++++++++++++++++ .../adapters.claude.environment.test.mjs | 3 ++ .../src/v2/agents/V2AgentSettingsPage.tsx | 4 +++ 3 files changed, 42 insertions(+) diff --git a/backend/__tests__/service/skills.test.js b/backend/__tests__/service/skills.test.js index 2f24f70f0d..827bfd5116 100644 --- a/backend/__tests__/service/skills.test.js +++ b/backend/__tests__/service/skills.test.js @@ -5,6 +5,9 @@ const Pod = require('../../models/Pod'); const PodAsset = require('../../models/PodAsset'); const User = require('../../models/User'); const { AgentInstallation } = require('../../models/AgentRegistry'); +const AgentIdentityService = require('../../services/agentIdentityService'); +const { issueRuntimeTokenForAgent } = require('../../routes/registry/tokens'); +const agentsRuntimeRoutes = require('../../routes/agentsRuntime'); const skillsRoutes = require('../../routes/skills'); const { setupMongoDb, closeMongoDb } = require('../utils/testUtils'); @@ -22,6 +25,7 @@ describe('Skills Catalog Routes', () => { app = express(); app.use(express.json()); app.use('/api/skills', skillsRoutes); + app.use('/api/agents/runtime', agentsRuntimeRoutes); const user = await User.create({ username: 'skilluser', @@ -94,6 +98,13 @@ describe('Skills Catalog Routes', () => { expect(deniedAdd.status).toBe(403); expect(await PodAsset.countDocuments({ podId, type: 'skill', 'metadata.skillName': 'Review' })).toBe(0); + const admin = await User.create({ username: 'skilladmin', email: 'skilladmin@example.com', password: 'password123', role: 'admin' }); + await Pod.updateOne({ _id: podId }, { $push: { members: admin._id } }); + const adminToken = jwt.sign({ id: admin._id.toString() }, JWT_SECRET); + const adminAdd = await request(app).post('/api/skills/import').set('Authorization', `Bearer ${adminToken}`) + .send({ ...body, name: 'Admin review' }); + expect(adminAdd.status).toBe(201); + const added = await request(app).post('/api/skills/import').set('Authorization', `Bearer ${authToken}`).send(body); expect(added.status).toBe(201); const url = `/api/skills/pods/${podId}/imported?scope=agent&agentName=scout&instanceId=default&name=Review`; @@ -105,4 +116,28 @@ describe('Skills Catalog Routes', () => { expect(removed.status).toBe(200); expect(await PodAsset.countDocuments({ podId, status: 'active', 'metadata.skillName': 'Review' })).toBe(0); }); + + test('records mounted names for the authenticated agent and returns them to the page', async () => { + const installation = await AgentInstallation.findOne({ podId, agentName: 'scout' }); + const agentUser = await AgentIdentityService.getOrCreateAgentUser('scout', { instanceId: 'default' }); + const { token } = await issueRuntimeTokenForAgent(agentUser, 'test skill mount', installation); + const path = `/api/agents/runtime/pods/${podId}/skill-load`; + const posted = await request(app).post(path).set('Authorization', `Bearer ${token}`) + .send({ mounted: ['review'], conflicted: ['draft'] }); + expect(posted.status).toBe(200); + const installAfter = await AgentInstallation.findById(installation._id).lean(); + expect(installAfter.skillLoad).toMatchObject({ mounted: ['review'], conflicted: ['draft'] }); + expect(installAfter.skillLoad.at).toBeInstanceOf(Date); + + const read = await request(app).get(`/api/skills/pods/${podId}/imported?scope=agent&agentName=scout&instanceId=default`) + .set('Authorization', `Bearer ${authToken}`); + expect(read.status).toBe(200); + expect(read.body.skillLoad).toMatchObject({ mounted: ['review'], conflicted: ['draft'] }); + expect(read.body.canEdit).toBe(true); + + const invalid = await request(app).post(path).set('Authorization', `Bearer ${token}`) + .send({ mounted: ['/private/skill'], conflicted: [] }); + expect(invalid.status).toBe(400); + expect((await AgentInstallation.findById(installation._id).lean()).skillLoad.mounted).toEqual(['review']); + }); }); diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 0081dc97db..de3820a61a 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -294,6 +294,7 @@ describe('claude adapter — ctx.environment', () => { test('mounts skills.claude entries into /.claude/skills/ as read-only copies', async () => { const { impl } = makeSpawnImpl(); + const onSkillsLinked = jest.fn(); const skillSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-skill-')); fs.writeFileSync(path.join(skillSrc, 'SKILL.md'), 'x', 'utf8'); @@ -301,6 +302,7 @@ describe('claude adapter — ctx.environment', () => { sessionId: null, cwd, environment: { skills: { claude: [skillSrc] } }, + onSkillsLinked, _spawnImpl: impl, }); @@ -310,6 +312,7 @@ describe('claude adapter — ctx.environment', () => { // guardrail was lost from cli 0.1.4 exactly this way). expect(fs.lstatSync(slot).isSymbolicLink()).toBe(false); expect(fs.readFileSync(path.join(slot, 'SKILL.md'), 'utf8')).toBe('x'); + expect(onSkillsLinked).toHaveBeenCalledWith({ mounted: [skillSrc], conflicted: [] }); fs.rmSync(skillSrc, { recursive: true, force: true }); }); diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index c00cd55044..efaf5eb90e 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -10,6 +10,7 @@ import { useAgentSettings } from './useAgentSettings'; import V2AgentIdentitySection from './V2AgentIdentitySection'; import V2AgentWakePods from './V2AgentWakePods'; import V2AgentPolicy from './V2AgentPolicy'; +import V2AgentSkills from '../components/V2AgentSkills'; import './v2-agent-settings.css'; interface RuntimeKey { @@ -257,6 +258,9 @@ const V2AgentSettingsPage: React.FC = () => { /> +
+ +
); }; From 9ec5ecb81a98d8cd78cb5df6d306063e143d4429 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:54:22 -0700 Subject: [PATCH 03/19] Search full skill catalog and scope load names to installed skills Co-authored-by: kite Agent-Model: codex/gpt-6-sol/medium --- backend/__tests__/service/skills.test.js | 12 ++++-- backend/routes/skills.ts | 14 ++++++- .../src/v2/__tests__/V2AgentSkills.test.tsx | 5 +++ frontend/src/v2/components/V2AgentSkills.tsx | 37 +++++++++++-------- 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/backend/__tests__/service/skills.test.js b/backend/__tests__/service/skills.test.js index 827bfd5116..187418d14d 100644 --- a/backend/__tests__/service/skills.test.js +++ b/backend/__tests__/service/skills.test.js @@ -118,26 +118,30 @@ describe('Skills Catalog Routes', () => { }); test('records mounted names for the authenticated agent and returns them to the page', async () => { + await request(app).post('/api/skills/import').set('Authorization', `Bearer ${authToken}`) + .send({ podId, name: 'review', content: '# Review', scope: 'agent', agentName: 'scout', instanceId: 'default' }); const installation = await AgentInstallation.findOne({ podId, agentName: 'scout' }); const agentUser = await AgentIdentityService.getOrCreateAgentUser('scout', { instanceId: 'default' }); const { token } = await issueRuntimeTokenForAgent(agentUser, 'test skill mount', installation); const path = `/api/agents/runtime/pods/${podId}/skill-load`; const posted = await request(app).post(path).set('Authorization', `Bearer ${token}`) - .send({ mounted: ['review'], conflicted: ['draft'] }); + .send({ mounted: ['review', 'private-operator-skill'], conflicted: ['draft'] }); expect(posted.status).toBe(200); const installAfter = await AgentInstallation.findById(installation._id).lean(); - expect(installAfter.skillLoad).toMatchObject({ mounted: ['review'], conflicted: ['draft'] }); + expect(installAfter.skillLoad).toMatchObject({ mounted: ['review', 'private-operator-skill'], conflicted: ['draft'] }); expect(installAfter.skillLoad.at).toBeInstanceOf(Date); const read = await request(app).get(`/api/skills/pods/${podId}/imported?scope=agent&agentName=scout&instanceId=default`) .set('Authorization', `Bearer ${authToken}`); expect(read.status).toBe(200); - expect(read.body.skillLoad).toMatchObject({ mounted: ['review'], conflicted: ['draft'] }); + expect(read.body.skillLoad).toMatchObject({ mounted: ['review'], conflicted: [] }); + expect(JSON.stringify(read.body)).not.toContain('private-operator-skill'); expect(read.body.canEdit).toBe(true); const invalid = await request(app).post(path).set('Authorization', `Bearer ${token}`) .send({ mounted: ['/private/skill'], conflicted: [] }); expect(invalid.status).toBe(400); - expect((await AgentInstallation.findById(installation._id).lean()).skillLoad.mounted).toEqual(['review']); + expect((await AgentInstallation.findById(installation._id).lean()).skillLoad.mounted) + .toEqual(['review', 'private-operator-skill']); }); }); diff --git a/backend/routes/skills.ts b/backend/routes/skills.ts index dd187ea1a5..a3e8eacc04 100644 --- a/backend/routes/skills.ts +++ b/backend/routes/skills.ts @@ -293,14 +293,24 @@ router.get('/pods/:podId/imported', auth, async (req: AuthReq, res: Res) => { })); const installation = scope === 'agent' ? await AgentInstallation.findOne({ podId, agentName: agentName.toLowerCase(), instanceId, status: 'active' }) - .select('installedBy skillLoad').lean() as { installedBy?: { toString: () => string }; skillLoad?: unknown } | null + .select('installedBy skillLoad').lean() as { + installedBy?: { toString: () => string }; + skillLoad?: { at?: Date; mounted?: string[]; conflicted?: string[] }; + } | null : null; if (scope === 'agent' && !installation) return res.status(404).json({ error: 'Agent not installed in this pod' }); const user = scope === 'agent' ? await User.findById(userId).select('role').lean() as { role?: string } | null : null; const canEdit = scope === 'agent' && Boolean(installation && ( installation.installedBy?.toString() === userId?.toString() || user?.role === 'admin' )); - return res.json({ items, canEdit, skillLoad: installation?.skillLoad || null }); + const installedNames = new Set(items.map((item) => String(item.name || '').toLowerCase())); + const load = installation?.skillLoad; + const skillLoad = load?.at ? { + at: load.at, + mounted: (load.mounted || []).filter((name) => installedNames.has(name.toLowerCase())), + conflicted: (load.conflicted || []).filter((name) => installedNames.has(name.toLowerCase())), + } : null; + return res.json({ items, canEdit, skillLoad }); } catch (error) { console.error('Error loading imported skills:', error); return res.status(500).json({ error: 'Failed to load imported skills' }); diff --git a/frontend/src/v2/__tests__/V2AgentSkills.test.tsx b/frontend/src/v2/__tests__/V2AgentSkills.test.tsx index ea48674baa..d3afb3d047 100644 --- a/frontend/src/v2/__tests__/V2AgentSkills.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSkills.test.tsx @@ -29,6 +29,11 @@ test('shows the last reported spawn state and installer controls', async () => { expect(screen.getByRole('button', { name: 'Add skill' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Add skill' })); expect(await screen.findByRole('button', { name: 'Add plan' })).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Find a skill'), { target: { value: 'plan' } }); + await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/skills/catalog', { + params: { source: 'awesome', limit: 60, q: 'plan' }, + })); + expect(await screen.findByRole('button', { name: 'Add plan' })).toBeInTheDocument(); axios.post.mockResolvedValue({ data: {} }); fireEvent.click(screen.getByRole('button', { name: 'Add plan' })); await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/skills/import', expect.objectContaining({ diff --git a/frontend/src/v2/components/V2AgentSkills.tsx b/frontend/src/v2/components/V2AgentSkills.tsx index 6940679cd0..30717e29b8 100644 --- a/frontend/src/v2/components/V2AgentSkills.tsx +++ b/frontend/src/v2/components/V2AgentSkills.tsx @@ -31,6 +31,7 @@ const V2AgentSkills: React.FC = ({ pods, agentName, instanceId }) => { const [canEdit, setCanEdit] = useState(false); const [catalog, setCatalog] = useState([]); const [catalogOpen, setCatalogOpen] = useState(false); + const [catalogLoading, setCatalogLoading] = useState(false); const [query, setQuery] = useState(''); const [busy, setBusy] = useState(null); const [loading, setLoading] = useState(true); @@ -64,18 +65,22 @@ const V2AgentSkills: React.FC = ({ pods, agentName, instanceId }) => { return () => { active = false; }; }, [podId, agentName, instanceId, t]); - const openCatalog = async () => { - setCatalogOpen(true); - setError(null); - try { - const response = await axios.get<{ items: CatalogItem[] }>('/api/skills/catalog', { - params: { source: 'awesome', limit: 200 }, - }); - setCatalog(response.data.items || []); - } catch (requestFailure) { - setError(requestError(requestFailure, t('agentSkills.catalogError'))); - } - }; + useEffect(() => { + if (!catalogOpen) return undefined; + let active = true; + setCatalog([]); + setCatalogLoading(true); + const timer = setTimeout(() => { + axios.get<{ items: CatalogItem[] }>('/api/skills/catalog', { + params: { source: 'awesome', limit: 60, q: query.trim() }, + }).then((response) => { + if (active) setCatalog(response.data.items || []); + }).catch((requestFailure) => { + if (active) setError(requestError(requestFailure, t('agentSkills.catalogError'))); + }).finally(() => { if (active) setCatalogLoading(false); }); + }, 150); + return () => { active = false; clearTimeout(timer); }; + }, [catalogOpen, query, t]); const add = async (item: CatalogItem) => { if (!canEdit || busy) return; @@ -115,14 +120,13 @@ const V2AgentSkills: React.FC = ({ pods, agentName, instanceId }) => { }; const installed = new Set(skills.map((skill) => skill.name.toLowerCase())); - const available = catalog.filter((item) => !installed.has(item.name.toLowerCase()) - && `${item.name} ${item.description || ''}`.toLowerCase().includes(query.toLowerCase())); + const available = catalog.filter((item) => !installed.has(item.name.toLowerCase())); return (

{t('agentSkills.title')}

- {canEdit && } + {canEdit && }
{pods.length > 1 && } {error &&

{error}

} {loading ?

{t('agentSkills.loading')}

: ( skills.length ?
diff --git a/frontend/src/v2/components/v2-agent-skills.css b/frontend/src/v2/components/v2-agent-skills.css index 8af6fc640b..c8038358e6 100644 --- a/frontend/src/v2/components/v2-agent-skills.css +++ b/frontend/src/v2/components/v2-agent-skills.css @@ -9,8 +9,6 @@ .v2-agent-skills__copy span { font-size: 13px; line-height: 18px; color: var(--v2-text-secondary); } .v2-agent-skills__copy small, .v2-agent-skills__reported { font: 500 11px/16px var(--v2-font-mono); color: var(--v2-text-muted); } .v2-agent-skills__empty { margin: 12px 0; color: var(--v2-text-muted); font-size: 13px; } -.v2-agent-skills__pod-label { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: var(--v2-text-secondary); font-size: 13px; } -.v2-agent-skills__pod-label select { min-width: 160px; height: 36px; padding: 0 8px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: var(--v2-surface); } .v2-agent-skills__reported { margin: 8px 0 0; } .v2-agent-skills__error { margin: 8px 0; font-size: 13px; color: var(--v2-text-primary); } .v2-agent-skills__catalog { margin-top: 20px; } From 7a4abe7a1d3102245ed3bdb467423261a816cc2e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:12:19 -0700 Subject: [PATCH 16/19] Show selected pod in agent Skills section Co-authored-by: kite Agent-Model: codex/gpt-6-sol/medium --- frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx | 2 ++ frontend/src/v2/__tests__/V2AgentSkills.test.tsx | 7 ++++--- frontend/src/v2/agents/V2AgentSettingsPage.tsx | 2 +- frontend/src/v2/components/V2AgentSkills.tsx | 5 +++-- frontend/src/v2/components/v2-agent-skills.css | 3 ++- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index d4178f31d5..9430ddd3a1 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -335,6 +335,8 @@ test('selects an installation the owner can manage without exposing a foreign po expect(axios.get).not.toHaveBeenCalledWith( '/api/skills/pods/foreign/imported', expect.anything(), ); + expect(screen.getByText('Pod · My pod')).toBeInTheDocument(); + expect(screen.queryByText('Pod · Other pod')).not.toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Key' }).compareDocumentPosition( screen.getByRole('heading', { name: 'Skills' }), ) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); diff --git a/frontend/src/v2/__tests__/V2AgentSkills.test.tsx b/frontend/src/v2/__tests__/V2AgentSkills.test.tsx index efe9d8724b..96cf09ada9 100644 --- a/frontend/src/v2/__tests__/V2AgentSkills.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSkills.test.tsx @@ -23,7 +23,8 @@ beforeEach(() => { }); test('shows the last reported spawn state and installer controls', async () => { - render(); + render(); + expect(screen.getByText('Pod · Planning')).toBeInTheDocument(); expect(await screen.findByText('Loaded at last spawn')).toBeInTheDocument(); expect(screen.getByText('Could not load at last spawn')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Add skill' })).toBeInTheDocument(); @@ -43,7 +44,7 @@ test('shows the last reported spawn state and installer controls', async () => { test('shows installed skills read-only to other pod members', async () => { axios.get.mockResolvedValue({ data: { ...installed, canEdit: false, skillLoad: null } }); - render(); + render(); expect(await screen.findAllByText('No spawn report yet')).toHaveLength(2); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); @@ -53,7 +54,7 @@ test('shows unavailable rather than not loaded when the last skill read failed', ...installed, skillLoad: { at: '2026-10-23T16:00:00Z', mounted: [], conflicted: [], status: 'unavailable' }, } }); - render(); + render(); expect(await screen.findAllByText('Skill status unavailable at last spawn')).toHaveLength(2); expect(screen.queryByText('Not loaded at last spawn')).not.toBeInTheDocument(); }); diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index 77609dbaab..6d801a7619 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -259,7 +259,7 @@ const V2AgentSettingsPage: React.FC = () => {
- + pod.id === podId)?.name || podId} agentName={agentName} instanceId={instanceId} />
); diff --git a/frontend/src/v2/components/V2AgentSkills.tsx b/frontend/src/v2/components/V2AgentSkills.tsx index 8c54d800ed..ff516c3a55 100644 --- a/frontend/src/v2/components/V2AgentSkills.tsx +++ b/frontend/src/v2/components/V2AgentSkills.tsx @@ -8,7 +8,7 @@ interface SkillLoad { at: string; mounted: string[]; conflicted: string[]; statu interface SkillsResponse { items: SkillItem[]; canEdit: boolean; skillLoad: SkillLoad | null } interface CatalogItem extends SkillItem { content?: string; license?: string | { name?: string } } -interface Props { podId: string; agentName: string; instanceId: string } +interface Props { podId: string; podName: string; agentName: string; instanceId: string } const requestError = (error: unknown, fallback: string): string => ( (error as { response?: { data?: { error?: string } } })?.response?.data?.error || fallback @@ -23,7 +23,7 @@ const loadLabel = (skill: SkillItem, load: SkillLoad | null, t: (key: string) => return t('agentSkills.notLoaded'); }; -const V2AgentSkills: React.FC = ({ podId, agentName, instanceId }) => { +const V2AgentSkills: React.FC = ({ podId, podName, agentName, instanceId }) => { const { t } = useTranslation(); const [skills, setSkills] = useState([]); const [skillLoad, setSkillLoad] = useState(null); @@ -127,6 +127,7 @@ const V2AgentSkills: React.FC = ({ podId, agentName, instanceId }) => {

{t('agentSkills.title')}

{canEdit && }
+

{t('agentSkills.pod')} · {podName}

{error &&

{error}

} {loading ?

{t('agentSkills.loading')}

: ( skills.length ?
diff --git a/frontend/src/v2/components/v2-agent-skills.css b/frontend/src/v2/components/v2-agent-skills.css index c8038358e6..cad1a11a9a 100644 --- a/frontend/src/v2/components/v2-agent-skills.css +++ b/frontend/src/v2/components/v2-agent-skills.css @@ -7,7 +7,8 @@ .v2-agent-skills__copy { display: flex; flex-direction: column; gap: 3px; min-width: 0; } .v2-agent-skills__copy strong { font-size: 14px; line-height: 20px; } .v2-agent-skills__copy span { font-size: 13px; line-height: 18px; color: var(--v2-text-secondary); } -.v2-agent-skills__copy small, .v2-agent-skills__reported { font: 500 11px/16px var(--v2-font-mono); color: var(--v2-text-muted); } +.v2-agent-skills__copy small, .v2-agent-skills__reported, .v2-agent-skills__pod { font: 500 11px/16px var(--v2-font-mono); color: var(--v2-text-muted); } +.v2-agent-skills__pod { margin: 0 0 12px; overflow-wrap: anywhere; } .v2-agent-skills__empty { margin: 12px 0; color: var(--v2-text-muted); font-size: 13px; } .v2-agent-skills__reported { margin: 8px 0 0; } .v2-agent-skills__error { margin: 8px 0; font-size: 13px; color: var(--v2-text-primary); } From 0345f34a5537c64ccd544c44b2f680845e233d49 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:12:50 -0700 Subject: [PATCH 17/19] Keep Wake and Pods before Skills in agent settings Co-authored-by: kite Agent-Model: codex/gpt-6-sol/medium --- frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 9430ddd3a1..2746d1e406 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -340,6 +340,9 @@ test('selects an installation the owner can manage without exposing a foreign po expect(screen.getByRole('heading', { name: 'Key' }).compareDocumentPosition( screen.getByRole('heading', { name: 'Skills' }), ) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'Wake' }).compareDocumentPosition( + screen.getByRole('heading', { name: 'Skills' }), + ) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: 'Regenerate key' })); fireEvent.click(screen.getByRole('button', { name: /replace the running agent/i })); await waitFor(() => expect(axios.post).toHaveBeenCalledWith( From a226f8a940d1b0341f1db894c87b7d8594f88557 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:38:46 -0700 Subject: [PATCH 18/19] Rebase agent Skills and bump CLI to 0.1.135 Co-authored-by: kite Agent-Model: codex/gpt-6-sol/medium --- cli/package-lock.json | 4 ++-- cli/package.json | 2 +- frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx | 6 ++++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 0fede37d39..68df616fe4 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.134", + "version": "0.1.135", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.134", + "version": "0.1.135", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index eef2b7eb28..ee66d6d795 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.134", + "version": "0.1.135", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 2746d1e406..96e145970c 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -368,10 +368,16 @@ test('runtime editor writes environment controls through the shared settings hoo }); renderPage(); const model = await screen.findByLabelText('Model'); + await screen.findByRole('heading', { name: 'Wake' }); + await screen.findByRole('heading', { name: 'Skills' }); const sections = Array.from(document.querySelectorAll('.v2-agent-settings h2')).map((node) => node.textContent); + expect(sections).toEqual(expect.arrayContaining(['Identity', 'Skills'])); + expect(sections.indexOf('Key')).toBeLessThan(sections.indexOf('Identity')); + expect(sections.indexOf('Identity')).toBeLessThan(sections.indexOf('Wake')); expect(sections.indexOf('Key')).toBeLessThan(sections.indexOf('Runtime')); expect(sections.indexOf('Wake')).toBeLessThan(sections.indexOf('Runtime')); expect(sections.indexOf('Runtime')).toBeLessThan(sections.indexOf('Connector grants')); + expect(sections.indexOf('Connector grants')).toBeLessThan(sections.indexOf('Skills')); fireEvent.change(model, { target: { value: 'new-model' } }); fireEvent.click(screen.getByRole('button', { name: 'Save runtime' })); await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( From 8ec0749ee16a095b16c4ddbca947a5b935eafe84 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:41:22 -0700 Subject: [PATCH 19/19] Keep Policy and Skills together in agent Settings Co-authored-by: kite Agent-Model: codex/gpt-6-sol/medium --- frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 96e145970c..8a60e13dd2 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -369,15 +369,17 @@ test('runtime editor writes environment controls through the shared settings hoo renderPage(); const model = await screen.findByLabelText('Model'); await screen.findByRole('heading', { name: 'Wake' }); + await screen.findByRole('heading', { name: 'Policy' }); await screen.findByRole('heading', { name: 'Skills' }); const sections = Array.from(document.querySelectorAll('.v2-agent-settings h2')).map((node) => node.textContent); - expect(sections).toEqual(expect.arrayContaining(['Identity', 'Skills'])); + expect(sections).toEqual(expect.arrayContaining(['Identity', 'Policy', 'Skills'])); expect(sections.indexOf('Key')).toBeLessThan(sections.indexOf('Identity')); expect(sections.indexOf('Identity')).toBeLessThan(sections.indexOf('Wake')); expect(sections.indexOf('Key')).toBeLessThan(sections.indexOf('Runtime')); expect(sections.indexOf('Wake')).toBeLessThan(sections.indexOf('Runtime')); expect(sections.indexOf('Runtime')).toBeLessThan(sections.indexOf('Connector grants')); - expect(sections.indexOf('Connector grants')).toBeLessThan(sections.indexOf('Skills')); + expect(sections.indexOf('Connector grants')).toBeLessThan(sections.indexOf('Policy')); + expect(sections.indexOf('Policy')).toBeLessThan(sections.indexOf('Skills')); fireEvent.change(model, { target: { value: 'new-model' } }); fireEvent.click(screen.getByRole('button', { name: 'Save runtime' })); await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(