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:
@@ -1,16 +1,5 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"${PLUGIN_ROOT}/hooks/sync-agents.py\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Final, cast
|
||||
|
||||
|
||||
AGENTS_DIR: Final = "agents"
|
||||
SESSION_START_EVENT: Final = "SessionStart"
|
||||
|
||||
|
||||
def _load_payload() -> dict[str, object] | None:
|
||||
try:
|
||||
raw = sys.stdin.read()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not raw.strip():
|
||||
return None
|
||||
try:
|
||||
parsed = cast(object, json.loads(raw))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
values = cast(dict[object, object], parsed)
|
||||
return {str(k): v for k, v in values.items()}
|
||||
|
||||
|
||||
def _should_sync(payload: dict[str, object]) -> bool:
|
||||
return payload.get("hook_event_name") == SESSION_START_EVENT
|
||||
|
||||
|
||||
def _plugin_root() -> Path:
|
||||
env_root = os.environ.get("PLUGIN_ROOT")
|
||||
if env_root:
|
||||
root = Path(env_root).expanduser().resolve()
|
||||
if root.joinpath(AGENTS_DIR).is_dir():
|
||||
return root
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _codex_home() -> Path:
|
||||
env_home = os.environ.get("CODEX_HOME")
|
||||
if env_home:
|
||||
return Path(env_home).expanduser().resolve()
|
||||
return Path.home().joinpath(".codex")
|
||||
|
||||
|
||||
def _copy_agent_file(source: Path, target: Path) -> None:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.is_symlink() or target.is_file():
|
||||
target.unlink()
|
||||
elif target.exists():
|
||||
raise IsADirectoryError(target)
|
||||
_ = target.write_bytes(source.read_bytes())
|
||||
|
||||
|
||||
def _sync_agents(plugin_root: Path, codex_home: Path) -> None:
|
||||
source_dir = plugin_root / AGENTS_DIR
|
||||
if not source_dir.is_dir():
|
||||
return
|
||||
|
||||
target_dir = codex_home / AGENTS_DIR
|
||||
for source in sorted(source_dir.rglob("*.toml")):
|
||||
if not source.is_file():
|
||||
continue
|
||||
target = target_dir / source.relative_to(source_dir)
|
||||
_copy_agent_file(source, target)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
payload = _load_payload()
|
||||
if payload is not None and _should_sync(payload):
|
||||
_sync_agents(_plugin_root(), _codex_home())
|
||||
except Exception as err: # noqa: BLE001 - hook boundary must never block turns.
|
||||
_ = sys.stderr.write(f"codex-ultrawork agent sync failed: {err}\n")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { lstat, mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
@@ -9,11 +8,6 @@ import test from "node:test";
|
||||
const hookDir = dirname(fileURLToPath(import.meta.url));
|
||||
const pluginRoot = dirname(hookDir);
|
||||
const detectorPath = join(hookDir, "ultrawork-detector.py");
|
||||
const syncAgentsPath = join(hookDir, "sync-agents.py");
|
||||
|
||||
async function makeTempDir() {
|
||||
return mkdtemp(join(tmpdir(), "codex-ultrawork-"));
|
||||
}
|
||||
|
||||
async function runPython(scriptPath, input, env = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -41,52 +35,6 @@ async function runPython(scriptPath, input, env = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
test("#given session start #when sync hook runs #then installs bundled reviewer agent", async () => {
|
||||
const codexHome = await makeTempDir();
|
||||
try {
|
||||
const result = await runPython(
|
||||
syncAgentsPath,
|
||||
'{"hook_event_name":"SessionStart"}',
|
||||
{ CODEX_HOME: codexHome },
|
||||
);
|
||||
|
||||
assert.equal(result.code, 0);
|
||||
assert.equal(result.signal, null);
|
||||
assert.equal(result.stdout, "");
|
||||
assert.equal(result.stderr, "");
|
||||
|
||||
const targetPath = join(codexHome, "agents", "codex-ultrawork-reviewer.toml");
|
||||
const targetStat = await lstat(targetPath);
|
||||
assert.equal(targetStat.isFile(), true);
|
||||
assert.equal(targetStat.isSymbolicLink(), false);
|
||||
const syncedAgent = await readFile(targetPath, "utf8");
|
||||
assert.match(syncedAgent, /^name = "codex-ultrawork-reviewer"$/m);
|
||||
assert.match(syncedAgent, /^model = "gpt-5.2"$/m);
|
||||
assert.match(syncedAgent, /^model_reasoning_effort = "xhigh"$/m);
|
||||
assert.match(syncedAgent, /^developer_instructions = """/m);
|
||||
} finally {
|
||||
await rm(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("#given malformed session payload #when sync hook runs #then exits zero without output", async () => {
|
||||
const codexHome = await makeTempDir();
|
||||
try {
|
||||
const result = await runPython(syncAgentsPath, "{", { CODEX_HOME: codexHome });
|
||||
|
||||
assert.equal(result.code, 0);
|
||||
assert.equal(result.signal, null);
|
||||
assert.equal(result.stdout, "");
|
||||
assert.equal(result.stderr, "");
|
||||
await assert.rejects(
|
||||
stat(join(codexHome, "agents", "codex-ultrawork-reviewer.toml")),
|
||||
/code: 'ENOENT'|ENOENT/,
|
||||
);
|
||||
} finally {
|
||||
await rm(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("#given ultrawork prompt #when detector runs #then emits directive", async () => {
|
||||
const payload = JSON.stringify({
|
||||
hook_event_name: "UserPromptSubmit",
|
||||
@@ -199,17 +147,14 @@ test("#given identifier-like ulw #when detector runs #then does not emit directi
|
||||
assert.equal(result.stderr, "");
|
||||
});
|
||||
|
||||
test("#given hook manifest #when read #then registers prompt and session hooks", async () => {
|
||||
test("#given hook manifest #when read #then registers only the prompt detector hook", async () => {
|
||||
const manifest = JSON.parse(await readFile(join(hookDir, "hooks.json"), "utf8"));
|
||||
|
||||
assert.match(
|
||||
manifest.hooks.UserPromptSubmit[0].hooks[0].command,
|
||||
/ultrawork-detector\.py/,
|
||||
);
|
||||
assert.match(
|
||||
manifest.hooks.SessionStart[0].hooks[0].command,
|
||||
/sync-agents\.py/,
|
||||
);
|
||||
assert.equal(manifest.hooks.SessionStart, undefined);
|
||||
assert.equal(pluginRoot.endsWith("components/ultrawork"), true);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user