Skip to content

Fix skill autocomplete fallback#1325

Closed
mulyoved wants to merge 3 commits intopingdotgg:mainfrom
mulyoved:fix/skill-autocomplete-fallback
Closed

Fix skill autocomplete fallback#1325
mulyoved wants to merge 3 commits intopingdotgg:mainfrom
mulyoved:fix/skill-autocomplete-fallback

Conversation

@mulyoved
Copy link

@mulyoved mulyoved commented Mar 23, 2026

Summary

  • add a first-party skills.list fallback for $ and /skills composer suggestions
  • merge built-in skill items with plugin-provided items so skill autocomplete does not depend on plugin registration alone
  • add regression coverage for core skill item building and fallback behavior

Verification

  • bun fmt
  • bun lint
  • bun typecheck

Note

Fix skill autocomplete fallback by introducing a plugin host and composer bridge for skills, prompts, and slash commands

  • Adds a full server-side plugin lifecycle (manager.ts) that discovers plugins from T3CODE_PLUGIN_DIRS and a local plugins/ dir, activates them, validates procedure I/O via Effect schemas, serves web bundles at /__plugins/:id/web.js, and hot-reloads on file changes.
  • Adds a client-side PluginHostProvider (host.tsx) that dynamically imports enabled plugin web modules, registers composer providers and slot renderers, and exposes them via React context with usePluginComposerItems and PluginSlot.
  • Introduces a composerBridge (composerBridge.ts) with buildComposerMenuItems that merges plugin-provided items with built-in skills, prompts, paths, and models, applying scoring and deduplication per trigger kind.
  • Extends detectComposerTrigger (composer-logic.ts) to recognize /workspace, /skills, and $<token> (skill-mention) trigger kinds, and makes the generic /<query> path return slash-command without a fixed allowlist.
  • Adds skills.list and prompts.list WS methods and a plugins.registryUpdated push channel to the protocol (ws.ts), backed by listAvailableSkills and listAvailablePrompts on the server.
  • Includes a reference codex-composer plugin (plugins/codex-composer/) that provides workspace and skill picker slash commands.
  • Risk: The ProjectSearchEntriesInput schema now accepts empty query strings; callers that relied on non-empty validation will silently pass.
📊 Macroscope summarized bd06126. 34 files reviewed, 9 issues evaluated, 1 issue filtered, 3 comments posted

🗂️ Filtered Issues

apps/web/src/plugins/host.tsx — 0 comments posted, 3 evaluated, 1 filtered
  • line 222: Concurrent invocations of loadPlugins (triggered by multiple onRegistryUpdated events or rapid effect re-runs) share the same cancelled flag but run independently. Both concurrent calls will proceed past the if (cancelled) checks and load the same plugins, causing duplicate entries in composerProvidersRef.current and loadedPluginsRef.current. The first invocation unregisters all plugins, then both start loading, leading to each plugin being registered twice. [ Cross-file consolidated ]

@coderabbitai
Copy link

coderabbitai bot commented Mar 23, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f31624e1-0b02-42d5-9a6c-68d971968b19

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Mar 23, 2026
@mulyoved mulyoved closed this Mar 23, 2026
}
};

const reloadAllPlugins = async (preferredIds?: string[]) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Medium plugins/manager.ts:329

Concurrent calls to reloadAllPlugins race and corrupt state: overlapping executions can double-unload plugins, clear pluginStates while another call is iterating, and install duplicate watchers. This happens because scheduleFullReload and per-plugin reload timers can fire simultaneously with different keys, and there is no mechanism to serialize reload operations. Consider adding a guard (e.g., a promise or flag) to ensure only one reload executes at a time, with subsequent calls either awaiting the in-progress reload or queuing.

Also found in 1 other location(s)

apps/web/src/plugins/host.tsx:222

Concurrent invocations of loadPlugins (triggered by multiple onRegistryUpdated events or rapid effect re-runs) share the same cancelled flag but run independently. Both concurrent calls will proceed past the if (cancelled) checks and load the same plugins, causing duplicate entries in composerProvidersRef.current and loadedPluginsRef.current. The first invocation unregisters all plugins, then both start loading, leading to each plugin being registered twice.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/plugins/manager.ts around line 329:

Concurrent calls to `reloadAllPlugins` race and corrupt state: overlapping executions can double-unload plugins, clear `pluginStates` while another call is iterating, and install duplicate watchers. This happens because `scheduleFullReload` and per-plugin reload timers can fire simultaneously with different keys, and there is no mechanism to serialize reload operations. Consider adding a guard (e.g., a promise or flag) to ensure only one reload executes at a time, with subsequent calls either awaiting the in-progress reload or queuing.

Evidence trail:
apps/server/src/plugins/manager.ts lines 253-263 (scheduleFullReload with rootWatchTimers), lines 310-319 (per-plugin reload with reloadTimers), lines 329-351 (reloadAllPlugins async function with no lock/mutex), lines 143-151 (clearWatchers and watchers array), lines 149-157 (unloadPlugin with cleanup tasks). git_grep for locking mechanisms (reloadLock, reloadMutex, reloadInProgress, isReloading) returned no results.

Also found in 1 other location(s):
- apps/web/src/plugins/host.tsx:222 -- Concurrent invocations of `loadPlugins` (triggered by multiple `onRegistryUpdated` events or rapid effect re-runs) share the same `cancelled` flag but run independently. Both concurrent calls will proceed past the `if (cancelled)` checks and load the same plugins, causing duplicate entries in `composerProvidersRef.current` and `loadedPluginsRef.current`. The first invocation unregisters all plugins, then both start loading, leading to each plugin being registered twice.

Comment on lines +39 to +42
function extractHeading(markdown: string): string | undefined {
const heading = /^#\s+(.+)$/m.exec(markdown)?.[1]?.trim();
return heading && heading.length > 0 ? heading : undefined;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🟢 Low src/prompts.ts:39

extractHeading searches the entire markdown string including frontmatter, so a YAML comment like # some note in the frontmatter block is incorrectly returned as the document heading instead of the actual markdown heading that appears after ---. Consider anchoring the regex to exclude frontmatter, e.g. by stripping the frontmatter block before matching or ensuring the heading appears after the frontmatter delimiter.

+function extractHeading(markdown: string): string | undefined {
+  const withoutFrontmatter = markdown.replace(/^---\n[\s\S]*?\n---\n?/, '');
+  const heading = /^#\s+(.+)$/m.exec(withoutFrontmatter)?.[1]?.trim();
+  return heading && heading.length > 0 ? heading : undefined;
+}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/prompts.ts around lines 39-42:

`extractHeading` searches the entire markdown string including frontmatter, so a YAML comment like `# some note` in the frontmatter block is incorrectly returned as the document heading instead of the actual markdown heading that appears after `---`. Consider anchoring the regex to exclude frontmatter, e.g. by stripping the frontmatter block before matching or ensuring the heading appears after the frontmatter delimiter.

Evidence trail:
apps/server/src/prompts.ts lines 28-29 (extractFrontmatter), lines 39-41 (extractHeading using /^#\s+(.+)$/m on full markdown), lines 44-46 (parsePromptMarkdown passes full input.markdown to extractHeading without stripping frontmatter). Commit: REVIEWED_COMMIT.

Comment on lines +45 to +52
function normalizePluginRoots(cwd: string): string[] {
const configuredRoots = (process.env[PLUGINS_ENV_VAR] ?? "")
.split(path.delimiter)
.map((value) => value.trim())
.filter((value) => value.length > 0)
.map((value) => path.resolve(value));
const localRoot = path.resolve(cwd, DEFAULT_LOCAL_PLUGINS_DIR);
return Array.from(new Set([localRoot, ...configuredRoots]));
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Medium plugins/discovery.ts:45

Line 50 resolves relative paths from the environment variable against process.cwd() rather than the cwd parameter. When a caller passes a cwd different from process.cwd(), relative paths in the environment variable resolve inconsistently with localRoot which correctly uses path.resolve(cwd, DEFAULT_LOCAL_PLUGINS_DIR). Consider changing path.resolve(value) to path.resolve(cwd, value) so all paths resolve relative to the same base.

-    .map((value) => path.resolve(value));
+    .map((value) => path.resolve(cwd, value));
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/plugins/discovery.ts around lines 45-52:

Line 50 resolves relative paths from the environment variable against `process.cwd()` rather than the `cwd` parameter. When a caller passes a `cwd` different from `process.cwd()`, relative paths in the environment variable resolve inconsistently with `localRoot` which correctly uses `path.resolve(cwd, DEFAULT_LOCAL_PLUGINS_DIR)`. Consider changing `path.resolve(value)` to `path.resolve(cwd, value)` so all paths resolve relative to the same base.

Evidence trail:
apps/server/src/plugins/discovery.ts lines 44-52 (REVIEWED_COMMIT): `normalizePluginRoots(cwd: string)` function shows line 50 `.map((value) => path.resolve(value))` resolving env var paths against process.cwd(), while line 51 `path.resolve(cwd, DEFAULT_LOCAL_PLUGINS_DIR)` resolves localRoot against the cwd parameter. apps/server/src/plugins/manager.ts line 337: `discoverPluginRoots(input.cwd)` shows the function is called with a cwd parameter that may differ from process.cwd().

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

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant