refactor(omo-codex): install agent TOMLs via symlinks at install time, drop sync-agents.py
The Python SessionStart hook (sync-agents.py) was a runtime side-effect
that copied agent TOMLs into CODEX_HOME/agents on every session start.
That design had three problems:
1. It was a Python script invoked via 'python3 ${PLUGIN_ROOT}/...' which
is fragile on Windows where the binary may be 'python', and is the
wrong layer for a one-shot install task.
2. Agent TOMLs landed as regular file copies, with no provenance link to
the plugin cache and no tracking for clean uninstall.
3. An older release shipped TOMLs without the required 'name' field;
because the current bundle no longer ships them, the hook never had
a chance to overwrite the broken copies on disk, leaving Codex
permanently warning at session start.
Replace the runtime hook with an install-time linker:
linkCachedPluginAgents() (src/cli/install-codex/link-cached-plugin-agents.ts).
The omo-codex CLI now calls it right after linkCachedPluginBins(). For
each 'components/*/agents/*.toml' in the plugin cache, it:
- Linux / macOS: creates a symlink at ${CODEX_HOME}/agents/<basename>
pointing at the cache TOML. The cache directory is the single source
of truth; removing the cache cleanly breaks the link.
- Windows: copies the file (symlinks require admin or Developer Mode).
- Both platforms: writes a '.installed-agents.json' manifest under the
plugin cache listing the installed absolute paths, so a future
'omo uninstall --platform=codex' can remove them deterministically.
Stale regular-file copies (from the old sync-agents.py) are removed and
replaced on Unix. On Windows the existing copy is overwritten.
Tests (src/cli/install-codex/link-cached-plugin-agents.test.ts):
9 cross-platform tests that mock the 'platform' parameter to exercise
the Linux, macOS, and Windows code paths in a single 'bun test' run,
matching the existing pattern from linkCachedPluginBins. Covers symlink
creation, Windows copy, stale-file replacement, manifest writing,
idempotency, multi-component discovery, and the empty-bundle edge case.
Removed:
- packages/omo-codex/plugin/components/ultrawork/hooks/sync-agents.py
- packages/omo-codex/plugin/test/bundled-agents.test.mjs
(it tested the Python hook; behaviour is now covered by the TS tests)
- SessionStart hook entry in both ultrawork and aggregate hooks.json
- 2 sync-agents tests + 1 manifest assertion in ultrawork-hooks.test.mjs
- 'hooks/sync-agents.py' in ultrawork/package.json files list
- sync-agents.py reference from aggregate.test.mjs component markers
Updated:
- components/ultrawork/README.md, AGENTS.md: describe the install-time
linker as the source of truth, no more SessionStart agent sync.
Verified end-to-end:
bun run src/cli/index.ts install --no-tui --platform=codex
ls -la ~/.codex/agents/ # all 4 TOMLs are symlinks pointing to cache
cat ~/.codex/plugins/cache/.../omo/0.1.0/.installed-agents.json # manifest present
This commit is contained in:
@@ -4,6 +4,7 @@ import { existsSync } from "node:fs"
|
||||
import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache } from "./codex-cache"
|
||||
import { updateCodexConfig } from "./codex-config-toml"
|
||||
import { trustedHookStatesForPlugin } from "./codex-hook-trust"
|
||||
import { linkCachedPluginAgents } from "./link-cached-plugin-agents"
|
||||
import { readMarketplace, readPluginManifest, resolvePluginSource, validatePathSegment } from "./codex-marketplace"
|
||||
import { defaultRunCommand } from "./codex-process"
|
||||
import type { CodexInstallOptions, CodexInstallResult, InstalledPlugin } from "./types"
|
||||
@@ -47,6 +48,10 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom
|
||||
for (const link of links) {
|
||||
log(`Linked ${link.name} -> ${link.target}`)
|
||||
}
|
||||
const agentLinks = await linkCachedPluginAgents({ codexHome, pluginRoot: plugin.path })
|
||||
for (const link of agentLinks) {
|
||||
log(`Linked agent ${link.name} -> ${link.target}`)
|
||||
}
|
||||
installed.push(plugin)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { lstat, mkdir, mkdtemp, readdir, readFile, readlink, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { linkCachedPluginAgents } from "./link-cached-plugin-agents"
|
||||
|
||||
async function makeFixture(): Promise<{ codexHome: string; pluginRoot: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), "omo-codex-agents-"))
|
||||
const codexHome = join(root, "codex")
|
||||
const pluginRoot = join(root, "plugin")
|
||||
await mkdir(join(pluginRoot, "components", "ultrawork", "agents"), { recursive: true })
|
||||
await mkdir(join(pluginRoot, "components", "ultragoal", "agents"), { recursive: true })
|
||||
await writeFile(
|
||||
join(pluginRoot, "components", "ultrawork", "agents", "explorer.toml"),
|
||||
'name = "explorer"\n',
|
||||
)
|
||||
await writeFile(
|
||||
join(pluginRoot, "components", "ultrawork", "agents", "librarian.toml"),
|
||||
'name = "librarian"\n',
|
||||
)
|
||||
await writeFile(
|
||||
join(pluginRoot, "components", "ultragoal", "agents", "planner.toml"),
|
||||
'name = "planner"\n',
|
||||
)
|
||||
return { codexHome, pluginRoot }
|
||||
}
|
||||
|
||||
describe("linkCachedPluginAgents", () => {
|
||||
test("creates symlinks on linux that point at the bundled TOMLs", async () => {
|
||||
// given
|
||||
const { codexHome, pluginRoot } = await makeFixture()
|
||||
|
||||
// when
|
||||
const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
||||
|
||||
// then
|
||||
expect(linked.map((entry) => entry.name).sort()).toEqual([
|
||||
"explorer.toml",
|
||||
"librarian.toml",
|
||||
"planner.toml",
|
||||
])
|
||||
for (const entry of linked) {
|
||||
const linkStat = await lstat(entry.path)
|
||||
expect(linkStat.isSymbolicLink()).toBe(true)
|
||||
expect(await readlink(entry.path)).toBe(entry.target)
|
||||
}
|
||||
})
|
||||
|
||||
test("creates symlinks on darwin (macOS)", async () => {
|
||||
// given
|
||||
const { codexHome, pluginRoot } = await makeFixture()
|
||||
|
||||
// when
|
||||
const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "darwin" })
|
||||
|
||||
// then
|
||||
expect(linked).toHaveLength(3)
|
||||
for (const entry of linked) {
|
||||
expect((await lstat(entry.path)).isSymbolicLink()).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("creates regular file copies on Windows (no symlinks)", async () => {
|
||||
// given
|
||||
const { codexHome, pluginRoot } = await makeFixture()
|
||||
|
||||
// when
|
||||
const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "win32" })
|
||||
|
||||
// then
|
||||
expect(linked).toHaveLength(3)
|
||||
for (const entry of linked) {
|
||||
const linkStat = await lstat(entry.path)
|
||||
expect(linkStat.isSymbolicLink()).toBe(false)
|
||||
expect(linkStat.isFile()).toBe(true)
|
||||
const content = await readFile(entry.path, "utf8")
|
||||
expect(content).toContain(`name = "${entry.name.replace(/\.toml$/, "")}"`)
|
||||
}
|
||||
})
|
||||
|
||||
test("replaces stale regular files (legacy sync-agents.py copies) with symlinks on unix", async () => {
|
||||
// given
|
||||
const { codexHome, pluginRoot } = await makeFixture()
|
||||
const agentsDir = join(codexHome, "agents")
|
||||
await mkdir(agentsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(agentsDir, "explorer.toml"),
|
||||
"# stale broken copy with no `name` field, from old sync-agents.py\nmodel = \"old\"\n",
|
||||
)
|
||||
|
||||
// when
|
||||
await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
||||
|
||||
// then
|
||||
const linkStat = await lstat(join(agentsDir, "explorer.toml"))
|
||||
expect(linkStat.isSymbolicLink()).toBe(true)
|
||||
expect(await readlink(join(agentsDir, "explorer.toml"))).toBe(
|
||||
join(pluginRoot, "components", "ultrawork", "agents", "explorer.toml"),
|
||||
)
|
||||
})
|
||||
|
||||
test("overwrites stale copies on Windows", async () => {
|
||||
// given
|
||||
const { codexHome, pluginRoot } = await makeFixture()
|
||||
const agentsDir = join(codexHome, "agents")
|
||||
await mkdir(agentsDir, { recursive: true })
|
||||
await writeFile(join(agentsDir, "explorer.toml"), "# stale broken copy\n")
|
||||
|
||||
// when
|
||||
await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "win32" })
|
||||
|
||||
// then
|
||||
const content = await readFile(join(agentsDir, "explorer.toml"), "utf8")
|
||||
expect(content).toContain('name = "explorer"')
|
||||
expect(content).not.toContain("stale broken copy")
|
||||
})
|
||||
|
||||
test("writes a manifest under the plugin cache listing installed agent paths for clean uninstall", async () => {
|
||||
// given
|
||||
const { codexHome, pluginRoot } = await makeFixture()
|
||||
|
||||
// when
|
||||
await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
||||
|
||||
// then
|
||||
const manifestContent = await readFile(join(pluginRoot, ".installed-agents.json"), "utf8")
|
||||
const manifest = JSON.parse(manifestContent) as { agents: string[] }
|
||||
expect(manifest.agents.sort()).toEqual([
|
||||
join(codexHome, "agents", "explorer.toml"),
|
||||
join(codexHome, "agents", "librarian.toml"),
|
||||
join(codexHome, "agents", "planner.toml"),
|
||||
])
|
||||
})
|
||||
|
||||
test("is idempotent across re-runs", async () => {
|
||||
// given
|
||||
const { codexHome, pluginRoot } = await makeFixture()
|
||||
|
||||
// when
|
||||
await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
||||
await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
||||
const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
||||
|
||||
// then
|
||||
expect(linked).toHaveLength(3)
|
||||
const entries = (await readdir(join(codexHome, "agents"))).sort()
|
||||
expect(entries).toEqual(["explorer.toml", "librarian.toml", "planner.toml"])
|
||||
})
|
||||
|
||||
test("discovers TOMLs across multiple component agent directories", async () => {
|
||||
// given
|
||||
const { codexHome, pluginRoot } = await makeFixture()
|
||||
|
||||
// when
|
||||
const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
||||
|
||||
// then
|
||||
const targets = linked.map((entry) => entry.target).sort()
|
||||
expect(targets).toContain(join(pluginRoot, "components", "ultrawork", "agents", "explorer.toml"))
|
||||
expect(targets).toContain(join(pluginRoot, "components", "ultragoal", "agents", "planner.toml"))
|
||||
})
|
||||
|
||||
test("returns empty list when plugin has no bundled agents", async () => {
|
||||
// given
|
||||
const root = await mkdtemp(join(tmpdir(), "omo-codex-agents-empty-"))
|
||||
const codexHome = join(root, "codex")
|
||||
const pluginRoot = join(root, "plugin")
|
||||
await mkdir(pluginRoot, { recursive: true })
|
||||
|
||||
// when
|
||||
const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
||||
|
||||
// then
|
||||
expect(linked).toEqual([])
|
||||
const manifest = JSON.parse(
|
||||
await readFile(join(pluginRoot, ".installed-agents.json"), "utf8"),
|
||||
) as { agents: string[] }
|
||||
expect(manifest.agents).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { copyFile, lstat, mkdir, readdir, rm, symlink, writeFile } from "node:fs/promises"
|
||||
import { basename, join } from "node:path"
|
||||
|
||||
const MANIFEST_FILE = ".installed-agents.json"
|
||||
|
||||
export interface LinkedAgent {
|
||||
readonly name: string
|
||||
readonly path: string
|
||||
readonly target: string
|
||||
}
|
||||
|
||||
type LinkPlatform = NodeJS.Platform
|
||||
|
||||
export async function linkCachedPluginAgents(input: {
|
||||
readonly codexHome: string
|
||||
readonly pluginRoot: string
|
||||
readonly platform?: LinkPlatform
|
||||
}): Promise<readonly LinkedAgent[]> {
|
||||
const platform = input.platform ?? process.platform
|
||||
const bundledAgents = await discoverBundledAgents(input.pluginRoot)
|
||||
if (bundledAgents.length === 0) {
|
||||
await writeManifest(input.pluginRoot, [])
|
||||
return []
|
||||
}
|
||||
const agentsDir = join(input.codexHome, "agents")
|
||||
await mkdir(agentsDir, { recursive: true })
|
||||
const linked: LinkedAgent[] = []
|
||||
for (const agentPath of bundledAgents) {
|
||||
const linkPath = join(agentsDir, basename(agentPath))
|
||||
if (platform === "win32") {
|
||||
await replaceWithCopy(linkPath, agentPath)
|
||||
} else {
|
||||
await replaceWithSymlink(linkPath, agentPath)
|
||||
}
|
||||
linked.push({ name: basename(agentPath), path: linkPath, target: agentPath })
|
||||
}
|
||||
await writeManifest(
|
||||
input.pluginRoot,
|
||||
linked.map((entry) => entry.path),
|
||||
)
|
||||
return linked
|
||||
}
|
||||
|
||||
async function discoverBundledAgents(pluginRoot: string): Promise<readonly string[]> {
|
||||
const componentsRoot = join(pluginRoot, "components")
|
||||
if (!(await exists(componentsRoot))) return []
|
||||
const componentEntries = await readdir(componentsRoot, { withFileTypes: true })
|
||||
const agents: string[] = []
|
||||
for (const entry of componentEntries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const agentsRoot = join(componentsRoot, entry.name, "agents")
|
||||
if (!(await exists(agentsRoot))) continue
|
||||
const agentEntries = await readdir(agentsRoot, { withFileTypes: true })
|
||||
for (const file of agentEntries) {
|
||||
if (!file.isFile() || !file.name.endsWith(".toml")) continue
|
||||
agents.push(join(agentsRoot, file.name))
|
||||
}
|
||||
}
|
||||
agents.sort()
|
||||
return agents
|
||||
}
|
||||
|
||||
async function replaceWithSymlink(linkPath: string, target: string): Promise<void> {
|
||||
await prepareReplacement(linkPath)
|
||||
await symlink(target, linkPath)
|
||||
}
|
||||
|
||||
async function replaceWithCopy(linkPath: string, target: string): Promise<void> {
|
||||
await prepareReplacement(linkPath)
|
||||
await copyFile(target, linkPath)
|
||||
}
|
||||
|
||||
async function prepareReplacement(linkPath: string): Promise<void> {
|
||||
if (!(await exists(linkPath))) return
|
||||
const entryStat = await lstat(linkPath)
|
||||
if (entryStat.isDirectory() && !entryStat.isSymbolicLink()) {
|
||||
throw new Error(`${linkPath} already exists and is a directory; refusing to replace`)
|
||||
}
|
||||
await rm(linkPath, { force: true })
|
||||
}
|
||||
|
||||
async function writeManifest(pluginRoot: string, agentPaths: readonly string[]): Promise<void> {
|
||||
const manifestPath = join(pluginRoot, MANIFEST_FILE)
|
||||
const payload = { agents: [...agentPaths].sort() }
|
||||
await writeFile(manifestPath, `${JSON.stringify(payload, null, "\t")}\n`)
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await lstat(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user