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
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
24 changes: 9 additions & 15 deletions docs/design/context-tree-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ workspace once per recorded target, cached in memory:
cwd = await workspace.cwd(agentId)
connect <target> --project-path <cwd> --json # clones on first use when the kind is github
install --host claude --project <cwd> # -> <cwd>/.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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
`<workspace>/.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
Expand All @@ -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/<slug>/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/<slug>/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.

Expand All @@ -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 `<HOME>/.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.
Expand Down
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
45 changes: 10 additions & 35 deletions packages/client/src/__tests__/context-tree.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 } : {}),
Expand All @@ -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,
Expand Down
50 changes: 21 additions & 29 deletions packages/client/src/__tests__/context-tree.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 5 additions & 15 deletions packages/client/src/runtime/context-tree.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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 `<HOME>/.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<string, { target: string; status: ContextTreeStatus }>();
Expand All @@ -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;
}
Expand Down Expand Up @@ -279,9 +271,6 @@ export class ContextTreeManager {

async #ensureAgentOnce(cwd: string, config: ContextTreeConfig): Promise<ContextTreeStatus> {
if (!this.#package) return this.#unavailable("PACKAGE_MISSING", config);
// The CLI never reads `CODEX_HOME`; `install --host codex` targets `<HOME>/.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
Expand All @@ -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);
Expand Down
14 changes: 7 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions scripts/__tests__/portable-runtime-dependencies.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]);
});

Expand Down Expand Up @@ -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");
Expand Down