diff --git a/apps/cli/package.json b/apps/cli/package.json index dda1317b1..f9c024aa2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -51,7 +51,7 @@ "test": "vitest run --maxWorkers=1" }, "dependencies": { - "@first-tree-ai/context-tree": "0.1.12" + "@first-tree-ai/context-tree": "0.1.14" }, "devDependencies": { "@opentag/client": "workspace:*", diff --git a/docs/design/context-tree-integration.md b/docs/design/context-tree-integration.md index b6db2d277..595c83cbd 100644 --- a/docs/design/context-tree-integration.md +++ b/docs/design/context-tree-integration.md @@ -109,7 +109,7 @@ workspace once per recorded target, cached in memory: cwd = await workspace.cwd(agentId) connect --project-path --json # clones on first use when the kind is github install --host claude --project # -> /.claude/skills/context-tree-* -install --host codex # -> $CODEX_HOME/skills/context-tree-* +install --host codex # -> $HOME/.agents/skills/context-tree-* ``` `connect` is idempotent for an identical connection, so this is the ensure operation. It also @@ -233,14 +233,12 @@ Two consequences to hold in view: ### Codex -Codex reads skills from `$CODEX_HOME/skills`, independently of the `plugins` and `hooks` features +Codex reads skills from `$HOME/.agents/skills`, independently of the `plugins` and `hooks` features OpenTag disables, so no marketplace or feature change is required. For `install --host codex`, -OpenTag sets `HOME` to the parent of the resolved Codex home, making the package install into the -same `.codex/skills` directory from which the spawned Runtime reads. That mapping is exact only -when the Codex home's basename is `.codex`. A home with any other name (for example -`CODEX_HOME=/opt/opentag/codex-home`) cannot be expressed by the redirect, so preparation reports -`unavailable: CODEX_HOME_UNSUPPORTED` before any CLI work rather than risking a misplaced -install; a Codex host the CLI reports as `skipped` is likewise reported as `unavailable` with the +OpenTag passes the account `HOME` and resolved `CODEX_HOME` used by the Codex runtime. +The package detects Codex at `CODEX_HOME` and installs skills into `$HOME/.agents/skills`, +including when the Codex home has a custom location or name. +A Codex host the CLI reports as `skipped` is reported as `unavailable` with the CLI's own reason instead of a reassuring `ready`. OpenTag writes only `context-tree-*` directories there; the operation is idempotent and reversible. @@ -363,7 +361,7 @@ Both delivery mechanisms were confirmed against the real CLIs before the surroun - Claude Code under `--print --input-format stream-json --setting-sources project` discovers `/.claude/skills/context-tree-*`; under `--setting-sources ""` it does not. -- Codex discovers `~/.codex/skills/context-tree-*` with `plugins` and `hooks` disabled. Its +- Codex discovers `~/.agents/skills/context-tree-*` with `plugins` and `hooks` disabled. Its `skip_host_skill_discovery` feature is separate from both. Automated coverage: target routing and config round-trip; rendered platform string, revision @@ -376,8 +374,8 @@ in every state. Four end-to-end tests run offline against the real packaged CLI and a real Git tree, with `HOME` and `OPENTAG_HOME` redirected: two Agent workspaces on one Computer resolving to the same checkout -and invoking `context-tree` by name through the shim; Codex skills landing in a custom `.codex` -home while the account home stays untouched; one Agent writing `members//memory.md` through +and invoking `context-tree` by name through the shim; Codex skills landing in the `.agents` +sibling of a custom `.codex` home while the account home stays untouched; one Agent writing `members//memory.md` through the real isolated-worktree protocol while a second Agent in a different workspace reads it back; and a Session still starting after the configured tree is deleted from underneath it. @@ -399,10 +397,6 @@ the dependency from the published bundle. - Validating a GitHub target during `opentag context-tree connect`, for example with a cheap `git ls-remote` probe. That adds network work to a command deliberately kept read-only; background preparation plus durable doctor visibility removes the Session-start harm meanwhile. -- Supporting an arbitrary `CODEX_HOME` whose basename is not `.codex`; the Context Tree CLI needs - an explicit skills-root flag because its host install can target only `/.codex/skills`. - Until then the two guards above report the unsupported home and a skipped host as `unavailable` - with a specific reason, so the state is diagnosable through the prompt and `opentag doctor`. - Sanitizing Claude project inputs (`settings.json`, `settings.local.json`, `hooks/`, `agents/`, `commands/`, and `CLAUDE.md`) on every `prepareAgent`, so project settings contribute only OpenTag-written content. diff --git a/packages/client/package.json b/packages/client/package.json index 79ae75ed4..e1c457598 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -29,7 +29,7 @@ "zod": "^4.5.1" }, "devDependencies": { - "@first-tree-ai/context-tree": "0.1.12", + "@first-tree-ai/context-tree": "0.1.14", "@types/semver": "^7.8.0", "@types/ws": "^8.18.1", "vitest": "^4.1.11" diff --git a/packages/client/src/__tests__/agent-home-context.integration.test.ts b/packages/client/src/__tests__/agent-home-context.integration.test.ts index dc78da34c..0400bb1fe 100644 --- a/packages/client/src/__tests__/agent-home-context.integration.test.ts +++ b/packages/client/src/__tests__/agent-home-context.integration.test.ts @@ -83,7 +83,7 @@ describe("shared Agent Home and Context Tree", () => { readFile(join(agentHome, ".claude", "skills", "context-tree-read", "SKILL.md"), "utf8"), ).resolves.toContain("context-tree sync"); await expect( - readFile(join(fixture.accountHome, ".codex", "skills", "context-tree-write", "SKILL.md"), "utf8"), + readFile(join(fixture.accountHome, ".agents", "skills", "context-tree-write", "SKILL.md"), "utf8"), ).resolves.toContain("context-tree prepare-write"); const unanchored = await runCli(["resolve", "--project-path", taskA, "--json"], taskA); diff --git a/packages/client/src/__tests__/context-tree.integration.test.ts b/packages/client/src/__tests__/context-tree.integration.test.ts index 093c00828..d891e8a77 100644 --- a/packages/client/src/__tests__/context-tree.integration.test.ts +++ b/packages/client/src/__tests__/context-tree.integration.test.ts @@ -141,7 +141,7 @@ describe("Context Tree end-to-end", () => { ).resolves.toContain("context-tree"); } await expect( - readFile(join(accountHome, ".codex", "skills", "context-tree-write", "SKILL.md"), "utf8"), + readFile(join(accountHome, ".agents", "skills", "context-tree-write", "SKILL.md"), "utf8"), ).resolves.toContain("context-tree"); // Exactly what a Session does: run the bare command name with the shim directory on PATH. @@ -152,12 +152,13 @@ describe("Context Tree end-to-end", () => { expect(JSON.parse(stdout.trim())).toMatchObject({ tree: { path: treePath } }); }); - it("installs Codex skills into a custom CODEX_HOME named .codex", async () => { - const { accountHome, openTagHome, treePath } = await isolatedAccount("opentag-ct-codex-home"); + it.each([".codex", "codex-home"])("installs skills into account HOME with custom CODEX_HOME %s", async (name) => { + const { accountHome, openTagHome, treePath, environment } = await isolatedAccount("opentag-ct-codex-home"); const customRoot = await temporaryDirectory("opentag-custom-codex-root-"); - const codexHome = join(customRoot, ".codex"); + const codexHome = join(customRoot, name); await mkdir(codexHome, { mode: 0o700, recursive: true }); const manager = new ContextTreeManager({ + environment, codexHome, home: openTagHome, ...(contextTreePackage ? { contextTreePackage } : {}), @@ -167,43 +168,17 @@ describe("Context Tree end-to-end", () => { status: "ready", treePath, }); - await expect(readFile(join(codexHome, "skills", "context-tree-read", "SKILL.md"), "utf8")).resolves.toContain( - "context-tree", - ); - // The install lands in the custom home, never in the OS account home. await expect( - readFile(join(accountHome, ".codex", "skills", "context-tree-read", "SKILL.md"), "utf8"), + readFile(join(accountHome, ".agents", "skills", "context-tree-read", "SKILL.md"), "utf8"), + ).resolves.toContain("context-tree"); + // Codex scans the account home, independently of where its configuration lives. + await expect( + readFile(join(customRoot, ".agents", "skills", "context-tree-read", "SKILL.md"), "utf8"), ).rejects.toMatchObject({ code: "ENOENT", }); }); - it("reports an unsupported CODE_HOME instead of misinstalling skills", async () => { - const { accountHome, openTagHome } = await isolatedAccount("opentag-ct-codex-home-unsupported"); - const customRoot = await temporaryDirectory("opentag-custom-codex-root-"); - // The supported configuration from the review: a Codex home whose basename is not `.codex`, - // with a sibling `.codex` that must never be written by the HOME redirect. - const codexHome = join(customRoot, "codex-home"); - await mkdir(codexHome, { mode: 0o700, recursive: true }); - await mkdir(join(customRoot, ".codex"), { mode: 0o700, recursive: true }); - const manager = new ContextTreeManager({ - codexHome, - home: openTagHome, - ...(contextTreePackage ? { contextTreePackage } : {}), - }); - - await expect(manager.ensureAgent(await temporaryDirectory("opentag-ct-custom-codex-agent-"))).resolves.toEqual({ - status: "unavailable", - reason: "CODEX_HOME_UNSUPPORTED", - }); - // Nothing may land in the configured Codex home, in the sibling `.codex`, or in the account home. - for (const root of [codexHome, join(customRoot, ".codex"), join(accountHome, ".codex")]) { - await expect(readFile(join(root, "skills", "context-tree-read", "SKILL.md"), "utf8")).rejects.toMatchObject({ - code: "ENOENT", - }); - } - }); - it("lets one Agent record member memory that another Agent then reads", async () => { const { accountHome, treePath, environment, manager } = await isolatedAccount("opentag-ct-write"); // The CLI installs Codex skills only for a host that is present; this account simulates one, diff --git a/packages/client/src/__tests__/context-tree.test.ts b/packages/client/src/__tests__/context-tree.test.ts index 6ab178c7d..3fac4deaa 100644 --- a/packages/client/src/__tests__/context-tree.test.ts +++ b/packages/client/src/__tests__/context-tree.test.ts @@ -1,7 +1,8 @@ -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { codexAgentRuntimeEnvironment } from "../providers/codex/agent-runtime.js"; import { type ContextTreeExecFile, ContextTreeManager, @@ -23,7 +24,7 @@ const installReply = { installed: [ { host: "codex", - path: "/home/user/.codex/skills", + path: "/home/user/.agents/skills", skills: [ "context-tree-connect", "context-tree-create", @@ -160,33 +161,24 @@ describe("ContextTreeManager", () => { expect(calls).toHaveLength(3); }); - it("uses the account environment for tree commands and redirects only Codex installation", async () => { - const environments: NodeJS.ProcessEnv[] = []; - const { home, cwd, manager } = await computer({ - target: managed, - codexHome: "/custom/.codex", - execFile: async (_file, args, options) => { - environments.push(options.env ?? {}); - return { stdout: JSON.stringify(args[1] === "connect" ? treeReply("/srv/tree") : installReply) }; - }, - }); - await expect(manager.ensureAgent(cwd)).resolves.toEqual({ status: "ready", treePath: "/srv/tree" }); - expect(environments.map((env) => env.HOME)).toEqual([home, home, "/custom"]); - expect(environments[2]).toEqual({ ...environments[0], HOME: "/custom" }); - }); - - it("reports an unsupported Codex home instead of a misleading ready state", async () => { - const { execFile, calls } = recording({ connect: treeReply("/srv/t"), install: installReply }); - const { cwd, manager } = await computer({ execFile, target: managed, codexHome: "/opt/opentag/codex-home" }); - - await expect(manager.ensureAgent(cwd)).resolves.toEqual({ - status: "unavailable", - reason: "CODEX_HOME_UNSUPPORTED", - }); - // The HOME redirect cannot express this home, so the CLI must not run at all: an install - // could otherwise land in a sibling `.codex` the Runtime is not launched against. - expect(calls).toEqual([]); - }); + it.each(["/custom/.codex", "/custom/codex-home"])( + "uses the runtime account HOME for installation with %s", + async (codexHome) => { + const environments: NodeJS.ProcessEnv[] = []; + const { home, cwd, manager } = await computer({ + target: managed, + codexHome, + execFile: async (_file, args, options) => { + environments.push(options.env ?? {}); + return { stdout: JSON.stringify(args[1] === "connect" ? treeReply("/srv/tree") : installReply) }; + }, + }); + await expect(manager.ensureAgent(cwd)).resolves.toEqual({ status: "ready", treePath: "/srv/tree" }); + const runtimeEnvironment = codexAgentRuntimeEnvironment({ ...process.env, HOME: home, CODEX_HOME: codexHome }); + expect(environments.map((env) => env.HOME)).toEqual([home, home, await realpath(runtimeEnvironment.HOME ?? "")]); + expect(environments[2]?.CODEX_HOME).toBe(runtimeEnvironment.CODEX_HOME); + }, + ); it.each([ // The CLI reports a missing host as a skipped entry with an explanatory reason. diff --git a/packages/client/src/runtime/context-tree.ts b/packages/client/src/runtime/context-tree.ts index 98bf55781..2b8aca339 100644 --- a/packages/client/src/runtime/context-tree.ts +++ b/packages/client/src/runtime/context-tree.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { createRequire } from "node:module"; -import { basename, dirname, join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; import { type ContextTreeConfig, @@ -164,7 +164,7 @@ export interface ContextTreeManagerOptions { platform?: NodeJS.Platform; /** Absolute path to the Node.js runtime the generated shim should exec. */ nodePath?: string; - /** Resolved home passed to Codex itself; host skill installation must use the same root. */ + /** Resolved Codex config home; the Context Tree CLI installs skills beside it under `.agents`. */ codexHome?: string; sessionStartBudgetMs?: number; failureCooldownMs?: number; @@ -189,13 +189,6 @@ export class ContextTreeManager { readonly #platform: NodeJS.Platform; readonly #nodePath: string; readonly #codexHome: string; - /** - * The Context Tree CLI installs Codex skills only into `/.codex/skills`, so the `HOME` - * redirect below is correct exactly when the Codex home's basename is `.codex`. Any other home - * (for example `CODEX_HOME=/opt/opentag/codex-home`) cannot be expressed and is reported - * rather than silently misinstalled next to it. - */ - readonly #codexHomeIsDefaultNamed: boolean; readonly #sessionStartBudgetMs: number; readonly #failureCooldownMs: number; readonly #ready = new Map(); @@ -218,7 +211,6 @@ export class ContextTreeManager { this.#platform = options.platform ?? process.platform; this.#nodePath = options.nodePath ?? process.execPath; this.#codexHome = resolve(options.codexHome ?? join(resolveAccountHome(this.#environment), ".codex")); - this.#codexHomeIsDefaultNamed = basename(this.#codexHome) === ".codex"; this.#sessionStartBudgetMs = options.sessionStartBudgetMs ?? SESSION_START_BUDGET_MS; this.#failureCooldownMs = options.failureCooldownMs ?? FAILURE_COOLDOWN_MS; } @@ -279,9 +271,6 @@ export class ContextTreeManager { async #ensureAgentOnce(cwd: string, config: ContextTreeConfig): Promise { if (!this.#package) return this.#unavailable("PACKAGE_MISSING", config); - // The CLI never reads `CODEX_HOME`; `install --host codex` targets `/.codex/skills`. - // An unsupported home is diagnosed before any CLI work, so nothing can land in the wrong place. - if (!this.#codexHomeIsDefaultNamed) return this.#unavailable("CODEX_HOME_UNSUPPORTED", config); try { // `connect` is idempotent for an identical connection and already returns the resolved @@ -290,11 +279,12 @@ export class ContextTreeManager { const treePath = (connected as { tree?: { path?: unknown } }).tree?.path; if (typeof treePath !== "string" || treePath.length === 0) return this.#unavailable("CONNECT_FAILED", config); // Claude Code loads skills from the workspace because OpenTag passes `--setting-sources - // project`; Codex loads them from its own home, and only for a host that is present. + // project`; Codex loads them from the account home's `.agents/skills` directory. await this.#run(["install", "--host", "claude", "--project", cwd], cwd, false); const codexInstall = await this.#run(["install", "--host", "codex"], cwd, false, { ...this.#environment, - HOME: dirname(this.#codexHome), + HOME: resolveAccountHome(this.#environment), + CODEX_HOME: this.#codexHome, }); // A skipped Codex host is a diagnosable state, never a silent `ready`. const skipReason = codexInstallSkipReason(codexInstall); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 803b32933..8380746b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,8 +48,8 @@ importers: apps/cli: dependencies: '@first-tree-ai/context-tree': - specifier: 0.1.12 - version: 0.1.12 + specifier: 0.1.14 + version: 0.1.14 devDependencies: '@opentag/client': specifier: workspace:* @@ -189,8 +189,8 @@ importers: version: 4.5.4 devDependencies: '@first-tree-ai/context-tree': - specifier: 0.1.12 - version: 0.1.12 + specifier: 0.1.14 + version: 0.1.14 '@types/semver': specifier: ^7.8.0 version: 7.8.0 @@ -983,8 +983,8 @@ packages: '@fastify/websocket@11.3.0': resolution: {integrity: sha512-g89ag4BCcD9YP5wBZXixzoLnuf5j89p/sXFcfpCiv2pdEkYYukBEoK3heVzqsp0EAtszVDc2BBZG0KZqeAShIA==} - '@first-tree-ai/context-tree@0.1.12': - resolution: {integrity: sha512-kJ3fNpkCqgDNzjcMPAmrCUJ6iZQBpvXnvU3WAZ5ahvz3Zk+h6ZeyKilE3yrmBfozRYz6yxKp//y59/65Qo7cDQ==} + '@first-tree-ai/context-tree@0.1.14': + resolution: {integrity: sha512-enK74Ngm+mCEZJN2AxY7K/RJmwJkB0wpf/1uSgylmpzbAYT7KImuPHpJ2UR2Jid3Mt6s7c98Ce25XlwYpNXRZw==} engines: {node: '>=22.13.0'} hasBin: true @@ -5309,7 +5309,7 @@ snapshots: - bufferutil - utf-8-validate - '@first-tree-ai/context-tree@0.1.12': + '@first-tree-ai/context-tree@0.1.14': dependencies: commander: 15.0.0 mdast-util-from-markdown: 2.0.3 diff --git a/scripts/__tests__/portable-runtime-dependencies.test.mjs b/scripts/__tests__/portable-runtime-dependencies.test.mjs index e8d66907f..afa4204c9 100644 --- a/scripts/__tests__/portable-runtime-dependencies.test.mjs +++ b/scripts/__tests__/portable-runtime-dependencies.test.mjs @@ -179,7 +179,7 @@ test("the portable runtime dependency contract accepts only the supported exact test("the checked-in apps/cli manifest is accepted under the portable shipping contract", () => { const cliManifest = JSON.parse(readFileSync(join(repoRoot, "apps", "cli", "package.json"), "utf8")); assert.deepEqual(readPortableDirectDependencyPins(cliManifest), [ - { name: "@first-tree-ai/context-tree", version: "0.1.12" }, + { name: "@first-tree-ai/context-tree", version: "0.1.14" }, ]); }); @@ -531,13 +531,13 @@ test("the real frozen install closure assembles, verifies, and runs the embedded }); assert.equal(closure.direct.length, 1); assert.equal(closure.packages[0].name, "@first-tree-ai/context-tree"); - assert.equal(closure.packages[0].version, "0.1.12"); + assert.equal(closure.packages[0].version, "0.1.14"); assert.ok(closure.packages.length >= 20, `real closure is unexpectedly small: ${closure.packages.length}`); writeJson(join(appDir, "package.json"), { name: "open-tag", version: "0.0.2", type: "module", - dependencies: { "@first-tree-ai/context-tree": "0.1.12" }, + dependencies: { "@first-tree-ai/context-tree": "0.1.14" }, }); writeDependencyClosureFile(appDir, closure); writeText(join(appDir, "cli", "index.mjs"), "export {};\n");