Skip to content

feat: add Codex skill picker#581

Open
IAmKongHai wants to merge 6 commits into
tiann:mainfrom
IAmKongHai:feat/codex-skill-picker-plugin-palette
Open

feat: add Codex skill picker#581
IAmKongHai wants to merge 6 commits into
tiann:mainfrom
IAmKongHai:feat/codex-skill-picker-plugin-palette

Conversation

@IAmKongHai
Copy link
Copy Markdown

Summary

Codex sessions in HAPI Web can now discover local Codex skills and insert explicit $skill-name invocations from a searchable picker. HAPI stays on the input-assist side of the boundary: it only reads bounded skill metadata, never expands full SKILL.md files, never runs skill scripts, and never auto-submits the composer.

What Changed

  • Added GET /api/sessions/:id/skills for Codex sessions, returning an empty list for non-Codex sessions.
  • Added local skill discovery for repo, user, plugin-cache, and admin scopes.
  • Added Codex plugin-cache scanning via .codex-plugin/plugin.json, including invocation names like compound-engineering:ce-plan.
  • Replaced the $ compact autocomplete path with a larger searchable picker that supports keyboard and pointer selection.
  • Preserved slash command autocomplete and non-Codex composer behavior.

Testing

  • bun run typecheck
  • bun run test:hub
  • bun run test:web
  • bun run build:web
  • Manual browser E2E: Codex $ opens the skill picker and inserts $compound-engineering:ce-plan without sending; Claude $ does not open the picker.

中文说明

HAPI Web 中的 Codex session 现在可以发现本地 Codex skills,并通过可搜索的选择器插入显式 $skill-name 调用文本。HAPI 仍然只做输入辅助:只读取有限的 skill metadata,不展开完整 SKILL.md,不执行 skill 脚本,也不会自动发送 composer 内容。

主要改动

  • 新增 GET /api/sessions/:id/skills,仅 Codex session 返回 skills,非 Codex session 返回空数组。
  • 新增 repo、user、plugin cache、admin scope 的本地 skill 发现逻辑。
  • 支持通过 .codex-plugin/plugin.json 扫描 Codex plugin cache,并返回类似 compound-engineering:ce-plan 的可调用名称。
  • $ 的小型 autocomplete 替换为更大的可搜索 picker,支持键盘和鼠标选择。
  • 保持 slash command autocomplete 和非 Codex composer 行为不变。

验证

  • bun run typecheck
  • bun run test:hub
  • bun run test:web
  • bun run build:web
  • 手动浏览器 E2E:Codex session 输入 $ 会打开 skill picker,并只插入 $compound-engineering:ce-plan ;Claude session 输入 $ 不会打开 picker。

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review mode: initial

Findings:

  • [Major] Hub skill discovery skips the default ~/.codex/skills user-skill root unless CODEX_HOME is set, so Codex skills disappear for the common default install path. The CLI-side scanner already treats ~/.codex/skills as a user root, so the web picker and local agent will disagree here.
  • [Minor] The picker clears loadedSkills on any refresh failure, which turns a transient read/network error into an empty dialog even when cached skills were already available.

Testing: Not run (review only).


function getUserSkillsRoots(options: CodexSkillScanOptions): string[] {
const roots = [join(getHomeDirectory(options), '.agents', 'skills')]
const codexHome = options.codexHome ?? process.env.CODEX_HOME
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] getUserSkillsRoots() only appends CODEX_HOME/skills when CODEX_HOME is present, so the default ~/.codex/skills directory is never scanned. That drops user Codex skills for the common default setup.

Suggested fix:

function getUserSkillsRoots(options: CodexSkillScanOptions): string[] {
    return [
        join(getHomeDirectory(options), '.agents', 'skills'),
        join(getCodexHome(options), 'skills'),
    ]
}

void refreshSkills().then((nextSkills) => {
if (cancelled) return
setLoadedSkills(nextSkills)
}).catch(() => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] This catch path replaces the existing list with [], so a transient refresh failure makes the picker empty even if skills already contained cached results. Keeping the previous list preserves a usable dialog when the refresh fails.

Suggested fix:

void refreshSkills().then((nextSkills) => {
    if (cancelled) return
    setLoadedSkills(nextSkills)
}).catch(() => {
    // keep the existing loadedSkills
})

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review mode: initial

Findings

  • [Major] Default Codex user skills are skipped — getUserSkillsRoots only adds CODEX_HOME/skills when the hub process exports CODEX_HOME, so the normal ~/.codex/skills location is omitted even though the existing CLI scanner treats it as a user root (cli/src/modules/common/skills.ts:24). This makes the web picker disagree with local Codex skill discovery and can show no user skills for common installs. Evidence: hub/src/skills/codexSkills.ts:90
    Suggested fix:

    function getUserSkillsRoots(options: CodexSkillScanOptions): string[] {
        const home = getHomeDirectory(options)
        const codexHome = getCodexHome(options)
        return [
            join(home, '.agents', 'skills'),
            join(codexHome, 'skills'),
        ]
    }
  • [Major] Plugin manifests can escape the plugin root — resolve(pluginPath, skillsValue) accepts absolute paths and .. traversal without checking that the resulting skillsDir stays under the plugin directory. A malformed plugin cache entry can make the hub scan unrelated directories for SKILL.md files and expose their bounded metadata through the web API. Evidence: hub/src/skills/codexSkills.ts:272
    Suggested fix:

    import { isAbsolute, relative } from 'node:path'
    
    const skillsDir = resolve(pluginPath, skillsValue)
    const rel = relative(pluginPath, skillsDir)
    if (rel.startsWith('..') || isAbsolute(rel)) {
        return null
    }
    
    return {
        name: manifest.name.trim(),
        skillsDir,
        pluginPath,
    }

Summary
Review mode: initial
The new skill picker path has two hub-side discovery issues: common Codex user skills can be missed, and plugin manifests can redirect scanning outside the plugin directory.

Testing

  • Not run (bun unavailable in workspace)

HAPI Bot

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Desktop Codex sessions cannot open the skill picker — the composer now filters $ out of active autocomplete prefixes at web/src/components/AssistantChat/HappyComposer.tsx:196, so the new skill button is the only picker entry point; that button is added with sm:hidden at web/src/components/AssistantChat/ComposerButtons.tsx:355, which hides it at desktop/tablet widths.
    Suggested fix:

    className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 transition-colors hover:bg-[var(--app-bg)] hover:text-[var(--app-fg)] disabled:cursor-not-allowed disabled:opacity-50"
  • [Major] Default Codex user skills are skipped — getUserSkillsRoots only adds CODEX_HOME/skills when CODEX_HOME is explicitly set at hub/src/skills/codexSkills.ts:90, so the normal ~/.codex/skills root is missed even though getCodexHome already defines that default.
    Suggested fix:

    function getUserSkillsRoots(options: CodexSkillScanOptions): string[] {
        const home = getHomeDirectory(options)
        const codexHome = getCodexHome(options)
        return [
            join(home, '.agents', 'skills'),
            join(codexHome, 'skills'),
        ]
    }
  • [Major] Plugin manifests can escape the plugin root — resolve(pluginPath, skillsValue) accepts absolute paths and .. traversal at hub/src/skills/codexSkills.ts:272 without checking that the resulting skills directory remains inside the plugin directory, so a malformed cached plugin manifest can make the hub scan unrelated directories and expose their skill metadata through the web API.
    Suggested fix:

    import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
    
    const skillsDir = resolve(pluginPath, skillsValue)
    const relativeSkillsDir = relative(pluginPath, skillsDir)
    if (relativeSkillsDir.startsWith('..') || isAbsolute(relativeSkillsDir)) {
        return null
    }
    
    return {
        name: manifest.name.trim(),
        skillsDir,
        pluginPath,
    }

Questions

  • None.

Summary
Review mode: follow-up after new commits
The latest head still has the two hub discovery issues from the earlier pass, and the follow-up UI change removes the desktop entry point for the new Codex skill picker.

Testing

  • Not run: bun is unavailable in this runner (/bin/bash: bun: command not found).

HAPI Bot

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Desktop Codex sessions cannot open the skill picker — HappyComposer removes $ from compact autocomplete prefixes, so typing $ no longer opens autocomplete, and this new button is hidden at desktop/tablet widths by sm:hidden at web/src/components/AssistantChat/ComposerButtons.tsx:366. That leaves desktop Codex users with no entry point for the new picker.
    Suggested fix:

    className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 transition-colors hover:bg-[var(--app-bg)] hover:text-[var(--app-fg)] disabled:cursor-not-allowed disabled:opacity-50"
  • [Major] Default Codex user skills are skipped — getUserSkillsRoots only adds CODEX_HOME/skills when CODEX_HOME is explicitly set, so the normal default from getCodexHome() (~/.codex) is used for plugin cache but not for ~/.codex/skills; users with default Codex installs will not see their user skills. Evidence hub/src/skills/codexSkills.ts:90.
    Suggested fix:

    function getUserSkillsRoots(options: CodexSkillScanOptions): string[] {
        const home = getHomeDirectory(options)
        const codexHome = getCodexHome(options)
        return [
            join(home, '.agents', 'skills'),
            join(codexHome, 'skills'),
        ]
    }
  • [Major] Plugin manifests can escape the plugin root — resolve(pluginPath, skillsValue) accepts absolute paths and .. traversal without checking the resolved directory, so a malformed cached plugin manifest can make the hub scan unrelated directories and expose their skill names/descriptions/paths through /api/sessions/:id/skills. Evidence hub/src/skills/codexSkills.ts:272.
    Suggested fix:

    import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
    
    const skillsDir = resolve(pluginPath, skillsValue)
    const relativeSkillsDir = relative(pluginPath, skillsDir)
    if (relativeSkillsDir.startsWith('..') || isAbsolute(relativeSkillsDir)) {
        return null
    }
    
    return {
        name: manifest.name.trim(),
        skillsDir,
        pluginPath,
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The current head still contains the two hub discovery issues from the earlier pass, and the UI still hides the only new skill-picker entry point on desktop/tablet after $ autocomplete was disabled.

Testing

  • Not run (automation).

HAPI Bot

Inline comments were omitted because GitHub returned HTTP 422 for valid diff anchors on this run.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Blocker] Skill discovery now scans the hub filesystem instead of the session machine — this route replaced the existing session RPC with scanCodexSkillsForSession(session.metadata.path), but metadata.path is the CLI/session working directory and HAPI supports CLI machines connecting to a hub over Socket.IO. When the hub and agent are not the same process/filesystem, /api/sessions/:id/skills returns hub-local skills or nothing, and can expose hub-local paths unrelated to the session. Evidence hub/src/web/routes/sessions.ts:582.
    Suggested fix:

    const result = await engine.listSkills(sessionResult.sessionId)
    return c.json(result.success ? result.skills ?? [] : [])
  • [Major] Default Codex user skills are skipped — getCodexHome() correctly defaults to ~/.codex, but getUserSkillsRoots() only adds CODEX_HOME/skills when the env var is explicitly set. A normal Codex install with skills in ~/.codex/skills will not show those user skills. Evidence hub/src/skills/codexSkills.ts:90.
    Suggested fix:

    function getUserSkillsRoots(options: CodexSkillScanOptions): string[] {
        const home = getHomeDirectory(options)
        const codexHome = getCodexHome(options)
        return [...new Set([
            join(home, '.agents', 'skills'),
            join(codexHome, 'skills'),
        ])]
    }
  • [Major] Plugin manifests can escape the plugin root — resolve(pluginPath, skillsValue) accepts absolute paths and .. traversal from cached plugin manifests, so the skills endpoint can scan unrelated directories and return their skill names/descriptions/paths. Evidence hub/src/skills/codexSkills.ts:272.
    Suggested fix:

    import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
    
    const skillsDir = resolve(pluginPath, skillsValue)
    const relativeSkillsDir = relative(pluginPath, skillsDir)
    if (relativeSkillsDir.startsWith('..') || isAbsolute(relativeSkillsDir)) {
        return null
    }
  • [Major] Typing $ no longer opens the Codex skill picker — $ is removed from the active autocomplete prefixes, and the only new picker button is hidden at sm and wider breakpoints. Desktop/tablet Codex sessions therefore have no visible entry point, and the PR’s stated $ trigger path is disabled. Evidence web/src/components/AssistantChat/HappyComposer.tsx:200.
    Suggested fix:

    const handleChange = useCallback((e: ReactChangeEvent<HTMLTextAreaElement>) => {
        const selection = { start: e.target.selectionStart, end: e.target.selectionEnd }
        const text = e.target.value
        setInputState({ text, selection })
    
        const dollarWord = findActiveWord(text, selection, ['$'])
        if (agentFlavor === 'codex' && dollarWord) {
            clearSuggestions()
            setSkillPickerAnchor({
                text,
                selection,
                query: dollarWord.activeWord.slice(1),
            })
        }
    }, [agentFlavor, clearSuggestions])

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The current head still has unresolved skill discovery regressions and adds a picker path that is not reachable from desktop/tablet or from the advertised $ typing flow.

Testing

  • Not run (automation).

HAPI Bot

Inline comments were omitted because GitHub returned HTTP 422 for resolvable diff anchors on this run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant