From 541fd27060cc8b0122a59692568b177d21803d15 Mon Sep 17 00:00:00 2001 From: clagentic <10177887+akuehner@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:36:04 -0400 Subject: [PATCH 1/2] feat(lr-c1a2): @ mention shows project-local agents in session input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user types @ in the session input, an inline autocomplete dropdown now lists all agents installed in the project's .claude/agents/ directory, plus Codex (when not already in a Codex session). Selecting an agent opens a new session with that agentName — the same action as the "Agent Chat" button. Graceful empty state: no menu shown when no agents are installed and Codex is suppressed. Backend: - lib/agents.js: add readProjectAgents(projectDir) — scans .claude/agents/*.md and returns [{name, slug, description}] from frontmatter, falling back to slug when name is absent. - lib/project-sessions.js: handle get_agents c2s message; responds with project_agents_list carrying the local agent array. - lib/ws-schema.js: register get_agents (c2s) and project_agents_list (s2c) message types. Frontend: - lib/public/modules/at-agents.js: new module — inline @ dropdown for project agents + Codex. Keyboard nav (Up/Down/Enter/Tab/Esc). Calls new_session on selection. Requests agents via get_agents WS message. - lib/public/modules/input.js: import at-agents; show/hide agent menu alongside mention menu on @ input; route keydown to agentMenuKeydown (takes priority over user mention); request project agents on init. - lib/public/modules/app-messages.js: handle project_agents_list; call requestProjectAgents on session_switched to pre-populate cache. - lib/public/css/at-agents.css: dropdown styling. - lib/public/style.css: import at-agents.css. Tests: test/agents-read-project.test.js — 12 new tests covering readProjectAgents for input validation, missing directory, empty dir, frontmatter parsing, non-.md file filtering, and sort order. 289/289 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- lib/agents.js | 44 +++++ lib/project-sessions.js | 11 ++ lib/public/css/at-agents.css | 68 ++++++++ lib/public/modules/app-messages.js | 9 + lib/public/modules/at-agents.js | 270 +++++++++++++++++++++++++++++ lib/public/modules/input.js | 22 ++- lib/public/style.css | 1 + lib/ws-schema.js | 12 +- test/agents-read-project.test.js | 200 +++++++++++++++++++++ 9 files changed, 633 insertions(+), 4 deletions(-) create mode 100644 lib/public/css/at-agents.css create mode 100644 lib/public/modules/at-agents.js create mode 100644 test/agents-read-project.test.js diff --git a/lib/agents.js b/lib/agents.js index 4e87ce53..9b173d62 100644 --- a/lib/agents.js +++ b/lib/agents.js @@ -223,12 +223,56 @@ function discoverAllAgents() { return getAll(); } +// Read all named-agent definitions from a project-local .claude/agents/ directory. +// +// projectDir: absolute path to the project root (cwd). +// Returns: array of { name, slug, description } objects, sorted by name. +// Returns [] if the directory does not exist or is unreadable — callers treat +// an empty array as "no project-local agents" and degrade gracefully. +// +// This reads the filesystem directly (no SDK subprocess) — it is synchronous +// and cheap. The agents directory is small (typically 0–20 files). +function readProjectAgents(projectDir) { + if (!projectDir || typeof projectDir !== "string") return []; + var agentsDir = path.join(projectDir, ".claude", "agents"); + var entries; + try { + entries = fs.readdirSync(agentsDir); + } catch (e) { + // Directory absent or unreadable — not an error. + return []; + } + var results = []; + for (var i = 0; i < entries.length; i++) { + var entry = entries[i]; + if (!entry || !entry.endsWith(".md")) continue; + var slug = entry.slice(0, -3); // strip .md + if (!slug) continue; + var filePath = path.join(agentsDir, entry); + var raw; + try { + raw = fs.readFileSync(filePath, "utf8"); + } catch (e) { + continue; + } + var parsed = parseFrontmatter(raw); + var name = (parsed && parsed.meta && parsed.meta.name) ? String(parsed.meta.name) : slug; + var description = (parsed && parsed.meta && parsed.meta.description) ? String(parsed.meta.description) : ""; + results.push({ name: name, slug: slug, description: description }); + } + results.sort(function (a, b) { + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; + }); + return results; +} + module.exports = { AGENTS_SOURCE_DIR: AGENTS_SOURCE_DIR, PLUGINS_CACHE_DIR: PLUGINS_CACHE_DIR, parseFrontmatter: parseFrontmatter, slugifyAgentName: slugifyAgentName, readAgentToolsFromFile: readAgentToolsFromFile, + readProjectAgents: readProjectAgents, // New SDK-backed surface: refresh: refresh, getAll: getAll, diff --git a/lib/project-sessions.js b/lib/project-sessions.js index fe6cd640..cb5853b2 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -183,6 +183,17 @@ function attachSessions(ctx) { return true; } + // lr-c1a2 — get_agents returns agents installed in the project's + // .claude/agents/ directory (not the SDK global catalog). Used by the + // @ mention dropdown in the session input to offer project-local agents. + if (msg.type === "get_agents") { + var projectAgents = []; + try { projectAgents = agentsModule.readProjectAgents(cwd); } + catch (e) { console.error("[project-sessions] get_agents failed:", e && e.message ? e.message : e); } + sendTo(ws, { type: "project_agents_list", agents: projectAgents }); + return true; + } + // toggle_agent_favorite flips an agent's membership in chattable.json. // Required field: name. if (msg.type === "toggle_agent_favorite") { diff --git a/lib/public/css/at-agents.css b/lib/public/css/at-agents.css new file mode 100644 index 00000000..edb39cc6 --- /dev/null +++ b/lib/public/css/at-agents.css @@ -0,0 +1,68 @@ +/* ========================================================================== + @ Agent Autocomplete Dropdown (lr-c1a2) + ========================================================================== */ + +.at-agents-menu { + display: none; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + max-height: 220px; + overflow-y: auto; + background: var(--sidebar-bg); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 6px; + box-shadow: 0 4px 12px rgba(var(--shadow-rgb), 0.15); + z-index: 11; +} + +.at-agents-menu.visible { display: block; } + +.at-agents-item { + display: flex; + align-items: baseline; + padding: 8px 14px; + cursor: pointer; + gap: 8px; + border-bottom: 1px solid var(--border-subtle); +} + +.at-agents-item:last-child { border-bottom: none; } + +.at-agents-item:hover, +.at-agents-item.active { + background: var(--sidebar-active); +} + +.at-agents-name { + font-size: 13px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + flex-shrink: 0; +} + +.at-agents-badge { + font-size: 10px; + font-weight: 600; + color: var(--text-dimmer); + background: var(--border-subtle); + border-radius: 4px; + padding: 1px 5px; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + flex-shrink: 0; +} + +.at-agents-desc { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} diff --git a/lib/public/modules/app-messages.js b/lib/public/modules/app-messages.js index e857150d..99333afd 100644 --- a/lib/public/modules/app-messages.js +++ b/lib/public/modules/app-messages.js @@ -15,6 +15,7 @@ import { updateDmBadge, renderSidebarPresence, setMentionActive, renderUserStrip import { refreshMobileChatSheet } from './sidebar-mobile.js'; import { handlePaletteSessionSwitch, setPaletteVersion } from './command-palette.js'; import { handleAgentsList, handleAgentFavoriteToggled } from './agent-picker.js'; +import { handleProjectAgentsList, requestProjectAgents } from './at-agents.js'; import { handleFindInSessionResults } from './session-search.js'; import { handleInputSync, autoResize, resetAutoResize, builtinCommands, setScheduleBtnDisabled } from './input.js'; import { startThinking, appendThinking, stopThinking, resetThinkingGroup, createToolItem, updateToolExecuting, updateToolResult, markAllToolsDone, closeToolGroup, removeToolFromGroup, resetToolState, getTools, getPlanContent, setPlanContent, renderPlanBanner, renderPlanCard, getTodoTools, handleTodoWrite, handleTaskCreate, handleTaskUpdate, applyDeadSessionTodoCompaction, isPlanFilePath, enableMainInput, addTurnMeta, updateSubagentActivity, addSubagentToolEntry, markSubagentDone, initSubagentStop, updateSubagentProgress, updateSubagentTaskStatus, renderAskUserQuestion, markAskUserAnswered, renderPermissionRequest, markPermissionCancelled, markPermissionResolved, renderElicitationRequest, markElicitationResolved } from './tools.js'; @@ -459,6 +460,10 @@ export function processMessage(msg) { handleAgentFavoriteToggled(msg); break; + case "project_agents_list": + handleProjectAgentsList(msg); + break; + case "session_presence": updateSessionPresence(msg.presence || {}); break; @@ -498,6 +503,10 @@ export function processMessage(msg) { break; case "session_switched": + // Prefetch project agents so the @ menu is ready without a round-trip + // the first time the user types @. Best-effort; at-agents.js handles + // an empty list gracefully. + requestProjectAgents(); hideHomeHub(); // Clear any stale replay indicator from a prior interrupted switch if (replayLoadingEl) replayLoadingEl.classList.add("hidden"); diff --git a/lib/public/modules/at-agents.js b/lib/public/modules/at-agents.js new file mode 100644 index 00000000..5648b343 --- /dev/null +++ b/lib/public/modules/at-agents.js @@ -0,0 +1,270 @@ +// at-agents.js — inline @ mention dropdown for project-local agents (lr-c1a2). +// +// When the user types @ in the session input this module shows a small inline +// autocomplete dropdown listing: +// 1. All agents defined in .claude/agents/ for the active project (from the +// server via the get_agents / project_agents_list WS round-trip). +// 2. Codex (always shown unless the current session vendor is already codex). +// +// Selecting an agent starts a new session with that agentName — identical to +// what the "Agent Chat" button does in the sidebar. The @ text is cleared from +// the input after selection (the session switch replaces the conversation). +// +// The dropdown is hidden when the agent list is empty (no .claude/agents/ and +// codex not available). Graceful empty state: nothing shown, no errors. +// +// Integration with input.js: +// - input.js calls showAgentMenu(query) when it detects an active @ and +// getAgentMentionMenuVisible() is true for keyboard routing. +// - input.js calls hideAgentMenu() on Escape, slash menu open, or send. +// - input.js checks isAgentMenuActive() before routing keydown events here. +// +// This module does NOT conflict with mention.js. Mention.js handles +// @user / @plain-vendor sends within an existing session. This module handles +// @agent, which *starts a new session* rather than sending a mention. The two +// are mutually exclusive: agents never appear in mention.js's candidate list, +// and users/plain-vendors never appear in the at-agents dropdown. + +import { escapeHtml } from './utils.js'; +import { store } from './store.js'; +import { getWs } from './ws-ref.js'; + +// --- Module state --- +var _ctx = null; +var _menuEl = null; +var _agents = []; // project-local agents from server: [{name, slug, description}] +var _items = []; // rendered items (filtered from _agents + codex entry) +var _activeIdx = -1; +var _menuVisible = false; +var _menuBound = false; +var _currentQuery = ""; + +// --- Codex entry (always available when not already in a Codex session) --- +var CODEX_ENTRY = { + name: "Codex", + slug: "codex", + description: "OpenAI Codex (Codex CLI)", + _isCodex: true, +}; + +// --- Public API --- + +export function initAtAgents(ctx) { + _ctx = ctx; + _ensureMenu(); + if (!_menuBound) { + _menuBound = true; + document.addEventListener("click", function (e) { + if (!_menuEl) return; + var item = e.target.closest(".at-agents-item"); + if (item) { + var idx = parseInt(item.dataset.idx, 10); + if (!isNaN(idx) && idx >= 0 && idx < _items.length) { + e.preventDefault(); + e.stopPropagation(); + _activateItem(_items[idx]); + } + return; + } + // Click outside the menu — hide it. + if (!_menuEl.contains(e.target)) { + hideAgentMenu(); + } + }); + } +} + +// Returns true when the @ agent menu is currently visible. +export function isAgentMenuActive() { + return _menuVisible; +} + +// Request a fresh project agent list from the server. Called once on WS open +// (or on session switch). The response arrives as project_agents_list and +// calls handleProjectAgentsList(). +export function requestProjectAgents() { + var ws = getWs(); + if (!ws || ws.readyState !== 1) return; + ws.send(JSON.stringify({ type: "get_agents" })); +} + +// Called from app-messages.js when project_agents_list arrives. +export function handleProjectAgentsList(msg) { + _agents = Array.isArray(msg.agents) ? msg.agents : []; + // Re-render if the menu is currently open. + if (_menuVisible) { + _render(_currentQuery); + } +} + +// Show the dropdown at the position of the input element. Filter by query. +// Called from input.js when @ is active. +export function showAgentMenu(query) { + _currentQuery = query || ""; + _ensureMenu(); + _render(_currentQuery); +} + +// Hide and clear the dropdown. +export function hideAgentMenu() { + if (!_menuVisible && !_menuEl) return; + _menuVisible = false; + _activeIdx = -1; + _items = []; + _currentQuery = ""; + if (_menuEl) { + _menuEl.classList.remove("visible"); + _menuEl.innerHTML = ""; + } +} + +// Keyboard navigation. Returns true if the event was consumed. +export function agentMenuKeydown(e) { + if (!_menuVisible || _items.length === 0) return false; + if (e.key === "ArrowDown") { + e.preventDefault(); + _setActive((_activeIdx + 1) % _items.length); + return true; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + _setActive((_activeIdx - 1 + _items.length) % _items.length); + return true; + } + if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) { + e.preventDefault(); + if (_activeIdx >= 0 && _activeIdx < _items.length) { + _activateItem(_items[_activeIdx]); + } + return true; + } + if (e.key === "Escape") { + e.preventDefault(); + hideAgentMenu(); + return true; + } + return false; +} + +// --- Private helpers --- + +function _ensureMenu() { + if (_menuEl) return; + _menuEl = document.createElement("div"); + _menuEl.id = "at-agents-menu"; + _menuEl.className = "at-agents-menu"; + // Attach near the input area (same parent as slash menu / mention menu). + var inputArea = document.getElementById("input-area"); + if (inputArea) { + inputArea.appendChild(_menuEl); + } else { + document.body.appendChild(_menuEl); + } +} + +function _buildItems(query) { + var q = (query || "").toLowerCase().trim(); + var sessionVendor = store.get("currentVendor") || "claude"; + var result = []; + + // Project-local agents from .claude/agents/. + for (var i = 0; i < _agents.length; i++) { + var a = _agents[i]; + if (!a || !a.name) continue; + if (q && a.name.toLowerCase().indexOf(q) === -1 && + (a.description || "").toLowerCase().indexOf(q) === -1) continue; + result.push(a); + } + + // Codex entry — only when not already in a Codex session. + if (sessionVendor !== "codex") { + if (!q || + CODEX_ENTRY.name.toLowerCase().indexOf(q) !== -1 || + CODEX_ENTRY.description.toLowerCase().indexOf(q) !== -1) { + result.push(CODEX_ENTRY); + } + } + + return result; +} + +function _render(query) { + _items = _buildItems(query); + if (_items.length === 0) { + hideAgentMenu(); + return; + } + _menuVisible = true; + _activeIdx = 0; + + var html = ""; + for (var i = 0; i < _items.length; i++) { + var item = _items[i]; + var isCodex = !!item._isCodex; + var badge = isCodex + ? 'codex' + : 'agent'; + var desc = item.description ? escapeHtml(item.description) : ""; + html += + '
' + + '' + escapeHtml(item.name) + '' + + badge + + (desc ? '' + desc + '' : '') + + '
'; + } + _menuEl.innerHTML = html; + _menuEl.classList.add("visible"); + + // Bind mouseenter for hover highlighting. + var itemEls = _menuEl.querySelectorAll(".at-agents-item"); + for (var j = 0; j < itemEls.length; j++) { + (function (el) { + el.addEventListener("mouseenter", function () { + _setActive(parseInt(el.dataset.idx, 10), true); + }); + })(itemEls[j]); + } +} + +function _setActive(idx, skipScroll) { + if (_items.length === 0) return; + if (idx < 0) idx = _items.length - 1; + if (idx >= _items.length) idx = 0; + _activeIdx = idx; + var els = _menuEl.querySelectorAll(".at-agents-item"); + for (var i = 0; i < els.length; i++) { + els[i].classList.toggle("active", i === idx); + } + if (!skipScroll && els[idx]) { + els[idx].scrollIntoView({ block: "nearest" }); + } +} + +function _activateItem(item) { + hideAgentMenu(); + // Clear the @ text from the input. + if (_ctx && _ctx.inputEl) { + var val = _ctx.inputEl.value; + // Remove everything from the last @ to the cursor. + var cursor = _ctx.inputEl.selectionStart; + var atIdx = val.lastIndexOf("@", cursor - 1); + if (atIdx !== -1) { + _ctx.inputEl.value = val.substring(0, atIdx) + val.substring(cursor); + _ctx.inputEl.selectionStart = _ctx.inputEl.selectionEnd = atIdx; + } + } + // Start (or switch to) the agent session. + if (item._isCodex) { + // Codex: open a new plain-codex session via vendor switch. + var ws = getWs(); + if (ws && ws.readyState === 1) { + ws.send(JSON.stringify({ type: "new_session", vendor: "codex" })); + } + } else { + // Named agent: open a new session with agentName. + var ws2 = getWs(); + if (ws2 && ws2.readyState === 1) { + ws2.send(JSON.stringify({ type: "new_session", agentName: item.name })); + } + } +} diff --git a/lib/public/modules/input.js b/lib/public/modules/input.js index 3bd5d3fc..529bcbd1 100644 --- a/lib/public/modules/input.js +++ b/lib/public/modules/input.js @@ -2,6 +2,7 @@ import { iconHtml, refreshIcons } from './icons.js'; import { setRewindMode, isRewindMode } from './rewind.js'; import { renderPicker as renderContextPicker } from './context-sources.js'; import { checkForMention, showMentionMenu, hideMentionMenu, isMentionMenuVisible, mentionMenuKeydown, setMentionAtIdx, parseMentionFromInput, clearMentionState, stickyReapplyMention, sendUserMention, renderUserMention, removeMentionChip } from './mention.js'; +import { initAtAgents, showAgentMenu, hideAgentMenu, isAgentMenuActive, agentMenuKeydown, requestProjectAgents } from './at-agents.js'; import { store } from './store.js'; import { sendWsQuiet } from './ws-ref.js'; @@ -101,6 +102,7 @@ export function sendMessage() { if (!text && images.length === 0 && pendingPastes.length === 0 && pendingFiles.length === 0) return; if (uploadingCount > 0) return; // wait for uploads to finish hideSlashMenu(); + hideAgentMenu(); if (ctx.hideSuggestionChips) ctx.hideSuggestionChips(); if (text === "/clear") { @@ -738,6 +740,9 @@ function createFileInput(accept, capture, multiple) { export function initInput(_ctx) { ctx = _ctx; + // Initialise the @ agent menu with the input context. + initAtAgents(ctx); + if (!slashMenuBound && ctx.slashMenu) { slashMenuBound = true; ctx.slashMenu.addEventListener("click", function (e) { @@ -988,22 +993,29 @@ export function initInput(_ctx) { if (val.startsWith("/") && !val.includes(" ") && val.length > 1) { showSlashMenu(val.substring(1)); hideMentionMenu(); + hideAgentMenu(); } else if (val === "/") { showSlashMenu(""); hideMentionMenu(); + hideAgentMenu(); } else { hideSlashMenu(); - // Check for @mention — skip selectionStart read (forced layout) when - // there is no @ in the value at all. + // Check for @mention / @agent — skip selectionStart read (forced layout) + // when there is no @ in the value at all. if (val.indexOf("@") !== -1) { var mentionCheck = checkForMention(val, ctx.inputEl.selectionStart); if (mentionCheck.active) { setMentionAtIdx(mentionCheck.startIdx); + // Show agent menu (project agents + Codex) for the @ trigger. + // Also show the user mention menu for user-to-user @mentions. + showAgentMenu(mentionCheck.query); showMentionMenu(mentionCheck.query); } else { + hideAgentMenu(); hideMentionMenu(); } } else { + hideAgentMenu(); hideMentionMenu(); } } @@ -1017,7 +1029,11 @@ export function initInput(_ctx) { ctx.inputEl.addEventListener("compositionend", function () { isComposing = false; }); ctx.inputEl.addEventListener("keydown", function (e) { - // @Mention menu keyboard navigation + // @ Agent menu keyboard navigation — takes priority over mention menu. + if (isAgentMenuActive()) { + if (agentMenuKeydown(e)) return; + } + // @Mention menu keyboard navigation (user-to-user / plain vendor) if (isMentionMenuVisible()) { if (mentionMenuKeydown(e)) return; } diff --git a/lib/public/style.css b/lib/public/style.css index 8ce8d075..e49af362 100644 --- a/lib/public/style.css +++ b/lib/public/style.css @@ -30,6 +30,7 @@ @import url("css/command-palette.css"); @import url("css/agent-picker.css"); @import url("css/mention.css"); +@import url("css/at-agents.css"); @import url("css/debate.css"); @import url("css/notifications-center.css"); diff --git a/lib/ws-schema.js b/lib/ws-schema.js index cf20553e..8269cc4f 100644 --- a/lib/ws-schema.js +++ b/lib/ws-schema.js @@ -460,7 +460,17 @@ var schema = { "ralph_phase": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Current ralph wizard phase" }, "ralph_crafting_started": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "File crafting session started" }, "ralph_files_status": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Prompt/judge file readiness status" }, - "ralph_files_content": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Loop file contents (prompt and judge)" } + "ralph_files_content": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Loop file contents (prompt and judge)" }, + + // ----------------------------------------------------------------------- + // Agents (SDK catalog + project-local) + // ----------------------------------------------------------------------- + "list_agents": { direction: "c2s", handler: "lib/project-sessions.js", description: "Request SDK agent catalog with favorites and recents" }, + "agents_list": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Full agent catalog with favorites and recents" }, + "toggle_agent_favorite": { direction: "c2s", handler: "lib/project-sessions.js", description: "Toggle an agent in/out of favorites" }, + "agent_favorite_toggled": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Favorite toggle confirmation" }, + "get_agents": { direction: "c2s", handler: "lib/project-sessions.js", description: "Request project-local agents from .claude/agents/ (lr-c1a2)" }, + "project_agents_list": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Project-local agent list for @ mention dropdown (lr-c1a2)" } }; module.exports = { schema: schema }; diff --git a/test/agents-read-project.test.js b/test/agents-read-project.test.js new file mode 100644 index 00000000..bca44346 --- /dev/null +++ b/test/agents-read-project.test.js @@ -0,0 +1,200 @@ +// lr-c1a2 — regression tests for readProjectAgents +// +// Verifies that the helper correctly scans a project's .claude/agents/ directory +// and returns {name, slug, description}[] entries parsed from frontmatter. + +var test = require("node:test"); +var assert = require("node:assert"); +var fs = require("fs"); +var path = require("path"); +var os = require("os"); + +var agentsModule = require("../lib/agents"); +var { readProjectAgents } = agentsModule; + +// --- Helpers --- + +function makeTmpProjectDir() { + var base = os.tmpdir(); + var dir = path.join(base, "clagentic-test-lr-c1a2-" + Date.now() + "-" + Math.random().toString(36).slice(2)); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function makeAgentsDir(projectDir) { + var agentsDir = path.join(projectDir, ".claude", "agents"); + fs.mkdirSync(agentsDir, { recursive: true }); + return agentsDir; +} + +function writeAgent(agentsDir, filename, content) { + fs.writeFileSync(path.join(agentsDir, filename), content, "utf8"); +} + +function rmDir(dir) { + // Node 16+ has fs.rmSync with recursive. + try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* best-effort */ } +} + +// ============================================================ +// Guard: export exists +// ============================================================ + +test("readProjectAgents is exported from lib/agents.js", function () { + assert.strictEqual(typeof readProjectAgents, "function", + "readProjectAgents must be exported from lib/agents.js"); +}); + +// ============================================================ +// Input validation +// ============================================================ + +test("readProjectAgents: returns [] for null projectDir", function () { + var result = readProjectAgents(null); + assert.deepStrictEqual(result, []); +}); + +test("readProjectAgents: returns [] for empty string", function () { + var result = readProjectAgents(""); + assert.deepStrictEqual(result, []); +}); + +test("readProjectAgents: returns [] for non-string", function () { + assert.deepStrictEqual(readProjectAgents(42), []); + assert.deepStrictEqual(readProjectAgents({}), []); +}); + +// ============================================================ +// Missing directory — graceful degradation +// ============================================================ + +test("readProjectAgents: returns [] when .claude/agents/ does not exist", function () { + var dir = makeTmpProjectDir(); + try { + var result = readProjectAgents(dir); + assert.deepStrictEqual(result, []); + } finally { + rmDir(dir); + } +}); + +test("readProjectAgents: returns [] for nonexistent projectDir", function () { + var result = readProjectAgents("/tmp/zzz-lr-c1a2-does-not-exist-ever"); + assert.deepStrictEqual(result, []); +}); + +// ============================================================ +// Empty agents directory +// ============================================================ + +test("readProjectAgents: returns [] for empty .claude/agents/ directory", function () { + var dir = makeTmpProjectDir(); + makeAgentsDir(dir); + try { + var result = readProjectAgents(dir); + assert.deepStrictEqual(result, []); + } finally { + rmDir(dir); + } +}); + +// ============================================================ +// Single agent with full frontmatter +// ============================================================ + +test("readProjectAgents: parses name and description from frontmatter", function () { + var dir = makeTmpProjectDir(); + var agentsDir = makeAgentsDir(dir); + writeAgent(agentsDir, "my-agent.md", [ + "---", + "name: My Custom Agent", + "description: Does something special", + "---", + "Agent body here.", + ].join("\n")); + try { + var result = readProjectAgents(dir); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, "My Custom Agent"); + assert.strictEqual(result[0].slug, "my-agent"); + assert.strictEqual(result[0].description, "Does something special"); + } finally { + rmDir(dir); + } +}); + +// ============================================================ +// Agent with no frontmatter — slug used as name +// ============================================================ + +test("readProjectAgents: uses slug as name when no frontmatter name field", function () { + var dir = makeTmpProjectDir(); + var agentsDir = makeAgentsDir(dir); + writeAgent(agentsDir, "plain-agent.md", "Just a plain body, no frontmatter."); + try { + var result = readProjectAgents(dir); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, "plain-agent"); + assert.strictEqual(result[0].slug, "plain-agent"); + assert.strictEqual(result[0].description, ""); + } finally { + rmDir(dir); + } +}); + +// ============================================================ +// Non-.md files are ignored +// ============================================================ + +test("readProjectAgents: ignores non-.md files in .claude/agents/", function () { + var dir = makeTmpProjectDir(); + var agentsDir = makeAgentsDir(dir); + writeAgent(agentsDir, "valid.md", "---\nname: Valid\n---\n"); + writeAgent(agentsDir, "ignored.txt", "should not appear"); + writeAgent(agentsDir, "also-ignored.json", '{"name":"json-agent"}'); + try { + var result = readProjectAgents(dir); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].slug, "valid"); + } finally { + rmDir(dir); + } +}); + +// ============================================================ +// Multiple agents — sorted by name +// ============================================================ + +test("readProjectAgents: returns multiple agents sorted by name", function () { + var dir = makeTmpProjectDir(); + var agentsDir = makeAgentsDir(dir); + writeAgent(agentsDir, "zebra.md", "---\nname: Zebra Agent\n---\n"); + writeAgent(agentsDir, "alpha.md", "---\nname: Alpha Agent\n---\n"); + writeAgent(agentsDir, "beta.md", "---\nname: Beta Agent\n---\n"); + try { + var result = readProjectAgents(dir); + assert.strictEqual(result.length, 3); + assert.strictEqual(result[0].name, "Alpha Agent"); + assert.strictEqual(result[1].name, "Beta Agent"); + assert.strictEqual(result[2].name, "Zebra Agent"); + } finally { + rmDir(dir); + } +}); + +// ============================================================ +// Agent with frontmatter but no description field +// ============================================================ + +test("readProjectAgents: description defaults to empty string when absent", function () { + var dir = makeTmpProjectDir(); + var agentsDir = makeAgentsDir(dir); + writeAgent(agentsDir, "nodesc.md", "---\nname: No Description Agent\n---\nBody."); + try { + var result = readProjectAgents(dir); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].description, ""); + } finally { + rmDir(dir); + } +}); From 2840d8a263ff33b002a1c9c34ba3196837622596 Mon Sep 17 00:00:00 2001 From: clagentic <10177887+akuehner@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:48:43 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(lr-c1a2):=20resolve=20Peaches=20nits=20?= =?UTF-8?q?=E2=80=94=20mutually=20exclusive=20menus,=20Codex=20dedup,=20tr?= =?UTF-8?q?aversal=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nit 1 + 3 (overlapping dropdowns + keyboard lock): in input.js, show the agent menu first when @ is typed; only fall through to showMentionMenu() if the agent menu ends up empty (isAgentMenuActive() returns false after showAgentMenu). The two menus are now mutually exclusive, so keyboard routing via agentMenuKeydown never blocks the mention menu. Nit 2 (Codex duplicate): remove CODEX_ENTRY from at-agents.js entirely. The agent @ menu now shows only named agents from .claude/agents/. Codex remains available via the mention menu (plain:codex), where its in-session semantics are correct. Also removes the unused store.js import and simplifies the _render badge to always emit "agent". Nit 4 (missing traversal test): add a test in agents-read-project.test.js proving that a traversal-style projectDir (e.g. paths with ../../ segments) passed to readProjectAgents() returns [] safely and never throws. Co-Authored-By: Claude Sonnet 4.6 --- lib/public/modules/at-agents.js | 73 +++++++++++--------------------- lib/public/modules/input.js | 12 ++++-- test/agents-read-project.test.js | 32 ++++++++++++++ 3 files changed, 65 insertions(+), 52 deletions(-) diff --git a/lib/public/modules/at-agents.js b/lib/public/modules/at-agents.js index 5648b343..408d0adb 100644 --- a/lib/public/modules/at-agents.js +++ b/lib/public/modules/at-agents.js @@ -1,32 +1,35 @@ // at-agents.js — inline @ mention dropdown for project-local agents (lr-c1a2). // // When the user types @ in the session input this module shows a small inline -// autocomplete dropdown listing: -// 1. All agents defined in .claude/agents/ for the active project (from the -// server via the get_agents / project_agents_list WS round-trip). -// 2. Codex (always shown unless the current session vendor is already codex). +// autocomplete dropdown listing all agents defined in .claude/agents/ for the +// active project (from the server via the get_agents / project_agents_list WS +// round-trip). // // Selecting an agent starts a new session with that agentName — identical to // what the "Agent Chat" button does in the sidebar. The @ text is cleared from // the input after selection (the session switch replaces the conversation). // -// The dropdown is hidden when the agent list is empty (no .claude/agents/ and -// codex not available). Graceful empty state: nothing shown, no errors. +// The dropdown is hidden when the agent list is empty (no .claude/agents/). +// When no named agents are installed, the @ keystroke falls through to the +// mention menu (mention.js), which handles @user / @plain-vendor (including +// plain:codex) sends within an existing session. // // Integration with input.js: -// - input.js calls showAgentMenu(query) when it detects an active @ and -// getAgentMentionMenuVisible() is true for keyboard routing. +// - input.js calls showAgentMenu(query) first; if the agent menu ends up +// empty (no matching agents) it falls through to showMentionMenu(query). +// - The two menus are mutually exclusive: if the agent menu is visible the +// mention menu is hidden, and vice versa. // - input.js calls hideAgentMenu() on Escape, slash menu open, or send. // - input.js checks isAgentMenuActive() before routing keydown events here. // // This module does NOT conflict with mention.js. Mention.js handles // @user / @plain-vendor sends within an existing session. This module handles -// @agent, which *starts a new session* rather than sending a mention. The two -// are mutually exclusive: agents never appear in mention.js's candidate list, -// and users/plain-vendors never appear in the at-agents dropdown. +// @agent, which *starts a new session* rather than sending a mention. Codex +// appears only in the mention menu (plain:codex) — it is NOT shown here so +// that the two entry points remain semantically distinct and Codex does not +// appear twice. import { escapeHtml } from './utils.js'; -import { store } from './store.js'; import { getWs } from './ws-ref.js'; // --- Module state --- @@ -39,14 +42,6 @@ var _menuVisible = false; var _menuBound = false; var _currentQuery = ""; -// --- Codex entry (always available when not already in a Codex session) --- -var CODEX_ENTRY = { - name: "Codex", - slug: "codex", - description: "OpenAI Codex (Codex CLI)", - _isCodex: true, -}; - // --- Public API --- export function initAtAgents(ctx) { @@ -164,10 +159,12 @@ function _ensureMenu() { function _buildItems(query) { var q = (query || "").toLowerCase().trim(); - var sessionVendor = store.get("currentVendor") || "claude"; var result = []; - // Project-local agents from .claude/agents/. + // Project-local named agents from .claude/agents/ only. + // Codex is intentionally excluded — it appears in the mention menu + // (plain:codex) where it has the correct in-session semantics. Showing it + // here as well would present two entry points with different behaviours. for (var i = 0; i < _agents.length; i++) { var a = _agents[i]; if (!a || !a.name) continue; @@ -176,15 +173,6 @@ function _buildItems(query) { result.push(a); } - // Codex entry — only when not already in a Codex session. - if (sessionVendor !== "codex") { - if (!q || - CODEX_ENTRY.name.toLowerCase().indexOf(q) !== -1 || - CODEX_ENTRY.description.toLowerCase().indexOf(q) !== -1) { - result.push(CODEX_ENTRY); - } - } - return result; } @@ -200,15 +188,11 @@ function _render(query) { var html = ""; for (var i = 0; i < _items.length; i++) { var item = _items[i]; - var isCodex = !!item._isCodex; - var badge = isCodex - ? 'codex' - : 'agent'; var desc = item.description ? escapeHtml(item.description) : ""; html += '
' + '' + escapeHtml(item.name) + '' + - badge + + 'agent' + (desc ? '' + desc + '' : '') + '
'; } @@ -253,18 +237,9 @@ function _activateItem(item) { _ctx.inputEl.selectionStart = _ctx.inputEl.selectionEnd = atIdx; } } - // Start (or switch to) the agent session. - if (item._isCodex) { - // Codex: open a new plain-codex session via vendor switch. - var ws = getWs(); - if (ws && ws.readyState === 1) { - ws.send(JSON.stringify({ type: "new_session", vendor: "codex" })); - } - } else { - // Named agent: open a new session with agentName. - var ws2 = getWs(); - if (ws2 && ws2.readyState === 1) { - ws2.send(JSON.stringify({ type: "new_session", agentName: item.name })); - } + // Start (or switch to) the named agent session. + var ws = getWs(); + if (ws && ws.readyState === 1) { + ws.send(JSON.stringify({ type: "new_session", agentName: item.name })); } } diff --git a/lib/public/modules/input.js b/lib/public/modules/input.js index 529bcbd1..730441aa 100644 --- a/lib/public/modules/input.js +++ b/lib/public/modules/input.js @@ -1006,10 +1006,16 @@ export function initInput(_ctx) { var mentionCheck = checkForMention(val, ctx.inputEl.selectionStart); if (mentionCheck.active) { setMentionAtIdx(mentionCheck.startIdx); - // Show agent menu (project agents + Codex) for the @ trigger. - // Also show the user mention menu for user-to-user @mentions. + // Agent menu takes priority: show it first. If it ends up empty (no + // named agents match the query) fall through to the mention menu so + // @user / plain:codex are still reachable. The two menus are mutually + // exclusive — whichever is shown hides the other. showAgentMenu(mentionCheck.query); - showMentionMenu(mentionCheck.query); + if (isAgentMenuActive()) { + hideMentionMenu(); + } else { + showMentionMenu(mentionCheck.query); + } } else { hideAgentMenu(); hideMentionMenu(); diff --git a/test/agents-read-project.test.js b/test/agents-read-project.test.js index bca44346..455dcca8 100644 --- a/test/agents-read-project.test.js +++ b/test/agents-read-project.test.js @@ -198,3 +198,35 @@ test("readProjectAgents: description defaults to empty string when absent", func rmDir(dir); } }); + +// ============================================================ +// Traversal-style projectDir — belt-and-suspenders safety +// ============================================================ + +test("readProjectAgents: returns [] for traversal-style path (e.g. ../../etc)", function () { + // The input is server-controlled, but this locks the contract: a path that + // looks like a traversal attempt must never throw and must return []. + // + // We use absolute paths to nonexistent directories so the test is + // deterministic regardless of cwd or what happens to exist on the host. + // path.join("/nonexistent-lr-c1a2-traversal/../../etc", ".claude", "agents") + // normalises to a path under a nonexistent root, so readdirSync throws ENOENT + // and readProjectAgents returns [] without propagating the error. + // path.join() normalises traversal segments before readdirSync sees the path, + // so these inputs exercise the traversal code path without any existing + // .claude/agents/ directory to accidentally read. All resolved targets are + // guaranteed non-existent by using a unique sentinel prefix. + var traversalPaths = [ + "/nonexistent-lr-c1a2-sentinel-xq7/../../nonexistent-lr-c1a2-sentinel-xq7", + "/nonexistent-lr-c1a2-sentinel-xq7/../../../nonexistent-lr-c1a2-sentinel-xq7", + ]; + for (var i = 0; i < traversalPaths.length; i++) { + var result; + var tpath = traversalPaths[i]; + assert.doesNotThrow(function () { + result = readProjectAgents(tpath); + }, "readProjectAgents must not throw for traversal-style path: " + tpath); + assert.deepStrictEqual(result, [], + "readProjectAgents must return [] for traversal-style path: " + tpath); + } +});