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..408d0adb
--- /dev/null
+++ b/lib/public/modules/at-agents.js
@@ -0,0 +1,245 @@
+// 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 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/).
+// 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) 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. 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 { 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 = "";
+
+// --- 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 result = [];
+
+ // 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;
+ if (q && a.name.toLowerCase().indexOf(q) === -1 &&
+ (a.description || "").toLowerCase().indexOf(q) === -1) continue;
+ result.push(a);
+ }
+
+ 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 desc = item.description ? escapeHtml(item.description) : "";
+ html +=
+ '
' +
+ '' + escapeHtml(item.name) + '' +
+ 'agent' +
+ (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 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 3bd5d3fc..730441aa 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,35 @@ 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);
- showMentionMenu(mentionCheck.query);
+ // 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);
+ if (isAgentMenuActive()) {
+ hideMentionMenu();
+ } else {
+ showMentionMenu(mentionCheck.query);
+ }
} else {
+ hideAgentMenu();
hideMentionMenu();
}
} else {
+ hideAgentMenu();
hideMentionMenu();
}
}
@@ -1017,7 +1035,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..455dcca8
--- /dev/null
+++ b/test/agents-read-project.test.js
@@ -0,0 +1,232 @@
+// 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);
+ }
+});
+
+// ============================================================
+// 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);
+ }
+});