feat(codex): clean up orphan sidecar dirs for retained agents

When writing Codex custom agents, delete any same-basename sibling
directory under agentsRoot if the retained agent declares zero
sidecarDirs. Previously, cleanupRemovedAgents only swept the sibling
directory when the TOML itself was being removed — a same-name agent
that lost its sidecar between plugin versions left an orphan directory
indefinitely.

The new sweep is narrowly scoped: it only runs against base names of
agents in the current install and only when the agent declares no
sidecars, so unrelated directories under agentsRoot are untouched.
Gated by isSafeManagedPath to match the existing cleanup safety
pattern.

Tests cover the three scenarios: orphan removed when agent has no
sidecars, sidecar preserved when agent declares one, unrelated
directory left alone.

Per docs/plans/2026-04-21-001-refactor-flatten-agents-directory-plan.md
Unit 4b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trevin Chow
2026-04-21 01:59:43 -07:00
parent 1726bd1c64
commit 99284a64c6
2 changed files with 92 additions and 2 deletions
+11 -2
View File
@@ -91,10 +91,19 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle):
await cleanupRemovedAgents(agentsRoot, manifest, currentAgents)
if (agents.length > 0) {
for (const agent of agents) {
const agentFile = `${sanitizePathName(agent.name)}.toml`
const agentBaseName = sanitizePathName(agent.name)
const agentFile = `${agentBaseName}.toml`
// If the agent declares no sidecars, remove any same-basename sibling
// directory left behind by a prior install that did. The manifest-driven
// cleanupRemovedAgents sweep above only removes the sibling dir when the
// TOML itself is being removed; a same-name agent that loses its sidecar
// would otherwise leave an orphan directory.
if ((agent.sidecarDirs ?? []).length === 0 && isSafeManagedPath(agentsRoot, agentBaseName)) {
await fs.rm(path.join(agentsRoot, agentBaseName), { recursive: true, force: true })
}
await writeText(path.join(agentsRoot, agentFile), renderCodexAgentToml(agent) + "\n")
for (const sidecar of agent.sidecarDirs ?? []) {
await copyDir(sidecar.sourceDir, path.join(agentsRoot, sanitizePathName(agent.name), sidecar.targetName))
await copyDir(sidecar.sourceDir, path.join(agentsRoot, agentBaseName, sidecar.targetName))
}
}
}
+81
View File
@@ -765,6 +765,87 @@ Workflow handoff:
expect(installedSkill).not.toContain("/prompts:settings")
expect(installedSkill).not.toContain("https://prompts:www.proofeditor.ai")
})
test("removes orphan sidecar dir when retained agent declares no sidecars", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-test-"))
const agentsRoot = path.join(tempRoot, ".codex", "agents")
const orphanDir = path.join(agentsRoot, "ce-foo", "stale-content")
await fs.mkdir(orphanDir, { recursive: true })
await fs.writeFile(path.join(orphanDir, "leftover.txt"), "stale", "utf8")
await fs.writeFile(path.join(agentsRoot, "ce-foo.toml"), "old-toml", "utf8")
const bundle: CodexBundle = {
prompts: [],
skillDirs: [],
generatedSkills: [],
agents: [
{
name: "ce-foo",
description: "Foo agent",
instructions: "Do foo.",
},
],
mcpServers: {},
}
await writeCodexBundle(tempRoot, bundle)
expect(await entryExists(path.join(agentsRoot, "ce-foo"))).toBe(false)
expect(await exists(path.join(agentsRoot, "ce-foo.toml"))).toBe(true)
})
test("keeps sidecar dir when retained agent declares sidecars", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-test-"))
const sidecarSource = await fs.mkdtemp(path.join(os.tmpdir(), "codex-sidecar-src-"))
await fs.writeFile(path.join(sidecarSource, "script.sh"), "#!/bin/sh\necho hi\n", "utf8")
const bundle: CodexBundle = {
prompts: [],
skillDirs: [],
generatedSkills: [],
agents: [
{
name: "ce-foo",
description: "Foo agent",
instructions: "Do foo.",
sidecarDirs: [{ sourceDir: sidecarSource, targetName: "scripts" }],
},
],
mcpServers: {},
}
await writeCodexBundle(tempRoot, bundle)
const agentsRoot = path.join(tempRoot, ".codex", "agents")
expect(await exists(path.join(agentsRoot, "ce-foo.toml"))).toBe(true)
expect(await exists(path.join(agentsRoot, "ce-foo", "scripts", "script.sh"))).toBe(true)
})
test("leaves unrelated directories under agentsRoot alone", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-test-"))
const agentsRoot = path.join(tempRoot, ".codex", "agents")
const unrelatedDir = path.join(agentsRoot, "ce-bar-extra")
await fs.mkdir(unrelatedDir, { recursive: true })
await fs.writeFile(path.join(unrelatedDir, "keep-me.txt"), "keep", "utf8")
const bundle: CodexBundle = {
prompts: [],
skillDirs: [],
generatedSkills: [],
agents: [
{
name: "ce-foo",
description: "Foo agent",
instructions: "Do foo.",
},
],
mcpServers: {},
}
await writeCodexBundle(tempRoot, bundle)
expect(await exists(path.join(unrelatedDir, "keep-me.txt"))).toBe(true)
})
})
describe("renderCodexConfig", () => {