Skip to content
Open
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 CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Names for the concepts this repo's design discussions keep reaching for. Archite
- **T3 form** - upstream's skill text with every Cursor mechanism rewritten into what T3 provides: `delegate_task` children and persona briefs read by path, `schedule_task`, `watch_pull_request`, `t3_thread_*` reads, and sheet entries for models. It lives in the text itself, so a skill needs no second file to run on T3.
- **Agent Skill** - one directory under `plugins/pstack/skills/` with portable `name` and `description` frontmatter. Upstream marks every pstack and thermos skill but `setup-pstack` `disable-model-invocation: true`, and the six cursor-team-kit skills carry no flag. The port drops the flag from `poteto-mode` alone, so the session-start hook can invoke it, and `poteto-mode` opens the others by path.
- **Model sheet** - `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, one role per line, each value a T3 entry (`<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs) or `inherit-parent`. `setup-pstack` writes it and `t3-sheet.mjs` checks it against `orchestrator_capabilities`.
- **Installer** - `tools/install.mjs`, the package's `t3-pstack` bin, run as `npx -y github:chhoumann/t3-pstack <install|update|doctor>`. It installs the plugin in every Claude Code and Codex home T3 can run, trusts the Codex session-start hook, moves the model sheet to its machine path, and checks the result.
- **Installer** - `tools/install.mjs`, the package's `t3-pstack` bin, run as `npx -y github:chhoumann/t3-pstack <install|update|doctor>`. It installs the plugin in every Claude Code and Codex home T3 can run, trusts the Codex session-start hook, moves the model sheet to its machine path, and checks the result. With `--project <dir>` or `--local <dir>` it installs for one project: at that Claude Code scope, and through the project's `.codex/config.toml`, a layer Codex reads only for a project the home trusts.
- **Frozen hooks file** - `hooks/hooks.json` or `hooks/codex-hooks.json`, pinned in `tools/frozen-hooks.json`. Codex trusts a hook by a hash of its entry, so a change stops the hook in every Codex home until the installer reruns. What the hook says lives in `session-start.sh` and the routing text, which change freely.
- **Model policy** - the role defaults in `plugins/pstack/models.json`, as tiers of T3 entries. The generator stamps them into each skill's Models section, interrogate's reviewer table, and setup-pstack's sheet block.
- **Role** - a named unit of delegated work with its own model choice (`arena runners`, `how explorer`, `swarm workers`). The role vocabulary lives in `models.json`.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ npx -y github:chhoumann/t3-pstack doctor

`install` and `update` are the same command, and a second run changes nothing. With Node 20 or later, it installs or updates the `t3-pstack@t3-pstack` plugin in every Claude Code and Codex config home T3 can run: `~/.claude` and `~/.codex`, `$CLAUDE_CONFIG_DIR` and `$CODEX_HOME`, and the home of each `claudeAgent` and `codex` instance in T3's settings, disabled ones included. Name any other home with `--claude-home <dir>` or `--codex-home <dir>`. Each home's `t3-pstack` marketplace must come from `chhoumann/t3-pstack`, or from `--source <owner/repo|git URL|dir>` on the same run, and a home whose marketplace has another source is left as it is. A local directory needs a path such as `./t3-pstack`, since `owner/repo` always means GitHub. On Codex it also trusts t3-pstack's session-start hook, once its hooks file is the one this release pins. Codex runs a plugin's hook only once it is trusted, and T3's Codex sessions never ask, so without this step the hook never runs. Run `update` after each release.

To install for one project instead of every session, add `--project <dir>` or `--local <dir>` to `install`, `update`, or `doctor`. On Claude Code, `--project` installs at project scope, writing the marketplace and the enabled plugin into the project's `.claude/settings.json` so the team shares them, and `--local` writes them into `.claude/settings.local.json` for you alone. Codex has no plugin scopes, so both flags enable the plugin in the project's `.codex/config.toml` and mark the project trusted in each Codex home, since Codex ignores a project's config until it is trusted. The plugin's files and the hook's trust stay in the home, and a home that did not already have the plugin enabled keeps it disabled outside the projects that enable it. Each teammate runs the same command once, since every home needs its own copy of the plugin.

`doctor` reports on the same homes and changes nothing itself: each marketplace's source, the installed version, whether its files are all there, whether it is enabled, the Codex hook's trust, Claude Code records that pin an old version, plain copies of the plugin's skills, and the model sheet. It exits 1 on any problem and prints a fix for each.

Then run `/t3-pstack:setup-pstack` in a T3 thread on each machine. It writes the model sheet, `~/.config/t3-pstack/models.md`, with one T3 target per role (`<providerInstanceId>/<modelId>` plus that provider's options), and checks it against the machine's `orchestrator_capabilities`. Without a sheet, every role runs on the defaults in its skill's Models section.
Expand Down
2 changes: 1 addition & 1 deletion tests/ci-forks-step.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function runStep(upstream, failing = "") {
if (upstream !== null) writeFileSync(join(dir, "tools/upstream.json"), upstream);
writeFileSync(calls, "");
writeFileSync(join(dir, "step.sh"), step.run);
writeFileSync(join(dir, "bin/bun"), '#!/bin/bash\necho "$2 $3" >> "$CALLS"\n[ "$2" != "$FAILING" ]\n', { mode: 0o755 });
writeFileSync(join(dir, "bin/bun"), '#!/usr/bin/env bash\necho "$2 $3" >> "$CALLS"\n[ "$2" != "$FAILING" ]\n', { mode: 0o755 });
const { status, stdout } = spawnSync("bash", ["-e", "step.sh"], {
cwd: dir,
env: { PATH: `${join(dir, "bin")}:${process.env.PATH}`, RUNNER_TEMP: dir, CALLS: calls, FAILING: failing },
Expand Down
123 changes: 118 additions & 5 deletions tests/install-e2e.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ function sh(bin, args, env = {}) {
return r.stdout;
}

// `source` null runs with no --source, as a user does.
function installer(command, source = repoRoot) {
const r = spawnSync("node", [join(repoRoot, "tools/install.mjs"), command, ...(source ? ["--source", source] : []), "--json"], {
// `source` null runs with no --source, as a user does. `extra` adds flags.
function installer(command, source = repoRoot, extra = []) {
const r = spawnSync("node", [join(repoRoot, "tools/install.mjs"), command, ...(source ? ["--source", source] : []), ...extra, "--json"], {
cwd: root,
env: process.env,
encoding: "utf8",
Expand Down Expand Up @@ -75,10 +75,10 @@ async function until(done, ms) {

// A T3-shaped Codex session: thread/start, then one turn. SessionStart runs
// at the first turn, before the model call.
async function probe(codexHome) {
async function probe(codexHome, cwd = root) {
const server = await AppServer.start({ dir: codexHome, bin: "codex" });
try {
const { thread } = await server.request("thread/start", { cwd: root, approvalPolicy: "never", sandbox: "read-only" });
const { thread } = await server.request("thread/start", { cwd, approvalPolicy: "never", sandbox: "read-only" });
await server.request("turn/start", { threadId: thread.id, input: [{ type: "text", text: "ping" }] });
expect(await until(() => server.notifications.some((m) => m.method === "turn/completed"), 90_000), "turn completed").toBe(true);
const rollout = readFileSync(thread.path, "utf8")
Expand Down Expand Up @@ -484,3 +484,116 @@ describe.skipIf(!enabled)("the installer, on a Codex home that changes while it
expect(await trustState()).toBe(null);
}, 300_000);
});

describe.skipIf(!enabled)("the installer, for one project", () => {
isolate({});
let project;
let elsewhere;
const source = () => repoRoot.replace(/\/$/, "");

test("install: the plugin loads in the project's sessions and nowhere else", async () => {
project = join(root, "project");
elsewhere = join(root, "elsewhere");
for (const dir of [project, elsewhere]) mkdirSync(dir, { recursive: true });
sh("git", ["init", "-q", project]);

const { status, report } = installer("install", repoRoot, ["--project", project]);
console.log("project install:", JSON.stringify(report.homes.map(({ dir, steps }) => ({ dir, steps }))));
expect(failing(report.homes.flatMap((h) => h.checks))).toEqual([]);
expect(status).toBe(0);
expect(report.project).toEqual({ scope: "project", dir: project });
const claudeSettings = join(project, ".claude/settings.json");
expect(row(report, ".claude").steps).toEqual([
`added the t3-pstack marketplace from ${source()} to ${claudeSettings}`,
`installed ${version} at project scope`,
]);
expect(row(report, ".codex").steps).toEqual([
`added the t3-pstack marketplace from ${source()}`,
`installed ${version}`,
"left it disabled outside the projects that enable it",
`trusted ${project} in ${join(home, ".codex/config.toml")}, so Codex reads its .codex/config.toml`,
`enabled it in ${join(project, ".codex/config.toml")}`,
`trusted hook ${HOOK} (was untrusted)`,
]);
expect(JSON.parse(readFileSync(claudeSettings, "utf8")).enabledPlugins).toEqual({ "t3-pstack@t3-pstack": true });
expect(existsSync(join(home, ".claude/settings.json"))).toBe(false);

const inside = await probe(join(home, ".codex"), project);
expect(inside.hookEvents).toEqual(["hook/started", "hook/completed"]);
expect(inside.routedMessages).toBe(1);
const outside = await probe(join(home, ".codex"), elsewhere);
expect(outside.hookEvents).toEqual([]);
expect(outside.routedMessages).toBe(0);
}, 300_000);

test("a second install changes nothing, and the global doctor sees no global install", () => {
const before = snapshot();
const { status, report } = installer("update", repoRoot, ["--project", project]);
expect(status).toBe(0);
expect(report.homes.flatMap((h) => h.steps)).toEqual([]);
expect(snapshot()).toEqual(before);
expect(installer("doctor", repoRoot, ["--project", project]).status).toBe(0);
const global = installer("doctor").report;
// Outside the project the plugin is off, so Codex lists none of its hooks.
expect(failing(row(global, ".codex").checks)).toEqual(["enabled: no", "session-start hook trusted: no t3-pstack hook listed"]);
}, 300_000);

test("--local writes the project's local settings, and keeps the Codex config's other lines", () => {
const local = join(root, "local");
mkdirSync(join(local, ".codex"), { recursive: true });
writeFileSync(join(local, ".codex/config.toml"), 'model = "o3"\n');
const { status, report } = installer("install", repoRoot, ["--local", local]);
expect(failing(report.homes.flatMap((h) => h.checks))).toEqual([]);
expect(status).toBe(0);
expect(existsSync(join(local, ".claude/settings.json"))).toBe(false);
expect(JSON.parse(readFileSync(join(local, ".claude/settings.local.json"), "utf8")).enabledPlugins).toEqual({
"t3-pstack@t3-pstack": true,
});
expect(readFileSync(join(local, ".codex/config.toml"), "utf8")).toBe('model = "o3"\n\n[plugins."t3-pstack@t3-pstack"]\nenabled = true\n');
}, 300_000);

test("trusts neither the project nor a hook when the project's own config names another marketplace", () => {
const counterfeit = pluginCopy(join(root, "counterfeit"));
const other = join(root, "other");
mkdirSync(join(other, ".codex"), { recursive: true });
writeFileSync(join(other, ".codex/config.toml"), `[marketplaces.t3-pstack]\nsource_type = "local"\nsource = "${counterfeit}"\n`);
const { status, report } = installer("install", repoRoot, ["--project", other]);
expect(status).toBe(1);
expect(row(report, ".codex").steps).toEqual([]);
expect(failing(row(report, ".codex").checks)[0]).toBe(
`marketplace: ${source()}, ${counterfeit}, which is neither chhoumann/t3-pstack nor --source`,
);
}, 300_000);

test("registers the marketplace in every fresh Claude Code home, at either scope", () => {
for (const scope of ["project", "local"]) {
const shared = join(root, `shared-${scope}`);
mkdirSync(shared, { recursive: true });
const homes = [`claude-a-${scope}`, `claude-b-${scope}`];
const { status, report } = installer("install", repoRoot, [`--${scope}`, shared, ...homes.flatMap((h) => ["--claude-home", join(root, h)])]);
for (const h of homes) expect(failing(row(report, `../${h}`).checks)).toEqual([]);
expect(status).toBe(0);
}
}, 300_000);

test("adds the home's own marketplace when only the project's config names it", () => {
const codexHome = join(root, "codex-fresh");
const named = join(root, "named");
mkdirSync(join(named, ".codex"), { recursive: true });
writeFileSync(join(named, ".codex/config.toml"), `[marketplaces.t3-pstack]\nsource_type = "local"\nsource = "${source()}"\n`);
const { status, report } = installer("install", repoRoot, ["--project", named, "--codex-home", codexHome]);
expect(failing(row(report, "../codex-fresh").checks)).toEqual([]);
expect(status).toBe(0);
expect(row(report, "../codex-fresh").steps.slice(0, 2)).toEqual([`added the t3-pstack marketplace from ${source()}`, `installed ${version}`]);
}, 300_000);

test("a later global install enables the plugin everywhere, and a project install keeps it so", () => {
expect(installer("install").status).toBe(0);
const next = join(root, "next");
mkdirSync(next, { recursive: true });
const { status, report } = installer("install", repoRoot, ["--project", next]);
expect(status).toBe(0);
expect(row(report, ".codex").steps).not.toContain("left it disabled outside the projects that enable it");
expect(installer("doctor").status).toBe(0);
}, 300_000);
});
Loading