From a9978f027f6d8bac73f110883271b346910e0ddd Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 15:06:13 -0500 Subject: [PATCH 01/11] Record why bounded recall fell back to free hot memory The fallback action now carries a classified failure (timeout, aborted, HTTP status and error code, or an error name) so local logs distinguish a client timeout from a server error or a recall deadline. Messages, URLs and bodies are never copied. --- src/runtime/hosted-client.ts | 12 ++++++++++++ src/runtime/lifecycle-engine.ts | 4 ++-- tests/runtime/hosted-client-security.test.ts | 13 ++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/runtime/hosted-client.ts b/src/runtime/hosted-client.ts index eab9638..32ca18c 100644 --- a/src/runtime/hosted-client.ts +++ b/src/runtime/hosted-client.ts @@ -246,6 +246,18 @@ export class HostedOnlyBotsRequestError extends Error { } } +/** + * Classify a failed hosted request for local diagnostics without copying its + * message, URL, headers or body: a timeout, an HTTP status/code, or an error name. + */ +export const describeHostedFailure = (error: unknown): Record => { + if (error instanceof HostedOnlyBotsRequestError) return { errorClass: "http", status: error.status, code: error.code }; + const name = error instanceof Error ? error.name : typeof error; + if (name === "TimeoutError") return { errorClass: "timeout" }; + if (name === "AbortError") return { errorClass: "aborted" }; + return { errorClass: /^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name) ? name : "unknown" }; +}; + const extractHostedError = (data: unknown): { message: string | null; code: string | null; details: unknown } => { if (!data || typeof data !== "object" || Array.isArray(data)) { return { message: null, code: null, details: undefined }; diff --git a/src/runtime/lifecycle-engine.ts b/src/runtime/lifecycle-engine.ts index 84001c9..8c282cf 100644 --- a/src/runtime/lifecycle-engine.ts +++ b/src/runtime/lifecycle-engine.ts @@ -19,7 +19,7 @@ import { pickHotContextValidationCommand, } from "./hot-context-cues"; import { buildRuntimeBillingRemediation, formatRuntimeBillingRemediation, isBillingBlockedError } from "./billing-remediation"; -import { HostedOnlyBotsClient, type HostedSession } from "./hosted-client"; +import { describeHostedFailure, HostedOnlyBotsClient, type HostedSession } from "./hosted-client"; import { isLocalOnlyRuntimeProfile, readLocalHotMemory, writeLocalHotMemory } from "./local-hot-memory"; import { workspaceHotScope, selectWorkspaceHotState } from "./workspace-hot-memory"; import { buildRuntimeContinuityOutcome, parseRuntimeContinuityOutcomeContent } from "./continuity-outcomes"; @@ -1785,7 +1785,7 @@ export class RuntimeLifecycleManager { warnings.push("Saved-context retrieval unavailable; continuing with free hot memory. A timed-out paid request may still settle; inspect billing before retrying."); actions.push(this.recordAction({ eventId: event.eventId, profileName, type: "context_pack", status: "failed", mode: profile.memoryMode, reason: "Bounded recall unavailable; preserved free hot-memory delivery", queueItemId: null, - details: { type: event.type, freeFallback: true } })); + details: { type: event.type, freeFallback: true, failure: describeHostedFailure(error) } })); } else { if (!isBillingBlockedError(error)) throw error; contextPack = buildLocalOnlyContextPack(event, packRequest); diff --git a/tests/runtime/hosted-client-security.test.ts b/tests/runtime/hosted-client-security.test.ts index 732b604..9fad693 100644 --- a/tests/runtime/hosted-client-security.test.ts +++ b/tests/runtime/hosted-client-security.test.ts @@ -4,7 +4,7 @@ import { schnorr } from "@noble/curves/secp256k1"; import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools"; import { afterEach, describe, expect, it } from "vitest"; -import { HostedOnlyBotsClient } from "../../src/runtime/hosted-client"; +import { describeHostedFailure, HostedOnlyBotsClient, HostedOnlyBotsRequestError } from "../../src/runtime/hosted-client"; const servers: Server[] = []; afterEach(async () => { @@ -183,3 +183,14 @@ describe("hosted client security boundaries", () => { expect(receivedBody).toEqual({ code: "obj_test", agent_id: "agent-test" }); }); }); + +describe("describeHostedFailure", () => { + it("distinguishes a recall timeout from an HTTP failure without copying messages", () => { + const timeout = new DOMException("The operation was aborted due to timeout https://secret.example/?token=x", "TimeoutError"); + expect(describeHostedFailure(timeout)).toEqual({ errorClass: "timeout" }); + const http = new HostedOnlyBotsRequestError({ status: 503, code: "recall_deadline_exceeded", message: "secret detail" }); + expect(describeHostedFailure(http)).toEqual({ errorClass: "http", status: 503, code: "recall_deadline_exceeded" }); + expect(describeHostedFailure(new TypeError("fetch failed: secret.example"))).toEqual({ errorClass: "TypeError" }); + expect(JSON.stringify(describeHostedFailure(Object.assign(new Error("x"), { name: "bad name with token=1" })))).not.toContain("token"); + }); +}); From 664e4c26b95be2658e13eb7c13fcd44b84a5bcdd Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 15:16:19 -0500 Subject: [PATCH 02/11] Add Claude Code and Codex plugins backed by one hook table Claude Code and Codex can now install Crosshead as a plugin from this repository's marketplace (.claude-plugin/marketplace.json and .agents/plugins/marketplace.json). Each plugin contains only its hooks, a setup skill and a POSIX launcher that runs the installed runtime's existing hook entrypoints; it never bundles, downloads or updates the runtime. Without a configured runtime, SessionStart prints one setup line and every hook exits 0. src/runtime/host-hook-spec.ts is the single source for hook events, matchers, status messages and timeouts. The settings.json/hooks.json installer and the generated plugin files (scripts/host-plugins.mjs, checked by npm run plugins:check and a unit test) both render from it. crosshead install claude-code|codex and quickstart take --hook-mode auto|plugin|settings. auto detects an enabled plugin. In plugin mode the installer writes no host hooks and idempotently removes the ones it wrote before. The hook entrypoint skips the duplicate registration, so each event is handled once even before migration. crosshead doctor reports the active mode per host and flags duplicate or missing registrations. quickstart reuses an already running daemon, so setting up a second host no longer fails with "Detached daemon exited before becoming ready". scripts/test-host-plugins.sh runs the real Claude Code and Codex CLIs in a disposable container against local fixtures. --- .agents/plugins/marketplace.json | 20 + .claude-plugin/marketplace.json | 18 + .github/workflows/ci.yml | 1 + .github/workflows/native-hosts.yml | 29 ++ README.md | 40 +- docs/ARCHITECTURE.md | 6 + docs/HOST-PLUGINS.md | 143 +++++++ package.json | 2 + .../claude-code/.claude-plugin/plugin.json | 19 + plugins/claude-code/hooks/hooks.json | 58 +++ plugins/claude-code/scripts/crosshead-hook | 42 ++ plugins/claude-code/skills/setup/SKILL.md | 53 +++ plugins/codex/.codex-plugin/plugin.json | 31 ++ plugins/codex/hooks/hooks.json | 67 +++ plugins/codex/scripts/crosshead-hook | 42 ++ plugins/codex/skills/setup/SKILL.md | 54 +++ scripts/host-plugins.mjs | 253 ++++++++++++ scripts/smoke-host-plugins.mjs | 385 ++++++++++++++++++ scripts/test-host-plugins.sh | 54 +++ src/runtime/claude-code-adapter.ts | 132 ++++-- src/runtime/claude-code-hook.ts | 26 +- src/runtime/cli.ts | 8 +- src/runtime/cli/core.ts | 10 +- src/runtime/cli/quickstart.ts | 9 +- src/runtime/codex-adapter.ts | 147 ++++--- src/runtime/codex-hook.ts | 26 +- src/runtime/host-hook-doctor.ts | 83 ++++ src/runtime/host-hook-spec.ts | 92 +++++ src/runtime/host-plugin-mode.ts | 161 ++++++++ tests/runtime/host-plugins.test.ts | 349 ++++++++++++++++ 30 files changed, 2222 insertions(+), 138 deletions(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 .claude-plugin/marketplace.json create mode 100644 docs/HOST-PLUGINS.md create mode 100644 plugins/claude-code/.claude-plugin/plugin.json create mode 100644 plugins/claude-code/hooks/hooks.json create mode 100755 plugins/claude-code/scripts/crosshead-hook create mode 100644 plugins/claude-code/skills/setup/SKILL.md create mode 100644 plugins/codex/.codex-plugin/plugin.json create mode 100644 plugins/codex/hooks/hooks.json create mode 100755 plugins/codex/scripts/crosshead-hook create mode 100644 plugins/codex/skills/setup/SKILL.md create mode 100644 scripts/host-plugins.mjs create mode 100644 scripts/smoke-host-plugins.mjs create mode 100755 scripts/test-host-plugins.sh create mode 100644 src/runtime/host-hook-doctor.ts create mode 100644 src/runtime/host-hook-spec.ts create mode 100644 src/runtime/host-plugin-mode.ts create mode 100644 tests/runtime/host-plugins.test.ts diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..f64e692 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "crosshead", + "interface": { + "displayName": "Crosshead" + }, + "plugins": [ + { + "name": "crosshead", + "source": { + "source": "local", + "path": "./plugins/codex" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..3a55e5e --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,18 @@ +{ + "name": "crosshead", + "owner": { + "name": "FriendMachine", + "url": "https://crosshead.sh" + }, + "metadata": { + "description": "Crosshead plugins for Claude Code. Codex reads .agents/plugins/marketplace.json." + }, + "plugins": [ + { + "name": "crosshead", + "source": "./plugins/claude-code", + "description": "Persistent memory across coding agents. Hooks connect this host to the locally installed Crosshead runtime, shared with Claude Code, Codex, Hermes, OpenCode and Pi.", + "version": "1.0.0" + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44ec19b..3dcfd74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,7 @@ jobs: - run: npm run typecheck - run: npm run check:boundary - run: npm run build + - run: npm run plugins:check - run: npm test release-build: diff --git a/.github/workflows/native-hosts.yml b/.github/workflows/native-hosts.yml index c9cb0cc..c84c338 100644 --- a/.github/workflows/native-hosts.yml +++ b/.github/workflows/native-hosts.yml @@ -7,6 +7,10 @@ on: - 'src/runtime/**' - 'tests/runtime/native-host-plugins.test.ts' - 'scripts/*native-host*' + - 'scripts/*host-plugins*' + - 'plugins/**' + - '.claude-plugin/**' + - '.agents/**' - 'package-lock.json' - '.github/workflows/native-hosts.yml' @@ -40,3 +44,28 @@ jobs: include-hidden-files: true if-no-files-found: ignore retention-days: 14 + + host-plugins: + name: Claude Code and Codex plugins + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run release:build + - run: npm run plugins:check + - run: bash scripts/test-host-plugins.sh + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: host-plugin-results + path: .native-host-test-logs/host-plugins/ + include-hidden-files: true + if-no-files-found: ignore + retention-days: 14 diff --git a/README.md b/README.md index 70db675..d31d5fa 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,40 @@ are covered by [TRADEMARKS.md](TRADEMARKS.md). ## Install +### Claude Code and Codex: plugin + +```bash +claude plugin marketplace add FriendMachine/crosshead +claude plugin install crosshead@crosshead +# Codex: +codex plugin marketplace add FriendMachine/crosshead +codex plugin add crosshead@crosshead +``` + +Then start a session and run `/crosshead:setup` in Claude Code, or ask Codex +to use the `crosshead:setup` skill. Until setup has run, each session starts +with one line saying Crosshead isn't set up; nothing else happens and the +session is never blocked. Setup asks for your consent, runs the installer +below, and connects the host with +`crosshead quickstart --adapter claude-code|codex --hook-mode plugin`. +Codex runs plugin hooks only after you trust them in `/hooks`. + +The plugins are thin. They contain the lifecycle hooks, the setup skill and a +small launcher that runs the installed runtime's hook entrypoints. They do not +bundle, download or update the runtime, so one runtime, daemon and identity +stays shared by every host. See [host plugins](docs/HOST-PLUGINS.md). + +### Other hosts and scripts: installer + ```bash curl -fsSL https://crosshead.sh/install | bash crosshead quickstart --api-base https://crosshead.sh --adapter claude-code --workspace-root . ``` +`--adapter` also takes `codex`, `pi`, `omp`, `hermes` and `opencode`. Without +the plugin, `crosshead install claude-code|codex` registers the hooks in +`.claude/settings.json` or `$CODEX_HOME/hooks.json` itself. + The installer downloads one release tarball, checks its SHA-256 against the installer script, installs three npm dependencies (`@noble/curves`, `nostr-tools`, `tar`) with install scripts disabled, and writes the wrappers @@ -41,7 +70,8 @@ same bytes (see [Releases](#releases)). in the runtime's local secret store under `~/.crosshead/config/secrets/`. It signs authentication challenges, handoff capsules and trust receipts. Logs redact key material and session tokens. -- The adapters register Claude Code and Codex hooks. On session start and each +- The Crosshead plugin (or, without it, the adapter installer) registers + Claude Code and Codex hooks. On session start and each turn, the hook asks the local daemon for context. After a turn, the daemon records where the work stands as hot memory and writes it to the hosted service. Promotion to warm memory and archiving to cold memory follow your @@ -61,8 +91,10 @@ same bytes (see [Releases](#releases)). - `crosshead status`, `crosshead doctor` and `crosshead logs` show what the runtime is doing. Local state lives in `~/.crosshead/state/`. -`crosshead uninstall` removes the runtime; `crosshead uninstall claude-code` -and `crosshead uninstall codex` remove the hooks. +`crosshead uninstall` removes the runtime. Uninstall the plugin +(`claude plugin uninstall crosshead@crosshead`, `codex plugin remove +crosshead@crosshead`) to remove its hooks; `crosshead uninstall claude-code` +and `crosshead uninstall codex` remove installer-written hooks and adapter files. ## Hermes and OpenCode @@ -85,6 +117,7 @@ uninstall commands, supported versions, and Docker tests. | Path | Contents | | --- | --- | | `src/runtime/` | CLI, daemon, host adapters, hooks, hosted API client | +| `plugins/`, `.claude-plugin/`, `.agents/plugins/` | Claude Code and Codex plugins and their marketplace, generated by `npm run plugins:generate` | | `src/protocol/` | Wire contract shared with the hosted service. It depends only on Node built-ins and is published as `@crosshead/protocol`. | | `schemas/` | Published JSON schemas for protocol records | | `tests/` | Unit tests. Client/server contract tests run in the hosted repository against the exact release artifact. | @@ -103,6 +136,7 @@ npm ci npm run typecheck npm run check:boundary npm run build # tests spawn the compiled daemon and hooks +npm run plugins:check npm test ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 49a8deb..265cbe6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,6 +27,12 @@ Rules, enforced by `npm run check:boundary` and `scripts/verify-release.mjs`: Code that only the hosted service needs, such as recall heuristics, does not belong here. Code both sides need lives here; the hosted service imports it. +The Claude Code and Codex plugins (`plugins/`, `.claude-plugin/`, +`.agents/plugins/`) are generated from `src/runtime/host-hook-spec.ts` and are +installed from this repository by the hosts, not from the release tarball. They +only run the installed runtime's hook entrypoints; see +[HOST-PLUGINS.md](HOST-PLUGINS.md). + ## Operator extensions FriendMachine's local operator runs a superset build from the hosted diff --git a/docs/HOST-PLUGINS.md b/docs/HOST-PLUGINS.md new file mode 100644 index 0000000..3c0107e --- /dev/null +++ b/docs/HOST-PLUGINS.md @@ -0,0 +1,143 @@ +# Claude Code and Codex plugins + +Claude Code and Codex install Crosshead as a plugin from this repository's +marketplace. The plugin replaces the hooks that `crosshead install claude-code` +and `crosshead install codex` used to write into host configuration. The runtime +is still installed by `curl -fsSL https://crosshead.sh/install | bash`. + +```sh +claude plugin marketplace add FriendMachine/crosshead +claude plugin install crosshead@crosshead +codex plugin marketplace add FriendMachine/crosshead +codex plugin add crosshead@crosshead +``` + +## What the plugin contains + +| File | Purpose | +| --- | --- | +| `hooks/hooks.json` | `SessionStart`, `PreCompact`, `PostCompact`, `UserPromptSubmit` and `Stop` hooks | +| `scripts/crosshead-hook` | POSIX `sh` launcher the hooks run | +| `skills/setup/SKILL.md` | `/crosshead:setup` (Claude Code) or `crosshead:setup` (Codex) | +| `.claude-plugin/plugin.json` or `.codex-plugin/plugin.json` | Manifest | + +The launcher finds the runtime home (`CROSSHEAD_HOME`, then `~/.crosshead`) and +runs the installed `crosshead-claude-code-hook` or `crosshead-codex-hook` with +`--via plugin`. These are the hook entrypoints the installer-written hooks use. +The launcher never downloads, installs or updates code. Without a configured +runtime, it prints one line on `SessionStart`, prints nothing for other events, +and exits 0: + +```text +Crosshead isn't set up: run /crosshead:setup. +Crosshead isn't set up: ask Codex to use the crosshead setup skill ($crosshead:setup). +``` + +The line is a `systemMessage` the user sees, and context the agent sees. If the +runtime predates plugin support, the line asks you to run setup to update it, +and the plugin stays inactive, so older installer-written hooks keep working +without duplicates. + +The setup skill explains what it is about to do, asks for consent, runs the +digest-checked installer, and then runs: + +```sh +crosshead quickstart --api-base https://crosshead.sh --adapter claude-code --workspace-root . --hook-mode plugin +crosshead quickstart --api-base https://crosshead.sh --adapter codex --workspace-root . --hook-mode plugin +``` + +`--hook-mode plugin` writes the adapter configuration and wrappers but no host +hooks. Codex additionally requires you to trust the plugin hooks in `/hooks`; +a new plugin version needs a new review. The plugins have no MCP server. + +## One hook table + +`src/runtime/host-hook-spec.ts` lists the hook events, matchers, Codex status +messages and timeouts. The installer-written hooks and the generated plugin +files are both rendered from it. `scripts/host-plugins.mjs` generates +`plugins/`, `.claude-plugin/marketplace.json` and +`.agents/plugins/marketplace.json`. CI runs `npm run plugins:check`, and +`tests/runtime/host-plugins.test.ts` fails if a checked-in file differs from the +generated output. Edit the spec or the generator, never the generated files, +then run `npm run build && npm run plugins:generate`. Raise +`CROSSHEAD_HOST_PLUGIN_VERSION` whenever generated content changes: hosts pin +installed plugins to their manifest version. + +Claude Code reads `.claude-plugin/marketplace.json`. Codex reads +`.agents/plugins/marketplace.json` first and would otherwise fall back to the +Claude file. Both list one plugin, `crosshead@crosshead`, pointing at the +host's own plugin directory. Plugin files are not part of the install tarball +or the npm package. + +## Hook modes, migration and doctor + +`crosshead install claude-code|codex` takes `--hook-mode auto|plugin|settings` +(default `auto`). `auto` selects `plugin` when the host enables +`crosshead@`: + +- Claude Code: `enabledPlugins` in `.claude/settings.local.json`, + `.claude/settings.json` (workspace) or `~/.claude/settings.json`, in that + order. +- Codex: `[plugins."crosshead@..."]` in `.codex/config.toml` or + `$CODEX_HOME/config.toml`. + +The mode is stored in the adapter configuration. In plugin mode the installer +removes the hooks it previously wrote (in the current workspace and the +previously configured one for Claude Code, and in `$CODEX_HOME/hooks.json` for +Codex). It keeps unrelated hooks and settings. It removes a file that held only +Crosshead hooks. Running it again changes nothing. + +The hook entrypoint makes sure each event is handled once even before +migration: + +| Recorded mode | Invocation | Result | +| --- | --- | --- | +| `plugin` | plugin | runs | +| `plugin` | installer-written hook | skipped | +| `settings` | installer-written hook | runs | +| `settings` | plugin, installer hooks cover this workspace | skipped | +| `settings` | plugin, no installer hooks here | runs | + +`crosshead doctor` adds `claude_code_hooks` and `codex_hooks` checks. Each check +reports the mode, whether the plugin is enabled, and any remaining +installer-written hooks. The check fails for duplicate registrations, for plugin +mode with the plugin disabled or uninstalled, and for settings mode with no +hooks for the workspace. Each failure names the command that fixes it. Use +`--workspace-root` to inspect a different project. + +Uninstalling the plugin removes its hooks. Doctor then reports that no Crosshead +hooks are active. Reinstall the plugin, or run +`crosshead install --hook-mode settings` to go back to installer-written +hooks. + +## Verification + +```sh +npm ci && npm run release:build +npm run plugins:check +bash scripts/test-host-plugins.sh +``` + +The wrapper runs one disposable container (2 CPUs, 3 GiB RAM, no extra swap, +bounded tmpfs). It uses the pinned Node image with Claude Code `2.1.319` and +Codex `0.167.0`. `scripts/smoke-host-plugins.mjs` runs there as UID 1000 with +the repository read-only. A local fixture stands in for the Anthropic and +OpenAI APIs and serves this repository's installer +(`docs/security-review/install.sh`), with its digest pointed at the locally +built release. Hosted Crosshead calls go to an unreachable local address. The +real CLIs run headless sessions (`claude -p`, `codex exec`) that fire the +hooks. The check covers: + +- `claude plugin validate --strict` and installation from the marketplace + files. +- The setup line when no runtime is installed. +- Setup without host hooks. +- One capture per session with installer hooks, with installer hooks plus the + plugin, and after migration. +- Doctor output in each of those states. +- No hooks left after the plugins are uninstalled. + +It does not cover model-driven skill invocation or the Codex trust dialog (runs +use `--dangerously-bypass-hook-trust`). It also does not cover identity +registration with the hosted service or native compaction: the compaction hooks +are driven with representative payloads. diff --git a/package.json b/package.json index ac69ffd..12cb959 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", "check:boundary": "node scripts/check-boundary.mjs", + "plugins:generate": "node scripts/host-plugins.mjs", + "plugins:check": "node scripts/host-plugins.mjs --check", "release:build": "npm run build && node scripts/build-release.mjs", "release:verify": "node scripts/verify-release.mjs" }, diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json new file mode 100644 index 0000000..8672f4e --- /dev/null +++ b/plugins/claude-code/.claude-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "crosshead", + "displayName": "Crosshead", + "version": "1.0.0", + "description": "Persistent memory across coding agents. Hooks connect this host to the locally installed Crosshead runtime, shared with Claude Code, Codex, Hermes, OpenCode and Pi.", + "author": { + "name": "FriendMachine", + "url": "https://crosshead.sh" + }, + "homepage": "https://crosshead.sh", + "repository": "https://github.com/FriendMachine/crosshead", + "license": "Apache-2.0", + "keywords": [ + "memory", + "continuity", + "hooks", + "crosshead" + ] +} diff --git a/plugins/claude-code/hooks/hooks.json b/plugins/claude-code/hooks/hooks.json new file mode 100644 index 0000000..348aa43 --- /dev/null +++ b/plugins/claude-code/hooks/hooks.json @@ -0,0 +1,58 @@ +{ + "description": "Crosshead persistent memory hooks. They run the locally installed Crosshead runtime.", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|compact", + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/crosshead-hook\" claude-code SessionStart" + } + ] + } + ], + "PreCompact": [ + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/crosshead-hook\" claude-code PreCompact" + } + ] + } + ], + "PostCompact": [ + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/crosshead-hook\" claude-code PostCompact" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/crosshead-hook\" claude-code UserPromptSubmit" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/crosshead-hook\" claude-code Stop" + } + ] + } + ] + } +} diff --git a/plugins/claude-code/scripts/crosshead-hook b/plugins/claude-code/scripts/crosshead-hook new file mode 100755 index 0000000..a1e8c58 --- /dev/null +++ b/plugins/claude-code/scripts/crosshead-hook @@ -0,0 +1,42 @@ +#!/bin/sh +# Crosshead host plugin hook launcher. Generated by scripts/host-plugins.mjs from +# src/runtime/host-hook-spec.ts; do not edit. +# +# Runs the hook entrypoint of the Crosshead runtime installed by +# `curl -fsSL https://crosshead.sh/install | bash`. It never downloads or +# installs code. Without a configured runtime it prints a setup notice on +# SessionStart and exits 0 so the session is never blocked. +host="$1" +event="$2" +case "$host" in + claude-code) + hook=crosshead-claude-code-hook + config="${CLAUDE_CONFIG_DIR:-${CLAUDE_CODE_HOME:-$HOME/.claude}}/onlybots/claude-code-adapter.json" + setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up: run /crosshead:setup.","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Crosshead isn'"'"'t set up: run /crosshead:setup."}}' + update_notice='{"systemMessage":"The installed Crosshead runtime predates the Claude Code plugin: run /crosshead:setup to update it.","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"The installed Crosshead runtime predates the Claude Code plugin: run /crosshead:setup to update it."}}' + ;; + codex) + hook=crosshead-codex-hook + config="${CODEX_HOME:-$HOME/.codex}/onlybots/codex-adapter.json" + setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up: ask Codex to use the crosshead setup skill ($crosshead:setup).","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Crosshead isn'"'"'t set up: ask Codex to use the crosshead setup skill ($crosshead:setup)."}}' + update_notice='{"systemMessage":"The installed Crosshead runtime predates the Codex plugin: ask Codex to use the crosshead setup skill ($crosshead:setup) to update it.","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"The installed Crosshead runtime predates the Codex plugin: ask Codex to use the crosshead setup skill ($crosshead:setup) to update it."}}' + ;; + *) exit 0 ;; +esac +home="${CROSSHEAD_HOME:-${CROSSHEAD_RUNTIME_HOME:-${ONLYBOTS_HOME:-${ONLYBOTS_RUNTIME_HOME:-}}}}" +if [ -z "$home" ]; then + if [ ! -e "$HOME/.crosshead" ] && [ -d "$HOME/.onlybots" ]; then home="$HOME/.onlybots"; else home="$HOME/.crosshead"; fi +fi +wrapper="$home/bin/$hook" +if [ -x "$wrapper" ] && [ -f "$config" ] && grep -q '"hookMode"' "$config" 2>/dev/null; then + exec "$wrapper" "$event" --home "$home" --via plugin +fi +cat > /dev/null 2>&1 || true +if [ "$event" = SessionStart ]; then + if [ -x "$wrapper" ] && [ -f "$config" ]; then + printf '%s\n' "$update_notice" + else + printf '%s\n' "$setup_notice" + fi +fi +exit 0 diff --git a/plugins/claude-code/skills/setup/SKILL.md b/plugins/claude-code/skills/setup/SKILL.md new file mode 100644 index 0000000..1b60c28 --- /dev/null +++ b/plugins/claude-code/skills/setup/SKILL.md @@ -0,0 +1,53 @@ +--- +name: setup +description: Install or update the Crosshead runtime and connect it to Claude Code. Use when a Crosshead notice says it isn't set up or predates the plugin, or when the user asks to set up Crosshead memory. +--- + +# Set up Crosshead for Claude Code + + + +This plugin contains only hooks and this skill. The hooks run the Crosshead +runtime installed on this machine (`~/.crosshead` unless `CROSSHEAD_HOME` is +set). One runtime, daemon and identity is shared by Claude Code, Codex, Hermes, +OpenCode and Pi. Setup installs that runtime and connects it to Claude Code. + +Run each command with your shell tool. Use `"${CROSSHEAD_HOME:-$HOME/.crosshead}/bin/crosshead"` +for `crosshead` if it is not on PATH. + +1. Check the current state: `crosshead doctor`. If the runtime is installed and + the `claude_code_hooks` check is ok with `mode=plugin`, setup is already + done; report that and stop. +2. Explain the next two steps to the user and ask for explicit consent. Do not + continue without a clear yes. + - The installer is downloaded from https://crosshead.sh/install and run with + bash. It downloads one release tarball, checks its SHA-256 against the + digest pinned in the installer, installs three npm dependencies with install + scripts disabled, and writes wrappers to `~/.crosshead/bin`. Runtimes + never update themselves. + - Quickstart creates a local identity if there is none (its private key stays + in `~/.crosshead`), registers it with the hosted service at + https://crosshead.sh and starts the local daemon. An existing identity is + reused. +3. Install or update the runtime: + + ```sh + curl -fsSL https://crosshead.sh/install | bash + ``` + +4. Connect Claude Code: + + ```sh + crosshead quickstart --api-base https://crosshead.sh --adapter claude-code --workspace-root . --hook-mode plugin + ``` + + `--hook-mode plugin` keeps quickstart from writing its own Claude Code hooks + (this plugin provides them) and removes Crosshead hooks an earlier install + wrote, so no event is captured twice. +5. Verify with `crosshead doctor`. Expect `ok claude_code_hooks: mode=plugin`. + Report any failed check to the user verbatim; do not work around it. +6. Start a new Claude Code session (or run /reload-plugins) so the hooks load with the runtime in place. + +Other hosts and scripts keep using `curl -fsSL https://crosshead.sh/install | bash` +followed by `crosshead quickstart --adapter `. Remove Crosshead from +Claude Code by uninstalling this plugin; `crosshead uninstall` removes the runtime. diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json new file mode 100644 index 0000000..4990c8f --- /dev/null +++ b/plugins/codex/.codex-plugin/plugin.json @@ -0,0 +1,31 @@ +{ + "name": "crosshead", + "version": "1.0.0", + "description": "Persistent memory across coding agents. Hooks connect this host to the locally installed Crosshead runtime, shared with Claude Code, Codex, Hermes, OpenCode and Pi.", + "author": { + "name": "FriendMachine", + "url": "https://crosshead.sh" + }, + "homepage": "https://crosshead.sh", + "repository": "https://github.com/FriendMachine/crosshead", + "license": "Apache-2.0", + "keywords": [ + "memory", + "continuity", + "hooks", + "crosshead" + ], + "skills": "./skills/", + "hooks": "./hooks/hooks.json", + "interface": { + "displayName": "Crosshead", + "shortDescription": "Persistent memory shared across coding agents", + "developerName": "FriendMachine", + "category": "Productivity", + "capabilities": [ + "Read", + "Write" + ], + "websiteURL": "https://crosshead.sh" + } +} diff --git a/plugins/codex/hooks/hooks.json b/plugins/codex/hooks/hooks.json new file mode 100644 index 0000000..916dd28 --- /dev/null +++ b/plugins/codex/hooks/hooks.json @@ -0,0 +1,67 @@ +{ + "description": "Crosshead persistent memory hooks. They run the locally installed Crosshead runtime.", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|compact", + "hooks": [ + { + "type": "command", + "command": "sh \"${PLUGIN_ROOT}/scripts/crosshead-hook\" codex SessionStart", + "statusMessage": "Crosshead session sync" + } + ] + } + ], + "PreCompact": [ + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": "sh \"${PLUGIN_ROOT}/scripts/crosshead-hook\" codex PreCompact", + "statusMessage": "Crosshead pre-compact checkpoint", + "timeout": 30 + } + ] + } + ], + "PostCompact": [ + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": "sh \"${PLUGIN_ROOT}/scripts/crosshead-hook\" codex PostCompact", + "statusMessage": "Crosshead post-compact sync", + "timeout": 30 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${PLUGIN_ROOT}/scripts/crosshead-hook\" codex UserPromptSubmit", + "statusMessage": "Crosshead context", + "timeout": 45 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${PLUGIN_ROOT}/scripts/crosshead-hook\" codex Stop", + "statusMessage": "Crosshead memory sync", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/plugins/codex/scripts/crosshead-hook b/plugins/codex/scripts/crosshead-hook new file mode 100755 index 0000000..a1e8c58 --- /dev/null +++ b/plugins/codex/scripts/crosshead-hook @@ -0,0 +1,42 @@ +#!/bin/sh +# Crosshead host plugin hook launcher. Generated by scripts/host-plugins.mjs from +# src/runtime/host-hook-spec.ts; do not edit. +# +# Runs the hook entrypoint of the Crosshead runtime installed by +# `curl -fsSL https://crosshead.sh/install | bash`. It never downloads or +# installs code. Without a configured runtime it prints a setup notice on +# SessionStart and exits 0 so the session is never blocked. +host="$1" +event="$2" +case "$host" in + claude-code) + hook=crosshead-claude-code-hook + config="${CLAUDE_CONFIG_DIR:-${CLAUDE_CODE_HOME:-$HOME/.claude}}/onlybots/claude-code-adapter.json" + setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up: run /crosshead:setup.","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Crosshead isn'"'"'t set up: run /crosshead:setup."}}' + update_notice='{"systemMessage":"The installed Crosshead runtime predates the Claude Code plugin: run /crosshead:setup to update it.","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"The installed Crosshead runtime predates the Claude Code plugin: run /crosshead:setup to update it."}}' + ;; + codex) + hook=crosshead-codex-hook + config="${CODEX_HOME:-$HOME/.codex}/onlybots/codex-adapter.json" + setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up: ask Codex to use the crosshead setup skill ($crosshead:setup).","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Crosshead isn'"'"'t set up: ask Codex to use the crosshead setup skill ($crosshead:setup)."}}' + update_notice='{"systemMessage":"The installed Crosshead runtime predates the Codex plugin: ask Codex to use the crosshead setup skill ($crosshead:setup) to update it.","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"The installed Crosshead runtime predates the Codex plugin: ask Codex to use the crosshead setup skill ($crosshead:setup) to update it."}}' + ;; + *) exit 0 ;; +esac +home="${CROSSHEAD_HOME:-${CROSSHEAD_RUNTIME_HOME:-${ONLYBOTS_HOME:-${ONLYBOTS_RUNTIME_HOME:-}}}}" +if [ -z "$home" ]; then + if [ ! -e "$HOME/.crosshead" ] && [ -d "$HOME/.onlybots" ]; then home="$HOME/.onlybots"; else home="$HOME/.crosshead"; fi +fi +wrapper="$home/bin/$hook" +if [ -x "$wrapper" ] && [ -f "$config" ] && grep -q '"hookMode"' "$config" 2>/dev/null; then + exec "$wrapper" "$event" --home "$home" --via plugin +fi +cat > /dev/null 2>&1 || true +if [ "$event" = SessionStart ]; then + if [ -x "$wrapper" ] && [ -f "$config" ]; then + printf '%s\n' "$update_notice" + else + printf '%s\n' "$setup_notice" + fi +fi +exit 0 diff --git a/plugins/codex/skills/setup/SKILL.md b/plugins/codex/skills/setup/SKILL.md new file mode 100644 index 0000000..54c52ec --- /dev/null +++ b/plugins/codex/skills/setup/SKILL.md @@ -0,0 +1,54 @@ +--- +name: setup +description: Install or update the Crosshead runtime and connect it to Codex. Use when a Crosshead notice says it isn't set up or predates the plugin, or when the user asks to set up Crosshead memory. +--- + +# Set up Crosshead for Codex + + + +This plugin contains only hooks and this skill. The hooks run the Crosshead +runtime installed on this machine (`~/.crosshead` unless `CROSSHEAD_HOME` is +set). One runtime, daemon and identity is shared by Claude Code, Codex, Hermes, +OpenCode and Pi. Setup installs that runtime and connects it to Codex. + +Run each command with your shell tool. Use `"${CROSSHEAD_HOME:-$HOME/.crosshead}/bin/crosshead"` +for `crosshead` if it is not on PATH. + +1. Check the current state: `crosshead doctor`. If the runtime is installed and + the `codex_hooks` check is ok with `mode=plugin`, setup is already + done; report that and stop. +2. Explain the next two steps to the user and ask for explicit consent. Do not + continue without a clear yes. + - The installer is downloaded from https://crosshead.sh/install and run with + bash. It downloads one release tarball, checks its SHA-256 against the + digest pinned in the installer, installs three npm dependencies with install + scripts disabled, and writes wrappers to `~/.crosshead/bin`. Runtimes + never update themselves. + - Quickstart creates a local identity if there is none (its private key stays + in `~/.crosshead`), registers it with the hosted service at + https://crosshead.sh and starts the local daemon. An existing identity is + reused. +3. Install or update the runtime: + + ```sh + curl -fsSL https://crosshead.sh/install | bash + ``` + +4. Connect Codex: + + ```sh + crosshead quickstart --api-base https://crosshead.sh --adapter codex --workspace-root . --hook-mode plugin + ``` + + `--hook-mode plugin` keeps quickstart from writing its own Codex hooks + (this plugin provides them) and removes Crosshead hooks an earlier install + wrote, so no event is captured twice. +5. Verify with `crosshead doctor`. Expect `ok codex_hooks: mode=plugin`. + Report any failed check to the user verbatim; do not work around it. +6. Codex runs plugin hooks only after you trust them. Ask the user to open `/hooks`, review the Crosshead hooks and trust them. A changed plugin version needs a new review. +7. Start a new Codex session so the hooks load with the runtime in place. + +Other hosts and scripts keep using `curl -fsSL https://crosshead.sh/install | bash` +followed by `crosshead quickstart --adapter `. Remove Crosshead from +Codex by uninstalling this plugin; `crosshead uninstall` removes the runtime. diff --git a/scripts/host-plugins.mjs b/scripts/host-plugins.mjs new file mode 100644 index 0000000..26a70a3 --- /dev/null +++ b/scripts/host-plugins.mjs @@ -0,0 +1,253 @@ +#!/usr/bin/env node +// Generates the Claude Code and Codex plugins and their marketplace files from +// the hook table in src/runtime/host-hook-spec.ts, the same table the legacy +// `crosshead install claude-code|codex` writer uses. +// +// node scripts/host-plugins.mjs write the generated files +// node scripts/host-plugins.mjs --check fail if a checked-in file differs +// +// Uses the compiled spec in dist/ (run `npm run build` first). The plugins are +// thin: hooks, a setup skill and a launcher that runs the installed runtime. +// They never bundle, download or install runtime code. +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +const require = createRequire(import.meta.url); +const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); + +const json = (value) => `${JSON.stringify(value, null, 2)}\n`; + +const author = { name: "FriendMachine", url: "https://crosshead.sh" }; +const homepage = "https://crosshead.sh"; +const repository = "https://github.com/FriendMachine/crosshead"; +const description = + "Persistent memory across coding agents. Hooks connect this host to the locally installed Crosshead runtime, shared with Claude Code, Codex, Hermes, OpenCode and Pi."; +const keywords = ["memory", "continuity", "hooks", "crosshead"]; + +const HOSTS = { + "claude-code": { + dir: "plugins/claude-code", + label: "Claude Code", + configFile: '${CLAUDE_CONFIG_DIR:-${CLAUDE_CODE_HOME:-$HOME/.claude}}/onlybots/claude-code-adapter.json', + hookWrapper: "crosshead-claude-code-hook", + doctorCheck: "claude_code_hooks", + activate: "Start a new Claude Code session (or run /reload-plugins) so the hooks load with the runtime in place.", + trust: null, + }, + codex: { + dir: "plugins/codex", + label: "Codex", + configFile: '${CODEX_HOME:-$HOME/.codex}/onlybots/codex-adapter.json', + hookWrapper: "crosshead-codex-hook", + doctorCheck: "codex_hooks", + activate: "Start a new Codex session so the hooks load with the runtime in place.", + trust: + "Codex runs plugin hooks only after you trust them. Ask the user to open `/hooks`, review the Crosshead hooks and trust them. A changed plugin version needs a new review.", + }, +}; + +const shQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`; + +const noticeJson = (message) => + JSON.stringify({ systemMessage: message, hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: message } }); + +const renderLauncher = (spec) => { + const cases = Object.entries(HOSTS) + .map( + ([host, info]) => ` ${host}) + hook=${info.hookWrapper} + config="${info.configFile}" + setup_notice=${shQuote(noticeJson(spec.pluginSetupHint(host)))} + update_notice=${shQuote(noticeJson(spec.pluginUpdateHint(host)))} + ;;` + ) + .join("\n"); + return `#!/bin/sh +# Crosshead host plugin hook launcher. Generated by scripts/host-plugins.mjs from +# src/runtime/host-hook-spec.ts; do not edit. +# +# Runs the hook entrypoint of the Crosshead runtime installed by +# \`curl -fsSL https://crosshead.sh/install | bash\`. It never downloads or +# installs code. Without a configured runtime it prints a setup notice on +# SessionStart and exits 0 so the session is never blocked. +host="$1" +event="$2" +case "$host" in +${cases} + *) exit 0 ;; +esac +home="\${CROSSHEAD_HOME:-\${CROSSHEAD_RUNTIME_HOME:-\${ONLYBOTS_HOME:-\${ONLYBOTS_RUNTIME_HOME:-}}}}" +if [ -z "$home" ]; then + if [ ! -e "$HOME/.crosshead" ] && [ -d "$HOME/.onlybots" ]; then home="$HOME/.onlybots"; else home="$HOME/.crosshead"; fi +fi +wrapper="$home/bin/$hook" +if [ -x "$wrapper" ] && [ -f "$config" ] && grep -q '"hookMode"' "$config" 2>/dev/null; then + exec "$wrapper" "$event" --home "$home" ${spec.PLUGIN_HOOK_VIA_FLAG.join(" ")} +fi +cat > /dev/null 2>&1 || true +if [ "$event" = SessionStart ]; then + if [ -x "$wrapper" ] && [ -f "$config" ]; then + printf '%s\\n' "$update_notice" + else + printf '%s\\n' "$setup_notice" + fi +fi +exit 0 +`; +}; + +const renderSetupSkill = (host) => { + const info = HOSTS[host]; + return `--- +name: setup +description: Install or update the Crosshead runtime and connect it to ${info.label}. Use when a Crosshead notice says it isn't set up or predates the plugin, or when the user asks to set up Crosshead memory. +--- + +# Set up Crosshead for ${info.label} + + + +This plugin contains only hooks and this skill. The hooks run the Crosshead +runtime installed on this machine (\`~/.crosshead\` unless \`CROSSHEAD_HOME\` is +set). One runtime, daemon and identity is shared by Claude Code, Codex, Hermes, +OpenCode and Pi. Setup installs that runtime and connects it to ${info.label}. + +Run each command with your shell tool. Use \`"\${CROSSHEAD_HOME:-$HOME/.crosshead}/bin/crosshead"\` +for \`crosshead\` if it is not on PATH. + +1. Check the current state: \`crosshead doctor\`. If the runtime is installed and + the \`${info.doctorCheck}\` check is ok with \`mode=plugin\`, setup is already + done; report that and stop. +2. Explain the next two steps to the user and ask for explicit consent. Do not + continue without a clear yes. + - The installer is downloaded from https://crosshead.sh/install and run with + bash. It downloads one release tarball, checks its SHA-256 against the + digest pinned in the installer, installs three npm dependencies with install + scripts disabled, and writes wrappers to \`~/.crosshead/bin\`. Runtimes + never update themselves. + - Quickstart creates a local identity if there is none (its private key stays + in \`~/.crosshead\`), registers it with the hosted service at + https://crosshead.sh and starts the local daemon. An existing identity is + reused. +3. Install or update the runtime: + + \`\`\`sh + curl -fsSL https://crosshead.sh/install | bash + \`\`\` + +4. Connect ${info.label}: + + \`\`\`sh + crosshead quickstart --api-base https://crosshead.sh --adapter ${host} --workspace-root . --hook-mode plugin + \`\`\` + + \`--hook-mode plugin\` keeps quickstart from writing its own ${info.label} hooks + (this plugin provides them) and removes Crosshead hooks an earlier install + wrote, so no event is captured twice. +5. Verify with \`crosshead doctor\`. Expect \`ok ${info.doctorCheck}: mode=plugin\`. + Report any failed check to the user verbatim; do not work around it. +${info.trust ? `6. ${info.trust}\n7. ${info.activate}` : `6. ${info.activate}`} + +Other hosts and scripts keep using \`curl -fsSL https://crosshead.sh/install | bash\` +followed by \`crosshead quickstart --adapter \`. Remove Crosshead from +${info.label} by uninstalling this plugin; \`crosshead uninstall\` removes the runtime. +`; +}; + +/** Map of repository-relative path to file contents. `spec` is host-hook-spec. */ +export const renderHostPluginFiles = (spec) => { + const version = spec.CROSSHEAD_HOST_PLUGIN_VERSION; + const name = spec.CROSSHEAD_PLUGIN_NAME; + const launcher = renderLauncher(spec); + const files = { + ".claude-plugin/marketplace.json": json({ + name: spec.CROSSHEAD_MARKETPLACE_NAME, + owner: author, + metadata: { description: "Crosshead plugins for Claude Code. Codex reads .agents/plugins/marketplace.json." }, + plugins: [{ name, source: `./${HOSTS["claude-code"].dir}`, description, version }], + }), + ".agents/plugins/marketplace.json": json({ + name: spec.CROSSHEAD_MARKETPLACE_NAME, + interface: { displayName: "Crosshead" }, + plugins: [ + { + name, + source: { source: "local", path: `./${HOSTS.codex.dir}` }, + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: "Productivity", + }, + ], + }), + [`${HOSTS["claude-code"].dir}/.claude-plugin/plugin.json`]: json({ + name, + displayName: "Crosshead", + version, + description, + author, + homepage, + repository, + license: "Apache-2.0", + keywords, + }), + [`${HOSTS.codex.dir}/.codex-plugin/plugin.json`]: json({ + name, + version, + description, + author, + homepage, + repository, + license: "Apache-2.0", + keywords, + skills: "./skills/", + hooks: "./hooks/hooks.json", + interface: { + displayName: "Crosshead", + shortDescription: "Persistent memory shared across coding agents", + developerName: "FriendMachine", + category: "Productivity", + capabilities: ["Read", "Write"], + websiteURL: homepage, + }, + }), + }; + for (const host of Object.keys(HOSTS)) { + const dir = HOSTS[host].dir; + files[`${dir}/hooks/hooks.json`] = spec.renderPluginHooksFile(host); + files[`${dir}/${spec.PLUGIN_HOOK_LAUNCHER}`] = launcher; + files[`${dir}/skills/setup/SKILL.md`] = renderSetupSkill(host); + } + return files; +}; + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); +if (isMain) { + const specPath = path.join(root, "dist", "runtime", "host-hook-spec.js"); + if (!existsSync(specPath)) { + console.error("Missing dist/runtime/host-hook-spec.js; run npm run build first."); + process.exit(1); + } + const spec = require(specPath); + const files = renderHostPluginFiles(spec); + const check = process.argv.includes("--check"); + const stale = []; + for (const [relative, content] of Object.entries(files)) { + const target = path.join(root, relative); + const current = existsSync(target) ? readFileSync(target, "utf8") : null; + if (current === content) continue; + if (check) { + stale.push(relative); + continue; + } + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, content); + if (relative.endsWith(spec.PLUGIN_HOOK_LAUNCHER)) chmodSync(target, 0o755); + console.log(`wrote ${relative}`); + } + if (stale.length > 0) { + console.error(`Generated host plugin files are stale; run node scripts/host-plugins.mjs:\n${stale.join("\n")}`); + process.exit(1); + } + if (check) console.log(`Host plugin files match the hook spec (${Object.keys(files).length} files).`); +} + diff --git a/scripts/smoke-host-plugins.mjs b/scripts/smoke-host-plugins.mjs new file mode 100644 index 0000000..b784a70 --- /dev/null +++ b/scripts/smoke-host-plugins.mjs @@ -0,0 +1,385 @@ +// Run inside a disposable container with the real Claude Code and Codex CLIs on +// PATH, this repository read-only at /crosshead (with node_modules and a local +// `npm run release:build` in release/), and a writable /test. No credentials: +// a local fixture stands in for the Anthropic and OpenAI APIs and for the +// crosshead.sh installer and release download. Hosted Crosshead calls go to an +// unreachable local address; nothing reaches a production service. +// +// Proves: plugins install from this repository's marketplace files; the +// plugins validate; without a runtime SessionStart prints one setup line and +// exits 0; the setup skill's commands connect the runtime without writing +// legacy hooks; with a legacy install present each event is captured exactly +// once before and after migration; uninstalling the plugins leaves no hooks. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawn, spawnSync } from "node:child_process"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { createRequire } from "node:module"; +import path from "node:path"; + +const require = createRequire(import.meta.url); +const { generateSecretKey, getPublicKey, nip19 } = require("nostr-tools"); +const persistence = require("/crosshead/dist/runtime/runtime-persistence.js"); +const spec = require("/crosshead/dist/runtime/host-hook-spec.js"); + +const repo = "/crosshead"; +const root = process.env.CROSSHEAD_SMOKE_ROOT ?? "/test"; +const logDir = path.join(root, "logs"); +mkdirSync(logDir, { recursive: true }); +const results = { checks: [], notExercised: [] }; +const check = (label) => { + results.checks.push(label); + console.log(`ok - ${label}`); +}; + +// ---- Fixture: model APIs, installer and release download ----------------- +const requests = []; +const replyText = "Fixture reply: findings recorded, next step is the transport round-trip check."; +const pkg = JSON.parse(readFileSync(path.join(repo, "package.json"), "utf8")); +const tarball = readFileSync(path.join(repo, "release", `crosshead-runtime-install-${pkg.version}.tgz`)); +const tarballSha = createHash("sha256").update(tarball).digest("hex"); +const sse = (res, events) => { + res.writeHead(200, { "content-type": "text/event-stream" }); + for (const [event, data] of events) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + res.end(); +}; +let installerScript = ""; +const server = createServer(async (req, res) => { + let raw = ""; + for await (const chunk of req) raw += chunk; + requests.push({ url: req.url, body: raw }); + appendFileSync(path.join(logDir, "fixture-requests.log"), `${req.method} ${req.url} ${raw.length}\n`); + if (req.url === "/install") return res.end(installerScript); + if (req.url === "/install/releases/crosshead-runtime.tgz") return res.end(tarball); + let body = {}; + try { body = JSON.parse(raw); } catch {} + if (req.url.startsWith("/v1/messages/count_tokens")) return res.end(JSON.stringify({ input_tokens: 10 })); + if (req.url.startsWith("/v1/messages")) { + const message = { id: "msg_fixture", type: "message", role: "assistant", model: body.model ?? "fixture", content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 10, output_tokens: 1 } }; + if (!body.stream) return res.end(JSON.stringify({ ...message, content: [{ type: "text", text: replyText }], stop_reason: "end_turn" })); + return sse(res, [ + ["message_start", { type: "message_start", message }], + ["content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }], + ["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: replyText } }], + ["content_block_stop", { type: "content_block_stop", index: 0 }], + ["message_delta", { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 5 } }], + ["message_stop", { type: "message_stop" }], + ]); + } + if (req.url.startsWith("/v1/responses")) { + const item = { id: "msg_fixture", type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: replyText, annotations: [] }] }; + const response = { id: "resp_fixture", object: "response", created_at: 1_800_000_000, status: "in_progress", model: body.model ?? "fixture", output: [] }; + return sse(res, [ + ["response.created", { type: "response.created", response }], + ["response.output_item.added", { type: "response.output_item.added", output_index: 0, item: { ...item, status: "in_progress", content: [] } }], + ["response.output_text.delta", { type: "response.output_text.delta", item_id: item.id, output_index: 0, content_index: 0, delta: replyText }], + ["response.output_item.done", { type: "response.output_item.done", output_index: 0, item }], + ["response.completed", { type: "response.completed", response: { ...response, status: "completed", output: [item], usage: { input_tokens: 10, input_tokens_details: { cached_tokens: 0 }, output_tokens: 5, output_tokens_details: { reasoning_tokens: 0 }, total_tokens: 15 } } }], + ]); + } + res.writeHead(404); + res.end("{}"); +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const base = `http://127.0.0.1:${server.address().port}`; +// The digest-checked installer from this repository, pointed at the local release. +installerScript = readFileSync(path.join(repo, "docs", "security-review", "install.sh"), "utf8") + .replace(/^BASE_URL=.*$/m, `BASE_URL="${base}"`) + .replace(/^RELEASE_ID=.*$/m, `RELEASE_ID="${pkg.version}-${tarballSha.slice(0, 12)}"`) + .replace(/^RELEASE_URL=.*$/m, `RELEASE_URL="${base}/install/releases/onlybots-runtime.tgz"`) + .replace(/^CROSSHEAD_RELEASE_URL=.*$/m, `CROSSHEAD_RELEASE_URL="${base}/install/releases/crosshead-runtime.tgz"`) + .replace(/^RELEASE_SHA256=.*$/m, `RELEASE_SHA256="${tarballSha}"`); + +// ---- Process helpers ------------------------------------------------------- +const homeEnv = (home) => ({ + // ~/.crosshead/bin stays off PATH: its claude/codex launchers check crosshead.sh for updates. + PATH: process.env.PATH, + HOME: home, + LANG: "C.UTF-8", + CROSSHEAD_HOT_SYNC_ENABLED: "false", + ANTHROPIC_BASE_URL: base, + ANTHROPIC_API_KEY: "fixture-not-a-real-key", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + DISABLE_AUTOUPDATER: "1", + FIXTURE_API_KEY: "fixture-only", + DO_NOT_TRACK: "1", +}); +let logIndex = 0; +const run = async (label, home, command, args, { cwd = home, input = "", allowFailure = false, env = {} } = {}) => { + const child = spawn(command, args, { cwd, env: { ...homeEnv(home), ...env }, stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.stdin.end(input); + const timer = setTimeout(() => child.kill("SIGKILL"), 150_000); + const code = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", resolve); }).finally(() => clearTimeout(timer)); + const file = path.join(logDir, `${String(++logIndex).padStart(2, "0")}-${label}.log`); + writeFileSync(file, `$ ${command} ${args.join(" ")}\nexit=${code}\n--- stdout\n${stdout}\n--- stderr\n${stderr}`); + if (!allowFailure) assert.equal(code, 0, `${label} exited ${code}; see ${file}`); + return { code, stdout, stderr, file }; +}; + +const makeHome = (name) => { + const home = path.join(root, name); + const project = path.join(home, "project"); + mkdirSync(project, { recursive: true }); + // The Claude CLI's project-trust prompt is skipped in -p mode; record onboarding as done. + writeFileSync(path.join(home, ".claude.json"), JSON.stringify({ hasCompletedOnboarding: true, projects: { [project]: { hasTrustDialogAccepted: true } } })); + mkdirSync(path.join(home, ".codex"), { recursive: true }); + writeFileSync( + path.join(home, ".codex", "config.toml"), + `model = "fixture-model"\nmodel_provider = "fixture"\n\n[model_providers.fixture]\nname = "Fixture"\nbase_url = "${base}/v1"\nwire_api = "responses"\nenv_key = "FIXTURE_API_KEY"\n` + ); + return { home, project }; +}; + +const addMarketplaces = async (home) => { + await run("claude-marketplace-add", home, "claude", ["plugin", "marketplace", "add", repo]); + await run("claude-plugin-install", home, "claude", ["plugin", "install", "crosshead@crosshead"]); + await run("codex-marketplace-add", home, "codex", ["plugin", "marketplace", "add", repo]); + await run("codex-plugin-add", home, "codex", ["plugin", "add", "crosshead@crosshead"]); + const list = await run("codex-plugin-list", home, "codex", ["plugin", "list"]); + assert.match(list.stdout, /\.agents\/plugins\/marketplace\.json/); + assert.match(list.stdout, /crosshead@crosshead\s+installed.*\/crosshead\/plugins\/codex/); + const claudeList = JSON.parse((await run("claude-plugin-list", home, "claude", ["plugin", "list", "--json"])).stdout); + assert(claudeList.some((entry) => entry.id === "crosshead@crosshead" && entry.enabled === true), "Claude plugin not enabled"); +}; + +const seedProfile = (home) => { + // Stands in for `quickstart` identity creation, which registers with the hosted service. + const paths = persistence.ensureRuntimeHomeLayout(path.join(home, ".crosshead")); + if (persistence.loadProfile(paths, "default")) return; + const key = generateSecretKey(); + const now = new Date().toISOString(); + persistence.saveProfile(paths, { + name: "default", apiBase: "http://127.0.0.1:9", npub: nip19.npubEncode(getPublicKey(key)), memoryMode: "assist", workspaceScope: "repo", + features: { feedback: false, relay: false, coldSync: false, autoWarmPromotion: false }, secureStoreMode: "file", createdAt: now, updatedAt: now, + }); + persistence.saveSecret(paths, "default", { nsec: nip19.nsecEncode(key), session: null }); + persistence.saveRuntimeConfig(paths, { ...persistence.loadRuntimeConfig(paths), defaultProfile: "default" }); +}; + +const installRuntime = async (home) => { + await run("installer", home, "bash", ["-c", `curl -fsSL ${base}/install | bash`]); + assert(existsSync(path.join(home, ".crosshead", "bin", "crosshead"))); + await run("update-pin", home, xhd(home), ["update", "--pin"]); + seedProfile(home); +}; + +const xhd = (home) => path.join(home, ".crosshead", "bin", "crosshead"); +const lifecycle = (home) => { + const file = path.join(home, ".crosshead", "state", "lifecycle-events.jsonl"); + return existsSync(file) ? readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)) : []; +}; +const eventCounts = (events, sessionId) => { + const counts = {}; + for (const event of events) if (!sessionId || event.sessionId === sessionId) counts[event.type] = (counts[event.type] ?? 0) + 1; + return counts; +}; + +// One real headless session per host. Returns the host's session id and its hook-run log. +const claudeSession = async (label, home, project) => { + const debugFile = path.join(logDir, `claude-debug-${label}.txt`); + const result = await run(`claude-session-${label}`, home, "claude", ["-p", "Investigate the duration serializer and report findings.", "--output-format", "json", "--debug-file", debugFile], { cwd: project }); + const sessionId = JSON.parse(result.stdout.trim().split("\n").at(-1)).session_id; + const hookRuns = readFileSync(debugFile, "utf8").split("\n").filter((line) => /Hooks: \S+ \[.*\] .*finished with status/.test(line)); + return { sessionId, hookRuns, result }; +}; +const codexSession = async (label, home, project) => { + // The human-readable output lists each hook run ("hook: Stop Completed"); --json omits them. + const result = await run(`codex-session-${label}`, home, "codex", ["exec", "--skip-git-repo-check", "--dangerously-bypass-hook-trust", "Investigate the duration serializer and report findings."], { cwd: project }); + const output = `${result.stdout}\n${result.stderr}`; + const sessionId = /^session id: (\S+)/m.exec(output)?.[1] ?? null; + const hookRuns = output.split("\n").map((line) => line.trim()).filter((line) => /^hook: \S+ Completed/.test(line)); + return { sessionId, hookRuns, result }; +}; +const waitFor = async (predicate, label) => { + for (let attempt = 0; attempt < 100; attempt++) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.fail(`timed out waiting for ${label}`); +}; +// Session ids are redacted in the lifecycle log, so each session is measured as the +// change in event counts it causes. Sessions run one at a time. +const baselines = {}; +const measuredSession = async (home, host, label, runSession) => { + const before = eventCounts(lifecycle(home)); + const session = await runSession(); + await waitFor(() => (eventCounts(lifecycle(home))["turn.completed"] ?? 0) > (before["turn.completed"] ?? 0), `${host} ${label} capture`); + await new Promise((resolve) => setTimeout(resolve, 1500)); // let any duplicate land before counting + const after = eventCounts(lifecycle(home)); + const delta = Object.fromEntries(Object.keys(after).map((type) => [type, after[type] - (before[type] ?? 0)]).filter(([, count]) => count > 0)); + assert.equal(delta["turn.completed"], 1, `${host} ${label}: ${JSON.stringify(delta)}`); + if (baselines[host]) assert.deepEqual(delta, baselines[host], `${host} ${label}: events differ from the single-registration baseline`); + else baselines[host] = delta; + results[`${host}:${label}`] = { lifecycleDelta: delta, hookRuns: session.hookRuns.length }; + return session; +}; +const crossheadHookLines = (session) => session.hookRuns.filter((line) => /crosshead/.test(line) || /^hook:/.test(line.trim())); +const doctor = async (home, project) => JSON.parse((await run("doctor", home, xhd(home), ["doctor", "--json", "--workspace-root", project], { cwd: project, allowFailure: true })).stdout); +const doctorCheck = (report, id) => report.checks.find((entry) => entry.id === id); + +const pluginHookCommand = (home, host, event) => { + const hooksFile = host === "codex" + ? path.join(home, ".codex", "plugins", "cache", "crosshead", "crosshead", spec.CROSSHEAD_HOST_PLUGIN_VERSION, "hooks", "hooks.json") + : path.join(repo, "plugins", "claude-code", "hooks", "hooks.json"); + return JSON.parse(readFileSync(hooksFile, "utf8")).hooks[event][0].hooks[0].command; +}; +const pluginRoot = (home, host) => + host === "codex" ? path.join(home, ".codex", "plugins", "cache", "crosshead", "crosshead", spec.CROSSHEAD_HOST_PLUGIN_VERSION) : path.join(repo, "plugins", "claude-code"); +// Runs the exact command a host would, with the plugin-root variables the host sets. +const runPluginHook = (label, home, project, host, event, payload) => + run(`hook-${host}-${event}-${label}`, home, "sh", ["-c", pluginHookCommand(home, host, event)], { + cwd: project, + input: JSON.stringify({ cwd: project, hook_event_name: event, ...payload }), + env: host === "codex" ? { PLUGIN_ROOT: pluginRoot(home, host), CLAUDE_PLUGIN_ROOT: pluginRoot(home, host) } : { CLAUDE_PLUGIN_ROOT: pluginRoot(home, host) }, + }); + +const scanForCrossheadHooks = (home, project) => { + const files = [ + path.join(home, ".claude", "settings.json"), + path.join(project, ".claude", "settings.json"), + path.join(project, ".claude", "settings.local.json"), + path.join(home, ".codex", "hooks.json"), + path.join(home, ".codex", "config.toml"), + ]; + const hits = files.filter((file) => existsSync(file) && /crosshead-(claude-code-|codex-)?hook|crosshead@crosshead/.test(readFileSync(file, "utf8"))); + return hits; +}; + +try { + await run("claude-version", root, "claude", ["--version"]); + await run("codex-version", root, "codex", ["--version"]); + for (const target of [repo, path.join(repo, "plugins", "claude-code")]) { + await run("validate", root, "claude", ["plugin", "validate", target, "--strict"]); + } + check("claude plugin validate --strict passes for the marketplace and the Claude Code plugin"); + + // ---- B1: existing legacy install, no plugins: single-registration baseline --- + const b = makeHome("legacy"); + await installRuntime(b.home); + await run("legacy-install-claude", b.home, xhd(b.home), ["install", "claude-code", "--workspace-root", b.project], { cwd: b.project }); + await run("legacy-install-codex", b.home, xhd(b.home), ["install", "codex"], { cwd: b.project }); + const claudeLegacy = await measuredSession(b.home, "claude-code", "legacy only", () => claudeSession("legacy-only", b.home, b.project)); + assert.equal(crossheadHookLines(claudeLegacy).length, 3); + const codexLegacy = await measuredSession(b.home, "codex", "legacy only", () => codexSession("legacy-only", b.home, b.project)); + assert.equal(codexLegacy.hookRuns.length, 3); + check("legacy settings-mode install: 3 hook runs and one capture per session in each host (baseline)"); + + // ---- A: new user. Plugins first, no runtime -------------------------------- + const a = makeHome("fresh"); + await addMarketplaces(a.home); + check("both plugins install from the repository marketplace (Codex reads .agents/plugins/marketplace.json)"); + for (const host of ["claude-code", "codex"]) { + const start = await runPluginHook("no-runtime", a.home, a.project, host, "SessionStart", { session_id: "no-runtime", source: "startup" }); + assert.equal(start.stdout.trim().split("\n").length, 1); + assert.equal(JSON.parse(start.stdout).systemMessage, spec.pluginSetupHint(host)); + for (const event of ["UserPromptSubmit", "Stop", "PreCompact", "PostCompact"]) { + const other = await runPluginHook("no-runtime", a.home, a.project, host, event, { session_id: "no-runtime", prompt: "x" }); + assert.equal(other.stdout, ""); + } + } + const claudeFresh = await claudeSession("no-runtime", a.home, a.project); + assert.equal(crossheadHookLines(claudeFresh).length, 3, claudeFresh.hookRuns.join("\n")); + assert(claudeFresh.hookRuns.every((line) => line.includes("status 0"))); + assert(requests.some((request) => request.url.startsWith("/v1/messages") && request.body.includes(spec.pluginSetupHint("claude-code"))), "Claude setup hint did not reach model context"); + const codexFresh = await codexSession("no-runtime", a.home, a.project); + assert(requests.some((request) => request.url.startsWith("/v1/responses") && request.body.includes(JSON.stringify(spec.pluginSetupHint("codex")).slice(1, -1))), "Codex setup hint did not reach model context"); + assert.deepEqual(codexFresh.hookRuns.map((line) => line.trim()), ["hook: SessionStart Completed", "hook: UserPromptSubmit Completed", "hook: Stop Completed"]); + assert(!existsSync(path.join(a.home, ".crosshead")), "a plugin hook created runtime state"); + check("without a runtime, SessionStart prints one setup line, every hook exits 0, real sessions complete and nothing is installed"); + + // The setup skill's commands, after consent: digest-checked installer, then quickstart in plugin mode. + await installRuntime(a.home); + for (const adapter of ["claude-code", "codex"]) { + const quick = await run(`quickstart-${adapter}`, a.home, xhd(a.home), ["quickstart", "--api-base", "http://127.0.0.1:9", "--adapter", adapter, "--workspace-root", ".", "--hook-mode", "plugin"], { cwd: a.project }); + assert.match(quick.stdout, /hook_mode: plugin/); + } + assert(!existsSync(path.join(a.project, ".claude", "settings.json")), "quickstart wrote Claude Code settings hooks"); + assert(!existsSync(path.join(a.home, ".codex", "hooks.json")), "quickstart wrote Codex hooks.json"); + const claudeA = await measuredSession(a.home, "claude-code", "after setup", () => claudeSession("setup-done", a.home, a.project)); + assert.equal(crossheadHookLines(claudeA).length, 3); + const codexA = await measuredSession(a.home, "codex", "after setup", () => codexSession("setup-done", a.home, a.project)); + assert.equal(codexA.hookRuns.length, 3); + for (const id of ["claude_code_hooks", "codex_hooks"]) assert.match(doctorCheck(await doctor(a.home, a.project), id).message, /^mode=plugin; plugin enabled/); + check("setup (installer + quickstart --hook-mode plugin) writes no host hooks; one capture per event in both hosts; doctor reports plugin mode"); + + // ---- B2: plugins added to the legacy install, then migration ------------- + await addMarketplaces(b.home); + const claudeDup = await measuredSession(b.home, "claude-code", "legacy + plugin", () => claudeSession("legacy-plus-plugin", b.home, b.project)); + assert.equal(claudeDup.hookRuns.filter((line) => /crosshead/.test(line)).length, 6, "expected both registrations to run"); + const codexDup = await measuredSession(b.home, "codex", "legacy + plugin", () => codexSession("legacy-plus-plugin", b.home, b.project)); + assert.equal(codexDup.hookRuns.length, 6, codexDup.hookRuns.join("\n")); + const before = await doctor(b.home, b.project); + for (const id of ["claude_code_hooks", "codex_hooks"]) assert.match(doctorCheck(before, id).message, /Duplicate registration/); + check("legacy + plugin before migration: both registrations run, exactly one captures, doctor flags the duplicate"); + + for (const adapter of ["claude-code", "codex"]) { + const migrated = await run(`migrate-${adapter}`, b.home, xhd(b.home), ["install", adapter, "--workspace-root", b.project], { cwd: b.project }); + assert.match(migrated.stdout, /hook_mode: plugin/); + assert.match(migrated.stdout, /migrated_legacy_hooks: /); + const again = await run(`migrate-again-${adapter}`, b.home, xhd(b.home), ["install", adapter, "--workspace-root", b.project], { cwd: b.project }); + assert.doesNotMatch(again.stdout, /migrated_legacy_hooks/); + } + const claudeMigrated = await measuredSession(b.home, "claude-code", "after migration", () => claudeSession("migrated", b.home, b.project)); + assert.equal(claudeMigrated.hookRuns.filter((line) => /crosshead/.test(line)).length, 3); + assert(claudeMigrated.hookRuns.every((line) => line.includes("plugin crosshead@crosshead")), "a legacy hook still ran"); + const codexMigrated = await measuredSession(b.home, "codex", "after migration", () => codexSession("migrated", b.home, b.project)); + assert.equal(codexMigrated.hookRuns.length, 3); + for (const host of ["claude-code", "codex"]) { + const before = eventCounts(lifecycle(b.home)); + for (const event of ["PreCompact", "PostCompact"]) { + await runPluginHook("migrated", b.home, b.project, host, event, { session_id: `direct-compact-${host}`, trigger: "manual" }); + } + await waitFor(() => (eventCounts(lifecycle(b.home))["compact.completed"] ?? 0) > (before["compact.completed"] ?? 0), `${host} compact events`); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const after = eventCounts(lifecycle(b.home)); + for (const type of ["compact.started", "compact.completed"]) assert.equal(after[type] - (before[type] ?? 0), 1, `${host} ${type}`); + } + const after = await doctor(b.home, b.project); + for (const id of ["claude_code_hooks", "codex_hooks"]) assert.match(doctorCheck(after, id).message, /^mode=plugin; plugin enabled.*no legacy hooks\.$/); + check("migration is idempotent; afterwards only the plugin hooks run, one capture per event (PreCompact/PostCompact driven directly), doctor ok"); + + // ---- Uninstall the plugins ---------------------------------------------- + await run("claude-plugin-uninstall", b.home, "claude", ["plugin", "uninstall", "crosshead@crosshead"]); + await run("codex-plugin-remove", b.home, "codex", ["plugin", "remove", "crosshead@crosshead"]); + const leftovers = scanForCrossheadHooks(b.home, b.project); + // Hosts may keep an inactive copy of an uninstalled plugin for garbage collection; record it. + const walk = (dir) => (existsSync(dir) ? readdirSync(dir, { withFileTypes: true }).flatMap((entry) => (entry.isDirectory() ? walk(path.join(dir, entry.name)) : [path.join(dir, entry.name)])) : []); + results.inactivePluginCacheAfterUninstall = [path.join(b.home, ".claude", "plugins", "cache", "crosshead"), path.join(b.home, ".codex", "plugins", "cache", "crosshead")].flatMap(walk); + assert.deepEqual(leftovers, [], `orphaned Crosshead hook registrations: ${leftovers.join(", ")}`); + const eventsBefore = lifecycle(b.home).length; + const claudeAfter = await claudeSession("uninstalled", b.home, b.project); + assert.equal(claudeAfter.hookRuns.filter((line) => /crosshead/.test(line)).length, 0); + const codexAfter = await codexSession("uninstalled", b.home, b.project); + assert.equal(codexAfter.hookRuns.length, 0); + assert.equal(lifecycle(b.home).length, eventsBefore, "a hook ran after the plugins were uninstalled"); + const removed = await doctor(b.home, b.project); + for (const id of ["claude_code_hooks", "codex_hooks"]) assert.match(doctorCheck(removed, id).message, /No Crosshead hooks are active/); + check("uninstalling the plugins leaves no Crosshead hooks in any host config; sessions run no Crosshead hook; doctor reports it"); + + results.notExercised.push( + "interactive /crosshead:setup and Codex skill invocation by a model (the skill's commands were run directly)", + "Codex hook trust review (/hooks); runs used --dangerously-bypass-hook-trust", + "identity registration with the hosted service (profile seeded locally; hosted API unreachable by design)", + "native compaction (PreCompact/PostCompact were driven with representative payloads)", + "downloading the installer from crosshead.sh and its production digest (a local rendering of docs/security-review/install.sh served the local release)" + ); + results.ok = true; +} catch (error) { + results.ok = false; + results.error = error instanceof Error ? error.stack : String(error); + process.exitCode = 1; +} finally { + writeFileSync(path.join(root, "host-plugins-result.json"), JSON.stringify(results, null, 2)); + for (const home of [path.join(root, "fresh"), path.join(root, "legacy")]) { + if (existsSync(path.join(home, ".crosshead", "bin", "crosshead"))) { + spawnSync(path.join(home, ".crosshead", "bin", "crosshead"), ["stop"], { env: homeEnv(home), stdio: "ignore", timeout: 20_000 }); + } + } + server.close(); + console.log(JSON.stringify(results, null, 2)); +} diff --git a/scripts/test-host-plugins.sh b/scripts/test-host-plugins.sh new file mode 100755 index 0000000..4ca73d9 --- /dev/null +++ b/scripts/test-host-plugins.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Claude Code and Codex plugin end-to-end check in one disposable container. +# Real host CLIs, local model/installer fixture, no credentials. +# Requires `npm ci && npm run release:build` (dist/ and release/). +set -euo pipefail +plugin_repo=$(cd "$(dirname "$0")/.." && pwd) +plugin_logs=${CROSSHEAD_TEST_LOG_DIR:-"$plugin_repo/.native-host-test-logs/host-plugins"} +# A prebuilt image with both CLIs may be supplied; otherwise they are installed at the pinned versions. +plugin_image=${CROSSHEAD_PLUGIN_TEST_IMAGE:-'node@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da'} +claude_version=${CROSSHEAD_TEST_CLAUDE_VERSION:-2.1.319} +codex_version=${CROSSHEAD_TEST_CODEX_VERSION:-0.167.0} +plugin_container=${CROSSHEAD_PLUGIN_TEST_CONTAINER:-"crosshead-host-plugins-$(date -u +%Y%m%d%H%M%S)-$$"} +version=$(node -p 'require(process.argv[1]).version' "$plugin_repo/package.json") +test -f "$plugin_repo/dist/runtime/host-hook-spec.js" || { printf 'Run npm run build first.\n' >&2; exit 1; } +test -f "$plugin_repo/release/crosshead-runtime-install-$version.tgz" || { printf 'Run npm run release:build first.\n' >&2; exit 1; } +mkdir -p "$plugin_logs" +docker ps -aq | sort > "$plugin_logs/containers-before.txt" +docker volume ls -q | sort > "$plugin_logs/volumes-before.txt" +plugin_cleanup() { + local plugin_status=$? + trap - EXIT INT TERM + docker rm -f -v "$plugin_container" > /dev/null 2>&1 || true + if docker ps -a --format '{{.Names}}' | grep -qx "$plugin_container"; then plugin_status=1; fi + docker ps -aq | sort > "$plugin_logs/containers-after.txt" + docker volume ls -q | sort > "$plugin_logs/volumes-after.txt" + diff -u "$plugin_logs/containers-before.txt" "$plugin_logs/containers-after.txt" > "$plugin_logs/container-diff.txt" || true + diff -u "$plugin_logs/volumes-before.txt" "$plugin_logs/volumes-after.txt" > "$plugin_logs/volume-diff.txt" || true + exit "$plugin_status" +} +trap plugin_cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +docker run -d --name "$plugin_container" --cpus 2 --memory 3g --memory-swap 3g \ + --tmpfs /tmp:rw,size=384m --tmpfs /test:rw,exec,size=768m,uid=1000,gid=1000 \ + --mount "type=bind,src=$plugin_repo,dst=/crosshead,readonly" \ + --user 0:0 "$plugin_image" sleep infinity > "$plugin_logs/container.txt" +docker inspect "$plugin_container" --format '{{.Id}} memory={{.HostConfig.Memory}} swap={{.HostConfig.MemorySwap}} cpus={{.HostConfig.NanoCpus}}' >> "$plugin_logs/container.txt" +docker exec "$plugin_container" bash -c " + set -euo pipefail + command -v curl > /dev/null || { apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl; } + if ! claude --version 2> /dev/null | grep -q '^$claude_version ' || ! codex --version 2> /dev/null | grep -q ' $codex_version\$'; then + npm install --global --no-audit --no-fund @anthropic-ai/claude-code@$claude_version @openai/codex@$codex_version + fi + claude --version; codex --version +" > "$plugin_logs/setup.log" 2>&1 +plugin_status=0 +docker exec --user 1000:1000 "$plugin_container" node /crosshead/scripts/smoke-host-plugins.mjs > "$plugin_logs/smoke.log" 2>&1 || plugin_status=$? +docker exec "$plugin_container" cat /test/host-plugins-result.json > "$plugin_logs/result.json" 2> /dev/null || true +docker exec "$plugin_container" tar -C /test -cf - logs 2> /dev/null | tar -C "$plugin_logs" -xf - 2> /dev/null || true +if [[ $plugin_status -ne 0 ]]; then + printf 'Host plugin checks failed (exit %s). Logs: %s\n' "$plugin_status" "$plugin_logs" >&2 + exit "$plugin_status" +fi +printf 'Claude Code and Codex plugin checks passed. Logs: %s\n' "$plugin_logs" diff --git a/src/runtime/claude-code-adapter.ts b/src/runtime/claude-code-adapter.ts index 9f4ef4d..8821c30 100644 --- a/src/runtime/claude-code-adapter.ts +++ b/src/runtime/claude-code-adapter.ts @@ -59,6 +59,17 @@ import { type HostAdapterTurnPrepareResult, } from "./host-adapter-base"; import { resolveClaudeCodeBinaryPath, resolveClaudeCodeHostHome } from "./runtime-env"; +import { renderHostHookGroups, type HostHookEvent } from "./host-hook-spec"; +import { + claudeProjectSettingsCandidates, + describeHookMode, + detectClaudeCodePlugin, + parseHookModeFlag, + normalizeHostHookMode, + resolveHostHookMode, + type HostHookMode, + type PluginDetection, +} from "./host-plugin-mode"; import type { HandoffCapsule, HandoffInspection, @@ -81,6 +92,8 @@ export type ClaudeCodeAdapterConfig = { workspaceRoot: string; profileName: string; claudeBinary: string; + /** Absent in configs written before host plugins; treated as "settings". */ + hookMode?: HostHookMode; installedAt: string; updatedAt: string; }; @@ -107,12 +120,17 @@ export type ClaudeCodeAdapterInstallOptions = { bridgeScriptPath: string; hookScriptPath: string; claudeBinary?: string; + /** "auto" (default) selects "plugin" when the Crosshead Claude Code plugin is enabled. */ + hookMode?: HostHookMode | "auto"; }; export type ClaudeCodeAdapterInstallResult = { adapter: RuntimeAdapterRecord; config: ClaudeCodeAdapterConfig; managedFiles: string[]; + pluginDetection: PluginDetection | null; + /** Settings files whose legacy Crosshead hooks were removed because the plugin supplies them. */ + migratedFiles: string[]; }; export type ClaudeCodeAdapterUninstallOptions = { @@ -307,6 +325,7 @@ const buildClaudeCodeAdapterConfig = (options: { profileName: string; adapterVersion: string; claudeBinary: string; + hookMode: HostHookMode; }): ClaudeCodeAdapterConfig => ({ marker: CLAUDE_CODE_MANAGED_CONFIG_MARKER, adapterName: "claude-code", @@ -316,13 +335,14 @@ const buildClaudeCodeAdapterConfig = (options: { workspaceRoot: options.workspaceRoot, profileName: options.profileName, claudeBinary: options.claudeBinary, + hookMode: options.hookMode, installedAt: nowIso(), updatedAt: nowIso(), }); const buildHookCommand = ( hookWrapperFile: string, - eventName: "SessionStart" | "PreCompact" | "PostCompact" | "UserPromptSubmit" | "Stop", + eventName: HostHookEvent, config: ClaudeCodeAdapterConfig ): string => [ @@ -385,43 +405,9 @@ const renderClaudeSettingsConfig = ( ): string => { const next = filterManagedClaudeHooks(existing, adapterPaths.hookWrapperFile); const hooks: ClaudeHooksConfig = next.hooks && typeof next.hooks === "object" && !Array.isArray(next.hooks) ? next.hooks : {}; - const managedHooks: Array< - readonly ["SessionStart" | "PreCompact" | "PostCompact" | "UserPromptSubmit" | "Stop", ClaudeHookMatcherGroup] - > = [ - [ - "SessionStart", - { - matcher: "startup|resume|compact", - hooks: [{ type: "command", command: buildHookCommand(adapterPaths.hookWrapperFile, "SessionStart", config) }], - }, - ], - [ - "PreCompact", - { - matcher: "manual|auto", - hooks: [{ type: "command", command: buildHookCommand(adapterPaths.hookWrapperFile, "PreCompact", config) }], - }, - ], - [ - "PostCompact", - { - matcher: "manual|auto", - hooks: [{ type: "command", command: buildHookCommand(adapterPaths.hookWrapperFile, "PostCompact", config) }], - }, - ], - [ - "UserPromptSubmit", - { - hooks: [{ type: "command", command: buildHookCommand(adapterPaths.hookWrapperFile, "UserPromptSubmit", config) }], - }, - ], - [ - "Stop", - { - hooks: [{ type: "command", command: buildHookCommand(adapterPaths.hookWrapperFile, "Stop", config) }], - }, - ], - ]; + const managedHooks = renderHostHookGroups("claude-code", (eventName) => + buildHookCommand(adapterPaths.hookWrapperFile, eventName, config) + ); for (const [eventName, group] of managedHooks) { const existingGroups = hooks[eventName] ?? []; hooks[eventName] = [...existingGroups, group]; @@ -586,6 +572,31 @@ export const claudeCodeContinuityTranslationContract: ContinuityTranslationContr reportFidelity: (translation) => translation.fidelity, }; +const removeLegacyClaudeHooks = (filePath: string, workspaceRoot: string, runtimeHome: string): boolean => { + if (!existsSync(filePath)) return false; + const existing = readClaudeSettingsConfig(filePath); + const filtered = filterManagedClaudeHooks(existing, path.join(runtimeHome, "bin", "crosshead-claude-code-hook")); + if (JSON.stringify(filtered) === JSON.stringify(existing)) return false; + if (Object.keys(filtered).length > 0) { + writeManagedFile(filePath, `${JSON.stringify(filtered, null, 2)}\n`, workspaceRoot); + } else { + rmSync(filePath, { force: true }); + } + return true; +}; + +/** True when a Claude Code settings file still registers Crosshead's legacy (settings-mode) hooks. */ +export const claudeSettingsFileHasLegacyHooks = (filePath: string, runtimeHome: string): boolean => { + if (!existsSync(filePath)) return false; + const existing = readClaudeSettingsConfig(filePath); + return JSON.stringify(filterManagedClaudeHooks(existing, path.join(runtimeHome, "bin", "crosshead-claude-code-hook")).hooks ?? {}) !== + JSON.stringify(existing.hooks ?? {}); +}; + +/** Project settings files (the workspace and its ancestors) that still register legacy Crosshead hooks. */ +export const findLegacyClaudeHookFiles = (workspaceRoot: string, runtimeHome: string): string[] => + claudeProjectSettingsCandidates(workspaceRoot).filter((filePath) => claudeSettingsFileHasLegacyHooks(filePath, runtimeHome)); + export const installClaudeCodeAdapter = async ( options: ClaudeCodeAdapterInstallOptions ): Promise => { @@ -596,6 +607,18 @@ export const installClaudeCodeAdapter = async ( const profileName = options.profileName ?? "default"; const adapterVersion = options.adapterVersion ?? ONLYBOTS_CLAUDE_CODE_ADAPTER_VERSION; const claudeBinary = resolveClaudeCodeBinary(runtimeLayout.home, options.claudeBinary); + const { mode: hookMode, detection: pluginDetection } = resolveHostHookMode(options.hookMode, () => + detectClaudeCodePlugin(hostHome, workspaceRoot) + ); + const previousConfig = existsSync(adapterPaths.configFile) + ? (() => { + try { + return loadClaudeCodeAdapterConfig(runtimeLayout.home, hostHome); + } catch { + return null; + } + })() + : null; const config = buildClaudeCodeAdapterConfig({ runtimeHome: runtimeLayout.home, @@ -604,6 +627,7 @@ export const installClaudeCodeAdapter = async ( profileName, adapterVersion, claudeBinary, + hookMode, }); assertManagedFilePath(adapterPaths.projectSettingsFile, workspaceRoot); @@ -612,11 +636,24 @@ export const installClaudeCodeAdapter = async ( `${CLAUDE_CODE_ADAPTER_MARKER}\n${JSON.stringify(config, null, 2)}\n`, hostHome ); - writeManagedFile( - adapterPaths.projectSettingsFile, - renderClaudeSettingsConfig(readClaudeSettingsConfig(adapterPaths.projectSettingsFile), adapterPaths, config), - workspaceRoot - ); + const migratedFiles: string[] = []; + if (hookMode === "plugin") { + // The plugin supplies the hooks. Retire legacy entries this runtime wrote so + // that no event is handled twice; unrelated hooks and settings are preserved. + const legacyRoots = [workspaceRoot, previousConfig?.workspaceRoot].filter( + (root, index, all): root is string => typeof root === "string" && all.indexOf(root) === index + ); + for (const root of legacyRoots) { + const filePath = projectSettingsFile(root); + if (removeLegacyClaudeHooks(filePath, root, runtimeLayout.home)) migratedFiles.push(filePath); + } + } else { + writeManagedFile( + adapterPaths.projectSettingsFile, + renderClaudeSettingsConfig(readClaudeSettingsConfig(adapterPaths.projectSettingsFile), adapterPaths, config), + workspaceRoot + ); + } writeManagedExecutable(adapterPaths.wrapperFile, options.bridgeScriptPath, runtimeLayout.home); writeManagedExecutable(adapterPaths.hookWrapperFile, options.hookScriptPath, runtimeLayout.home); writeManagedExecutable(path.join(config.runtimeHome, "bin", "onlybots-claude-code"), options.bridgeScriptPath, runtimeLayout.home); @@ -626,7 +663,7 @@ export const installClaudeCodeAdapter = async ( const managedFiles = [ adapterPaths.configFile, - adapterPaths.projectSettingsFile, + ...(hookMode === "settings" ? [adapterPaths.projectSettingsFile] : []), adapterPaths.wrapperFile, adapterPaths.hookWrapperFile, path.join(config.runtimeHome, "bin", "onlybots-claude-code"), @@ -650,6 +687,8 @@ export const installClaudeCodeAdapter = async ( adapter: existingRecord, config, managedFiles, + pluginDetection, + migratedFiles, }; }; @@ -759,7 +798,7 @@ export class ClaudeCodeRuntimeAdapter extends HostRuntimeAdapterBase { hostHome: input.hostHome ?? this.config.hostHome, managedFiles: [ configFile(this.config.hostHome), - projectSettingsFile(this.config.workspaceRoot), + ...(normalizeHostHookMode(this.config.hookMode) === "settings" ? [projectSettingsFile(this.config.workspaceRoot)] : []), path.join(this.config.runtimeHome, "bin", "crosshead-claude-code"), path.join(this.config.runtimeHome, "bin", "crosshead-claude-code-hook"), path.join(this.config.runtimeHome, "bin", "onlybots-claude-code"), @@ -1289,6 +1328,7 @@ export const installClaudeCodeFromCli = async ( bridgeScriptPath, hookScriptPath, claudeBinary: typeof flags["claude-bin"] === "string" ? flags["claude-bin"] : undefined, + hookMode: parseHookModeFlag(flags["hook-mode"]), }); return [ `Installed Claude Code adapter ${result.adapter.version}`, @@ -1296,6 +1336,8 @@ export const installClaudeCodeFromCli = async ( `claude_home: ${result.config.hostHome}`, `workspace_root: ${result.config.workspaceRoot}`, `claude_bin: ${result.config.claudeBinary}`, + `hook_mode: ${describeHookMode(result.config.hookMode ?? "settings", result.pluginDetection)}`, + ...result.migratedFiles.map((filePath) => `migrated_legacy_hooks: ${filePath}`), ].join("\n"); }; diff --git a/src/runtime/claude-code-hook.ts b/src/runtime/claude-code-hook.ts index f7cb2ca..9a79a05 100644 --- a/src/runtime/claude-code-hook.ts +++ b/src/runtime/claude-code-hook.ts @@ -5,7 +5,9 @@ import path from "path"; import { captureHotFinding } from "./hot-continuity"; import { readLastAssistantMessage } from "./hook-transcript"; -import { ClaudeCodeRuntimeAdapter } from "./claude-code-adapter"; +import { ClaudeCodeRuntimeAdapter, findLegacyClaudeHookFiles, loadClaudeCodeAdapterConfig } from "./claude-code-adapter"; +import { pluginSetupHint } from "./host-hook-spec"; +import { decideHookDispatch, isPluginHookInvocation, normalizeHostHookMode, writePluginSetupNotice } from "./host-plugin-mode"; import { ensureRuntimeHomeLayout, resolveRuntimeHome } from "./local-runtime"; import { writeJsonFile } from "./runtime-persistence"; import { assertSecureNodeRuntimeVersion } from "./node-runtime-version"; @@ -364,6 +366,28 @@ export const runOnlyBotsClaudeCodeHookCli = async ( const payload = readStdinJson(stdinOverride); const workspaceRoot = resolveHookWorkspaceRoot(payload, flags); const workspaceId = resolveHookWorkspaceId(payload, flags, workspaceRoot); + const viaPlugin = isPluginHookInvocation(flags); + if (eventName !== "help") { + let installedConfig: ReturnType | null = null; + try { + installedConfig = loadClaudeCodeAdapterConfig(runtimeHome, hostHome); + } catch (error) { + if (!viaPlugin) throw error; + } + if (!installedConfig) { + // Plugin installed before setup: tell the user how to finish, never fail the session. + if (normalizedEventName === "sessionstart" || normalizedEventName === "session_start") { + writePluginSetupNotice("SessionStart", pluginSetupHint("claude-code")); + } + return; + } + const decision = decideHookDispatch({ + viaPlugin, + mode: normalizeHostHookMode(installedConfig.hookMode), + legacyHooksPresent: () => findLegacyClaudeHookFiles(workspaceRoot, runtimeHome).length > 0, + }); + if (decision === "skip-duplicate") return; + } const adapter = ClaudeCodeRuntimeAdapter.fromInstalled({ home: runtimeHome, hostHome, diff --git a/src/runtime/cli.ts b/src/runtime/cli.ts index 444451d..5c1110f 100644 --- a/src/runtime/cli.ts +++ b/src/runtime/cli.ts @@ -252,11 +252,11 @@ export const runOnlyBotsCtlCli = async ( "crosshead install [--home ] [--channel stable|beta|canary]", "crosshead install hermes [--home ] [--hermes-home ] [--hermes-bin ] [--profile ]", "crosshead install opencode [--home ] [--opencode-home ] [--profile ]", - "crosshead install codex [--home ] [--codex-home ] [--codex-bin ] [--profile ] [--version ]", - "crosshead install claude-code [--home ] [--claude-home ] [--workspace-root ] [--profile ] [--version ] [--claude-bin ]", + "crosshead install codex [--home ] [--codex-home ] [--codex-bin ] [--profile ] [--version ] [--hook-mode auto|plugin|settings]", + "crosshead install claude-code [--home ] [--claude-home ] [--workspace-root ] [--profile ] [--version ] [--claude-bin ] [--hook-mode auto|plugin|settings]", "crosshead install pi [--home ] [--profile ] [--version ] # embedded hosts without shell wiring", "crosshead install omp [--home ] [--profile ] [--workspace-root ] [--omp-bin ] [--no-envrc] # Pi-compatible OMP project wiring", - "crosshead quickstart --api-base [--home ] [--profile ] [--adapter codex|claude-code|pi|omp|hermes|opencode|none] [--workspace-root ] [--omp-bin ] [--create-checkout] [--json]", + "crosshead quickstart --api-base [--home ] [--profile ] [--adapter codex|claude-code|pi|omp|hermes|opencode|none] [--workspace-root ] [--hook-mode auto|plugin|settings] [--omp-bin ] [--create-checkout] [--json]", capabilityHelpLine, "crosshead keygen [--json]", "crosshead keygen --setup --profile --api-base [--home ] [--memory-mode assist] [--workspace-scope repo|workspace|global] [--json]", @@ -274,7 +274,7 @@ export const runOnlyBotsCtlCli = async ( "crosshead update --pin|--auto|--policy [--home ]", "crosshead update [adapter] [--home ] [--channel stable|beta|canary] [--version ]", "crosshead rollback [adapter] --to [--home ]", - "crosshead doctor [--home ] [--json]", + "crosshead doctor [--home ] [--workspace-root ] [--json]", "crosshead logs [--home ] [--lines 50]", "crosshead events [--home ] [--lines 50] [--json]", "crosshead workspace link --name --workspace-root [--home ] [--profile ]", diff --git a/src/runtime/cli/core.ts b/src/runtime/cli/core.ts index 4d35cb7..afff8e3 100644 --- a/src/runtime/cli/core.ts +++ b/src/runtime/cli/core.ts @@ -4,6 +4,7 @@ import { RUNTIME_ADAPTER_NAMES, ensureRuntimeHomeLayout, loadAdapterRecord, load import { billingFromCli } from "./billing"; import { maintainRuntimeBeforeLaunch, runtimeUpdatePolicy } from "../runtime-auto-update"; import { DEFAULT_CHECKOUT_CREDIT_UNITS } from "../billing-remediation"; +import { inspectHostHookRegistrations } from "../host-hook-doctor"; export const installCoreFromCli = async (argv: string[], cliScriptPath: string, daemonScriptPath: string): Promise => { const { flags } = parseArgs(argv); @@ -199,10 +200,11 @@ export const statusFromCli = async (argv: string[]): Promise => { export const doctorFromCli = async (argv: string[]): Promise => { const { flags } = parseArgs(argv); - const report = await buildRuntimeDoctorDirect( - typeof flags.home === "string" ? flags.home : undefined, - typeof flags.profile === "string" ? flags.profile : undefined - ); + const home = typeof flags.home === "string" ? flags.home : undefined; + const daemonReport = await buildRuntimeDoctorDirect(home, typeof flags.profile === "string" ? flags.profile : undefined); + const hookChecks = inspectHostHookRegistrations(home, typeof flags["workspace-root"] === "string" ? flags["workspace-root"] : process.cwd()); + const checks = [...daemonReport.checks, ...hookChecks]; + const report = { ...daemonReport, ok: checks.every((check) => check.ok), checks }; return flags.json ? JSON.stringify(report, null, 2) : formatRuntimeDoctor(report); }; diff --git a/src/runtime/cli/quickstart.ts b/src/runtime/cli/quickstart.ts index 3351012..8c42fea 100644 --- a/src/runtime/cli/quickstart.ts +++ b/src/runtime/cli/quickstart.ts @@ -8,6 +8,8 @@ import { ensureRuntimeHomeLayout, loadProfile, loadRuntimeConfig } from "../runt import { billingFromCli } from "./billing"; import { installCoreFromCli, keygenFromCli, startFromCli, statusFromCli } from "./core"; import { DEFAULT_CHECKOUT_CREDIT_UNITS } from "../billing-remediation"; +import { probeRuntimeDaemonHealth, readDaemonOwnershipState } from "../daemon-ownership"; +import { resolveRuntimePaths } from "../runtime-persistence"; export type QuickstartCliPaths = { cliScriptPath: string; @@ -113,6 +115,7 @@ export const quickstartFromCli = async (argv: string[], paths: QuickstartCliPath ...flagPair("--claude-home", typeof flags["claude-home"] === "string" ? flags["claude-home"] : undefined), ...flagPair("--workspace-root", typeof flags["workspace-root"] === "string" ? flags["workspace-root"] : undefined), ...flagPair("--claude-bin", typeof flags["claude-bin"] === "string" ? flags["claude-bin"] : undefined), + ...flagPair("--hook-mode", typeof flags["hook-mode"] === "string" ? flags["hook-mode"] : undefined), ...flagPair("--omp-bin", typeof flags["omp-bin"] === "string" ? flags["omp-bin"] : undefined), ...(boolFlagDisabled(flags, "no-envrc") ? ["--no-envrc"] : []), ]; @@ -150,7 +153,11 @@ export const quickstartFromCli = async (argv: string[], paths: QuickstartCliPath detail: adapter === "omp" ? `${adapterOutput}; OMP uses the Pi embedded adapter path` : adapterOutput, }); - if (!boolFlagDisabled(flags, "no-start")) { + const runningDaemon = readDaemonOwnershipState(resolveRuntimePaths(home)); + if (!boolFlagDisabled(flags, "no-start") && runningDaemon && (await probeRuntimeDaemonHealth(runningDaemon))) { + // Quickstart for a second host shares the daemon the first one started. + steps.push({ id: "daemon", ok: true, detail: `onlybotsd already running on port ${runningDaemon.port}` }); + } else if (!boolFlagDisabled(flags, "no-start")) { const startOutput = await startFromCli([...flagPair("--home", home), ...flagPair("--port", typeof flags.port === "string" ? flags.port : undefined)]); steps.push({ id: "daemon", ok: true, detail: startOutput }); } else { diff --git a/src/runtime/codex-adapter.ts b/src/runtime/codex-adapter.ts index 5fa4067..e1a3f88 100644 --- a/src/runtime/codex-adapter.ts +++ b/src/runtime/codex-adapter.ts @@ -57,6 +57,16 @@ import { type HostAdapterTurnPrepareResult, } from "./host-adapter-base"; import { resolveCodexHostHome } from "./runtime-env"; +import { renderHostHookGroups, type HostHookEvent } from "./host-hook-spec"; +import { + describeHookMode, + detectCodexPlugin, + normalizeHostHookMode, + parseHookModeFlag, + resolveHostHookMode, + type HostHookMode, + type PluginDetection, +} from "./host-plugin-mode"; import { loadRuntimeConfig } from "./runtime-persistence"; import type { HandoffCapsule, @@ -80,6 +90,8 @@ export type CodexAdapterConfig = { codexBinary: string; profileName: string; skillName: string; + /** Absent in configs written before host plugins; treated as "settings". */ + hookMode?: HostHookMode; installedAt: string; updatedAt: string; }; @@ -109,6 +121,10 @@ export type CodexAdapterInstallOptions = { adapterVersion?: string; bridgeScriptPath: string; hookScriptPath: string; + /** "auto" (default) selects "plugin" when the Crosshead Codex plugin is enabled. */ + hookMode?: HostHookMode | "auto"; + /** Project directory used to find a project-level `.codex/config.toml` during plugin detection. */ + workspaceRoot?: string; }; export type CodexAdapterInstallResult = { @@ -116,6 +132,9 @@ export type CodexAdapterInstallResult = { config: CodexAdapterConfig; managedFiles: string[]; preservedFiles: string[]; + pluginDetection: PluginDetection | null; + /** Hook files whose legacy Crosshead hooks were removed because the plugin supplies them. */ + migratedFiles: string[]; }; export type CodexAdapterUninstallOptions = { @@ -301,7 +320,7 @@ export const resolveCodexAdapterPaths = (runtimeHomeInput?: string, hostHomeInpu const buildHookCommand = ( hookWrapperFile: string, - eventName: "SessionStart" | "PreCompact" | "PostCompact" | "UserPromptSubmit" | "Stop", + eventName: HostHookEvent, config: CodexAdapterConfig ): string => [ @@ -378,77 +397,9 @@ const filterManagedHooks = (existing: CodexHooksConfig, hookWrapperPath: string) const renderHooksConfig = (existing: CodexHooksConfig, adapterPaths: CodexAdapterPaths, config: CodexAdapterConfig): string => { const next = filterManagedHooks(existing, adapterPaths.hookWrapperFile); - const managedHooks: Array< - readonly ["SessionStart" | "PreCompact" | "PostCompact" | "UserPromptSubmit" | "Stop", CodexHookMatcherGroup] - > = [ - [ - "SessionStart", - { - matcher: "startup|resume|compact", - hooks: [ - { - type: "command", - command: buildHookCommand(adapterPaths.hookWrapperFile, "SessionStart", config), - statusMessage: "Crosshead session sync", - }, - ], - }, - ], - [ - "PreCompact", - { - matcher: "manual|auto", - hooks: [ - { - type: "command", - command: buildHookCommand(adapterPaths.hookWrapperFile, "PreCompact", config), - statusMessage: "Crosshead pre-compact checkpoint", - timeout: 30, - }, - ], - }, - ], - [ - "PostCompact", - { - matcher: "manual|auto", - hooks: [ - { - type: "command", - command: buildHookCommand(adapterPaths.hookWrapperFile, "PostCompact", config), - statusMessage: "Crosshead post-compact sync", - timeout: 30, - }, - ], - }, - ], - [ - "UserPromptSubmit", - { - hooks: [ - { - type: "command", - command: buildHookCommand(adapterPaths.hookWrapperFile, "UserPromptSubmit", config), - statusMessage: "Crosshead context", - timeout: 45, - }, - ], - }, - ], - [ - "Stop", - { - hooks: [ - { - type: "command", - command: buildHookCommand(adapterPaths.hookWrapperFile, "Stop", config), - statusMessage: "Crosshead memory sync", - timeout: 30, - }, - ], - }, - ], - ]; + const managedHooks = renderHostHookGroups("codex", (eventName) => + buildHookCommand(adapterPaths.hookWrapperFile, eventName, config) + ); for (const [eventName, group] of managedHooks) { const existingGroups = next.hooks[eventName] ?? []; next.hooks[eventName] = [...existingGroups, group]; @@ -544,6 +495,7 @@ const buildCodexAdapterConfig = (options: { profileName: string; skillName: string; adapterVersion: string; + hookMode: HostHookMode; }): CodexAdapterConfig => ({ marker: CODEX_MANAGED_CONFIG_MARKER, adapterName: "codex", @@ -553,6 +505,7 @@ const buildCodexAdapterConfig = (options: { codexBinary: options.codexBinary, profileName: options.profileName, skillName: options.skillName, + hookMode: options.hookMode, installedAt: nowIso(), updatedAt: nowIso(), }); @@ -562,7 +515,7 @@ const managedCodexFiles = (config: CodexAdapterConfig): string[] => { return [ path.join(config.hostHome, "skills", config.skillName, "SKILL.md"), configFile(config.hostHome), - hooksFile(config.hostHome), + ...(normalizeHostHookMode(config.hookMode) === "settings" ? [hooksFile(config.hostHome)] : []), path.join(config.runtimeHome, "bin", "crosshead-codex"), path.join(config.runtimeHome, "bin", "crosshead-codex-hook"), path.join(config.runtimeHome, "bin", "onlybots-codex"), @@ -647,6 +600,26 @@ export const codexContinuityTranslationContract: ContinuityTranslationContract translation.fidelity, }; +const removeLegacyCodexHooks = (filePath: string, hostHome: string, runtimeHome: string): boolean => { + if (!codexHooksFileHasLegacyHooks(filePath, runtimeHome)) return false; + const filtered = filterManagedHooks(readHooksConfig(filePath), path.join(runtimeHome, "bin", "crosshead-codex-hook")); + if (Object.values(filtered.hooks).some((groups) => (groups ?? []).length > 0)) { + writeManagedFile(filePath, `${JSON.stringify(filtered, null, 2)}\n`, hostHome); + } else { + rmSync(filePath, { force: true }); + } + return true; +}; + +/** True when a Codex hooks.json still registers Crosshead's legacy (settings-mode) hooks. */ +export const codexHooksFileHasLegacyHooks = (filePath: string, runtimeHome: string): boolean => { + if (!existsSync(filePath)) return false; + const existing = readHooksConfig(filePath); + return JSON.stringify(filterManagedHooks(existing, path.join(runtimeHome, "bin", "crosshead-codex-hook")).hooks) !== JSON.stringify(existing.hooks); +}; + +export const codexLegacyHooksFile = (hostHome: string): string => hooksFile(hostHome); + export const installCodexAdapter = async (options: CodexAdapterInstallOptions): Promise => { const runtimeLayout = ensureRuntimeHomeLayout(options.home); const hostHome = resolveCodexHome(options.hostHome); @@ -658,6 +631,9 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): const profileName = options.profileName ?? "default"; const adapterVersion = options.adapterVersion ?? ONLYBOTS_CODEX_ADAPTER_VERSION; const codexBinary = resolveCodexBinary(runtimeLayout.home, options.codexBinary); + const { mode: hookMode, detection: pluginDetection } = resolveHostHookMode(options.hookMode, () => + detectCodexPlugin(hostHome, path.resolve(options.workspaceRoot ?? process.cwd())) + ); const config = buildCodexAdapterConfig({ runtimeHome: runtimeLayout.home, @@ -666,6 +642,7 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): profileName, skillName: selectedSkill.skillName, adapterVersion, + hookMode, }); writeManagedFile( @@ -683,11 +660,17 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): `${CODEX_ADAPTER_MARKER}\n${JSON.stringify(config, null, 2)}\n`, hostHome ); - writeManagedFile( - adapterPaths.hooksFile, - renderHooksConfig(readHooksConfig(adapterPaths.hooksFile), adapterPaths, config), - hostHome - ); + const migratedFiles: string[] = []; + if (hookMode === "plugin") { + // The plugin supplies the hooks. Retire legacy entries so no event runs twice. + if (removeLegacyCodexHooks(adapterPaths.hooksFile, hostHome, runtimeLayout.home)) migratedFiles.push(adapterPaths.hooksFile); + } else { + writeManagedFile( + adapterPaths.hooksFile, + renderHooksConfig(readHooksConfig(adapterPaths.hooksFile), adapterPaths, config), + hostHome + ); + } writeManagedExecutable(adapterPaths.wrapperFile, options.bridgeScriptPath, runtimeLayout.home, config); writeManagedExecutable(adapterPaths.hookWrapperFile, options.hookScriptPath, runtimeLayout.home, config); writeManagedExecutable(path.join(config.runtimeHome, "bin", "onlybots-codex"), options.bridgeScriptPath, runtimeLayout.home, config); @@ -702,7 +685,7 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): const managedFiles = [ adapterPaths.skillFile, adapterPaths.configFile, - adapterPaths.hooksFile, + ...(hookMode === "settings" ? [adapterPaths.hooksFile] : []), adapterPaths.wrapperFile, adapterPaths.hookWrapperFile, path.join(config.runtimeHome, "bin", "onlybots-codex"), @@ -727,6 +710,8 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): config, managedFiles, preservedFiles: selectedSkill.preservedFiles, + pluginDetection, + migratedFiles, }; }; @@ -1261,6 +1246,8 @@ export const installCodexFromCli = async ( adapterVersion: typeof flags.version === "string" ? flags.version : undefined, bridgeScriptPath, hookScriptPath, + hookMode: parseHookModeFlag(flags["hook-mode"]), + workspaceRoot: typeof flags["workspace-root"] === "string" ? flags["workspace-root"] : undefined, }); return [ `Installed Codex adapter ${result.adapter.version}`, @@ -1268,6 +1255,8 @@ export const installCodexFromCli = async ( `runtime_home: ${result.config.runtimeHome}`, `codex_home: ${result.config.hostHome}`, `codex_bin: ${result.config.codexBinary}`, + `hook_mode: ${describeHookMode(result.config.hookMode ?? "settings", result.pluginDetection)}`, + ...result.migratedFiles.map((filePath) => `migrated_legacy_hooks: ${filePath}`), ...(result.preservedFiles.length === 0 ? [] : [`preserved: ${result.preservedFiles.join(", ")}`]), diff --git a/src/runtime/codex-hook.ts b/src/runtime/codex-hook.ts index f7b646b..d18f626 100644 --- a/src/runtime/codex-hook.ts +++ b/src/runtime/codex-hook.ts @@ -2,7 +2,9 @@ import { ensureHookRuntime, reportHookFailure } from "./hook-runtime"; import { existsSync, readFileSync } from "fs"; import path from "path"; -import { CodexRuntimeAdapter } from "./codex-adapter"; +import { CodexRuntimeAdapter, codexHooksFileHasLegacyHooks, codexLegacyHooksFile, loadCodexAdapterConfig, resolveCodexHome } from "./codex-adapter"; +import { pluginSetupHint } from "./host-hook-spec"; +import { decideHookDispatch, isPluginHookInvocation, normalizeHostHookMode, writePluginSetupNotice } from "./host-plugin-mode"; import { ensureRuntimeHomeLayout, resolveRuntimeHome } from "./local-runtime"; import { compactContinuitySummary } from "./summary-compaction"; import { captureHotFinding } from "./hot-continuity"; @@ -370,6 +372,28 @@ export const runOnlyBotsCodexHookCli = async ( flags["allow-ephemeral-config"] === true || flags["allow-ephemeral-config"] === "1" || (process.env.CROSSHEAD_CODEX_ALLOW_EPHEMERAL_CONFIG ?? process.env.ONLYBOTS_CODEX_ALLOW_EPHEMERAL_CONFIG) === "1"; + const viaPlugin = isPluginHookInvocation(flags); + if (eventName !== "help") { + let installedConfig: ReturnType | null = null; + try { + installedConfig = loadCodexAdapterConfig(runtimeHome, hostHome, { allowEphemeralConfig }); + } catch (error) { + if (!viaPlugin) throw error; + } + if (!installedConfig) { + // Plugin installed before setup: tell the user how to finish, never fail the session. + if (normalizedEventName === "sessionstart" || normalizedEventName === "session_start") { + writePluginSetupNotice("SessionStart", pluginSetupHint("codex")); + } + return; + } + const decision = decideHookDispatch({ + viaPlugin, + mode: normalizeHostHookMode(installedConfig.hookMode), + legacyHooksPresent: () => codexHooksFileHasLegacyHooks(codexLegacyHooksFile(resolveCodexHome(hostHome)), runtimeHome), + }); + if (decision === "skip-duplicate") return; + } const adapter = CodexRuntimeAdapter.fromInstalled({ home: runtimeHome, hostHome, diff --git a/src/runtime/host-hook-doctor.ts b/src/runtime/host-hook-doctor.ts new file mode 100644 index 0000000..57f626e --- /dev/null +++ b/src/runtime/host-hook-doctor.ts @@ -0,0 +1,83 @@ +import path from "path"; + +import { findLegacyClaudeHookFiles, loadClaudeCodeAdapterConfig } from "./claude-code-adapter"; +import { codexHooksFileHasLegacyHooks, codexLegacyHooksFile, loadCodexAdapterConfig } from "./codex-adapter"; +import type { HookHost } from "./host-hook-spec"; +import { detectClaudeCodePlugin, detectCodexPlugin, normalizeHostHookMode, type HostHookMode, type PluginDetection } from "./host-plugin-mode"; +import type { RuntimeDoctorCheck } from "./local-runtime"; +import { loadAdapterRecord, resolveRuntimePaths } from "./runtime-persistence"; + +export type HostHookInspection = { + host: HookHost; + mode: HostHookMode; + plugin: PluginDetection; + legacyHookFiles: string[]; +}; + +const installCommand = (host: HookHost): string => `crosshead install ${host}`; + +/** Doctor verdict for one host's hook registration. Exported for tests. */ +export const evaluateHostHookInspection = (inspection: HostHookInspection): RuntimeDoctorCheck => { + const { host, mode, plugin, legacyHookFiles } = inspection; + const id = `${host === "codex" ? "codex" : "claude_code"}_hooks`; + const pluginState = plugin.enabled ? `plugin enabled${plugin.source ? ` in ${plugin.source}` : ""}` : "plugin not enabled"; + const legacyState = legacyHookFiles.length > 0 ? `legacy hooks in ${legacyHookFiles.join(", ")}` : "no legacy hooks"; + const summary = `mode=${mode}; ${pluginState}; ${legacyState}.`; + if (mode === "plugin") { + if (!plugin.enabled) { + return { id, ok: false, message: `${summary} No Crosshead hooks are active: enable the Crosshead plugin, or run ${installCommand(host)} --hook-mode settings.` }; + } + if (legacyHookFiles.length > 0) { + return { id, ok: false, message: `${summary} Duplicate registration (the legacy entries are ignored at run time): run ${installCommand(host)} to remove them.` }; + } + return { id, ok: true, message: summary }; + } + if (plugin.enabled && legacyHookFiles.length > 0) { + return { id, ok: false, message: `${summary} Duplicate registration (the plugin defers to the legacy hooks here): run ${installCommand(host)} --hook-mode plugin to migrate.` }; + } + if (!plugin.enabled && legacyHookFiles.length === 0) { + return { id, ok: false, message: `${summary} No Crosshead hooks found for this workspace: install the Crosshead plugin or rerun ${installCommand(host)}.` }; + } + return { id, ok: true, message: summary }; +}; + +/** Hook registration checks for each installed Claude Code / Codex adapter. */ +export const inspectHostHookRegistrations = (home?: string, workspaceRoot: string = process.cwd()): RuntimeDoctorCheck[] => { + const paths = resolveRuntimePaths(home); + const checks: RuntimeDoctorCheck[] = []; + const claudeRecord = loadAdapterRecord(paths, "claude-code"); + if (claudeRecord) { + try { + const config = loadClaudeCodeAdapterConfig(paths.home, claudeRecord.hostHome ?? undefined); + const roots = [...new Set([path.resolve(workspaceRoot), config.workspaceRoot])]; + checks.push( + evaluateHostHookInspection({ + host: "claude-code", + mode: normalizeHostHookMode(config.hookMode), + plugin: detectClaudeCodePlugin(config.hostHome, path.resolve(workspaceRoot)), + legacyHookFiles: [...new Set(roots.flatMap((root) => findLegacyClaudeHookFiles(root, paths.home)))], + }) + ); + } catch (error) { + checks.push({ id: "claude_code_hooks", ok: false, message: error instanceof Error ? error.message : String(error) }); + } + } + const codexRecord = loadAdapterRecord(paths, "codex"); + if (codexRecord) { + try { + const config = loadCodexAdapterConfig(paths.home, codexRecord.hostHome ?? undefined); + const hooksFile = codexLegacyHooksFile(config.hostHome); + checks.push( + evaluateHostHookInspection({ + host: "codex", + mode: normalizeHostHookMode(config.hookMode), + plugin: detectCodexPlugin(config.hostHome, path.resolve(workspaceRoot)), + legacyHookFiles: codexHooksFileHasLegacyHooks(hooksFile, paths.home) ? [hooksFile] : [], + }) + ); + } catch (error) { + checks.push({ id: "codex_hooks", ok: false, message: error instanceof Error ? error.message : String(error) }); + } + } + return checks; +}; diff --git a/src/runtime/host-hook-spec.ts b/src/runtime/host-hook-spec.ts new file mode 100644 index 0000000..e3673fb --- /dev/null +++ b/src/runtime/host-hook-spec.ts @@ -0,0 +1,92 @@ +// Single source of truth for the Claude Code and Codex lifecycle hooks Crosshead +// registers. The legacy installers (`crosshead install claude-code|codex`) and the +// generated host plugins (`plugins/`, see scripts/host-plugins.mjs) both render +// their hook files from this table, so the two modes cannot drift apart. + +export type HookHost = "claude-code" | "codex"; + +export type HostHookEvent = "SessionStart" | "PreCompact" | "PostCompact" | "UserPromptSubmit" | "Stop"; + +export type HostHookHandler = { + type: "command"; + command: string; + statusMessage?: string; + timeout?: number; +}; + +export type HostHookMatcherGroup = { + matcher?: string; + hooks: HostHookHandler[]; +}; + +type HostHookEventSpec = { + event: HostHookEvent; + matcher?: string; + /** Codex shows this while the hook runs. Claude Code registrations omit it. */ + codexStatusMessage: string; + /** Codex timeout in seconds. Claude Code uses its own defaults. */ + codexTimeout?: number; +}; + +export const HOST_HOOK_EVENTS: readonly HostHookEventSpec[] = [ + { event: "SessionStart", matcher: "startup|resume|compact", codexStatusMessage: "Crosshead session sync" }, + { event: "PreCompact", matcher: "manual|auto", codexStatusMessage: "Crosshead pre-compact checkpoint", codexTimeout: 30 }, + { event: "PostCompact", matcher: "manual|auto", codexStatusMessage: "Crosshead post-compact sync", codexTimeout: 30 }, + { event: "UserPromptSubmit", codexStatusMessage: "Crosshead context", codexTimeout: 45 }, + { event: "Stop", codexStatusMessage: "Crosshead memory sync", codexTimeout: 30 }, +]; + +/** Plugin identity shared by both hosts' marketplaces: `crosshead@crosshead`. */ +export const CROSSHEAD_PLUGIN_NAME = "crosshead"; +export const CROSSHEAD_MARKETPLACE_NAME = "crosshead"; +/** Bump when any generated plugin file changes; hosts pin installed plugins by version. */ +export const CROSSHEAD_HOST_PLUGIN_VERSION = "1.0.0"; + +/** Path, relative to the plugin root, of the launcher the plugin hooks run. */ +export const PLUGIN_HOOK_LAUNCHER = "scripts/crosshead-hook"; + +/** Flag the plugin launcher adds so the runtime can tell plugin and legacy invocations apart. */ +export const PLUGIN_HOOK_VIA_FLAG = ["--via", "plugin"] as const; + +export const pluginRootVariable = (host: HookHost): string => (host === "codex" ? "PLUGIN_ROOT" : "CLAUDE_PLUGIN_ROOT"); + +export const pluginSetupHint = (host: HookHost): string => + host === "codex" + ? "Crosshead isn't set up: ask Codex to use the crosshead setup skill ($crosshead:setup)." + : "Crosshead isn't set up: run /crosshead:setup."; + +export const pluginUpdateHint = (host: HookHost): string => + host === "codex" + ? "The installed Crosshead runtime predates the Codex plugin: ask Codex to use the crosshead setup skill ($crosshead:setup) to update it." + : "The installed Crosshead runtime predates the Claude Code plugin: run /crosshead:setup to update it."; + +/** Ordered `[event, matcher group]` pairs for one host, with the caller's command per event. */ +export const renderHostHookGroups = ( + host: HookHost, + commandFor: (event: HostHookEvent) => string +): Array => + HOST_HOOK_EVENTS.map((spec) => { + const handler: HostHookHandler = { type: "command", command: commandFor(spec.event) }; + if (host === "codex") { + handler.statusMessage = spec.codexStatusMessage; + if (spec.codexTimeout !== undefined) handler.timeout = spec.codexTimeout; + } + const group: HostHookMatcherGroup = spec.matcher === undefined ? { hooks: [handler] } : { matcher: spec.matcher, hooks: [handler] }; + return [spec.event, group] as const; + }); + +/** + * The command a plugin hook runs. The launcher resolves the installed runtime at + * run time. It is run through `sh` so a lost executable bit cannot break it. + */ +export const pluginHookCommand = (host: HookHost, event: HostHookEvent): string => + `sh "\${${pluginRootVariable(host)}}/${PLUGIN_HOOK_LAUNCHER}" ${host} ${event}`; + +/** Contents of a plugin's `hooks/hooks.json`. */ +export const renderPluginHooksFile = (host: HookHost): string => { + const hooks: Partial> = {}; + for (const [event, group] of renderHostHookGroups(host, (name) => pluginHookCommand(host, name))) { + hooks[event] = [group]; + } + return `${JSON.stringify({ description: "Crosshead persistent memory hooks. They run the locally installed Crosshead runtime.", hooks }, null, 2)}\n`; +}; diff --git a/src/runtime/host-plugin-mode.ts b/src/runtime/host-plugin-mode.ts new file mode 100644 index 0000000..98bdcae --- /dev/null +++ b/src/runtime/host-plugin-mode.ts @@ -0,0 +1,161 @@ +import { existsSync, readFileSync } from "fs"; +import path from "path"; + +import { CROSSHEAD_PLUGIN_NAME, PLUGIN_HOOK_VIA_FLAG, type HookHost } from "./host-hook-spec"; + +// Hook registration modes for Claude Code and Codex: +// - "settings": the legacy installer writes hooks into .claude/settings.json or +// $CODEX_HOME/hooks.json. +// - "plugin": the Crosshead host plugin supplies the hooks; the installer writes +// only the adapter config and wrappers, and removes legacy hook entries. +// The selected mode is stored in the adapter config so the hook entrypoint can +// make sure exactly one registration handles each event. + +export type HostHookMode = "settings" | "plugin"; + +export type PluginDetection = { + enabled: boolean; + /** Config file that decided the result, or null when no file mentions the plugin. */ + source: string | null; +}; + +const readJson = (filePath: string): Record | null => { + try { + const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : null; + } catch { + return null; + } +}; + +const isCrossheadPluginId = (id: string): boolean => id === CROSSHEAD_PLUGIN_NAME || id.startsWith(`${CROSSHEAD_PLUGIN_NAME}@`); + +/** Claude Code settings precedence for `enabledPlugins`: local, then project, then user. */ +export const claudePluginSettingsFiles = (hostHome: string, workspaceRoot: string): string[] => [ + path.join(workspaceRoot, ".claude", "settings.local.json"), + path.join(workspaceRoot, ".claude", "settings.json"), + path.join(hostHome, "settings.json"), +]; + +export const detectClaudeCodePlugin = (hostHome: string, workspaceRoot: string): PluginDetection => { + for (const filePath of claudePluginSettingsFiles(hostHome, workspaceRoot)) { + const enabledPlugins = readJson(filePath)?.enabledPlugins; + if (!enabledPlugins || typeof enabledPlugins !== "object" || Array.isArray(enabledPlugins)) continue; + const entries = Object.entries(enabledPlugins as Record).filter(([id]) => isCrossheadPluginId(id)); + if (entries.length === 0) continue; + return { enabled: entries.some(([, value]) => value === true), source: filePath }; + } + return { enabled: false, source: null }; +}; + +/** + * Reads `[plugins."crosshead@"] enabled = true|false` from a Codex + * config.toml without a TOML dependency. Only that table is interpreted. + */ +export const readCodexPluginEnabled = (content: string): boolean | null => { + let inTable = false; + let result: boolean | null = null; + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.replace(/\s+#.*$/, "").trim(); + if (line.startsWith("[")) { + const match = /^\[\s*plugins\s*\.\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\s*\]$/.exec(line); + const id = match ? match[1] ?? match[2] ?? match[3] ?? "" : ""; + inTable = id !== "" && isCrossheadPluginId(id); + if (inTable && result === null) result = true; // Codex treats a listed plugin as enabled unless disabled. + continue; + } + if (!inTable) continue; + const enabled = /^enabled\s*=\s*(true|false)$/.exec(line); + if (enabled) result = enabled[1] === "true"; + } + return result; +}; + +export const codexPluginConfigFiles = (hostHome: string, workspaceRoot: string): string[] => [ + path.join(workspaceRoot, ".codex", "config.toml"), + path.join(hostHome, "config.toml"), +]; + +export const detectCodexPlugin = (hostHome: string, workspaceRoot: string = process.cwd()): PluginDetection => { + for (const filePath of codexPluginConfigFiles(hostHome, workspaceRoot)) { + if (!existsSync(filePath)) continue; + let content: string; + try { + content = readFileSync(filePath, "utf8"); + } catch { + continue; + } + const enabled = readCodexPluginEnabled(content); + if (enabled !== null) return { enabled, source: filePath }; + } + return { enabled: false, source: null }; +}; + +export const detectHostPlugin = (host: HookHost, hostHome: string, workspaceRoot: string): PluginDetection => + host === "codex" ? detectCodexPlugin(hostHome, workspaceRoot) : detectClaudeCodePlugin(hostHome, workspaceRoot); + +/** `--hook-mode plugin|settings|auto` (default auto: plugin when the host has it enabled). */ +export const resolveHostHookMode = ( + requested: string | boolean | undefined, + detect: () => PluginDetection +): { mode: HostHookMode; detection: PluginDetection | null } => { + if (requested === undefined || requested === "auto") { + const detection = detect(); + return { mode: detection.enabled ? "plugin" : "settings", detection }; + } + if (requested === "plugin" || requested === "settings") return { mode: requested, detection: null }; + throw new Error("--hook-mode must be one of: auto, plugin, settings"); +}; + +export const parseHookModeFlag = (value: string | boolean | undefined): HostHookMode | "auto" | undefined => { + if (value === undefined) return undefined; + if (value === "auto" || value === "plugin" || value === "settings") return value; + throw new Error("--hook-mode must be one of: auto, plugin, settings"); +}; + +export const describeHookMode = (mode: HostHookMode, detection: PluginDetection | null): string => + mode === "plugin" + ? `plugin (hooks come from the Crosshead plugin${detection?.source ? `; enabled in ${detection.source}` : ""})` + : "settings (hooks written by this installer)"; + +export const normalizeHostHookMode = (value: unknown): HostHookMode => (value === "plugin" ? "plugin" : "settings"); + +export const isPluginHookInvocation = (flags: Record): boolean => + flags[PLUGIN_HOOK_VIA_FLAG[0].slice(2)] === PLUGIN_HOOK_VIA_FLAG[1]; + +export type HookDispatchDecision = "run" | "skip-duplicate"; + +/** + * Exactly one registration handles each event: + * - plugin mode: the plugin runs; any leftover legacy entry is ignored. + * - settings mode: legacy entries run; the plugin runs only where no legacy + * entry covers this workspace (for example a project the installer never saw). + */ +export const decideHookDispatch = (input: { + viaPlugin: boolean; + mode: HostHookMode; + legacyHooksPresent: () => boolean; +}): HookDispatchDecision => { + if (input.mode === "plugin") return input.viaPlugin ? "run" : "skip-duplicate"; + if (!input.viaPlugin) return "run"; + return input.legacyHooksPresent() ? "skip-duplicate" : "run"; +}; + +/** Settings files Claude Code may load project hooks from for a session in `workspaceRoot`. */ +export const claudeProjectSettingsCandidates = (workspaceRoot: string): string[] => { + const candidates: string[] = []; + let current = path.resolve(workspaceRoot); + for (;;) { + candidates.push(path.join(current, ".claude", "settings.json")); + const parent = path.dirname(current); + if (parent === current) return candidates; + current = parent; + } +}; + +/** One-line JSON both hosts accept on SessionStart: a user-visible notice plus model context. */ +export const writePluginSetupNotice = (hookEventName: "SessionStart", message: string): void => { + process.stdout.write( + `${JSON.stringify({ systemMessage: message, hookSpecificOutput: { hookEventName, additionalContext: message } })}\n` + ); +}; diff --git a/tests/runtime/host-plugins.test.ts b/tests/runtime/host-plugins.test.ts new file mode 100644 index 0000000..7009a1b --- /dev/null +++ b/tests/runtime/host-plugins.test.ts @@ -0,0 +1,349 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { installClaudeCodeAdapter, loadClaudeCodeAdapterConfig, uninstallClaudeCodeAdapter } from "../../src/runtime/claude-code-adapter"; +import { runOnlyBotsClaudeCodeHookCli } from "../../src/runtime/claude-code-hook"; +import { installCodexAdapter, loadCodexAdapterConfig } from "../../src/runtime/codex-adapter"; +import { runOnlyBotsCodexHookCli } from "../../src/runtime/codex-hook"; +import { evaluateHostHookInspection, inspectHostHookRegistrations } from "../../src/runtime/host-hook-doctor"; +import * as spec from "../../src/runtime/host-hook-spec"; +import { decideHookDispatch, detectClaudeCodePlugin, readCodexPluginEnabled } from "../../src/runtime/host-plugin-mode"; +// @ts-expect-error -- plain ESM generator script without type declarations +import { renderHostPluginFiles } from "../../scripts/host-plugins.mjs"; + +const repoRoot = path.resolve(__dirname, "../.."); +const dirs: string[] = []; +const savedEnv = { ...process.env }; +afterEach(() => { + dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })); + process.env = { ...savedEnv }; +}); + +const tempDir = (label: string): string => { + const dir = mkdtempSync(path.join(os.tmpdir(), `crosshead-plugins-${label}-`)); + dirs.push(dir); + return dir; +}; + +const fixture = (label: string) => { + const root = tempDir(label); + const home = path.join(root, "runtime"); + const claudeHome = path.join(root, "claude"); + const codexHome = path.join(root, "codex"); + const workspace = path.join(root, "project"); + for (const dir of [claudeHome, codexHome, workspace]) mkdirSync(dir, { recursive: true }); + return { root, home, claudeHome, codexHome, workspace }; +}; + +const scripts = { bridgeScriptPath: path.join(repoRoot, "dist/runtime/claude-code-bridge.js"), hookScriptPath: path.join(repoRoot, "dist/runtime/claude-code-hook.js") }; +const readJson = (filePath: string) => JSON.parse(readFileSync(filePath, "utf8")); +const enableClaudePlugin = (claudeHome: string, enabled = true) => + writeFileSync(path.join(claudeHome, "settings.json"), JSON.stringify({ enabledPlugins: { "crosshead@crosshead": enabled } })); +const enableCodexPlugin = (codexHome: string, enabled = true) => + writeFileSync(path.join(codexHome, "config.toml"), `model = "x"\n\n[plugins."crosshead@crosshead"]\nenabled = ${enabled}\n`); +const managedCommands = (hooks: Record }>> | undefined, marker: string) => + Object.values(hooks ?? {}).flatMap((groups) => groups.flatMap((group) => group.hooks.map((hook) => hook.command))).filter((command) => command.includes(marker)); + +describe("host plugin files", () => { + it("checked-in plugin and marketplace files match the shared hook spec", () => { + const files = renderHostPluginFiles(spec) as Record; + expect(Object.keys(files).sort()).toEqual([ + ".agents/plugins/marketplace.json", + ".claude-plugin/marketplace.json", + "plugins/claude-code/.claude-plugin/plugin.json", + "plugins/claude-code/hooks/hooks.json", + "plugins/claude-code/scripts/crosshead-hook", + "plugins/claude-code/skills/setup/SKILL.md", + "plugins/codex/.codex-plugin/plugin.json", + "plugins/codex/hooks/hooks.json", + "plugins/codex/scripts/crosshead-hook", + "plugins/codex/skills/setup/SKILL.md", + ]); + for (const [relative, content] of Object.entries(files)) { + expect(readFileSync(path.join(repoRoot, relative), "utf8"), `${relative} is stale; run npm run plugins:generate`).toBe(content); + } + }); + + it("plugin hooks cover exactly the events the legacy installer registers", () => { + for (const host of ["claude-code", "codex"] as const) { + const hooks = JSON.parse(spec.renderPluginHooksFile(host)).hooks as Record }>>; + expect(Object.keys(hooks)).toEqual(spec.HOST_HOOK_EVENTS.map((entry) => entry.event)); + for (const entry of spec.HOST_HOOK_EVENTS) { + expect(hooks[entry.event]).toHaveLength(1); + expect(hooks[entry.event]![0]!.matcher).toBe(entry.matcher); + expect(hooks[entry.event]![0]!.hooks[0]!.command).toBe(spec.pluginHookCommand(host, entry.event)); + } + } + }); + + it("skills never download code other than the digest-pinned installer", () => { + const files = renderHostPluginFiles(spec) as Record; + for (const host of ["claude-code", "codex"]) { + const skill = files[`plugins/${host}/skills/setup/SKILL.md`]!; + expect(skill.match(/https?:\/\/[^\s)`]+/g)?.filter((url) => !url.startsWith("https://crosshead.sh"))).toEqual([]); + expect(skill).toContain(`--adapter ${host} --workspace-root . --hook-mode plugin`); + expect(skill).toContain("ask for explicit consent"); + } + const launcherCode = files["plugins/codex/scripts/crosshead-hook"]!.split("\n").filter((line) => !line.trimStart().startsWith("#")); + expect(launcherCode.join("\n")).not.toMatch(/curl|wget|npm |npx |node /); + }); +}); + +describe("legacy hook rendering", () => { + it("keeps the settings-mode hook layout unchanged", async () => { + const f = fixture("legacy-claude"); + await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, hookMode: "settings", ...scripts }); + const hookWrapper = path.join(f.home, "bin", "crosshead-claude-code-hook"); + const command = (event: string) => `'${hookWrapper}' '${event}' --home '${f.home}' --claude-home '${f.claudeHome}'`; + expect(readJson(path.join(f.workspace, ".claude", "settings.json"))).toEqual({ + hooks: { + SessionStart: [{ matcher: "startup|resume|compact", hooks: [{ type: "command", command: command("SessionStart") }] }], + PreCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PreCompact") }] }], + PostCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PostCompact") }] }], + UserPromptSubmit: [{ hooks: [{ type: "command", command: command("UserPromptSubmit") }] }], + Stop: [{ hooks: [{ type: "command", command: command("Stop") }] }], + }, + }); + }); + + it("keeps the Codex settings-mode hook layout unchanged", async () => { + const f = fixture("legacy-codex"); + await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, hookMode: "settings", ...scripts }); + const hookWrapper = path.join(f.home, "bin", "crosshead-codex-hook"); + const command = (event: string) => `'${hookWrapper}' '${event}' --home '${f.home}' --codex-home '${f.codexHome}'`; + expect(readJson(path.join(f.codexHome, "hooks.json"))).toEqual({ + hooks: { + SessionStart: [{ matcher: "startup|resume|compact", hooks: [{ type: "command", command: command("SessionStart"), statusMessage: "Crosshead session sync" }] }], + PreCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PreCompact"), statusMessage: "Crosshead pre-compact checkpoint", timeout: 30 }] }], + PostCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PostCompact"), statusMessage: "Crosshead post-compact sync", timeout: 30 }] }], + UserPromptSubmit: [{ hooks: [{ type: "command", command: command("UserPromptSubmit"), statusMessage: "Crosshead context", timeout: 45 }] }], + Stop: [{ hooks: [{ type: "command", command: command("Stop"), statusMessage: "Crosshead memory sync", timeout: 30 }] }], + }, + }); + }); +}); + +describe("plugin mode install and migration", () => { + it("Claude Code: detects the enabled plugin, skips hook writing and removes legacy hooks idempotently", async () => { + const f = fixture("claude-migrate"); + const settings = path.join(f.workspace, ".claude", "settings.json"); + await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); + const legacy = readJson(settings); + legacy.hooks.Stop.push({ hooks: [{ type: "command", command: "echo unrelated" }] }); + legacy.model = "keep-me"; + writeFileSync(settings, JSON.stringify(legacy)); + expect(loadClaudeCodeAdapterConfig(f.home, f.claudeHome).hookMode).toBe("settings"); + + enableClaudePlugin(f.claudeHome); + const first = await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); + expect(first.config.hookMode).toBe("plugin"); + expect(first.pluginDetection?.source).toBe(path.join(f.claudeHome, "settings.json")); + expect(first.migratedFiles).toEqual([settings]); + const migrated = readJson(settings); + expect(managedCommands(migrated.hooks, "crosshead-claude-code-hook")).toEqual([]); + expect(migrated.hooks.Stop).toEqual([{ hooks: [{ type: "command", command: "echo unrelated" }] }]); + expect(migrated.model).toBe("keep-me"); + expect(first.managedFiles).not.toContain(settings); + + const second = await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); + expect(second.migratedFiles).toEqual([]); + expect(readJson(settings)).toEqual(migrated); + }); + + it("Claude Code: removes a settings file that only held Crosshead hooks; explicit --hook-mode wins over detection", async () => { + const f = fixture("claude-explicit"); + const settings = path.join(f.workspace, ".claude", "settings.json"); + await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); + const result = await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, hookMode: "plugin", ...scripts }); + expect(result.pluginDetection).toBeNull(); + expect(result.migratedFiles).toEqual([settings]); + expect(existsSync(settings)).toBe(false); + + enableClaudePlugin(f.claudeHome); + const forced = await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, hookMode: "settings", ...scripts }); + expect(forced.config.hookMode).toBe("settings"); + expect(managedCommands(readJson(settings).hooks, "crosshead-claude-code-hook")).toHaveLength(5); + }); + + it("Codex: detects the enabled plugin in config.toml and migrates hooks.json", async () => { + const f = fixture("codex-migrate"); + const hooksFile = path.join(f.codexHome, "hooks.json"); + await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, ...scripts }); + const legacy = readJson(hooksFile); + legacy.hooks.PreToolUse = [{ matcher: "Bash", hooks: [{ type: "command", command: "echo unrelated" }] }]; + writeFileSync(hooksFile, JSON.stringify(legacy)); + + enableCodexPlugin(f.codexHome); + const result = await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, ...scripts }); + expect(result.config.hookMode).toBe("plugin"); + expect(result.migratedFiles).toEqual([hooksFile]); + expect(readJson(hooksFile)).toEqual({ hooks: { PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo unrelated" }] }] } }); + expect(loadCodexAdapterConfig(f.home, f.codexHome).hookMode).toBe("plugin"); + + const again = await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, ...scripts }); + expect(again.migratedFiles).toEqual([]); + }); + + it("Codex: a disabled plugin keeps settings mode", async () => { + const f = fixture("codex-disabled"); + enableCodexPlugin(f.codexHome, false); + const result = await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, ...scripts }); + expect(result.config.hookMode).toBe("settings"); + expect(managedCommands(readJson(path.join(f.codexHome, "hooks.json")).hooks, "crosshead-codex-hook")).toHaveLength(5); + }); +}); + +describe("plugin detection", () => { + it("reads Codex plugin tables without a TOML dependency", () => { + expect(readCodexPluginEnabled('[plugins."crosshead@crosshead"]\nenabled = true\n')).toBe(true); + expect(readCodexPluginEnabled("[plugins.'crosshead@local']\nenabled = false # off\n")).toBe(false); + expect(readCodexPluginEnabled('[plugins."crosshead@crosshead"]\n')).toBe(true); + expect(readCodexPluginEnabled('[plugins."other@x"]\nenabled = true\n[features]\nenabled = true\n')).toBeNull(); + expect(readCodexPluginEnabled('[plugins."crosshead-extra@x"]\nenabled = true\n')).toBeNull(); + }); + + it("applies Claude Code settings precedence (local over project over user)", () => { + const f = fixture("detect"); + enableClaudePlugin(f.claudeHome, true); + mkdirSync(path.join(f.workspace, ".claude"), { recursive: true }); + writeFileSync(path.join(f.workspace, ".claude", "settings.local.json"), JSON.stringify({ enabledPlugins: { "crosshead@crosshead": false } })); + expect(detectClaudeCodePlugin(f.claudeHome, f.workspace)).toEqual({ enabled: false, source: path.join(f.workspace, ".claude", "settings.local.json") }); + }); +}); + +describe("hook dispatch", () => { + it("runs exactly one registration per event", () => { + const present = () => true; + const absent = () => false; + expect(decideHookDispatch({ viaPlugin: true, mode: "plugin", legacyHooksPresent: present })).toBe("run"); + expect(decideHookDispatch({ viaPlugin: false, mode: "plugin", legacyHooksPresent: present })).toBe("skip-duplicate"); + expect(decideHookDispatch({ viaPlugin: false, mode: "settings", legacyHooksPresent: present })).toBe("run"); + expect(decideHookDispatch({ viaPlugin: true, mode: "settings", legacyHooksPresent: present })).toBe("skip-duplicate"); + expect(decideHookDispatch({ viaPlugin: true, mode: "settings", legacyHooksPresent: absent })).toBe("run"); + }); + + const captureStdout = async (run: () => Promise): Promise => { + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + chunks.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + await run(); + } finally { + process.stdout.write = original; + } + return chunks.join(""); + }; + + it("plugin invocations before setup print the setup notice on SessionStart only", async () => { + const f = fixture("unconfigured"); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + process.env.CODEX_HOME = f.codexHome; + const payload = JSON.stringify({ session_id: "s1", cwd: f.workspace }); + const claudeStart = await captureStdout(() => runOnlyBotsClaudeCodeHookCli(["SessionStart", "--home", f.home, "--claude-home", f.claudeHome, "--via", "plugin"], payload)); + expect(JSON.parse(claudeStart)).toEqual({ + systemMessage: spec.pluginSetupHint("claude-code"), + hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: spec.pluginSetupHint("claude-code") }, + }); + expect(await captureStdout(() => runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--claude-home", f.claudeHome, "--via", "plugin"], payload))).toBe(""); + const codexStart = await captureStdout(() => runOnlyBotsCodexHookCli(["SessionStart", "--home", f.home, "--via", "plugin"], payload)); + expect(JSON.parse(codexStart).systemMessage).toBe(spec.pluginSetupHint("codex")); + // Legacy invocations keep failing loudly when the adapter is missing. + await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--claude-home", f.claudeHome], payload)).rejects.toThrow("not installed"); + }); + + it("skips the duplicate registration before touching the daemon", async () => { + const f = fixture("dedupe"); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + const payload = JSON.stringify({ session_id: "s1", cwd: f.workspace }); + const claudeArgs = (...extra: string[]) => ["Stop", "--home", f.home, "--claude-home", f.claudeHome, ...extra]; + await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); + // Settings mode with legacy hooks in this workspace: the plugin defers, legacy runs (and reaches the daemon). + await expect(runOnlyBotsClaudeCodeHookCli(claudeArgs("--via", "plugin"), payload)).resolves.toBeUndefined(); + await expect(runOnlyBotsClaudeCodeHookCli(claudeArgs(), payload)).rejects.toThrow("daemon is unavailable"); + // Settings mode in a workspace the installer never saw: the plugin handles it. + const other = path.join(f.root, "other"); + mkdirSync(other); + await expect(runOnlyBotsClaudeCodeHookCli(claudeArgs("--via", "plugin"), JSON.stringify({ session_id: "s2", cwd: other }))).rejects.toThrow("daemon is unavailable"); + // Plugin mode: legacy invocations are ignored, plugin invocations run. + await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, hookMode: "plugin", ...scripts }); + await expect(runOnlyBotsClaudeCodeHookCli(claudeArgs(), payload)).resolves.toBeUndefined(); + await expect(runOnlyBotsClaudeCodeHookCli(claudeArgs("--via", "plugin"), payload)).rejects.toThrow("daemon is unavailable"); + + process.env.CODEX_HOME = f.codexHome; + await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, ...scripts }); + await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--via", "plugin"], payload)).resolves.toBeUndefined(); + await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, hookMode: "plugin", ...scripts }); + await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--codex-home", f.codexHome], payload)).resolves.toBeUndefined(); + }); +}); + +describe("plugin launcher", () => { + const launcher = path.join(repoRoot, "plugins", "claude-code", spec.PLUGIN_HOOK_LAUNCHER); + const run = (env: NodeJS.ProcessEnv, ...args: string[]) => + spawnSync("sh", [launcher, ...args], { input: '{"session_id":"s"}', encoding: "utf8", env: { PATH: process.env.PATH, ...env } }); + + it("prints one setup line on SessionStart and exits 0 without a runtime", () => { + const f = fixture("launcher"); + for (const host of ["claude-code", "codex"] as const) { + const start = run({ HOME: f.root }, host, "SessionStart"); + expect(start.status).toBe(0); + expect(start.stdout.trim().split("\n")).toHaveLength(1); + expect(JSON.parse(start.stdout).systemMessage).toBe(spec.pluginSetupHint(host)); + const stop = run({ HOME: f.root }, host, "Stop"); + expect(stop.status).toBe(0); + expect(stop.stdout).toBe(""); + } + expect(run({ HOME: f.root }, "unknown-host", "SessionStart").stdout).toBe(""); + }); + + it("asks for an update when the runtime predates plugin support", () => { + const f = fixture("launcher-old"); + mkdirSync(path.join(f.root, ".crosshead", "bin"), { recursive: true }); + writeFileSync(path.join(f.root, ".crosshead", "bin", "crosshead-claude-code-hook"), "#!/bin/sh\necho ran\n", { mode: 0o755 }); + mkdirSync(path.join(f.claudeHome, "onlybots"), { recursive: true }); + writeFileSync(path.join(f.claudeHome, "onlybots", "claude-code-adapter.json"), '{"marker":"x"}'); + const start = run({ HOME: f.root, CLAUDE_CONFIG_DIR: f.claudeHome }, "claude-code", "SessionStart"); + expect(JSON.parse(start.stdout).systemMessage).toBe(spec.pluginUpdateHint("claude-code")); + expect(run({ HOME: f.root, CLAUDE_CONFIG_DIR: f.claudeHome }, "claude-code", "Stop").stdout).toBe(""); + + writeFileSync(path.join(f.claudeHome, "onlybots", "claude-code-adapter.json"), '{"marker":"x","hookMode":"plugin"}'); + const current = run({ HOME: f.root, CLAUDE_CONFIG_DIR: f.claudeHome }, "claude-code", "Stop"); + expect(current.stdout).toBe("ran\n"); + }); + + it("is the same file in both plugins and passes sh -n", () => { + const codexLauncher = path.join(repoRoot, "plugins", "codex", spec.PLUGIN_HOOK_LAUNCHER); + expect(readFileSync(codexLauncher, "utf8")).toBe(readFileSync(launcher, "utf8")); + execFileSync("sh", ["-n", launcher]); + }); +}); + +describe("doctor hook checks", () => { + it("reports the active mode and flags duplicates or missing hooks", async () => { + const ok = evaluateHostHookInspection({ host: "claude-code", mode: "plugin", plugin: { enabled: true, source: "/u/settings.json" }, legacyHookFiles: [] }); + expect(ok).toMatchObject({ id: "claude_code_hooks", ok: true }); + expect(ok.message).toContain("mode=plugin"); + expect(evaluateHostHookInspection({ host: "codex", mode: "plugin", plugin: { enabled: false, source: null }, legacyHookFiles: [] }).ok).toBe(false); + const duplicate = evaluateHostHookInspection({ host: "codex", mode: "settings", plugin: { enabled: true, source: null }, legacyHookFiles: ["/c/hooks.json"] }); + expect(duplicate.ok).toBe(false); + expect(duplicate.message).toContain("--hook-mode plugin"); + + const f = fixture("doctor"); + await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); + enableClaudePlugin(f.claudeHome); + const [check] = inspectHostHookRegistrations(f.home, f.workspace); + expect(check).toMatchObject({ id: "claude_code_hooks", ok: false }); + expect(check!.message).toContain("Duplicate registration"); + await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); + expect(inspectHostHookRegistrations(f.home, f.workspace)[0]).toMatchObject({ ok: true }); + await uninstallClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome }); + expect(inspectHostHookRegistrations(f.home, f.workspace)).toEqual([]); + }); +}); From bbb1e09d945b2d0b72b72b423f9091f1279a3530 Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 15:18:27 -0500 Subject: [PATCH 03/11] Report plugin-mode adapters as healthy in doctor's automation checks In plugin mode the installer intentionally writes no host hook files, so the adapter automation checks no longer report them missing. They point to the claude_code_hooks/codex_hooks checks, which verify the plugin. --- scripts/smoke-host-plugins.mjs | 4 +++- src/runtime/host-plugin-mode.ts | 11 +++++++++++ src/runtime/runtime-status.ts | 18 ++++++++++++++---- tests/runtime/host-plugins.test.ts | 8 ++++++-- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/scripts/smoke-host-plugins.mjs b/scripts/smoke-host-plugins.mjs index b784a70..0bf9bf3 100644 --- a/scripts/smoke-host-plugins.mjs +++ b/scripts/smoke-host-plugins.mjs @@ -304,7 +304,9 @@ try { assert.equal(crossheadHookLines(claudeA).length, 3); const codexA = await measuredSession(a.home, "codex", "after setup", () => codexSession("setup-done", a.home, a.project)); assert.equal(codexA.hookRuns.length, 3); - for (const id of ["claude_code_hooks", "codex_hooks"]) assert.match(doctorCheck(await doctor(a.home, a.project), id).message, /^mode=plugin; plugin enabled/); + const setupDoctor = await doctor(a.home, a.project); + for (const id of ["claude_code_hooks", "codex_hooks"]) assert.match(doctorCheck(setupDoctor, id).message, /^mode=plugin; plugin enabled/); + for (const id of ["adapter_claude_code_automation", "adapter_codex_automation"]) assert.equal(doctorCheck(setupDoctor, id).ok, true, doctorCheck(setupDoctor, id).message); check("setup (installer + quickstart --hook-mode plugin) writes no host hooks; one capture per event in both hosts; doctor reports plugin mode"); // ---- B2: plugins added to the legacy install, then migration ------------- diff --git a/src/runtime/host-plugin-mode.ts b/src/runtime/host-plugin-mode.ts index 98bdcae..74c2785 100644 --- a/src/runtime/host-plugin-mode.ts +++ b/src/runtime/host-plugin-mode.ts @@ -120,6 +120,17 @@ export const describeHookMode = (mode: HostHookMode, detection: PluginDetection export const normalizeHostHookMode = (value: unknown): HostHookMode => (value === "plugin" ? "plugin" : "settings"); +/** Hook mode recorded in a Claude Code or Codex adapter config file (marker line, then JSON). */ +export const readAdapterConfigHookMode = (configFile: string): HostHookMode => { + try { + const content = readFileSync(configFile, "utf8"); + const parsed = JSON.parse(content.slice(content.indexOf("{"))) as { hookMode?: unknown }; + return normalizeHostHookMode(parsed.hookMode); + } catch { + return "settings"; + } +}; + export const isPluginHookInvocation = (flags: Record): boolean => flags[PLUGIN_HOOK_VIA_FLAG[0].slice(2)] === PLUGIN_HOOK_VIA_FLAG[1]; diff --git a/src/runtime/runtime-status.ts b/src/runtime/runtime-status.ts index e62513a..d550ccc 100644 --- a/src/runtime/runtime-status.ts +++ b/src/runtime/runtime-status.ts @@ -53,6 +53,7 @@ import type { RuntimeDoctorReport, RuntimeStatus, } from "./local-runtime"; +import { readAdapterConfigHookMode } from "./host-plugin-mode"; const isSessionFresh = (session: HostedSession | null): boolean => { if (!session) { @@ -114,12 +115,15 @@ export const evaluateAdapterAutomation = (adapter: RuntimeAdapterRecord): Runtim const hasManagedFile = (predicate: (filePath: string) => boolean): boolean => [...managedFiles].some((filePath) => predicate(filePath)); const basename = (name: string) => (filePath: string): boolean => path.basename(filePath) === name; + // In plugin mode the host plugin supplies the hooks; the _hooks doctor check verifies it. + const pluginMode = (configName: string): boolean => + [...managedFiles].some((filePath) => path.basename(filePath) === configName && readAdapterConfigHookMode(filePath) === "plugin"); if (adapter.name === "codex") { const requirements = [ { label: "repo-local codex launcher", ok: hasManagedFile(basename("codex")) }, { label: "Codex hook wrapper", ok: (hasManagedFile(basename("crosshead-codex-hook")) || hasManagedFile(basename("onlybots-codex-hook"))) }, - { label: "Codex hooks.json", ok: hasManagedFile((filePath) => path.basename(filePath) === "hooks.json") }, + { label: "Codex hooks.json", ok: pluginMode("codex-adapter.json") || hasManagedFile((filePath) => path.basename(filePath) === "hooks.json") }, ]; const missing = requirements.filter((entry) => !entry.ok).map((entry) => entry.label); return { @@ -129,7 +133,9 @@ export const evaluateAdapterAutomation = (adapter: RuntimeAdapterRecord): Runtim adapter.status === "failed" ? `Codex automatic integration is broken: ${adapter.lastError ?? "adapter attach failed"}.` : missing.length === 0 - ? "Codex automatic integration is healthy through the repo-local launcher and managed hooks." + ? pluginMode("codex-adapter.json") + ? "Codex automatic integration uses the Crosshead plugin's hooks (see codex_hooks)." + : "Codex automatic integration is healthy through the repo-local launcher and managed hooks." : `Codex automatic integration is degraded; missing ${missing.join(", ")}.`, }; } @@ -140,7 +146,9 @@ export const evaluateAdapterAutomation = (adapter: RuntimeAdapterRecord): Runtim { label: "Claude hook wrapper", ok: (hasManagedFile(basename("crosshead-claude-code-hook")) || hasManagedFile(basename("onlybots-claude-code-hook"))) }, { label: "project Claude settings hooks", - ok: hasManagedFile((filePath) => path.basename(filePath) === "settings.json" && filePath.includes(`${path.sep}.claude${path.sep}`)), + ok: + pluginMode("claude-code-adapter.json") || + hasManagedFile((filePath) => path.basename(filePath) === "settings.json" && filePath.includes(`${path.sep}.claude${path.sep}`)), }, ]; const missing = requirements.filter((entry) => !entry.ok).map((entry) => entry.label); @@ -151,7 +159,9 @@ export const evaluateAdapterAutomation = (adapter: RuntimeAdapterRecord): Runtim adapter.status === "failed" ? `Claude Code automatic integration is broken: ${adapter.lastError ?? "adapter attach failed"}.` : missing.length === 0 - ? "Claude Code automatic integration is healthy through repo-local project hooks and the managed claude launcher." + ? pluginMode("claude-code-adapter.json") + ? "Claude Code automatic integration uses the Crosshead plugin's hooks (see claude_code_hooks)." + : "Claude Code automatic integration is healthy through repo-local project hooks and the managed claude launcher." : `Claude Code automatic integration is degraded; missing ${missing.join(", ")}.`, }; } diff --git a/tests/runtime/host-plugins.test.ts b/tests/runtime/host-plugins.test.ts index 7009a1b..ebd3bfd 100644 --- a/tests/runtime/host-plugins.test.ts +++ b/tests/runtime/host-plugins.test.ts @@ -11,6 +11,7 @@ import { installCodexAdapter, loadCodexAdapterConfig } from "../../src/runtime/c import { runOnlyBotsCodexHookCli } from "../../src/runtime/codex-hook"; import { evaluateHostHookInspection, inspectHostHookRegistrations } from "../../src/runtime/host-hook-doctor"; import * as spec from "../../src/runtime/host-hook-spec"; +import { evaluateAdapterAutomation } from "../../src/runtime/runtime-status"; import { decideHookDispatch, detectClaudeCodePlugin, readCodexPluginEnabled } from "../../src/runtime/host-plugin-mode"; // @ts-expect-error -- plain ESM generator script without type declarations import { renderHostPluginFiles } from "../../scripts/host-plugins.mjs"; @@ -341,9 +342,12 @@ describe("doctor hook checks", () => { const [check] = inspectHostHookRegistrations(f.home, f.workspace); expect(check).toMatchObject({ id: "claude_code_hooks", ok: false }); expect(check!.message).toContain("Duplicate registration"); - await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); + const pluginInstall = await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); expect(inspectHostHookRegistrations(f.home, f.workspace)[0]).toMatchObject({ ok: true }); + expect(evaluateAdapterAutomation(pluginInstall.adapter)).toMatchObject({ id: "adapter_claude_code_automation", ok: true }); + const codexInstall = await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, hookMode: "plugin", ...scripts }); + expect(evaluateAdapterAutomation(codexInstall.adapter)).toMatchObject({ id: "adapter_codex_automation", ok: true }); await uninstallClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome }); - expect(inspectHostHookRegistrations(f.home, f.workspace)).toEqual([]); + expect(inspectHostHookRegistrations(f.home, f.workspace).map((entry) => entry.id)).toEqual(["codex_hooks"]); }); }); From 782f02bd65dc0b503c203e442053201fe9326967 Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 16:32:27 -0500 Subject: [PATCH 04/11] Validate hosted failure codes and say when a bounded fallback was not charged The recorded failure code passes the same identifier check as the error name. The fallback warning now follows the failure class: a server deadline that reports nothing charged says so, a 4xx names the rejection, and only timeouts, network failures and unconfirmed server errors keep the may-still-settle caution. A lifecycle test asserts that the fallback action records details.failure. --- src/runtime/hosted-client.ts | 24 ++++++++- src/runtime/lifecycle-engine.ts | 4 +- tests/runtime/hosted-client-security.test.ts | 24 ++++++++- .../runtime/hosted-lifecycle-security.test.ts | 53 ++++++++++++++++++- 4 files changed, 99 insertions(+), 6 deletions(-) diff --git a/src/runtime/hosted-client.ts b/src/runtime/hosted-client.ts index 32ca18c..10c4bb9 100644 --- a/src/runtime/hosted-client.ts +++ b/src/runtime/hosted-client.ts @@ -250,12 +250,32 @@ export class HostedOnlyBotsRequestError extends Error { * Classify a failed hosted request for local diagnostics without copying its * message, URL, headers or body: a timeout, an HTTP status/code, or an error name. */ +const SAFE_FAILURE_LABEL = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; +const safeFailureLabel = (value: string | null): string | null => (value !== null && SAFE_FAILURE_LABEL.test(value) ? value : null); + export const describeHostedFailure = (error: unknown): Record => { - if (error instanceof HostedOnlyBotsRequestError) return { errorClass: "http", status: error.status, code: error.code }; + // The server chooses `code`; validate it like an error name so it cannot carry message text. + if (error instanceof HostedOnlyBotsRequestError) return { errorClass: "http", status: error.status, code: safeFailureLabel(error.code) }; const name = error instanceof Error ? error.name : typeof error; if (name === "TimeoutError") return { errorClass: "timeout" }; if (name === "AbortError") return { errorClass: "aborted" }; - return { errorClass: /^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name) ? name : "unknown" }; + return { errorClass: safeFailureLabel(name) ?? "unknown" }; +}; + +/** + * Warning for a bounded recall that fell back to free hot memory. Only a timeout, abort, + * network failure or unconfirmed server error leaves open whether the paid request settled. + */ +export const describeBoundedRecallFallback = (error: unknown): string => { + const prefix = "Saved-context retrieval unavailable; continuing with free hot memory."; + if (error instanceof HostedOnlyBotsRequestError) { + const details = error.details && typeof error.details === "object" ? (error.details as Record) : null; + if (error.code === "recall_deadline_exceeded" || details?.charged_sats === 0) { + return `${prefix} The server missed its response budget (HTTP ${error.status}) and charged nothing; a retry is safe.`; + } + if (error.status < 500) return `${prefix} The server rejected the request (HTTP ${error.status}).`; + } + return `${prefix} A timed-out or failed paid request may still settle; inspect billing before retrying.`; }; const extractHostedError = (data: unknown): { message: string | null; code: string | null; details: unknown } => { diff --git a/src/runtime/lifecycle-engine.ts b/src/runtime/lifecycle-engine.ts index 8c282cf..da1f2af 100644 --- a/src/runtime/lifecycle-engine.ts +++ b/src/runtime/lifecycle-engine.ts @@ -19,7 +19,7 @@ import { pickHotContextValidationCommand, } from "./hot-context-cues"; import { buildRuntimeBillingRemediation, formatRuntimeBillingRemediation, isBillingBlockedError } from "./billing-remediation"; -import { describeHostedFailure, HostedOnlyBotsClient, type HostedSession } from "./hosted-client"; +import { describeBoundedRecallFallback, describeHostedFailure, HostedOnlyBotsClient, type HostedSession } from "./hosted-client"; import { isLocalOnlyRuntimeProfile, readLocalHotMemory, writeLocalHotMemory } from "./local-hot-memory"; import { workspaceHotScope, selectWorkspaceHotState } from "./workspace-hot-memory"; import { buildRuntimeContinuityOutcome, parseRuntimeContinuityOutcomeContent } from "./continuity-outcomes"; @@ -1782,7 +1782,7 @@ export class RuntimeLifecycleManager { } catch (error) { if (!isBillingBlockedError(error) && packRequest.recallPolicy === "bounded") { contextPack = buildLocalOnlyContextPack(event, packRequest); - warnings.push("Saved-context retrieval unavailable; continuing with free hot memory. A timed-out paid request may still settle; inspect billing before retrying."); + warnings.push(describeBoundedRecallFallback(error)); actions.push(this.recordAction({ eventId: event.eventId, profileName, type: "context_pack", status: "failed", mode: profile.memoryMode, reason: "Bounded recall unavailable; preserved free hot-memory delivery", queueItemId: null, details: { type: event.type, freeFallback: true, failure: describeHostedFailure(error) } })); diff --git a/tests/runtime/hosted-client-security.test.ts b/tests/runtime/hosted-client-security.test.ts index 9fad693..c86d563 100644 --- a/tests/runtime/hosted-client-security.test.ts +++ b/tests/runtime/hosted-client-security.test.ts @@ -4,7 +4,7 @@ import { schnorr } from "@noble/curves/secp256k1"; import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools"; import { afterEach, describe, expect, it } from "vitest"; -import { describeHostedFailure, HostedOnlyBotsClient, HostedOnlyBotsRequestError } from "../../src/runtime/hosted-client"; +import { describeBoundedRecallFallback, describeHostedFailure, HostedOnlyBotsClient, HostedOnlyBotsRequestError } from "../../src/runtime/hosted-client"; const servers: Server[] = []; afterEach(async () => { @@ -192,5 +192,27 @@ describe("describeHostedFailure", () => { expect(describeHostedFailure(http)).toEqual({ errorClass: "http", status: 503, code: "recall_deadline_exceeded" }); expect(describeHostedFailure(new TypeError("fetch failed: secret.example"))).toEqual({ errorClass: "TypeError" }); expect(JSON.stringify(describeHostedFailure(Object.assign(new Error("x"), { name: "bad name with token=1" })))).not.toContain("token"); + // A server-chosen code that is not a plain identifier is dropped, not copied. + const unsafeCode = new HostedOnlyBotsRequestError({ status: 500, code: "oops token=secret https://x", message: "m" }); + expect(describeHostedFailure(unsafeCode)).toEqual({ errorClass: "http", status: 500, code: null }); + expect(describeHostedFailure(new HostedOnlyBotsRequestError({ status: 502, code: "a".repeat(65), message: "m" })).code).toBeNull(); + }); +}); + +describe("describeBoundedRecallFallback", () => { + it("says nothing was charged only when the server confirms it, and keeps the settle caution otherwise", () => { + const deadline = new HostedOnlyBotsRequestError({ status: 503, code: "recall_deadline_exceeded", message: "late", + details: { charged_sats: 0, retryable: true } }); + expect(describeBoundedRecallFallback(deadline)).toContain("charged nothing"); + expect(describeBoundedRecallFallback(deadline)).not.toContain("may still settle"); + const timeout = new DOMException("timed out", "TimeoutError"); + expect(describeBoundedRecallFallback(timeout)).toContain("may still settle"); + expect(describeBoundedRecallFallback(new TypeError("fetch failed"))).toContain("may still settle"); + const gateway = new HostedOnlyBotsRequestError({ status: 504, code: null, message: "gateway timeout" }); + expect(describeBoundedRecallFallback(gateway)).toContain("may still settle"); + const rejected = describeBoundedRecallFallback(new HostedOnlyBotsRequestError({ status: 400, code: "bad_request", message: "secret detail" })); + expect(rejected).toContain("HTTP 400"); + expect(rejected).not.toContain("may still settle"); + expect(rejected).not.toContain("secret detail"); }); }); diff --git a/tests/runtime/hosted-lifecycle-security.test.ts b/tests/runtime/hosted-lifecycle-security.test.ts index 27b281e..119564d 100644 --- a/tests/runtime/hosted-lifecycle-security.test.ts +++ b/tests/runtime/hosted-lifecycle-security.test.ts @@ -26,7 +26,7 @@ const jsonResponse = (body: unknown, status = 200): Response => headers: { "content-type": "application/json" }, }); -const installFetchMock = (npub: string, contextStatus: number) => { +const installFetchMock = (npub: string, contextStatus: number, contextPack?: () => Promise) => { vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); if (url.pathname === "/v1/auth/challenge") { @@ -51,6 +51,7 @@ const installFetchMock = (npub: string, contextStatus: number) => { }); } if (url.pathname === "/v1/context/pack") { + if (contextPack) return contextPack(); if (contextStatus !== 200) { return jsonResponse({ error: { code: "payment_required", message: "funding required" } }, contextStatus); } @@ -170,4 +171,54 @@ describe("hosted lifecycle prompt boundary", () => { expect(promptSurface).not.toContain("SERVER_AUTHORED_FRAGMENT_MUST_NOT_REACH_THE_PROMPT"); if (contextStatus === 200) expect(result.contextPack?.packId).toBe("hosted-pack-safe-id"); }); + + it.each([ + { + label: "the server's recall deadline", + contextPack: async () => jsonResponse({ error: { code: "recall_deadline_exceeded", message: "late", + details: { deadline_ms: 2500, charged_sats: 0, retryable: true } } }, 503), + failure: { errorClass: "http", status: 503, code: "recall_deadline_exceeded" }, + warning: "charged nothing", + }, + { + label: "the client's recall timeout", + contextPack: async () => { throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); }, + failure: { errorClass: "timeout" }, + warning: "may still settle", + }, + ])("records why bounded recall fell back to free hot memory after $label", async ({ contextPack, failure, warning }) => { + const signer = identity(); + const home = mkdtempSync(path.join(os.tmpdir(), "crosshead-bounded-fallback-")); + homes.push(home); + installFetchMock(signer.npub, 200, contextPack); + await setupRuntimeProfile({ + home, + profileName: "default", + apiBase: "https://crosshead.sh", + npub: signer.npub, + nsec: signer.nsec, + memoryMode: "assist", + }); + + const daemon = new OnlyBotsLocalRuntimeDaemon(home); + const result = await daemon.submitLifecycleEvent({ + runtime: "claude-code", + type: "turn.started", + workspaceId: "repo:security-test", + sessionId: "current-session", + turnId: "current-turn", + payload: { + goal: "Continue the verified local work", + recallPolicy: "bounded", + hotMemorySnapshot: verifiedHotSnapshot, + }, + }); + + const fallback = result.actions.find((action) => action.type === "context_pack" && action.status === "failed"); + expect(fallback?.details).toMatchObject({ freeFallback: true, failure }); + expect(result.warnings.filter((entry) => entry.startsWith("Saved-context retrieval unavailable"))).toEqual([ + expect.stringContaining(warning), + ]); + expect(JSON.stringify(result.contextPack)).toContain("CLIENT_VERIFIED_HOT_MEMORY_MUST_REACH_THE_PROMPT"); + }); }); From b63a7a88b731d45bbaaa15c78d213db43413ce6c Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 16:44:16 -0500 Subject: [PATCH 05/11] Restart a daemon left on another release when quickstart reuses it The daemon's health response now reports its entry script. When quickstart finds a healthy daemon running a different release than the installed one, it restarts it on the installed release through the ownership-checked stop/start path, and reports the mismatch when a safe restart is not possible (legacy ownership, organizational worker). --port applies on start and restart; a current daemon on another port is reported instead of silently kept. --- src/runtime/cli/quickstart.ts | 90 +++++++++++++++++++++++++++++---- src/runtime/daemon-ownership.ts | 39 ++++++++++---- src/runtime/runtime-http.ts | 3 ++ 3 files changed, 113 insertions(+), 19 deletions(-) diff --git a/src/runtime/cli/quickstart.ts b/src/runtime/cli/quickstart.ts index 8c42fea..69a08a1 100644 --- a/src/runtime/cli/quickstart.ts +++ b/src/runtime/cli/quickstart.ts @@ -6,9 +6,12 @@ import { nativeHostFromCli } from "../native-host-adapter"; import { parseArgs } from "../local-runtime"; import { ensureRuntimeHomeLayout, loadProfile, loadRuntimeConfig } from "../runtime-persistence"; import { billingFromCli } from "./billing"; -import { installCoreFromCli, keygenFromCli, startFromCli, statusFromCli } from "./core"; +import { installCoreFromCli, keygenFromCli, statusFromCli } from "./core"; import { DEFAULT_CHECKOUT_CREDIT_UNITS } from "../billing-remediation"; -import { probeRuntimeDaemonHealth, readDaemonOwnershipState } from "../daemon-ownership"; +import { realpathSync } from "node:fs"; +import path from "node:path"; +import { readDaemonOwnershipState, readRuntimeDaemonHealth } from "../daemon-ownership"; +import { startDetachedRuntimeDaemon, stopDetachedRuntimeDaemon } from "../local-runtime"; import { resolveRuntimePaths } from "../runtime-persistence"; export type QuickstartCliPaths = { @@ -82,6 +85,74 @@ const formatQuickstart = (result: Record): string => { return lines.join("\n"); }; +const canonicalScript = (scriptPath: string): string => { + try { + return realpathSync(scriptPath); + } catch { + return path.resolve(scriptPath); + } +}; + +/** + * Starts the daemon, or reuses a healthy one so quickstart for a second host + * shares it. A daemon still running another release (for example after the + * installer updated the runtime) is restarted on the installed release through + * the ownership-checked stop/start path; if that is not safe, the mismatch is + * reported instead. `--port` is honored on start and restart, and a running + * daemon on another port is reported rather than silently kept. + */ +export const ensureQuickstartDaemon = async (input: { + home?: string; + daemonScriptPath: string; + port?: string; +}): Promise => { + const requestedPort = input.port === undefined ? undefined : Number(input.port); + if (requestedPort !== undefined && (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535)) { + throw new Error("--port must be an integer between 0 and 65535"); + } + const state = readDaemonOwnershipState(resolveRuntimePaths(input.home)); + const health = state ? await readRuntimeDaemonHealth(state) : null; + if (!state || !health) { + const started = await startDetachedRuntimeDaemon({ home: input.home, port: requestedPort, daemonScriptPath: input.daemonScriptPath }); + return { id: "daemon", ok: true, detail: `Started onlybotsd on port ${started.port}` }; + } + const installed = canonicalScript(input.daemonScriptPath); + const running = health.daemonScriptPath ? canonicalScript(health.daemonScriptPath) : null; + if (running === installed) { + if (requestedPort !== undefined && requestedPort !== 0 && requestedPort !== state.port) { + return { + id: "daemon", + ok: false, + detail: `onlybotsd already running on port ${state.port}; --port ${requestedPort} was not applied. Run crosshead stop, then crosshead start --port ${requestedPort}.`, + }; + } + return { id: "daemon", ok: true, detail: `onlybotsd already running on port ${state.port}` }; + } + const runningLabel = running ?? "a release that predates release reporting"; + if (state.organizationalWorkerConfigPath) { + return { + id: "daemon", + ok: false, + detail: `onlybotsd on port ${state.port} runs ${runningLabel}, but ${installed} is installed. It hosts an organizational worker, which needs an explicit operator restart: run crosshead stop, then crosshead start.`, + }; + } + try { + await stopDetachedRuntimeDaemon(input.home); + const restarted = await startDetachedRuntimeDaemon({ + home: input.home, + port: requestedPort ?? state.port ?? undefined, + daemonScriptPath: input.daemonScriptPath, + }); + return { id: "daemon", ok: true, detail: `Restarted onlybotsd on port ${restarted.port}: it was running ${runningLabel}; now ${installed}` }; + } catch (error) { + return { + id: "daemon", + ok: false, + detail: `onlybotsd on port ${state.port} runs ${runningLabel}, but ${installed} is installed, and it could not be restarted safely (${error instanceof Error ? error.message : String(error)}). Run crosshead stop, then crosshead start.`, + }; + } +}; + export const quickstartFromCli = async (argv: string[], paths: QuickstartCliPaths): Promise => { const { flags } = parseArgs(argv); const home = typeof flags.home === "string" ? flags.home : undefined; @@ -153,13 +224,14 @@ export const quickstartFromCli = async (argv: string[], paths: QuickstartCliPath detail: adapter === "omp" ? `${adapterOutput}; OMP uses the Pi embedded adapter path` : adapterOutput, }); - const runningDaemon = readDaemonOwnershipState(resolveRuntimePaths(home)); - if (!boolFlagDisabled(flags, "no-start") && runningDaemon && (await probeRuntimeDaemonHealth(runningDaemon))) { - // Quickstart for a second host shares the daemon the first one started. - steps.push({ id: "daemon", ok: true, detail: `onlybotsd already running on port ${runningDaemon.port}` }); - } else if (!boolFlagDisabled(flags, "no-start")) { - const startOutput = await startFromCli([...flagPair("--home", home), ...flagPair("--port", typeof flags.port === "string" ? flags.port : undefined)]); - steps.push({ id: "daemon", ok: true, detail: startOutput }); + if (!boolFlagDisabled(flags, "no-start")) { + steps.push( + await ensureQuickstartDaemon({ + home, + daemonScriptPath: paths.daemonScriptPath, + port: typeof flags.port === "string" ? flags.port : undefined, + }) + ); } else { steps.push({ id: "daemon", ok: false, detail: "skipped by --no-start" }); } diff --git a/src/runtime/daemon-ownership.ts b/src/runtime/daemon-ownership.ts index 0c3fe37..f3aaa9d 100644 --- a/src/runtime/daemon-ownership.ts +++ b/src/runtime/daemon-ownership.ts @@ -51,34 +51,53 @@ export const daemonOwnerIsLive = (state: RuntimeDaemonState | null): boolean => catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; } }; -/** A bounded health response belongs to the recorded owner, not just a port. */ -export const probeRuntimeDaemonHealth = async (state: RuntimeDaemonState, timeoutMs = 1_000): Promise => { - if (state.status !== "running" || !state.port || !daemonOwnerIsLive(state)) return false; +export type RuntimeDaemonHealth = { + coreVersion?: string; + /** Entry script of the running daemon (absent in daemons that predate it). */ + daemonScriptPath?: string; +}; + +/** + * Authenticated health of the recorded owner, or null when it is not the live + * recorded owner. A bounded health response belongs to the owner, not just a port. + */ +export const readRuntimeDaemonHealth = async (state: RuntimeDaemonState, timeoutMs = 1_000): Promise => { + if (state.status !== "running" || !state.port || !daemonOwnerIsLive(state)) return null; try { const response = await fetch(`http://127.0.0.1:${state.port}/health`, { redirect: "error", signal: AbortSignal.timeout(Math.max(1, timeoutMs)), headers: { "x-onlybots-local-token": state.controlToken }, }); - if (!response.ok) return false; - if (!state.ownerId) return true; // Read-only compatibility for legacy runtimes. + if (!response.ok) return null; const reader = response.body?.getReader(); - if (!reader) return false; + if (!reader) return state.ownerId ? null : {}; const chunks: Uint8Array[] = []; let bytes = 0; while (true) { const chunk = await reader.read(); if (chunk.done) break; bytes += chunk.value.byteLength; - if (bytes > 4_096) { await reader.cancel(); return false; } + if (bytes > 4_096) { await reader.cancel(); return null; } chunks.push(chunk.value); } - const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { ownerId?: string; ownerProcess?: HostedControllerProcess }; + let body: { ownerId?: string; ownerProcess?: HostedControllerProcess; coreVersion?: unknown; daemonScriptPath?: unknown }; + try { body = JSON.parse(Buffer.concat(chunks).toString("utf8")); } + catch { if (!state.ownerId) return {}; return null; } + const health: RuntimeDaemonHealth = { + ...(typeof body.coreVersion === "string" ? { coreVersion: body.coreVersion } : {}), + ...(typeof body.daemonScriptPath === "string" ? { daemonScriptPath: body.daemonScriptPath } : {}), + }; + if (!state.ownerId) return health; // Read-only compatibility for legacy runtimes. return body.ownerId === state.ownerId && body.ownerProcess?.pid === state.ownerProcess?.pid && body.ownerProcess?.startedAtTicks === state.ownerProcess?.startedAtTicks && - body.ownerProcess?.bootId === state.ownerProcess?.bootId && daemonOwnerIsLive(state); - } catch { return false; } + body.ownerProcess?.bootId === state.ownerProcess?.bootId && daemonOwnerIsLive(state) ? health : null; + } catch { return null; } }; +/** A bounded health response belongs to the recorded owner, not just a port. */ +export const probeRuntimeDaemonHealth = async (state: RuntimeDaemonState, timeoutMs = 1_000): Promise => + (await readRuntimeDaemonHealth(state, timeoutMs)) !== null; + export class RuntimeDaemonOwnership { readonly ownerId = randomUUID(); readonly ownerProcess: HostedControllerProcess = readRuntimeProcess(process.pid); diff --git a/src/runtime/runtime-http.ts b/src/runtime/runtime-http.ts index 0a7e338..3ad9a8e 100644 --- a/src/runtime/runtime-http.ts +++ b/src/runtime/runtime-http.ts @@ -2,6 +2,7 @@ // socket lifecycle; every route delegates to a daemon method, so this module is a // routing layer over the daemon's public surface. +import path from "path"; import { IncomingMessage, ServerResponse } from "http"; import type { RuntimeLifecycleEventRequest } from "./lifecycle-engine"; @@ -212,6 +213,8 @@ export const handleRuntimeDaemonRequest = async ( sendJson(response, 200, { ok: true, coreVersion: loadRuntimeConfig(paths).coreVersion, + // The release this daemon runs, so quickstart can spot a daemon left on an older install. + daemonScriptPath: path.join(__dirname, "daemon.js"), ownerId: owner.ownerId, ownerProcess: owner.ownerProcess, }); From e007b2af2a6a02d4a3da8071e976c7baba8ed26c Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 16:44:16 -0500 Subject: [PATCH 06/11] Give each hook event exactly one capture across plugin enablement scopes - Dedup checks exactly the settings files the host loads: for Claude Code the user settings and the session's CLAUDE_PROJECT_DIR (plus the git-root local file), with no ancestor walk; for Codex the hooks.json of the CODEX_HOME it ran with. Doctor uses the same lookup. - Legacy hooks are detected by counting Crosshead commands; empty arrays, empty groups and null groups never count and never throw. - Plugin mode, and removal of installer hooks, require the plugin enabled at user scope. Project-only enablement keeps settings mode; Codex project config counts only for trusted projects. Doctor explains both. - Codex keeps its installer hooks until a plugin invocation has been recorded, so untrusted plugin hooks never leave a session uncaptured; doctor points to /hooks trust when none has been seen. - The setup skill is user-invoked only, and SessionStart notices are user-visible systemMessages with no model context. - The launcher only locates the runtime home (empty variables unset, two-homes conflict reported); the runtime resolves --claude-home/--codex-home installs itself, and a capability marker in the hook wrappers identifies runtimes that support plugin dispatch. - Migration never deletes a settings file, keeps unknown Codex hooks.json keys, refuses symlinked files before writing anything, and the TOML reader handles comments and matches only crosshead@crosshead. - SessionStart also matches clear; plugin content is pinned per version. --- README.md | 15 +- docs/HOST-PLUGINS.md | 144 ++-- plugins/claude-code/hooks/hooks.json | 2 +- plugins/claude-code/scripts/crosshead-hook | 48 +- plugins/claude-code/skills/setup/SKILL.md | 12 +- plugins/codex/hooks/hooks.json | 2 +- plugins/codex/scripts/crosshead-hook | 48 +- plugins/codex/skills/setup/SKILL.md | 15 +- plugins/codex/skills/setup/agents/openai.yaml | 2 + scripts/host-plugins.mjs | 71 +- scripts/smoke-host-plugins.mjs | 10 +- src/runtime/adapter-utils.ts | 4 +- src/runtime/claude-code-adapter.ts | 151 +++-- src/runtime/claude-code-hook.ts | 30 +- src/runtime/cli/core.ts | 5 +- src/runtime/codex-adapter.ts | 133 ++-- src/runtime/codex-hook.ts | 32 +- src/runtime/host-hook-doctor.ts | 96 ++- src/runtime/host-hook-spec.ts | 28 +- src/runtime/host-plugin-mode.ts | 524 ++++++++++++--- src/runtime/runtime-env.ts | 10 +- src/runtime/runtime-status.ts | 4 +- tests/runtime/host-plugins.test.ts | 615 +++++++++++++++--- 23 files changed, 1548 insertions(+), 453 deletions(-) create mode 100644 plugins/codex/skills/setup/agents/openai.yaml diff --git a/README.md b/README.md index d31d5fa..e323d9f 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,14 @@ codex plugin marketplace add FriendMachine/crosshead codex plugin add crosshead@crosshead ``` -Then start a session and run `/crosshead:setup` in Claude Code, or ask Codex -to use the `crosshead:setup` skill. Until setup has run, each session starts -with one line saying Crosshead isn't set up; nothing else happens and the -session is never blocked. Setup asks for your consent, runs the installer -below, and connects the host with -`crosshead quickstart --adapter claude-code|codex --hook-mode plugin`. -Codex runs plugin hooks only after you trust them in `/hooks`. +Then start a session and run `/crosshead:setup` in Claude Code, or type +`$crosshead:setup` in Codex. Only you can start setup; the model does not run +it on its own. Until setup has run, each session starts with one line saying +Crosshead isn't set up; nothing else happens and the session is never blocked. +Setup asks for your consent, runs the installer below, and connects the host +with `crosshead quickstart --adapter claude-code|codex --hook-mode plugin`. +Codex runs plugin hooks only after you trust them in `/hooks`. The plugin +launcher needs a POSIX shell; Windows is not supported. The plugins are thin. They contain the lifecycle hooks, the setup skill and a small launcher that runs the installed runtime's hook entrypoints. They do not diff --git a/docs/HOST-PLUGINS.md b/docs/HOST-PLUGINS.md index 3c0107e..e981434 100644 --- a/docs/HOST-PLUGINS.md +++ b/docs/HOST-PLUGINS.md @@ -18,25 +18,40 @@ codex plugin add crosshead@crosshead | --- | --- | | `hooks/hooks.json` | `SessionStart`, `PreCompact`, `PostCompact`, `UserPromptSubmit` and `Stop` hooks | | `scripts/crosshead-hook` | POSIX `sh` launcher the hooks run | -| `skills/setup/SKILL.md` | `/crosshead:setup` (Claude Code) or `crosshead:setup` (Codex) | +| `skills/setup/SKILL.md` | `/crosshead:setup` (Claude Code) or `$crosshead:setup` (Codex), user-invoked only | +| `skills/setup/agents/openai.yaml` (Codex) | `allow_implicit_invocation: false` | | `.claude-plugin/plugin.json` or `.codex-plugin/plugin.json` | Manifest | -The launcher finds the runtime home (`CROSSHEAD_HOME`, then `~/.crosshead`) and -runs the installed `crosshead-claude-code-hook` or `crosshead-codex-hook` with -`--via plugin`. These are the hook entrypoints the installer-written hooks use. -The launcher never downloads, installs or updates code. Without a configured +The launcher locates the runtime home the way the installer does: the first +non-empty `CROSSHEAD_HOME`, `CROSSHEAD_RUNTIME_HOME`, `ONLYBOTS_HOME` or +`ONLYBOTS_RUNTIME_HOME`, else `~/.crosshead` (or `~/.onlybots` when only that +exists). If both `~/.crosshead` and `~/.onlybots` exist as separate +installations, it prints the runtime's "choose one with `CROSSHEAD_HOME`" +notice instead of picking one. It then runs the installed +`crosshead-claude-code-hook` or `crosshead-codex-hook` with `--via plugin`. +These are the hook entrypoints the installer-written hooks use. The launcher +does not read host configuration: the runtime finds the adapter configuration +itself (the host's `CLAUDE_CONFIG_DIR`/`CODEX_HOME`, else the directory recorded +at install time), so `--claude-home` and `--codex-home` installs work. The +launcher never downloads, installs or updates code. Without a configured runtime, it prints one line on `SessionStart`, prints nothing for other events, and exits 0: ```text -Crosshead isn't set up: run /crosshead:setup. -Crosshead isn't set up: ask Codex to use the crosshead setup skill ($crosshead:setup). +Crosshead isn't set up. To set it up, run /crosshead:setup. +Crosshead isn't set up. To set it up, type $crosshead:setup. ``` -The line is a `systemMessage` the user sees, and context the agent sees. If the -runtime predates plugin support, the line asks you to run setup to update it, -and the plugin stays inactive, so older installer-written hooks keep working -without duplicates. +The line is a `systemMessage` only: the user sees it, and it is not added to +the model's context, so nothing invites the model to run setup. The setup skill +is user-invoked only (`disable-model-invocation: true` in Claude Code; +`policy.allow_implicit_invocation: false` in Codex, where an explicit +`$crosshead:setup` still works). If the runtime's hook wrapper predates plugin +dispatch (it lacks the `crosshead-hook-capability` marker), the line asks you +to run setup to update it, and the plugin stays inactive, so older +installer-written hooks keep working without duplicates. + +The launcher is POSIX `sh`. Windows is not supported. The setup skill explains what it is about to do, asks for consent, runs the digest-checked installer, and then runs: @@ -48,7 +63,19 @@ crosshead quickstart --api-base https://crosshead.sh --adapter codex --workspace `--hook-mode plugin` writes the adapter configuration and wrappers but no host hooks. Codex additionally requires you to trust the plugin hooks in `/hooks`; -a new plugin version needs a new review. The plugins have no MCP server. +a new plugin version needs a new review. The plugins have no MCP server and +ask for no credentials. The Codex marketplace entry still carries +`policy.authentication: "ON_INSTALL"` because Codex requires both policy fields +on every entry and documents no other value. + +When `quickstart` finds a daemon already running, it compares the daemon's +release (its health response reports its entry script) with the installed one. +A daemon left on another release is restarted on the installed release through +the ownership-checked stop/start path. If that is not safe (a legacy daemon +without ownership records, or one hosting an organizational worker), quickstart +reports the mismatch instead. `--port` applies when quickstart starts or +restarts the daemon; if a current daemon already runs on another port, +quickstart reports that rather than ignoring the flag. ## One hook table @@ -61,7 +88,9 @@ files are both rendered from it. `scripts/host-plugins.mjs` generates generated output. Edit the spec or the generator, never the generated files, then run `npm run build && npm run plugins:generate`. Raise `CROSSHEAD_HOST_PLUGIN_VERSION` whenever generated content changes: hosts pin -installed plugins to their manifest version. +installed plugins to their manifest version. `host-plugins.test.ts` pins a +SHA-256 of the generated `plugins/` content per version and fails when the +content changes without a new version entry. Claude Code reads `.claude-plugin/marketplace.json`. Codex reads `.agents/plugins/marketplace.json` first and would otherwise fall back to the @@ -72,44 +101,74 @@ or the npm package. ## Hook modes, migration and doctor `crosshead install claude-code|codex` takes `--hook-mode auto|plugin|settings` -(default `auto`). `auto` selects `plugin` when the host enables -`crosshead@`: - -- Claude Code: `enabledPlugins` in `.claude/settings.local.json`, - `.claude/settings.json` (workspace) or `~/.claude/settings.json`, in that - order. -- Codex: `[plugins."crosshead@..."]` in `.codex/config.toml` or - `$CODEX_HOME/config.toml`. +(default `auto`, or the explicit mode of the previous install, so +`crosshead update` repeats it). Plugin mode is chosen only when the plugin is +enabled at user scope, because the mode is global and the hooks it removes can +cover every project: + +- Claude Code: `"enabledPlugins": {"crosshead@crosshead": true}` in the user + settings (`settings.json` in the Claude config directory). +- Codex: `[plugins."crosshead@crosshead"]` (enabled unless `enabled = false`) + in `$CODEX_HOME/config.toml`. + +Only this repository's marketplace id, `crosshead@crosshead`, is recognized. +When the plugin is enabled only for a project (Claude Code project or local +settings, or a Codex project `.codex/config.toml`, which Codex applies only to +trusted projects), the mode stays `settings` and the installer hooks stay in +place; in that project the plugin defers to them. `--hook-mode plugin` in that +case writes no new installer hooks and removes none. The mode is stored in the adapter configuration. In plugin mode the installer removes the hooks it previously wrote (in the current workspace and the previously configured one for Claude Code, and in `$CODEX_HOME/hooks.json` for -Codex). It keeps unrelated hooks and settings. It removes a file that held only -Crosshead hooks. Running it again changes nothing. - -The hook entrypoint makes sure each event is handled once even before -migration: - -| Recorded mode | Invocation | Result | -| --- | --- | --- | -| `plugin` | plugin | runs | -| `plugin` | installer-written hook | skipped | -| `settings` | installer-written hook | runs | -| `settings` | plugin, installer hooks cover this workspace | skipped | -| `settings` | plugin, no installer hooks here | runs | - -`crosshead doctor` adds `claude_code_hooks` and `codex_hooks` checks. Each check -reports the mode, whether the plugin is enabled, and any remaining -installer-written hooks. The check fails for duplicate registrations, for plugin -mode with the plugin disabled or uninstalled, and for settings mode with no -hooks for the workspace. Each failure names the command that fixes it. Use -`--workspace-root` to inspect a different project. +Codex). It keeps unrelated hooks, settings and top-level keys, and never deletes +a file: one left with nothing becomes `{}` (`{"hooks": {}}` for Codex). It +checks every file it would change before writing any, and refuses a symlinked +settings file without changing anything. Running it again changes nothing. + +Codex runs plugin hooks only after the user trusts them in `/hooks`, again for +each changed version. So the Codex installer keeps its old hooks until the +runtime has seen a plugin invocation (recorded in +`state/host-plugin-invocations.json`); rerun `crosshead install codex` after the +first session with trusted hooks to remove them. + +The hook entrypoint makes sure each event is handled once. "Installer hooks +loaded" means installer-written Crosshead hook commands (counted one by one; +empty arrays or groups never count) in exactly the files the host loads for the +session: + +- Claude Code: the user settings, and `.claude/settings.json` and + `.claude/settings.local.json` in the session's project directory + (`CLAUDE_PROJECT_DIR`), plus `.claude/settings.local.json` at the git root. + Ancestor directories are not checked, because Claude Code does not read them. +- Codex: `hooks.json` in the `CODEX_HOME` Codex ran with. + +| Invocation | Installer hooks loaded | Recorded mode | Result | +| --- | --- | --- | --- | +| installer-written hook | yes | either | runs | +| plugin | yes | either | skipped | +| plugin | no | either | runs | +| installer-written hook | no (stale session) | `plugin` | skipped | +| installer-written hook | no | `settings` | runs | + +`crosshead doctor` adds `claude_code_hooks` and `codex_hooks` checks for the +workspace (`--workspace-root`, else `CLAUDE_PROJECT_DIR`, else the current +directory). Each check reports the mode, where the plugin is enabled, and any +remaining installer-written hooks. The check fails for duplicate registrations, +for plugin mode with the plugin disabled or uninstalled, for settings mode with +no hooks for the workspace, and, for Codex, when the plugin is enabled but no +plugin invocation has been seen (it points to `/hooks` trust). It explains +project-only enablement instead of failing. Each failure names the fix. Uninstalling the plugin removes its hooks. Doctor then reports that no Crosshead hooks are active. Reinstall the plugin, or run `crosshead install --hook-mode settings` to go back to installer-written hooks. +`SessionStart` matches `startup|resume|clear|compact` in both modes: `/clear` +starts a fresh context like `startup`. Installer-written hooks from earlier +versions keep their old matcher until the next install. + ## Verification ```sh @@ -138,6 +197,7 @@ hooks. The check covers: - No hooks left after the plugins are uninstalled. It does not cover model-driven skill invocation or the Codex trust dialog (runs -use `--dangerously-bypass-hook-trust`). It also does not cover identity +use `--dangerously-bypass-hook-trust`); the unit tests cover the untrusted +window with direct hook invocations. It also does not cover identity registration with the hosted service or native compaction: the compaction hooks are driven with representative payloads. diff --git a/plugins/claude-code/hooks/hooks.json b/plugins/claude-code/hooks/hooks.json index 348aa43..bd679c8 100644 --- a/plugins/claude-code/hooks/hooks.json +++ b/plugins/claude-code/hooks/hooks.json @@ -3,7 +3,7 @@ "hooks": { "SessionStart": [ { - "matcher": "startup|resume|compact", + "matcher": "startup|resume|clear|compact", "hooks": [ { "type": "command", diff --git a/plugins/claude-code/scripts/crosshead-hook b/plugins/claude-code/scripts/crosshead-hook index a1e8c58..390dad6 100755 --- a/plugins/claude-code/scripts/crosshead-hook +++ b/plugins/claude-code/scripts/crosshead-hook @@ -4,39 +4,47 @@ # # Runs the hook entrypoint of the Crosshead runtime installed by # `curl -fsSL https://crosshead.sh/install | bash`. It never downloads or -# installs code. Without a configured runtime it prints a setup notice on -# SessionStart and exits 0 so the session is never blocked. +# installs code. It only locates the runtime home (as the installer does); the +# runtime finds its own host configuration, including --claude-home and +# --codex-home installs. Without a usable runtime it prints a notice on +# SessionStart and exits 0 so the session is never blocked. POSIX sh only; +# Windows is not supported. host="$1" event="$2" case "$host" in claude-code) hook=crosshead-claude-code-hook - config="${CLAUDE_CONFIG_DIR:-${CLAUDE_CODE_HOME:-$HOME/.claude}}/onlybots/claude-code-adapter.json" - setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up: run /crosshead:setup.","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Crosshead isn'"'"'t set up: run /crosshead:setup."}}' - update_notice='{"systemMessage":"The installed Crosshead runtime predates the Claude Code plugin: run /crosshead:setup to update it.","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"The installed Crosshead runtime predates the Claude Code plugin: run /crosshead:setup to update it."}}' + setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up. To set it up, run /crosshead:setup."}' + update_notice='{"systemMessage":"The installed Crosshead runtime predates this plugin. To update it, run /crosshead:setup."}' ;; codex) hook=crosshead-codex-hook - config="${CODEX_HOME:-$HOME/.codex}/onlybots/codex-adapter.json" - setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up: ask Codex to use the crosshead setup skill ($crosshead:setup).","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Crosshead isn'"'"'t set up: ask Codex to use the crosshead setup skill ($crosshead:setup)."}}' - update_notice='{"systemMessage":"The installed Crosshead runtime predates the Codex plugin: ask Codex to use the crosshead setup skill ($crosshead:setup) to update it.","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"The installed Crosshead runtime predates the Codex plugin: ask Codex to use the crosshead setup skill ($crosshead:setup) to update it."}}' + setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up. To set it up, type $crosshead:setup."}' + update_notice='{"systemMessage":"The installed Crosshead runtime predates this plugin. To update it, type $crosshead:setup."}' ;; *) exit 0 ;; esac +conflict_notice='{"systemMessage":"Crosshead found separate ~/.crosshead and ~/.onlybots installations. Set CROSSHEAD_HOME to choose one; no state was moved."}' +notice() { + cat > /dev/null 2>&1 || true + if [ "$event" = SessionStart ]; then printf '%s\n' "$1"; fi + exit 0 +} +# Empty variables count as unset, as in the installer and the runtime. home="${CROSSHEAD_HOME:-${CROSSHEAD_RUNTIME_HOME:-${ONLYBOTS_HOME:-${ONLYBOTS_RUNTIME_HOME:-}}}}" if [ -z "$home" ]; then - if [ ! -e "$HOME/.crosshead" ] && [ -d "$HOME/.onlybots" ]; then home="$HOME/.onlybots"; else home="$HOME/.crosshead"; fi + if [ -e "$HOME/.crosshead" ] && [ -e "$HOME/.onlybots" ] && ! [ "$HOME/.crosshead" -ef "$HOME/.onlybots" ]; then + notice "$conflict_notice" + fi + if [ ! -e "$HOME/.crosshead" ] && [ -e "$HOME/.onlybots" ]; then home="$HOME/.onlybots"; else home="$HOME/.crosshead"; fi fi wrapper="$home/bin/$hook" -if [ -x "$wrapper" ] && [ -f "$config" ] && grep -q '"hookMode"' "$config" 2>/dev/null; then - exec "$wrapper" "$event" --home "$home" --via plugin -fi -cat > /dev/null 2>&1 || true -if [ "$event" = SessionStart ]; then - if [ -x "$wrapper" ] && [ -f "$config" ]; then - printf '%s\n' "$update_notice" - else - printf '%s\n' "$setup_notice" - fi +[ -x "$wrapper" ] || notice "$setup_notice" +# A runtime that predates plugin dispatch would treat this call as an installer hook. +grep -q 'crosshead-hook-capability: plugin-dispatch-v1' "$wrapper" 2> /dev/null || notice "$update_notice" +# The Codex hook wrapper sets CODEX_HOME to the configured home; keep the one Codex ran with. +if [ "$host" = codex ]; then + CROSSHEAD_HOST_CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" + export CROSSHEAD_HOST_CODEX_HOME fi -exit 0 +exec "$wrapper" "$event" --home "$home" --via plugin --plugin-version 1.0.0 diff --git a/plugins/claude-code/skills/setup/SKILL.md b/plugins/claude-code/skills/setup/SKILL.md index 1b60c28..c2c4a48 100644 --- a/plugins/claude-code/skills/setup/SKILL.md +++ b/plugins/claude-code/skills/setup/SKILL.md @@ -1,6 +1,7 @@ --- name: setup -description: Install or update the Crosshead runtime and connect it to Claude Code. Use when a Crosshead notice says it isn't set up or predates the plugin, or when the user asks to set up Crosshead memory. +description: Install or update the Crosshead runtime and connect it to Claude Code. Run only when the user invokes it. +disable-model-invocation: true --- # Set up Crosshead for Claude Code @@ -42,10 +43,11 @@ for `crosshead` if it is not on PATH. ``` `--hook-mode plugin` keeps quickstart from writing its own Claude Code hooks - (this plugin provides them) and removes Crosshead hooks an earlier install - wrote, so no event is captured twice. -5. Verify with `crosshead doctor`. Expect `ok claude_code_hooks: mode=plugin`. - Report any failed check to the user verbatim; do not work around it. + (this plugin provides them). When the plugin is enabled for the user (the + default for a plugin install) it also removes Crosshead hooks an earlier + install wrote. Either way each event is captured once. +5. Verify with `crosshead doctor`. Expect `claude_code_hooks` to report `mode=plugin`. + Report any other failed check to the user verbatim; do not work around it. 6. Start a new Claude Code session (or run /reload-plugins) so the hooks load with the runtime in place. Other hosts and scripts keep using `curl -fsSL https://crosshead.sh/install | bash` diff --git a/plugins/codex/hooks/hooks.json b/plugins/codex/hooks/hooks.json index 916dd28..4f3c519 100644 --- a/plugins/codex/hooks/hooks.json +++ b/plugins/codex/hooks/hooks.json @@ -3,7 +3,7 @@ "hooks": { "SessionStart": [ { - "matcher": "startup|resume|compact", + "matcher": "startup|resume|clear|compact", "hooks": [ { "type": "command", diff --git a/plugins/codex/scripts/crosshead-hook b/plugins/codex/scripts/crosshead-hook index a1e8c58..390dad6 100755 --- a/plugins/codex/scripts/crosshead-hook +++ b/plugins/codex/scripts/crosshead-hook @@ -4,39 +4,47 @@ # # Runs the hook entrypoint of the Crosshead runtime installed by # `curl -fsSL https://crosshead.sh/install | bash`. It never downloads or -# installs code. Without a configured runtime it prints a setup notice on -# SessionStart and exits 0 so the session is never blocked. +# installs code. It only locates the runtime home (as the installer does); the +# runtime finds its own host configuration, including --claude-home and +# --codex-home installs. Without a usable runtime it prints a notice on +# SessionStart and exits 0 so the session is never blocked. POSIX sh only; +# Windows is not supported. host="$1" event="$2" case "$host" in claude-code) hook=crosshead-claude-code-hook - config="${CLAUDE_CONFIG_DIR:-${CLAUDE_CODE_HOME:-$HOME/.claude}}/onlybots/claude-code-adapter.json" - setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up: run /crosshead:setup.","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Crosshead isn'"'"'t set up: run /crosshead:setup."}}' - update_notice='{"systemMessage":"The installed Crosshead runtime predates the Claude Code plugin: run /crosshead:setup to update it.","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"The installed Crosshead runtime predates the Claude Code plugin: run /crosshead:setup to update it."}}' + setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up. To set it up, run /crosshead:setup."}' + update_notice='{"systemMessage":"The installed Crosshead runtime predates this plugin. To update it, run /crosshead:setup."}' ;; codex) hook=crosshead-codex-hook - config="${CODEX_HOME:-$HOME/.codex}/onlybots/codex-adapter.json" - setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up: ask Codex to use the crosshead setup skill ($crosshead:setup).","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Crosshead isn'"'"'t set up: ask Codex to use the crosshead setup skill ($crosshead:setup)."}}' - update_notice='{"systemMessage":"The installed Crosshead runtime predates the Codex plugin: ask Codex to use the crosshead setup skill ($crosshead:setup) to update it.","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"The installed Crosshead runtime predates the Codex plugin: ask Codex to use the crosshead setup skill ($crosshead:setup) to update it."}}' + setup_notice='{"systemMessage":"Crosshead isn'"'"'t set up. To set it up, type $crosshead:setup."}' + update_notice='{"systemMessage":"The installed Crosshead runtime predates this plugin. To update it, type $crosshead:setup."}' ;; *) exit 0 ;; esac +conflict_notice='{"systemMessage":"Crosshead found separate ~/.crosshead and ~/.onlybots installations. Set CROSSHEAD_HOME to choose one; no state was moved."}' +notice() { + cat > /dev/null 2>&1 || true + if [ "$event" = SessionStart ]; then printf '%s\n' "$1"; fi + exit 0 +} +# Empty variables count as unset, as in the installer and the runtime. home="${CROSSHEAD_HOME:-${CROSSHEAD_RUNTIME_HOME:-${ONLYBOTS_HOME:-${ONLYBOTS_RUNTIME_HOME:-}}}}" if [ -z "$home" ]; then - if [ ! -e "$HOME/.crosshead" ] && [ -d "$HOME/.onlybots" ]; then home="$HOME/.onlybots"; else home="$HOME/.crosshead"; fi + if [ -e "$HOME/.crosshead" ] && [ -e "$HOME/.onlybots" ] && ! [ "$HOME/.crosshead" -ef "$HOME/.onlybots" ]; then + notice "$conflict_notice" + fi + if [ ! -e "$HOME/.crosshead" ] && [ -e "$HOME/.onlybots" ]; then home="$HOME/.onlybots"; else home="$HOME/.crosshead"; fi fi wrapper="$home/bin/$hook" -if [ -x "$wrapper" ] && [ -f "$config" ] && grep -q '"hookMode"' "$config" 2>/dev/null; then - exec "$wrapper" "$event" --home "$home" --via plugin -fi -cat > /dev/null 2>&1 || true -if [ "$event" = SessionStart ]; then - if [ -x "$wrapper" ] && [ -f "$config" ]; then - printf '%s\n' "$update_notice" - else - printf '%s\n' "$setup_notice" - fi +[ -x "$wrapper" ] || notice "$setup_notice" +# A runtime that predates plugin dispatch would treat this call as an installer hook. +grep -q 'crosshead-hook-capability: plugin-dispatch-v1' "$wrapper" 2> /dev/null || notice "$update_notice" +# The Codex hook wrapper sets CODEX_HOME to the configured home; keep the one Codex ran with. +if [ "$host" = codex ]; then + CROSSHEAD_HOST_CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" + export CROSSHEAD_HOST_CODEX_HOME fi -exit 0 +exec "$wrapper" "$event" --home "$home" --via plugin --plugin-version 1.0.0 diff --git a/plugins/codex/skills/setup/SKILL.md b/plugins/codex/skills/setup/SKILL.md index 54c52ec..7be2a33 100644 --- a/plugins/codex/skills/setup/SKILL.md +++ b/plugins/codex/skills/setup/SKILL.md @@ -1,6 +1,6 @@ --- name: setup -description: Install or update the Crosshead runtime and connect it to Codex. Use when a Crosshead notice says it isn't set up or predates the plugin, or when the user asks to set up Crosshead memory. +description: Install or update the Crosshead runtime and connect it to Codex. Run only when the user invokes it. --- # Set up Crosshead for Codex @@ -42,11 +42,14 @@ for `crosshead` if it is not on PATH. ``` `--hook-mode plugin` keeps quickstart from writing its own Codex hooks - (this plugin provides them) and removes Crosshead hooks an earlier install - wrote, so no event is captured twice. -5. Verify with `crosshead doctor`. Expect `ok codex_hooks: mode=plugin`. - Report any failed check to the user verbatim; do not work around it. -6. Codex runs plugin hooks only after you trust them. Ask the user to open `/hooks`, review the Crosshead hooks and trust them. A changed plugin version needs a new review. + (this plugin provides them). When the plugin is enabled for the user (the + default for a plugin install) it also removes Crosshead hooks an earlier + install wrote. Either way each event is captured once. +5. Verify with `crosshead doctor`. Expect `codex_hooks` to report `mode=plugin`. + Until Codex has run the plugin hooks, that check warns that no plugin hook + invocation has been seen; the next step resolves it. + Report any other failed check to the user verbatim; do not work around it. +6. Codex runs plugin hooks only after you trust them. Ask the user to open `/hooks`, review the Crosshead hooks and trust them. A changed plugin version needs a new review. Until Codex has run them, quickstart keeps any Crosshead hooks an earlier install wrote; rerun `crosshead install codex` after the first session to remove those. 7. Start a new Codex session so the hooks load with the runtime in place. Other hosts and scripts keep using `curl -fsSL https://crosshead.sh/install | bash` diff --git a/plugins/codex/skills/setup/agents/openai.yaml b/plugins/codex/skills/setup/agents/openai.yaml new file mode 100644 index 0000000..5b1f887 --- /dev/null +++ b/plugins/codex/skills/setup/agents/openai.yaml @@ -0,0 +1,2 @@ +policy: + allow_implicit_invocation: false diff --git a/scripts/host-plugins.mjs b/scripts/host-plugins.mjs index 26a70a3..883070b 100644 --- a/scripts/host-plugins.mjs +++ b/scripts/host-plugins.mjs @@ -29,7 +29,6 @@ const HOSTS = { "claude-code": { dir: "plugins/claude-code", label: "Claude Code", - configFile: '${CLAUDE_CONFIG_DIR:-${CLAUDE_CODE_HOME:-$HOME/.claude}}/onlybots/claude-code-adapter.json', hookWrapper: "crosshead-claude-code-hook", doctorCheck: "claude_code_hooks", activate: "Start a new Claude Code session (or run /reload-plugins) so the hooks load with the runtime in place.", @@ -38,26 +37,24 @@ const HOSTS = { codex: { dir: "plugins/codex", label: "Codex", - configFile: '${CODEX_HOME:-$HOME/.codex}/onlybots/codex-adapter.json', hookWrapper: "crosshead-codex-hook", doctorCheck: "codex_hooks", activate: "Start a new Codex session so the hooks load with the runtime in place.", trust: - "Codex runs plugin hooks only after you trust them. Ask the user to open `/hooks`, review the Crosshead hooks and trust them. A changed plugin version needs a new review.", + "Codex runs plugin hooks only after you trust them. Ask the user to open `/hooks`, review the Crosshead hooks and trust them. A changed plugin version needs a new review. Until Codex has run them, quickstart keeps any Crosshead hooks an earlier install wrote; rerun `crosshead install codex` after the first session to remove those.", }, }; const shQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`; -const noticeJson = (message) => - JSON.stringify({ systemMessage: message, hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: message } }); +// User-visible only: no additionalContext, so the model is not invited to run setup. +const noticeJson = (message) => JSON.stringify({ systemMessage: message }); const renderLauncher = (spec) => { const cases = Object.entries(HOSTS) .map( ([host, info]) => ` ${host}) hook=${info.hookWrapper} - config="${info.configFile}" setup_notice=${shQuote(noticeJson(spec.pluginSetupHint(host)))} update_notice=${shQuote(noticeJson(spec.pluginUpdateHint(host)))} ;;` @@ -69,31 +66,41 @@ const renderLauncher = (spec) => { # # Runs the hook entrypoint of the Crosshead runtime installed by # \`curl -fsSL https://crosshead.sh/install | bash\`. It never downloads or -# installs code. Without a configured runtime it prints a setup notice on -# SessionStart and exits 0 so the session is never blocked. +# installs code. It only locates the runtime home (as the installer does); the +# runtime finds its own host configuration, including --claude-home and +# --codex-home installs. Without a usable runtime it prints a notice on +# SessionStart and exits 0 so the session is never blocked. POSIX sh only; +# Windows is not supported. host="$1" event="$2" case "$host" in ${cases} *) exit 0 ;; esac +conflict_notice=${shQuote(noticeJson(spec.PLUGIN_HOME_CONFLICT_HINT))} +notice() { + cat > /dev/null 2>&1 || true + if [ "$event" = SessionStart ]; then printf '%s\\n' "$1"; fi + exit 0 +} +# Empty variables count as unset, as in the installer and the runtime. home="\${CROSSHEAD_HOME:-\${CROSSHEAD_RUNTIME_HOME:-\${ONLYBOTS_HOME:-\${ONLYBOTS_RUNTIME_HOME:-}}}}" if [ -z "$home" ]; then - if [ ! -e "$HOME/.crosshead" ] && [ -d "$HOME/.onlybots" ]; then home="$HOME/.onlybots"; else home="$HOME/.crosshead"; fi + if [ -e "$HOME/.crosshead" ] && [ -e "$HOME/.onlybots" ] && ! [ "$HOME/.crosshead" -ef "$HOME/.onlybots" ]; then + notice "$conflict_notice" + fi + if [ ! -e "$HOME/.crosshead" ] && [ -e "$HOME/.onlybots" ]; then home="$HOME/.onlybots"; else home="$HOME/.crosshead"; fi fi wrapper="$home/bin/$hook" -if [ -x "$wrapper" ] && [ -f "$config" ] && grep -q '"hookMode"' "$config" 2>/dev/null; then - exec "$wrapper" "$event" --home "$home" ${spec.PLUGIN_HOOK_VIA_FLAG.join(" ")} +[ -x "$wrapper" ] || notice "$setup_notice" +# A runtime that predates plugin dispatch would treat this call as an installer hook. +grep -q '${spec.PLUGIN_DISPATCH_CAPABILITY}' "$wrapper" 2> /dev/null || notice "$update_notice" +# The Codex hook wrapper sets CODEX_HOME to the configured home; keep the one Codex ran with. +if [ "$host" = codex ]; then + CROSSHEAD_HOST_CODEX_HOME="\${CODEX_HOME:-$HOME/.codex}" + export CROSSHEAD_HOST_CODEX_HOME fi -cat > /dev/null 2>&1 || true -if [ "$event" = SessionStart ]; then - if [ -x "$wrapper" ] && [ -f "$config" ]; then - printf '%s\\n' "$update_notice" - else - printf '%s\\n' "$setup_notice" - fi -fi -exit 0 +exec "$wrapper" "$event" --home "$home" ${spec.PLUGIN_HOOK_VIA_FLAG.join(" ")} ${spec.PLUGIN_VERSION_FLAG} ${spec.CROSSHEAD_HOST_PLUGIN_VERSION} `; }; @@ -101,8 +108,8 @@ const renderSetupSkill = (host) => { const info = HOSTS[host]; return `--- name: setup -description: Install or update the Crosshead runtime and connect it to ${info.label}. Use when a Crosshead notice says it isn't set up or predates the plugin, or when the user asks to set up Crosshead memory. ---- +description: Install or update the Crosshead runtime and connect it to ${info.label}. Run only when the user invokes it. +${host === "claude-code" ? "disable-model-invocation: true\n" : ""}--- # Set up Crosshead for ${info.label} @@ -143,10 +150,17 @@ for \`crosshead\` if it is not on PATH. \`\`\` \`--hook-mode plugin\` keeps quickstart from writing its own ${info.label} hooks - (this plugin provides them) and removes Crosshead hooks an earlier install - wrote, so no event is captured twice. -5. Verify with \`crosshead doctor\`. Expect \`ok ${info.doctorCheck}: mode=plugin\`. - Report any failed check to the user verbatim; do not work around it. + (this plugin provides them). When the plugin is enabled for the user (the + default for a plugin install) it also removes Crosshead hooks an earlier + install wrote. Either way each event is captured once. +5. Verify with \`crosshead doctor\`. Expect \`${info.doctorCheck}\` to report \`mode=plugin\`.${ + info.trust + ? ` + Until Codex has run the plugin hooks, that check warns that no plugin hook + invocation has been seen; the next step resolves it.` + : "" + } + Report any other failed check to the user verbatim; do not work around it. ${info.trust ? `6. ${info.trust}\n7. ${info.activate}` : `6. ${info.activate}`} Other hosts and scripts keep using \`curl -fsSL https://crosshead.sh/install | bash\` @@ -174,6 +188,8 @@ export const renderHostPluginFiles = (spec) => { { name, source: { source: "local", path: `./${HOSTS.codex.dir}` }, + // Codex requires both policy fields on every entry; ON_INSTALL is the only + // documented authentication value. The plugin itself asks for no credentials. policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, category: "Productivity", }, @@ -217,6 +233,9 @@ export const renderHostPluginFiles = (spec) => { files[`${dir}/${spec.PLUGIN_HOOK_LAUNCHER}`] = launcher; files[`${dir}/skills/setup/SKILL.md`] = renderSetupSkill(host); } + // Codex has no SKILL.md field for this; agents/openai.yaml keeps the setup + // skill out of implicit (model-chosen) invocation. `$crosshead:setup` still works. + files[`${HOSTS.codex.dir}/skills/setup/agents/openai.yaml`] = "policy:\n allow_implicit_invocation: false\n"; return files; }; diff --git a/scripts/smoke-host-plugins.mjs b/scripts/smoke-host-plugins.mjs index 0bf9bf3..1bf645a 100644 --- a/scripts/smoke-host-plugins.mjs +++ b/scripts/smoke-host-plugins.mjs @@ -285,12 +285,16 @@ try { const claudeFresh = await claudeSession("no-runtime", a.home, a.project); assert.equal(crossheadHookLines(claudeFresh).length, 3, claudeFresh.hookRuns.join("\n")); assert(claudeFresh.hookRuns.every((line) => line.includes("status 0"))); - assert(requests.some((request) => request.url.startsWith("/v1/messages") && request.body.includes(spec.pluginSetupHint("claude-code"))), "Claude setup hint did not reach model context"); + // The notice is for the user only (systemMessage): it must not reach the model. + const inModel = (prefix, text) => requests.some((request) => request.url.startsWith(prefix) && request.body.includes(text)); + assert(!inModel("/v1/messages", "isn't set up. To set it up"), "Claude setup hint reached model context"); const codexFresh = await codexSession("no-runtime", a.home, a.project); - assert(requests.some((request) => request.url.startsWith("/v1/responses") && request.body.includes(JSON.stringify(spec.pluginSetupHint("codex")).slice(1, -1))), "Codex setup hint did not reach model context"); + assert(!inModel("/v1/responses", "isn't set up. To set it up"), "Codex setup hint reached model context"); + // Recorded, not asserted: whether either host lists the user-only setup skill to the model. + results.setupSkillListedToModel = { claude: inModel("/v1/messages", "crosshead:setup"), codex: inModel("/v1/responses", "crosshead:setup") }; assert.deepEqual(codexFresh.hookRuns.map((line) => line.trim()), ["hook: SessionStart Completed", "hook: UserPromptSubmit Completed", "hook: Stop Completed"]); assert(!existsSync(path.join(a.home, ".crosshead")), "a plugin hook created runtime state"); - check("without a runtime, SessionStart prints one setup line, every hook exits 0, real sessions complete and nothing is installed"); + check("without a runtime, SessionStart prints one user-only setup line (not in model context), every hook exits 0, real sessions complete and nothing is installed"); // The setup skill's commands, after consent: digest-checked installer, then quickstart in plugin mode. await installRuntime(a.home); diff --git a/src/runtime/adapter-utils.ts b/src/runtime/adapter-utils.ts index 4a311bb..63f4a21 100644 --- a/src/runtime/adapter-utils.ts +++ b/src/runtime/adapter-utils.ts @@ -181,8 +181,8 @@ export type WrapperEnvExports = { runtimeHome: string; }; -export const buildWrapperScript = (scriptPath: string, env?: WrapperEnvExports): string => `#!/usr/bin/env bash -set -euo pipefail +export const buildWrapperScript = (scriptPath: string, env?: WrapperEnvExports, marker?: string): string => `#!/usr/bin/env bash +${marker ? `# ${marker}\n` : ""}set -euo pipefail unset NODE_OPTIONS NODE_PATH ${ env diff --git a/src/runtime/claude-code-adapter.ts b/src/runtime/claude-code-adapter.ts index 8821c30..1234ef3 100644 --- a/src/runtime/claude-code-adapter.ts +++ b/src/runtime/claude-code-adapter.ts @@ -59,14 +59,18 @@ import { type HostAdapterTurnPrepareResult, } from "./host-adapter-base"; import { resolveClaudeCodeBinaryPath, resolveClaudeCodeHostHome } from "./runtime-env"; -import { renderHostHookGroups, type HostHookEvent } from "./host-hook-spec"; +import { PLUGIN_DISPATCH_CAPABILITY, renderHostHookGroups, type HostHookEvent } from "./host-hook-spec"; import { - claudeProjectSettingsCandidates, + claudeLoadedSettingsFiles, + countMatchingHookCommands, describeHookMode, detectClaudeCodePlugin, - parseHookModeFlag, + normalizeHookModeRequest, normalizeHostHookMode, + parseHookModeFlag, + readSettingsForInspection, resolveHostHookMode, + type HookModeRequest, type HostHookMode, type PluginDetection, } from "./host-plugin-mode"; @@ -94,6 +98,8 @@ export type ClaudeCodeAdapterConfig = { claudeBinary: string; /** Absent in configs written before host plugins; treated as "settings". */ hookMode?: HostHookMode; + /** Explicit `--hook-mode` of the last install; reinstalls without the flag repeat it. */ + hookModeRequest?: HostHookMode | null; installedAt: string; updatedAt: string; }; @@ -120,8 +126,8 @@ export type ClaudeCodeAdapterInstallOptions = { bridgeScriptPath: string; hookScriptPath: string; claudeBinary?: string; - /** "auto" (default) selects "plugin" when the Crosshead Claude Code plugin is enabled. */ - hookMode?: HostHookMode | "auto"; + /** "auto" (default) selects "plugin" when the Crosshead Claude Code plugin is enabled at user scope. */ + hookMode?: HookModeRequest; }; export type ClaudeCodeAdapterInstallResult = { @@ -131,6 +137,8 @@ export type ClaudeCodeAdapterInstallResult = { pluginDetection: PluginDetection | null; /** Settings files whose legacy Crosshead hooks were removed because the plugin supplies them. */ migratedFiles: string[]; + /** Explanation when plugin hooks were requested but plugin mode could not be used. */ + hookModeNote: string | null; }; export type ClaudeCodeAdapterUninstallOptions = { @@ -291,8 +299,8 @@ const projectSettingsFile = (workspaceRoot: string): string => path.join(workspa const isManagedContent = (filePath: string): boolean => existsSync(filePath) && readFileSync(filePath, "utf8").includes(CLAUDE_CODE_ADAPTER_MARKER); -const writeManagedExecutable = (filePath: string, scriptPath: string, runtimeHome: string): void => { - writeManagedFile(filePath, buildWrapperScript(scriptPath), runtimeHome); +const writeManagedExecutable = (filePath: string, scriptPath: string, runtimeHome: string, marker?: string): void => { + writeManagedFile(filePath, buildWrapperScript(scriptPath, undefined, marker), runtimeHome); chmodSync(filePath, 0o755); }; @@ -326,6 +334,7 @@ const buildClaudeCodeAdapterConfig = (options: { adapterVersion: string; claudeBinary: string; hookMode: HostHookMode; + hookModeRequest: HostHookMode | null; }): ClaudeCodeAdapterConfig => ({ marker: CLAUDE_CODE_MANAGED_CONFIG_MARKER, adapterName: "claude-code", @@ -336,6 +345,7 @@ const buildClaudeCodeAdapterConfig = (options: { profileName: options.profileName, claudeBinary: options.claudeBinary, hookMode: options.hookMode, + hookModeRequest: options.hookModeRequest, installedAt: nowIso(), updatedAt: nowIso(), }); @@ -356,6 +366,11 @@ const buildHookCommand = ( const CLAUDE_HOOK_MARKER = "crosshead-claude-code-hook"; +/** True for a hook command an installer-written (settings-mode) Crosshead registration uses. */ +const isLegacyClaudeHookCommand = (command: string | undefined, hookWrapperPath: string): boolean => + isManagedHookCommand(command, hookWrapperPath, CLAUDE_HOOK_MARKER) || + isManagedHookCommand(command, path.join(path.dirname(hookWrapperPath), "onlybots-claude-code-hook"), "onlybots-claude-code-hook"); + const readClaudeSettingsConfig = (filePath: string): ClaudeSettingsConfig => { if (!existsSync(filePath)) { return {}; @@ -378,11 +393,13 @@ const filterManagedClaudeHooks = (existing: ClaudeSettingsConfig, hookWrapperPat const filteredHooks: ClaudeHooksConfig = {}; for (const [eventName, matcherGroups] of Object.entries(hooks)) { - const remainingGroups = (matcherGroups ?? []) + const remainingGroups = (Array.isArray(matcherGroups) ? matcherGroups : []) .filter((group): group is ClaudeHookMatcherGroup => !!group && typeof group === "object" && !Array.isArray(group)) .map((group) => ({ ...group, - hooks: (group.hooks ?? []).filter((hook) => !isManagedHookCommand(hook.command, hookWrapperPath, CLAUDE_HOOK_MARKER) && !isManagedHookCommand(hook.command, path.join(path.dirname(hookWrapperPath), "onlybots-claude-code-hook"), "onlybots-claude-code-hook")), + hooks: (Array.isArray(group.hooks) ? group.hooks : []).filter( + (hook) => !(hook && typeof hook === "object" && isLegacyClaudeHookCommand(hook.command, hookWrapperPath)) + ), })) .filter((group) => group.hooks.length > 0); if (remainingGroups.length > 0) { @@ -572,31 +589,33 @@ export const claudeCodeContinuityTranslationContract: ContinuityTranslationContr reportFidelity: (translation) => translation.fidelity, }; -const removeLegacyClaudeHooks = (filePath: string, workspaceRoot: string, runtimeHome: string): boolean => { - if (!existsSync(filePath)) return false; - const existing = readClaudeSettingsConfig(filePath); - const filtered = filterManagedClaudeHooks(existing, path.join(runtimeHome, "bin", "crosshead-claude-code-hook")); - if (JSON.stringify(filtered) === JSON.stringify(existing)) return false; - if (Object.keys(filtered).length > 0) { - writeManagedFile(filePath, `${JSON.stringify(filtered, null, 2)}\n`, workspaceRoot); - } else { - rmSync(filePath, { force: true }); - } +const legacyHookWrapper = (runtimeHome: string): string => path.join(runtimeHome, "bin", "crosshead-claude-code-hook"); + +/** Number of installer-written Crosshead hook handlers in a Claude Code settings file. */ +export const countLegacyClaudeHookCommands = (filePath: string, runtimeHome: string): number => + countMatchingHookCommands(readSettingsForInspection(filePath), (command) => isLegacyClaudeHookCommand(command, legacyHookWrapper(runtimeHome))); + +/** + * Settings files Claude Code loads for a session in `projectDir` (user settings in + * `userConfigDir`) that still register installer-written Crosshead hooks. + */ +export const findLegacyClaudeHookFiles = (userConfigDir: string, projectDir: string, runtimeHome: string): string[] => + claudeLoadedSettingsFiles(userConfigDir, projectDir) + .map((source) => source.file) + .filter((filePath) => countLegacyClaudeHookCommands(filePath, runtimeHome) > 0); + +/** + * Removes installer-written Crosshead hooks from a settings file, keeping every + * other key and hook. A file left with nothing is rewritten as `{}`, never + * deleted, so a tracked project file stays in place. + */ +const removeLegacyClaudeHooks = (filePath: string, managedRoot: string, runtimeHome: string): boolean => { + if (countLegacyClaudeHookCommands(filePath, runtimeHome) === 0) return false; + const filtered = filterManagedClaudeHooks(readClaudeSettingsConfig(filePath), legacyHookWrapper(runtimeHome)); + writeManagedFile(filePath, `${JSON.stringify(filtered, null, 2)}\n`, managedRoot); return true; }; -/** True when a Claude Code settings file still registers Crosshead's legacy (settings-mode) hooks. */ -export const claudeSettingsFileHasLegacyHooks = (filePath: string, runtimeHome: string): boolean => { - if (!existsSync(filePath)) return false; - const existing = readClaudeSettingsConfig(filePath); - return JSON.stringify(filterManagedClaudeHooks(existing, path.join(runtimeHome, "bin", "crosshead-claude-code-hook")).hooks ?? {}) !== - JSON.stringify(existing.hooks ?? {}); -}; - -/** Project settings files (the workspace and its ancestors) that still register legacy Crosshead hooks. */ -export const findLegacyClaudeHookFiles = (workspaceRoot: string, runtimeHome: string): string[] => - claudeProjectSettingsCandidates(workspaceRoot).filter((filePath) => claudeSettingsFileHasLegacyHooks(filePath, runtimeHome)); - export const installClaudeCodeAdapter = async ( options: ClaudeCodeAdapterInstallOptions ): Promise => { @@ -607,9 +626,6 @@ export const installClaudeCodeAdapter = async ( const profileName = options.profileName ?? "default"; const adapterVersion = options.adapterVersion ?? ONLYBOTS_CLAUDE_CODE_ADAPTER_VERSION; const claudeBinary = resolveClaudeCodeBinary(runtimeLayout.home, options.claudeBinary); - const { mode: hookMode, detection: pluginDetection } = resolveHostHookMode(options.hookMode, () => - detectClaudeCodePlugin(hostHome, workspaceRoot) - ); const previousConfig = existsSync(adapterPaths.configFile) ? (() => { try { @@ -619,6 +635,13 @@ export const installClaudeCodeAdapter = async ( } })() : null; + // Plugin mode needs the plugin enabled in the user settings of this Claude config dir. + const resolution = resolveHostHookMode( + options.hookMode, + () => detectClaudeCodePlugin(hostHome, workspaceRoot), + normalizeHookModeRequest(previousConfig?.hookModeRequest) + ); + const { mode: hookMode, detection: pluginDetection } = resolution; const config = buildClaudeCodeAdapterConfig({ runtimeHome: runtimeLayout.home, @@ -628,26 +651,43 @@ export const installClaudeCodeAdapter = async ( adapterVersion, claudeBinary, hookMode, + hookModeRequest: resolution.request, }); - assertManagedFilePath(adapterPaths.projectSettingsFile, workspaceRoot); + // Plugin mode retires legacy entries this runtime wrote (in this workspace and + // the previously configured one) so that no event is handled twice. + const legacyRoots = [workspaceRoot, previousConfig?.workspaceRoot].filter( + (root, index, all): root is string => typeof root === "string" && all.indexOf(root) === index + ); + const migrationTargets = + hookMode === "plugin" + ? legacyRoots + .map((root) => ({ root, filePath: projectSettingsFile(root) })) + .filter(({ filePath }) => countLegacyClaudeHookCommands(filePath, runtimeLayout.home) > 0) + : []; + // Check every host file this install changes before writing any of them. A + // symlinked settings file is refused rather than followed or half-migrated. + if (hookMode === "settings" && resolution.writeLegacyHooks) assertManagedFilePath(adapterPaths.projectSettingsFile, workspaceRoot); + for (const { root, filePath } of migrationTargets) { + try { + assertManagedFilePath(filePath, root); + } catch (error) { + throw new Error( + `Refusing to migrate Crosshead hooks in ${filePath}: ${error instanceof Error ? error.message : String(error)}. No files were changed; remove the Crosshead hooks from it by hand or rerun with --hook-mode settings.` + ); + } + } + assertManagedFilePath(adapterPaths.configFile, hostHome); writeManagedFile( adapterPaths.configFile, `${CLAUDE_CODE_ADAPTER_MARKER}\n${JSON.stringify(config, null, 2)}\n`, hostHome ); const migratedFiles: string[] = []; - if (hookMode === "plugin") { - // The plugin supplies the hooks. Retire legacy entries this runtime wrote so - // that no event is handled twice; unrelated hooks and settings are preserved. - const legacyRoots = [workspaceRoot, previousConfig?.workspaceRoot].filter( - (root, index, all): root is string => typeof root === "string" && all.indexOf(root) === index - ); - for (const root of legacyRoots) { - const filePath = projectSettingsFile(root); - if (removeLegacyClaudeHooks(filePath, root, runtimeLayout.home)) migratedFiles.push(filePath); - } - } else { + for (const { root, filePath } of migrationTargets) { + if (removeLegacyClaudeHooks(filePath, root, runtimeLayout.home)) migratedFiles.push(filePath); + } + if (hookMode === "settings" && resolution.writeLegacyHooks) { writeManagedFile( adapterPaths.projectSettingsFile, renderClaudeSettingsConfig(readClaudeSettingsConfig(adapterPaths.projectSettingsFile), adapterPaths, config), @@ -655,15 +695,17 @@ export const installClaudeCodeAdapter = async ( ); } writeManagedExecutable(adapterPaths.wrapperFile, options.bridgeScriptPath, runtimeLayout.home); - writeManagedExecutable(adapterPaths.hookWrapperFile, options.hookScriptPath, runtimeLayout.home); + writeManagedExecutable(adapterPaths.hookWrapperFile, options.hookScriptPath, runtimeLayout.home, PLUGIN_DISPATCH_CAPABILITY); writeManagedExecutable(path.join(config.runtimeHome, "bin", "onlybots-claude-code"), options.bridgeScriptPath, runtimeLayout.home); - writeManagedExecutable(path.join(config.runtimeHome, "bin", "onlybots-claude-code-hook"), options.hookScriptPath, runtimeLayout.home); + writeManagedExecutable(path.join(config.runtimeHome, "bin", "onlybots-claude-code-hook"), options.hookScriptPath, runtimeLayout.home, PLUGIN_DISPATCH_CAPABILITY); writeManagedFile(adapterPaths.claudeLaunchWrapperFile, renderClaudeLaunchWrapper(config), runtimeLayout.home); chmodSync(adapterPaths.claudeLaunchWrapperFile, 0o755); const managedFiles = [ adapterPaths.configFile, - ...(hookMode === "settings" ? [adapterPaths.projectSettingsFile] : []), + ...(hookMode === "settings" && (resolution.writeLegacyHooks || countLegacyClaudeHookCommands(adapterPaths.projectSettingsFile, runtimeLayout.home) > 0) + ? [adapterPaths.projectSettingsFile] + : []), adapterPaths.wrapperFile, adapterPaths.hookWrapperFile, path.join(config.runtimeHome, "bin", "onlybots-claude-code"), @@ -689,6 +731,7 @@ export const installClaudeCodeAdapter = async ( managedFiles, pluginDetection, migratedFiles, + hookModeNote: resolution.note, }; }; @@ -714,13 +757,8 @@ export const uninstallClaudeCodeAdapter = async ( continue; } if (filePath === projectSettingsFile(workspaceRoot)) { - const filtered = filterManagedClaudeHooks(readClaudeSettingsConfig(filePath), path.join(runtimeHome, "bin", "crosshead-claude-code-hook")); - if (Object.keys(filtered).length > 0) { - writeManagedFile(filePath, `${JSON.stringify(filtered, null, 2)}\n`, workspaceRoot); - } else { - rmSync(filePath, { force: true }); - removed.push(filePath); - } + // Never delete the settings file (it may be tracked); leave it cleaned, or `{}`. + removeLegacyClaudeHooks(filePath, workspaceRoot, runtimeHome); continue; } if (["onlybots-claude-code", "onlybots-claude-code-hook", "crosshead-claude-code", "crosshead-claude-code-hook", "claude"].includes(path.basename(filePath)) || isManagedContent(filePath)) { @@ -1337,6 +1375,7 @@ export const installClaudeCodeFromCli = async ( `workspace_root: ${result.config.workspaceRoot}`, `claude_bin: ${result.config.claudeBinary}`, `hook_mode: ${describeHookMode(result.config.hookMode ?? "settings", result.pluginDetection)}`, + ...(result.hookModeNote ? [`hook_mode_note: ${result.hookModeNote}`] : []), ...result.migratedFiles.map((filePath) => `migrated_legacy_hooks: ${filePath}`), ].join("\n"); }; diff --git a/src/runtime/claude-code-hook.ts b/src/runtime/claude-code-hook.ts index 9a79a05..ab8ec47 100644 --- a/src/runtime/claude-code-hook.ts +++ b/src/runtime/claude-code-hook.ts @@ -7,7 +7,16 @@ import { readLastAssistantMessage } from "./hook-transcript"; import { ClaudeCodeRuntimeAdapter, findLegacyClaudeHookFiles, loadClaudeCodeAdapterConfig } from "./claude-code-adapter"; import { pluginSetupHint } from "./host-hook-spec"; -import { decideHookDispatch, isPluginHookInvocation, normalizeHostHookMode, writePluginSetupNotice } from "./host-plugin-mode"; +import { + claudeSessionProjectDir, + claudeUserConfigDir, + decideHookDispatch, + isPluginHookInvocation, + normalizeHostHookMode, + recordPluginInvocation, + resolvePluginHostHome, + writePluginSetupNotice, +} from "./host-plugin-mode"; import { ensureRuntimeHomeLayout, resolveRuntimeHome } from "./local-runtime"; import { writeJsonFile } from "./runtime-persistence"; import { assertSecureNodeRuntimeVersion } from "./node-runtime-version"; @@ -362,11 +371,12 @@ export const runOnlyBotsClaudeCodeHookCli = async ( const eventName = positionals[0] ?? "help"; const normalizedEventName = eventName.toLowerCase(); const runtimeHome = resolveRuntimeHome(typeof flags.home === "string" ? flags.home : undefined); - const hostHome = typeof flags["claude-home"] === "string" ? flags["claude-home"] : undefined; + const viaPlugin = isPluginHookInvocation(flags); + const hostHome = + typeof flags["claude-home"] === "string" ? flags["claude-home"] : viaPlugin ? resolvePluginHostHome("claude-code", runtimeHome) : undefined; const payload = readStdinJson(stdinOverride); const workspaceRoot = resolveHookWorkspaceRoot(payload, flags); const workspaceId = resolveHookWorkspaceId(payload, flags, workspaceRoot); - const viaPlugin = isPluginHookInvocation(flags); if (eventName !== "help") { let installedConfig: ReturnType | null = null; try { @@ -377,14 +387,24 @@ export const runOnlyBotsClaudeCodeHookCli = async ( if (!installedConfig) { // Plugin installed before setup: tell the user how to finish, never fail the session. if (normalizedEventName === "sessionstart" || normalizedEventName === "session_start") { - writePluginSetupNotice("SessionStart", pluginSetupHint("claude-code")); + writePluginSetupNotice(pluginSetupHint("claude-code")); } return; } + if (viaPlugin) { + try { + recordPluginInvocation(runtimeHome, "claude-code", typeof flags["plugin-version"] === "string" ? flags["plugin-version"] : null); + } catch { + // A receipt is diagnostic only; never fail the hook over it. + } + } + // Exactly the settings files Claude Code loaded for this session: the user + // settings and the session's project directory (CLAUDE_PROJECT_DIR). const decision = decideHookDispatch({ viaPlugin, mode: normalizeHostHookMode(installedConfig.hookMode), - legacyHooksPresent: () => findLegacyClaudeHookFiles(workspaceRoot, runtimeHome).length > 0, + legacyHooksPresent: () => + findLegacyClaudeHookFiles(claudeUserConfigDir(), claudeSessionProjectDir(workspaceRoot), runtimeHome).length > 0, }); if (decision === "skip-duplicate") return; } diff --git a/src/runtime/cli/core.ts b/src/runtime/cli/core.ts index afff8e3..fd100be 100644 --- a/src/runtime/cli/core.ts +++ b/src/runtime/cli/core.ts @@ -202,7 +202,10 @@ export const doctorFromCli = async (argv: string[]): Promise => { const { flags } = parseArgs(argv); const home = typeof flags.home === "string" ? flags.home : undefined; const daemonReport = await buildRuntimeDoctorDirect(home, typeof flags.profile === "string" ? flags.profile : undefined); - const hookChecks = inspectHostHookRegistrations(home, typeof flags["workspace-root"] === "string" ? flags["workspace-root"] : process.cwd()); + // Inside a Claude Code session, CLAUDE_PROJECT_DIR is the project whose settings it loaded. + const workspaceRoot = + typeof flags["workspace-root"] === "string" ? flags["workspace-root"] : process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const hookChecks = inspectHostHookRegistrations(home, workspaceRoot); const checks = [...daemonReport.checks, ...hookChecks]; const report = { ...daemonReport, ok: checks.every((check) => check.ok), checks }; return flags.json ? JSON.stringify(report, null, 2) : formatRuntimeDoctor(report); diff --git a/src/runtime/codex-adapter.ts b/src/runtime/codex-adapter.ts index e1a3f88..5d87a90 100644 --- a/src/runtime/codex-adapter.ts +++ b/src/runtime/codex-adapter.ts @@ -57,13 +57,18 @@ import { type HostAdapterTurnPrepareResult, } from "./host-adapter-base"; import { resolveCodexHostHome } from "./runtime-env"; -import { renderHostHookGroups, type HostHookEvent } from "./host-hook-spec"; +import { PLUGIN_DISPATCH_CAPABILITY, renderHostHookGroups, type HostHookEvent } from "./host-hook-spec"; import { + countMatchingHookCommands, describeHookMode, detectCodexPlugin, + normalizeHookModeRequest, normalizeHostHookMode, parseHookModeFlag, + readPluginInvocation, + readSettingsForInspection, resolveHostHookMode, + type HookModeRequest, type HostHookMode, type PluginDetection, } from "./host-plugin-mode"; @@ -92,6 +97,8 @@ export type CodexAdapterConfig = { skillName: string; /** Absent in configs written before host plugins; treated as "settings". */ hookMode?: HostHookMode; + /** Explicit `--hook-mode` of the last install; reinstalls without the flag repeat it. */ + hookModeRequest?: HostHookMode | null; installedAt: string; updatedAt: string; }; @@ -121,8 +128,8 @@ export type CodexAdapterInstallOptions = { adapterVersion?: string; bridgeScriptPath: string; hookScriptPath: string; - /** "auto" (default) selects "plugin" when the Crosshead Codex plugin is enabled. */ - hookMode?: HostHookMode | "auto"; + /** "auto" (default) selects "plugin" when the Crosshead Codex plugin is enabled in the user config. */ + hookMode?: HookModeRequest; /** Project directory used to find a project-level `.codex/config.toml` during plugin detection. */ workspaceRoot?: string; }; @@ -135,6 +142,8 @@ export type CodexAdapterInstallResult = { pluginDetection: PluginDetection | null; /** Hook files whose legacy Crosshead hooks were removed because the plugin supplies them. */ migratedFiles: string[]; + /** Explanation when plugin mode could not be used, or legacy hooks were kept for now. */ + hookModeNote: string | null; }; export type CodexAdapterUninstallOptions = { @@ -271,13 +280,15 @@ const writeManagedExecutable = ( filePath: string, scriptPath: string, runtimeHome: string, - config?: Pick + config?: Pick, + marker?: string ): void => { writeManagedFile( filePath, buildWrapperScript( scriptPath, - config ? { hostHomeVar: "CODEX_HOME", hostHome: config.hostHome, runtimeHome: config.runtimeHome } : undefined + config ? { hostHomeVar: "CODEX_HOME", hostHome: config.hostHome, runtimeHome: config.runtimeHome } : undefined, + marker ), runtimeHome ); @@ -344,7 +355,7 @@ type CodexHookMatcherGroup = { hooks: CodexHookHandler[]; }; -type CodexHooksConfig = { +type CodexHooksConfig = Record & { hooks: Partial< Record< | "SessionStart" @@ -361,6 +372,7 @@ type CodexHooksConfig = { const CODEX_HOOK_MARKER = "crosshead-codex-hook"; +// Keeps every top-level key; only `hooks` is interpreted. const readHooksConfig = (filePath: string): CodexHooksConfig => { if (!existsSync(filePath)) { return { hooks: {} }; @@ -369,9 +381,7 @@ const readHooksConfig = (filePath: string): CodexHooksConfig => { const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown; if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { const hooks = (parsed as { hooks?: CodexHooksConfig["hooks"] }).hooks; - if (hooks && typeof hooks === "object" && !Array.isArray(hooks)) { - return { hooks }; - } + return { ...(parsed as Record), hooks: hooks && typeof hooks === "object" && !Array.isArray(hooks) ? hooks : {} }; } } catch { // Replace corrupt or unsupported files with a valid managed merge on install. @@ -379,20 +389,28 @@ const readHooksConfig = (filePath: string): CodexHooksConfig => { return { hooks: {} }; }; +/** True for a hook command an installer-written (settings-mode) Crosshead registration uses. */ +const isLegacyCodexHookCommand = (command: string | undefined, hookWrapperPath: string): boolean => + isManagedHookCommand(command, hookWrapperPath, CODEX_HOOK_MARKER) || + isManagedHookCommand(command, path.join(path.dirname(hookWrapperPath), "onlybots-codex-hook"), "onlybots-codex-hook"); + const filterManagedHooks = (existing: CodexHooksConfig, hookWrapperPath: string): CodexHooksConfig => { const hooks: CodexHooksConfig["hooks"] = {}; for (const [eventName, matcherGroups] of Object.entries(existing.hooks)) { - const remainingGroups = (matcherGroups ?? []) + const remainingGroups = (Array.isArray(matcherGroups) ? matcherGroups : []) + .filter((group): group is CodexHookMatcherGroup => !!group && typeof group === "object" && !Array.isArray(group)) .map((group) => ({ ...group, - hooks: (group.hooks ?? []).filter((hook) => !isManagedHookCommand(hook.command, hookWrapperPath, CODEX_HOOK_MARKER) && !isManagedHookCommand(hook.command, path.join(path.dirname(hookWrapperPath), "onlybots-codex-hook"), "onlybots-codex-hook")), + hooks: (Array.isArray(group.hooks) ? group.hooks : []).filter( + (hook) => !(hook && typeof hook === "object" && isLegacyCodexHookCommand(hook.command, hookWrapperPath)) + ), })) .filter((group) => group.hooks.length > 0); if (remainingGroups.length > 0) { hooks[eventName as keyof CodexHooksConfig["hooks"]] = remainingGroups; } } - return { hooks }; + return { ...existing, hooks }; }; const renderHooksConfig = (existing: CodexHooksConfig, adapterPaths: CodexAdapterPaths, config: CodexAdapterConfig): string => { @@ -496,6 +514,7 @@ const buildCodexAdapterConfig = (options: { skillName: string; adapterVersion: string; hookMode: HostHookMode; + hookModeRequest: HostHookMode | null; }): CodexAdapterConfig => ({ marker: CODEX_MANAGED_CONFIG_MARKER, adapterName: "codex", @@ -506,6 +525,7 @@ const buildCodexAdapterConfig = (options: { profileName: options.profileName, skillName: options.skillName, hookMode: options.hookMode, + hookModeRequest: options.hookModeRequest, installedAt: nowIso(), updatedAt: nowIso(), }); @@ -600,40 +620,70 @@ export const codexContinuityTranslationContract: ContinuityTranslationContract translation.fidelity, }; +const legacyCodexHookWrapper = (runtimeHome: string): string => path.join(runtimeHome, "bin", "crosshead-codex-hook"); + +/** Number of installer-written Crosshead hook handlers in a Codex hooks.json. */ +export const countLegacyCodexHookCommands = (filePath: string, runtimeHome: string): number => + countMatchingHookCommands(readSettingsForInspection(filePath), (command) => isLegacyCodexHookCommand(command, legacyCodexHookWrapper(runtimeHome))); + +/** True when a Codex hooks.json still registers Crosshead's legacy (settings-mode) hooks. */ +export const codexHooksFileHasLegacyHooks = (filePath: string, runtimeHome: string): boolean => + countLegacyCodexHookCommands(filePath, runtimeHome) > 0; + +/** + * Removes installer-written Crosshead hooks, keeping other hooks and top-level + * keys. The file is never deleted; with nothing left it holds `{"hooks": {}}`. + */ const removeLegacyCodexHooks = (filePath: string, hostHome: string, runtimeHome: string): boolean => { if (!codexHooksFileHasLegacyHooks(filePath, runtimeHome)) return false; - const filtered = filterManagedHooks(readHooksConfig(filePath), path.join(runtimeHome, "bin", "crosshead-codex-hook")); - if (Object.values(filtered.hooks).some((groups) => (groups ?? []).length > 0)) { - writeManagedFile(filePath, `${JSON.stringify(filtered, null, 2)}\n`, hostHome); - } else { - rmSync(filePath, { force: true }); - } + const filtered = filterManagedHooks(readHooksConfig(filePath), legacyCodexHookWrapper(runtimeHome)); + writeManagedFile(filePath, `${JSON.stringify(filtered, null, 2)}\n`, hostHome); return true; }; -/** True when a Codex hooks.json still registers Crosshead's legacy (settings-mode) hooks. */ -export const codexHooksFileHasLegacyHooks = (filePath: string, runtimeHome: string): boolean => { - if (!existsSync(filePath)) return false; - const existing = readHooksConfig(filePath); - return JSON.stringify(filterManagedHooks(existing, path.join(runtimeHome, "bin", "crosshead-codex-hook")).hooks) !== JSON.stringify(existing.hooks); -}; - export const codexLegacyHooksFile = (hostHome: string): string => hooksFile(hostHome); export const installCodexAdapter = async (options: CodexAdapterInstallOptions): Promise => { const runtimeLayout = ensureRuntimeHomeLayout(options.home); const hostHome = resolveCodexHome(options.hostHome); + const previousConfig = existsSync(configFile(hostHome)) + ? (() => { + try { + return loadCodexAdapterConfig(runtimeLayout.home, hostHome); + } catch { + return null; + } + })() + : null; + // Plugin mode needs the plugin enabled in $CODEX_HOME/config.toml. Project + // config applies only to trusted projects and never selects the global mode. + const resolution = resolveHostHookMode( + options.hookMode, + () => detectCodexPlugin(hostHome, path.resolve(options.workspaceRoot ?? process.cwd())), + normalizeHookModeRequest(previousConfig?.hookModeRequest) + ); + const { mode: hookMode, detection: pluginDetection } = resolution; + // Codex runs plugin hooks only after the user trusts them in /hooks. Keep the + // installer hooks until a plugin invocation has been observed; until then the + // dispatch rule lets the installer hooks capture and the plugin defer. + const legacyPresent = codexHooksFileHasLegacyHooks(hooksFile(hostHome), runtimeLayout.home); + const pluginObserved = readPluginInvocation(runtimeLayout.home, "codex") !== null; + const migrateLegacy = hookMode === "plugin" && legacyPresent && pluginObserved; + const writeLegacy = hookMode === "settings" && resolution.writeLegacyHooks; + const hookModeNote = + resolution.note ?? + (hookMode === "plugin" && legacyPresent && !pluginObserved + ? `kept the installer hooks in ${hooksFile(hostHome)} because Codex has not run the plugin hooks yet. Open /hooks in Codex, trust the Crosshead hooks, start a session, then rerun crosshead install codex to remove them.` + : null); assertManagedFilePath(primarySkillFile(hostHome), hostHome); assertManagedFilePath(fallbackSkillFile(hostHome), hostHome); - assertManagedFilePath(hooksFile(hostHome), hostHome); + if (writeLegacy || migrateLegacy) assertManagedFilePath(hooksFile(hostHome), hostHome); + assertManagedFilePath(configFile(hostHome), hostHome); const selectedSkill = selectSkillTarget(hostHome); const adapterPaths = resolveCodexAdapterPaths(runtimeLayout.home, hostHome); const profileName = options.profileName ?? "default"; const adapterVersion = options.adapterVersion ?? ONLYBOTS_CODEX_ADAPTER_VERSION; const codexBinary = resolveCodexBinary(runtimeLayout.home, options.codexBinary); - const { mode: hookMode, detection: pluginDetection } = resolveHostHookMode(options.hookMode, () => - detectCodexPlugin(hostHome, path.resolve(options.workspaceRoot ?? process.cwd())) - ); const config = buildCodexAdapterConfig({ runtimeHome: runtimeLayout.home, @@ -643,6 +693,7 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): skillName: selectedSkill.skillName, adapterVersion, hookMode, + hookModeRequest: resolution.request, }); writeManagedFile( @@ -661,10 +712,10 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): hostHome ); const migratedFiles: string[] = []; - if (hookMode === "plugin") { - // The plugin supplies the hooks. Retire legacy entries so no event runs twice. + if (migrateLegacy) { + // The plugin supplies the hooks and Codex has run them. Retire legacy entries so no event runs twice. if (removeLegacyCodexHooks(adapterPaths.hooksFile, hostHome, runtimeLayout.home)) migratedFiles.push(adapterPaths.hooksFile); - } else { + } else if (writeLegacy) { writeManagedFile( adapterPaths.hooksFile, renderHooksConfig(readHooksConfig(adapterPaths.hooksFile), adapterPaths, config), @@ -672,9 +723,9 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): ); } writeManagedExecutable(adapterPaths.wrapperFile, options.bridgeScriptPath, runtimeLayout.home, config); - writeManagedExecutable(adapterPaths.hookWrapperFile, options.hookScriptPath, runtimeLayout.home, config); + writeManagedExecutable(adapterPaths.hookWrapperFile, options.hookScriptPath, runtimeLayout.home, config, PLUGIN_DISPATCH_CAPABILITY); writeManagedExecutable(path.join(config.runtimeHome, "bin", "onlybots-codex"), options.bridgeScriptPath, runtimeLayout.home, config); - writeManagedExecutable(path.join(config.runtimeHome, "bin", "onlybots-codex-hook"), options.hookScriptPath, runtimeLayout.home, config); + writeManagedExecutable(path.join(config.runtimeHome, "bin", "onlybots-codex-hook"), options.hookScriptPath, runtimeLayout.home, config, PLUGIN_DISPATCH_CAPABILITY); writeManagedFile( adapterPaths.codexLaunchWrapperFile, renderCodexLaunchWrapper(config), @@ -685,7 +736,7 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): const managedFiles = [ adapterPaths.skillFile, adapterPaths.configFile, - ...(hookMode === "settings" ? [adapterPaths.hooksFile] : []), + ...(writeLegacy || (legacyPresent && !migrateLegacy) ? [adapterPaths.hooksFile] : []), adapterPaths.wrapperFile, adapterPaths.hookWrapperFile, path.join(config.runtimeHome, "bin", "onlybots-codex"), @@ -712,6 +763,7 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): preservedFiles: selectedSkill.preservedFiles, pluginDetection, migratedFiles, + hookModeNote, }; }; @@ -736,14 +788,8 @@ export const uninstallCodexAdapter = async (options: CodexAdapterUninstallOption continue; } if (filePath === hooksFile(hostHome)) { - const filtered = filterManagedHooks(readHooksConfig(filePath), path.join(runtimeHome, "bin", "crosshead-codex-hook")); - const hasRemainingHooks = Object.values(filtered.hooks).some((groups) => (groups ?? []).length > 0); - if (hasRemainingHooks) { - writeManagedFile(filePath, `${JSON.stringify(filtered, null, 2)}\n`, hostHome); - } else { - rmSync(filePath, { force: true }); - removed.push(filePath); - } + // Never delete hooks.json; leave other hooks and keys, or `{"hooks": {}}`. + removeLegacyCodexHooks(filePath, hostHome, runtimeHome); continue; } if (managedBasenames.has(path.basename(filePath)) || isManagedContent(filePath)) { @@ -1256,6 +1302,7 @@ export const installCodexFromCli = async ( `codex_home: ${result.config.hostHome}`, `codex_bin: ${result.config.codexBinary}`, `hook_mode: ${describeHookMode(result.config.hookMode ?? "settings", result.pluginDetection)}`, + ...(result.hookModeNote ? [`hook_mode_note: ${result.hookModeNote}`] : []), ...result.migratedFiles.map((filePath) => `migrated_legacy_hooks: ${filePath}`), ...(result.preservedFiles.length === 0 ? [] diff --git a/src/runtime/codex-hook.ts b/src/runtime/codex-hook.ts index d18f626..ed39f4d 100644 --- a/src/runtime/codex-hook.ts +++ b/src/runtime/codex-hook.ts @@ -4,7 +4,15 @@ import path from "path"; import { CodexRuntimeAdapter, codexHooksFileHasLegacyHooks, codexLegacyHooksFile, loadCodexAdapterConfig, resolveCodexHome } from "./codex-adapter"; import { pluginSetupHint } from "./host-hook-spec"; -import { decideHookDispatch, isPluginHookInvocation, normalizeHostHookMode, writePluginSetupNotice } from "./host-plugin-mode"; +import { + codexLoadedHome, + decideHookDispatch, + isPluginHookInvocation, + normalizeHostHookMode, + recordPluginInvocation, + resolvePluginHostHome, + writePluginSetupNotice, +} from "./host-plugin-mode"; import { ensureRuntimeHomeLayout, resolveRuntimeHome } from "./local-runtime"; import { compactContinuitySummary } from "./summary-compaction"; import { captureHotFinding } from "./hot-continuity"; @@ -364,7 +372,9 @@ export const runOnlyBotsCodexHookCli = async ( const eventName = positionals[0] ?? "help"; const normalizedEventName = eventName.toLowerCase(); const runtimeHome = resolveRuntimeHome(typeof flags.home === "string" ? flags.home : undefined); - const hostHome = typeof flags["codex-home"] === "string" ? flags["codex-home"] : undefined; + const viaPlugin = isPluginHookInvocation(flags); + const hostHome = + typeof flags["codex-home"] === "string" ? flags["codex-home"] : viaPlugin ? resolvePluginHostHome("codex", runtimeHome) : undefined; const payload = readStdinJson(stdinOverride); const workspaceRoot = resolveHookWorkspaceRoot(payload, flags); const workspaceId = resolveHookWorkspaceId(payload, flags, workspaceRoot); @@ -372,7 +382,6 @@ export const runOnlyBotsCodexHookCli = async ( flags["allow-ephemeral-config"] === true || flags["allow-ephemeral-config"] === "1" || (process.env.CROSSHEAD_CODEX_ALLOW_EPHEMERAL_CONFIG ?? process.env.ONLYBOTS_CODEX_ALLOW_EPHEMERAL_CONFIG) === "1"; - const viaPlugin = isPluginHookInvocation(flags); if (eventName !== "help") { let installedConfig: ReturnType | null = null; try { @@ -383,14 +392,27 @@ export const runOnlyBotsCodexHookCli = async ( if (!installedConfig) { // Plugin installed before setup: tell the user how to finish, never fail the session. if (normalizedEventName === "sessionstart" || normalizedEventName === "session_start") { - writePluginSetupNotice("SessionStart", pluginSetupHint("codex")); + writePluginSetupNotice(pluginSetupHint("codex")); } return; } + if (viaPlugin) { + // Proof that Codex runs (so the user trusted) the plugin hooks; migration waits for it. + try { + recordPluginInvocation(runtimeHome, "codex", typeof flags["plugin-version"] === "string" ? flags["plugin-version"] : null); + } catch { + // A receipt is diagnostic only; never fail the hook over it. + } + } + // The hooks.json Codex loaded: an installer hook names it with --codex-home; + // the plugin launcher passes the CODEX_HOME Codex ran with. + const loadedHooksFile = codexLegacyHooksFile( + viaPlugin ? codexLoadedHome(resolveCodexHome(hostHome)) : resolveCodexHome(hostHome) + ); const decision = decideHookDispatch({ viaPlugin, mode: normalizeHostHookMode(installedConfig.hookMode), - legacyHooksPresent: () => codexHooksFileHasLegacyHooks(codexLegacyHooksFile(resolveCodexHome(hostHome)), runtimeHome), + legacyHooksPresent: () => codexHooksFileHasLegacyHooks(loadedHooksFile, runtimeHome), }); if (decision === "skip-duplicate") return; } diff --git a/src/runtime/host-hook-doctor.ts b/src/runtime/host-hook-doctor.ts index 57f626e..bf1a4eb 100644 --- a/src/runtime/host-hook-doctor.ts +++ b/src/runtime/host-hook-doctor.ts @@ -1,9 +1,18 @@ import path from "path"; -import { findLegacyClaudeHookFiles, loadClaudeCodeAdapterConfig } from "./claude-code-adapter"; +import { countLegacyClaudeHookCommands, findLegacyClaudeHookFiles, loadClaudeCodeAdapterConfig } from "./claude-code-adapter"; import { codexHooksFileHasLegacyHooks, codexLegacyHooksFile, loadCodexAdapterConfig } from "./codex-adapter"; import type { HookHost } from "./host-hook-spec"; -import { detectClaudeCodePlugin, detectCodexPlugin, normalizeHostHookMode, type HostHookMode, type PluginDetection } from "./host-plugin-mode"; +import { + detectClaudeCodePlugin, + detectCodexPlugin, + normalizeHookModeRequest, + normalizeHostHookMode, + readPluginInvocation, + type HostHookMode, + type PluginDetection, + type PluginInvocationRecord, +} from "./host-plugin-mode"; import type { RuntimeDoctorCheck } from "./local-runtime"; import { loadAdapterRecord, resolveRuntimePaths } from "./runtime-persistence"; @@ -11,51 +20,100 @@ export type HostHookInspection = { host: HookHost; mode: HostHookMode; plugin: PluginDetection; + /** Installer-written Crosshead hooks the host loads for the inspected workspace. */ legacyHookFiles: string[]; + /** Last observed plugin hook run, or null if the host never ran one. */ + pluginInvocation?: PluginInvocationRecord | null; + /** The last install asked for plugin hooks (`--hook-mode plugin`). */ + pluginRequested?: boolean; }; const installCommand = (host: HookHost): string => `crosshead install ${host}`; +const CODEX_TRUST_HINT = + "Codex runs plugin hooks only after you trust them: open /hooks in Codex, review and trust the Crosshead hooks (again after each plugin update), then start a session."; + +const describePlugin = (plugin: PluginDetection): string => { + const state = plugin.enabled + ? `plugin enabled${plugin.source ? ` in ${plugin.source}` : ""}${plugin.enabledAtUserScope ? "" : " (this project only)"}` + : plugin.source + ? `plugin disabled in ${plugin.source}` + : "plugin not enabled"; + return plugin.ignored ? `${state} (${plugin.ignored})` : state; +}; + /** Doctor verdict for one host's hook registration. Exported for tests. */ export const evaluateHostHookInspection = (inspection: HostHookInspection): RuntimeDoctorCheck => { const { host, mode, plugin, legacyHookFiles } = inspection; const id = `${host === "codex" ? "codex" : "claude_code"}_hooks`; - const pluginState = plugin.enabled ? `plugin enabled${plugin.source ? ` in ${plugin.source}` : ""}` : "plugin not enabled"; const legacyState = legacyHookFiles.length > 0 ? `legacy hooks in ${legacyHookFiles.join(", ")}` : "no legacy hooks"; - const summary = `mode=${mode}; ${pluginState}; ${legacyState}.`; + const summary = `mode=${mode}; ${describePlugin(plugin)}; ${legacyState}.`; + const hasLegacy = legacyHookFiles.length > 0; + // Codex: plugin hooks that never ran are most likely untrusted. + const codexUnseen = host === "codex" && plugin.enabled && !inspection.pluginInvocation; + const fail = (message: string): RuntimeDoctorCheck => ({ id, ok: false, message: `${summary} ${message}` }); + const ok = (message?: string): RuntimeDoctorCheck => ({ id, ok: true, message: message ? `${summary} ${message}` : summary }); + if (mode === "plugin") { if (!plugin.enabled) { - return { id, ok: false, message: `${summary} No Crosshead hooks are active: enable the Crosshead plugin, or run ${installCommand(host)} --hook-mode settings.` }; + return hasLegacy + ? fail(`The plugin is not enabled here, so only the installer hooks capture: enable the Crosshead plugin, or run ${installCommand(host)} --hook-mode settings.`) + : fail(`No Crosshead hooks are active: enable the Crosshead plugin, or run ${installCommand(host)} --hook-mode settings.`); + } + if (hasLegacy && codexUnseen) { + return fail(`Plugin mode is configured, but Codex has not run a Crosshead plugin hook yet, so the installer hooks are kept and capture meanwhile. ${CODEX_TRUST_HINT} Then run ${installCommand(host)} to remove the installer hooks.`); } - if (legacyHookFiles.length > 0) { - return { id, ok: false, message: `${summary} Duplicate registration (the legacy entries are ignored at run time): run ${installCommand(host)} to remove them.` }; + if (hasLegacy) { + return fail(`Duplicate registration (the plugin defers to the installer hooks while they exist): run ${installCommand(host)} to remove them.`); } - return { id, ok: true, message: summary }; + if (codexUnseen) { + return fail(`Plugin mode is configured, but no Crosshead plugin hook invocation has been seen, so nothing may be capturing. ${CODEX_TRUST_HINT}`); + } + return ok(); + } + const requested = inspection.pluginRequested + ? ` Plugin hooks were requested; enable the plugin at user scope and run ${installCommand(host)} --hook-mode plugin to migrate.` + : ""; + if (plugin.enabled && hasLegacy) { + if (!plugin.enabledAtUserScope) { + return ok(`The plugin is enabled only for this project, so the installer hooks stay in place for other projects; here the plugin defers to them and each event is captured once.${requested}`); + } + return fail( + `Duplicate registration (the plugin defers to the installer hooks here): run ${installCommand(host)} --hook-mode plugin to migrate${host === "codex" && !inspection.pluginInvocation ? ` once Codex has run the plugin hooks. ${CODEX_TRUST_HINT}` : "."}` + ); } - if (plugin.enabled && legacyHookFiles.length > 0) { - return { id, ok: false, message: `${summary} Duplicate registration (the plugin defers to the legacy hooks here): run ${installCommand(host)} --hook-mode plugin to migrate.` }; + if (plugin.enabled && codexUnseen) { + return fail(`No Crosshead plugin hook invocation has been seen and no installer hooks are present, so nothing may be capturing. ${CODEX_TRUST_HINT}`); } - if (!plugin.enabled && legacyHookFiles.length === 0) { - return { id, ok: false, message: `${summary} No Crosshead hooks found for this workspace: install the Crosshead plugin or rerun ${installCommand(host)}.` }; + if (!plugin.enabled && !hasLegacy) { + return fail(`No Crosshead hooks found for this workspace: install the Crosshead plugin or rerun ${installCommand(host)}.${requested}`); } - return { id, ok: true, message: summary }; + return ok(requested ? requested.trim() : undefined); }; /** Hook registration checks for each installed Claude Code / Codex adapter. */ export const inspectHostHookRegistrations = (home?: string, workspaceRoot: string = process.cwd()): RuntimeDoctorCheck[] => { const paths = resolveRuntimePaths(home); + const workspace = path.resolve(workspaceRoot); const checks: RuntimeDoctorCheck[] = []; const claudeRecord = loadAdapterRecord(paths, "claude-code"); if (claudeRecord) { try { const config = loadClaudeCodeAdapterConfig(paths.home, claudeRecord.hostHome ?? undefined); - const roots = [...new Set([path.resolve(workspaceRoot), config.workspaceRoot])]; + const mode = normalizeHostHookMode(config.hookMode); + // Exactly the files Claude Code loads for a session in this workspace. + const legacy = findLegacyClaudeHookFiles(config.hostHome, workspace, paths.home); + // In plugin mode, also report what a migration left in the configured workspace. + const configured = path.join(config.workspaceRoot, ".claude", "settings.json"); + if (mode === "plugin" && !legacy.includes(configured) && countLegacyClaudeHookCommands(configured, paths.home) > 0) legacy.push(configured); checks.push( evaluateHostHookInspection({ host: "claude-code", - mode: normalizeHostHookMode(config.hookMode), - plugin: detectClaudeCodePlugin(config.hostHome, path.resolve(workspaceRoot)), - legacyHookFiles: [...new Set(roots.flatMap((root) => findLegacyClaudeHookFiles(root, paths.home)))], + mode, + plugin: detectClaudeCodePlugin(config.hostHome, workspace), + legacyHookFiles: legacy, + pluginInvocation: readPluginInvocation(paths.home, "claude-code"), + pluginRequested: normalizeHookModeRequest(config.hookModeRequest) === "plugin", }) ); } catch (error) { @@ -71,8 +129,10 @@ export const inspectHostHookRegistrations = (home?: string, workspaceRoot: strin evaluateHostHookInspection({ host: "codex", mode: normalizeHostHookMode(config.hookMode), - plugin: detectCodexPlugin(config.hostHome, path.resolve(workspaceRoot)), + plugin: detectCodexPlugin(config.hostHome, workspace), legacyHookFiles: codexHooksFileHasLegacyHooks(hooksFile, paths.home) ? [hooksFile] : [], + pluginInvocation: readPluginInvocation(paths.home, "codex"), + pluginRequested: normalizeHookModeRequest(config.hookModeRequest) === "plugin", }) ); } catch (error) { diff --git a/src/runtime/host-hook-spec.ts b/src/runtime/host-hook-spec.ts index e3673fb..204a3ce 100644 --- a/src/runtime/host-hook-spec.ts +++ b/src/runtime/host-hook-spec.ts @@ -29,7 +29,7 @@ type HostHookEventSpec = { }; export const HOST_HOOK_EVENTS: readonly HostHookEventSpec[] = [ - { event: "SessionStart", matcher: "startup|resume|compact", codexStatusMessage: "Crosshead session sync" }, + { event: "SessionStart", matcher: "startup|resume|clear|compact", codexStatusMessage: "Crosshead session sync" }, { event: "PreCompact", matcher: "manual|auto", codexStatusMessage: "Crosshead pre-compact checkpoint", codexTimeout: 30 }, { event: "PostCompact", matcher: "manual|auto", codexStatusMessage: "Crosshead post-compact sync", codexTimeout: 30 }, { event: "UserPromptSubmit", codexStatusMessage: "Crosshead context", codexTimeout: 45 }, @@ -50,15 +50,27 @@ export const PLUGIN_HOOK_VIA_FLAG = ["--via", "plugin"] as const; export const pluginRootVariable = (host: HookHost): string => (host === "codex" ? "PLUGIN_ROOT" : "CLAUDE_PLUGIN_ROOT"); -export const pluginSetupHint = (host: HookHost): string => - host === "codex" - ? "Crosshead isn't set up: ask Codex to use the crosshead setup skill ($crosshead:setup)." - : "Crosshead isn't set up: run /crosshead:setup."; +/** + * Marker in the runtime's Claude Code and Codex hook wrappers. A wrapper without + * it runs a runtime that predates plugin dispatch, which the launcher must not + * call (it would treat a plugin invocation as an installer hook). + */ +export const PLUGIN_DISPATCH_CAPABILITY = "crosshead-hook-capability: plugin-dispatch-v1"; + +/** Flag the launcher adds so invocation receipts record which plugin version ran. */ +export const PLUGIN_VERSION_FLAG = "--plugin-version"; + +const setupCommand = (host: HookHost): string => (host === "codex" ? "type $crosshead:setup" : "run /crosshead:setup"); + +// SessionStart notices are shown to the user only (systemMessage); they are not +// added to model context, so the model is never invited to run setup itself. +export const pluginSetupHint = (host: HookHost): string => `Crosshead isn't set up. To set it up, ${setupCommand(host)}.`; export const pluginUpdateHint = (host: HookHost): string => - host === "codex" - ? "The installed Crosshead runtime predates the Codex plugin: ask Codex to use the crosshead setup skill ($crosshead:setup) to update it." - : "The installed Crosshead runtime predates the Claude Code plugin: run /crosshead:setup to update it."; + `The installed Crosshead runtime predates this plugin. To update it, ${setupCommand(host)}.`; + +export const PLUGIN_HOME_CONFLICT_HINT = + "Crosshead found separate ~/.crosshead and ~/.onlybots installations. Set CROSSHEAD_HOME to choose one; no state was moved."; /** Ordered `[event, matcher group]` pairs for one host, with the caller's command per event. */ export const renderHostHookGroups = ( diff --git a/src/runtime/host-plugin-mode.ts b/src/runtime/host-plugin-mode.ts index 74c2785..ba6c4e4 100644 --- a/src/runtime/host-plugin-mode.ts +++ b/src/runtime/host-plugin-mode.ts @@ -1,24 +1,38 @@ import { existsSync, readFileSync } from "fs"; +import os from "os"; import path from "path"; -import { CROSSHEAD_PLUGIN_NAME, PLUGIN_HOOK_VIA_FLAG, type HookHost } from "./host-hook-spec"; +import { CROSSHEAD_MARKETPLACE_NAME, CROSSHEAD_PLUGIN_NAME, PLUGIN_HOOK_VIA_FLAG, type HookHost } from "./host-hook-spec"; +import { ensureRuntimeHomeLayout, loadAdapterRecord, parseJsonFile, resolveRuntimePaths, writeJsonFile } from "./runtime-persistence"; // Hook registration modes for Claude Code and Codex: // - "settings": the legacy installer writes hooks into .claude/settings.json or // $CODEX_HOME/hooks.json. // - "plugin": the Crosshead host plugin supplies the hooks; the installer writes // only the adapter config and wrappers, and removes legacy hook entries. -// The selected mode is stored in the adapter config so the hook entrypoint can -// make sure exactly one registration handles each event. +// Plugin mode is recorded only when the plugin is enabled at user scope, because +// the mode and the legacy hooks it removes apply to every project. The hook +// entrypoint makes sure exactly one registration handles each event. export type HostHookMode = "settings" | "plugin"; +export type PluginScope = "user" | "project" | "local"; + export type PluginDetection = { + /** Effective state for the inspected workspace. */ enabled: boolean; - /** Config file that decided the result, or null when no file mentions the plugin. */ + /** Config file that decided the effective state, or null when no loaded file mentions the plugin. */ source: string | null; + scope: PluginScope | null; + /** True when the user-scope config enables the plugin, so it applies in every project. */ + enabledAtUserScope: boolean; + /** Why a project-level setting was not applied (Codex ignores untrusted project config). */ + ignored?: string; }; +/** The only plugin id this runtime recognizes: this repository's marketplace. */ +export const CROSSHEAD_PLUGIN_ID = `${CROSSHEAD_PLUGIN_NAME}@${CROSSHEAD_MARKETPLACE_NAME}`; + const readJson = (filePath: string): Record | null => { try { const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown; @@ -28,86 +42,353 @@ const readJson = (filePath: string): Record | null => { } }; -const isCrossheadPluginId = (id: string): boolean => id === CROSSHEAD_PLUGIN_NAME || id.startsWith(`${CROSSHEAD_PLUGIN_NAME}@`); +const nonEmpty = (value: string | undefined): string | undefined => (value && value.length > 0 ? value : undefined); + +/** Nearest ancestor (including `start`) that contains a `.git` entry. */ +const findGitRoot = (start: string): string | null => { + let current = path.resolve(start); + for (;;) { + if (existsSync(path.join(current, ".git"))) return current; + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +}; + +// ---- Claude Code ------------------------------------------------------------ + +/** The user settings directory Claude Code itself loads: `CLAUDE_CONFIG_DIR`, else `~/.claude`. */ +export const claudeUserConfigDir = (env: NodeJS.ProcessEnv = process.env): string => + path.resolve(nonEmpty(env.CLAUDE_CONFIG_DIR) ?? path.join(os.homedir(), ".claude")); + +/** The project directory of a Claude Code session: `CLAUDE_PROJECT_DIR` when set, else the payload cwd. */ +export const claudeSessionProjectDir = (fallback: string, env: NodeJS.ProcessEnv = process.env): string => + path.resolve(nonEmpty(env.CLAUDE_PROJECT_DIR) ?? fallback); + +export type ClaudeSettingsSource = { scope: PluginScope; file: string }; + +/** + * The settings files Claude Code loads for a session whose project directory is + * `projectDir`, highest precedence first (managed settings excluded): + * - `.claude/settings.local.json` at the git repository root (Claude Code 2.1.211+; + * not when the root is the home directory), and in the project directory; + * - `.claude/settings.json` in the project directory only (no ancestor lookup); + * - `settings.json` in the user settings directory. + * See https://code.claude.com/docs/en/settings.md. + */ +export const claudeLoadedSettingsFiles = (userConfigDir: string, projectDir: string): ClaudeSettingsSource[] => { + const project = path.resolve(projectDir); + const gitRoot = findGitRoot(project); + const localDirs = gitRoot && gitRoot !== path.resolve(os.homedir()) ? [gitRoot, project] : [project]; + const userFile = path.join(path.resolve(userConfigDir), "settings.json"); + const sources: ClaudeSettingsSource[] = [ + ...localDirs.map((dir) => ({ scope: "local" as const, file: path.join(dir, ".claude", "settings.local.json") })), + { scope: "project", file: path.join(project, ".claude", "settings.json") }, + ]; + // In the home directory the project file is the user file; report it once, as user settings. + const seen = new Set([userFile]); + return [...sources.filter((source) => (seen.has(source.file) ? false : (seen.add(source.file), true))), { scope: "user", file: userFile }]; +}; + +const claudeEnabledPluginValue = (filePath: string): boolean | null => { + const enabledPlugins = readJson(filePath)?.enabledPlugins; + if (!enabledPlugins || typeof enabledPlugins !== "object" || Array.isArray(enabledPlugins)) return null; + const value = (enabledPlugins as Record)[CROSSHEAD_PLUGIN_ID]; + return typeof value === "boolean" ? value : null; +}; + +export const detectClaudeCodePlugin = (userConfigDir: string, projectDir: string): PluginDetection => { + const sources = claudeLoadedSettingsFiles(userConfigDir, projectDir); + const userSource = sources.find((source) => source.scope === "user")!; + const enabledAtUserScope = claudeEnabledPluginValue(userSource.file) === true; + for (const source of sources) { + const value = claudeEnabledPluginValue(source.file); + if (value === null) continue; + return { enabled: value, source: source.file, scope: source.scope, enabledAtUserScope }; + } + return { enabled: false, source: null, scope: null, enabledAtUserScope }; +}; + +// ---- Codex: a small TOML reader for the keys Crosshead needs --------------- + +/** Removes a `#` comment that is outside a string. */ +const stripTomlComment = (line: string): string => { + let quote: '"' | "'" | null = null; + for (let index = 0; index < line.length; index++) { + const char = line[index]!; + if (quote === '"' && char === "\\") { + index++; + continue; + } + if (quote) { + if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'") quote = char; + else if (char === "#") return line.slice(0, index); + } + return line; +}; + +/** Splits a dotted TOML key (`a."b.c".d`) into its parts, or null if it is malformed. */ +const parseTomlKey = (raw: string): string[] | null => { + const parts: string[] = []; + let rest = raw.trim(); + while (rest.length > 0) { + let part: string; + const quoted = /^"((?:[^"\\]|\\.)*)"|^'([^']*)'/.exec(rest); + if (quoted) { + part = quoted[1] !== undefined ? quoted[1].replace(/\\(.)/g, "$1") : quoted[2]!; + rest = rest.slice(quoted[0].length).trimStart(); + } else { + const bare = /^[A-Za-z0-9_-]+/.exec(rest); + if (!bare) return null; + part = bare[0]; + rest = rest.slice(part.length).trimStart(); + } + parts.push(part); + if (rest.length === 0) break; + if (!rest.startsWith(".")) return null; + rest = rest.slice(1).trimStart(); + } + return parts.length > 0 ? parts : null; +}; + +type TomlScalar = boolean | string; +type TomlEntries = { tables: string[][]; values: Array<{ key: string[]; value: TomlScalar }> }; -/** Claude Code settings precedence for `enabledPlugins`: local, then project, then user. */ -export const claudePluginSettingsFiles = (hostHome: string, workspaceRoot: string): string[] => [ - path.join(workspaceRoot, ".claude", "settings.local.json"), - path.join(workspaceRoot, ".claude", "settings.json"), - path.join(hostHome, "settings.json"), -]; +const parseTomlScalar = (raw: string): TomlScalar | undefined => { + const value = raw.trim(); + if (value === "true") return true; + if (value === "false") return false; + const quoted = /^"((?:[^"\\]|\\.)*)"$|^'([^']*)'$/.exec(value); + if (quoted) return quoted[1] !== undefined ? quoted[1].replace(/\\(.)/g, "$1") : quoted[2]!; + return undefined; +}; -export const detectClaudeCodePlugin = (hostHome: string, workspaceRoot: string): PluginDetection => { - for (const filePath of claudePluginSettingsFiles(hostHome, workspaceRoot)) { - const enabledPlugins = readJson(filePath)?.enabledPlugins; - if (!enabledPlugins || typeof enabledPlugins !== "object" || Array.isArray(enabledPlugins)) continue; - const entries = Object.entries(enabledPlugins as Record).filter(([id]) => isCrossheadPluginId(id)); - if (entries.length === 0) continue; - return { enabled: entries.some(([, value]) => value === true), source: filePath }; +/** Index of the first `ch` outside strings and brackets, or -1. */ +const indexOutside = (text: string, ch: string, trackDepth: boolean): number => { + let depth = 0; + let quote: string | null = null; + for (let index = 0; index < text.length; index++) { + const char = text[index]!; + if (quote) { + if (quote === '"' && char === "\\") index++; + else if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'") quote = char; + else if (trackDepth && (char === "{" || char === "[")) depth++; + else if (trackDepth && (char === "}" || char === "]")) depth--; + else if (char === ch && depth === 0) return index; + } + return -1; +}; + +/** Splits `a = 1, b = { c = 2 }` at top-level commas. */ +const splitInlineTable = (body: string): string[] => { + const parts: string[] = []; + let rest = body; + for (;;) { + const comma = indexOutside(rest, ",", true); + if (comma < 0) break; + parts.push(rest.slice(0, comma)); + rest = rest.slice(comma + 1); } - return { enabled: false, source: null }; + parts.push(rest); + return parts.filter((part) => part.trim().length > 0); +}; + +const collectTomlAssignment = (prefix: string[], assignment: string, entries: TomlEntries): void => { + const equals = indexOutside(assignment, "=", false); + if (equals < 0) return; + const key = parseTomlKey(assignment.slice(0, equals)); + if (!key) return; + const full = [...prefix, ...key]; + const rawValue = assignment.slice(equals + 1).trim(); + if (rawValue.startsWith("{") && rawValue.endsWith("}")) { + entries.tables.push(full); + for (const part of splitInlineTable(rawValue.slice(1, -1))) collectTomlAssignment(full, part, entries); + return; + } + const scalar = parseTomlScalar(rawValue); + if (scalar !== undefined) entries.values.push({ key: full, value: scalar }); +}; + +const bracketBalance = (text: string): number => { + let balance = 0; + let quote: string | null = null; + for (let index = 0; index < text.length; index++) { + const char = text[index]!; + if (quote) { + if (quote === '"' && char === "\\") index++; + else if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'") quote = char; + else if (char === "[" || char === "{") balance++; + else if (char === "]" || char === "}") balance--; + } + return balance; }; /** - * Reads `[plugins."crosshead@"] enabled = true|false` from a Codex - * config.toml without a TOML dependency. Only that table is interpreted. + * Collects tables and boolean/string values from a Codex config.toml. Enough of + * TOML for `[plugins."id"]`, `[projects."path"]`, dotted keys and inline tables; + * multi-line strings and arrays are skipped without being misread as tables. */ -export const readCodexPluginEnabled = (content: string): boolean | null => { - let inTable = false; - let result: boolean | null = null; - for (const rawLine of content.split(/\r?\n/)) { - const line = rawLine.replace(/\s+#.*$/, "").trim(); +export const parseTomlEntries = (content: string): TomlEntries => { + const entries: TomlEntries = { tables: [], values: [] }; + let table: string[] = []; + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index++) { + let line = stripTomlComment(lines[index]!).trim(); + if (line.length === 0) continue; + const multiline = /("""|''')/.exec(line); + if (multiline && line.indexOf(multiline[1]!, multiline.index + 3) < 0) { + // Skip to the end of a multi-line string. + while (++index < lines.length && !lines[index]!.includes(multiline[1]!)); + continue; + } + if (line.startsWith("[[")) { + // Arrays of tables are not used for these keys; ignore their contents. + table = ["\u0000array"]; + continue; + } if (line.startsWith("[")) { - const match = /^\[\s*plugins\s*\.\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\s*\]$/.exec(line); - const id = match ? match[1] ?? match[2] ?? match[3] ?? "" : ""; - inTable = id !== "" && isCrossheadPluginId(id); - if (inTable && result === null) result = true; // Codex treats a listed plugin as enabled unless disabled. + const header = /^\[([^\]]*)\]$/.exec(line); + const key = header ? parseTomlKey(header[1]!) : null; + table = key ?? ["\u0000invalid"]; + if (key) entries.tables.push(key); continue; } - if (!inTable) continue; - const enabled = /^enabled\s*=\s*(true|false)$/.exec(line); - if (enabled) result = enabled[1] === "true"; + // Join a value that spans lines (multi-line arrays or inline tables). + while (bracketBalance(line) > 0 && index + 1 < lines.length) { + line = `${line} ${stripTomlComment(lines[++index]!).trim()}`; + } + collectTomlAssignment(table, line, entries); } + return entries; +}; + +const keyEquals = (left: string[], right: string[]): boolean => left.length === right.length && left.every((part, index) => part === right[index]); + +const tomlValue = (entries: TomlEntries, key: string[]): TomlScalar | undefined => { + let result: TomlScalar | undefined; + for (const entry of entries.values) if (keyEquals(entry.key, key)) result = entry.value; return result; }; -export const codexPluginConfigFiles = (hostHome: string, workspaceRoot: string): string[] => [ - path.join(workspaceRoot, ".codex", "config.toml"), - path.join(hostHome, "config.toml"), -]; +/** + * Reads `[plugins."crosshead@crosshead"] enabled = true|false` from a Codex + * config.toml. A listed plugin is enabled unless `enabled = false`. Returns null + * when the file does not mention the plugin. + */ +export const readCodexPluginEnabled = (content: string): boolean | null => { + const entries = parseTomlEntries(content); + const key = ["plugins", CROSSHEAD_PLUGIN_ID]; + const enabled = tomlValue(entries, [...key, "enabled"]); + if (typeof enabled === "boolean") return enabled; + const mentioned = + entries.tables.some((table) => keyEquals(table, key)) || + entries.values.some((entry) => entry.key.length > 2 && keyEquals(entry.key.slice(0, 2), key)); + return mentioned ? true : null; +}; + +/** True when the user config marks `dir` as a trusted project (`[projects.""] trust_level = "trusted"`). */ +export const readCodexProjectTrusted = (content: string, dir: string): boolean => + tomlValue(parseTomlEntries(content), ["projects", path.resolve(dir), "trust_level"]) === "trusted"; -export const detectCodexPlugin = (hostHome: string, workspaceRoot: string = process.cwd()): PluginDetection => { - for (const filePath of codexPluginConfigFiles(hostHome, workspaceRoot)) { - if (!existsSync(filePath)) continue; - let content: string; - try { - content = readFileSync(filePath, "utf8"); - } catch { +const readText = (filePath: string): string | null => { + try { + return existsSync(filePath) ? readFileSync(filePath, "utf8") : null; + } catch { + return null; + } +}; + +/** Project-level Codex config files for a workspace: the workspace and its git root. */ +export const codexProjectConfigFiles = (workspaceRoot: string): Array<{ dir: string; file: string }> => { + const workspace = path.resolve(workspaceRoot); + const gitRoot = findGitRoot(workspace); + const dirs = [workspace, ...(gitRoot && gitRoot !== workspace ? [gitRoot] : [])]; + return dirs.map((dir) => ({ dir, file: path.join(dir, ".codex", "config.toml") })); +}; + +/** + * Codex plugin state for a workspace. The user config (`$CODEX_HOME/config.toml`) + * applies everywhere. A project's `.codex/config.toml` applies only when the user + * config trusts that project; otherwise Codex ignores it. + */ +export const detectCodexPlugin = (codexHome: string, workspaceRoot: string = process.cwd()): PluginDetection => { + const userFile = path.join(path.resolve(codexHome), "config.toml"); + const userContent = readText(userFile); + const userValue = userContent === null ? null : readCodexPluginEnabled(userContent); + const enabledAtUserScope = userValue === true; + let ignored: string | undefined; + for (const { dir, file } of codexProjectConfigFiles(workspaceRoot)) { + if (file === userFile) continue; + const content = readText(file); + if (content === null) continue; + const value = readCodexPluginEnabled(content); + if (value === null) continue; + const trusted = userContent !== null && readCodexProjectTrusted(userContent, dir); + if (!trusted) { + ignored = `${file} is ignored until Codex trusts ${dir}`; continue; } - const enabled = readCodexPluginEnabled(content); - if (enabled !== null) return { enabled, source: filePath }; + return { enabled: value, source: file, scope: "project", enabledAtUserScope }; } - return { enabled: false, source: null }; + const extra = ignored ? { ignored } : {}; + if (userValue !== null) return { enabled: userValue, source: userFile, scope: "user", enabledAtUserScope, ...extra }; + return { enabled: false, source: null, scope: null, enabledAtUserScope, ...extra }; }; -export const detectHostPlugin = (host: HookHost, hostHome: string, workspaceRoot: string): PluginDetection => - host === "codex" ? detectCodexPlugin(hostHome, workspaceRoot) : detectClaudeCodePlugin(hostHome, workspaceRoot); +// ---- Hook mode selection ---------------------------------------------------- + +export type HookModeRequest = HostHookMode | "auto"; -/** `--hook-mode plugin|settings|auto` (default auto: plugin when the host has it enabled). */ +export type HookModeResolution = { + mode: HostHookMode; + detection: PluginDetection; + /** The explicit request this install honored, kept so later reinstalls (for example `crosshead update`) repeat it. */ + request: HostHookMode | null; + /** False when plugin hooks were requested but cannot be used globally: write no installer hooks either. */ + writeLegacyHooks: boolean; + note: string | null; +}; + +/** + * `--hook-mode plugin|settings|auto` (default auto, or the explicit request of a + * previous install). Plugin mode needs the plugin enabled at user scope: it + * removes the installer hooks, which can cover every project. When plugin hooks + * are requested but the plugin is enabled only for some projects (or not yet), + * the mode stays "settings", existing installer hooks are kept, no new ones are + * written, and the plugin runs wherever no installer hooks load. + */ export const resolveHostHookMode = ( requested: string | boolean | undefined, - detect: () => PluginDetection -): { mode: HostHookMode; detection: PluginDetection | null } => { - if (requested === undefined || requested === "auto") { - const detection = detect(); - return { mode: detection.enabled ? "plugin" : "settings", detection }; + detect: () => PluginDetection, + previousRequest?: HostHookMode | null +): HookModeResolution => { + const request = parseHookModeFlag(requested) ?? previousRequest ?? "auto"; + const detection = detect(); + if (request === "settings") return { mode: "settings", detection, request: "settings", writeLegacyHooks: true, note: null }; + if (detection.enabledAtUserScope) { + return { mode: "plugin", detection, request: request === "plugin" ? "plugin" : null, writeLegacyHooks: false, note: null }; } - if (requested === "plugin" || requested === "settings") return { mode: requested, detection: null }; - throw new Error("--hook-mode must be one of: auto, plugin, settings"); + if (request === "auto") return { mode: "settings", detection, request: null, writeLegacyHooks: true, note: null }; + const where = detection.enabled && detection.source ? `enabled only in ${detection.source}` : "not enabled at user scope"; + return { + mode: "settings", + detection, + request: "plugin", + writeLegacyHooks: false, + note: `the Crosshead plugin is ${where}; existing installer hooks are kept, no new ones are written, and the plugin runs where none load. Enable the plugin at user scope and rerun to migrate.`, + }; }; -export const parseHookModeFlag = (value: string | boolean | undefined): HostHookMode | "auto" | undefined => { +export const parseHookModeFlag = (value: string | boolean | undefined): HookModeRequest | undefined => { if (value === undefined) return undefined; if (value === "auto" || value === "plugin" || value === "settings") return value; throw new Error("--hook-mode must be one of: auto, plugin, settings"); @@ -120,53 +401,128 @@ export const describeHookMode = (mode: HostHookMode, detection: PluginDetection export const normalizeHostHookMode = (value: unknown): HostHookMode => (value === "plugin" ? "plugin" : "settings"); -/** Hook mode recorded in a Claude Code or Codex adapter config file (marker line, then JSON). */ -export const readAdapterConfigHookMode = (configFile: string): HostHookMode => { +export const normalizeHookModeRequest = (value: unknown): HostHookMode | null => + value === "plugin" || value === "settings" ? value : null; + +/** + * True when a Claude Code or Codex adapter config file (marker line, then JSON) + * relies on plugin hooks: plugin mode, or plugin hooks requested where the plugin + * is not enabled at user scope (no installer hooks are written then). + */ +export const adapterConfigUsesPluginHooks = (configFile: string): boolean => { try { const content = readFileSync(configFile, "utf8"); - const parsed = JSON.parse(content.slice(content.indexOf("{"))) as { hookMode?: unknown }; - return normalizeHostHookMode(parsed.hookMode); + const parsed = JSON.parse(content.slice(content.indexOf("{"))) as { hookMode?: unknown; hookModeRequest?: unknown }; + return normalizeHostHookMode(parsed.hookMode) === "plugin" || normalizeHookModeRequest(parsed.hookModeRequest) === "plugin"; } catch { - return "settings"; + return false; } }; +// ---- Dispatch ----------------------------------------------------------------- + export const isPluginHookInvocation = (flags: Record): boolean => flags[PLUGIN_HOOK_VIA_FLAG[0].slice(2)] === PLUGIN_HOOK_VIA_FLAG[1]; export type HookDispatchDecision = "run" | "skip-duplicate"; /** - * Exactly one registration handles each event: - * - plugin mode: the plugin runs; any leftover legacy entry is ignored. - * - settings mode: legacy entries run; the plugin runs only where no legacy - * entry covers this workspace (for example a project the installer never saw). + * Exactly one registration handles each event. `legacyHooksPresent` answers + * whether the host loaded installer-written Crosshead hooks for this session. + * - Installer hooks loaded: they run and the plugin defers, in either mode. + * This covers projects the migration never saw and, for Codex, the window + * before the user trusts the plugin hooks. + * - No installer hooks loaded: the plugin runs. In plugin mode a stale + * installer invocation (for example from a session that started before the + * migration) is skipped; in settings mode it still runs. */ export const decideHookDispatch = (input: { viaPlugin: boolean; mode: HostHookMode; legacyHooksPresent: () => boolean; }): HookDispatchDecision => { - if (input.mode === "plugin") return input.viaPlugin ? "run" : "skip-duplicate"; - if (!input.viaPlugin) return "run"; - return input.legacyHooksPresent() ? "skip-duplicate" : "run"; + if (input.viaPlugin) return input.legacyHooksPresent() ? "skip-duplicate" : "run"; + if (input.mode === "settings") return "run"; + return input.legacyHooksPresent() ? "run" : "skip-duplicate"; }; -/** Settings files Claude Code may load project hooks from for a session in `workspaceRoot`. */ -export const claudeProjectSettingsCandidates = (workspaceRoot: string): string[] => { - const candidates: string[] = []; - let current = path.resolve(workspaceRoot); - for (;;) { - candidates.push(path.join(current, ".claude", "settings.json")); - const parent = path.dirname(current); - if (parent === current) return candidates; - current = parent; +/** + * Counts hook handlers whose command `isCrosshead` recognizes. Tolerates any + * shape: non-object hooks, non-array events, null or non-object groups and + * handlers are ignored, so empty arrays never count. + */ +export const countMatchingHookCommands = (settings: unknown, isCrosshead: (command: string) => boolean): number => { + if (!settings || typeof settings !== "object" || Array.isArray(settings)) return 0; + const hooks = (settings as { hooks?: unknown }).hooks; + if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return 0; + let count = 0; + for (const groups of Object.values(hooks as Record)) { + if (!Array.isArray(groups)) continue; + for (const group of groups) { + if (!group || typeof group !== "object" || Array.isArray(group)) continue; + const handlers = (group as { hooks?: unknown }).hooks; + if (!Array.isArray(handlers)) continue; + for (const handler of handlers) { + const command = handler && typeof handler === "object" ? (handler as { command?: unknown }).command : undefined; + if (typeof command === "string" && isCrosshead(command)) count++; + } + } } + return count; }; -/** One-line JSON both hosts accept on SessionStart: a user-visible notice plus model context. */ -export const writePluginSetupNotice = (hookEventName: "SessionStart", message: string): void => { - process.stdout.write( - `${JSON.stringify({ systemMessage: message, hookSpecificOutput: { hookEventName, additionalContext: message } })}\n` - ); +/** Reads a JSON settings file for inspection; missing or invalid files read as null. */ +export const readSettingsForInspection = (filePath: string): unknown => (existsSync(filePath) ? readJson(filePath) : null); + +// ---- Plugin invocation receipts ----------------------------------------------- + +export type PluginInvocationRecord = { lastSeenAt: string; pluginVersion: string | null }; + +const invocationFile = (runtimeHome: string): string => path.join(path.resolve(runtimeHome), "state", "host-plugin-invocations.json"); + +export const readPluginInvocation = (runtimeHome: string, host: HookHost): PluginInvocationRecord | null => { + const records = parseJsonFile | null>(invocationFile(runtimeHome), null); + const record = records?.[host] as Partial | undefined; + return record && typeof record.lastSeenAt === "string" + ? { lastSeenAt: record.lastSeenAt, pluginVersion: typeof record.pluginVersion === "string" ? record.pluginVersion : null } + : null; +}; + +/** + * Records that the host ran a plugin hook (for Codex, proof that the user trusted + * the plugin hooks). Rewritten at most every 10 minutes per host, or when the + * plugin version changes. + */ +export const recordPluginInvocation = (runtimeHome: string, host: HookHost, pluginVersion: string | null, now: Date = new Date()): void => { + const previous = readPluginInvocation(runtimeHome, host); + if (previous && previous.pluginVersion === pluginVersion && now.getTime() - Date.parse(previous.lastSeenAt) < 10 * 60_000) return; + ensureRuntimeHomeLayout(runtimeHome); + const file = invocationFile(runtimeHome); + const records = parseJsonFile | null>(file, null) ?? {}; + writeJsonFile(file, { ...records, [host]: { lastSeenAt: now.toISOString(), pluginVersion } }); +}; + +/** + * Host config directory for a plugin invocation, which carries no + * `--claude-home`/`--codex-home`: the host's own environment variable when set, + * otherwise the directory the adapter was installed into (so `--claude-home` + * and `--codex-home` installs work), otherwise the default. + */ +export const resolvePluginHostHome = (host: HookHost, runtimeHome: string, env: NodeJS.ProcessEnv = process.env): string | undefined => { + const fromEnv = host === "codex" ? nonEmpty(env.CODEX_HOME) : nonEmpty(env.CLAUDE_CONFIG_DIR) ?? nonEmpty(env.CLAUDE_CODE_HOME); + if (fromEnv) return path.resolve(fromEnv); + try { + return loadAdapterRecord(resolveRuntimePaths(runtimeHome), host)?.hostHome ?? undefined; + } catch { + return undefined; + } +}; + +/** The Codex home whose hooks.json the host loaded, as the plugin launcher saw it before the wrapper's environment. */ +export const codexLoadedHome = (fallback: string, env: NodeJS.ProcessEnv = process.env): string => + path.resolve(nonEmpty(env.CROSSHEAD_HOST_CODEX_HOME) ?? fallback); + +/** One-line SessionStart JSON both hosts accept: a notice for the user only, with no model context. */ +export const writePluginSetupNotice = (message: string): void => { + process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`); }; diff --git a/src/runtime/runtime-env.ts b/src/runtime/runtime-env.ts index bd87926..93aaa7d 100644 --- a/src/runtime/runtime-env.ts +++ b/src/runtime/runtime-env.ts @@ -15,14 +15,18 @@ export const resolveDefaultRuntimeHome = (userHome: string): string => { return existsSync(current) ? current : existsSync(legacy) ? legacy : current; }; +// An empty environment variable counts as unset, as it does for the installer +// and the plugin launcher (`${CROSSHEAD_HOME:-...}`). +const envValue = (name: string): string | undefined => process.env[name] || undefined; + export const resolveCrossheadHome = (home?: string): string => - path.resolve(home ?? process.env.CROSSHEAD_HOME ?? process.env.CROSSHEAD_RUNTIME_HOME ?? process.env.ONLYBOTS_HOME ?? process.env.ONLYBOTS_RUNTIME_HOME ?? resolveDefaultRuntimeHome(os.homedir())); + path.resolve(home ?? envValue("CROSSHEAD_HOME") ?? envValue("CROSSHEAD_RUNTIME_HOME") ?? envValue("ONLYBOTS_HOME") ?? envValue("ONLYBOTS_RUNTIME_HOME") ?? resolveDefaultRuntimeHome(os.homedir())); export const resolveCodexHostHome = (hostHome?: string): string => - path.resolve(hostHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex")); + path.resolve(hostHome ?? envValue("CODEX_HOME") ?? path.join(os.homedir(), ".codex")); export const resolveClaudeCodeHostHome = (hostHome?: string): string => - path.resolve(hostHome ?? (process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CODE_HOME) ?? path.join(os.homedir(), ".claude")); + path.resolve(hostHome ?? envValue("CLAUDE_CONFIG_DIR") ?? envValue("CLAUDE_CODE_HOME") ?? path.join(os.homedir(), ".claude")); export const resolveClaudeCodeBinaryPath = (binary?: string): string => binary ?? process.env.CLAUDE_CODE_BIN ?? "claude"; diff --git a/src/runtime/runtime-status.ts b/src/runtime/runtime-status.ts index d550ccc..b4ec531 100644 --- a/src/runtime/runtime-status.ts +++ b/src/runtime/runtime-status.ts @@ -53,7 +53,7 @@ import type { RuntimeDoctorReport, RuntimeStatus, } from "./local-runtime"; -import { readAdapterConfigHookMode } from "./host-plugin-mode"; +import { adapterConfigUsesPluginHooks } from "./host-plugin-mode"; const isSessionFresh = (session: HostedSession | null): boolean => { if (!session) { @@ -117,7 +117,7 @@ export const evaluateAdapterAutomation = (adapter: RuntimeAdapterRecord): Runtim const basename = (name: string) => (filePath: string): boolean => path.basename(filePath) === name; // In plugin mode the host plugin supplies the hooks; the _hooks doctor check verifies it. const pluginMode = (configName: string): boolean => - [...managedFiles].some((filePath) => path.basename(filePath) === configName && readAdapterConfigHookMode(filePath) === "plugin"); + [...managedFiles].some((filePath) => path.basename(filePath) === configName && adapterConfigUsesPluginHooks(filePath)); if (adapter.name === "codex") { const requirements = [ diff --git a/tests/runtime/host-plugins.test.ts b/tests/runtime/host-plugins.test.ts index ebd3bfd..17e8feb 100644 --- a/tests/runtime/host-plugins.test.ts +++ b/tests/runtime/host-plugins.test.ts @@ -1,24 +1,53 @@ import { execFileSync, spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { installClaudeCodeAdapter, loadClaudeCodeAdapterConfig, uninstallClaudeCodeAdapter } from "../../src/runtime/claude-code-adapter"; +import { + countLegacyClaudeHookCommands, + findLegacyClaudeHookFiles, + installClaudeCodeAdapter, + loadClaudeCodeAdapterConfig, + uninstallClaudeCodeAdapter, +} from "../../src/runtime/claude-code-adapter"; import { runOnlyBotsClaudeCodeHookCli } from "../../src/runtime/claude-code-hook"; -import { installCodexAdapter, loadCodexAdapterConfig } from "../../src/runtime/codex-adapter"; +import { ensureQuickstartDaemon } from "../../src/runtime/cli/quickstart"; +import { countLegacyCodexHookCommands, installCodexAdapter, loadCodexAdapterConfig, uninstallCodexAdapter } from "../../src/runtime/codex-adapter"; import { runOnlyBotsCodexHookCli } from "../../src/runtime/codex-hook"; +import { readRuntimeDaemonHealth } from "../../src/runtime/daemon-ownership"; import { evaluateHostHookInspection, inspectHostHookRegistrations } from "../../src/runtime/host-hook-doctor"; import * as spec from "../../src/runtime/host-hook-spec"; +import { startDetachedRuntimeDaemon, stopDetachedRuntimeDaemon } from "../../src/runtime/local-runtime"; +import { ensureRuntimeHomeLayout, loadDaemonState } from "../../src/runtime/runtime-persistence"; import { evaluateAdapterAutomation } from "../../src/runtime/runtime-status"; -import { decideHookDispatch, detectClaudeCodePlugin, readCodexPluginEnabled } from "../../src/runtime/host-plugin-mode"; +import { + claudeLoadedSettingsFiles, + countMatchingHookCommands, + decideHookDispatch, + detectClaudeCodePlugin, + detectCodexPlugin, + readCodexPluginEnabled, + readPluginInvocation, + recordPluginInvocation, +} from "../../src/runtime/host-plugin-mode"; // @ts-expect-error -- plain ESM generator script without type declarations import { renderHostPluginFiles } from "../../scripts/host-plugins.mjs"; const repoRoot = path.resolve(__dirname, "../.."); const dirs: string[] = []; const savedEnv = { ...process.env }; +// Host variables from the shell running the suite (for example inside a Claude Code +// session) must not leak into the fixtures. +const hostVariables = ["CLAUDE_PROJECT_DIR", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_HOME", "CODEX_HOME", "CROSSHEAD_HOST_CODEX_HOME"]; +beforeEach(() => { + for (const name of hostVariables) delete process.env[name]; + // Default host paths (~/.claude, ~/.codex, ~/.crosshead) resolve inside a scratch home, + // never the real one. + process.env.HOME = tempDir("home"); +}); afterEach(() => { dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })); process.env = { ...savedEnv }; @@ -46,6 +75,10 @@ const enableClaudePlugin = (claudeHome: string, enabled = true) => writeFileSync(path.join(claudeHome, "settings.json"), JSON.stringify({ enabledPlugins: { "crosshead@crosshead": enabled } })); const enableCodexPlugin = (codexHome: string, enabled = true) => writeFileSync(path.join(codexHome, "config.toml"), `model = "x"\n\n[plugins."crosshead@crosshead"]\nenabled = ${enabled}\n`); +const writeJson = (filePath: string, value: unknown) => { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, JSON.stringify(value)); +}; const managedCommands = (hooks: Record }>> | undefined, marker: string) => Object.values(hooks ?? {}).flatMap((groups) => groups.flatMap((group) => group.hooks.map((hook) => hook.command))).filter((command) => command.includes(marker)); @@ -63,12 +96,32 @@ describe("host plugin files", () => { "plugins/codex/hooks/hooks.json", "plugins/codex/scripts/crosshead-hook", "plugins/codex/skills/setup/SKILL.md", + "plugins/codex/skills/setup/agents/openai.yaml", ]); for (const [relative, content] of Object.entries(files)) { expect(readFileSync(path.join(repoRoot, relative), "utf8"), `${relative} is stale; run npm run plugins:generate`).toBe(content); } }); + // Hosts pin an installed plugin to its manifest version (Codex also re-asks for + // hook trust when a version changes). Changing generated plugin content without + // raising CROSSHEAD_HOST_PLUGIN_VERSION would ship different files under an + // already-installed version. Pin each version's content here; add a new entry, + // never edit an old one. 1.0.0 is unreleased while PR #14 is open. + const PLUGIN_CONTENT_SHA256: Record = { + "1.0.0": "1c6295e2195529599233fae8d5a46b5671ef18998beaf39652e7bc6d955dc4d4", + }; + it("generated plugin content is pinned to CROSSHEAD_HOST_PLUGIN_VERSION", () => { + const files = renderHostPluginFiles(spec) as Record; + const hash = createHash("sha256"); + for (const relative of Object.keys(files).filter((name) => name.startsWith("plugins/")).sort()) hash.update(`${relative}\0${files[relative]}\0`); + const digest = hash.digest("hex"); + expect( + PLUGIN_CONTENT_SHA256[spec.CROSSHEAD_HOST_PLUGIN_VERSION], + `plugin content changed (sha256 ${digest}): raise CROSSHEAD_HOST_PLUGIN_VERSION and pin the new digest` + ).toBe(digest); + }); + it("plugin hooks cover exactly the events the legacy installer registers", () => { for (const host of ["claude-code", "codex"] as const) { const hooks = JSON.parse(spec.renderPluginHooksFile(host)).hooks as Record }>>; @@ -89,6 +142,17 @@ describe("host plugin files", () => { expect(skill).toContain(`--adapter ${host} --workspace-root . --hook-mode plugin`); expect(skill).toContain("ask for explicit consent"); } + // The setup skill is user-invoked only. + expect(files["plugins/claude-code/skills/setup/SKILL.md"]).toMatch(/^---\n[^]*\ndisable-model-invocation: true\n---\n/); + expect(files["plugins/codex/skills/setup/agents/openai.yaml"]).toBe("policy:\n allow_implicit_invocation: false\n"); + // Notices are for the user: no model context, and no instruction aimed at the model. + for (const host of ["claude-code", "codex"] as const) { + for (const hint of [spec.pluginSetupHint(host), spec.pluginUpdateHint(host)]) { + expect(hint).toMatch(/To (set it up|update it), (run \/crosshead:setup|type \$crosshead:setup)\.$/); + expect(hint).not.toMatch(/ask (Codex|Claude)|use the .* skill/); + } + } + expect(files["plugins/codex/scripts/crosshead-hook"]).not.toContain("additionalContext"); const launcherCode = files["plugins/codex/scripts/crosshead-hook"]!.split("\n").filter((line) => !line.trimStart().startsWith("#")); expect(launcherCode.join("\n")).not.toMatch(/curl|wget|npm |npx |node /); }); @@ -100,9 +164,11 @@ describe("legacy hook rendering", () => { await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, hookMode: "settings", ...scripts }); const hookWrapper = path.join(f.home, "bin", "crosshead-claude-code-hook"); const command = (event: string) => `'${hookWrapper}' '${event}' --home '${f.home}' --claude-home '${f.claudeHome}'`; + // The layout is unchanged except that SessionStart also matches `clear` (both + // hosts start a fresh context after /clear, like startup), changed deliberately. expect(readJson(path.join(f.workspace, ".claude", "settings.json"))).toEqual({ hooks: { - SessionStart: [{ matcher: "startup|resume|compact", hooks: [{ type: "command", command: command("SessionStart") }] }], + SessionStart: [{ matcher: "startup|resume|clear|compact", hooks: [{ type: "command", command: command("SessionStart") }] }], PreCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PreCompact") }] }], PostCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PostCompact") }] }], UserPromptSubmit: [{ hooks: [{ type: "command", command: command("UserPromptSubmit") }] }], @@ -118,7 +184,7 @@ describe("legacy hook rendering", () => { const command = (event: string) => `'${hookWrapper}' '${event}' --home '${f.home}' --codex-home '${f.codexHome}'`; expect(readJson(path.join(f.codexHome, "hooks.json"))).toEqual({ hooks: { - SessionStart: [{ matcher: "startup|resume|compact", hooks: [{ type: "command", command: command("SessionStart"), statusMessage: "Crosshead session sync" }] }], + SessionStart: [{ matcher: "startup|resume|clear|compact", hooks: [{ type: "command", command: command("SessionStart"), statusMessage: "Crosshead session sync" }] }], PreCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PreCompact"), statusMessage: "Crosshead pre-compact checkpoint", timeout: 30 }] }], PostCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PostCompact"), statusMessage: "Crosshead post-compact sync", timeout: 30 }] }], UserPromptSubmit: [{ hooks: [{ type: "command", command: command("UserPromptSubmit"), statusMessage: "Crosshead context", timeout: 45 }] }], @@ -128,11 +194,17 @@ describe("legacy hook rendering", () => { }); }); + +const install = (f: ReturnType, extra: Partial[0]> = {}) => + installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts, ...extra }); +const installCodex = (f: ReturnType, extra: Partial[0]> = {}) => + installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, workspaceRoot: f.workspace, ...scripts, ...extra }); + describe("plugin mode install and migration", () => { - it("Claude Code: detects the enabled plugin, skips hook writing and removes legacy hooks idempotently", async () => { + it("Claude Code: detects the plugin at user scope, skips hook writing and removes legacy hooks idempotently", async () => { const f = fixture("claude-migrate"); const settings = path.join(f.workspace, ".claude", "settings.json"); - await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); + await install(f); const legacy = readJson(settings); legacy.hooks.Stop.push({ hooks: [{ type: "command", command: "echo unrelated" }] }); legacy.model = "keep-me"; @@ -140,7 +212,7 @@ describe("plugin mode install and migration", () => { expect(loadClaudeCodeAdapterConfig(f.home, f.claudeHome).hookMode).toBe("settings"); enableClaudePlugin(f.claudeHome); - const first = await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); + const first = await install(f); expect(first.config.hookMode).toBe("plugin"); expect(first.pluginDetection?.source).toBe(path.join(f.claudeHome, "settings.json")); expect(first.migratedFiles).toEqual([settings]); @@ -150,138 +222,415 @@ describe("plugin mode install and migration", () => { expect(migrated.model).toBe("keep-me"); expect(first.managedFiles).not.toContain(settings); - const second = await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); + const second = await install(f); expect(second.migratedFiles).toEqual([]); expect(readJson(settings)).toEqual(migrated); }); - it("Claude Code: removes a settings file that only held Crosshead hooks; explicit --hook-mode wins over detection", async () => { - const f = fixture("claude-explicit"); + it("Claude Code: never deletes a settings file; it is left as {} (also on uninstall)", async () => { + const f = fixture("claude-keep-file"); const settings = path.join(f.workspace, ".claude", "settings.json"); - await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); - const result = await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, hookMode: "plugin", ...scripts }); - expect(result.pluginDetection).toBeNull(); - expect(result.migratedFiles).toEqual([settings]); - expect(existsSync(settings)).toBe(false); - + await install(f); enableClaudePlugin(f.claudeHome); - const forced = await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, hookMode: "settings", ...scripts }); + expect((await install(f)).migratedFiles).toEqual([settings]); + expect(readJson(settings)).toEqual({}); + + const forced = await install(f, { hookMode: "settings" }); expect(forced.config.hookMode).toBe("settings"); expect(managedCommands(readJson(settings).hooks, "crosshead-claude-code-hook")).toHaveLength(5); + await uninstallClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome }); + expect(readJson(settings)).toEqual({}); + }); + + it("Claude Code: project-scope enablement keeps settings mode and never removes the global hooks", async () => { + const f = fixture("claude-project-scope"); + // The default install: workspace = the user's home, so the hooks land in the user settings file. + const userHome = path.join(f.root, "user"); + const claudeHome = path.join(userHome, ".claude"); + const userSettings = path.join(claudeHome, "settings.json"); + mkdirSync(claudeHome, { recursive: true }); + await install(f, { hostHome: claudeHome, workspaceRoot: userHome }); + expect(countLegacyClaudeHookCommands(userSettings, f.home)).toBe(5); + writeJson(path.join(f.workspace, ".claude", "settings.json"), { enabledPlugins: { "crosshead@crosshead": true } }); + + for (const hookMode of [undefined, "plugin"] as const) { + const result = await install(f, { hostHome: claudeHome, workspaceRoot: userHome, hookMode }); + expect(result.config.hookMode).toBe("settings"); + expect(result.migratedFiles).toEqual([]); + expect(countLegacyClaudeHookCommands(userSettings, f.home)).toBe(5); + } + const explicit = await install(f, { hostHome: claudeHome, workspaceRoot: f.workspace, hookMode: "plugin" }); + expect(explicit.config.hookMode).toBe("settings"); + expect(explicit.hookModeNote).toContain("user scope"); + expect(existsSync(path.join(f.workspace, ".claude", "settings.json")) && countLegacyClaudeHookCommands(path.join(f.workspace, ".claude", "settings.json"), f.home)).toBe(0); + // A later reinstall without the flag (for example `crosshead update`) repeats the request. + expect(loadClaudeCodeAdapterConfig(f.home, claudeHome).hookModeRequest).toBe("plugin"); + + const [check] = inspectHostHookRegistrations(f.home, f.workspace); + expect(check).toMatchObject({ id: "claude_code_hooks", ok: true }); + expect(check!.message).toContain("this project only"); + expect(check!.message).toContain("each event is captured once"); + }); + + it("Claude Code: refuses a symlinked settings file before writing anything", async () => { + const f = fixture("claude-symlink"); + const other = path.join(f.root, "other"); + await install(f, { workspaceRoot: other }); + const otherSettings = path.join(other, ".claude", "settings.json"); + const real = path.join(f.root, "dotfiles-settings.json"); + writeFileSync(real, readFileSync(otherSettings)); + rmSync(otherSettings); + symlinkSync(real, otherSettings); + // Legacy hooks in the new workspace as well, so a partial migration would be visible. + await install(f, { hookMode: "settings" }); + const configPath = path.join(f.claudeHome, "onlybots", "claude-code-adapter.json"); + const workspaceSettings = path.join(f.workspace, ".claude", "settings.json"); + // The previous workspace is recorded as `other` only in the config written by the first install. + const config = readFileSync(configPath, "utf8"); + writeFileSync(configPath, config.replace(JSON.stringify(f.workspace), JSON.stringify(other))); + const before = { config: readFileSync(configPath, "utf8"), workspace: readFileSync(workspaceSettings, "utf8"), real: readFileSync(real, "utf8") }; + enableClaudePlugin(f.claudeHome); + await expect(install(f, { hookMode: "plugin" })).rejects.toThrow(/Refusing to migrate Crosshead hooks in .*other.*No files were changed/); + expect(readFileSync(configPath, "utf8")).toBe(before.config); + expect(readFileSync(workspaceSettings, "utf8")).toBe(before.workspace); + expect(readFileSync(real, "utf8")).toBe(before.real); }); - it("Codex: detects the enabled plugin in config.toml and migrates hooks.json", async () => { + it("Codex: keeps legacy hooks until a plugin invocation is seen, then migrates and keeps unknown keys", async () => { const f = fixture("codex-migrate"); const hooksFile = path.join(f.codexHome, "hooks.json"); - await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, ...scripts }); + await installCodex(f); const legacy = readJson(hooksFile); legacy.hooks.PreToolUse = [{ matcher: "Bash", hooks: [{ type: "command", command: "echo unrelated" }] }]; + legacy.$schema = "keep-me"; writeFileSync(hooksFile, JSON.stringify(legacy)); enableCodexPlugin(f.codexHome); - const result = await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, ...scripts }); - expect(result.config.hookMode).toBe("plugin"); + const pending = await installCodex(f); + expect(pending.config.hookMode).toBe("plugin"); + expect(pending.migratedFiles).toEqual([]); + expect(pending.hookModeNote).toContain("/hooks"); + expect(countLegacyCodexHookCommands(hooksFile, f.home)).toBe(5); + + recordPluginInvocation(f.home, "codex", spec.CROSSHEAD_HOST_PLUGIN_VERSION); + const result = await installCodex(f); expect(result.migratedFiles).toEqual([hooksFile]); - expect(readJson(hooksFile)).toEqual({ hooks: { PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo unrelated" }] }] } }); + expect(readJson(hooksFile)).toEqual({ $schema: "keep-me", hooks: { PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo unrelated" }] }] } }); expect(loadCodexAdapterConfig(f.home, f.codexHome).hookMode).toBe("plugin"); - - const again = await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, ...scripts }); - expect(again.migratedFiles).toEqual([]); + expect((await installCodex(f)).migratedFiles).toEqual([]); + await uninstallCodexAdapter({ home: f.home, hostHome: f.codexHome }); + expect(readJson(hooksFile).$schema).toBe("keep-me"); }); - it("Codex: a disabled plugin keeps settings mode", async () => { + it("Codex: a disabled plugin, or one enabled only in project config, keeps settings mode", async () => { const f = fixture("codex-disabled"); enableCodexPlugin(f.codexHome, false); - const result = await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, ...scripts }); + const result = await installCodex(f); expect(result.config.hookMode).toBe("settings"); expect(managedCommands(readJson(path.join(f.codexHome, "hooks.json")).hooks, "crosshead-codex-hook")).toHaveLength(5); + + writeFileSync(path.join(f.codexHome, "config.toml"), `[projects."${f.workspace}"]\ntrust_level = "trusted"\n`); + mkdirSync(path.join(f.workspace, ".codex")); + writeFileSync(path.join(f.workspace, ".codex", "config.toml"), '[plugins."crosshead@crosshead"]\nenabled = true\n'); + const project = await installCodex(f, { hookMode: "plugin" }); + expect(project.pluginDetection).toMatchObject({ enabled: true, scope: "project", enabledAtUserScope: false }); + expect(project.config.hookMode).toBe("settings"); + expect(project.migratedFiles).toEqual([]); + expect(countLegacyCodexHookCommands(path.join(f.codexHome, "hooks.json"), f.home)).toBe(5); + }); + + it("Codex: refuses a symlinked hooks.json before writing anything", async () => { + const f = fixture("codex-symlink"); + const hooksFile = path.join(f.codexHome, "hooks.json"); + await installCodex(f); + const real = path.join(f.root, "hooks-real.json"); + writeFileSync(real, readFileSync(hooksFile)); + rmSync(hooksFile); + symlinkSync(real, hooksFile); + const configPath = path.join(f.codexHome, "onlybots", "codex-adapter.json"); + const before = readFileSync(configPath, "utf8"); + enableCodexPlugin(f.codexHome); + recordPluginInvocation(f.home, "codex", null); + await expect(installCodex(f)).rejects.toThrow("regular file"); + expect(readFileSync(configPath, "utf8")).toBe(before); + expect(countLegacyCodexHookCommands(real, f.home)).toBe(5); }); }); describe("plugin detection", () => { it("reads Codex plugin tables without a TOML dependency", () => { expect(readCodexPluginEnabled('[plugins."crosshead@crosshead"]\nenabled = true\n')).toBe(true); - expect(readCodexPluginEnabled("[plugins.'crosshead@local']\nenabled = false # off\n")).toBe(false); + expect(readCodexPluginEnabled("[plugins.'crosshead@crosshead']\nenabled = false # off\n")).toBe(false); + expect(readCodexPluginEnabled('[plugins."crosshead@crosshead"]\nenabled = false#c\n')).toBe(false); + expect(readCodexPluginEnabled('[plugins."crosshead@crosshead"] # comment\nenabled = true\n')).toBe(true); expect(readCodexPluginEnabled('[plugins."crosshead@crosshead"]\n')).toBe(true); + expect(readCodexPluginEnabled('[plugins]\n"crosshead@crosshead" = { enabled = false }\n')).toBe(false); + expect(readCodexPluginEnabled('plugins."crosshead@crosshead".enabled = false\n')).toBe(false); + expect(readCodexPluginEnabled('notes = """\n[plugins."crosshead@crosshead"]\n"""\n')).toBeNull(); + expect(readCodexPluginEnabled('list = [\n "a",\n]\n[plugins."crosshead@crosshead"]\nenabled = false\n')).toBe(false); + expect(readCodexPluginEnabled('x = "#"\n[plugins."crosshead@crosshead"]\nenabled = false\n')).toBe(false); + // Only this repository's marketplace counts. + expect(readCodexPluginEnabled("[plugins.'crosshead@local']\nenabled = true\n")).toBeNull(); expect(readCodexPluginEnabled('[plugins."other@x"]\nenabled = true\n[features]\nenabled = true\n')).toBeNull(); - expect(readCodexPluginEnabled('[plugins."crosshead-extra@x"]\nenabled = true\n')).toBeNull(); + expect(readCodexPluginEnabled('[plugins."crosshead-extra@crosshead"]\nenabled = true\n')).toBeNull(); }); - it("applies Claude Code settings precedence (local over project over user)", () => { + it("Codex ignores a project config until the project is trusted", () => { + const f = fixture("codex-trust"); + mkdirSync(path.join(f.workspace, ".codex")); + writeFileSync(path.join(f.workspace, ".codex", "config.toml"), '[plugins."crosshead@crosshead"]\nenabled = true\n'); + const untrusted = detectCodexPlugin(f.codexHome, f.workspace); + expect(untrusted).toMatchObject({ enabled: false, enabledAtUserScope: false }); + expect(untrusted.ignored).toContain("until Codex trusts"); + writeFileSync(path.join(f.codexHome, "config.toml"), `[projects."${f.workspace}"]\ntrust_level = "trusted"\n`); + expect(detectCodexPlugin(f.codexHome, f.workspace)).toMatchObject({ enabled: true, scope: "project", enabledAtUserScope: false }); + enableCodexPlugin(f.codexHome); + expect(detectCodexPlugin(f.codexHome, f.workspace)).toMatchObject({ enabled: true, scope: "user", enabledAtUserScope: true }); + }); + + it("applies Claude Code settings precedence (local over project over user) and only this marketplace", () => { const f = fixture("detect"); enableClaudePlugin(f.claudeHome, true); - mkdirSync(path.join(f.workspace, ".claude"), { recursive: true }); - writeFileSync(path.join(f.workspace, ".claude", "settings.local.json"), JSON.stringify({ enabledPlugins: { "crosshead@crosshead": false } })); - expect(detectClaudeCodePlugin(f.claudeHome, f.workspace)).toEqual({ enabled: false, source: path.join(f.workspace, ".claude", "settings.local.json") }); + writeJson(path.join(f.workspace, ".claude", "settings.local.json"), { enabledPlugins: { "crosshead@crosshead": false } }); + expect(detectClaudeCodePlugin(f.claudeHome, f.workspace)).toEqual({ + enabled: false, + source: path.join(f.workspace, ".claude", "settings.local.json"), + scope: "local", + enabledAtUserScope: true, + }); + writeJson(path.join(f.claudeHome, "settings.json"), { enabledPlugins: { "crosshead@elsewhere": true } }); + rmSync(path.join(f.workspace, ".claude", "settings.local.json")); + expect(detectClaudeCodePlugin(f.claudeHome, f.workspace)).toMatchObject({ enabled: false, source: null }); + }); + + it("lists exactly the settings files Claude Code loads, with no ancestor lookup", () => { + const f = fixture("loaded-files"); + const sub = path.join(f.workspace, "sub"); + mkdirSync(sub); + // The session's project directory is a git repository root. + mkdirSync(path.join(sub, ".git")); + expect(claudeLoadedSettingsFiles(f.claudeHome, sub).map((entry) => entry.file)).toEqual([ + path.join(sub, ".claude", "settings.local.json"), + path.join(sub, ".claude", "settings.json"), + path.join(f.claudeHome, "settings.json"), + ]); + // A session in a subdirectory of a repository: the local file also lives at the repository root, + // but the shared project file is read only from the session's directory. + rmSync(path.join(sub, ".git"), { recursive: true }); + mkdirSync(path.join(f.workspace, ".git")); + expect(claudeLoadedSettingsFiles(f.claudeHome, sub).map((entry) => entry.file)).toEqual([ + path.join(f.workspace, ".claude", "settings.local.json"), + path.join(sub, ".claude", "settings.local.json"), + path.join(sub, ".claude", "settings.json"), + path.join(f.claudeHome, "settings.json"), + ]); + }); + + it("counts Crosshead commands: empty arrays, empty groups and null groups never count or throw", () => { + const f = fixture("counting"); + const isCrosshead = (command: string) => command.includes("crosshead-claude-code-hook"); + for (const shape of [ + { hooks: { Stop: [] } }, + { hooks: { Stop: [null, {}, { hooks: null }, { hooks: [] }, { hooks: [null, { command: 3 }] }], PreCompact: null, Bad: "x" } }, + { hooks: [] }, + { hooks: null }, + null, + [], + ]) { + expect(countMatchingHookCommands(shape, isCrosshead)).toBe(0); + } + expect(countMatchingHookCommands({ hooks: { Stop: [null, { hooks: [{ command: "/x/bin/crosshead-claude-code-hook Stop" }] }] } }, isCrosshead)).toBe(1); + + writeJson(path.join(f.workspace, ".claude", "settings.json"), { hooks: { Stop: [] } }); + expect(findLegacyClaudeHookFiles(f.claudeHome, f.workspace, f.home)).toEqual([]); + writeJson(path.join(f.codexHome, "hooks.json"), { hooks: { Stop: [null, { hooks: null }] } }); + expect(countLegacyCodexHookCommands(path.join(f.codexHome, "hooks.json"), f.home)).toBe(0); }); }); +const captureStdout = async (run: () => Promise): Promise => { + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + chunks.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + await run(); + } finally { + process.stdout.write = original; + } + return chunks.join(""); +}; + +// A hook that runs reaches the (absent) daemon and fails; a skipped one resolves. +const RAN = "daemon is unavailable"; + describe("hook dispatch", () => { it("runs exactly one registration per event", () => { const present = () => true; const absent = () => false; - expect(decideHookDispatch({ viaPlugin: true, mode: "plugin", legacyHooksPresent: present })).toBe("run"); - expect(decideHookDispatch({ viaPlugin: false, mode: "plugin", legacyHooksPresent: present })).toBe("skip-duplicate"); + // Installer hooks loaded: they run and the plugin defers, in either mode. expect(decideHookDispatch({ viaPlugin: false, mode: "settings", legacyHooksPresent: present })).toBe("run"); expect(decideHookDispatch({ viaPlugin: true, mode: "settings", legacyHooksPresent: present })).toBe("skip-duplicate"); + expect(decideHookDispatch({ viaPlugin: false, mode: "plugin", legacyHooksPresent: present })).toBe("run"); + expect(decideHookDispatch({ viaPlugin: true, mode: "plugin", legacyHooksPresent: present })).toBe("skip-duplicate"); + // None loaded: the plugin runs; a stale installer invocation is skipped in plugin mode. expect(decideHookDispatch({ viaPlugin: true, mode: "settings", legacyHooksPresent: absent })).toBe("run"); + expect(decideHookDispatch({ viaPlugin: true, mode: "plugin", legacyHooksPresent: absent })).toBe("run"); + expect(decideHookDispatch({ viaPlugin: false, mode: "plugin", legacyHooksPresent: absent })).toBe("skip-duplicate"); + expect(decideHookDispatch({ viaPlugin: false, mode: "settings", legacyHooksPresent: absent })).toBe("run"); }); - const captureStdout = async (run: () => Promise): Promise => { - const chunks: string[] = []; - const original = process.stdout.write.bind(process.stdout); - process.stdout.write = ((chunk: string | Uint8Array) => { - chunks.push(String(chunk)); - return true; - }) as typeof process.stdout.write; - try { - await run(); - } finally { - process.stdout.write = original; - } - return chunks.join(""); - }; - - it("plugin invocations before setup print the setup notice on SessionStart only", async () => { + it("plugin invocations before setup print a user-only setup notice on SessionStart", async () => { const f = fixture("unconfigured"); process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; process.env.CODEX_HOME = f.codexHome; const payload = JSON.stringify({ session_id: "s1", cwd: f.workspace }); const claudeStart = await captureStdout(() => runOnlyBotsClaudeCodeHookCli(["SessionStart", "--home", f.home, "--claude-home", f.claudeHome, "--via", "plugin"], payload)); - expect(JSON.parse(claudeStart)).toEqual({ - systemMessage: spec.pluginSetupHint("claude-code"), - hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: spec.pluginSetupHint("claude-code") }, - }); + expect(JSON.parse(claudeStart)).toEqual({ systemMessage: spec.pluginSetupHint("claude-code") }); expect(await captureStdout(() => runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--claude-home", f.claudeHome, "--via", "plugin"], payload))).toBe(""); const codexStart = await captureStdout(() => runOnlyBotsCodexHookCli(["SessionStart", "--home", f.home, "--via", "plugin"], payload)); - expect(JSON.parse(codexStart).systemMessage).toBe(spec.pluginSetupHint("codex")); + expect(JSON.parse(codexStart)).toEqual({ systemMessage: spec.pluginSetupHint("codex") }); // Legacy invocations keep failing loudly when the adapter is missing. await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--claude-home", f.claudeHome], payload)).rejects.toThrow("not installed"); }); - it("skips the duplicate registration before touching the daemon", async () => { - const f = fixture("dedupe"); + it("plugin invocations find a --claude-home install through the adapter record", async () => { + const f = fixture("claude-home"); process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + await install(f, { workspaceRoot: path.join(f.root, "elsewhere") }); + // No CLAUDE_CONFIG_DIR and no --claude-home: the plugin launcher passes neither. const payload = JSON.stringify({ session_id: "s1", cwd: f.workspace }); - const claudeArgs = (...extra: string[]) => ["Stop", "--home", f.home, "--claude-home", f.claudeHome, ...extra]; - await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); - // Settings mode with legacy hooks in this workspace: the plugin defers, legacy runs (and reaches the daemon). - await expect(runOnlyBotsClaudeCodeHookCli(claudeArgs("--via", "plugin"), payload)).resolves.toBeUndefined(); - await expect(runOnlyBotsClaudeCodeHookCli(claudeArgs(), payload)).rejects.toThrow("daemon is unavailable"); - // Settings mode in a workspace the installer never saw: the plugin handles it. - const other = path.join(f.root, "other"); - mkdirSync(other); - await expect(runOnlyBotsClaudeCodeHookCli(claudeArgs("--via", "plugin"), JSON.stringify({ session_id: "s2", cwd: other }))).rejects.toThrow("daemon is unavailable"); - // Plugin mode: legacy invocations are ignored, plugin invocations run. - await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, hookMode: "plugin", ...scripts }); - await expect(runOnlyBotsClaudeCodeHookCli(claudeArgs(), payload)).resolves.toBeUndefined(); - await expect(runOnlyBotsClaudeCodeHookCli(claudeArgs("--via", "plugin"), payload)).rejects.toThrow("daemon is unavailable"); + await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--via", "plugin"], payload)).rejects.toThrow(RAN); + }); + + it("Claude Code: checks the session's project directory, not its ancestors, plus the user settings", async () => { + const f = fixture("claude-dirs"); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + process.env.CLAUDE_CONFIG_DIR = f.claudeHome; + const sub = path.join(f.workspace, "sub"); + mkdirSync(sub); + await install(f); + const hook = (projectDir: string, cwd: string, ...extra: string[]) => { + process.env.CLAUDE_PROJECT_DIR = projectDir; + return runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, ...extra], JSON.stringify({ session_id: "s", cwd })); + }; + // Session in the workspace: the legacy hooks load, so the plugin defers. + await expect(hook(f.workspace, f.workspace, "--via", "plugin")).resolves.toBeUndefined(); + await expect(hook(f.workspace, f.workspace, "--claude-home", f.claudeHome)).rejects.toThrow(RAN); + // After `cd sub` inside that session the project directory is unchanged. + await expect(hook(f.workspace, sub, "--via", "plugin")).resolves.toBeUndefined(); + // A session started in a subdirectory does not load W/.claude/settings.json: the plugin captures. + await expect(hook(sub, sub, "--via", "plugin")).rejects.toThrow(RAN); + }); + + it("Claude Code: legacy hooks in the user settings cover a workspace outside $HOME", async () => { + const f = fixture("claude-outside-home"); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + // Default install layout: the workspace is the user's home, the hooks land in ~/.claude/settings.json. + const userHome = path.join(f.root, "user"); + const claudeHome = path.join(userHome, ".claude"); + mkdirSync(claudeHome, { recursive: true }); + await install(f, { hostHome: claudeHome, workspaceRoot: userHome }); + process.env.CLAUDE_CONFIG_DIR = claudeHome; + const outside = path.join(f.root, "srv", "project"); + mkdirSync(outside, { recursive: true }); + process.env.CLAUDE_PROJECT_DIR = outside; + const payload = JSON.stringify({ session_id: "s", cwd: outside }); + await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--via", "plugin"], payload)).resolves.toBeUndefined(); + await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--claude-home", claudeHome], payload)).rejects.toThrow(RAN); + const [check] = inspectHostHookRegistrations(f.home, outside); + expect(check!.message).toContain(path.join(claudeHome, "settings.json")); + }); + + it("empty hook arrays do not make the plugin defer", async () => { + const f = fixture("claude-empty"); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + process.env.CLAUDE_CONFIG_DIR = f.claudeHome; + enableClaudePlugin(f.claudeHome); + await install(f, { hookMode: "plugin" }); + writeJson(path.join(f.workspace, ".claude", "settings.json"), { hooks: { Stop: [], SessionStart: [null, { hooks: [] }] } }); + process.env.CLAUDE_PROJECT_DIR = f.workspace; + await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--via", "plugin"], JSON.stringify({ session_id: "s", cwd: f.workspace }))).rejects.toThrow(RAN); + expect(inspectHostHookRegistrations(f.home, f.workspace)[0]).toMatchObject({ ok: true }); + + process.env.CODEX_HOME = f.codexHome; + await installCodex(f, { hookMode: "settings" }); + writeJson(path.join(f.codexHome, "hooks.json"), { hooks: { Stop: [null, { hooks: null }] } }); + await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--via", "plugin"], JSON.stringify({ session_id: "s", cwd: f.workspace }))).rejects.toThrow(RAN); + }); + + it("Claude Code: plugin mode defers to leftover installer hooks and skips stale ones", async () => { + const f = fixture("claude-plugin-mode"); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + process.env.CLAUDE_CONFIG_DIR = f.claudeHome; + process.env.CLAUDE_PROJECT_DIR = f.workspace; + const payload = JSON.stringify({ session_id: "s1", cwd: f.workspace }); + const args = (...extra: string[]) => ["Stop", "--home", f.home, ...extra]; + enableClaudePlugin(f.claudeHome); + await install(f, { hookMode: "plugin" }); + await expect(runOnlyBotsClaudeCodeHookCli(args("--claude-home", f.claudeHome), payload)).resolves.toBeUndefined(); + await expect(runOnlyBotsClaudeCodeHookCli(args("--via", "plugin", "--plugin-version", "9.9.9"), payload)).rejects.toThrow(RAN); + expect(readPluginInvocation(f.home, "claude-code")?.pluginVersion).toBe("9.9.9"); + // Hooks an older install left in this project still load: they capture, the plugin defers. + await install(f, { hookMode: "settings" }); + writeFileSync( + path.join(f.claudeHome, "onlybots", "claude-code-adapter.json"), + readFileSync(path.join(f.claudeHome, "onlybots", "claude-code-adapter.json"), "utf8").replace('"hookMode": "settings"', '"hookMode": "plugin"') + ); + await expect(runOnlyBotsClaudeCodeHookCli(args("--via", "plugin"), payload)).resolves.toBeUndefined(); + await expect(runOnlyBotsClaudeCodeHookCli(args("--claude-home", f.claudeHome), payload)).rejects.toThrow(RAN); + }); + it("Codex: until Codex has run the plugin hooks, the installer hooks capture and doctor explains /hooks trust", async () => { + const f = fixture("codex-trust-window"); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; process.env.CODEX_HOME = f.codexHome; - await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, ...scripts }); + const payload = JSON.stringify({ session_id: "s1", cwd: f.workspace }); + await installCodex(f); + enableCodexPlugin(f.codexHome); + const pending = await installCodex(f, { hookMode: "plugin" }); + expect(pending.config.hookMode).toBe("plugin"); + expect(countLegacyCodexHookCommands(path.join(f.codexHome, "hooks.json"), f.home)).toBe(5); + const window = inspectHostHookRegistrations(f.home, f.workspace).find((check) => check.id === "codex_hooks")!; + expect(window.ok).toBe(false); + expect(window.message).toContain("/hooks"); + expect(window.message).toContain("capture meanwhile"); + + // Untrusted plugin hooks never fire; the installer hook still captures. + await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--codex-home", f.codexHome], payload)).rejects.toThrow(RAN); + // Once trusted, both fire: the plugin defers and records that it ran. await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--via", "plugin"], payload)).resolves.toBeUndefined(); - await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, hookMode: "plugin", ...scripts }); + expect(readPluginInvocation(f.home, "codex")).not.toBeNull(); + const migrated = await installCodex(f); + expect(migrated.migratedFiles).toEqual([path.join(f.codexHome, "hooks.json")]); + await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--via", "plugin"], payload)).rejects.toThrow(RAN); + // A Codex session that loaded hooks.json before the migration: its installer hook is skipped. await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--codex-home", f.codexHome], payload)).resolves.toBeUndefined(); + expect(inspectHostHookRegistrations(f.home, f.workspace).find((check) => check.id === "codex_hooks")).toMatchObject({ ok: true }); + }); + + it("Codex: doctor warns when plugin mode is configured but no plugin hook has run", () => { + const check = evaluateHostHookInspection({ + host: "codex", + mode: "plugin", + plugin: { enabled: true, source: "/c/config.toml", scope: "user", enabledAtUserScope: true }, + legacyHookFiles: [], + pluginInvocation: null, + }); + expect(check.ok).toBe(false); + expect(check.message).toContain("no Crosshead plugin hook invocation has been seen"); + expect(check.message).toContain("/hooks"); + expect( + evaluateHostHookInspection({ + host: "codex", + mode: "plugin", + plugin: { enabled: true, source: "/c/config.toml", scope: "user", enabledAtUserScope: true }, + legacyHookFiles: [], + pluginInvocation: { lastSeenAt: new Date().toISOString(), pluginVersion: "1.0.0" }, + }).ok + ).toBe(true); }); }); @@ -289,14 +638,18 @@ describe("plugin launcher", () => { const launcher = path.join(repoRoot, "plugins", "claude-code", spec.PLUGIN_HOOK_LAUNCHER); const run = (env: NodeJS.ProcessEnv, ...args: string[]) => spawnSync("sh", [launcher, ...args], { input: '{"session_id":"s"}', encoding: "utf8", env: { PATH: process.env.PATH, ...env } }); + const fakeWrapper = (home: string, name: string, body: string) => { + mkdirSync(path.join(home, "bin"), { recursive: true }); + writeFileSync(path.join(home, "bin", name), body, { mode: 0o755 }); + }; - it("prints one setup line on SessionStart and exits 0 without a runtime", () => { + it("prints one user-only setup line on SessionStart and exits 0 without a runtime", () => { const f = fixture("launcher"); for (const host of ["claude-code", "codex"] as const) { const start = run({ HOME: f.root }, host, "SessionStart"); expect(start.status).toBe(0); expect(start.stdout.trim().split("\n")).toHaveLength(1); - expect(JSON.parse(start.stdout).systemMessage).toBe(spec.pluginSetupHint(host)); + expect(JSON.parse(start.stdout)).toEqual({ systemMessage: spec.pluginSetupHint(host) }); const stop = run({ HOME: f.root }, host, "Stop"); expect(stop.status).toBe(0); expect(stop.stdout).toBe(""); @@ -304,19 +657,48 @@ describe("plugin launcher", () => { expect(run({ HOME: f.root }, "unknown-host", "SessionStart").stdout).toBe(""); }); - it("asks for an update when the runtime predates plugin support", () => { + it("asks for an update when the runtime's hook wrapper predates plugin dispatch", () => { const f = fixture("launcher-old"); - mkdirSync(path.join(f.root, ".crosshead", "bin"), { recursive: true }); - writeFileSync(path.join(f.root, ".crosshead", "bin", "crosshead-claude-code-hook"), "#!/bin/sh\necho ran\n", { mode: 0o755 }); - mkdirSync(path.join(f.claudeHome, "onlybots"), { recursive: true }); - writeFileSync(path.join(f.claudeHome, "onlybots", "claude-code-adapter.json"), '{"marker":"x"}'); - const start = run({ HOME: f.root, CLAUDE_CONFIG_DIR: f.claudeHome }, "claude-code", "SessionStart"); + const home = path.join(f.root, ".crosshead"); + fakeWrapper(home, "crosshead-claude-code-hook", "#!/bin/sh\necho ran\n"); + const start = run({ HOME: f.root }, "claude-code", "SessionStart"); expect(JSON.parse(start.stdout).systemMessage).toBe(spec.pluginUpdateHint("claude-code")); - expect(run({ HOME: f.root, CLAUDE_CONFIG_DIR: f.claudeHome }, "claude-code", "Stop").stdout).toBe(""); + expect(run({ HOME: f.root }, "claude-code", "Stop").stdout).toBe(""); + }); - writeFileSync(path.join(f.claudeHome, "onlybots", "claude-code-adapter.json"), '{"marker":"x","hookMode":"plugin"}'); - const current = run({ HOME: f.root, CLAUDE_CONFIG_DIR: f.claudeHome }, "claude-code", "Stop"); - expect(current.stdout).toBe("ran\n"); + it("runs the wrapper without reading host config itself, so --claude-home and --codex-home installs work", () => { + const f = fixture("launcher-current"); + const home = path.join(f.root, ".crosshead"); + for (const name of ["crosshead-claude-code-hook", "crosshead-codex-hook"]) { + fakeWrapper(home, name, `#!/bin/sh\n# ${spec.PLUGIN_DISPATCH_CAPABILITY}\necho "$@" "codex_home=$CROSSHEAD_HOST_CODEX_HOME"\n`); + } + // No adapter config at the default host paths. + const claude = run({ HOME: f.root }, "claude-code", "Stop"); + expect(claude.stdout).toBe(`Stop --home ${home} --via plugin --plugin-version ${spec.CROSSHEAD_HOST_PLUGIN_VERSION} codex_home=\n`); + const codex = run({ HOME: f.root, CODEX_HOME: "/elsewhere/codex" }, "codex", "Stop"); + expect(codex.stdout).toContain("codex_home=/elsewhere/codex"); + expect(run({ HOME: f.root }, "codex", "Stop").stdout).toContain(`codex_home=${f.root}/.codex`); + }); + + it("treats empty home variables as unset and keeps the runtime's two-homes check", () => { + const f = fixture("launcher-homes"); + const home = path.join(f.root, ".crosshead"); + fakeWrapper(home, "crosshead-claude-code-hook", `#!/bin/sh\n# ${spec.PLUGIN_DISPATCH_CAPABILITY}\necho "$3"\n`); + expect(run({ HOME: f.root, CROSSHEAD_HOME: "" }, "claude-code", "Stop").stdout).toBe(`${home}\n`); + // The runtime agrees: an empty CROSSHEAD_HOME is unset, not the current directory. + const resolved = spawnSync(process.execPath, ["-e", 'process.stdout.write(require(process.argv[1]).resolveCrossheadHome())', path.join(repoRoot, "dist/runtime/runtime-env.js")], { + cwd: f.workspace, + encoding: "utf8", + env: { PATH: process.env.PATH, HOME: f.root, CROSSHEAD_HOME: "", CROSSHEAD_RUNTIME_HOME: "", ONLYBOTS_HOME: "", ONLYBOTS_RUNTIME_HOME: "" }, + }); + expect(resolved.stdout).toBe(home); + + mkdirSync(path.join(f.root, ".onlybots")); + const conflict = run({ HOME: f.root }, "claude-code", "SessionStart"); + expect(JSON.parse(conflict.stdout).systemMessage).toBe(spec.PLUGIN_HOME_CONFLICT_HINT); + expect(run({ HOME: f.root }, "claude-code", "Stop").stdout).toBe(""); + // An explicit choice wins. + expect(run({ HOME: f.root, CROSSHEAD_HOME: home }, "claude-code", "Stop").stdout).toBe(`${home}\n`); }); it("is the same file in both plugins and passes sh -n", () => { @@ -328,26 +710,69 @@ describe("plugin launcher", () => { describe("doctor hook checks", () => { it("reports the active mode and flags duplicates or missing hooks", async () => { - const ok = evaluateHostHookInspection({ host: "claude-code", mode: "plugin", plugin: { enabled: true, source: "/u/settings.json" }, legacyHookFiles: [] }); + const ok = evaluateHostHookInspection({ host: "claude-code", mode: "plugin", plugin: { enabled: true, source: "/u/settings.json", scope: "user", enabledAtUserScope: true }, legacyHookFiles: [] }); expect(ok).toMatchObject({ id: "claude_code_hooks", ok: true }); - expect(ok.message).toContain("mode=plugin"); - expect(evaluateHostHookInspection({ host: "codex", mode: "plugin", plugin: { enabled: false, source: null }, legacyHookFiles: [] }).ok).toBe(false); - const duplicate = evaluateHostHookInspection({ host: "codex", mode: "settings", plugin: { enabled: true, source: null }, legacyHookFiles: ["/c/hooks.json"] }); + expect(ok.message).toBe("mode=plugin; plugin enabled in /u/settings.json; no legacy hooks."); + expect(evaluateHostHookInspection({ host: "codex", mode: "plugin", plugin: { enabled: false, source: null, scope: null, enabledAtUserScope: false }, legacyHookFiles: [] }).ok).toBe(false); + const duplicate = evaluateHostHookInspection({ + host: "codex", + mode: "settings", + plugin: { enabled: true, source: null, scope: "user", enabledAtUserScope: true }, + legacyHookFiles: ["/c/hooks.json"], + pluginInvocation: { lastSeenAt: new Date().toISOString(), pluginVersion: null }, + }); expect(duplicate.ok).toBe(false); expect(duplicate.message).toContain("--hook-mode plugin"); const f = fixture("doctor"); - await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); + process.env.CLAUDE_CONFIG_DIR = f.claudeHome; + await install(f); enableClaudePlugin(f.claudeHome); const [check] = inspectHostHookRegistrations(f.home, f.workspace); expect(check).toMatchObject({ id: "claude_code_hooks", ok: false }); expect(check!.message).toContain("Duplicate registration"); - const pluginInstall = await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.workspace, claudeBinary: process.execPath, ...scripts }); + const pluginInstall = await install(f); expect(inspectHostHookRegistrations(f.home, f.workspace)[0]).toMatchObject({ ok: true }); expect(evaluateAdapterAutomation(pluginInstall.adapter)).toMatchObject({ id: "adapter_claude_code_automation", ok: true }); - const codexInstall = await installCodexAdapter({ home: f.home, hostHome: f.codexHome, codexBinary: process.execPath, hookMode: "plugin", ...scripts }); + enableCodexPlugin(f.codexHome); + const codexInstall = await installCodex(f, { hookMode: "plugin" }); + expect(codexInstall.config.hookMode).toBe("plugin"); expect(evaluateAdapterAutomation(codexInstall.adapter)).toMatchObject({ id: "adapter_codex_automation", ok: true }); await uninstallClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome }); expect(inspectHostHookRegistrations(f.home, f.workspace).map((entry) => entry.id)).toEqual(["codex_hooks"]); }); }); + +describe("quickstart daemon", () => { + it("restarts a daemon left on another release through the ownership-checked path, and reports --port", async () => { + const f = fixture("stale-daemon"); + // A copy of the built runtime stands in for the release the daemon was started from. + const oldRelease = path.join(f.root, "old-release"); + cpSync(path.join(repoRoot, "dist"), path.join(oldRelease, "dist"), { recursive: true }); + cpSync(path.join(repoRoot, "package.json"), path.join(oldRelease, "package.json")); + symlinkSync(path.join(repoRoot, "node_modules"), path.join(oldRelease, "node_modules")); + const oldDaemon = path.join(oldRelease, "dist", "runtime", "daemon.js"); + const installedDaemon = path.join(repoRoot, "dist", "runtime", "daemon.js"); + const paths = ensureRuntimeHomeLayout(f.home); + try { + const first = await startDetachedRuntimeDaemon({ home: f.home, daemonScriptPath: oldDaemon }); + expect((await readRuntimeDaemonHealth(first))?.daemonScriptPath).toBe(oldDaemon); + + const restarted = await ensureQuickstartDaemon({ home: f.home, daemonScriptPath: installedDaemon }); + expect(restarted).toMatchObject({ id: "daemon", ok: true }); + expect(restarted.detail).toMatch(/^Restarted onlybotsd on port \d+: it was running .*old-release/); + const state = loadDaemonState(paths); + expect(state.pid).not.toBe(first.pid); + expect((await readRuntimeDaemonHealth(state))?.daemonScriptPath).toBe(installedDaemon); + + const reused = await ensureQuickstartDaemon({ home: f.home, daemonScriptPath: installedDaemon }); + expect(reused).toEqual({ id: "daemon", ok: true, detail: `onlybotsd already running on port ${state.port}` }); + const otherPort = await ensureQuickstartDaemon({ home: f.home, daemonScriptPath: installedDaemon, port: String(state.port! === 65535 ? 65534 : state.port! + 1) }); + expect(otherPort.ok).toBe(false); + expect(otherPort.detail).toContain("was not applied"); + expect(loadDaemonState(paths).pid).toBe(state.pid); + } finally { + await stopDetachedRuntimeDaemon(f.home); + } + }, 60_000); +}); From 632b49a1fd88e5efc3f29f328c61cf8b9feaeeff Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 16:44:16 -0500 Subject: [PATCH 07/11] Run the host plugin end-to-end container with --rm --- scripts/test-host-plugins.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-host-plugins.sh b/scripts/test-host-plugins.sh index 4ca73d9..88c4fc0 100755 --- a/scripts/test-host-plugins.sh +++ b/scripts/test-host-plugins.sh @@ -30,7 +30,7 @@ plugin_cleanup() { trap plugin_cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM -docker run -d --name "$plugin_container" --cpus 2 --memory 3g --memory-swap 3g \ +docker run -d --rm --name "$plugin_container" --cpus 2 --memory 3g --memory-swap 3g \ --tmpfs /tmp:rw,size=384m --tmpfs /test:rw,exec,size=768m,uid=1000,gid=1000 \ --mount "type=bind,src=$plugin_repo,dst=/crosshead,readonly" \ --user 0:0 "$plugin_image" sleep infinity > "$plugin_logs/container.txt" From fa19c1807c034f350f84e6ba4a8ad092816d7754 Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 22:13:30 -0500 Subject: [PATCH 08/11] Distinguish unknown paid commits from confirmed free recall failures --- src/runtime/hosted-client.ts | 6 +++++- tests/runtime/hosted-client-security.test.ts | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/runtime/hosted-client.ts b/src/runtime/hosted-client.ts index 2edcd5f..a5fe35d 100644 --- a/src/runtime/hosted-client.ts +++ b/src/runtime/hosted-client.ts @@ -270,9 +270,13 @@ export const describeBoundedRecallFallback = (error: unknown): string => { const prefix = "Saved-context retrieval unavailable; continuing with free hot memory."; if (error instanceof HostedOnlyBotsRequestError) { const details = error.details && typeof error.details === "object" ? (error.details as Record) : null; - if (error.code === "recall_deadline_exceeded" || details?.charged_sats === 0) { + if (error.code === "paid_operation_commit_unknown") { + return `${prefix} The payment outcome is unknown; retry only with the same idempotency key to resolve it without a duplicate charge.`; + } + if (error.code === "recall_deadline_exceeded" && details?.charged_sats === 0) { return `${prefix} The server missed its response budget (HTTP ${error.status}) and charged nothing; a retry is safe.`; } + if (details?.charged_sats === 0) return `${prefix} The server could not deliver the request (HTTP ${error.status}) and charged nothing; a retry is safe.`; if (error.status < 500) return `${prefix} The server rejected the request (HTTP ${error.status}).`; } return `${prefix} A timed-out or failed paid request may still settle; inspect billing before retrying.`; diff --git a/tests/runtime/hosted-client-security.test.ts b/tests/runtime/hosted-client-security.test.ts index 2c24d0f..e575404 100644 --- a/tests/runtime/hosted-client-security.test.ts +++ b/tests/runtime/hosted-client-security.test.ts @@ -225,6 +225,16 @@ describe("describeHostedFailure", () => { }); describe("describeBoundedRecallFallback", () => { + it("keeps unknown commits distinct from confirmed free failures and contradicting charge claims", () => { + const uncertain = describeBoundedRecallFallback(new HostedOnlyBotsRequestError({ status: 503, code: "paid_operation_commit_unknown", message: "private", details: { charged_sats: 0 } })); + expect(uncertain).toContain("same idempotency key"); + expect(uncertain).not.toContain("charged nothing"); + const contradictory = describeBoundedRecallFallback(new HostedOnlyBotsRequestError({ status: 503, code: "recall_deadline_exceeded", message: "private", details: { charged_sats: 2 } })); + expect(contradictory).toContain("may still settle"); + const rejected = describeBoundedRecallFallback(new HostedOnlyBotsRequestError({ status: 400, code: "bad_request", message: "private", details: { charged_sats: 0 } })); + expect(rejected).toContain("charged nothing"); + expect(rejected).not.toContain("missed its response budget"); + }); it("says nothing was charged only when the server confirms it, and keeps the settle caution otherwise", () => { const deadline = new HostedOnlyBotsRequestError({ status: 503, code: "recall_deadline_exceeded", message: "late", details: { charged_sats: 0, retryable: true } }); From 3c6a523e695c625f51db008e51a189e6e0a9fb79 Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 22:17:16 -0500 Subject: [PATCH 09/11] Exercise hosted fallback diagnostics independently of local-first defaults --- tests/runtime/hosted-lifecycle-security.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/runtime/hosted-lifecycle-security.test.ts b/tests/runtime/hosted-lifecycle-security.test.ts index 8b7bd09..bf21938 100644 --- a/tests/runtime/hosted-lifecycle-security.test.ts +++ b/tests/runtime/hosted-lifecycle-security.test.ts @@ -189,7 +189,16 @@ describe("hosted lifecycle prompt boundary", () => { failure: { errorClass: "timeout" }, warning: "may still settle", }, + { + label: "an unacknowledged paid commit", + contextPack: async () => jsonResponse({ error: { code: "paid_operation_commit_unknown", message: "uncertain", details: { retry_with_same_key: true } } }, 503), + failure: { errorClass: "http", status: 503, code: "paid_operation_commit_unknown" }, + warning: "same idempotency key", + }, ])("records why bounded recall fell back to free hot memory after $label", async ({ contextPack, failure, warning }) => { + // Local-first sessions deliberately skip paid automatic recall. Exercise the + // still-supported hosted path explicitly rather than depending on defaults. + vi.stubEnv("CROSSHEAD_HOT_SYNC_ENABLED", "false"); const signer = identity(); const home = mkdtempSync(path.join(os.tmpdir(), "crosshead-bounded-fallback-")); homes.push(home); From 3752b98c8fbf8b54e1ca0102a612e4783ea5ee10 Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 22:19:28 -0500 Subject: [PATCH 10/11] Scope plugin dispatch and migration evidence to each event, version and host home --- .claude-plugin/marketplace.json | 2 +- .github/workflows/native-hosts.yml | 30 ++++++++++ .../claude-code/.claude-plugin/plugin.json | 2 +- plugins/claude-code/hooks/hooks.json | 2 +- plugins/claude-code/scripts/crosshead-hook | 2 +- plugins/codex/.codex-plugin/plugin.json | 2 +- plugins/codex/hooks/hooks.json | 2 +- plugins/codex/scripts/crosshead-hook | 2 +- scripts/smoke-host-plugins.mjs | 11 ++-- src/runtime/claude-code-adapter.ts | 8 +-- src/runtime/claude-code-hook.ts | 7 ++- src/runtime/codex-adapter.ts | 11 ++-- src/runtime/codex-hook.ts | 7 ++- src/runtime/host-hook-doctor.ts | 8 ++- src/runtime/host-hook-spec.ts | 4 +- src/runtime/host-plugin-mode.ts | 30 +++++++--- tests/runtime/host-plugins.test.ts | 58 +++++++++++++++---- 17 files changed, 141 insertions(+), 47 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3a55e5e..5da2138 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "crosshead", "source": "./plugins/claude-code", "description": "Persistent memory across coding agents. Hooks connect this host to the locally installed Crosshead runtime, shared with Claude Code, Codex, Hermes, OpenCode and Pi.", - "version": "1.0.0" + "version": "1.0.1" } ] } diff --git a/.github/workflows/native-hosts.yml b/.github/workflows/native-hosts.yml index c84c338..7ca402c 100644 --- a/.github/workflows/native-hosts.yml +++ b/.github/workflows/native-hosts.yml @@ -69,3 +69,33 @@ jobs: include-hidden-files: true if-no-files-found: ignore retention-days: 14 + + host-plugins-macos: + name: Claude Code and Codex plugins (macOS) + runs-on: macos-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run release:build + - run: npm run plugins:check + - name: Install pinned host CLIs + run: npm install --global --no-audit --no-fund @anthropic-ai/claude-code@2.1.319 @openai/codex@0.167.0 + - name: Exercise installed plugins + env: + CROSSHEAD_SMOKE_ROOT: ${{ runner.temp }}/crosshead-plugin-smoke + run: node scripts/smoke-host-plugins.mjs + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: host-plugin-macos-results + path: ${{ runner.temp }}/crosshead-plugin-smoke/ + include-hidden-files: true + if-no-files-found: ignore + retention-days: 7 diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json index 8672f4e..3a6b575 100644 --- a/plugins/claude-code/.claude-plugin/plugin.json +++ b/plugins/claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "crosshead", "displayName": "Crosshead", - "version": "1.0.0", + "version": "1.0.1", "description": "Persistent memory across coding agents. Hooks connect this host to the locally installed Crosshead runtime, shared with Claude Code, Codex, Hermes, OpenCode and Pi.", "author": { "name": "FriendMachine", diff --git a/plugins/claude-code/hooks/hooks.json b/plugins/claude-code/hooks/hooks.json index bd679c8..49d5791 100644 --- a/plugins/claude-code/hooks/hooks.json +++ b/plugins/claude-code/hooks/hooks.json @@ -3,7 +3,7 @@ "hooks": { "SessionStart": [ { - "matcher": "startup|resume|clear|compact", + "matcher": "startup|resume|clear|compact|fork", "hooks": [ { "type": "command", diff --git a/plugins/claude-code/scripts/crosshead-hook b/plugins/claude-code/scripts/crosshead-hook index 390dad6..2e4fc50 100755 --- a/plugins/claude-code/scripts/crosshead-hook +++ b/plugins/claude-code/scripts/crosshead-hook @@ -47,4 +47,4 @@ if [ "$host" = codex ]; then CROSSHEAD_HOST_CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" export CROSSHEAD_HOST_CODEX_HOME fi -exec "$wrapper" "$event" --home "$home" --via plugin --plugin-version 1.0.0 +exec "$wrapper" "$event" --home "$home" --via plugin --plugin-version 1.0.1 diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json index 4990c8f..6981417 100644 --- a/plugins/codex/.codex-plugin/plugin.json +++ b/plugins/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "crosshead", - "version": "1.0.0", + "version": "1.0.1", "description": "Persistent memory across coding agents. Hooks connect this host to the locally installed Crosshead runtime, shared with Claude Code, Codex, Hermes, OpenCode and Pi.", "author": { "name": "FriendMachine", diff --git a/plugins/codex/hooks/hooks.json b/plugins/codex/hooks/hooks.json index 4f3c519..5e259b1 100644 --- a/plugins/codex/hooks/hooks.json +++ b/plugins/codex/hooks/hooks.json @@ -3,7 +3,7 @@ "hooks": { "SessionStart": [ { - "matcher": "startup|resume|clear|compact", + "matcher": "startup|resume|clear|compact|fork", "hooks": [ { "type": "command", diff --git a/plugins/codex/scripts/crosshead-hook b/plugins/codex/scripts/crosshead-hook index 390dad6..2e4fc50 100755 --- a/plugins/codex/scripts/crosshead-hook +++ b/plugins/codex/scripts/crosshead-hook @@ -47,4 +47,4 @@ if [ "$host" = codex ]; then CROSSHEAD_HOST_CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" export CROSSHEAD_HOST_CODEX_HOME fi -exec "$wrapper" "$event" --home "$home" --via plugin --plugin-version 1.0.0 +exec "$wrapper" "$event" --home "$home" --via plugin --plugin-version 1.0.1 diff --git a/scripts/smoke-host-plugins.mjs b/scripts/smoke-host-plugins.mjs index 1bf645a..f60ffac 100644 --- a/scripts/smoke-host-plugins.mjs +++ b/scripts/smoke-host-plugins.mjs @@ -17,13 +17,13 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, write import { createServer } from "node:http"; import { createRequire } from "node:module"; import path from "node:path"; +import { fileURLToPath } from "node:url"; const require = createRequire(import.meta.url); const { generateSecretKey, getPublicKey, nip19 } = require("nostr-tools"); -const persistence = require("/crosshead/dist/runtime/runtime-persistence.js"); -const spec = require("/crosshead/dist/runtime/host-hook-spec.js"); - -const repo = "/crosshead"; +const repo = path.resolve(process.env.CROSSHEAD_SMOKE_REPO ?? fileURLToPath(new URL("..", import.meta.url))); +const persistence = require(path.join(repo, "dist/runtime/runtime-persistence.js")); +const spec = require(path.join(repo, "dist/runtime/host-hook-spec.js")); const root = process.env.CROSSHEAD_SMOKE_ROOT ?? "/test"; const logDir = path.join(root, "logs"); mkdirSync(logDir, { recursive: true }); @@ -142,7 +142,8 @@ const addMarketplaces = async (home) => { await run("codex-plugin-add", home, "codex", ["plugin", "add", "crosshead@crosshead"]); const list = await run("codex-plugin-list", home, "codex", ["plugin", "list"]); assert.match(list.stdout, /\.agents\/plugins\/marketplace\.json/); - assert.match(list.stdout, /crosshead@crosshead\s+installed.*\/crosshead\/plugins\/codex/); + assert.match(list.stdout, /crosshead@crosshead\s+installed/); + assert(list.stdout.includes(path.join(repo, "plugins", "codex")), "Codex loaded the wrong plugin source"); const claudeList = JSON.parse((await run("claude-plugin-list", home, "claude", ["plugin", "list", "--json"])).stdout); assert(claudeList.some((entry) => entry.id === "crosshead@crosshead" && entry.enabled === true), "Claude plugin not enabled"); }; diff --git a/src/runtime/claude-code-adapter.ts b/src/runtime/claude-code-adapter.ts index 1234ef3..ce4e142 100644 --- a/src/runtime/claude-code-adapter.ts +++ b/src/runtime/claude-code-adapter.ts @@ -592,17 +592,17 @@ export const claudeCodeContinuityTranslationContract: ContinuityTranslationContr const legacyHookWrapper = (runtimeHome: string): string => path.join(runtimeHome, "bin", "crosshead-claude-code-hook"); /** Number of installer-written Crosshead hook handlers in a Claude Code settings file. */ -export const countLegacyClaudeHookCommands = (filePath: string, runtimeHome: string): number => - countMatchingHookCommands(readSettingsForInspection(filePath), (command) => isLegacyClaudeHookCommand(command, legacyHookWrapper(runtimeHome))); +export const countLegacyClaudeHookCommands = (filePath: string, runtimeHome: string, match?: import("./host-plugin-mode").HookMatch): number => + countMatchingHookCommands(readSettingsForInspection(filePath), (command) => isLegacyClaudeHookCommand(command, legacyHookWrapper(runtimeHome)), match); /** * Settings files Claude Code loads for a session in `projectDir` (user settings in * `userConfigDir`) that still register installer-written Crosshead hooks. */ -export const findLegacyClaudeHookFiles = (userConfigDir: string, projectDir: string, runtimeHome: string): string[] => +export const findLegacyClaudeHookFiles = (userConfigDir: string, projectDir: string, runtimeHome: string, match?: import("./host-plugin-mode").HookMatch): string[] => claudeLoadedSettingsFiles(userConfigDir, projectDir) .map((source) => source.file) - .filter((filePath) => countLegacyClaudeHookCommands(filePath, runtimeHome) > 0); + .filter((filePath) => countLegacyClaudeHookCommands(filePath, runtimeHome, match) > 0); /** * Removes installer-written Crosshead hooks from a settings file, keeping every diff --git a/src/runtime/claude-code-hook.ts b/src/runtime/claude-code-hook.ts index ab8ec47..fe0befc 100644 --- a/src/runtime/claude-code-hook.ts +++ b/src/runtime/claude-code-hook.ts @@ -393,7 +393,7 @@ export const runOnlyBotsClaudeCodeHookCli = async ( } if (viaPlugin) { try { - recordPluginInvocation(runtimeHome, "claude-code", typeof flags["plugin-version"] === "string" ? flags["plugin-version"] : null); + recordPluginInvocation(runtimeHome, "claude-code", typeof flags["plugin-version"] === "string" ? flags["plugin-version"] : null, claudeUserConfigDir()); } catch { // A receipt is diagnostic only; never fail the hook over it. } @@ -404,7 +404,10 @@ export const runOnlyBotsClaudeCodeHookCli = async ( viaPlugin, mode: normalizeHostHookMode(installedConfig.hookMode), legacyHooksPresent: () => - findLegacyClaudeHookFiles(claudeUserConfigDir(), claudeSessionProjectDir(workspaceRoot), runtimeHome).length > 0, + findLegacyClaudeHookFiles(claudeUserConfigDir(), claudeSessionProjectDir(workspaceRoot), runtimeHome, { + event: eventName, + matcherValue: normalizedEventName.replace(/_/g, "") === "sessionstart" ? pickString(payload, ["source", "payload.source"]) ?? undefined : pickCompactionTrigger(payload) ?? undefined, + }).length > 0, }); if (decision === "skip-duplicate") return; } diff --git a/src/runtime/codex-adapter.ts b/src/runtime/codex-adapter.ts index 5d87a90..ba39a5c 100644 --- a/src/runtime/codex-adapter.ts +++ b/src/runtime/codex-adapter.ts @@ -66,6 +66,7 @@ import { normalizeHostHookMode, parseHookModeFlag, readPluginInvocation, + pluginInvocationMatches, readSettingsForInspection, resolveHostHookMode, type HookModeRequest, @@ -623,12 +624,12 @@ export const codexContinuityTranslationContract: ContinuityTranslationContract path.join(runtimeHome, "bin", "crosshead-codex-hook"); /** Number of installer-written Crosshead hook handlers in a Codex hooks.json. */ -export const countLegacyCodexHookCommands = (filePath: string, runtimeHome: string): number => - countMatchingHookCommands(readSettingsForInspection(filePath), (command) => isLegacyCodexHookCommand(command, legacyCodexHookWrapper(runtimeHome))); +export const countLegacyCodexHookCommands = (filePath: string, runtimeHome: string, match?: import("./host-plugin-mode").HookMatch): number => + countMatchingHookCommands(readSettingsForInspection(filePath), (command) => isLegacyCodexHookCommand(command, legacyCodexHookWrapper(runtimeHome)), match); /** True when a Codex hooks.json still registers Crosshead's legacy (settings-mode) hooks. */ -export const codexHooksFileHasLegacyHooks = (filePath: string, runtimeHome: string): boolean => - countLegacyCodexHookCommands(filePath, runtimeHome) > 0; +export const codexHooksFileHasLegacyHooks = (filePath: string, runtimeHome: string, match?: import("./host-plugin-mode").HookMatch): boolean => + countLegacyCodexHookCommands(filePath, runtimeHome, match) > 0; /** * Removes installer-written Crosshead hooks, keeping other hooks and top-level @@ -667,7 +668,7 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): // installer hooks until a plugin invocation has been observed; until then the // dispatch rule lets the installer hooks capture and the plugin defer. const legacyPresent = codexHooksFileHasLegacyHooks(hooksFile(hostHome), runtimeLayout.home); - const pluginObserved = readPluginInvocation(runtimeLayout.home, "codex") !== null; + const pluginObserved = pluginInvocationMatches(readPluginInvocation(runtimeLayout.home, "codex"), hostHome); const migrateLegacy = hookMode === "plugin" && legacyPresent && pluginObserved; const writeLegacy = hookMode === "settings" && resolution.writeLegacyHooks; const hookModeNote = diff --git a/src/runtime/codex-hook.ts b/src/runtime/codex-hook.ts index ed39f4d..86f9359 100644 --- a/src/runtime/codex-hook.ts +++ b/src/runtime/codex-hook.ts @@ -399,7 +399,7 @@ export const runOnlyBotsCodexHookCli = async ( if (viaPlugin) { // Proof that Codex runs (so the user trusted) the plugin hooks; migration waits for it. try { - recordPluginInvocation(runtimeHome, "codex", typeof flags["plugin-version"] === "string" ? flags["plugin-version"] : null); + recordPluginInvocation(runtimeHome, "codex", typeof flags["plugin-version"] === "string" ? flags["plugin-version"] : null, codexLoadedHome(resolveCodexHome(hostHome))); } catch { // A receipt is diagnostic only; never fail the hook over it. } @@ -412,7 +412,10 @@ export const runOnlyBotsCodexHookCli = async ( const decision = decideHookDispatch({ viaPlugin, mode: normalizeHostHookMode(installedConfig.hookMode), - legacyHooksPresent: () => codexHooksFileHasLegacyHooks(loadedHooksFile, runtimeHome), + legacyHooksPresent: () => codexHooksFileHasLegacyHooks(loadedHooksFile, runtimeHome, { + event: eventName, + matcherValue: normalizedEventName.replace(/_/g, "") === "sessionstart" ? pickString(payload, ["source", "payload.source"]) ?? undefined : pickCompactionTrigger(payload) ?? undefined, + }), }); if (decision === "skip-duplicate") return; } diff --git a/src/runtime/host-hook-doctor.ts b/src/runtime/host-hook-doctor.ts index bf1a4eb..89a4dfc 100644 --- a/src/runtime/host-hook-doctor.ts +++ b/src/runtime/host-hook-doctor.ts @@ -9,6 +9,7 @@ import { normalizeHookModeRequest, normalizeHostHookMode, readPluginInvocation, + pluginInvocationMatches, type HostHookMode, type PluginDetection, type PluginInvocationRecord, @@ -18,6 +19,7 @@ import { loadAdapterRecord, resolveRuntimePaths } from "./runtime-persistence"; export type HostHookInspection = { host: HookHost; + hostHome?: string; mode: HostHookMode; plugin: PluginDetection; /** Installer-written Crosshead hooks the host loads for the inspected workspace. */ @@ -50,7 +52,7 @@ export const evaluateHostHookInspection = (inspection: HostHookInspection): Runt const summary = `mode=${mode}; ${describePlugin(plugin)}; ${legacyState}.`; const hasLegacy = legacyHookFiles.length > 0; // Codex: plugin hooks that never ran are most likely untrusted. - const codexUnseen = host === "codex" && plugin.enabled && !inspection.pluginInvocation; + const codexUnseen = host === "codex" && plugin.enabled && !pluginInvocationMatches(inspection.pluginInvocation, inspection.hostHome ?? ""); const fail = (message: string): RuntimeDoctorCheck => ({ id, ok: false, message: `${summary} ${message}` }); const ok = (message?: string): RuntimeDoctorCheck => ({ id, ok: true, message: message ? `${summary} ${message}` : summary }); @@ -79,7 +81,7 @@ export const evaluateHostHookInspection = (inspection: HostHookInspection): Runt return ok(`The plugin is enabled only for this project, so the installer hooks stay in place for other projects; here the plugin defers to them and each event is captured once.${requested}`); } return fail( - `Duplicate registration (the plugin defers to the installer hooks here): run ${installCommand(host)} --hook-mode plugin to migrate${host === "codex" && !inspection.pluginInvocation ? ` once Codex has run the plugin hooks. ${CODEX_TRUST_HINT}` : "."}` + `Duplicate registration (the plugin defers to the installer hooks here): run ${installCommand(host)} --hook-mode plugin to migrate${codexUnseen ? ` once Codex has run the plugin hooks. ${CODEX_TRUST_HINT}` : "."}` ); } if (plugin.enabled && codexUnseen) { @@ -109,6 +111,7 @@ export const inspectHostHookRegistrations = (home?: string, workspaceRoot: strin checks.push( evaluateHostHookInspection({ host: "claude-code", + hostHome: config.hostHome, mode, plugin: detectClaudeCodePlugin(config.hostHome, workspace), legacyHookFiles: legacy, @@ -128,6 +131,7 @@ export const inspectHostHookRegistrations = (home?: string, workspaceRoot: strin checks.push( evaluateHostHookInspection({ host: "codex", + hostHome: config.hostHome, mode: normalizeHostHookMode(config.hookMode), plugin: detectCodexPlugin(config.hostHome, workspace), legacyHookFiles: codexHooksFileHasLegacyHooks(hooksFile, paths.home) ? [hooksFile] : [], diff --git a/src/runtime/host-hook-spec.ts b/src/runtime/host-hook-spec.ts index 204a3ce..deb66d3 100644 --- a/src/runtime/host-hook-spec.ts +++ b/src/runtime/host-hook-spec.ts @@ -29,7 +29,7 @@ type HostHookEventSpec = { }; export const HOST_HOOK_EVENTS: readonly HostHookEventSpec[] = [ - { event: "SessionStart", matcher: "startup|resume|clear|compact", codexStatusMessage: "Crosshead session sync" }, + { event: "SessionStart", matcher: "startup|resume|clear|compact|fork", codexStatusMessage: "Crosshead session sync" }, { event: "PreCompact", matcher: "manual|auto", codexStatusMessage: "Crosshead pre-compact checkpoint", codexTimeout: 30 }, { event: "PostCompact", matcher: "manual|auto", codexStatusMessage: "Crosshead post-compact sync", codexTimeout: 30 }, { event: "UserPromptSubmit", codexStatusMessage: "Crosshead context", codexTimeout: 45 }, @@ -40,7 +40,7 @@ export const HOST_HOOK_EVENTS: readonly HostHookEventSpec[] = [ export const CROSSHEAD_PLUGIN_NAME = "crosshead"; export const CROSSHEAD_MARKETPLACE_NAME = "crosshead"; /** Bump when any generated plugin file changes; hosts pin installed plugins by version. */ -export const CROSSHEAD_HOST_PLUGIN_VERSION = "1.0.0"; +export const CROSSHEAD_HOST_PLUGIN_VERSION = "1.0.1"; /** Path, relative to the plugin root, of the launcher the plugin hooks run. */ export const PLUGIN_HOOK_LAUNCHER = "scripts/crosshead-hook"; diff --git a/src/runtime/host-plugin-mode.ts b/src/runtime/host-plugin-mode.ts index ba6c4e4..ff4d640 100644 --- a/src/runtime/host-plugin-mode.ts +++ b/src/runtime/host-plugin-mode.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "fs"; import os from "os"; import path from "path"; -import { CROSSHEAD_MARKETPLACE_NAME, CROSSHEAD_PLUGIN_NAME, PLUGIN_HOOK_VIA_FLAG, type HookHost } from "./host-hook-spec"; +import { CROSSHEAD_HOST_PLUGIN_VERSION, CROSSHEAD_MARKETPLACE_NAME, CROSSHEAD_PLUGIN_NAME, PLUGIN_HOOK_VIA_FLAG, type HookHost } from "./host-hook-spec"; import { ensureRuntimeHomeLayout, loadAdapterRecord, parseJsonFile, resolveRuntimePaths, writeJsonFile } from "./runtime-persistence"; // Hook registration modes for Claude Code and Codex: @@ -451,15 +451,25 @@ export const decideHookDispatch = (input: { * shape: non-object hooks, non-array events, null or non-object groups and * handlers are ignored, so empty arrays never count. */ -export const countMatchingHookCommands = (settings: unknown, isCrosshead: (command: string) => boolean): number => { +export type HookMatch = { event: string; matcherValue?: string }; + +export const countMatchingHookCommands = (settings: unknown, isCrosshead: (command: string) => boolean, match?: HookMatch): number => { if (!settings || typeof settings !== "object" || Array.isArray(settings)) return 0; const hooks = (settings as { hooks?: unknown }).hooks; if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return 0; let count = 0; - for (const groups of Object.values(hooks as Record)) { + for (const [event, groups] of Object.entries(hooks as Record)) { + if (match && event.toLowerCase().replace(/_/g, "") !== match.event.toLowerCase().replace(/_/g, "")) continue; if (!Array.isArray(groups)) continue; for (const group of groups) { if (!group || typeof group !== "object" || Array.isArray(group)) continue; + if (match) { + const matcher = (group as { matcher?: unknown }).matcher; + if (typeof matcher === "string" && matcher !== "" && matcher !== "*") { + if (match.matcherValue === undefined) continue; + try { if (!new RegExp(matcher).test(match.matcherValue)) continue; } catch { continue; } + } + } const handlers = (group as { hooks?: unknown }).hooks; if (!Array.isArray(handlers)) continue; for (const handler of handlers) { @@ -476,7 +486,7 @@ export const readSettingsForInspection = (filePath: string): unknown => (existsS // ---- Plugin invocation receipts ----------------------------------------------- -export type PluginInvocationRecord = { lastSeenAt: string; pluginVersion: string | null }; +export type PluginInvocationRecord = { lastSeenAt: string; pluginVersion: string | null; hostHome: string | null }; const invocationFile = (runtimeHome: string): string => path.join(path.resolve(runtimeHome), "state", "host-plugin-invocations.json"); @@ -484,7 +494,7 @@ export const readPluginInvocation = (runtimeHome: string, host: HookHost): Plugi const records = parseJsonFile | null>(invocationFile(runtimeHome), null); const record = records?.[host] as Partial | undefined; return record && typeof record.lastSeenAt === "string" - ? { lastSeenAt: record.lastSeenAt, pluginVersion: typeof record.pluginVersion === "string" ? record.pluginVersion : null } + ? { lastSeenAt: record.lastSeenAt, pluginVersion: typeof record.pluginVersion === "string" ? record.pluginVersion : null, hostHome: typeof record.hostHome === "string" ? record.hostHome : null } : null; }; @@ -493,13 +503,17 @@ export const readPluginInvocation = (runtimeHome: string, host: HookHost): Plugi * the plugin hooks). Rewritten at most every 10 minutes per host, or when the * plugin version changes. */ -export const recordPluginInvocation = (runtimeHome: string, host: HookHost, pluginVersion: string | null, now: Date = new Date()): void => { +export const pluginInvocationMatches = (record: PluginInvocationRecord | null | undefined, hostHome: string): boolean => + record?.pluginVersion === CROSSHEAD_HOST_PLUGIN_VERSION && record.hostHome === path.resolve(hostHome); + +export const recordPluginInvocation = (runtimeHome: string, host: HookHost, pluginVersion: string | null, hostHome: string, now: Date = new Date()): void => { const previous = readPluginInvocation(runtimeHome, host); - if (previous && previous.pluginVersion === pluginVersion && now.getTime() - Date.parse(previous.lastSeenAt) < 10 * 60_000) return; + const resolvedHome = path.resolve(hostHome); + if (previous && previous.pluginVersion === pluginVersion && previous.hostHome === resolvedHome && now.getTime() - Date.parse(previous.lastSeenAt) < 10 * 60_000) return; ensureRuntimeHomeLayout(runtimeHome); const file = invocationFile(runtimeHome); const records = parseJsonFile | null>(file, null) ?? {}; - writeJsonFile(file, { ...records, [host]: { lastSeenAt: now.toISOString(), pluginVersion } }); + writeJsonFile(file, { ...records, [host]: { lastSeenAt: now.toISOString(), pluginVersion, hostHome: resolvedHome } }); }; /** diff --git a/tests/runtime/host-plugins.test.ts b/tests/runtime/host-plugins.test.ts index 17e8feb..a4e0069 100644 --- a/tests/runtime/host-plugins.test.ts +++ b/tests/runtime/host-plugins.test.ts @@ -110,6 +110,7 @@ describe("host plugin files", () => { // never edit an old one. 1.0.0 is unreleased while PR #14 is open. const PLUGIN_CONTENT_SHA256: Record = { "1.0.0": "1c6295e2195529599233fae8d5a46b5671ef18998beaf39652e7bc6d955dc4d4", + "1.0.1": "1fcdad297292b4ac92cf109a9b29da8f26928710099f028e53c12ae9c45ab5e6", }; it("generated plugin content is pinned to CROSSHEAD_HOST_PLUGIN_VERSION", () => { const files = renderHostPluginFiles(spec) as Record; @@ -168,7 +169,7 @@ describe("legacy hook rendering", () => { // hosts start a fresh context after /clear, like startup), changed deliberately. expect(readJson(path.join(f.workspace, ".claude", "settings.json"))).toEqual({ hooks: { - SessionStart: [{ matcher: "startup|resume|clear|compact", hooks: [{ type: "command", command: command("SessionStart") }] }], + SessionStart: [{ matcher: "startup|resume|clear|compact|fork", hooks: [{ type: "command", command: command("SessionStart") }] }], PreCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PreCompact") }] }], PostCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PostCompact") }] }], UserPromptSubmit: [{ hooks: [{ type: "command", command: command("UserPromptSubmit") }] }], @@ -184,7 +185,7 @@ describe("legacy hook rendering", () => { const command = (event: string) => `'${hookWrapper}' '${event}' --home '${f.home}' --codex-home '${f.codexHome}'`; expect(readJson(path.join(f.codexHome, "hooks.json"))).toEqual({ hooks: { - SessionStart: [{ matcher: "startup|resume|clear|compact", hooks: [{ type: "command", command: command("SessionStart"), statusMessage: "Crosshead session sync" }] }], + SessionStart: [{ matcher: "startup|resume|clear|compact|fork", hooks: [{ type: "command", command: command("SessionStart"), statusMessage: "Crosshead session sync" }] }], PreCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PreCompact"), statusMessage: "Crosshead pre-compact checkpoint", timeout: 30 }] }], PostCompact: [{ matcher: "manual|auto", hooks: [{ type: "command", command: command("PostCompact"), statusMessage: "Crosshead post-compact sync", timeout: 30 }] }], UserPromptSubmit: [{ hooks: [{ type: "command", command: command("UserPromptSubmit"), statusMessage: "Crosshead context", timeout: 45 }] }], @@ -312,7 +313,7 @@ describe("plugin mode install and migration", () => { expect(pending.hookModeNote).toContain("/hooks"); expect(countLegacyCodexHookCommands(hooksFile, f.home)).toBe(5); - recordPluginInvocation(f.home, "codex", spec.CROSSHEAD_HOST_PLUGIN_VERSION); + recordPluginInvocation(f.home, "codex", spec.CROSSHEAD_HOST_PLUGIN_VERSION, f.codexHome); const result = await installCodex(f); expect(result.migratedFiles).toEqual([hooksFile]); expect(readJson(hooksFile)).toEqual({ $schema: "keep-me", hooks: { PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo unrelated" }] }] } }); @@ -339,6 +340,23 @@ describe("plugin mode install and migration", () => { expect(countLegacyCodexHookCommands(path.join(f.codexHome, "hooks.json"), f.home)).toBe(5); }); + it.each(["older-version", "different-home", "unbound-receipt"])("Codex: %s receipt cannot authorize global hook removal", async (kind) => { + const f = fixture(`codex-receipt-${kind}`); + await installCodex(f); + enableCodexPlugin(f.codexHome); + if (kind === "unbound-receipt") { + writeJson(path.join(f.home, "state", "host-plugin-invocations.json"), { codex: { lastSeenAt: new Date().toISOString(), pluginVersion: spec.CROSSHEAD_HOST_PLUGIN_VERSION } }); + } else { + recordPluginInvocation(f.home, "codex", kind === "older-version" ? "1.0.0" : spec.CROSSHEAD_HOST_PLUGIN_VERSION, kind === "different-home" ? path.join(f.root, "other-codex") : f.codexHome); + } + const pending = await installCodex(f); + expect(pending.migratedFiles).toEqual([]); + expect(countLegacyCodexHookCommands(path.join(f.codexHome, "hooks.json"), f.home)).toBe(5); + expect(inspectHostHookRegistrations(f.home, f.workspace).find((check) => check.id === "codex_hooks")).toMatchObject({ ok: false }); + recordPluginInvocation(f.home, "codex", spec.CROSSHEAD_HOST_PLUGIN_VERSION, f.codexHome); + expect((await installCodex(f)).migratedFiles).toEqual([path.join(f.codexHome, "hooks.json")]); + }); + it("Codex: refuses a symlinked hooks.json before writing anything", async () => { const f = fixture("codex-symlink"); const hooksFile = path.join(f.codexHome, "hooks.json"); @@ -350,7 +368,7 @@ describe("plugin mode install and migration", () => { const configPath = path.join(f.codexHome, "onlybots", "codex-adapter.json"); const before = readFileSync(configPath, "utf8"); enableCodexPlugin(f.codexHome); - recordPluginInvocation(f.home, "codex", null); + recordPluginInvocation(f.home, "codex", spec.CROSSHEAD_HOST_PLUGIN_VERSION, f.codexHome); await expect(installCodex(f)).rejects.toThrow("regular file"); expect(readFileSync(configPath, "utf8")).toBe(before); expect(countLegacyCodexHookCommands(real, f.home)).toBe(5); @@ -502,7 +520,7 @@ describe("hook dispatch", () => { await install(f, { workspaceRoot: path.join(f.root, "elsewhere") }); // No CLAUDE_CONFIG_DIR and no --claude-home: the plugin launcher passes neither. const payload = JSON.stringify({ session_id: "s1", cwd: f.workspace }); - await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--via", "plugin"], payload)).rejects.toThrow(RAN); + await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--via", "plugin", "--plugin-version", spec.CROSSHEAD_HOST_PLUGIN_VERSION], payload)).rejects.toThrow(RAN); }); it("Claude Code: checks the session's project directory, not its ancestors, plus the user settings", async () => { @@ -538,7 +556,7 @@ describe("hook dispatch", () => { mkdirSync(outside, { recursive: true }); process.env.CLAUDE_PROJECT_DIR = outside; const payload = JSON.stringify({ session_id: "s", cwd: outside }); - await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--via", "plugin"], payload)).resolves.toBeUndefined(); + await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--via", "plugin", "--plugin-version", spec.CROSSHEAD_HOST_PLUGIN_VERSION], payload)).resolves.toBeUndefined(); await expect(runOnlyBotsClaudeCodeHookCli(["Stop", "--home", f.home, "--claude-home", claudeHome], payload)).rejects.toThrow(RAN); const [check] = inspectHostHookRegistrations(f.home, outside); expect(check!.message).toContain(path.join(claudeHome, "settings.json")); @@ -561,6 +579,25 @@ describe("hook dispatch", () => { await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--via", "plugin"], JSON.stringify({ session_id: "s", cwd: f.workspace }))).rejects.toThrow(RAN); }); + it.each(["claude-code", "codex"] as const)("%s: partial legacy registrations defer only matching events and sources", async (host) => { + const f = fixture(`${host}-partial`); + process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; + process.env.CLAUDE_CONFIG_DIR = f.claudeHome; + process.env.CLAUDE_PROJECT_DIR = f.workspace; + process.env.CODEX_HOME = f.codexHome; + if (host === "codex") await installCodex(f); else await install(f); + const file = host === "codex" ? path.join(f.codexHome, "hooks.json") : path.join(f.workspace, ".claude", "settings.json"); + const legacy = readJson(file); + legacy.hooks = { SessionStart: [{ ...legacy.hooks.SessionStart[0], matcher: "startup|resume" }] }; + writeJson(file, legacy); + const hook = host === "codex" ? runOnlyBotsCodexHookCli : runOnlyBotsClaudeCodeHookCli; + const invoke = (event: string, source?: string) => hook([event, "--home", f.home, "--via", "plugin", "--plugin-version", spec.CROSSHEAD_HOST_PLUGIN_VERSION], JSON.stringify({ session_id: "partial", cwd: f.workspace, source })); + await expect(invoke("SessionStart", "startup")).resolves.toBeUndefined(); + await expect(invoke("SessionStart", "fork")).rejects.toThrow(RAN); + await expect(invoke("UserPromptSubmit")).rejects.toThrow(RAN); + await expect(invoke("Stop")).rejects.toThrow(RAN); + }); + it("Claude Code: plugin mode defers to leftover installer hooks and skips stale ones", async () => { const f = fixture("claude-plugin-mode"); process.env.CROSSHEAD_HOOK_AUTOSTART = "0"; @@ -601,11 +638,11 @@ describe("hook dispatch", () => { // Untrusted plugin hooks never fire; the installer hook still captures. await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--codex-home", f.codexHome], payload)).rejects.toThrow(RAN); // Once trusted, both fire: the plugin defers and records that it ran. - await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--via", "plugin"], payload)).resolves.toBeUndefined(); + await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--via", "plugin", "--plugin-version", spec.CROSSHEAD_HOST_PLUGIN_VERSION], payload)).resolves.toBeUndefined(); expect(readPluginInvocation(f.home, "codex")).not.toBeNull(); const migrated = await installCodex(f); expect(migrated.migratedFiles).toEqual([path.join(f.codexHome, "hooks.json")]); - await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--via", "plugin"], payload)).rejects.toThrow(RAN); + await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--via", "plugin", "--plugin-version", spec.CROSSHEAD_HOST_PLUGIN_VERSION], payload)).rejects.toThrow(RAN); // A Codex session that loaded hooks.json before the migration: its installer hook is skipped. await expect(runOnlyBotsCodexHookCli(["Stop", "--home", f.home, "--codex-home", f.codexHome], payload)).resolves.toBeUndefined(); expect(inspectHostHookRegistrations(f.home, f.workspace).find((check) => check.id === "codex_hooks")).toMatchObject({ ok: true }); @@ -625,10 +662,11 @@ describe("hook dispatch", () => { expect( evaluateHostHookInspection({ host: "codex", + hostHome: "/c", mode: "plugin", plugin: { enabled: true, source: "/c/config.toml", scope: "user", enabledAtUserScope: true }, legacyHookFiles: [], - pluginInvocation: { lastSeenAt: new Date().toISOString(), pluginVersion: "1.0.0" }, + pluginInvocation: { lastSeenAt: new Date().toISOString(), pluginVersion: spec.CROSSHEAD_HOST_PLUGIN_VERSION, hostHome: "/c" }, }).ok ).toBe(true); }); @@ -719,7 +757,7 @@ describe("doctor hook checks", () => { mode: "settings", plugin: { enabled: true, source: null, scope: "user", enabledAtUserScope: true }, legacyHookFiles: ["/c/hooks.json"], - pluginInvocation: { lastSeenAt: new Date().toISOString(), pluginVersion: null }, + pluginInvocation: { lastSeenAt: new Date().toISOString(), pluginVersion: null, hostHome: null }, }); expect(duplicate.ok).toBe(false); expect(duplicate.message).toContain("--hook-mode plugin"); From b4d0ddd443948d1f88bb930900bac8083ac53302 Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 22:25:51 -0500 Subject: [PATCH 11/11] Prepare 0.5.0-alpha.9 with native host plugins and recall diagnostics --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a318652..46ea5cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@crosshead/runtime", - "version": "0.5.0-alpha.8", + "version": "0.5.0-alpha.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@crosshead/runtime", - "version": "0.5.0-alpha.8", + "version": "0.5.0-alpha.9", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^1.9.7", diff --git a/package.json b/package.json index 3834990..ebf3b16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@crosshead/runtime", - "version": "0.5.0-alpha.8", + "version": "0.5.0-alpha.9", "description": "Crosshead local runtime: persistent memory for Claude Code, Codex, Pi, Hermes and OpenCode. CLI, daemon, host adapters and hooks.", "license": "Apache-2.0", "private": true,