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" 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}`); } 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, };