From 67df3cb405f3152ee7e35f227af6176eab8057ac Mon Sep 17 00:00:00 2001 From: Son Nguyen Date: Wed, 15 Jul 2026 21:37:51 -0700 Subject: [PATCH 1/3] fix(cc-discovery): follow symlinked agent/command/hook markdown files Completes the symlink support #232 added for skill DIRECTORIES: the same Dirent quirk (ent.isFile() is false for a symlink pointing at a regular file) still hid symlinked .md files from every file-enumeration site, so an agent, command, output style, or hook script linked into ~/.claude via ln -s was invisible to the Config Explorer while Claude Code itself resolves and uses it. Adds an isFileLike(ent, absPath) counterpart to isDirLike (statSync follows the link; broken symlinks are non-files, never throw) and uses it at all four sites: readMdFilesAt (agents/commands/output-styles), countMdIn (plugin contribution counts), the plugin hooks counter, and readHookScripts. Regular files short-circuit on ent.isFile(), so the non-symlink path costs no extra stat. Tests: symlinked agent .md discovered, broken .md symlink skipped without throwing, plus direct isFileLike unit coverage (file symlink true, directory symlink false). Co-Authored-By: Claude Fable 5 --- server/__tests__/cc-discovery-helpers.test.js | 62 ++++++++++++++++++- server/lib/cc-discovery.js | 28 +++++++-- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/server/__tests__/cc-discovery-helpers.test.js b/server/__tests__/cc-discovery-helpers.test.js index fec4e453..f5d95a00 100644 --- a/server/__tests__/cc-discovery-helpers.test.js +++ b/server/__tests__/cc-discovery-helpers.test.js @@ -2,7 +2,9 @@ * @file cc-discovery-helpers.test.js * @description Direct unit tests for the exported helpers in * `server/lib/cc-discovery.js`: parseFrontmatter, redactSettings, isUnder, - * and the MAX_FILE_BYTES constant. The integration tests in + * isFileLike, the symlinked skill-directory and agent/command markdown-file + * discovery paths (readSkills / readAgents), and the MAX_FILE_BYTES + * constant. The integration tests in * cc-config.test.js exercise these indirectly through HTTP routes; this * file pins down their behavior at the function level so future refactors * surface regressions immediately. @@ -18,7 +20,9 @@ const { parseFrontmatter, redactSettings, isUnder, + isFileLike, readSkills, + readAgents, MAX_FILE_BYTES, HOOK_EVENT_TYPES, } = require("../lib/cc-discovery"); @@ -252,6 +256,62 @@ describe("readSkills (symlinked skill directories)", () => { }); }); +describe("readAgents (symlinked markdown files)", () => { + it("includes an agent .md that is a symlink to a real file", () => { + // Same Dirent quirk as symlinked skill directories, but for files: + // ent.isFile() is false for a symlink pointing at a regular file, so a + // version-controlled agent linked into .claude/agents/ was invisible. + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-file-symlink-")); + const realFile = path.join(tmp, "repo", "reviewer.md"); + fs.mkdirSync(path.dirname(realFile), { recursive: true }); + fs.writeFileSync(realFile, "---\nname: reviewer\ndescription: Reviews diffs\n---\n\nBody.\n"); + const projectRoot = path.join(tmp, "project"); + const agentsDir = path.join(projectRoot, ".claude", "agents"); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.symlinkSync(realFile, path.join(agentsDir, "reviewer.md"), "file"); + + const items = readAgents({ scope: "project", cwd: projectRoot }); + const names = items.map((a) => a.name); + assert.ok(names.includes("reviewer"), `expected symlinked agent in ${names}`); + }); + + it("skips a broken .md symlink without throwing", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-file-broken-")); + const projectRoot = path.join(tmp, "project"); + const agentsDir = path.join(projectRoot, ".claude", "agents"); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.symlinkSync(path.join(tmp, "gone.md"), path.join(agentsDir, "ghost.md"), "file"); + + let items; + assert.doesNotThrow(() => { + items = readAgents({ scope: "project", cwd: projectRoot }); + }); + assert.ok(!items.some((a) => /ghost/.test(a.file || ""))); + }); +}); + +describe("isFileLike", () => { + it("true for a regular file dirent", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-isfilelike-")); + fs.writeFileSync(path.join(tmp, "a.md"), "x"); + const ent = fs.readdirSync(tmp, { withFileTypes: true })[0]; + assert.equal(isFileLike(ent, path.join(tmp, ent.name)), true); + }); + + it("true for a symlink resolving to a file, false for one resolving to a directory", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-isfilelike-sym-")); + fs.writeFileSync(path.join(tmp, "real.md"), "x"); + fs.mkdirSync(path.join(tmp, "realdir")); + fs.symlinkSync(path.join(tmp, "real.md"), path.join(tmp, "file-link.md"), "file"); + fs.symlinkSync(path.join(tmp, "realdir"), path.join(tmp, "dir-link"), "dir"); + const ents = Object.fromEntries( + fs.readdirSync(tmp, { withFileTypes: true }).map((e) => [e.name, e]) + ); + assert.equal(isFileLike(ents["file-link.md"], path.join(tmp, "file-link.md")), true); + assert.equal(isFileLike(ents["dir-link"], path.join(tmp, "dir-link")), false); + }); +}); + describe("module exports", () => { it("MAX_FILE_BYTES is 256 KB", () => { assert.equal(MAX_FILE_BYTES, 256 * 1024); diff --git a/server/lib/cc-discovery.js b/server/lib/cc-discovery.js index c76204df..e75a5474 100644 --- a/server/lib/cc-discovery.js +++ b/server/lib/cc-discovery.js @@ -157,6 +157,24 @@ function isDirLike(ent, absPath) { } } +/** + * File counterpart of {@link isDirLike}: true if `ent` is a regular file, OR a + * symlink that resolves to one. Same Dirent quirk — `ent.isFile()` returns + * false for a symlink even when it points at a file, so agents/commands/hook + * scripts installed via `ln -s` were invisible to the Config Explorer while + * Claude Code itself resolves and uses them. Broken symlinks are treated as + * non-files rather than throwing. + */ +function isFileLike(ent, absPath) { + if (ent.isFile()) return true; + if (!ent.isSymbolicLink()) return false; + try { + return fs.statSync(absPath).isFile(); + } catch { + return false; + } +} + // ── Skills ────────────────────────────────────────────────────────────── function readSkillsAt(scope, claudeDir) { @@ -203,8 +221,9 @@ function readMdFilesAt(scope, claudeDir, subdir) { const entries = listDir(dir); const out = []; for (const ent of entries) { - if (!ent.isFile() || !ent.name.endsWith(".md")) continue; + if (!ent.name.endsWith(".md")) continue; const file = path.join(dir, ent.name); + if (!isFileLike(ent, file)) continue; const read = safeReadText(file); if (!read) continue; const { frontmatter, body } = parseFrontmatter(read.text); @@ -245,7 +264,7 @@ function countMdIn(dir) { try { return fs .readdirSync(dir, { withFileTypes: true }) - .filter((e) => e.isFile() && e.name.endsWith(".md")).length; + .filter((e) => e.name.endsWith(".md") && isFileLike(e, path.join(dir, e.name))).length; } catch { return 0; } @@ -284,7 +303,7 @@ function readPluginContributions(installPath) { try { return fs .readdirSync(path.join(installPath, "hooks"), { withFileTypes: true }) - .filter((e) => e.isFile()).length; + .filter((e) => isFileLike(e, path.join(installPath, "hooks", e.name))).length; } catch { return 0; } @@ -602,7 +621,7 @@ function readHookScripts() { return { dir, items: entries - .filter((e) => e.isFile()) + .filter((e) => isFileLike(e, path.join(dir, e.name))) .map((e) => { const file = path.join(dir, e.name); let stat; @@ -806,6 +825,7 @@ module.exports = { parseFrontmatter, redactSettings, isUnder, + isFileLike, MAX_FILE_BYTES, HOOK_EVENT_TYPES, }; From 6a0ae9e9d1e9afd39ad5e2623194b6b724f62245 Mon Sep 17 00:00:00 2001 From: Son Nguyen Date: Wed, 15 Jul 2026 21:39:47 -0700 Subject: [PATCH 2/3] feat(seed): add a stable waiting-on-input fixture that exercises the Waiting UI The two existing stable fixtures only cover working/idle agent states, so a freshly seeded dashboard never shows the Waiting overlay - the yellow badge, the awaiting_reason chip/tooltip (urgent "notification" -> amber), the Kanban Waiting column, or SessionDetail's waiting-for-input banner all stayed invisible in demos. Adds a third idempotent fixture (demo-waiting-...): an active session whose main agent is blocked on a permission prompt, with awaiting_input_since stamped ~4 minutes in the past (so the banner's "how long" readout renders something real) and awaiting_reason = "notification". Wired into FIXTURE_SESSION_IDS so --reset cleans it up like the others, and listed in the printed test URLs. Verified against a scratch DB: insert -> correct session/agent rows (awaiting_input_since + awaiting_reason set on both), re-run -> skipped (idempotent). Co-Authored-By: Claude Fable 5 --- scripts/seed.js | 58 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/scripts/seed.js b/scripts/seed.js index 9ae49b26..f7b029c8 100644 --- a/scripts/seed.js +++ b/scripts/seed.js @@ -4,9 +4,12 @@ * Seeds the database with sample data for development and demo purposes. * * Default behavior is ADDITIVE and IDEMPOTENT: - * node scripts/seed.js Insert the two stable test fixtures - * (single-agent + deeply-nested). Re-runs - * are no-ops if fixtures already exist. + * node scripts/seed.js Insert the three stable test fixtures + * (single-agent, deeply-nested, and a + * waiting-on-input session that exercises + * the Waiting badge / reason chip / + * banner UI). Re-runs are no-ops if + * fixtures already exist. * * node scripts/seed.js --full Also insert the random/demo sessions * (old behavior; produces unbounded data @@ -35,6 +38,10 @@ const FIXTURES = { sessionId: "demo-solo-0001-0001-0001-000000000001", mainAgentId: "demo-solo-0001-main", }, + waiting: { + sessionId: "demo-waiting-0001-0001-0001-000000000001", + mainAgentId: "demo-waiting-0001-main", + }, nested: { sessionId: "demo-nested-0001-0001-0001-000000000001", mainAgentId: "demo-nested-0001-main", @@ -51,7 +58,11 @@ const FIXTURES = { }, }; -const FIXTURE_SESSION_IDS = [FIXTURES.solo.sessionId, FIXTURES.nested.sessionId]; +const FIXTURE_SESSION_IDS = [ + FIXTURES.solo.sessionId, + FIXTURES.waiting.sessionId, + FIXTURES.nested.sessionId, +]; const args = new Set(process.argv.slice(2)); const FULL = args.has("--full"); @@ -137,7 +148,7 @@ function deleteFixtureRows() { tx(); } -// ── Stable fixtures (the two test cases for AgentCard click behavior) ────── +// ── Stable fixtures (AgentCard click behavior + the Waiting overlay demo) ── function seedFixtures() { const result = { inserted: [], skipped: [] }; @@ -172,7 +183,41 @@ function seedFixtures() { result.inserted.push("Single Agent: Quick Hotfix"); } - // 2. Deeply-nested session (depth 4, branching — click PARENT toggles, LEAF navigates) + // 2. Waiting-on-input session — exercises the yellow Waiting overlay end + // to end: awaiting_input_since + awaiting_reason drive the Waiting + // badge, the reason chip/tooltip (urgent "notification" → amber), the + // Kanban Waiting column, and SessionDetail's waiting-for-input banner. + if (sessionExists(FIXTURES.waiting.sessionId)) { + result.skipped.push("Waiting Demo: Permission Prompt"); + } else { + stmts.insertSession.run( + FIXTURES.waiting.sessionId, + "Waiting Demo: Permission Prompt", + "active", + "/home/dev/waiting-demo", + "claude-opus-4-6", + null + ); + stmts.insertAgent.run( + FIXTURES.waiting.mainAgentId, + FIXTURES.waiting.sessionId, + "Main Agent", + "main", + null, + "waiting", + "Blocked on a permission prompt (Bash: npm publish)", + null, + null + ); + // Stamp the awaiting overlay a few minutes in the past so the banner's + // "how long" readout shows something real on first render. + const awaitingTs = new Date(Date.now() - 4 * 60 * 1000).toISOString(); + stmts.setSessionAwaitingInput.run(awaitingTs, "notification", FIXTURES.waiting.sessionId); + stmts.setAgentAwaitingInput.run(awaitingTs, "notification", FIXTURES.waiting.mainAgentId); + result.inserted.push("Waiting Demo: Permission Prompt"); + } + + // 3. Deeply-nested session (depth 4, branching — click PARENT toggles, LEAF navigates) if (sessionExists(FIXTURES.nested.sessionId)) { result.skipped.push("Deep Nesting: Multi-Agent Research Pipeline"); } else { @@ -582,6 +627,7 @@ function main() { console.log(""); console.log("Test URLs:"); console.log(` Single-agent: /sessions/${FIXTURES.solo.sessionId}`); + console.log(` Waiting (reason): /sessions/${FIXTURES.waiting.sessionId}`); console.log(` Nested (depth 4): /sessions/${FIXTURES.nested.sessionId}`); } From de4b19431db59112547dec35c71d947c07234937 Mon Sep 17 00:00:00 2001 From: Son Nguyen Date: Wed, 15 Jul 2026 21:40:21 -0700 Subject: [PATCH 3/3] chore(skills): include README-KO.md in the doc-coverage matrix The update-project-docs skill's change->docs mapping already lists README-KO.md as part of the canonical translated doc set, but the doc-coverage.sh verification matrix never checked it - so a term missing from the Korean README scored a clean matrix and the drift went unnoticed (it had to be checked by hand during recent sweeps). Adds the README-KO.md row right after the other translations. Verified: `doc-coverage.sh awaiting_reason` now reports the KO column (6 hits). Co-Authored-By: Claude Fable 5 --- .claude/skills/update-project-docs/scripts/doc-coverage.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/skills/update-project-docs/scripts/doc-coverage.sh b/.claude/skills/update-project-docs/scripts/doc-coverage.sh index 19b13447..b9758e0a 100755 --- a/.claude/skills/update-project-docs/scripts/doc-coverage.sh +++ b/.claude/skills/update-project-docs/scripts/doc-coverage.sh @@ -20,6 +20,7 @@ DOCS=( "README.md" "README-VN.md" "README-CN.md" + "README-KO.md" "ARCHITECTURE.md" "index.html" "wiki/index.html"