Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ DOCS=(
"README.md"
"README-VN.md"
"README-CN.md"
"README-KO.md"
"ARCHITECTURE.md"
"index.html"
"wiki/index.html"
Expand Down
58 changes: 52 additions & 6 deletions scripts/seed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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");
Expand Down Expand Up @@ -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: [] };

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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}`);
}

Expand Down
62 changes: 61 additions & 1 deletion server/__tests__/cc-discovery-helpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -18,7 +20,9 @@ const {
parseFrontmatter,
redactSettings,
isUnder,
isFileLike,
readSkills,
readAgents,
MAX_FILE_BYTES,
HOOK_EVENT_TYPES,
} = require("../lib/cc-discovery");
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 24 additions & 4 deletions server/lib/cc-discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -806,6 +825,7 @@ module.exports = {
parseFrontmatter,
redactSettings,
isUnder,
isFileLike,
MAX_FILE_BYTES,
HOOK_EVENT_TYPES,
};
Loading