From 8ac3d81711c8218e6b4eb2ba22f2de5ec7a300bd Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 13 Nov 2026 21:59:04 -0500 Subject: [PATCH 1/3] Add scoped onboarding for Codex and Claude Code --- .claude-plugin/marketplace.json | 2 +- README.md | 61 ++- docs/HOST-PLUGINS.md | 23 +- .../claude-code/.claude-plugin/plugin.json | 2 +- plugins/claude-code/scripts/crosshead-hook | 2 +- plugins/claude-code/skills/setup/SKILL.md | 70 ++- plugins/codex/.codex-plugin/plugin.json | 2 +- plugins/codex/scripts/crosshead-hook | 2 +- plugins/codex/skills/setup/SKILL.md | 73 ++-- scripts/host-plugins.mjs | 84 ++-- src/runtime/claude-code-adapter.ts | 28 +- src/runtime/claude-code-hook.ts | 84 ++-- src/runtime/cli.ts | 52 ++- src/runtime/cli/core.ts | 19 +- src/runtime/cli/memory.ts | 8 + src/runtime/cli/workspace.ts | 232 ++++++++++ src/runtime/codex-adapter.ts | 27 +- src/runtime/codex-hook.ts | 86 ++-- src/runtime/host-adapter-base.ts | 31 +- src/runtime/host-hook-spec.ts | 2 +- src/runtime/lifecycle-engine.ts | 9 +- src/runtime/local-hot-memory.ts | 29 +- src/runtime/local-runtime.ts | 17 +- src/runtime/runtime-env.ts | 3 + src/runtime/startup-context.ts | 7 +- src/runtime/workspace-hooks.ts | 401 ++++++++++++++++++ tests/runtime/host-adapter-base.test.ts | 8 + tests/runtime/host-plugins.test.ts | 5 +- tests/runtime/profile-selection.test.ts | 59 +++ tests/runtime/startup-context-capture.test.ts | 8 + tests/runtime/workspace-setup.test.ts | 176 ++++++++ 31 files changed, 1322 insertions(+), 290 deletions(-) create mode 100644 src/runtime/cli/workspace.ts create mode 100644 src/runtime/workspace-hooks.ts create mode 100644 tests/runtime/profile-selection.test.ts create mode 100644 tests/runtime/workspace-setup.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7145c8b..3cd4d96 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.1.0" + "version": "1.2.0" } ] } diff --git a/README.md b/README.md index bd84c54..cda493b 100644 --- a/README.md +++ b/README.md @@ -27,26 +27,53 @@ 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. +Setup asks where Crosshead should run and previews the directories, profiles, +and files before applying the setup. In Codex, review the new project hooks +in `/hooks`. The plugin launcher needs a POSIX shell. 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 +### Choose where Crosshead runs ```bash curl -fsSL https://crosshead.sh/install | bash -crosshead quickstart --api-base https://crosshead.sh --adapter claude-code --workspace-root . +crosshead quickstart ``` -`--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. +Quickstart recommends the current project. Add more directories, opt into all +projects, or choose manual commands only. Each directory can use Codex, Claude +Code, or both, with an existing or new profile. Project hooks use their selected +profile at startup and resume, including when the launcher skips `direnv`. + +For scripts and agents, preview the same setup as JSON: + +```sh +crosshead quickstart --scope project --workspace-root . --adapter both --profile work --dry-run --json +``` + +Use an existing profile name or pass `--api-base https://crosshead.sh` to create +one. Apply by removing `--dry-run`. After starting a session, use +`crosshead workspace check` to see the configured profile and observed startup +and resume. Run the same check in an unselected directory to inspect its scope. + +```sh +crosshead workspace add --workspace-root ../other-project --adapter codex --profile work +crosshead workspace list +crosshead workspace remove --workspace-root ../other-project +``` + +Project setup writes `.codex/hooks.json` and `.claude/settings.local.json` for +the selected hosts. Keep the generated entries local to this machine. Existing +user-wide Crosshead hook commands for this runtime are listed in the preview +when setup replaces them. The runtime skips old Crosshead plugin and hook +registrations after scoped setup. Review and restart host sessions after a +scope change. Source excerpts are off for new bindings unless you choose +`--source-excerpts on`. Checkpoints can still contain prompts and summaries. + +Other hosts use `crosshead quickstart --adapter pi|omp|hermes|opencode`. The installer downloads one release tarball, checks its SHA-256 against the installer script, installs three npm dependencies (`@noble/curves`, @@ -102,8 +129,9 @@ a fresh session after choosing either setting. memory mode (`crosshead memory mode`). - Hot-memory documents are signed by the local identity before upload and are ignored on read if the signature, identity, or scope does not verify. Bounded - source-file excerpts are captured by default; set - `CROSSHEAD_CAPTURE_SOURCE_EVIDENCE=0` when that disclosure is not intended. + source-file capture follows each scoped binding's source excerpt choice. Legacy + installs capture excerpts by default and accept + `CROSSHEAD_CAPTURE_SOURCE_EVIDENCE=0` to opt out. - Paid recall (warm and cold memory) is selected and assembled by the hosted service. This client sends the turn's goal and workspace scope, but no hosted response field is injected into an agent prompt. Only locally signature- @@ -115,6 +143,17 @@ a fresh session after choosing either setting. - `crosshead status`, `crosshead doctor` and `crosshead logs` show what the runtime is doing. Local state lives in `~/.crosshead/state/`. +Set `CROSSHEAD_PROFILE` for explicit CLI commands or older adapter setups. +`ONLYBOTS_PROFILE` is the legacy alias. Explicit CLI `--profile` takes precedence. +Scoped hooks use the profile selected during setup. Use +`crosshead memory mode off --profile ` to stop automatic memory hooks for +that profile, or `crosshead workspace remove` to detach a project. + +Local-only checkpoints are stored per profile. Older flat snapshots in the +runtime's `state/local-hot-memory/*.json` lack a profile owner. Review those files +directly to recover saved findings. Automatic recall uses checkpoints with an +identified profile and workspace in scoped setup. + `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` diff --git a/docs/HOST-PLUGINS.md b/docs/HOST-PLUGINS.md index e981434..daca65f 100644 --- a/docs/HOST-PLUGINS.md +++ b/docs/HOST-PLUGINS.md @@ -53,18 +53,21 @@ 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: +The setup skill asks where Crosshead should run, which profile to use and +whether to include source excerpts. It previews the selected configuration: ```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 +crosshead quickstart --scope project --adapter claude-code --workspace-root . --profile work --dry-run --json +crosshead quickstart --scope project --adapter codex --workspace-root . --profile work --dry-run --json ``` -`--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 and -ask for no credentials. The Codex marketplace entry still carries +Apply the preview without `--dry-run`. Add `--api-base` if you choose a new +profile. The scoped runtime dispatches from its project hooks. It skips +legacy and plugin hook invocations. In Codex, trust the new hooks in +`/hooks`, then start a fresh session. `crosshead workspace check` and +`crosshead doctor` distinguish configured hooks from observed startup and +resume. 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. @@ -100,6 +103,10 @@ or the npm package. ## Hook modes, migration and doctor +The following legacy modes apply to installations that have yet to choose +a workspace scope. Use `crosshead quickstart` to preview moving to project, +all-project, or manual scope. + `crosshead install claude-code|codex` takes `--hook-mode auto|plugin|settings` (default `auto`, or the explicit mode of the previous install, so `crosshead update` repeats it). Plugin mode is chosen only when the plugin is diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json index 0746270..8a65317 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.1.0", + "version": "1.2.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", diff --git a/plugins/claude-code/scripts/crosshead-hook b/plugins/claude-code/scripts/crosshead-hook index e1928b1..ec476c1 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.1.0 +exec "$wrapper" "$event" --home "$home" --via plugin --plugin-version 1.2.0 diff --git a/plugins/claude-code/skills/setup/SKILL.md b/plugins/claude-code/skills/setup/SKILL.md index c2c4a48..2ca7dad 100644 --- a/plugins/claude-code/skills/setup/SKILL.md +++ b/plugins/claude-code/skills/setup/SKILL.md @@ -8,48 +8,36 @@ disable-model-invocation: true -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: +Crosshead shares one local runtime across projects and hosts. Start by asking +where the user wants automatic memory. Recommend the current project. Offer +additional directories, all projects, or manual commands only. Confirm the +profile for each directory; reuse an existing profile when the user selects +one. Explain that source excerpts can preserve exact workspace text and reach +the hosted service and later model prompts. Enable excerpts only on request. + +1. Check `crosshead --help` and `crosshead workspace list --json`. If the CLI is + missing or its help lacks `--scope`, explain the install and ask for explicit + consent before running `curl -fsSL https://crosshead.sh/install | bash`. + The installer verifies the downloaded release digest and installs the + runtime. A new hosted profile uses a local key and authenticates to its + selected service. When Crosshead is installed, reuse its runtime home. +2. Preview the user's selection. Replace the example profile name with the + user's choice. Add `--api-base https://crosshead.sh` only if they want to + create a new hosted profile. ```sh - curl -fsSL https://crosshead.sh/install | bash + crosshead quickstart --scope project --adapter claude-code --workspace-root . --profile WORK --dry-run --json ``` -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). 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` -followed by `crosshead quickstart --adapter `. Remove Crosshead from -Claude Code by uninstalling this plugin; `crosshead uninstall` removes the runtime. + Use `--scope all` for user-wide hooks or `--scope manual` for manual use. + Show the paths, profiles, source excerpt choices and any replaced hooks. + Apply the confirmed command without `--dry-run`. For further directories + with existing profiles, preview and apply `crosshead workspace add`. +3. Open a fresh session in the selected project. + Run `crosshead workspace check --json`. Report the configured profile and + whether startup or resume has been observed. Check an unselected directory + when the user chose project scope. + +Use `crosshead workspace remove` to detach a project. Keep machine-specific +hook entries local. Use an explicit `--profile` for memory commands when +working outside a configured project. diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json index 4cadda4..76520ec 100644 --- a/plugins/codex/.codex-plugin/plugin.json +++ b/plugins/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "crosshead", - "version": "1.1.0", + "version": "1.2.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", diff --git a/plugins/codex/scripts/crosshead-hook b/plugins/codex/scripts/crosshead-hook index e1928b1..ec476c1 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.1.0 +exec "$wrapper" "$event" --home "$home" --via plugin --plugin-version 1.2.0 diff --git a/plugins/codex/skills/setup/SKILL.md b/plugins/codex/skills/setup/SKILL.md index 7be2a33..091e280 100644 --- a/plugins/codex/skills/setup/SKILL.md +++ b/plugins/codex/skills/setup/SKILL.md @@ -7,51 +7,36 @@ description: Install or update the Crosshead runtime and connect it to Codex. Ru -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: +Crosshead shares one local runtime across projects and hosts. Start by asking +where the user wants automatic memory. Recommend the current project. Offer +additional directories, all projects, or manual commands only. Confirm the +profile for each directory; reuse an existing profile when the user selects +one. Explain that source excerpts can preserve exact workspace text and reach +the hosted service and later model prompts. Enable excerpts only on request. + +1. Check `crosshead --help` and `crosshead workspace list --json`. If the CLI is + missing or its help lacks `--scope`, explain the install and ask for explicit + consent before running `curl -fsSL https://crosshead.sh/install | bash`. + The installer verifies the downloaded release digest and installs the + runtime. A new hosted profile uses a local key and authenticates to its + selected service. When Crosshead is installed, reuse its runtime home. +2. Preview the user's selection. Replace the example profile name with the + user's choice. Add `--api-base https://crosshead.sh` only if they want to + create a new hosted profile. ```sh - curl -fsSL https://crosshead.sh/install | bash + crosshead quickstart --scope project --adapter codex --workspace-root . --profile WORK --dry-run --json ``` -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). 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` -followed by `crosshead quickstart --adapter `. Remove Crosshead from -Codex by uninstalling this plugin; `crosshead uninstall` removes the runtime. + Use `--scope all` for user-wide hooks or `--scope manual` for manual use. + Show the paths, profiles, source excerpt choices and any replaced hooks. + Apply the confirmed command without `--dry-run`. For further directories + with existing profiles, preview and apply `crosshead workspace add`. +3. Open a fresh session in the selected project and review the project’s hooks in `/hooks`. + Run `crosshead workspace check --json`. Report the configured profile and + whether startup or resume has been observed. Check an unselected directory + when the user chose project scope. + +Use `crosshead workspace remove` to detach a project. Keep machine-specific +hook entries local. Use an explicit `--profile` for memory commands when +working outside a configured project. diff --git a/scripts/host-plugins.mjs b/scripts/host-plugins.mjs index 883070b..0830735 100644 --- a/scripts/host-plugins.mjs +++ b/scripts/host-plugins.mjs @@ -30,18 +30,11 @@ const HOSTS = { dir: "plugins/claude-code", label: "Claude Code", 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", 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. 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.", }, }; @@ -115,57 +108,40 @@ ${host === "claude-code" ? "disable-model-invocation: true\n" : ""}--- -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}: +Crosshead shares one local runtime across projects and hosts. Start by asking +where the user wants automatic memory. Recommend the current project. Offer +additional directories, all projects, or manual commands only. Confirm the +profile for each directory; reuse an existing profile when the user selects +one. Explain that source excerpts can preserve exact workspace text and reach +the hosted service and later model prompts. Enable excerpts only on request. + +1. Check \`crosshead --help\` and \`crosshead workspace list --json\`. If the CLI is + missing or its help lacks \`--scope\`, explain the install and ask for explicit + consent before running \`curl -fsSL https://crosshead.sh/install | bash\`. + The installer verifies the downloaded release digest and installs the + runtime. A new hosted profile uses a local key and authenticates to its + selected service. When Crosshead is installed, reuse its runtime home. +2. Preview the user's selection. Replace the example profile name with the + user's choice. Add \`--api-base https://crosshead.sh\` only if they want to + create a new hosted profile. \`\`\`sh - crosshead quickstart --api-base https://crosshead.sh --adapter ${host} --workspace-root . --hook-mode plugin + crosshead quickstart --scope project --adapter ${host} --workspace-root . --profile WORK --dry-run --json \`\`\` - \`--hook-mode plugin\` keeps quickstart from writing its own ${info.label} hooks - (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\` -followed by \`crosshead quickstart --adapter \`. Remove Crosshead from -${info.label} by uninstalling this plugin; \`crosshead uninstall\` removes the runtime. + Use \`--scope all\` for user-wide hooks or \`--scope manual\` for manual use. + Show the paths, profiles, source excerpt choices and any replaced hooks. + Apply the confirmed command without \`--dry-run\`. For further directories + with existing profiles, preview and apply \`crosshead workspace add\`. +3. Open a fresh session in the selected project${host === "codex" + ? " and review the project’s hooks in \`/hooks\`" : ""}. + Run \`crosshead workspace check --json\`. Report the configured profile and + whether startup or resume has been observed. Check an unselected directory + when the user chose project scope. + +Use \`crosshead workspace remove\` to detach a project. Keep machine-specific +hook entries local. Use an explicit \`--profile\` for memory commands when +working outside a configured project. `; }; diff --git a/src/runtime/claude-code-adapter.ts b/src/runtime/claude-code-adapter.ts index 6042b0d..6910cae 100644 --- a/src/runtime/claude-code-adapter.ts +++ b/src/runtime/claude-code-adapter.ts @@ -58,7 +58,7 @@ import { type HostAdapterTurnPrepareRequest, type HostAdapterTurnPrepareResult, } from "./host-adapter-base"; -import { resolveClaudeCodeBinaryPath, resolveClaudeCodeHostHome } from "./runtime-env"; +import { resolveClaudeCodeBinaryPath, resolveClaudeCodeHostHome, resolveCrossheadProfile } from "./runtime-env"; import { PLUGIN_DISPATCH_CAPABILITY, renderHostHookGroups, type HostHookEvent } from "./host-hook-spec"; import { claudeLoadedSettingsFiles, @@ -95,6 +95,8 @@ export type ClaudeCodeAdapterConfig = { hostHome: string; workspaceRoot: string; profileName: string; + sourceExcerpts?: boolean; + workspaceOnly?: boolean; claudeBinary: string; /** Absent in configs written before host plugins; treated as "settings". */ hookMode?: HostHookMode; @@ -784,13 +786,11 @@ export const loadClaudeCodeAdapterConfig = ( if (parsed.marker !== CLAUDE_CODE_MANAGED_CONFIG_MARKER) { throw new Error("Claude Code adapter config is not managed by Crosshead"); } - if (runtimeHomeInput && parsed.runtimeHome !== resolveRuntimeHome(runtimeHomeInput)) { - return { - ...parsed, - runtimeHome: resolveRuntimeHome(runtimeHomeInput), - }; - } - return parsed; + return { + ...parsed, + runtimeHome: runtimeHomeInput ? resolveRuntimeHome(runtimeHomeInput) : parsed.runtimeHome, + profileName: resolveCrossheadProfile() ?? parsed.profileName, + }; }; const CLAUDE_CODE_ADAPTER_IDENTITY: HostAdapterIdentity = { @@ -822,6 +822,18 @@ export class ClaudeCodeRuntimeAdapter extends HostRuntimeAdapterBase { ); } + static forWorkspace(home: string, hostHome: string, workspaceRoot: string, profileName: string, sourceExcerpts: boolean): ClaudeCodeRuntimeAdapter { + return new ClaudeCodeRuntimeAdapter(new LocalRuntimeClient({ home, timeoutMs: 30_000 }), { + ...buildClaudeCodeAdapterConfig({ + runtimeHome: home, hostHome, workspaceRoot, profileName, + adapterVersion: ONLYBOTS_CLAUDE_CODE_ADAPTER_VERSION, claudeBinary: "claude", + hookMode: "settings", hookModeRequest: null, + }), + sourceExcerpts, + workspaceOnly: true, + }); + } + protected async attachWorkspace(input: { profileName?: string; workspaceRoot: string; diff --git a/src/runtime/claude-code-hook.ts b/src/runtime/claude-code-hook.ts index 0a61ee9..413d890 100644 --- a/src/runtime/claude-code-hook.ts +++ b/src/runtime/claude-code-hook.ts @@ -20,7 +20,8 @@ import { writePluginSetupNotice, } from "./host-plugin-mode"; import { ensureRuntimeHomeLayout, resolveRuntimeHome } from "./local-runtime"; -import { writeJsonFile } from "./runtime-persistence"; +import { loadProfile, resolveRuntimePaths, writeJsonFile } from "./runtime-persistence"; +import { readWorkspaceHookPolicy, recordWorkspaceHookStart, workspaceHookForInvocation, workspaceHookStateFile } from "./workspace-hooks"; import { assertSecureNodeRuntimeVersion } from "./node-runtime-version"; type HookTurnState = { @@ -119,8 +120,7 @@ const normalizeHookTurnState = ( const hookTurnKey = (sessionId: string | null, turnId: string | null): string | null => sessionId || turnId ? `${sessionId ?? "-"}:${turnId ?? "-"}` : null; -const loadHookState = (runtimeHome: string, workspaceRoot: string, workspaceId: string): HookState => { - const filePath = hookStateFile(runtimeHome); +const loadHookState = (filePath: string, workspaceRoot: string, workspaceId: string): HookState => { if (!existsSync(filePath)) { return defaultHookState(workspaceRoot, workspaceId); } @@ -159,8 +159,8 @@ const loadHookState = (runtimeHome: string, workspaceRoot: string, workspaceId: } }; -const saveHookState = (runtimeHome: string, state: HookState): void => { - writeJsonFile(hookStateFile(runtimeHome), state); +const saveHookState = (stateFile: string, state: HookState): void => { + writeJsonFile(stateFile, state); }; const selectHookTurnState = (state: HookState, sessionId: string | null, turnId: string | null): HookTurnState => { @@ -192,17 +192,17 @@ const selectHookTurnState = (state: HookState, sessionId: string | null, turnId: return state; }; -const saveHookTurnState = (runtimeHome: string, state: HookState, turn: HookTurnState): void => { +const saveHookTurnState = (stateFile: string, state: HookState, turn: HookTurnState): void => { const activeTurns = { ...state.activeTurns }; const key = hookTurnKey(turn.sessionId, turn.turnId); if (key) { activeTurns[key] = turn; } - saveHookState(runtimeHome, { ...state, ...turn, activeTurns }); + saveHookState(stateFile, { ...state, ...turn, activeTurns }); }; const clearHookTurnState = ( - runtimeHome: string, + stateFile: string, state: HookState, sessionId: string | null, turnId: string | null, @@ -224,7 +224,7 @@ const clearHookTurnState = ( lastContextReadAt: fallback.lastContextReadAt, updatedAt: nowIso(), }; - saveHookState(runtimeHome, { ...next, activeTurns }); + saveHookState(stateFile, { ...next, activeTurns }); }; const getNestedValue = (payload: Record | null, dottedKey: string): unknown => { @@ -286,7 +286,8 @@ const resolvePathCandidate = (value: string): string => (path.isAbsolute(value) const resolveHookWorkspaceRoot = ( payload: Record | null, - flags: Record + flags: Record, + useEnvironment = true ): string => { const candidate = pickFlagString(flags, ["workspace-root", "workspaceRoot"]) ?? @@ -302,7 +303,7 @@ const resolveHookWorkspaceRoot = ( "payload.workspaceRoot", "payload.cwd", ]) ?? - pickEnvString(["ONLYBOTS_WORKSPACE_ROOT", "BENCH_WORKSPACE"]); + (useEnvironment ? pickEnvString(["ONLYBOTS_WORKSPACE_ROOT", "BENCH_WORKSPACE"]) : null); return candidate ? resolvePathCandidate(candidate) : process.cwd(); }; @@ -374,17 +375,27 @@ export const runOnlyBotsClaudeCodeHookCli = async ( const normalizedEventName = eventName.toLowerCase(); const runtimeHome = resolveRuntimeHome(typeof flags.home === "string" ? flags.home : undefined); const viaPlugin = isPluginHookInvocation(flags); - const hostHome = - typeof flags["claude-home"] === "string" ? flags["claude-home"] : viaPlugin ? resolvePluginHostHome("claude-code", runtimeHome) : undefined; + const paths = resolveRuntimePaths(runtimeHome); + const policy = readWorkspaceHookPolicy(paths); + // Ignore retired settings and plugin registrations before reading the chat payload. + if (policy && !policy.bindings.some((binding) => binding.host === "claude-code" && binding.id === flags.binding)) return; const isToolFailure = normalizedEventName === "posttoolusefailure" || normalizedEventName === "post_tool_use_failure"; const isPostToolUse = isToolFailure || normalizedEventName === "posttooluse" || normalizedEventName === "post_tool_use"; - // This hook runs after every matching tool call. With evidence capture off - // (the default) it has nothing to record, so leave before any other work. - if (isPostToolUse && !taskEvidenceCaptureEnabled()) return; + const selectedBinding = policy?.bindings.find((binding) => binding.id === flags.binding); + if (isPostToolUse && !(selectedBinding?.sourceExcerpts ?? taskEvidenceCaptureEnabled())) return; const payload = readStdinJson(stdinOverride); - const workspaceRoot = resolveHookWorkspaceRoot(payload, flags); - const workspaceId = resolveHookWorkspaceId(payload, flags, workspaceRoot); - if (eventName !== "help") { + const scoped = workspaceHookForInvocation(policy, "claude-code", flags.binding, resolveHookWorkspaceRoot(payload, {}, false)); + if (scoped === null) return; + const hostHome = scoped?.binding.hostHome ?? + (typeof flags["claude-home"] === "string" ? flags["claude-home"] : viaPlugin ? resolvePluginHostHome("claude-code", runtimeHome) : undefined); + const workspaceRoot = scoped?.root ?? resolveHookWorkspaceRoot(payload, flags); + const workspaceId = scoped ? `repo:${workspaceRoot}` : resolveHookWorkspaceId(payload, flags, workspaceRoot); + if (scoped) { + const profile = loadProfile(paths, scoped.binding.profile); + if (!profile) throw new Error(`Unknown Crosshead profile: ${scoped.binding.profile}`); + if (profile.memoryMode === "off") return; + } + if (!scoped && eventName !== "help") { let installedConfig: ReturnType | null = null; try { installedConfig = loadClaudeCodeAdapterConfig(runtimeHome, hostHome); @@ -398,6 +409,7 @@ export const runOnlyBotsClaudeCodeHookCli = async ( } return; } + if (loadProfile(paths, installedConfig.profileName)?.memoryMode === "off") return; if (viaPlugin) { try { recordPluginInvocation(runtimeHome, "claude-code", typeof flags["plugin-version"] === "string" ? flags["plugin-version"] : null, claudeUserConfigDir()); @@ -418,11 +430,14 @@ export const runOnlyBotsClaudeCodeHookCli = async ( }); if (decision === "skip-duplicate") return; } - const adapter = ClaudeCodeRuntimeAdapter.fromInstalled({ - home: runtimeHome, - hostHome, - }); - const state = loadHookState(runtimeHome, workspaceRoot, workspaceId); + const adapter = scoped + ? ClaudeCodeRuntimeAdapter.forWorkspace(runtimeHome, scoped.binding.hostHome, workspaceRoot, scoped.binding.profile, scoped.binding.sourceExcerpts) + : ClaudeCodeRuntimeAdapter.fromInstalled({ + home: runtimeHome, + hostHome, + }); + const stateFile = scoped ? workspaceHookStateFile(paths, scoped.binding, workspaceRoot) : hookStateFile(runtimeHome); + const state = loadHookState(stateFile, workspaceRoot, workspaceId); if (eventName === "help") { process.stdout.write( @@ -477,7 +492,12 @@ export const runOnlyBotsClaudeCodeHookCli = async ( hostHome, hostVersion: pickString(payload, ["claude_version", "claudeVersion", "client.version"]) ?? "claude-code-hook", }); - saveHookState(runtimeHome, { + if (scoped) { + try { + recordWorkspaceHookStart(paths, scoped.binding, workspaceRoot, pickString(payload, ["source", "payload.source"])); + } catch { /* Diagnostics must not interrupt the host session. */ } + } + saveHookState(stateFile, { ...state, sessionId, workspaceRoot, @@ -517,7 +537,7 @@ export const runOnlyBotsClaudeCodeHookCli = async ( taskEvidence: await adapter.captureTaskEvidence(pickString(payload, ["transcript_path", "transcriptPath", "conversation_path", "conversationPath"]), sessionId, turnWorkspaceRoot, turnState.lastContextReadAt, prompt), }, }); - saveHookTurnState(runtimeHome, state, { + saveHookTurnState(stateFile, state, { ...turnState, sessionId, turnId, @@ -559,7 +579,7 @@ export const runOnlyBotsClaudeCodeHookCli = async ( goal: prompt, }, }); - saveHookTurnState(runtimeHome, state, { + saveHookTurnState(stateFile, state, { ...turnState, sessionId, turnId, @@ -597,7 +617,7 @@ export const runOnlyBotsClaudeCodeHookCli = async ( workspaceRoot: turnWorkspaceRoot, workspaceId: turnState.workspaceId, sessionId, - hostHome, + hostHome, hostVersion: pickString(payload, ["claude_version", "claudeVersion", "client.version"]) ?? "claude-code-hook", }); } @@ -615,7 +635,7 @@ export const runOnlyBotsClaudeCodeHookCli = async ( sessionStart: (prompt ?? "").trim() ? claimSessionStartContext(runtimeHome, "claude-code", turnState.workspaceId, sessionId) : false, transcript: prompt ? [{ role: "user", content: prompt }] : [], }); - saveHookTurnState(runtimeHome, state, { + saveHookTurnState(stateFile, state, { sessionId, turnId, prompt, @@ -693,7 +713,7 @@ export const runOnlyBotsClaudeCodeHookCli = async ( packId, goal: prompt ?? undefined, summary, - hostHome, + hostHome, hostVersion, transcript, taskEvidence, @@ -707,7 +727,7 @@ export const runOnlyBotsClaudeCodeHookCli = async ( packId, goal: prompt ?? undefined, summary, - hostHome, + hostHome, hostVersion, transcript, taskEvidence, @@ -736,7 +756,7 @@ export const runOnlyBotsClaudeCodeHookCli = async ( transcript, taskEvidence, }); - clearHookTurnState(runtimeHome, state, sessionId, turnId, { + clearHookTurnState(stateFile, state, sessionId, turnId, { workspaceRoot: turnWorkspaceRoot, workspaceId: turnState.workspaceId, lastContextReadAt: turnState.lastContextReadAt, diff --git a/src/runtime/cli.ts b/src/runtime/cli.ts index 5dd84e2..5533f88 100644 --- a/src/runtime/cli.ts +++ b/src/runtime/cli.ts @@ -23,6 +23,13 @@ import { quickstartFromCli } from "./cli/quickstart"; import { buildRuntimeBillingRemediation, formatRuntimeBillingRemediation } from "./billing-remediation"; import { HostedOnlyBotsRequestError } from "./hosted-client"; import { assertSecureNodeRuntimeVersion } from "./node-runtime-version"; +import { resolveCrossheadProfile } from "./runtime-env"; +import { parseArgs } from "./adapter-utils"; +import { scopedQuickstartFromCli, uninstallWorkspaceHostFromCli, workspaceHooksFromCli } from "./cli/workspace"; +import { runOnlyBotsCodexHookCli } from "./codex-hook"; +import { runOnlyBotsClaudeCodeHookCli } from "./claude-code-hook"; +import { reportHookFailure } from "./hook-runtime"; +import { runCheckpointPageCommand } from "./checkpoint-pages"; const renderOnlyBotsCtlCliError = ( argv: string[], @@ -78,6 +85,8 @@ export const runOnlyBotsCtlCli = async ( extensions: OnlyBotsCtlCliExtensions = {} ): Promise => { assertSecureNodeRuntimeVersion(); + const profile = resolveCrossheadProfile(); + if (profile && !argv.includes("--profile")) argv = [...argv, "--profile", profile]; const command = argv[0]; const rest = argv.slice(1); const extension = command ? extensions.commands?.[command] : undefined; @@ -144,7 +153,7 @@ export const runOnlyBotsCtlCli = async ( output = await eventsFromCli(rest); break; case "workspace": - output = await workspaceFromCli(rest); + output = await (rest[0] === "link" ? workspaceFromCli(rest) : workspaceHooksFromCli(rest)); break; case "memory": output = await memoryFromCli(rest); @@ -198,16 +207,38 @@ export const runOnlyBotsCtlCli = async ( output = await capabilitiesFromCli(rest); break; case "quickstart": - case "onboard": - output = await quickstartFromCli(rest, { + case "onboard": { + const { flags } = parseArgs(rest); + const adapter = flags.adapter ?? flags.host; + const guided = !adapter && !flags.scope && !flags["hook-mode"] && !flags.json && !flags["dry-run"] && + Boolean(process.stdin.isTTY && process.stderr.isTTY); + const scoped = guided || flags.scope !== undefined || flags["dry-run"] !== undefined || (!flags["hook-mode"] && + (adapter === "codex" || adapter === "claude-code" || adapter === "both")); + const scripts = { cliScriptPath, daemonScriptPath, codexBridgeScriptPath, codexHookScriptPath, claudeCodeBridgeScriptPath, claudeCodeHookScriptPath, - }); - break; + }; + output = scoped ? await scopedQuickstartFromCli(flags, scripts, guided) : await quickstartFromCli(rest, scripts); + break; + } + case "checkpoint-page": { + const { positionals, flags } = parseArgs([command, ...rest]); + const host = flags.runtime; + if (host !== "codex" && host !== "claude-code") throw new Error("Choose --runtime codex or claude-code"); + runCheckpointPageCommand(host, positionals, flags); + return; + } + case "host-hook": { + const [host, ...args] = rest; + if (host !== "codex" && host !== "claude-code") throw new Error("Unknown Crosshead hook host"); + const runHook = host === "codex" ? runOnlyBotsCodexHookCli : runOnlyBotsClaudeCodeHookCli; + await runHook(args).catch((error: unknown) => reportHookFailure(host, args[0], error)); + return; + } case "start": output = await startFromCli(rest); break; @@ -229,11 +260,11 @@ export const runOnlyBotsCtlCli = async ( break; } if (rest[0] === "codex") { - output = await uninstallCodexFromCli(rest.slice(1)); + output = await uninstallWorkspaceHostFromCli("codex", rest.slice(1)) ?? await uninstallCodexFromCli(rest.slice(1)); break; } if (rest[0] === "claude-code") { - output = await uninstallClaudeCodeFromCli(rest.slice(1)); + output = await uninstallWorkspaceHostFromCli("claude-code", rest.slice(1)) ?? await uninstallClaudeCodeFromCli(rest.slice(1)); break; } if (rest[0] === "pi") { @@ -259,7 +290,9 @@ export const runOnlyBotsCtlCli = async ( "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 ] [--hook-mode auto|plugin|settings] [--omp-bin ] [--create-checkout] [--json]", + "crosshead quickstart # guided project setup", + "crosshead quickstart --scope project|all|manual [--adapter codex|claude-code|both] [--workspace-root ] [--profile ] [--api-base ] [--source-excerpts on|off] [--dry-run] [--json]", + "crosshead quickstart --adapter pi|omp|hermes|opencode|none [--api-base ] [--profile ] [--json]", capabilityHelpLine, "crosshead keygen [--json]", "crosshead keygen --setup --profile --api-base [--home ] [--memory-mode assist] [--workspace-scope repo|workspace|global] [--json]", @@ -278,6 +311,9 @@ export const runOnlyBotsCtlCli = async ( "crosshead update [adapter] [--home ] [--channel stable|beta|canary] [--version ]", "crosshead rollback [adapter] --to [--home ]", "crosshead doctor [--home ] [--workspace-root ] [--json]", + "crosshead workspace add --workspace-root --adapter codex|claude-code|both --profile [--source-excerpts on|off] [--dry-run] [--json]", + "crosshead workspace remove --workspace-root [--adapter codex|claude-code|both] [--dry-run] [--json]", + "crosshead workspace list|check [--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 fd100be..4926d84 100644 --- a/src/runtime/cli/core.ts +++ b/src/runtime/cli/core.ts @@ -5,6 +5,7 @@ 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"; +import { inspectWorkspaceHooks } from "../workspace-hooks"; export const installCoreFromCli = async (argv: string[], cliScriptPath: string, daemonScriptPath: string): Promise => { const { flags } = parseArgs(argv); @@ -119,7 +120,8 @@ const parseSetupCliOptions = ( apiBase: String(flags["api-base"] ?? flags.apiBase ?? ""), memoryMode: (flags["memory-mode"] as RuntimeMemoryMode | undefined) ?? "assist", workspaceScope: (flags["workspace-scope"] as "repo" | "workspace" | "global" | undefined) ?? "repo", - makeDefault: true, + makeDefault: flags["no-default"] !== true, + createOnly: flags["create-only"] === true, }); export const setupProfileFromCli = async (argv: string[]): Promise => { @@ -205,9 +207,20 @@ export const doctorFromCli = async (argv: string[]): Promise => { // 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 workspaceHooks = inspectWorkspaceHooks(home, workspaceRoot); + const hookChecks = workspaceHooks.scope === "legacy" + ? inspectHostHookRegistrations(home, workspaceRoot) + : workspaceHooks.bindings.length + ? workspaceHooks.bindings.map((binding) => ({ + id: `${binding.host.replace("-", "_")}_hooks`, + ok: binding.configured && binding.memoryMode !== null, + message: `profile=${binding.profile}; ${!binding.configured ? "hook settings missing or changed" : + binding.memoryMode === null ? "profile missing" : binding.memoryMode === "off" ? "memory off" : + binding.startup || binding.resume ? "observed in host" : "configured; waiting for host"}. ${binding.file}`, + })) + : [{ id: "workspace_hooks", ok: true, message: "No automatic Crosshead hooks apply in this directory." }]; const checks = [...daemonReport.checks, ...hookChecks]; - const report = { ...daemonReport, ok: checks.every((check) => check.ok), checks }; + const report = { ...daemonReport, ok: checks.every((check) => check.ok), checks, workspaceHooks }; return flags.json ? JSON.stringify(report, null, 2) : formatRuntimeDoctor(report); }; diff --git a/src/runtime/cli/memory.ts b/src/runtime/cli/memory.ts index d07da94..5e63238 100644 --- a/src/runtime/cli/memory.ts +++ b/src/runtime/cli/memory.ts @@ -135,6 +135,14 @@ export const memoryFromCli = async (argv: string[]): Promise => { }, profileName ); + if (result.scope === "local_hot_memory") { + if (flags.json || flags.debug) return JSON.stringify(result, null, 2); + const matches = Array.isArray(result.results) ? result.results as Array> : []; + return [ + `Local hot memory · ${matches.length} matches`, + ...matches.map((entry) => `${entry.source ?? entry.kind}: ${entry.text}`), + ].join("\n"); + } const presentation = buildMemorySearchPresentation( query, typeof flags["top-k"] === "string" ? Number(flags["top-k"]) : 10, diff --git a/src/runtime/cli/workspace.ts b/src/runtime/cli/workspace.ts new file mode 100644 index 0000000..3322b9c --- /dev/null +++ b/src/runtime/cli/workspace.ts @@ -0,0 +1,232 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { createInterface } from "node:readline/promises"; +import { parseArgs, shellQuote } from "../adapter-utils"; +import type { HookHost } from "../host-hook-spec"; +import { loadProfile, loadRuntimeConfig, resolveRuntimePaths } from "../runtime-persistence"; +import { + applyWorkspaceHookPlan, assertWorkspacePlanCurrent, inspectWorkspaceHooks, planWorkspaceHooks, readWorkspaceHookPolicy, summarizeWorkspaceHookPlan, + withWorkspaceSetupLock, workspaceProjectRoot, type HookScope, type WorkspaceHookTarget, +} from "../workspace-hooks"; +import { installCoreFromCli, keygenFromCli } from "./core"; +import { ensureQuickstartDaemon, type QuickstartCliPaths } from "./quickstart"; + +type Flags = Record; +const str = (flags: Flags, key: string): string | undefined => typeof flags[key] === "string" ? flags[key] : undefined; +const enabled = (flags: Flags, key: string): boolean => flags[key] === true || flags[key] === "true"; +const selectedHosts = (value: string): HookHost[] => { + if (value === "both") return ["codex", "claude-code"]; + if (value === "codex" || value === "claude-code") return [value]; + throw new Error("Choose --adapter codex, claude-code, or both"); +}; +const scopeValue = (value: string): HookScope => { + if (value === "project" || value === "all" || value === "manual") return value; + throw new Error("Choose --scope project, all, or manual"); +}; +const sourceExcerpts = (flags: Flags): boolean | undefined => { + const value = flags["source-excerpts"]; + if (value === undefined) return undefined; + if (value === "off") return false; + if (value === true || value === "on") return true; + throw new Error("Choose --source-excerpts on or off"); +}; +const planOptions = (flags: Flags) => ({ + home: str(flags, "home"), codexHome: str(flags, "codex-home"), claudeHome: str(flags, "claude-home"), +}); +const homeArgs = (home?: string): string[] => home ? ["--home", home] : []; + +const describePlan = (plan: ReturnType): string => [ + `Crosshead scope: ${plan.scope === "project" ? "selected projects" : plan.scope === "all" ? "all projects" : "manual commands only"}`, + ...plan.bindings.map((binding) => ` ${binding.root ?? "Every directory"} · ${binding.host} · profile ${binding.profile} · source excerpts ${binding.sourceExcerpts ? "on" : "off"}`), + ...(plan.replacesUserHooksIn.length ? ["Replaces earlier Crosshead hooks in", ...plan.replacesUserHooksIn.map((file) => ` ${file}`)] : []), + ...(plan.files.length ? ["Files to update", ...plan.files.map((file) => ` ${file}`)] : ["Hook settings already match"]), +].join("\n"); + +export const formatWorkspaceHookInspection = (inspection: ReturnType): string => { + if (inspection.scope === "legacy") return "Crosshead uses the existing host setup. Run crosshead quickstart to choose a scope."; + if (inspection.bindings.length === 0) return "No automatic Crosshead hooks apply here."; + return inspection.bindings.map((binding) => { + const status = !binding.configured ? "hook settings missing or changed" : + !binding.memoryMode ? "profile missing" : binding.memoryMode === "off" ? "memory off" : + binding.startup || binding.resume ? "observed in host" : + !inspection.workspace && !binding.root ? "configured; check from a project" : "configured; waiting for host"; + return [ + `${binding.host} · ${binding.root ?? "all projects"} · profile ${binding.profile} · ${status}`, + ` ${binding.file}`, + ...(binding.startup ? [` Startup observed ${binding.startup.observedAt}`] : []), + ...(binding.resume ? [` Resume observed ${binding.resume.observedAt}`] : []), + ...(binding.configured && binding.memoryMode && binding.memoryMode !== "off" && !binding.startup && !binding.resume + ? [binding.host === "codex" + ? " Open the project in Codex, review its hooks in /hooks, then open a fresh session." + : " Open a fresh Claude Code session in this project."] : []), + ].join("\n"); + }).join("\n"); +}; + +export const workspaceHooksFromCli = async (argv: string[]): Promise => { + const { positionals, flags } = parseArgs(argv); + const command = positionals[0]; + const options = planOptions(flags); + const directory = str(flags, "workspace-root") ?? process.cwd(); + if (command === "list" || command === "check") { + const inspection = inspectWorkspaceHooks(options.home, command === "check" ? directory : undefined); + return flags.json ? JSON.stringify(inspection, null, 2) : formatWorkspaceHookInspection(inspection); + } + if (command !== "add" && command !== "remove") throw new Error("Choose workspace add, remove, list, check, or link"); + const hosts = selectedHosts(str(flags, "adapter") ?? (command === "remove" ? "both" : "")); + const paths = resolveRuntimePaths(options.home); + const profile = str(flags, "profile") ?? loadRuntimeConfig(paths).defaultProfile; + if (command === "add" && (!profile || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(profile) || !loadProfile(paths, profile))) { + throw new Error("Choose an existing --profile, or run crosshead quickstart to create one."); + } + const current = inspectWorkspaceHooks(options.home); + if (command === "add" && current.scope === "all") { + throw new Error("All-project hooks are active. Use crosshead quickstart --scope project to preview changing scope."); + } + if (command === "remove" && current.scope !== "project") { + throw new Error("Project removal requires project scope. Run crosshead quickstart --scope manual to turn off automatic hooks."); + } + const plan = planWorkspaceHooks({ + ...options, scope: "project", + targets: command === "add" ? [{ directory, hosts, profile: profile!, sourceExcerpts: sourceExcerpts(flags) }] : [], + ...(command === "remove" ? { remove: { directory, hosts } } : {}), + }); + const preview = summarizeWorkspaceHookPlan(plan); + if (enabled(flags, "dry-run")) return flags.json ? JSON.stringify({ applied: false, ...preview }, null, 2) : describePlan(preview); + if (!existsSync(path.join(paths.bin, "crosshead"))) throw new Error("Install the Crosshead CLI first with crosshead install."); + await withWorkspaceSetupLock(paths, async () => applyWorkspaceHookPlan(plan)); + const inspection = inspectWorkspaceHooks(options.home, existsSync(directory) ? directory : undefined); + return flags.json ? JSON.stringify({ applied: true, ...inspection }, null, 2) : formatWorkspaceHookInspection(inspection); +}; + +export const uninstallWorkspaceHostFromCli = async (host: HookHost, argv: string[]): Promise => { + const options = planOptions(parseArgs(argv).flags); + const paths = resolveRuntimePaths(options.home); + if (!readWorkspaceHookPolicy(paths)) return null; + await withWorkspaceSetupLock(paths, async () => { + const policy = readWorkspaceHookPolicy(paths); + if (!policy) throw new Error("Workspace scope changed during uninstall. Run the command again."); + const plan = planWorkspaceHooks({ + ...options, + scope: policy.bindings.every((binding) => binding.host === host) ? "manual" : policy.scope, + targets: [], removeHost: host, cleanup: true, + }); + applyWorkspaceHookPlan(plan); + }); + return `Detached automatic Crosshead hooks for ${host}.`; +}; + +const guidedSetup = async (flags: Flags, fallbackProfile: string, profiles: string[]) => { + const terminal = createInterface({ input: process.stdin, output: process.stderr }); + const ask = async (question: string, fallback: string): Promise => + (await terminal.question(`${question} [${fallback}] `)).trim() || fallback; + try { + process.stderr.write("Where should Crosshead run for Codex and Claude Code?\n 1 This project (recommended)\n 2 All projects\n 3 Manual commands only\n"); + const choice = await ask("Choose 1, 2, or 3", "1"); + if (!["1", "2", "3"].includes(choice)) throw new Error("Choose 1, 2, or 3"); + const scope: HookScope = choice === "1" ? "project" : choice === "2" ? "all" : "manual"; + if (profiles.length) process.stderr.write(`Existing profiles: ${profiles.join(", ")}\n`); + const targets: WorkspaceHookTarget[] = []; + do { + const directory = scope === "project" ? await ask("Project directory", str(flags, "workspace-root") ?? process.cwd()) : process.cwd(); + const hosts = scope === "manual" ? [] : selectedHosts(await ask("Host (codex, claude-code, or both)", "codex")); + const profile = await ask("Profile name (choose an existing name or enter a new one)", fallbackProfile); + let capture = false; + if (hosts.length) { + process.stderr.write("Checkpoints can include your prompt and a summary. Source excerpts also preserve exact text from workspace reads. Synced excerpts can reach the service and later model prompts.\n"); + capture = /^y(es)?$/i.test(await ask("Include source excerpts?", "no")); + } + targets.push({ directory, hosts, profile, sourceExcerpts: capture }); + } while (scope === "project" && /^y(es)?$/i.test(await ask("Add another project?", "no"))); + return { scope, targets, confirm: async (preview: string) => { + process.stderr.write(`\n${preview}\n`); + return /^y(es)?$/i.test(await ask("Apply this setup?", "no")); + }, close: () => terminal.close() }; + } catch (error) { + terminal.close(); + throw error; + } +}; + +export const scopedQuickstartFromCli = async (flags: Flags, scripts: QuickstartCliPaths, guided: boolean): Promise => { + if (flags.scope === true) throw new Error("Choose --scope project, all, or manual"); + if (flags["hook-mode"] !== undefined) throw new Error("Choose a workspace --scope or legacy --hook-mode"); + const options = planOptions(flags); + const paths = resolveRuntimePaths(options.home); + const fallbackProfile = str(flags, "profile") ?? loadRuntimeConfig(paths).defaultProfile ?? "default"; + const knownProfiles = existsSync(paths.profiles) + ? readdirSync(paths.profiles).filter((file) => file.endsWith(".json")).map((file) => file.slice(0, -5)) + : []; + const flow = guided ? await guidedSetup(flags, fallbackProfile, knownProfiles) : null; + try { + const scope = flow?.scope ?? scopeValue(str(flags, "scope") ?? "project"); + const targets = flow?.targets ?? [{ + directory: str(flags, "workspace-root") ?? process.cwd(), + hosts: scope === "manual" ? [] : selectedHosts(str(flags, "adapter") ?? str(flags, "host") ?? "codex"), + profile: fallbackProfile, sourceExcerpts: sourceExcerpts(flags), + }]; + const names = [...new Set(targets.map((target) => target.profile))]; + if (names.some((name) => !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name))) throw new Error("Profile names must use letters, numbers, dots, underscores or hyphens."); + const profileSnapshot = (name: string) => { + const profile = loadProfile(paths, name); + return profile ? JSON.stringify([profile.npub, profile.apiBase, profile.memoryMode, profile.workspaceScope]) : null; + }; + const profilesBefore = new Map(names.map((name) => [name, profileSnapshot(name)])); + const newProfiles = names.filter((name) => profilesBefore.get(name) === null); + const apiBase = str(flags, "api-base") ?? str(flags, "apiBase") ?? (guided ? "https://crosshead.sh" : undefined); + if (newProfiles.length > 0 && !apiBase) throw new Error("Supply --api-base to create a new profile."); + const explicitMode = str(flags, "memory-mode"); + const mode = explicitMode ?? (scope === "manual" ? "manual" : "assist"); + if (!["off", "manual", "assist", "auto"].includes(mode)) throw new Error("Choose --memory-mode off, manual, assist, or auto"); + for (const name of names) { + const profile = loadProfile(paths, name); + if (profile && explicitMode && profile.memoryMode !== explicitMode) { + throw new Error(`Profile ${name} uses ${profile.memoryMode}. To change it, run crosshead memory mode ${explicitMode} --profile ${shellQuote(name)}.`); + } + } + if (flags["create-checkout"]) throw new Error("Run crosshead billing checkout separately after project setup."); + const plan = planWorkspaceHooks({ ...options, scope, targets: scope === "manual" ? [] : targets }); + const executable = path.join(paths.bin, "crosshead"); + const installRuntime = !existsSync(executable) || + !readFileSync(executable, "utf8").includes(shellQuote(scripts.cliScriptPath)) || + !existsSync(path.join(paths.bin, "crossheadd")); + const preview = { ...summarizeWorkspaceHookPlan(plan), installRuntime, runtimeHome: paths.home, newProfiles, apiBase: newProfiles.length > 0 ? apiBase : null }; + const runtimePlan = installRuntime ? `Set up runtime files under ${paths.home}.\n` : ""; + const profilePlan = newProfiles.length > 0 + ? `Create ${newProfiles.join(", ")} at ${apiBase}; private keys stay on this machine.\n` : ""; + if (enabled(flags, "dry-run")) { + return flags.json ? JSON.stringify({ applied: false, ...preview }, null, 2) : runtimePlan + profilePlan + describePlan(preview); + } + if (flow && !await flow.confirm(runtimePlan + profilePlan + describePlan(preview))) return "Setup cancelled."; + const daemon = await withWorkspaceSetupLock(paths, async () => { + assertWorkspacePlanCurrent(plan); + if (names.some((name) => profileSnapshot(name) !== profilesBefore.get(name))) { + throw new Error("A selected profile changed since preview. Run setup again to review it."); + } + const args = homeArgs(options.home); + if (installRuntime) { + const channel = str(flags, "channel") ?? loadRuntimeConfig(paths).channel; + await installCoreFromCli([...args, "--channel", channel], scripts.cliScriptPath, scripts.daemonScriptPath); + } + for (const name of newProfiles) { + await keygenFromCli([...args, "--setup", "--profile", name, "--api-base", apiBase!, "--memory-mode", mode, + "--workspace-scope", "repo", "--no-default", "--create-only", "--json"]); + } + const result = enabled(flags, "no-start") ? null : await ensureQuickstartDaemon({ + home: options.home, daemonScriptPath: scripts.daemonScriptPath, port: str(flags, "port"), + }); + applyWorkspaceHookPlan(plan); + return result; + }); + const inspection = inspectWorkspaceHooks(options.home); + const checks = scope === "project" ? [...new Set(targets.map((target) => workspaceProjectRoot(target.directory)))] : [process.cwd()]; + return flags.json + ? JSON.stringify({ command: "quickstart", applied: true, home: paths.home, profiles: names, daemon, ...inspection }, null, 2) + : [ + "Crosshead setup complete.", + ...(daemon ? [daemon.detail] : []), + formatWorkspaceHookInspection(inspection), + ...checks.map((root) => `Verify from a fresh session with crosshead workspace check --workspace-root ${shellQuote(root)}${options.home ? ` --home ${shellQuote(paths.home)}` : ""}`), + ].join("\n"); + } finally { flow?.close(); } +}; diff --git a/src/runtime/codex-adapter.ts b/src/runtime/codex-adapter.ts index 07866f1..410fbfa 100644 --- a/src/runtime/codex-adapter.ts +++ b/src/runtime/codex-adapter.ts @@ -56,7 +56,7 @@ import { type HostAdapterTurnPrepareRequest, type HostAdapterTurnPrepareResult, } from "./host-adapter-base"; -import { resolveCodexHostHome } from "./runtime-env"; +import { resolveCodexHostHome, resolveCrossheadProfile } from "./runtime-env"; import { PLUGIN_DISPATCH_CAPABILITY, renderHostHookGroups, type HostHookEvent } from "./host-hook-spec"; import { countMatchingHookCommands, @@ -95,6 +95,8 @@ export type CodexAdapterConfig = { hostHome: string; codexBinary: string; profileName: string; + sourceExcerpts?: boolean; + workspaceOnly?: boolean; skillName: string; /** Absent in configs written before host plugins; treated as "settings". */ hookMode?: HostHookMode; @@ -262,7 +264,7 @@ const buildEphemeralCodexAdapterConfig = (runtimeHomeInput?: string, hostHomeInp runtimeHome: runtimeLayout.home, hostHome: resolveCodexHome(hostHomeInput), codexBinary: (process.env.CROSSHEAD_CODEX_BINARY ?? process.env.ONLYBOTS_CODEX_BINARY) ?? "codex", - profileName: (process.env.CROSSHEAD_PROFILE ?? process.env.ONLYBOTS_PROFILE) ?? configuredProfileName ?? "default", + profileName: resolveCrossheadProfile() ?? configuredProfileName ?? "default", skillName: "crosshead-runtime", installedAt: timestamp, updatedAt: timestamp, @@ -820,13 +822,11 @@ export const loadCodexAdapterConfig = ( if (parsed.marker !== CODEX_MANAGED_CONFIG_MARKER) { throw new Error("Codex adapter config is not managed by Crosshead"); } - if (runtimeHomeInput && parsed.runtimeHome !== resolveRuntimeHome(runtimeHomeInput)) { - return { - ...parsed, - runtimeHome: resolveRuntimeHome(runtimeHomeInput), - }; - } - return parsed; + return { + ...parsed, + runtimeHome: runtimeHomeInput ? resolveRuntimeHome(runtimeHomeInput) : parsed.runtimeHome, + profileName: resolveCrossheadProfile() ?? parsed.profileName, + }; }; const CODEX_ADAPTER_IDENTITY: HostAdapterIdentity = { @@ -860,6 +860,15 @@ export class CodexRuntimeAdapter extends HostRuntimeAdapterBase { ); } + static forWorkspace(home: string, hostHome: string, profileName: string, sourceExcerpts: boolean): CodexRuntimeAdapter { + return new CodexRuntimeAdapter(new LocalRuntimeClient({ home, timeoutMs: 30_000 }), { + ...buildEphemeralCodexAdapterConfig(home, hostHome), + profileName, + sourceExcerpts, + workspaceOnly: true, + }); + } + protected async attachWorkspace(input: { profileName?: string; workspaceRoot: string; diff --git a/src/runtime/codex-hook.ts b/src/runtime/codex-hook.ts index 16e54a8..efe42c5 100644 --- a/src/runtime/codex-hook.ts +++ b/src/runtime/codex-hook.ts @@ -19,7 +19,8 @@ import { captureHotFinding } from "./hot-continuity"; import { claimSessionStartContext, releaseSessionStartContext } from "./hook-session-context"; import { taskEvidenceCaptureEnabled } from "./task-evidence"; import { readLastAssistantMessage } from "./hook-transcript"; -import { writeJsonFile } from "./runtime-persistence"; +import { loadProfile, resolveRuntimePaths, writeJsonFile } from "./runtime-persistence"; +import { readWorkspaceHookPolicy, recordWorkspaceHookStart, workspaceHookForInvocation, workspaceHookStateFile } from "./workspace-hooks"; import { assertSecureNodeRuntimeVersion } from "./node-runtime-version"; type HookTurnState = { @@ -118,8 +119,7 @@ const normalizeHookTurnState = ( const hookTurnKey = (sessionId: string | null, turnId: string | null): string | null => sessionId || turnId ? `${sessionId ?? "-"}:${turnId ?? "-"}` : null; -const loadHookState = (runtimeHome: string, workspaceRoot: string, workspaceId: string): HookState => { - const filePath = hookStateFile(runtimeHome); +const loadHookState = (filePath: string, workspaceRoot: string, workspaceId: string): HookState => { if (!existsSync(filePath)) { return defaultHookState(workspaceRoot, workspaceId); } @@ -158,8 +158,8 @@ const loadHookState = (runtimeHome: string, workspaceRoot: string, workspaceId: } }; -const saveHookState = (runtimeHome: string, state: HookState): void => { - writeJsonFile(hookStateFile(runtimeHome), state); +const saveHookState = (stateFile: string, state: HookState): void => { + writeJsonFile(stateFile, state); }; const selectHookTurnState = (state: HookState, sessionId: string | null, turnId: string | null): HookTurnState => { @@ -191,17 +191,17 @@ const selectHookTurnState = (state: HookState, sessionId: string | null, turnId: return state; }; -const saveHookTurnState = (runtimeHome: string, state: HookState, turn: HookTurnState): void => { +const saveHookTurnState = (stateFile: string, state: HookState, turn: HookTurnState): void => { const activeTurns = { ...state.activeTurns }; const key = hookTurnKey(turn.sessionId, turn.turnId); if (key) { activeTurns[key] = turn; } - saveHookState(runtimeHome, { ...state, ...turn, activeTurns }); + saveHookState(stateFile, { ...state, ...turn, activeTurns }); }; const clearHookTurnState = ( - runtimeHome: string, + stateFile: string, state: HookState, sessionId: string | null, turnId: string | null, @@ -223,7 +223,7 @@ const clearHookTurnState = ( lastContextReadAt: fallback.lastContextReadAt, updatedAt: nowIso(), }; - saveHookState(runtimeHome, { ...next, activeTurns }); + saveHookState(stateFile, { ...next, activeTurns }); }; const getNestedValue = (payload: Record | null, dottedKey: string): unknown => { @@ -285,7 +285,8 @@ const resolvePathCandidate = (value: string): string => (path.isAbsolute(value) const resolveHookWorkspaceRoot = ( payload: Record | null, - flags: Record + flags: Record, + useEnvironment = true ): string => { const candidate = pickFlagString(flags, ["workspace-root", "workspaceRoot"]) ?? @@ -301,7 +302,7 @@ const resolveHookWorkspaceRoot = ( "payload.workspaceRoot", "payload.cwd", ]) ?? - pickEnvString(["ONLYBOTS_WORKSPACE_ROOT", "BENCH_WORKSPACE"]); + (useEnvironment ? pickEnvString(["ONLYBOTS_WORKSPACE_ROOT", "BENCH_WORKSPACE"]) : null); return candidate ? resolvePathCandidate(candidate) : process.cwd(); }; @@ -375,21 +376,31 @@ export const runOnlyBotsCodexHookCli = async ( const normalizedEventName = eventName.toLowerCase(); const runtimeHome = resolveRuntimeHome(typeof flags.home === "string" ? flags.home : undefined); const viaPlugin = isPluginHookInvocation(flags); - const hostHome = - typeof flags["codex-home"] === "string" ? flags["codex-home"] : viaPlugin ? resolvePluginHostHome("codex", runtimeHome) : undefined; + const paths = resolveRuntimePaths(runtimeHome); + const policy = readWorkspaceHookPolicy(paths); + // Ignore retired settings and plugin registrations before reading the chat payload. + if (policy && !policy.bindings.some((binding) => binding.host === "codex" && binding.id === flags.binding)) return; const isToolFailure = normalizedEventName === "posttoolusefailure" || normalizedEventName === "post_tool_use_failure"; const isPostToolUse = isToolFailure || normalizedEventName === "posttooluse" || normalizedEventName === "post_tool_use"; - // This hook runs after every matching tool call. With evidence capture off - // (the default) it has nothing to record, so leave before any other work. - if (isPostToolUse && !taskEvidenceCaptureEnabled()) return; + const selectedBinding = policy?.bindings.find((binding) => binding.id === flags.binding); + if (isPostToolUse && !(selectedBinding?.sourceExcerpts ?? taskEvidenceCaptureEnabled())) return; const payload = readStdinJson(stdinOverride); - const workspaceRoot = resolveHookWorkspaceRoot(payload, flags); - const workspaceId = resolveHookWorkspaceId(payload, flags, workspaceRoot); + const scoped = workspaceHookForInvocation(policy, "codex", flags.binding, resolveHookWorkspaceRoot(payload, {}, false)); + if (scoped === null) return; + const hostHome = scoped?.binding.hostHome ?? + (typeof flags["codex-home"] === "string" ? flags["codex-home"] : viaPlugin ? resolvePluginHostHome("codex", runtimeHome) : undefined); + const workspaceRoot = scoped?.root ?? resolveHookWorkspaceRoot(payload, flags); + const workspaceId = scoped ? `repo:${workspaceRoot}` : resolveHookWorkspaceId(payload, flags, workspaceRoot); + if (scoped) { + const profile = loadProfile(paths, scoped.binding.profile); + if (!profile) throw new Error(`Unknown Crosshead profile: ${scoped.binding.profile}`); + if (profile.memoryMode === "off") return; + } const allowEphemeralConfig = 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"; - if (eventName !== "help") { + if (!scoped && eventName !== "help") { let installedConfig: ReturnType | null = null; try { installedConfig = loadCodexAdapterConfig(runtimeHome, hostHome, { allowEphemeralConfig }); @@ -403,6 +414,7 @@ export const runOnlyBotsCodexHookCli = async ( } return; } + if (loadProfile(paths, installedConfig.profileName)?.memoryMode === "off") return; if (viaPlugin) { // Proof that Codex runs (so the user trusted) the plugin hooks; migration waits for it. try { @@ -426,12 +438,15 @@ export const runOnlyBotsCodexHookCli = async ( }); if (decision === "skip-duplicate") return; } - const adapter = CodexRuntimeAdapter.fromInstalled({ - home: runtimeHome, - hostHome, - allowEphemeralConfig, - }); - const state = loadHookState(runtimeHome, workspaceRoot, workspaceId); + const adapter = scoped + ? CodexRuntimeAdapter.forWorkspace(runtimeHome, scoped.binding.hostHome, scoped.binding.profile, scoped.binding.sourceExcerpts) + : CodexRuntimeAdapter.fromInstalled({ + home: runtimeHome, + hostHome, + allowEphemeralConfig, + }); + const stateFile = scoped ? workspaceHookStateFile(paths, scoped.binding, workspaceRoot) : hookStateFile(runtimeHome); + const state = loadHookState(stateFile, workspaceRoot, workspaceId); if (eventName === "help") { process.stdout.write( @@ -486,7 +501,12 @@ export const runOnlyBotsCodexHookCli = async ( hostHome, hostVersion: pickString(payload, ["codex_version", "codexVersion", "client.version"]) ?? "codex-hook", }); - saveHookState(runtimeHome, { + if (scoped) { + try { + recordWorkspaceHookStart(paths, scoped.binding, workspaceRoot, pickString(payload, ["source", "payload.source"])); + } catch { /* Diagnostics must not interrupt the host session. */ } + } + saveHookState(stateFile, { ...state, sessionId, workspaceRoot, @@ -527,7 +547,7 @@ export const runOnlyBotsCodexHookCli = async ( taskEvidence: await adapter.captureTaskEvidence(pickString(payload, ["transcript_path", "transcriptPath", "conversation_path", "conversationPath"]), sessionId, turnWorkspaceRoot, turnState.lastContextReadAt, prompt), }, }); - saveHookTurnState(runtimeHome, state, { + saveHookTurnState(stateFile, state, { ...turnState, sessionId, turnId, @@ -570,7 +590,7 @@ export const runOnlyBotsCodexHookCli = async ( goal: prompt, }, }); - saveHookTurnState(runtimeHome, state, { + saveHookTurnState(stateFile, state, { ...turnState, sessionId, turnId, @@ -609,7 +629,7 @@ export const runOnlyBotsCodexHookCli = async ( workspaceRoot: turnWorkspaceRoot, workspaceId: turnState.workspaceId, sessionId, - hostHome, + hostHome, hostVersion: pickString(payload, ["codex_version", "codexVersion", "client.version"]) ?? "codex-hook", }); } @@ -627,7 +647,7 @@ export const runOnlyBotsCodexHookCli = async ( sessionStart: (prompt ?? "").trim() ? claimSessionStartContext(runtimeHome, "codex", turnState.workspaceId, sessionId) : false, transcript: prompt ? [{ role: "user", content: prompt }] : [], }); - saveHookTurnState(runtimeHome, state, { + saveHookTurnState(stateFile, state, { sessionId, turnId, prompt, @@ -716,7 +736,7 @@ export const runOnlyBotsCodexHookCli = async ( packId, goal: prompt ?? undefined, summary, - hostHome, + hostHome, hostVersion, transcript, taskEvidence, @@ -730,7 +750,7 @@ export const runOnlyBotsCodexHookCli = async ( packId, goal: prompt ?? undefined, summary, - hostHome, + hostHome, hostVersion, transcript, taskEvidence, @@ -759,7 +779,7 @@ export const runOnlyBotsCodexHookCli = async ( transcript, taskEvidence, }); - clearHookTurnState(runtimeHome, state, sessionId, turnId, { + clearHookTurnState(stateFile, state, sessionId, turnId, { workspaceRoot, workspaceId: turnState.workspaceId, lastContextReadAt: turnState.lastContextReadAt, diff --git a/src/runtime/host-adapter-base.ts b/src/runtime/host-adapter-base.ts index ae27727..04e7519 100644 --- a/src/runtime/host-adapter-base.ts +++ b/src/runtime/host-adapter-base.ts @@ -33,6 +33,8 @@ export type HostAdapterBaseConfig = { adapterVersion: string; /** Runtime home holding this host's bridge wrapper under bin/. */ runtimeHome?: string; + sourceExcerpts?: boolean; + workspaceOnly?: boolean; }; export type HostAdapterTranscriptEntry = { @@ -219,7 +221,8 @@ const emptyHotMemorySnapshot = (): Record => ({ scope: "all", s export const filterHotMemorySnapshotForWorkspace = ( hotMemory: Record | null, - workspaceId: string + workspaceId: string, + workspaceOnly = false ): Record | null => { if (!hotMemory) { return null; @@ -230,14 +233,14 @@ export const filterHotMemorySnapshotForWorkspace = ( return null; } const entryWorkspaceId = getWorkspaceId(parseHotMemoryEntryContent(hotMemory)); - return entryWorkspaceId && entryWorkspaceId !== workspaceId ? null : hotMemory; + return entryWorkspaceId === workspaceId || (!workspaceOnly && !entryWorkspaceId) ? hotMemory : null; } const scopes = scopedEntries.filter((entry) => { if (!hasHotMemoryEntryContent(entry)) { return false; } const entryWorkspaceId = getWorkspaceId(parseHotMemoryEntryContent(entry)); - return !entryWorkspaceId || entryWorkspaceId === workspaceId; + return entryWorkspaceId === workspaceId || (!workspaceOnly && !entryWorkspaceId); }); if (scopes.length === 0) { return null; @@ -268,9 +271,9 @@ export abstract class HostRuntimeAdapterBase { protected abstract readonly config: HostAdapterBaseConfig; async captureTaskEvidence(file: string | null, sessionId: string, workspaceRoot: string, since?: string | null, goal?: string | null): Promise { - // Exact workspace excerpts can contain credentials even after filtering. - // Capture is therefore explicit opt-in, never a side effect of assist/auto. - if (!taskEvidenceCaptureEnabled()) return undefined; + // Scoped setup stores the capture choice independently of memory mode. + // Legacy installs continue using their environment capture policy. + if (!(this.config.sourceExcerpts ?? taskEvidenceCaptureEnabled())) return undefined; // Check the current profile at capture time, including a mode change during // this turn. Never inspect tool output when automatic memory is disabled. const status = await this.client.status(this.config.profileName).catch(() => null); @@ -282,6 +285,12 @@ export abstract class HostRuntimeAdapterBase { protected bridgeCommand(): string | null { const wrapper = { "claude-code": "crosshead-claude-code", codex: "crosshead-codex" }[this.identity.runtimeId as string]; if (!wrapper || !this.config.runtimeHome) return null; + if (this.config.workspaceOnly) { + const cli = path.join(this.config.runtimeHome, "bin", "crosshead"); + return existsSync(cli) + ? `${shellQuote(cli)} checkpoint-page%PAGE% --runtime ${this.identity.runtimeId} --home ${shellQuote(this.config.runtimeHome)}` + : null; + } // Plugin-only installs may have no wrapper; never name a command that is not there. const file = path.join(this.config.runtimeHome, "bin", wrapper); if (!existsSync(file)) return null; @@ -300,7 +309,7 @@ export abstract class HostRuntimeAdapterBase { protected renderHotContext(snapshot: unknown, prompt: string, workspaceId: string, sessionStart: boolean): string | undefined { const command = this.bridgeCommand(); if (!command || !this.config.runtimeHome) return undefined; - const selected = selectHotCheckpoints(snapshot, prompt); + const selected = selectHotCheckpoints(snapshot, prompt, this.config.workspaceOnly ? workspaceId : undefined); if (!selected.length) return undefined; const full = renderHotCheckpoints(selected, HOT_FULL_RENDER_LIMIT); const inline = sessionStart ? inlineCheckpointPages(this.identity.runtimeId) : 0; @@ -322,7 +331,7 @@ export abstract class HostRuntimeAdapterBase { * anything was recorded. */ async recordToolEvidence(input: HostAdapterToolEvidenceRequest): Promise { - if (!taskEvidenceCaptureEnabled()) return false; + if (!(this.config.sourceExcerpts ?? taskEvidenceCaptureEnabled())) return false; const taskEvidence = evidenceFromToolCall(input.toolName, input.toolInput, input.toolResponse, input.workspaceRoot, nowIso(), input.failed === true); if (!taskEvidence.sources.length) return false; const profileName = input.profileName ?? this.config.profileName; @@ -388,7 +397,7 @@ export abstract class HostRuntimeAdapterBase { hostVersion: input.hostVersion, }); const rawPreTurnHotMemory = await this.client.readHotMemory(profileName, "all").catch(() => null); - const preTurnHotMemory = filterHotMemorySnapshotForWorkspace(rawPreTurnHotMemory, workspaceId); + const preTurnHotMemory = filterHotMemorySnapshotForWorkspace(rawPreTurnHotMemory, workspaceId, this.config.workspaceOnly); const response = await this.client.emitEvent({ runtime: this.identity.runtimeId, profileName, @@ -421,7 +430,7 @@ export abstract class HostRuntimeAdapterBase { const contextPack = response.contextPack && typeof response.contextPack === "object" && !Array.isArray(response.contextPack) ? response.contextPack - : null; + : null; const packId = contextPack && typeof contextPack.packId === "string" ? contextPack.packId : null; const freshness = buildRuntimeStartupFreshness({ runtime: this.identity.runtimeId, @@ -439,6 +448,8 @@ export abstract class HostRuntimeAdapterBase { runtime: this.identity.runtimeId, profileName, mode: response.mode, + sourceExcerpts: this.config.sourceExcerpts ?? taskEvidenceCaptureEnabled(), + workspaceId: this.config.workspaceOnly ? workspaceId : undefined, status: null, manifest: null, inbox: null, diff --git a/src/runtime/host-hook-spec.ts b/src/runtime/host-hook-spec.ts index 9ced7a2..115f61e 100644 --- a/src/runtime/host-hook-spec.ts +++ b/src/runtime/host-hook-spec.ts @@ -49,7 +49,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.1.0"; +export const CROSSHEAD_HOST_PLUGIN_VERSION = "1.2.0"; /** 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/lifecycle-engine.ts b/src/runtime/lifecycle-engine.ts index bd469b5..b3516f9 100644 --- a/src/runtime/lifecycle-engine.ts +++ b/src/runtime/lifecycle-engine.ts @@ -1752,7 +1752,7 @@ export class RuntimeLifecycleManager { } else { if (localOnlyProfile || hotSyncEnabled()) { if (!hasHotMemoryOverride(packRequest)) { - packRequest.hotMemorySnapshot = localOnlyProfile ? readLocalHotMemory(this.deps.paths.state, HOT_SCOPE_ALL) : getHotSyncManager({stateDir:this.deps.paths.state,origin:profile.apiBase,npub:profile.npub,nsec:secret.nsec,profileName}).read(); + packRequest.hotMemorySnapshot = localOnlyProfile ? readLocalHotMemory(this.deps.paths.state, HOT_SCOPE_ALL, profile) : getHotSyncManager({stateDir:this.deps.paths.state,origin:profile.apiBase,npub:profile.npub,nsec:secret.nsec,profileName}).read(); } contextPack = buildLocalOnlyContextPack(event, packRequest); actions.push( @@ -1995,7 +1995,7 @@ export class RuntimeLifecycleManager { const partitionScope = workspaceHotScope(scope, event.workspaceId); const client = localOnlyProfile ? null : this.buildClient(profile, secret); const readHot = async (name: string | undefined) => client - ? client.readHotMemory(name) : readLocalHotMemory(this.deps.paths.state, name); + ? client.readHotMemory(name) : readLocalHotMemory(this.deps.paths.state, name, profile); const alias = await readHot(scope); const partition = partitionScope === scope ? alias : await readHot(partitionScope); if (client) this.persistClientSession(profileName, secret, client); @@ -3181,8 +3181,9 @@ export class RuntimeLifecycleManager { const { profile, secret } = this.resolveProfile(item.profileName); if (item.kind === "hot_write" && isLocalOnlyRuntimeProfile(profile)) { const partition = workspaceHotScope(item.payload.scope, item.workspaceId); - if (partition !== item.payload.scope) writeLocalHotMemory(this.deps.paths.state, JSON.stringify(item.payload.hotState, null, 2), partition); - writeLocalHotMemory(this.deps.paths.state, JSON.stringify(item.payload.hotState, null, 2), item.payload.scope); + const content = JSON.stringify(item.payload.hotState, null, 2); + if (partition !== item.payload.scope) writeLocalHotMemory(this.deps.paths.state, content, partition, profile); + writeLocalHotMemory(this.deps.paths.state, content, item.payload.scope, profile); return null; } const client = this.buildClient(profile, secret); diff --git a/src/runtime/local-hot-memory.ts b/src/runtime/local-hot-memory.ts index 59796c9..a3b0c85 100644 --- a/src/runtime/local-hot-memory.ts +++ b/src/runtime/local-hot-memory.ts @@ -20,7 +20,12 @@ export const isLocalOnlyRuntimeProfile = (profile: RuntimeProfileLike): boolean } }; -const localHotMemoryDir = (stateDir: string): string => path.join(stateDir, LOCAL_HOT_MEMORY_DIR); +type HotMemoryOwner = { name: string; npub: string }; + +const localHotMemoryDir = (stateDir: string, owner?: HotMemoryOwner): string => + owner + ? path.join(stateDir, LOCAL_HOT_MEMORY_DIR, "profiles", createHash("sha256").update(JSON.stringify([owner.name, owner.npub])).digest("hex")) + : path.join(stateDir, LOCAL_HOT_MEMORY_DIR); const normalizeHotScope = (scope?: string | null): string => { const trimmed = typeof scope === "string" ? scope.trim() : ""; @@ -31,8 +36,8 @@ const normalizeHotScope = (scope?: string | null): string => { const safeScopeFileName = (scope: string): string => `${encodeURIComponent(scope)}.json`; -const localHotMemoryFile = (stateDir: string, scope?: string | null): string => - path.join(localHotMemoryDir(stateDir), safeScopeFileName(normalizeHotScope(scope))); +const localHotMemoryFile = (directory: string, scope?: string | null): string => + path.join(directory, safeScopeFileName(normalizeHotScope(scope))); const etagFor = (content: string): string => createHash("sha256").update(content).digest("base64url"); @@ -44,8 +49,8 @@ const scopeFromFileName = (fileName: string): string => { } }; -const readScopeFile = (stateDir: string, fileName: string): Record | null => { - const filePath = path.join(localHotMemoryDir(stateDir), fileName); +const readScopeFile = (directory: string, fileName: string): Record | null => { + const filePath = path.join(directory, fileName); if (!existsSync(filePath) || !fileName.endsWith(".json")) { return null; } @@ -64,13 +69,13 @@ const readScopeFile = (stateDir: string, fileName: string): Record => { +export const readLocalHotMemory = (stateDir: string, scope?: string | null, owner?: HotMemoryOwner): Record => { + const directory = localHotMemoryDir(stateDir, owner); const normalizedScope = normalizeHotScope(scope); if (normalizedScope === HOT_SCOPE_ALL) { - const directory = localHotMemoryDir(stateDir); const scopes = existsSync(directory) ? readdirSync(directory) - .map((fileName) => readScopeFile(stateDir, fileName)) + .map((fileName) => readScopeFile(directory, fileName)) .filter((entry): entry is Record => entry !== null) : []; return { @@ -79,7 +84,7 @@ export const readLocalHotMemory = (stateDir: string, scope?: string | null): Rec }; } - const filePath = localHotMemoryFile(stateDir, normalizedScope); + const filePath = localHotMemoryFile(directory, normalizedScope); if (!existsSync(filePath)) { return { scope: normalizedScope, @@ -100,10 +105,10 @@ export const readLocalHotMemory = (stateDir: string, scope?: string | null): Rec }; }; -export const writeLocalHotMemory = (stateDir: string, content: string, scope?: string | null): Record => { +export const writeLocalHotMemory = (stateDir: string, content: string, scope?: string | null, owner?: HotMemoryOwner): Record => { const normalizedScope = normalizeHotScope(scope); if (normalizedScope === HOT_SCOPE_ALL) throw new Error("Hot-memory scope all is read-only"); - const filePath = localHotMemoryFile(stateDir, normalizedScope); + const filePath = localHotMemoryFile(localHotMemoryDir(stateDir, owner), normalizedScope); writeManagedFile(filePath, content, stateDir); - return readLocalHotMemory(stateDir, normalizedScope); + return readLocalHotMemory(stateDir, normalizedScope, owner); }; diff --git a/src/runtime/local-runtime.ts b/src/runtime/local-runtime.ts index e5428c9..bee10a4 100644 --- a/src/runtime/local-runtime.ts +++ b/src/runtime/local-runtime.ts @@ -173,6 +173,7 @@ import { writeRuntimeLog, } from "./runtime-persistence"; import { nowIso } from "./adapter-utils"; +import { applyWorkspaceHookPlan, planWorkspaceHooks, readWorkspaceHookPolicy, withWorkspaceSetupLock } from "./workspace-hooks"; import { cloneOrganizationalWorkerReadiness, normalizeOrganizationalWorkerReadiness, @@ -457,6 +458,7 @@ export type SetupRuntimeProfileOptions = { workspaceScope?: "repo" | "workspace" | "global"; features?: Partial; makeDefault?: boolean; + createOnly?: boolean; }; type GeneratedRuntimeIdentity = { @@ -1286,7 +1288,14 @@ export const uninstallRuntimeCore = async (home?: string): Promise => { } const daemon = readDaemonOwnershipState(paths); if (daemonOwnerIsLive(daemon)) await stopDetachedRuntimeDaemon(paths.home); - rmSync(canonicalHome, { recursive: true, force: true }); + if (readWorkspaceHookPolicy(paths)) { + await withWorkspaceSetupLock(paths, async () => { + applyWorkspaceHookPlan(planWorkspaceHooks({ home: paths.home, scope: "manual", targets: [], cleanup: true })); + rmSync(canonicalHome, { recursive: true, force: true }); + }); + } else { + rmSync(canonicalHome, { recursive: true, force: true }); + } if (resolvedHome !== canonicalHome) { try { if (lstatSync(resolvedHome).isSymbolicLink()) rmSync(resolvedHome, { force: true }); } catch { /* The alias may disappear with its target. */ } @@ -1311,6 +1320,7 @@ export const setupRuntimeProfile = async (options: SetupRuntimeProfileOptions): } const paths = ensureRuntimeHomeLayout(options.home); writeRuntimeInstallMarker(paths); + if (options.createOnly && loadProfile(paths, options.profileName)) throw new Error(`Profile ${options.profileName} already exists`); const secret = loadSecret(paths, options.profileName); const localOnlyProfile = isLocalOnlyRuntimeProfile({ apiBase: options.apiBase }); const session = localOnlyProfile @@ -1340,6 +1350,7 @@ export const setupRuntimeProfile = async (options: SetupRuntimeProfileOptions): createdAt: loadProfile(paths, options.profileName)?.createdAt ?? nowIso(), updatedAt: nowIso(), }; + if (options.createOnly && loadProfile(paths, options.profileName)) throw new Error(`Profile ${options.profileName} was created during setup`); saveProfile(paths, profile); saveSecret(paths, options.profileName, { nsec: options.nsec, @@ -3256,7 +3267,7 @@ export class OnlyBotsLocalRuntimeDaemon { }); const profile = loadProfile(this.paths, resolvedProfileName); if (profile && isLocalOnlyRuntimeProfile(profile)) { - return readLocalHotMemory(this.paths.state, scope); + return readLocalHotMemory(this.paths.state, scope, profile); } if (profile && hotSyncEnabled()) { const manager=getHotSyncManager({stateDir:this.paths.state,origin:profile.apiBase,npub:profile.npub,nsec:secret.nsec,profileName:resolvedProfileName}); @@ -3292,7 +3303,7 @@ export class OnlyBotsLocalRuntimeDaemon { // No hosted index exists for this profile. Answer from local hot memory. const query = typeof payload.query === "string" ? payload.query : ""; const workspaceId = typeof payload.local_workspace_id === "string" ? payload.local_workspace_id : null; - const results = searchHotCheckpoints(readLocalHotMemory(this.paths.state, "all"), query, Number(payload.top_k) || 10, workspaceId); + const results = searchHotCheckpoints(readLocalHotMemory(this.paths.state, "all", profile), query, Number(payload.top_k) || 10, workspaceId); return { scope: "local_hot_memory", query, count: results.length, results }; } delete payload.local_workspace_id; diff --git a/src/runtime/runtime-env.ts b/src/runtime/runtime-env.ts index 93aaa7d..a3140c9 100644 --- a/src/runtime/runtime-env.ts +++ b/src/runtime/runtime-env.ts @@ -19,6 +19,9 @@ export const resolveDefaultRuntimeHome = (userHome: string): string => { // and the plugin launcher (`${CROSSHEAD_HOME:-...}`). const envValue = (name: string): string | undefined => process.env[name] || undefined; +export const resolveCrossheadProfile = (profile?: string): string | undefined => + profile ?? envValue("CROSSHEAD_PROFILE") ?? envValue("ONLYBOTS_PROFILE"); + export const resolveCrossheadHome = (home?: string): string => path.resolve(home ?? envValue("CROSSHEAD_HOME") ?? envValue("CROSSHEAD_RUNTIME_HOME") ?? envValue("ONLYBOTS_HOME") ?? envValue("ONLYBOTS_RUNTIME_HOME") ?? resolveDefaultRuntimeHome(os.homedir())); diff --git a/src/runtime/startup-context.ts b/src/runtime/startup-context.ts index 9238de1..592516c 100644 --- a/src/runtime/startup-context.ts +++ b/src/runtime/startup-context.ts @@ -16,6 +16,8 @@ type RuntimeStartupContextInput = { runtime: RuntimeStartupRuntime; profileName: string; mode: RuntimeLifecycleMemoryMode; + sourceExcerpts?: boolean; + workspaceId?: string; status: RuntimeStatus | null; manifest: ContextPackManifest | null; inbox: RuntimeInboxHudState | null; @@ -653,11 +655,12 @@ const formatInjectedContextLine = (manifest: ContextPackManifest | null): string }; export const renderRuntimeStartupContext = (input: RuntimeStartupContextInput): string => { + if (input.mode === "off") return ""; // A fresh store still needs a capture contract. Otherwise the agent may invent // a repository memory file or omit facts that are absent from its final answer. // Native hooks capture findings plus bounded observed workspace reads. const captureGuidance = input.runtime !== "pi" && (input.mode === "auto" || input.mode === "assist") - ? `Crosshead hooks automatically capture your final response${input.runtime === "hermes" || input.runtime === "opencode" ? "" : " and bounded workspace file-read evidence"} as a continuity checkpoint. When handing off unfinished work, include the exact discovered requirements: input/output types and units, boundary rules, decisions and rejected alternatives, verification, and next steps. Distinguish observed facts from assumptions. No manual hook invocation or repository memory file is needed. Continuity does not authorize file changes; obey the user's read-only and other task constraints.` + ? `Crosshead hooks automatically capture your final response${input.sourceExcerpts ? " and bounded workspace file-read evidence" : ""} as a continuity checkpoint. When handing off unfinished work, include the exact discovered requirements: input/output types and units, boundary rules, decisions and rejected alternatives, verification, and next steps. Distinguish observed facts from assumptions. No manual hook invocation or repository memory file is needed. Continuity does not authorize file changes; obey the user's read-only and other task constraints.` : null; const manifest = input.manifest; const trimmedContext = input.injectedContext.trim(); @@ -713,7 +716,7 @@ export const renderRuntimeStartupContext = (input: RuntimeStartupContextInput): formatHealthLine(input.status), formatLifecycleLine(input.actions, manifest), manifest ? formatLayerLine("Hot memory", manifest.hotMemory) : hasHotMemoryFallback - ? input.hotContextText || renderHotCheckpoints(selectHotCheckpoints(input.hotMemorySnapshot, input.goal ?? null)) || formatFreeHotMemorySnapshotLine(input.hotMemorySnapshot) + ? input.hotContextText || renderHotCheckpoints(selectHotCheckpoints(input.hotMemorySnapshot, input.goal ?? null, input.workspaceId)) || formatFreeHotMemorySnapshotLine(input.hotMemorySnapshot) : null, formatWarmLine(manifest), manifest && (manifest.layeredContext.available > 0 || manifest.layeredContext.injected > 0) diff --git a/src/runtime/workspace-hooks.ts b/src/runtime/workspace-hooks.ts new file mode 100644 index 0000000..8a83365 --- /dev/null +++ b/src/runtime/workspace-hooks.ts @@ -0,0 +1,401 @@ +import { createHash, randomUUID } from "node:crypto"; +import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; +import path from "node:path"; +import { assertManagedFilePath, shellQuote, writeManagedFile } from "./adapter-utils"; +import { HOST_HOOK_EVENTS, PLUGIN_DISPATCH_CAPABILITY, renderHostHookGroups, type HookHost } from "./host-hook-spec"; +import { homedir } from "node:os"; +import { loadProfile, resolveRuntimePaths, type RuntimePaths } from "./runtime-persistence"; +import { resolveClaudeCodeHostHome, resolveCodexHostHome } from "./runtime-env"; + +export type HookScope = "project" | "all" | "manual"; +export type WorkspaceHookBinding = { + id: string; + host: HookHost; + root: string | null; + hostHome: string; + profile: string; + sourceExcerpts: boolean; +}; + +type HookPolicy = { version: 1; scope: HookScope; bindings: WorkspaceHookBinding[] }; +type Settings = Record; +type Edit = { file: string; root: string; before: string | null; after: string; executable?: boolean }; +const hosts: HookHost[] = ["codex", "claude-code"]; +const policyFile = (paths: RuntimePaths): string => path.join(paths.config, "workspace-hooks.json"); +const object = (value: unknown): value is Settings => value !== null && typeof value === "object" && !Array.isArray(value); +const profileName = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; +const bindingId = /^[a-f0-9-]{36}$/; +const isBinding = (value: unknown): value is WorkspaceHookBinding => + object(value) && typeof value.id === "string" && bindingId.test(value.id) && + (value.host === "codex" || value.host === "claude-code") && + (value.root === null || (typeof value.root === "string" && path.isAbsolute(value.root))) && + typeof value.hostHome === "string" && path.isAbsolute(value.hostHome) && + typeof value.profile === "string" && profileName.test(value.profile) && typeof value.sourceExcerpts === "boolean"; + +const readText = (file: string): string | null => { + try { return readFileSync(file, "utf8"); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +}; + +export const readWorkspaceHookPolicy = (paths: RuntimePaths): HookPolicy | null => { + const file = policyFile(paths); + assertManagedFilePath(file, paths.home); + const text = readText(file); + if (text === null) return null; + let data: unknown; + try { data = JSON.parse(text); } catch { throw new Error(`Invalid Crosshead workspace hook settings in ${file}`); } + if (!object(data) || data.version !== 1 || !Array.isArray(data.bindings) || !data.bindings.every(isBinding) || + (data.scope !== "project" && data.scope !== "all" && data.scope !== "manual") || + (data.scope === "manual" && data.bindings.length > 0) || + data.bindings.some((binding) => (binding.root === null) !== (data.scope === "all"))) { + throw new Error("Invalid Crosshead workspace hook settings. Run crosshead workspace list to inspect the configuration."); + } + return { version: 1, scope: data.scope, bindings: data.bindings }; +}; + +const repositoryRoot = (directory: string): string | null => { + for (let current = directory;; current = path.dirname(current)) { + const git = path.join(current, ".git"); + if (existsSync(path.join(git, "HEAD"))) return current; + try { + const info = statSync(git); + if (info.isFile() && info.size <= 4096 && readText(git)?.startsWith("gitdir: ")) return current; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (path.dirname(current) === current) return null; + } +}; + +const directoryPath = (directory: string): string => { + const resolved = realpathSync(directory); + if (!statSync(resolved).isDirectory()) throw new Error(`Expected a directory: ${directory}`); + return resolved; +}; + +export const workspaceProjectRoot = (directory: string): string => { + const resolved = directoryPath(directory); + return repositoryRoot(resolved) ?? resolved; +}; + +const appliesIn = (binding: WorkspaceHookBinding, directory: string): boolean => { + if (binding.root === null) return true; + const relative = path.relative(binding.root, directory); + if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) return false; + const repo = repositoryRoot(directory); + return repo === null || repo === binding.root; +}; + +/** undefined uses legacy dispatch; null skips the invocation, including stale hooks. */ +export const workspaceHookForInvocation = ( + policy: HookPolicy | null, host: HookHost, id: string | boolean | undefined, directory: string +): { binding: WorkspaceHookBinding; root: string } | null | undefined => { + if (!policy) return id === undefined ? undefined : null; + const binding = policy.bindings.find((entry) => entry.host === host && entry.id === id); + if (!binding) return null; + let resolved: string; + try { resolved = directoryPath(directory); } catch { return null; } + if (!appliesIn(binding, resolved)) return null; + // Within plain directories, the closest explicit binding wins. + if (binding.root && policy.bindings.some((other) => + other.host === host && other.root && other.root.length > binding.root!.length && appliesIn(other, resolved))) return null; + return { binding, root: binding.root ?? repositoryRoot(resolved) ?? resolved }; +}; + +const hookFile = (binding: WorkspaceHookBinding): string => + binding.root + ? path.join(binding.root, binding.host === "codex" ? ".codex/hooks.json" : ".claude/settings.local.json") + : path.join(binding.hostHome, binding.host === "codex" ? "hooks.json" : "settings.json"); + +const hostHome = (host: HookHost, options: { codexHome?: string; claudeHome?: string }): string => + host === "codex" ? resolveCodexHostHome(options.codexHome) : resolveClaudeCodeHostHome(options.claudeHome); + +const hookPrefix = (paths: RuntimePaths, host: HookHost): string => + `${shellQuote(path.join(paths.bin, "crosshead"))} host-hook ${host} `; + +const invokesLegacyHook = (command: string, wrapper: string, runtimeHomes: string[], host: HookHost): boolean => { + const executable = [shellQuote(wrapper), wrapper].find((token) => command.startsWith(`${token} `)); + if (!executable) return false; + const args = command.slice(executable.length).trimStart(); + const homeFlag = host === "codex" ? "--codex-home" : "--claude-home"; + return HOST_HOOK_EVENTS.some(({ event }) => [event, shellQuote(event)].some((token) => { + if (args === token) return true; + return runtimeHomes.some((home) => [home, shellQuote(home)].some((value) => { + const prefix = `${token} --home ${value}`; + if (args === prefix) return true; + const hostPrefix = `${prefix} ${homeFlag} `; + if (!args.startsWith(hostPrefix)) return false; + const argument = args.slice(hostPrefix.length); + // The installers end the command with one absolute host-home argument. + return /^'\/(?:[^']|'"'"')*'$/.test(argument) || /^\/[\w./-]*$/.test(argument); + })); + })); +}; + +const bindingCommands = (paths: RuntimePaths, binding: WorkspaceHookBinding) => + renderHostHookGroups(binding.host, (event) => + `${hookPrefix(paths, binding.host)}${event} --home ${shellQuote(paths.home)} --binding ${binding.id}`); + +const settings = (file: string, text: string | null): Settings => { + if (text === null) return {}; + let value: unknown; + try { value = JSON.parse(text); } catch { throw new Error(`Invalid JSON in ${file}`); } + if (!object(value) || (value.hooks !== undefined && !object(value.hooks))) { + throw new Error(`Expected a settings object with a hooks object in ${file}`); + } + return value; +}; + +const removeCommands = (data: Settings, remove: (command: string) => boolean): void => { + if (!object(data.hooks)) return; + for (const [event, groups] of Object.entries(data.hooks)) { + if (!Array.isArray(groups)) continue; + data.hooks[event] = groups.flatMap((group: unknown) => { + if (!object(group) || !Array.isArray(group.hooks)) return [group]; + const hooks = group.hooks.filter((handler: unknown) => !object(handler) || + typeof handler.command !== "string" || !remove(handler.command)); + return hooks.length === group.hooks.length ? [group] : hooks.length ? [{ ...group, hooks }] : []; + }); + if (Array.isArray(data.hooks[event]) && (data.hooks[event] as unknown[]).length === 0 && groups.length > 0) { + delete data.hooks[event]; + } + } +}; + +const workspaceHookStateBase = (paths: RuntimePaths, binding: WorkspaceHookBinding, root: string): string => + path.join(paths.state, "workspace-hooks", binding.id, createHash("sha256").update(root).digest("hex")); + +const receiptFile = (paths: RuntimePaths, binding: WorkspaceHookBinding, root: string, source: "startup" | "resume"): string => + `${workspaceHookStateBase(paths, binding, root)}-${source}.json`; + +export const recordWorkspaceHookStart = ( + paths: RuntimePaths, binding: WorkspaceHookBinding, root: string, source: string | null +): void => { + if (source !== "startup" && source !== "resume") return; + writeManagedFile(receiptFile(paths, binding, root, source), JSON.stringify({ + profile: binding.profile, workspaceRoot: root, observedAt: new Date().toISOString(), + }), paths.home); +}; + +export const workspaceHookStateFile = (paths: RuntimePaths, binding: WorkspaceHookBinding, root: string): string => + `${workspaceHookStateBase(paths, binding, root)}.json`; + +export const inspectWorkspaceHooks = (home?: string, directory?: string) => { + const paths = resolveRuntimePaths(home); + const policy = readWorkspaceHookPolicy(paths); + const resolved = directory ? directoryPath(directory) : null; + const bindings = (policy?.bindings ?? []).filter((binding) => !resolved || + workspaceHookForInvocation(policy, binding.host, binding.id, resolved) !== null); + return { + scope: policy?.scope ?? "legacy", + workspace: resolved, + bindings: bindings.map((binding) => { + const file = hookFile(binding); + let configured = false; + try { + const hooks = settings(file, readText(file)).hooks; + configured = object(hooks) && bindingCommands(paths, binding).every(([event, group]) => { + const groups = hooks[event]; + return Array.isArray(groups) && groups.some((entry) => object(entry) && entry.matcher === group.matcher && + Array.isArray(entry.hooks) && entry.hooks.some((handler) => + object(handler) && handler.type === "command" && handler.command === group.hooks[0]!.command)); + }); + } catch { /* Invalid or removed settings are reported as missing. */ } + const observation = (source: "startup" | "resume") => { + const expectedRoot = binding.root ?? (resolved ? repositoryRoot(resolved) ?? resolved : null); + if (!expectedRoot) return null; + try { + const receipt = JSON.parse(readText(receiptFile(paths, binding, expectedRoot, source)) ?? "null") as Settings | null; + return receipt?.profile === binding.profile && receipt.workspaceRoot === expectedRoot + ? receipt : null; + } catch { return null; } + }; + return { + ...binding, file, configured, + memoryMode: loadProfile(paths, binding.profile)?.memoryMode ?? null, + startup: configured ? observation("startup") : null, + resume: configured ? observation("resume") : null, + }; + }), + }; +}; + +export type WorkspaceHookTarget = { + directory: string; + hosts: HookHost[]; + profile: string; + sourceExcerpts?: boolean; +}; + +type PlanOptions = { + home?: string; + codexHome?: string; + claudeHome?: string; + scope: HookScope; + targets: WorkspaceHookTarget[]; + remove?: { directory: string; hosts: HookHost[] }; + removeHost?: HookHost; + cleanup?: boolean; +}; + +export const planWorkspaceHooks = (options: PlanOptions) => { + const paths = resolveRuntimePaths(options.home); + const previous = readWorkspaceHookPolicy(paths); + const policy: HookPolicy = { + version: 1, scope: options.scope, + bindings: previous?.scope === options.scope ? [...previous.bindings] : [], + }; + if (options.removeHost) policy.bindings = policy.bindings.filter((binding) => binding.host !== options.removeHost); + if (options.remove) { + const root = existsSync(options.remove.directory) ? workspaceProjectRoot(options.remove.directory) : path.resolve(options.remove.directory); + policy.bindings = policy.bindings.filter((binding) => + binding.root !== root || !options.remove!.hosts.includes(binding.host)); + } + const planned = new Set(); + for (const target of options.targets) { + if (!profileName.test(target.profile)) throw new Error(`Invalid profile name: ${target.profile}`); + const root = options.scope === "all" ? null : workspaceProjectRoot(target.directory); + if (root && (root === path.parse(root).root || root === realHome(homedir()))) { + throw new Error("Choose a project directory inside your home, or select all projects explicitly."); + } + for (const host of target.hosts) { + const key = JSON.stringify([root, host]); + if (planned.has(key)) throw new Error(`Choose one profile per host in ${root ?? "all projects"}.`); + planned.add(key); + const existing = policy.bindings.find((binding) => binding.root === root && binding.host === host); + const resolvedHome = hostHome(host, options); + const sourceExcerpts = target.sourceExcerpts ?? existing?.sourceExcerpts ?? false; + if (existing?.profile === target.profile && existing.sourceExcerpts === sourceExcerpts && + existing.hostHome === resolvedHome) continue; + policy.bindings = policy.bindings.filter((binding) => binding !== existing); + policy.bindings.push({ id: randomUUID(), root, host, hostHome: resolvedHome, profile: target.profile, sourceExcerpts }); + } + } + + const edits = new Map(); + const settingsFiles = new Map(); + for (const binding of [...(previous?.bindings ?? []), ...policy.bindings]) { + settingsFiles.set(hookFile(binding), { root: binding.root ?? binding.hostHome, host: binding.host }); + if (binding.root && binding.host === "claude-code") { + settingsFiles.set(path.join(binding.root, ".claude/settings.json"), { root: binding.root, host: binding.host }); + } + } + // Retire settings-mode hooks that refer to this runtime. Other hooks and + // settings stay in place. A plugin invocation is gated by the same policy. + for (const host of options.cleanup ? [] : hosts) { + const root = hostHome(host, options); + settingsFiles.set(path.join(root, host === "codex" ? "hooks.json" : "settings.json"), { root, host }); + } + const retired: string[] = []; + const physicalHome = realHome(paths.home); + const runtimeHomes = [paths.home, physicalHome]; + for (const [file, { root, host }] of settingsFiles) { + const next = policy.bindings.filter((binding) => hookFile(binding) === file); + assertManagedFilePath(file, root); + const before = readText(file); + if (before === null && next.length === 0) continue; + const data = settings(file, before); + const original = JSON.stringify(data); + const owned = new Set([...(previous?.bindings ?? []), ...next] + .filter((binding) => hookFile(binding) === file) + .flatMap((binding) => bindingCommands(paths, binding).map(([, group]) => group.hooks[0]!.command))); + removeCommands(data, (command) => { + if (owned.has(command)) return true; + const legacy = ["crosshead", "onlybots"].some((name) => runtimeHomes.some((home) => + invokesLegacyHook(command, path.join(home, "bin", `${name}-${host}-hook`), runtimeHomes, host))); + if (legacy && !retired.includes(file)) retired.push(file); + return legacy; + }); + for (const binding of next) { + const hooks = (data.hooks ??= {}) as Settings; + for (const [event, group] of bindingCommands(paths, binding)) { + const groups = (hooks[event] ??= []); + if (!Array.isArray(groups)) throw new Error(`Invalid ${event} hook groups in ${file}`); + groups.push(group); + } + } + if (JSON.stringify(data) !== original) edits.set(file, { file, root, before, after: `${JSON.stringify(data, null, 2)}\n` }); + } + + // Plugin launchers use these stable paths even for a manual/project setup. + // Keep them dispatching through the current CLI across release updates. + for (const host of options.cleanup ? [] : hosts) { + for (const name of ["crosshead", "onlybots"]) { + const file = path.join(paths.bin, `${name}-${host}-hook`); + assertManagedFilePath(file, paths.home); + const before = readText(file); + const after = `#!/bin/sh\n# ${PLUGIN_DISPATCH_CAPABILITY}\nexec ${shellQuote(path.join(paths.bin, "crosshead"))} host-hook ${host} "$@"\n`; + if (before !== after) edits.set(file, { file, root: paths.home, before, after, executable: true }); + } + } + const file = policyFile(paths); + const before = readText(file); + const after = `${JSON.stringify(policy, null, 2)}\n`; + if (before !== after) edits.set(file, { file, root: paths.home, before, after }); + for (const edit of edits.values()) assertManagedFilePath(edit.file, edit.root); + return { paths, policy, edits: [...edits.values()], retired }; +}; + +const realHome = (home: string): string => { + try { return realpathSync(home); } catch { return home; } +}; + +export type WorkspaceHookPlan = ReturnType; + +export const summarizeWorkspaceHookPlan = (plan: WorkspaceHookPlan) => ({ + scope: plan.policy.scope, + bindings: plan.policy.bindings, + files: plan.edits.map((edit) => edit.file), + replacesUserHooksIn: plan.retired, +}); + +export const assertWorkspacePlanCurrent = (plan: WorkspaceHookPlan): void => { + for (const edit of plan.edits) { + assertManagedFilePath(edit.file, edit.root); + if (readText(edit.file) !== edit.before) throw new Error(`Settings changed since preview: ${edit.file}. Run setup again.`); + } +}; + +export const withWorkspaceSetupLock = async (paths: RuntimePaths, operation: () => Promise): Promise => { + const file = path.join(paths.config, "workspace-setup.lock"); + assertManagedFilePath(file, paths.home); + mkdirSync(paths.config, { recursive: true, mode: 0o700 }); + let lock: number; + try { lock = openSync(file, "wx", 0o600); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + throw new Error(`A setup is already in progress. If it was interrupted and has exited, remove ${file} and retry.`); + } + try { return await operation(); } + finally { + closeSync(lock); + rmSync(file, { force: true }); + } +}; + +export const applyWorkspaceHookPlan = (plan: WorkspaceHookPlan): void => { + for (const binding of plan.policy.bindings) { + if (!loadProfile(plan.paths, binding.profile)) throw new Error(`Unknown profile: ${binding.profile}`); + } + assertWorkspacePlanCurrent(plan); + const written: Edit[] = []; + try { + for (const edit of plan.edits) { + writeManagedFile(edit.file, edit.after, edit.root); + written.push(edit); + if (edit.executable) chmodSync(edit.file, 0o755); + } + } catch (error) { + for (const edit of written.reverse()) { + if (edit.before === null) rmSync(edit.file, { force: true }); + else { + writeManagedFile(edit.file, edit.before, edit.root); + if (edit.executable) chmodSync(edit.file, 0o755); + } + } + throw error; + } +}; diff --git a/tests/runtime/host-adapter-base.test.ts b/tests/runtime/host-adapter-base.test.ts index e00ceae..8554f07 100644 --- a/tests/runtime/host-adapter-base.test.ts +++ b/tests/runtime/host-adapter-base.test.ts @@ -24,6 +24,14 @@ class SecurityBoundaryAdapter extends HostRuntimeAdapterBase { } describe("host adapter hot-memory filtering", () => { + it("requires a workspace owner for scoped setup", () => { + const snapshot = { scope: "all", scopes: [ + { scope: "shared", content: "An unscoped legacy note" }, + { scope: "codex", content: JSON.stringify({ workspaceId: "repo:/fixture", currentGoal: "Scoped finding" }) }, + ] }; + expect(filterHotMemorySnapshotForWorkspace(snapshot, "repo:/fixture", true)?.scopes).toEqual([snapshot.scopes[1]]); + expect(filterHotMemorySnapshotForWorkspace(snapshot, "repo:/other", true)).toBeNull(); + }); it("delivers the newer workspace copy once and accepts newer legacy-client updates", () => { const workspaceId = "repo:/tmp/project", runtime = "codex"; const older = { workspaceId, runtime, updatedAt: "2026-10-20T00:00:00Z", lastTurn: { summary: "old finding" } }; diff --git a/tests/runtime/host-plugins.test.ts b/tests/runtime/host-plugins.test.ts index 1010d42..9863570 100644 --- a/tests/runtime/host-plugins.test.ts +++ b/tests/runtime/host-plugins.test.ts @@ -112,6 +112,7 @@ describe("host plugin files", () => { "1.0.0": "1c6295e2195529599233fae8d5a46b5671ef18998beaf39652e7bc6d955dc4d4", "1.0.1": "1fcdad297292b4ac92cf109a9b29da8f26928710099f028e53c12ae9c45ab5e6", "1.1.0": "afca3f9cf117ed90071a265abd0fa6ebe4d0cb22d1b0e344d20ce70af16d3a9b", + "1.2.0": "0fa7de4ce73cb71b04d44a57ceef7707864973ffae5b3dba4dffafd22d5e348d", }; it("generated plugin content is pinned to CROSSHEAD_HOST_PLUGIN_VERSION", () => { const files = renderHostPluginFiles(spec) as Record; @@ -142,8 +143,8 @@ describe("host plugin files", () => { 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"); + expect(skill).toContain(`--scope project --adapter ${host} --workspace-root . --profile WORK --dry-run --json`); + expect(skill).toMatch(/ask for explicit\s+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/); diff --git a/tests/runtime/profile-selection.test.ts b/tests/runtime/profile-selection.test.ts new file mode 100644 index 0000000..7e43858 --- /dev/null +++ b/tests/runtime/profile-selection.test.ts @@ -0,0 +1,59 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadCodexAdapterConfig } from "../../src/runtime/codex-adapter"; +import { loadClaudeCodeAdapterConfig } from "../../src/runtime/claude-code-adapter"; +import { runOnlyBotsCtlCli } from "../../src/runtime/cli"; +import { resolveCrossheadProfile } from "../../src/runtime/runtime-env"; + +const roots: string[] = []; +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("project profile selection", () => { + it("prefers explicit profiles, then the canonical environment variable, then the legacy alias", () => { + vi.stubEnv("CROSSHEAD_PROFILE", "crosshead"); + vi.stubEnv("ONLYBOTS_PROFILE", "legacy"); + expect(resolveCrossheadProfile("explicit")).toBe("explicit"); + expect(resolveCrossheadProfile()).toBe("crosshead"); + vi.stubEnv("CROSSHEAD_PROFILE", ""); + expect(resolveCrossheadProfile()).toBe("legacy"); + vi.stubEnv("ONLYBOTS_PROFILE", ""); + expect(resolveCrossheadProfile()).toBeUndefined(); + }); + + it.each([ + ["codex", "__onlybots_codex_adapter__", loadCodexAdapterConfig], + ["claude-code", "__onlybots_claude_code_adapter__", loadClaudeCodeAdapterConfig], + ] as const)("selects the environment profile for installed %s hooks without rewriting saved config", (host, marker, load) => { + const root = mkdtempSync(path.join(os.tmpdir(), "crosshead-profile-")); + roots.push(root); + mkdirSync(path.join(root, "onlybots")); + const file = path.join(root, "onlybots", `${host}-adapter.json`); + const saved = JSON.stringify({ marker, profileName: "default", runtimeHome: "/saved-runtime", hostHome: root }); + writeFileSync(file, saved); + vi.stubEnv("CROSSHEAD_PROFILE", "crosshead"); + vi.stubEnv("ONLYBOTS_PROFILE", "legacy"); + expect(load(undefined, root)).toMatchObject({ profileName: "crosshead", runtimeHome: "/saved-runtime" }); + expect(load("/explicit-runtime", root)).toMatchObject({ profileName: "crosshead", runtimeHome: "/explicit-runtime" }); + vi.stubEnv("CROSSHEAD_PROFILE", ""); + expect(load(undefined, root).profileName).toBe("legacy"); + vi.stubEnv("ONLYBOTS_PROFILE", ""); + expect(load(undefined, root).profileName).toBe("default"); + expect(readFileSync(file, "utf8")).toBe(saved); + }); + + it("passes the environment profile through the CLI while preserving an explicit --profile", async () => { + vi.stubEnv("CROSSHEAD_PROFILE", "crosshead"); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const probe = vi.fn(async () => "ok"); + await runOnlyBotsCtlCli(["probe"], { commands: { probe } }); + expect(probe).toHaveBeenLastCalledWith(["--profile", "crosshead"]); + await runOnlyBotsCtlCli(["probe", "--profile", "explicit"], { commands: { probe } }); + expect(probe).toHaveBeenLastCalledWith(["--profile", "explicit"]); + }); +}); diff --git a/tests/runtime/startup-context-capture.test.ts b/tests/runtime/startup-context-capture.test.ts index d302f9c..60c5d82 100644 --- a/tests/runtime/startup-context-capture.test.ts +++ b/tests/runtime/startup-context-capture.test.ts @@ -20,6 +20,14 @@ describe("native hook capture contract", () => { expect(renderRuntimeStartupContext({ ...empty, actions: [], warnings: [], runtime: "codex", mode })).toBe(""); }); + it("suppresses previously saved memory and HUD output when memory is off", () => { + expect(renderRuntimeStartupContext({ + ...empty, runtime: "codex", mode: "off", actions: [], warnings: ["Saved warning"], + injectedContext: "A previously saved private finding", + hotMemorySnapshot: { scopes: [{ scope: "codex", content: "Saved checkpoint" }] }, + })).toBe(""); + }); + it("keeps prior findings and capture guidance together", () => { const context = renderRuntimeStartupContext({ ...empty, actions: [], warnings: [], runtime: "codex", mode: "auto", diff --git a/tests/runtime/workspace-setup.test.ts b/tests/runtime/workspace-setup.test.ts new file mode 100644 index 0000000..9aaa823 --- /dev/null +++ b/tests/runtime/workspace-setup.test.ts @@ -0,0 +1,176 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, it, vi } from "vitest"; +import { stopDetachedRuntimeDaemon } from "../../src/runtime/local-runtime"; +import { readDaemonOwnershipState } from "../../src/runtime/daemon-ownership"; +import { saveCheckpointPages } from "../../src/runtime/checkpoint-pages"; +import { resolveRuntimePaths, loadRuntimeConfig } from "../../src/runtime/runtime-persistence"; + +const cliScript = path.resolve(__dirname, "../../dist/runtime/cli.js"); +const localEndpoint = "http://127.0.0.1:0"; + +it("previews and verifies the installed hooks across two projects and profiles", async () => { + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "crosshead-workspaces-"))); + const home = path.join(root, "runtime"); + const codexHome = path.join(root, "codex"); + const claudeHome = path.join(root, "claude"); + const projectA = path.join(root, "repo A's checkout"); + const projectB = path.join(root, "repo B"); + const outside = path.join(root, "unselected"); + const subdirectory = path.join(projectA, "src"); + const nestedRepo = path.join(projectA, "nested"); + const env = { + PATH: process.env.PATH, + CROSSHEAD_PROFILE: "default", + CROSSHEAD_CAPTURE_SOURCE_EVIDENCE: "1", + CROSSHEAD_HOOK_AUTOSTART: "0", + }; + const cli = (...args: string[]) => { + const run = spawnSync(process.execPath, [cliScript, ...args, "--home", home], { env, encoding: "utf8", timeout: 10000 }); + expect(run.status, run.stderr || run.stdout || String(run.error)).toBe(0); + return run.stdout; + }; + const readJson = (file: string) => JSON.parse(readFileSync(file, "utf8")); + const check = (directory: string) => JSON.parse(cli("workspace", "check", "--workspace-root", directory, "--json")); + const configFor = (host: string, directory: string) => path.join(directory, host === "codex" ? ".codex/hooks.json" : ".claude/settings.local.json"); + const hook = (file: string, event: string) => readJson(file).hooks[event].at(-1).hooks[0].command as string; + const invoke = (command: string, cwd: string, payload: Record) => { + const run = spawnSync("sh", ["-c", command], { cwd, env, input: JSON.stringify({ cwd, ...payload }), encoding: "utf8", timeout: 10000 }); + expect(run.stderr).toBe(""); + expect(run.status, String(run.error)).toBe(0); + return run.stdout; + }; + const common = ["--codex-home", codexHome, "--claude-home", claudeHome]; + try { + for (const dir of [codexHome, claudeHome, path.join(projectA, ".git"), path.join(projectB, ".git"), subdirectory, path.join(nestedRepo, ".git"), outside]) { + mkdirSync(dir, { recursive: true }); + } + for (const project of [projectA, projectB, nestedRepo, outside]) { + mkdirSync(path.join(project, ".git"), { recursive: true }); + writeFileSync(path.join(project, ".git/HEAD"), "ref: refs/heads/main\n"); + } + cli("keygen", "--setup", "--profile", "default", "--api-base", localEndpoint); + const global = path.join(codexHome, "hooks.json"); + const foreignHook = { type: "command", command: `printf '%s' '${home}/bin/onlybots-codex-hook'` }; + const otherRuntime = { type: "command", command: `'${home}/bin/onlybots-codex-hook' SessionStart --home '${root}/other-runtime'` }; + const legacy = `'${home}/bin/onlybots-codex-hook' SessionStart --home '${home}' --codex-home '${codexHome}'`; + const customHook = { type: "command", command: `${legacy} && printf 'keep custom action'` }; + const globalText = JSON.stringify({ description: "keep me", hooks: { SessionStart: [{ hooks: [ + foreignHook, otherRuntime, customHook, { type: "command", command: legacy }, + ] }] } }); + writeFileSync(global, globalText); + const firstSetup = [ + "quickstart", "--scope", "project", "--adapter", "both", "--workspace-root", subdirectory, + "--profile", "a", "--api-base", localEndpoint, "--port", "0", ...common, "--json", + ]; + const preview = JSON.parse(cli(...firstSetup, "--dry-run")); + expect(preview.applied).toBe(false); + expect(preview.newProfiles).toEqual(["a"]); + expect(preview.bindings).toHaveLength(2); + expect(preview.bindings.every((binding: { root: string }) => binding.root === projectA)).toBe(true); + expect(preview.replacesUserHooksIn).toEqual([global]); + expect(readFileSync(global, "utf8")).toBe(globalText); + expect(existsSync(configFor("codex", projectA))).toBe(false); + expect(existsSync(path.join(home, "config/profiles/a.json"))).toBe(false); + + expect(JSON.parse(cli(...firstSetup)).applied).toBe(true); + expect(readJson(global)).toEqual({ description: "keep me", hooks: { SessionStart: [{ hooks: [foreignHook, otherRuntime, customHook] }] } }); + expect(check(projectA).bindings).toEqual(expect.arrayContaining([ + expect.objectContaining({ host: "codex", profile: "a", configured: true, startup: null, resume: null }), + expect.objectContaining({ host: "claude-code", profile: "a", configured: true, startup: null, resume: null }), + ])); + cli("keygen", "--setup", "--profile", "b", "--api-base", localEndpoint, "--no-default"); + cli("workspace", "add", "--workspace-root", projectB, "--adapter", "codex", "--profile", "b", ...common); + cli("workspace", "add", "--workspace-root", projectB, "--adapter", "claude-code", "--profile", "a", ...common); + expect(loadRuntimeConfig(resolveRuntimePaths(home)).defaultProfile).toBe("default"); + + const aCodex = configFor("codex", projectA); + const aClaude = configFor("claude-code", projectA); + const bCodex = configFor("codex", projectB); + const startA = hook(aCodex, "SessionStart"); + invoke(startA, subdirectory, { source: "startup", session_id: "a-codex" }); + invoke(hook(aCodex, "UserPromptSubmit"), subdirectory, { session_id: "a-codex", turn_id: "one", prompt: "Repair the queue" }); + const eventFile = path.join(home, "state/lifecycle-events.jsonl"); + const beforeTool = readFileSync(eventFile, "utf8"); + invoke(hook(aCodex, "PostToolUse"), subdirectory, { + session_id: "a-codex", turn_id: "one", tool_name: "Read", + tool_input: { file_path: path.join(projectA, "rules.md") }, + tool_response: { type: "text", file: { filePath: path.join(projectA, "rules.md"), content: "Source excerpts are disabled for this binding." } }, + }); + expect(readFileSync(eventFile, "utf8")).toBe(beforeTool); + const finding = "Keep the queue ordered by the original receipt ID."; + invoke(hook(aCodex, "Stop"), subdirectory, { session_id: "a-codex", turn_id: "one", summary: finding }); + await vi.waitFor(() => expect(cli("memory", "hot", "--profile", "a", "--json")).toContain(finding), { timeout: 6000, interval: 200 }); + expect(cli("memory", "hot", "--profile", "b", "--json")).not.toContain(finding); + expect(cli("memory", "search", "queue receipt", "--profile", "a", "--json")).toContain(finding); + expect(cli("memory", "search", "queue receipt", "--profile", "b", "--json")).not.toContain(finding); + const pageKey = saveCheckpointPages(home, "codex", `repo:${projectA}`, ["First saved page", "Further saved source observations"]); + expect(cli("checkpoint-page", "2", "--runtime", "codex", "--key", pageKey)).toContain("Further saved source observations"); + invoke(hook(aClaude, "SessionStart"), projectA, { source: "startup", session_id: "a-claude" }); + const aContext = invoke(hook(aClaude, "UserPromptSubmit"), projectA, { session_id: "a-claude", prompt: "Continue the queue repair" }); + expect(aContext).toContain("profile=a"); + expect(aContext).toContain(finding); + expect(aContext).not.toContain("bounded workspace file-read evidence"); + invoke(startA, projectA, { source: "resume", session_id: "a-codex" }); + invoke(hook(bCodex, "SessionStart"), projectB, { source: "startup", session_id: "b-codex" }); + const bContext = invoke(hook(bCodex, "UserPromptSubmit"), projectB, { session_id: "b-codex", prompt: "Continue the queue repair" }); + expect(bContext).toContain("profile=b"); + expect(bContext).not.toContain(finding); + const sameProfileContext = invoke(hook(configFor("claude-code", projectB), "UserPromptSubmit"), projectB, { + session_id: "b-claude", prompt: "Continue the queue repair", + }); + expect(sameProfileContext).toContain("profile=a"); + expect(sameProfileContext).not.toContain(finding); + expect(check(projectA).bindings.find((binding: { host: string }) => binding.host === "codex")).toMatchObject({ + startup: { profile: "a", workspaceRoot: projectA }, + resume: { profile: "a", workspaceRoot: projectA }, + }); + expect(check(outside).bindings).toEqual([]); + expect(check(nestedRepo).bindings).toEqual([]); + const events = path.join(home, "state/lifecycle-events.jsonl"); + const before = readFileSync(events, "utf8"); + expect(invoke(startA, outside, { source: "startup", session_id: "outside" })).toBe(""); + expect(invoke(startA, nestedRepo, { source: "startup", session_id: "nested" })).toBe(""); + expect(invoke(`'${home}/bin/crosshead-codex-hook' SessionStart --via plugin --home '${home}'`, outside, { source: "startup" })).toBe(""); + cli("memory", "mode", "off", "--profile", "a"); + expect(invoke(hook(aClaude, "UserPromptSubmit"), projectA, { session_id: "a-claude", prompt: "Continue the queue repair" })).toBe(""); + expect(invoke(hook(aCodex, "Stop"), projectA, { session_id: "a-codex", summary: "Do not save this" })).toBe(""); + expect(readFileSync(events, "utf8")).toBe(before); + + cli("workspace", "remove", "--workspace-root", projectA, ...common); + expect(check(projectA).bindings).toEqual([]); + expect(check(projectB).bindings).toHaveLength(2); + expect(invoke(startA, projectA, { source: "resume", session_id: "a-codex" })).toBe(""); + expect(readFileSync(events, "utf8")).toBe(before); + + cli("workspace", "add", "--workspace-root", projectB, "--adapter", "codex", "--profile", "b", "--source-excerpts", "on", ...common); + invoke(hook(bCodex, "SessionStart"), projectB, { source: "startup", session_id: "capture-on" }); + invoke(hook(bCodex, "UserPromptSubmit"), projectB, { session_id: "capture-on", turn_id: "capture", prompt: "Inspect the selected project" }); + const toolOutput = "Selected workspace source text for the opted-in capture."; + invoke(hook(bCodex, "PostToolUse"), projectB, { + session_id: "capture-on", turn_id: "capture", tool_name: "Read", + tool_input: { file_path: path.join(projectB, "rules.md") }, + tool_response: { type: "text", file: { filePath: path.join(projectB, "rules.md"), content: toolOutput } }, + }); + expect(readFileSync(events, "utf8")).toContain(toolOutput); + + cli("quickstart", "--scope", "all", "--adapter", "codex", "--profile", "b", "--no-start", ...common); + expect(check(outside).bindings).toHaveLength(1); + invoke(hook(global, "SessionStart"), outside, { source: "startup", session_id: "global" }); + expect(check(outside).bindings[0].startup).toMatchObject({ profile: "b", workspaceRoot: outside }); + cli("quickstart", "--scope", "manual", "--profile", "b", "--no-start", ...common); + expect(check(outside)).toMatchObject({ scope: "manual", bindings: [] }); + expect(readJson(global).hooks.SessionStart[0].hooks).toEqual([foreignHook, otherRuntime, customHook]); + cli("workspace", "add", "--workspace-root", projectB, "--adapter", "both", "--profile", "b", ...common); + cli("uninstall", "codex", ...common); + expect(check(projectB).bindings.map((binding: { host: string }) => binding.host)).toEqual(["claude-code"]); + cli("uninstall"); + expect(existsSync(home)).toBe(false); + expect(readJson(configFor("claude-code", projectB)).hooks).toEqual({}); + } finally { + if (readDaemonOwnershipState(resolveRuntimePaths(home))) await stopDetachedRuntimeDaemon(home); + rmSync(root, { recursive: true, force: true }); + } +}, 30000); From 3dedb335b078b33c789b708f563db708ebd07369 Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Sat, 14 Nov 2026 02:15:48 -0500 Subject: [PATCH 2/3] Preserve continuity and installation state during scoped upgrades --- README.md | 20 ++- src/runtime/adapter-utils.ts | 5 + src/runtime/claude-code-adapter.ts | 18 +- src/runtime/claude-code-hook.ts | 15 +- src/runtime/cli.ts | 16 +- src/runtime/cli/core.ts | 6 +- src/runtime/cli/quickstart.ts | 7 + src/runtime/cli/workspace.ts | 29 ++- src/runtime/codex-adapter.ts | 36 ++-- src/runtime/codex-hook.ts | 15 +- src/runtime/daemon-kernel-lock.ts | 4 +- src/runtime/host-adapter-base.ts | 32 +++- src/runtime/local-hot-memory.ts | 49 +++++- src/runtime/local-runtime.ts | 15 +- src/runtime/runtime-env.ts | 3 + src/runtime/runtime-persistence.ts | 4 +- src/runtime/workspace-hooks.ts | 125 ++++++++++--- tests/runtime/onboarding-upgrade.test.ts | 215 +++++++++++++++++++++++ tests/runtime/profile-selection.test.ts | 18 +- 19 files changed, 536 insertions(+), 96 deletions(-) create mode 100644 tests/runtime/onboarding-upgrade.test.ts diff --git a/README.md b/README.md index cda493b..9070d4f 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,10 @@ crosshead workspace list crosshead workspace remove --workspace-root ../other-project ``` -Project setup writes `.codex/hooks.json` and `.claude/settings.local.json` for -the selected hosts. Keep the generated entries local to this machine. Existing +Project setup writes local hook settings for each selected host. Codex settings +are excluded locally through Git's info/exclude file. When a repository already +tracks its Codex hooks, Crosshead uses a directory-restricted user registration +and preserves the tracked file. Claude Code uses settings.local.json. Existing user-wide Crosshead hook commands for this runtime are listed in the preview when setup replaces them. The runtime skips old Crosshead plugin and hook registrations after scoped setup. Review and restart host sessions after a @@ -143,16 +145,20 @@ a fresh session after choosing either setting. - `crosshead status`, `crosshead doctor` and `crosshead logs` show what the runtime is doing. Local state lives in `~/.crosshead/state/`. -Set `CROSSHEAD_PROFILE` for explicit CLI commands or older adapter setups. +Set `CROSSHEAD_PROFILE` for explicit CLI commands. Legacy hooks retain their +installed profile. `ONLYBOTS_PROFILE` is the legacy alias. Explicit CLI `--profile` takes precedence. Scoped hooks use the profile selected during setup. Use `crosshead memory mode off --profile ` to stop automatic memory hooks for that profile, or `crosshead workspace remove` to detach a project. -Local-only checkpoints are stored per profile. Older flat snapshots in the -runtime's `state/local-hot-memory/*.json` lack a profile owner. Review those files -directly to recover saved findings. Automatic recall uses checkpoints with an -identified profile and workspace in scoped setup. +Local-only checkpoints are stored per profile. A legacy flat store remains +readable for its sole existing local-only profile, whose identity is pinned +before creating another profile. If several profiles already shared an unowned +store, review its `state/local-hot-memory/*.json` files to establish ownership. +Scoped setup retains matching subdirectory and symlink history. Its preview +shows adoption of earlier unscoped checkpoints when one project is selected +for an existing profile. Later unscoped captures require an explicit workspace. `crosshead uninstall` removes the runtime. Uninstall the plugin (`claude plugin uninstall crosshead@crosshead`, `codex plugin remove diff --git a/src/runtime/adapter-utils.ts b/src/runtime/adapter-utils.ts index 63f4a21..975e171 100644 --- a/src/runtime/adapter-utils.ts +++ b/src/runtime/adapter-utils.ts @@ -99,6 +99,11 @@ export const parseArgs = (args: string[]): { positionals: string[]; flags: Recor positionals.push(value); continue; } + const equals = value.indexOf("="); + if (equals > 2) { + flags[value.slice(2, equals)] = value.slice(equals + 1); + continue; + } const key = value.slice(2); const next = args[index + 1]; if (!next || next.startsWith("--")) { diff --git a/src/runtime/claude-code-adapter.ts b/src/runtime/claude-code-adapter.ts index 6910cae..2893fc8 100644 --- a/src/runtime/claude-code-adapter.ts +++ b/src/runtime/claude-code-adapter.ts @@ -1,3 +1,4 @@ +import { readWorkspaceHookPolicy, refreshWorkspaceHooks } from "./workspace-hooks"; import { accessSync, chmodSync, constants, existsSync, readFileSync, rmSync } from "fs"; import os from "os"; import path from "path"; @@ -97,6 +98,8 @@ export type ClaudeCodeAdapterConfig = { profileName: string; sourceExcerpts?: boolean; workspaceOnly?: boolean; + legacyUnscoped?: boolean; + legacyBefore?: string; claudeBinary: string; /** Absent in configs written before host plugins; treated as "settings". */ hookMode?: HostHookMode; @@ -623,6 +626,7 @@ export const installClaudeCodeAdapter = async ( options: ClaudeCodeAdapterInstallOptions ): Promise => { const runtimeLayout = ensureRuntimeHomeLayout(options.home); + const scopedPolicy = readWorkspaceHookPolicy(runtimeLayout); const hostHome = resolveClaudeCodeHome(options.hostHome); const workspaceRoot = resolveClaudeWorkspaceRoot(options.workspaceRoot, runtimeLayout.home); const adapterPaths = resolveClaudeCodeAdapterPaths(runtimeLayout.home, hostHome, workspaceRoot); @@ -663,14 +667,14 @@ export const installClaudeCodeAdapter = async ( (root, index, all): root is string => typeof root === "string" && all.indexOf(root) === index ); const migrationTargets = - hookMode === "plugin" + !scopedPolicy && 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); + if (!scopedPolicy && hookMode === "settings" && resolution.writeLegacyHooks) assertManagedFilePath(adapterPaths.projectSettingsFile, workspaceRoot); for (const { root, filePath } of migrationTargets) { try { assertManagedFilePath(filePath, root); @@ -690,7 +694,7 @@ export const installClaudeCodeAdapter = async ( for (const { root, filePath } of migrationTargets) { if (removeLegacyClaudeHooks(filePath, root, runtimeLayout.home)) migratedFiles.push(filePath); } - if (hookMode === "settings" && resolution.writeLegacyHooks) { + if (!scopedPolicy && hookMode === "settings" && resolution.writeLegacyHooks) { writeManagedFile( adapterPaths.projectSettingsFile, renderClaudeSettingsConfig(readClaudeSettingsConfig(adapterPaths.projectSettingsFile), adapterPaths, config), @@ -728,6 +732,8 @@ export const installClaudeCodeAdapter = async ( updatedAt: nowIso(), }); + if (scopedPolicy) await refreshWorkspaceHooks(runtimeLayout.home); + return { adapter: existingRecord, config, @@ -789,7 +795,7 @@ export const loadClaudeCodeAdapterConfig = ( return { ...parsed, runtimeHome: runtimeHomeInput ? resolveRuntimeHome(runtimeHomeInput) : parsed.runtimeHome, - profileName: resolveCrossheadProfile() ?? parsed.profileName, + profileName: parsed.profileName, }; }; @@ -822,7 +828,7 @@ export class ClaudeCodeRuntimeAdapter extends HostRuntimeAdapterBase { ); } - static forWorkspace(home: string, hostHome: string, workspaceRoot: string, profileName: string, sourceExcerpts: boolean): ClaudeCodeRuntimeAdapter { + static forWorkspace(home: string, hostHome: string, workspaceRoot: string, profileName: string, sourceExcerpts: boolean, legacyUnscoped = false, legacyBefore?: string): ClaudeCodeRuntimeAdapter { return new ClaudeCodeRuntimeAdapter(new LocalRuntimeClient({ home, timeoutMs: 30_000 }), { ...buildClaudeCodeAdapterConfig({ runtimeHome: home, hostHome, workspaceRoot, profileName, @@ -831,6 +837,8 @@ export class ClaudeCodeRuntimeAdapter extends HostRuntimeAdapterBase { }), sourceExcerpts, workspaceOnly: true, + legacyUnscoped, + legacyBefore, }); } diff --git a/src/runtime/claude-code-hook.ts b/src/runtime/claude-code-hook.ts index 413d890..50d9f76 100644 --- a/src/runtime/claude-code-hook.ts +++ b/src/runtime/claude-code-hook.ts @@ -21,7 +21,7 @@ import { } from "./host-plugin-mode"; import { ensureRuntimeHomeLayout, resolveRuntimeHome } from "./local-runtime"; import { loadProfile, resolveRuntimePaths, writeJsonFile } from "./runtime-persistence"; -import { readWorkspaceHookPolicy, recordWorkspaceHookStart, workspaceHookForInvocation, workspaceHookStateFile } from "./workspace-hooks"; +import { readWorkspacePolicyForHook, recordWorkspaceHookStart, workspaceHookForInvocation, workspaceHookStateFile } from "./workspace-hooks"; import { assertSecureNodeRuntimeVersion } from "./node-runtime-version"; type HookTurnState = { @@ -373,14 +373,17 @@ export const runOnlyBotsClaudeCodeHookCli = async ( const { positionals, flags } = parseArgs(argv); const eventName = positionals[0] ?? "help"; const normalizedEventName = eventName.toLowerCase(); + const isToolFailure = normalizedEventName === "posttoolusefailure" || normalizedEventName === "post_tool_use_failure"; + const isPostToolUse = isToolFailure || normalizedEventName === "posttooluse" || normalizedEventName === "post_tool_use"; + if (isPostToolUse && (flags["source-excerpts"] === "off" || + (!flags.binding && !taskEvidenceCaptureEnabled()))) return; const runtimeHome = resolveRuntimeHome(typeof flags.home === "string" ? flags.home : undefined); const viaPlugin = isPluginHookInvocation(flags); const paths = resolveRuntimePaths(runtimeHome); - const policy = readWorkspaceHookPolicy(paths); + const policy = readWorkspacePolicyForHook(paths, normalizedEventName, flags.binding); + if (policy === false) return; // Ignore retired settings and plugin registrations before reading the chat payload. if (policy && !policy.bindings.some((binding) => binding.host === "claude-code" && binding.id === flags.binding)) return; - const isToolFailure = normalizedEventName === "posttoolusefailure" || normalizedEventName === "post_tool_use_failure"; - const isPostToolUse = isToolFailure || normalizedEventName === "posttooluse" || normalizedEventName === "post_tool_use"; const selectedBinding = policy?.bindings.find((binding) => binding.id === flags.binding); if (isPostToolUse && !(selectedBinding?.sourceExcerpts ?? taskEvidenceCaptureEnabled())) return; const payload = readStdinJson(stdinOverride); @@ -390,10 +393,12 @@ export const runOnlyBotsClaudeCodeHookCli = async ( (typeof flags["claude-home"] === "string" ? flags["claude-home"] : viaPlugin ? resolvePluginHostHome("claude-code", runtimeHome) : undefined); const workspaceRoot = scoped?.root ?? resolveHookWorkspaceRoot(payload, flags); const workspaceId = scoped ? `repo:${workspaceRoot}` : resolveHookWorkspaceId(payload, flags, workspaceRoot); + let legacyUnscoped = false; if (scoped) { const profile = loadProfile(paths, scoped.binding.profile); if (!profile) throw new Error(`Unknown Crosshead profile: ${scoped.binding.profile}`); if (profile.memoryMode === "off") return; + legacyUnscoped = scoped.binding.legacyUnscoped === true && scoped.binding.legacyNpub === profile.npub; } if (!scoped && eventName !== "help") { let installedConfig: ReturnType | null = null; @@ -431,7 +436,7 @@ export const runOnlyBotsClaudeCodeHookCli = async ( if (decision === "skip-duplicate") return; } const adapter = scoped - ? ClaudeCodeRuntimeAdapter.forWorkspace(runtimeHome, scoped.binding.hostHome, workspaceRoot, scoped.binding.profile, scoped.binding.sourceExcerpts) + ? ClaudeCodeRuntimeAdapter.forWorkspace(runtimeHome, scoped.binding.hostHome, workspaceRoot, scoped.binding.profile, scoped.binding.sourceExcerpts, legacyUnscoped, scoped.binding.legacyBefore) : ClaudeCodeRuntimeAdapter.fromInstalled({ home: runtimeHome, hostHome, diff --git a/src/runtime/cli.ts b/src/runtime/cli.ts index 5533f88..700d74c 100644 --- a/src/runtime/cli.ts +++ b/src/runtime/cli.ts @@ -85,15 +85,22 @@ export const runOnlyBotsCtlCli = async ( extensions: OnlyBotsCtlCliExtensions = {} ): Promise => { assertSecureNodeRuntimeVersion(); - const profile = resolveCrossheadProfile(); - if (profile && !argv.includes("--profile")) argv = [...argv, "--profile", profile]; const command = argv[0]; - const rest = argv.slice(1); + let rest = argv.slice(1); const extension = command ? extensions.commands?.[command] : undefined; if (extension) { process.stdout.write(`${await extension(rest)}\n`); return; } + const profileCommands = new Set([ + "setup", "keygen", "status", "doctor", "memory", "messages", "billing", "public", "context", + "agent", "lineage", "passport", "workspace", "handoff", "sign-challenge", "sign-deletion-challenge", "organization", "quickstart", "onboard", + ]); + const profile = resolveCrossheadProfile(); + if (profile && command && profileCommands.has(command) && + !rest.some((arg) => arg === "--profile" || arg.startsWith("--profile="))) { + rest = [...rest, "--profile", profile]; + } const cliScriptPath = extensions.cliScriptPath ?? path.resolve(__filename); const daemonScriptPath = extensions.daemonScriptPath ?? path.resolve(__dirname, "daemon.js"); const codexBridgeScriptPath = path.resolve(__dirname, "codex-bridge.js"); @@ -212,8 +219,7 @@ export const runOnlyBotsCtlCli = async ( const adapter = flags.adapter ?? flags.host; const guided = !adapter && !flags.scope && !flags["hook-mode"] && !flags.json && !flags["dry-run"] && Boolean(process.stdin.isTTY && process.stderr.isTTY); - const scoped = guided || flags.scope !== undefined || flags["dry-run"] !== undefined || (!flags["hook-mode"] && - (adapter === "codex" || adapter === "claude-code" || adapter === "both")); + const scoped = guided || flags.scope !== undefined; const scripts = { cliScriptPath, daemonScriptPath, diff --git a/src/runtime/cli/core.ts b/src/runtime/cli/core.ts index 4926d84..bd5c4a1 100644 --- a/src/runtime/cli/core.ts +++ b/src/runtime/cli/core.ts @@ -5,7 +5,7 @@ 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"; -import { inspectWorkspaceHooks } from "../workspace-hooks"; +import { inspectWorkspaceHooks, workspaceBindingStatus } from "../workspace-hooks"; export const installCoreFromCli = async (argv: string[], cliScriptPath: string, daemonScriptPath: string): Promise => { const { flags } = parseArgs(argv); @@ -214,9 +214,7 @@ export const doctorFromCli = async (argv: string[]): Promise => { ? workspaceHooks.bindings.map((binding) => ({ id: `${binding.host.replace("-", "_")}_hooks`, ok: binding.configured && binding.memoryMode !== null, - message: `profile=${binding.profile}; ${!binding.configured ? "hook settings missing or changed" : - binding.memoryMode === null ? "profile missing" : binding.memoryMode === "off" ? "memory off" : - binding.startup || binding.resume ? "observed in host" : "configured; waiting for host"}. ${binding.file}`, + message: `profile=${binding.profile}; ${workspaceBindingStatus(binding, workspaceHooks.workspace)}. ${binding.file}`, })) : [{ id: "workspace_hooks", ok: true, message: "No automatic Crosshead hooks apply in this directory." }]; const checks = [...daemonReport.checks, ...hookChecks]; diff --git a/src/runtime/cli/quickstart.ts b/src/runtime/cli/quickstart.ts index 69a08a1..56791f0 100644 --- a/src/runtime/cli/quickstart.ts +++ b/src/runtime/cli/quickstart.ts @@ -162,6 +162,13 @@ export const quickstartFromCli = async (argv: string[], paths: QuickstartCliPath const memoryMode = typeof flags["memory-mode"] === "string" ? flags["memory-mode"] : "assist"; const workspaceScope = typeof flags["workspace-scope"] === "string" ? flags["workspace-scope"] : "repo"; const adapter = selectedAdapter(flags); + if (flags["dry-run"]) { + const preview = { applied: false, scope: "legacy", adapter, profile: profileName, + home: resolveRuntimePaths(home).home, workspaceRoot: typeof flags["workspace-root"] === "string" ? path.resolve(flags["workspace-root"]) : process.cwd(), + memoryMode, workspaceScope }; + return flags.json ? JSON.stringify(preview, null, 2) + : `Legacy setup preview for ${adapter}, profile ${profileName}, in ${preview.home}. Use --scope project to configure selected projects.`; + } const coreArgs = [...flagPair("--home", home), ...flagPair("--channel", typeof flags.channel === "string" ? flags.channel : undefined)]; const profileArgs = [ ...flagPair("--home", home), diff --git a/src/runtime/cli/workspace.ts b/src/runtime/cli/workspace.ts index 3322b9c..8cddc6d 100644 --- a/src/runtime/cli/workspace.ts +++ b/src/runtime/cli/workspace.ts @@ -3,11 +3,14 @@ import path from "node:path"; import { createInterface } from "node:readline/promises"; import { parseArgs, shellQuote } from "../adapter-utils"; import type { HookHost } from "../host-hook-spec"; -import { loadProfile, loadRuntimeConfig, resolveRuntimePaths } from "../runtime-persistence"; +import { loadAdapterRecord, loadProfile, loadRuntimeConfig, resolveRuntimePaths } from "../runtime-persistence"; import { applyWorkspaceHookPlan, assertWorkspacePlanCurrent, inspectWorkspaceHooks, planWorkspaceHooks, readWorkspaceHookPolicy, summarizeWorkspaceHookPlan, - withWorkspaceSetupLock, workspaceProjectRoot, type HookScope, type WorkspaceHookTarget, + withWorkspaceSetupLock, workspaceBindingStatus, workspaceProjectRoot, type HookScope, type WorkspaceHookTarget, } from "../workspace-hooks"; +import { isRuntimeProfileName } from "../runtime-env"; +import { uninstallCodexFromCli } from "../codex-adapter"; +import { uninstallClaudeCodeFromCli } from "../claude-code-adapter"; import { installCoreFromCli, keygenFromCli } from "./core"; import { ensureQuickstartDaemon, type QuickstartCliPaths } from "./quickstart"; @@ -38,6 +41,8 @@ const homeArgs = (home?: string): string[] => home ? ["--home", home] : []; const describePlan = (plan: ReturnType): string => [ `Crosshead scope: ${plan.scope === "project" ? "selected projects" : plan.scope === "all" ? "all projects" : "manual commands only"}`, ...plan.bindings.map((binding) => ` ${binding.root ?? "Every directory"} · ${binding.host} · profile ${binding.profile} · source excerpts ${binding.sourceExcerpts ? "on" : "off"}`), + ...plan.bindings.filter(binding => binding.legacyUnscoped).map(binding => + ` Retain earlier unscoped checkpoints for profile ${binding.profile} in ${binding.root ?? "the selected workspace"}`), ...(plan.replacesUserHooksIn.length ? ["Replaces earlier Crosshead hooks in", ...plan.replacesUserHooksIn.map((file) => ` ${file}`)] : []), ...(plan.files.length ? ["Files to update", ...plan.files.map((file) => ` ${file}`)] : ["Hook settings already match"]), ].join("\n"); @@ -46,10 +51,7 @@ export const formatWorkspaceHookInspection = (inspection: ReturnType { - const status = !binding.configured ? "hook settings missing or changed" : - !binding.memoryMode ? "profile missing" : binding.memoryMode === "off" ? "memory off" : - binding.startup || binding.resume ? "observed in host" : - !inspection.workspace && !binding.root ? "configured; check from a project" : "configured; waiting for host"; + const status = workspaceBindingStatus(binding, inspection.workspace); return [ `${binding.host} · ${binding.root ?? "all projects"} · profile ${binding.profile} · ${status}`, ` ${binding.file}`, @@ -76,7 +78,7 @@ export const workspaceHooksFromCli = async (argv: string[]): Promise => const hosts = selectedHosts(str(flags, "adapter") ?? (command === "remove" ? "both" : "")); const paths = resolveRuntimePaths(options.home); const profile = str(flags, "profile") ?? loadRuntimeConfig(paths).defaultProfile; - if (command === "add" && (!profile || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(profile) || !loadProfile(paths, profile))) { + if (command === "add" && (!profile || !isRuntimeProfileName(profile) || !loadProfile(paths, profile))) { throw new Error("Choose an existing --profile, or run crosshead quickstart to create one."); } const current = inspectWorkspaceHooks(options.home); @@ -106,14 +108,23 @@ export const uninstallWorkspaceHostFromCli = async (host: HookHost, argv: string await withWorkspaceSetupLock(paths, async () => { const policy = readWorkspaceHookPolicy(paths); if (!policy) throw new Error("Workspace scope changed during uninstall. Run the command again."); + const homes = new Set(policy.bindings.filter(binding => binding.host === host).map(binding => binding.hostHome)); + const recordedHome = loadAdapterRecord(paths, host)?.hostHome; + if (recordedHome) homes.add(recordedHome); + const explicitHome = host === "codex" ? options.codexHome : options.claudeHome; + if (explicitHome) homes.add(explicitHome); const plan = planWorkspaceHooks({ ...options, scope: policy.bindings.every((binding) => binding.host === host) ? "manual" : policy.scope, targets: [], removeHost: host, cleanup: true, }); applyWorkspaceHookPlan(plan); + const uninstall = host === "codex" ? uninstallCodexFromCli : uninstallClaudeCodeFromCli; + const homeFlag = host === "codex" ? "--codex-home" : "--claude-home"; + if (homes.size === 0) await uninstall(argv); + else for (const home of homes) await uninstall([...argv, homeFlag, home]); }); - return `Detached automatic Crosshead hooks for ${host}.`; + return `Uninstalled Crosshead for ${host}.`; }; const guidedSetup = async (flags: Flags, fallbackProfile: string, profiles: string[]) => { @@ -166,7 +177,7 @@ export const scopedQuickstartFromCli = async (flags: Flags, scripts: QuickstartC profile: fallbackProfile, sourceExcerpts: sourceExcerpts(flags), }]; const names = [...new Set(targets.map((target) => target.profile))]; - if (names.some((name) => !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name))) throw new Error("Profile names must use letters, numbers, dots, underscores or hyphens."); + if (names.some((name) => !isRuntimeProfileName(name))) throw new Error("Profile names must use 1–128 letters, numbers, dots, underscores, plus signs or hyphens."); const profileSnapshot = (name: string) => { const profile = loadProfile(paths, name); return profile ? JSON.stringify([profile.npub, profile.apiBase, profile.memoryMode, profile.workspaceScope]) : null; diff --git a/src/runtime/codex-adapter.ts b/src/runtime/codex-adapter.ts index 410fbfa..36001e5 100644 --- a/src/runtime/codex-adapter.ts +++ b/src/runtime/codex-adapter.ts @@ -1,3 +1,4 @@ +import { readWorkspaceHookPolicy, refreshWorkspaceHooks } from "./workspace-hooks"; import { accessSync, chmodSync, constants, existsSync, readFileSync, rmSync } from "fs"; import os from "os"; import path from "path"; @@ -97,6 +98,8 @@ export type CodexAdapterConfig = { profileName: string; sourceExcerpts?: boolean; workspaceOnly?: boolean; + legacyUnscoped?: boolean; + legacyBefore?: string; skillName: string; /** Absent in configs written before host plugins; treated as "settings". */ hookMode?: HostHookMode; @@ -649,6 +652,7 @@ export const codexLegacyHooksFile = (hostHome: string): string => hooksFile(host export const installCodexAdapter = async (options: CodexAdapterInstallOptions): Promise => { const runtimeLayout = ensureRuntimeHomeLayout(options.home); + const scopedPolicy = readWorkspaceHookPolicy(runtimeLayout); const hostHome = resolveCodexHome(options.hostHome); const previousConfig = existsSync(configFile(hostHome)) ? (() => { @@ -672,8 +676,8 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): // dispatch rule lets the installer hooks capture and the plugin defer. const legacyPresent = codexHooksFileHasLegacyHooks(hooksFile(hostHome), runtimeLayout.home); const pluginObserved = pluginInvocationMatches(readPluginInvocation(runtimeLayout.home, "codex"), hostHome); - const migrateLegacy = hookMode === "plugin" && legacyPresent && pluginObserved; - const writeLegacy = hookMode === "settings" && resolution.writeLegacyHooks; + const migrateLegacy = !scopedPolicy && hookMode === "plugin" && legacyPresent && pluginObserved; + const writeLegacy = !scopedPolicy && hookMode === "settings" && resolution.writeLegacyHooks; const hookModeNote = resolution.note ?? (hookMode === "plugin" && legacyPresent && !pluginObserved @@ -700,15 +704,17 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): hookModeRequest: resolution.request, }); - writeManagedFile( - adapterPaths.skillFile, - renderCodexSkillMarkdown(config, adapterPaths.wrapperFile), - hostHome - ); - // Retire only adapter-owned legacy skill copies; leave operator-authored skills intact. - for (const name of ["onlybots-bootstrap", "onlybots-runtime"]) { - const legacy = path.join(hostHome, "skills", name, "SKILL.md"); - if (isManagedContent(legacy)) rmSync(legacy); + if (!scopedPolicy) { + writeManagedFile( + adapterPaths.skillFile, + renderCodexSkillMarkdown(config, adapterPaths.wrapperFile), + hostHome + ); + // Retire only adapter-owned legacy skill copies; leave operator-authored skills intact. + for (const name of ["onlybots-bootstrap", "onlybots-runtime"]) { + const legacy = path.join(hostHome, "skills", name, "SKILL.md"); + if (isManagedContent(legacy)) rmSync(legacy); + } } writeManagedFile( adapterPaths.configFile, @@ -760,6 +766,8 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): updatedAt: nowIso(), }); + if (scopedPolicy) await refreshWorkspaceHooks(runtimeLayout.home); + return { adapter: existingRecord, config, @@ -825,7 +833,7 @@ export const loadCodexAdapterConfig = ( return { ...parsed, runtimeHome: runtimeHomeInput ? resolveRuntimeHome(runtimeHomeInput) : parsed.runtimeHome, - profileName: resolveCrossheadProfile() ?? parsed.profileName, + profileName: parsed.profileName, }; }; @@ -860,12 +868,14 @@ export class CodexRuntimeAdapter extends HostRuntimeAdapterBase { ); } - static forWorkspace(home: string, hostHome: string, profileName: string, sourceExcerpts: boolean): CodexRuntimeAdapter { + static forWorkspace(home: string, hostHome: string, profileName: string, sourceExcerpts: boolean, legacyUnscoped = false, legacyBefore?: string): CodexRuntimeAdapter { return new CodexRuntimeAdapter(new LocalRuntimeClient({ home, timeoutMs: 30_000 }), { ...buildEphemeralCodexAdapterConfig(home, hostHome), profileName, sourceExcerpts, workspaceOnly: true, + legacyUnscoped, + legacyBefore, }); } diff --git a/src/runtime/codex-hook.ts b/src/runtime/codex-hook.ts index efe42c5..a1b127a 100644 --- a/src/runtime/codex-hook.ts +++ b/src/runtime/codex-hook.ts @@ -20,7 +20,7 @@ import { claimSessionStartContext, releaseSessionStartContext } from "./hook-ses import { taskEvidenceCaptureEnabled } from "./task-evidence"; import { readLastAssistantMessage } from "./hook-transcript"; import { loadProfile, resolveRuntimePaths, writeJsonFile } from "./runtime-persistence"; -import { readWorkspaceHookPolicy, recordWorkspaceHookStart, workspaceHookForInvocation, workspaceHookStateFile } from "./workspace-hooks"; +import { readWorkspacePolicyForHook, recordWorkspaceHookStart, workspaceHookForInvocation, workspaceHookStateFile } from "./workspace-hooks"; import { assertSecureNodeRuntimeVersion } from "./node-runtime-version"; type HookTurnState = { @@ -374,14 +374,17 @@ export const runOnlyBotsCodexHookCli = async ( const { positionals, flags } = parseArgs(argv); const eventName = positionals[0] ?? "help"; const normalizedEventName = eventName.toLowerCase(); + const isToolFailure = normalizedEventName === "posttoolusefailure" || normalizedEventName === "post_tool_use_failure"; + const isPostToolUse = isToolFailure || normalizedEventName === "posttooluse" || normalizedEventName === "post_tool_use"; + if (isPostToolUse && (flags["source-excerpts"] === "off" || + (!flags.binding && !taskEvidenceCaptureEnabled()))) return; const runtimeHome = resolveRuntimeHome(typeof flags.home === "string" ? flags.home : undefined); const viaPlugin = isPluginHookInvocation(flags); const paths = resolveRuntimePaths(runtimeHome); - const policy = readWorkspaceHookPolicy(paths); + const policy = readWorkspacePolicyForHook(paths, normalizedEventName, flags.binding); + if (policy === false) return; // Ignore retired settings and plugin registrations before reading the chat payload. if (policy && !policy.bindings.some((binding) => binding.host === "codex" && binding.id === flags.binding)) return; - const isToolFailure = normalizedEventName === "posttoolusefailure" || normalizedEventName === "post_tool_use_failure"; - const isPostToolUse = isToolFailure || normalizedEventName === "posttooluse" || normalizedEventName === "post_tool_use"; const selectedBinding = policy?.bindings.find((binding) => binding.id === flags.binding); if (isPostToolUse && !(selectedBinding?.sourceExcerpts ?? taskEvidenceCaptureEnabled())) return; const payload = readStdinJson(stdinOverride); @@ -391,10 +394,12 @@ export const runOnlyBotsCodexHookCli = async ( (typeof flags["codex-home"] === "string" ? flags["codex-home"] : viaPlugin ? resolvePluginHostHome("codex", runtimeHome) : undefined); const workspaceRoot = scoped?.root ?? resolveHookWorkspaceRoot(payload, flags); const workspaceId = scoped ? `repo:${workspaceRoot}` : resolveHookWorkspaceId(payload, flags, workspaceRoot); + let legacyUnscoped = false; if (scoped) { const profile = loadProfile(paths, scoped.binding.profile); if (!profile) throw new Error(`Unknown Crosshead profile: ${scoped.binding.profile}`); if (profile.memoryMode === "off") return; + legacyUnscoped = scoped.binding.legacyUnscoped === true && scoped.binding.legacyNpub === profile.npub; } const allowEphemeralConfig = flags["allow-ephemeral-config"] === true || @@ -439,7 +444,7 @@ export const runOnlyBotsCodexHookCli = async ( if (decision === "skip-duplicate") return; } const adapter = scoped - ? CodexRuntimeAdapter.forWorkspace(runtimeHome, scoped.binding.hostHome, scoped.binding.profile, scoped.binding.sourceExcerpts) + ? CodexRuntimeAdapter.forWorkspace(runtimeHome, scoped.binding.hostHome, scoped.binding.profile, scoped.binding.sourceExcerpts, legacyUnscoped, scoped.binding.legacyBefore) : CodexRuntimeAdapter.fromInstalled({ home: runtimeHome, hostHome, diff --git a/src/runtime/daemon-kernel-lock.ts b/src/runtime/daemon-kernel-lock.ts index b675b53..b6693cd 100644 --- a/src/runtime/daemon-kernel-lock.ts +++ b/src/runtime/daemon-kernel-lock.ts @@ -8,8 +8,8 @@ export type DaemonKernelLock = { alive(): boolean; close(): Promise }; * loopback listener is a kernel-owned lock that also releases on SIGKILL. * A port collision fails closed; we never evict a listener or probe its data. * No protocol or credentials are served on this socket. */ -export const acquireLoopbackDaemonLock = async (home: string): Promise => { - const port = 16384 + createHash("sha256").update(`onlybots-daemon:${home}`).digest().readUInt32BE(0) % 32768; +export const acquireLoopbackDaemonLock = async (home: string, purpose = "daemon"): Promise => { + const port = 16384 + createHash("sha256").update(`onlybots-${purpose}:${home}`).digest().readUInt32BE(0) % 32768; const server = createServer(socket => socket.destroy()); await new Promise((resolve, reject) => { server.once("error", () => reject(new Error(`Daemon home is already controlled or ownership port ${port} is occupied`))); diff --git a/src/runtime/host-adapter-base.ts b/src/runtime/host-adapter-base.ts index 04e7519..169c49e 100644 --- a/src/runtime/host-adapter-base.ts +++ b/src/runtime/host-adapter-base.ts @@ -1,3 +1,4 @@ +import { workspaceProjectRoot } from "./workspace-hooks"; import { existsSync } from "fs"; import { nowIso, renderInjectedPrompt, shellQuote } from "./adapter-utils"; import { workspaceHotScope } from "./workspace-hot-memory"; @@ -35,6 +36,8 @@ export type HostAdapterBaseConfig = { runtimeHome?: string; sourceExcerpts?: boolean; workspaceOnly?: boolean; + legacyUnscoped?: boolean; + legacyBefore?: string; }; export type HostAdapterTranscriptEntry = { @@ -222,11 +225,36 @@ const emptyHotMemorySnapshot = (): Record => ({ scope: "all", s export const filterHotMemorySnapshotForWorkspace = ( hotMemory: Record | null, workspaceId: string, - workspaceOnly = false + workspaceOnly = false, + legacyUnscoped = false, + legacyBefore?: string ): Record | null => { if (!hotMemory) { return null; } + if (workspaceOnly && workspaceId.startsWith("repo:")) { + const target = workspaceId.slice(5); + const equivalent = (id: string | null, observedAt?: unknown): boolean => { + if (id === workspaceId) return true; + if (!id) return legacyUnscoped && Boolean(legacyBefore) && typeof observedAt === "string" && + Date.parse(observedAt) <= Date.parse(legacyBefore!); + if (!id.startsWith("repo:") || !path.isAbsolute(id.slice(5))) return false; + try { return workspaceProjectRoot(id.slice(5)) === target; } catch { return false; } + }; + const normalize = (entry: Record): Record | null => { + const content = parseHotMemoryEntryContent(entry); + if (!content || !equivalent(getWorkspaceId(content), entry.updated_at ?? content.updatedAt ?? content.occurredAt)) return null; + const checkpoints = Array.isArray(content.continuityCheckpoints) + ? content.continuityCheckpoints.filter(isRecord).filter(checkpoint => equivalent(getWorkspaceId(checkpoint), checkpoint.occurredAt ?? entry.updated_at ?? content.updatedAt)) + .map(checkpoint => ({ ...checkpoint, workspaceId })) : undefined; + return { ...entry, content: JSON.stringify({ ...content, workspaceId, + ...(checkpoints ? { continuityCheckpoints: checkpoints } : {}) }) }; + }; + hotMemory = Array.isArray(hotMemory.scopes) + ? { ...hotMemory, scopes: hotMemory.scopes.filter(isRecord).map(normalize).filter(isRecord) } + : normalize(hotMemory); + if (!hotMemory) return null; + } const scopedEntries = Array.isArray(hotMemory.scopes) ? hotMemory.scopes.filter(isRecord) : null; if (!scopedEntries) { if (!hasHotMemoryEntryContent(hotMemory)) { @@ -397,7 +425,7 @@ export abstract class HostRuntimeAdapterBase { hostVersion: input.hostVersion, }); const rawPreTurnHotMemory = await this.client.readHotMemory(profileName, "all").catch(() => null); - const preTurnHotMemory = filterHotMemorySnapshotForWorkspace(rawPreTurnHotMemory, workspaceId, this.config.workspaceOnly); + const preTurnHotMemory = filterHotMemorySnapshotForWorkspace(rawPreTurnHotMemory, workspaceId, this.config.workspaceOnly, this.config.legacyUnscoped, this.config.legacyBefore); const response = await this.client.emitEvent({ runtime: this.identity.runtimeId, profileName, diff --git a/src/runtime/local-hot-memory.ts b/src/runtime/local-hot-memory.ts index a3b0c85..365f87d 100644 --- a/src/runtime/local-hot-memory.ts +++ b/src/runtime/local-hot-memory.ts @@ -27,6 +27,43 @@ const localHotMemoryDir = (stateDir: string, owner?: HotMemoryOwner): string => ? path.join(stateDir, LOCAL_HOT_MEMORY_DIR, "profiles", createHash("sha256").update(JSON.stringify([owner.name, owner.npub])).digest("hex")) : path.join(stateDir, LOCAL_HOT_MEMORY_DIR); +const legacyOwnerFile = (stateDir: string): string => path.join(stateDir, "legacy-local-hot-owner.json"); + +/** Before namespacing, a flat store was shared by the runtime's local-only profiles. + * Adopt it only for its sole existing local profile, and pin that identity before + * another profile can be created. Old bytes remain available for recovery. */ +const legacyOwner = (stateDir: string): HotMemoryOwner | null => { + const pinned = legacyOwnerFile(stateDir); + if (existsSync(pinned)) { + try { + const owner = JSON.parse(readFileSync(pinned, "utf8")) as HotMemoryOwner; + return typeof owner.name === "string" && typeof owner.npub === "string" ? owner : null; + } catch { return null; } + } + const profiles = path.join(path.dirname(stateDir), "config", "profiles"); + if (!existsSync(profiles)) return null; + const local = readdirSync(profiles).filter(name => name.endsWith(".json")).flatMap(name => { + try { + const profile = JSON.parse(readFileSync(path.join(profiles, name), "utf8")); + return typeof profile.name === "string" && typeof profile.npub === "string" && isLocalOnlyRuntimeProfile(profile) ? [profile as HotMemoryOwner] : []; + } catch { return []; } + }); + return local.length === 1 ? { name: local[0]!.name, npub: local[0]!.npub } : null; +}; + +export const preserveLegacyLocalHotOwner = (stateDir: string): void => { + if (existsSync(legacyOwnerFile(stateDir))) return; + const directory = localHotMemoryDir(stateDir); + if (!existsSync(directory) || !readdirSync(directory).some(name => name.endsWith(".json"))) return; + const owner = legacyOwner(stateDir); + writeManagedFile(legacyOwnerFile(stateDir), JSON.stringify(owner ?? { ambiguous: true }), stateDir); +}; + +const canReadLegacy = (stateDir: string, owner: HotMemoryOwner): boolean => { + const legacy = legacyOwner(stateDir); + return legacy?.name === owner.name && legacy.npub === owner.npub; +}; + const normalizeHotScope = (scope?: string | null): string => { const trimmed = typeof scope === "string" ? scope.trim() : ""; const normalized = trimmed.length > 0 ? trimmed : SHARED_HOT_SCOPE; @@ -78,14 +115,17 @@ export const readLocalHotMemory = (stateDir: string, scope?: string | null, owne .map((fileName) => readScopeFile(directory, fileName)) .filter((entry): entry is Record => entry !== null) : []; - return { - scope: HOT_SCOPE_ALL, - scopes, - }; + if (owner && canReadLegacy(stateDir, owner)) { + const legacy = readLocalHotMemory(stateDir, HOT_SCOPE_ALL).scopes as Array>; + const ownedScopes = new Set(scopes.map(entry => entry.scope)); + scopes.push(...legacy.map(entry => ownedScopes.has(entry.scope) ? { ...entry, scope: `legacy:${entry.scope}` } : entry)); + } + return { scope: HOT_SCOPE_ALL, scopes }; } const filePath = localHotMemoryFile(directory, normalizedScope); if (!existsSync(filePath)) { + if (owner && canReadLegacy(stateDir, owner)) return readLocalHotMemory(stateDir, normalizedScope); return { scope: normalizedScope, content: "", @@ -106,6 +146,7 @@ export const readLocalHotMemory = (stateDir: string, scope?: string | null, owne }; export const writeLocalHotMemory = (stateDir: string, content: string, scope?: string | null, owner?: HotMemoryOwner): Record => { + if (owner) preserveLegacyLocalHotOwner(stateDir); const normalizedScope = normalizeHotScope(scope); if (normalizedScope === HOT_SCOPE_ALL) throw new Error("Hot-memory scope all is read-only"); const filePath = localHotMemoryFile(localHotMemoryDir(stateDir, owner), normalizedScope); diff --git a/src/runtime/local-runtime.ts b/src/runtime/local-runtime.ts index bee10a4..7c6942b 100644 --- a/src/runtime/local-runtime.ts +++ b/src/runtime/local-runtime.ts @@ -29,7 +29,7 @@ import { OperatorRuntimePolicyBroker, type OperatorWorkspaceManifest } from "./o import { HOSTED_PI_CONTROLLER_ADAPTER, HOSTED_PI_CONTROLLER_MANIFEST, hostedControllerProcessIsLive, type HostedControllerProcess } from "./hosted-pi-controller-manifest"; import { acquireRuntimeDaemonOwnership, daemonOwnerIsLive, probeRuntimeDaemonHealth, readDaemonOwnershipState, type RuntimeDaemonOwnership } from "./daemon-ownership"; import { HostedOnlyBotsClient, type HostedSession } from "./hosted-client"; -import { isLocalOnlyRuntimeProfile, readLocalHotMemory } from "./local-hot-memory"; +import { isLocalOnlyRuntimeProfile, preserveLegacyLocalHotOwner, readLocalHotMemory } from "./local-hot-memory"; import { searchHotCheckpoints } from "./hot-continuity"; import { RuntimeLifecycleManager, @@ -173,7 +173,7 @@ import { writeRuntimeLog, } from "./runtime-persistence"; import { nowIso } from "./adapter-utils"; -import { applyWorkspaceHookPlan, planWorkspaceHooks, readWorkspaceHookPolicy, withWorkspaceSetupLock } from "./workspace-hooks"; +import { applyWorkspaceHookPlan, planWorkspaceHooks, readWorkspaceHookPolicy, refreshWorkspaceHooks, withWorkspaceSetupLock } from "./workspace-hooks"; import { cloneOrganizationalWorkerReadiness, normalizeOrganizationalWorkerReadiness, @@ -1350,6 +1350,7 @@ export const setupRuntimeProfile = async (options: SetupRuntimeProfileOptions): createdAt: loadProfile(paths, options.profileName)?.createdAt ?? nowIso(), updatedAt: nowIso(), }; + preserveLegacyLocalHotOwner(paths.state); if (options.createOnly && loadProfile(paths, options.profileName)) throw new Error(`Profile ${options.profileName} was created during setup`); saveProfile(paths, profile); saveSecret(paths, options.profileName, { @@ -1649,7 +1650,7 @@ export const inspectPublishedRuntimeRelease = async (home?: string): Promise<{ i return { installed: inspectInstalledRuntimeRelease(paths).releaseId, available: parseInstallerReleaseMetadata(script).releaseId }; }; -const reapplyManagedRuntimeRelease = async ( +export const reapplyManagedRuntimeRelease = async ( paths: RuntimePaths, release: DownloadedRuntimeRelease, specs: ManagedAdapterReinstallSpec[] @@ -1660,6 +1661,9 @@ const reapplyManagedRuntimeRelease = async ( if (specs.some(spec => spec.name === "hermes" || spec.name === "opencode") && !existsSync(path.join(release.releaseRoot, "dist", "runtime", "native-host-adapter.js"))) { throw new Error("Target release does not support installed Hermes/OpenCode plugins; uninstall those adapters before downgrading"); } + const workspacePolicy = readWorkspaceHookPolicy(paths); + const workspaceModule = path.join(release.releaseRoot, "dist", "runtime", "workspace-hooks.js"); + if (workspacePolicy && !existsSync(workspaceModule)) throw new Error("This release does not support the configured workspace hooks."); const config = loadRuntimeConfig(paths); const nextRuntime = require(release.localRuntimeModulePath) as { installRuntimeCore: typeof installRuntimeCore; @@ -1732,6 +1736,11 @@ const reapplyManagedRuntimeRelease = async ( }); } + if (workspacePolicy) { + const scoped = require(workspaceModule) as { refreshWorkspaceHooks: typeof refreshWorkspaceHooks }; + await scoped.refreshWorkspaceHooks(paths.home); + } + return { coreVersion: nextRuntime.ONLYBOTS_RUNTIME_CORE_VERSION, adapters, diff --git a/src/runtime/runtime-env.ts b/src/runtime/runtime-env.ts index a3140c9..f61cd2c 100644 --- a/src/runtime/runtime-env.ts +++ b/src/runtime/runtime-env.ts @@ -36,3 +36,6 @@ export const resolveClaudeCodeBinaryPath = (binary?: string): string => /** @deprecated Use resolveCrossheadHome. */ export const resolveOnlyBotsHome = resolveCrossheadHome; + +export const isRuntimeProfileName = (value: unknown): value is string => + typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/.test(value); diff --git a/src/runtime/runtime-persistence.ts b/src/runtime/runtime-persistence.ts index f12f6fc..408b0cb 100644 --- a/src/runtime/runtime-persistence.ts +++ b/src/runtime/runtime-persistence.ts @@ -22,7 +22,7 @@ import { randomUUID } from "crypto"; import path from "path"; import { nowIso } from "./adapter-utils"; -import { resolveOnlyBotsHome } from "./runtime-env"; +import { isRuntimeProfileName, resolveOnlyBotsHome } from "./runtime-env"; import type { AgentPassport } from "../protocol/passports"; import { buildDefaultContinuityPolicy, @@ -246,7 +246,7 @@ export const healthFile = (paths: RuntimePaths): string => path.join(paths.state export const lifecycleEventsFile = (paths: RuntimePaths): string => path.join(paths.state, "lifecycle-events.jsonl"); const portablePathToken = (value: string, fieldName: string): string => { - if (!/^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/.test(value)) { + if (!isRuntimeProfileName(value)) { throw new Error(`${fieldName} must be a portable path token`); } return value; diff --git a/src/runtime/workspace-hooks.ts b/src/runtime/workspace-hooks.ts index 8a83365..6245432 100644 --- a/src/runtime/workspace-hooks.ts +++ b/src/runtime/workspace-hooks.ts @@ -1,11 +1,14 @@ +import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; import path from "node:path"; import { assertManagedFilePath, shellQuote, writeManagedFile } from "./adapter-utils"; import { HOST_HOOK_EVENTS, PLUGIN_DISPATCH_CAPABILITY, renderHostHookGroups, type HookHost } from "./host-hook-spec"; import { homedir } from "node:os"; +import { acquireLocalHostedBridge } from "./local-hosted-bridge"; +import { acquireLoopbackDaemonLock } from "./daemon-kernel-lock"; import { loadProfile, resolveRuntimePaths, type RuntimePaths } from "./runtime-persistence"; -import { resolveClaudeCodeHostHome, resolveCodexHostHome } from "./runtime-env"; +import { isRuntimeProfileName, resolveClaudeCodeHostHome, resolveCodexHostHome } from "./runtime-env"; export type HookScope = "project" | "all" | "manual"; export type WorkspaceHookBinding = { @@ -15,6 +18,10 @@ export type WorkspaceHookBinding = { hostHome: string; profile: string; sourceExcerpts: boolean; + registration?: "project" | "user"; + legacyUnscoped?: boolean; + legacyBefore?: string; + legacyNpub?: string; }; type HookPolicy = { version: 1; scope: HookScope; bindings: WorkspaceHookBinding[] }; @@ -23,14 +30,17 @@ type Edit = { file: string; root: string; before: string | null; after: string; const hosts: HookHost[] = ["codex", "claude-code"]; const policyFile = (paths: RuntimePaths): string => path.join(paths.config, "workspace-hooks.json"); const object = (value: unknown): value is Settings => value !== null && typeof value === "object" && !Array.isArray(value); -const profileName = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; const bindingId = /^[a-f0-9-]{36}$/; const isBinding = (value: unknown): value is WorkspaceHookBinding => object(value) && typeof value.id === "string" && bindingId.test(value.id) && (value.host === "codex" || value.host === "claude-code") && (value.root === null || (typeof value.root === "string" && path.isAbsolute(value.root))) && typeof value.hostHome === "string" && path.isAbsolute(value.hostHome) && - typeof value.profile === "string" && profileName.test(value.profile) && typeof value.sourceExcerpts === "boolean"; + typeof value.profile === "string" && isRuntimeProfileName(value.profile) && typeof value.sourceExcerpts === "boolean" && + (value.registration === undefined || value.registration === "project" || value.registration === "user") && + (value.legacyUnscoped === undefined || typeof value.legacyUnscoped === "boolean") && + (value.legacyBefore === undefined || (typeof value.legacyBefore === "string" && Number.isFinite(Date.parse(value.legacyBefore)))) && + (value.legacyNpub === undefined || typeof value.legacyNpub === "string"); const readText = (file: string): string | null => { try { return readFileSync(file, "utf8"); } @@ -106,10 +116,26 @@ export const workspaceHookForInvocation = ( }; const hookFile = (binding: WorkspaceHookBinding): string => - binding.root + binding.root && binding.registration !== "user" ? path.join(binding.root, binding.host === "codex" ? ".codex/hooks.json" : ".claude/settings.local.json") : path.join(binding.hostHome, binding.host === "codex" ? "hooks.json" : "settings.json"); +const codexHookRegistration = (root: string): "project" | "user" => { + try { + execFileSync("git", ["-C", root, "ls-files", "--error-unmatch", "--", ".codex/hooks.json"], { stdio: "ignore", timeout: 2000 }); + return "user"; + } catch { return "project"; } +}; + +const projectIgnoreFile = (root: string): { file: string; root: string } => { + try { + const exclude = execFileSync("git", ["-C", root, "rev-parse", "--git-path", "info/exclude"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000 }).trim(); + const file = path.resolve(root, exclude); + return { file, root: path.dirname(file) }; + } catch { return { file: path.join(root, ".gitignore"), root }; } +}; +const localHookIgnore = "# Crosshead local hook settings\n/.codex/hooks.json\n"; + const hostHome = (host: HookHost, options: { codexHome?: string; claudeHome?: string }): string => host === "codex" ? resolveCodexHostHome(options.codexHome) : resolveClaudeCodeHostHome(options.claudeHome); @@ -137,7 +163,7 @@ const invokesLegacyHook = (command: string, wrapper: string, runtimeHomes: strin const bindingCommands = (paths: RuntimePaths, binding: WorkspaceHookBinding) => renderHostHookGroups(binding.host, (event) => - `${hookPrefix(paths, binding.host)}${event} --home ${shellQuote(paths.home)} --binding ${binding.id}`); + `${hookPrefix(paths, binding.host)}${event} --home ${shellQuote(paths.home)} --binding ${binding.id} --source-excerpts ${binding.sourceExcerpts ? "on" : "off"}`); const settings = (file: string, text: string | null): Settings => { if (text === null) return {}; @@ -239,6 +265,7 @@ type PlanOptions = { remove?: { directory: string; hosts: HookHost[] }; removeHost?: HookHost; cleanup?: boolean; + refresh?: boolean; }; export const planWorkspaceHooks = (options: PlanOptions) => { @@ -256,7 +283,7 @@ export const planWorkspaceHooks = (options: PlanOptions) => { } const planned = new Set(); for (const target of options.targets) { - if (!profileName.test(target.profile)) throw new Error(`Invalid profile name: ${target.profile}`); + if (!isRuntimeProfileName(target.profile)) throw new Error(`Invalid profile name: ${target.profile}`); const root = options.scope === "all" ? null : workspaceProjectRoot(target.directory); if (root && (root === path.parse(root).root || root === realHome(homedir()))) { throw new Error("Choose a project directory inside your home, or select all projects explicitly."); @@ -267,25 +294,31 @@ export const planWorkspaceHooks = (options: PlanOptions) => { planned.add(key); const existing = policy.bindings.find((binding) => binding.root === root && binding.host === host); const resolvedHome = hostHome(host, options); + const registration = host === "codex" && root ? codexHookRegistration(root) : "project"; + const selectedProfile = loadProfile(paths, target.profile); + const inheritLegacy = existing?.profile === target.profile && existing.legacyNpub === selectedProfile?.npub; + const legacyUnscoped = (inheritLegacy ? existing?.legacyUnscoped : undefined) ?? (!previous && root !== null && Boolean(selectedProfile) && + new Set(options.targets.filter((entry) => entry.profile === target.profile).map((entry) => workspaceProjectRoot(entry.directory))).size === 1); const sourceExcerpts = target.sourceExcerpts ?? existing?.sourceExcerpts ?? false; if (existing?.profile === target.profile && existing.sourceExcerpts === sourceExcerpts && - existing.hostHome === resolvedHome) continue; + existing.hostHome === resolvedHome && (existing.registration ?? "project") === registration) continue; policy.bindings = policy.bindings.filter((binding) => binding !== existing); - policy.bindings.push({ id: randomUUID(), root, host, hostHome: resolvedHome, profile: target.profile, sourceExcerpts }); + policy.bindings.push({ id: randomUUID(), root, host, hostHome: resolvedHome, profile: target.profile, sourceExcerpts, registration, legacyUnscoped, + ...(legacyUnscoped ? { legacyBefore: inheritLegacy ? existing?.legacyBefore ?? new Date().toISOString() : new Date().toISOString(), legacyNpub: selectedProfile!.npub } : {}) }); } } const edits = new Map(); const settingsFiles = new Map(); for (const binding of [...(previous?.bindings ?? []), ...policy.bindings]) { - settingsFiles.set(hookFile(binding), { root: binding.root ?? binding.hostHome, host: binding.host }); + settingsFiles.set(hookFile(binding), { root: binding.root && binding.registration !== "user" ? binding.root : binding.hostHome, host: binding.host }); if (binding.root && binding.host === "claude-code") { settingsFiles.set(path.join(binding.root, ".claude/settings.json"), { root: binding.root, host: binding.host }); } } // Retire settings-mode hooks that refer to this runtime. Other hooks and // settings stay in place. A plugin invocation is gated by the same policy. - for (const host of options.cleanup ? [] : hosts) { + for (const host of options.cleanup || options.refresh ? [] : hosts) { const root = hostHome(host, options); settingsFiles.set(path.join(root, host === "codex" ? "hooks.json" : "settings.json"), { root, host }); } @@ -301,7 +334,10 @@ export const planWorkspaceHooks = (options: PlanOptions) => { const original = JSON.stringify(data); const owned = new Set([...(previous?.bindings ?? []), ...next] .filter((binding) => hookFile(binding) === file) - .flatMap((binding) => bindingCommands(paths, binding).map(([, group]) => group.hooks[0]!.command))); + .flatMap((binding) => bindingCommands(paths, binding).flatMap(([, group]) => { + const command = group.hooks[0]!.command; + return [command, command.replace(/ --source-excerpts (on|off)$/, "")]; + }))); removeCommands(data, (command) => { if (owned.has(command)) return true; const legacy = ["crosshead", "onlybots"].some((name) => runtimeHomes.some((home) => @@ -320,6 +356,22 @@ export const planWorkspaceHooks = (options: PlanOptions) => { if (JSON.stringify(data) !== original) edits.set(file, { file, root, before, after: `${JSON.stringify(data, null, 2)}\n` }); } + const projectBindings = [...(previous?.bindings ?? []), ...policy.bindings] + .filter((binding) => binding.host === "codex" && binding.root && binding.registration !== "user"); + const ignoreFiles = new Map(); + for (const root of new Set(projectBindings.map((binding) => binding.root!))) { + const ignore = projectIgnoreFile(root); + const active = policy.bindings.some((binding) => binding.host === "codex" && binding.root === root && binding.registration !== "user"); + ignoreFiles.set(ignore.file, { root: ignore.root, active: active || Boolean(ignoreFiles.get(ignore.file)?.active) }); + } + for (const [file, { root, active }] of ignoreFiles) { + const before = readText(file); + let after = before ?? ""; + if (active && !after.split("\n").includes("/.codex/hooks.json")) after += (after && !after.endsWith("\n") ? "\n" : "") + localHookIgnore; + if (!active) after = after.replace(localHookIgnore, ""); + if (after !== (before ?? "")) edits.set(file, { file, root, before, after }); + } + // Plugin launchers use these stable paths even for a manual/project setup. // Keep them dispatching through the current CLI across release updates. for (const host of options.cleanup ? [] : hosts) { @@ -360,20 +412,12 @@ export const assertWorkspacePlanCurrent = (plan: WorkspaceHookPlan): void => { }; export const withWorkspaceSetupLock = async (paths: RuntimePaths, operation: () => Promise): Promise => { - const file = path.join(paths.config, "workspace-setup.lock"); - assertManagedFilePath(file, paths.home); mkdirSync(paths.config, { recursive: true, mode: 0o700 }); - let lock: number; - try { lock = openSync(file, "wx", 0o600); } - catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; - throw new Error(`A setup is already in progress. If it was interrupted and has exited, remove ${file} and retry.`); - } + const lock = process.platform === "darwin" + ? await acquireLoopbackDaemonLock(realpathSync(paths.home), "workspace-setup") + : await acquireLocalHostedBridge(paths.config, createHash("sha256").update("crosshead-workspace-setup").digest("hex").slice(0, 24)); try { return await operation(); } - finally { - closeSync(lock); - rmSync(file, { force: true }); - } + finally { await lock.close(); } }; export const applyWorkspaceHookPlan = (plan: WorkspaceHookPlan): void => { @@ -399,3 +443,36 @@ export const applyWorkspaceHookPlan = (plan: WorkspaceHookPlan): void => { throw error; } }; + +export type WorkspaceHookInspection = ReturnType; +export const workspaceBindingStatus = ( + binding: WorkspaceHookInspection["bindings"][number], workspace: string | null +): string => { + if (!binding.configured) return "hook settings missing or changed"; + if (!binding.memoryMode) return "profile missing"; + if (binding.memoryMode === "off") return "memory off"; + if (binding.startup || binding.resume) return "observed in host"; + return !workspace && !binding.root ? "configured; check from a project" : "configured; waiting for host"; +}; + +/** Malformed local configuration disables hook work without failing host tools. */ +export const readWorkspacePolicyForHook = ( + paths: RuntimePaths, event: string, binding: string | boolean | undefined +): HookPolicy | null | false => { + try { return readWorkspaceHookPolicy(paths); } + catch { + if (binding && ["sessionstart", "session_start", "userpromptsubmit", "user_prompt_submit"].includes(event)) { + console.error("Crosshead workspace settings are invalid. Automatic memory is paused; run crosshead workspace check."); + } + return false; + } +}; + +export const refreshWorkspaceHooks = async (home: string): Promise => { + const paths = resolveRuntimePaths(home); + await withWorkspaceSetupLock(paths, async () => { + const policy = readWorkspaceHookPolicy(paths); + if (!policy) return; + applyWorkspaceHookPlan(planWorkspaceHooks({ home, scope: policy.scope, targets: [], refresh: true })); + }); +}; diff --git a/tests/runtime/onboarding-upgrade.test.ts b/tests/runtime/onboarding-upgrade.test.ts new file mode 100644 index 0000000..c847702 --- /dev/null +++ b/tests/runtime/onboarding-upgrade.test.ts @@ -0,0 +1,215 @@ +import { spawn, spawnSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { installCodexAdapter, loadCodexAdapterConfig } from "../../src/runtime/codex-adapter"; +import { installClaudeCodeAdapter } from "../../src/runtime/claude-code-adapter"; +import { generateRuntimeIdentity, installRuntimeCore, reapplyManagedRuntimeRelease, setupRuntimeProfile, stopDetachedRuntimeDaemon, updateRuntimeAdapterVersion } from "../../src/runtime/local-runtime"; +import { readDaemonOwnershipState } from "../../src/runtime/daemon-ownership"; +import { resolveRuntimePaths } from "../../src/runtime/runtime-persistence"; +import { readLocalHotMemory, writeLocalHotMemory } from "../../src/runtime/local-hot-memory"; +import { inspectWorkspaceHooks, workspaceBindingStatus } from "../../src/runtime/workspace-hooks"; +import { filterHotMemorySnapshotForWorkspace } from "../../src/runtime/host-adapter-base"; +import { HostedOnlyBotsClient } from "../../src/runtime/hosted-client"; +import { isRuntimeProfileName } from "../../src/runtime/runtime-env"; +import { runOnlyBotsCodexHookCli } from "../../src/runtime/codex-hook"; +import { runOnlyBotsClaudeCodeHookCli } from "../../src/runtime/claude-code-hook"; + +const repo = path.resolve(__dirname, "../.."); +const cliFile = path.join(repo, "dist/runtime/cli.js"); +const daemonFile = path.join(repo, "dist/runtime/daemon.js"); +const dirs: string[] = []; +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); +const fixture = () => { + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "xhd-upgrade-"))); dirs.push(root); + const home = path.join(root, "runtime"), project = path.join(root, "project"); + const codexHome = path.join(root, "codex"), claudeHome = path.join(root, "claude"); + for (const dir of [project, codexHome, claudeHome]) mkdirSync(dir, { recursive: true }); + const env = { PATH: process.env.PATH!, TMPDIR: tmpdir(), CODEX_HOME: codexHome, CLAUDE_CONFIG_DIR: claudeHome, CROSSHEAD_PROFILE: "leftover", CROSSHEAD_HOOK_AUTOSTART: "0" }; + const run = (...args: string[]) => spawnSync(process.execPath, [cliFile, ...args, "--home", home], { cwd: project, env, encoding: "utf8", timeout: 12000 }); + const cli = (...args: string[]) => { const result = run(...args); expect(result.status, result.stderr || result.stdout).toBe(0); return result.stdout; }; + const hooks = (host: "codex" | "claude-code") => inspectWorkspaceHooks(home, project).bindings.find(binding => binding.host === host)!; + const invoke = (host: "codex" | "claude-code", event: string, payload: object) => { + const file = hooks(host).file; + const command = JSON.parse(readFileSync(file, "utf8")).hooks[event].at(-1).hooks[0].command; + const result = spawnSync("sh", ["-c", command], { cwd: project, env, input: JSON.stringify({ cwd: project, ...payload }), encoding: "utf8", timeout: 10000 }); + expect(result.status, result.stderr).toBe(0); expect(result.stderr).toBe(""); return result.stdout; + }; + return { root, home, project, codexHome, claudeHome, cli, run, hooks, invoke, env }; +}; +const profile = async (home: string, name: string) => setupRuntimeProfile({ home, profileName: name, apiBase: "http://127.0.0.1:0", ...generateRuntimeIdentity(), makeDefault: false }); + +describe("legacy onboarding upgrades", () => { + it("retains flat checkpoints through scoped startup/search/update and fully uninstalls adapters", async () => { + const f = fixture(); + const paths = resolveRuntimePaths(f.home); + const identity = await profile(f.home, "work"); + await installRuntimeCore({ home: f.home, cliScriptPath: cliFile, daemonScriptPath: daemonFile }); + await installCodexAdapter({ home: f.home, hostHome: f.codexHome, profileName: "work", codexBinary: "/bin/true", bridgeScriptPath: path.join(repo, "dist/runtime/codex-bridge.js"), hookScriptPath: path.join(repo, "dist/runtime/codex-hook.js") }); + await installClaudeCodeAdapter({ home: f.home, hostHome: f.claudeHome, workspaceRoot: f.project, profileName: "work", claudeBinary: "/bin/true", bridgeScriptPath: path.join(repo, "dist/runtime/claude-code-bridge.js"), hookScriptPath: path.join(repo, "dist/runtime/claude-code-hook.js") }); + const requirement = "Existing invoice requirements use integer cents and round half up."; + const legacy = JSON.stringify({ runtime: "codex", continuityCheckpoints: [{ runtime: "codex", workspaceId: null, sessionId: "old", turnId: "old", goal: "invoice requirements", summary: requirement, occurredAt: new Date().toISOString() }] }); + writeLocalHotMemory(paths.state, legacy, "codex"); + expect(loadCodexAdapterConfig(f.home, f.codexHome).profileName).toBe("work"); + expect(f.cli("memory", "hot", "--profile=work", "--json")).toContain(requirement); + const dry = JSON.parse(f.cli("quickstart", "--adapter", "pi", "--profile", "work", "--dry-run", "--json")); + expect(dry).toMatchObject({ applied: false, scope: "legacy", adapter: "pi" }); + f.cli("quickstart", "--adapter", "codex", "--profile", "work", "--codex-home", f.codexHome, "--codex-bin", "/bin/true", "--no-start", "--json"); + expect(existsSync(path.join(paths.config, "workspace-hooks.json"))).toBe(false); + try { + f.cli("quickstart", "--scope", "project", "--adapter", "both", "--profile", "work", "--workspace-root", f.project, "--codex-home", f.codexHome, "--claude-home", f.claudeHome, "--port", "0", "--json"); + f.invoke("codex", "SessionStart", { source: "startup", session_id: "new" }); + expect(f.invoke("codex", "UserPromptSubmit", { session_id: "new", prompt: "Continue invoice requirements" })).toContain(requirement); + expect(f.cli("memory", "search", "invoice", "--profile", "work", "--json")).toContain(requirement); + await profile(f.home, "other"); + expect(JSON.stringify(readLocalHotMemory(paths.state, "all", identity))).toContain(requirement); + expect(f.cli("memory", "hot", "--profile", "other", "--json")).not.toContain(requirement); + const before = f.hooks("codex"); + const settings = JSON.parse(readFileSync(before.file, "utf8")); + for (const groups of Object.values(settings.hooks) as Array }>>) { + for (const group of groups) for (const handler of group.hooks) handler.command = handler.command.replace(/ --source-excerpts (on|off)$/, ""); + } + writeFileSync(before.file, JSON.stringify(settings)); + const nextRelease = path.join(paths.libRuntime, "fixture-next"); + mkdirSync(nextRelease, { recursive: true }); + cpSync(path.join(repo, "dist"), path.join(nextRelease, "dist"), { recursive: true }); + symlinkSync(path.join(repo, "node_modules"), path.join(nextRelease, "node_modules"), "dir"); + const nextScript = (name: string) => path.join(nextRelease, "dist/runtime", name); + await reapplyManagedRuntimeRelease(paths, { + baseUrl: "http://127.0.0.1:0", releaseId: "fixture", releaseUrl: "", releaseSha256: "", + releaseRoot: nextRelease, cliScriptPath: nextScript("cli.js"), daemonScriptPath: nextScript("daemon.js"), + localRuntimeModulePath: nextScript("local-runtime.js"), codexAdapterModulePath: nextScript("codex-adapter.js"), claudeCodeAdapterModulePath: nextScript("claude-code-adapter.js"), piAdapterModulePath: nextScript("pi-adapter.js"), + }, [{ name: "codex", profileName: "work", hostHome: f.codexHome, codexBinary: "/bin/true" }, { name: "claude-code", profileName: "work", hostHome: f.claudeHome, workspaceRoot: f.project, claudeBinary: "/bin/true" }]); + expect(readFileSync(path.join(paths.bin, "crosshead-codex"), "utf8")).toContain(nextScript("codex-bridge.js")); + expect(readFileSync(path.join(paths.bin, "crosshead-claude-code"), "utf8")).toContain(nextScript("claude-code-bridge.js")); + await updateRuntimeAdapterVersion(f.home, "codex", "0.3.0"); + expect(f.hooks("codex")).toMatchObject({ id: before.id, profile: "work", configured: true }); + expect(JSON.parse(readFileSync(before.file, "utf8")).hooks.SessionStart).toHaveLength(1); + f.invoke("claude-code", "SessionStart", { source: "resume", session_id: "resumed" }); + expect(f.invoke("claude-code", "UserPromptSubmit", { session_id: "resumed", prompt: "Continue invoice requirements" })).toContain(requirement); + expect(readFileSync(path.join(paths.state, "local-hot-memory/codex.json"), "utf8")).toBe(legacy); + f.env.CODEX_HOME = ""; + f.env.CLAUDE_CONFIG_DIR = ""; + f.cli("uninstall", "codex"); + for (const file of ["onlybots/codex-adapter.json", "skills/crosshead-bootstrap/SKILL.md"]) expect(existsSync(path.join(f.codexHome, file))).toBe(false); + expect(existsSync(path.join(paths.bin, "crosshead-codex-hook"))).toBe(false); + expect(f.hooks("claude-code").configured).toBe(true); + f.cli("uninstall", "claude-code"); + expect(existsSync(path.join(f.claudeHome, "onlybots/claude-code-adapter.json"))).toBe(false); + expect(existsSync(path.join(paths.bin, "crosshead-claude-code-hook"))).toBe(false); + } finally { if (readDaemonOwnershipState(paths)) await stopDetachedRuntimeDaemon(f.home); } + }, 30000); + + it("maps symlinks and subdirectories only to their own repository", () => { + const f = fixture(); + mkdirSync(path.join(f.project, ".git")); writeFileSync(path.join(f.project, ".git/HEAD"), "ref: refs/heads/main\n"); + mkdirSync(path.join(f.project, "src")); + const alias = path.join(f.root, "alias"); symlinkSync(f.project, alias); + const checkpoint = { runtime: "codex", workspaceId: `repo:${alias}/src`, summary: "Old path rule", goal: "requirements", occurredAt: new Date().toISOString() }; + const snapshot = { scopes: [{ scope: "codex", content: JSON.stringify({ workspaceId: checkpoint.workspaceId, continuityCheckpoints: [checkpoint] }) }] }; + const mapped = filterHotMemorySnapshotForWorkspace(snapshot, `repo:${f.project}`, true); + expect(JSON.stringify(mapped)).toContain("Old path rule"); + expect(JSON.stringify(mapped)).not.toContain(alias); + const unrelated = path.join(f.root, "unrelated"); mkdirSync(unrelated); + expect(filterHotMemorySnapshotForWorkspace(snapshot, `repo:${unrelated}`, true)).toBeNull(); + expect(snapshot.scopes[0]!.content).toContain(alias); + }); + + it("keeps generated Codex settings local and preserves already tracked hooks", async () => { + const f = fixture(); await profile(f.home, "work"); + expect(spawnSync("git", ["init", "-q", f.project]).status).toBe(0); + f.cli("quickstart", "--scope", "project", "--adapter", "codex", "--profile", "work", "--codex-home", f.codexHome, "--no-start"); + const local = path.join(f.project, ".codex/hooks.json"); + expect(spawnSync("git", ["-C", f.project, "check-ignore", local]).status).toBe(0); + f.cli("workspace", "remove", "--workspace-root", f.project); + writeFileSync(local, JSON.stringify({ hooks: {}, description: "team-owned" })); + expect(spawnSync("git", ["-C", f.project, "add", ".codex/hooks.json"]).status).toBe(0); + f.cli("workspace", "add", "--workspace-root", f.project, "--adapter", "codex", "--profile", "work", "--codex-home", f.codexHome); + expect(f.hooks("codex").file).toBe(path.join(f.codexHome, "hooks.json")); + expect(JSON.parse(readFileSync(local, "utf8"))).toEqual({ hooks: {}, description: "team-owned" }); + }); + + it("retains a shared Git exclude while another worktree remains configured", async () => { + const f = fixture(); await profile(f.home, "work"); + expect(spawnSync("git", ["init", "-q", f.project]).status).toBe(0); + expect(spawnSync("git", ["-C", f.project, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "--allow-empty", "-qm", "base"]).status).toBe(0); + const sibling = path.join(f.root, "sibling"); + expect(spawnSync("git", ["-C", f.project, "worktree", "add", "-qb", "sibling", sibling]).status).toBe(0); + f.cli("quickstart", "--scope", "project", "--adapter", "codex", "--profile", "work", "--codex-home", f.codexHome, "--no-start"); + f.cli("workspace", "add", "--workspace-root", sibling, "--adapter", "codex", "--profile", "work", "--codex-home", f.codexHome); + f.cli("workspace", "remove", "--workspace-root", f.project); + expect(spawnSync("git", ["-C", sibling, "check-ignore", ".codex/hooks.json"]).status).toBe(0); + }); + + it("keeps ambiguous legacy stores quarantined after a profile changes endpoint", async () => { + const f = fixture(); + const first = await profile(f.home, "first"); + const key = generateRuntimeIdentity(); + await setupRuntimeProfile({ home: f.home, profileName: "second", apiBase: "http://127.0.0.1:0", ...key }); + const paths = resolveRuntimePaths(f.home); + writeLocalHotMemory(paths.state, "Unknown ownership", "codex"); + expect(JSON.stringify(readLocalHotMemory(paths.state, "all", first))).not.toContain("Unknown ownership"); + vi.spyOn(HostedOnlyBotsClient.prototype, "ensureSession").mockResolvedValue(null as never); + await setupRuntimeProfile({ home: f.home, profileName: "second", apiBase: "https://example.invalid", ...key }); + expect(JSON.stringify(readLocalHotMemory(paths.state, "all", first))).not.toContain("Unknown ownership"); + expect(readFileSync(path.join(paths.state, "local-hot-memory/codex.json"), "utf8")).toBe("Unknown ownership"); + }); + + it("does not transfer legacy adoption to another profile or future unscoped captures", async () => { + const f = fixture(); await profile(f.home, "first"); await profile(f.home, "second"); + f.cli("quickstart", "--scope", "project", "--adapter", "codex", "--profile", "first", "--codex-home", f.codexHome, "--no-start"); + expect(f.hooks("codex").legacyUnscoped).toBe(true); + const unscoped = { scopes: [{ scope: "codex", updated_at: "2099-01-01T00:00:00Z", content: JSON.stringify({ summary: "Future unrelated note" }) }] }; + expect(filterHotMemorySnapshotForWorkspace(unscoped, `repo:${f.project}`, true, true, "2026-01-01T00:00:00Z")).toBeNull(); + f.cli("workspace", "add", "--workspace-root", f.project, "--adapter", "codex", "--profile", "second", "--codex-home", f.codexHome); + expect(f.hooks("codex").legacyUnscoped).toBe(false); + }); + + it("uses the persistence grammar for existing profile names", async () => { + const f = fixture(); await profile(f.home, "work+personal"); + f.cli("quickstart", "--scope", "project", "--adapter", "codex", "--profile=work+personal", "--codex-home", f.codexHome, "--no-start"); + expect(f.hooks("codex").profile).toBe("work+personal"); + expect(isRuntimeProfileName("a".repeat(128))).toBe(true); + expect(isRuntimeProfileName("a".repeat(129))).toBe(false); + }); + + it.each([runOnlyBotsCodexHookCli, runOnlyBotsClaudeCodeHookCli])("treats corrupt policy as paused hook work", async run => { + const f = fixture(); mkdirSync(path.join(f.home, "config"), { recursive: true }); + writeFileSync(path.join(f.home, "config/workspace-hooks.json"), "{partial"); + const warning = vi.spyOn(console, "error").mockImplementation(() => {}); + await expect(run(["PostToolUse", "--home", "/missing", "--binding", "ignored", "--source-excerpts", "off"], "invalid stdin")).resolves.toBeUndefined(); + await expect(run(["PostToolUse", "--home", f.home, "--binding", "ignored"], "invalid stdin")).resolves.toBeUndefined(); + expect(warning).not.toHaveBeenCalled(); + await expect(run(["SessionStart", "--home", f.home, "--binding", "ignored"], "{}")).resolves.toBeUndefined(); + expect(warning).toHaveBeenCalledOnce(); + }); + + it("uses one status ladder for unobserved all-project bindings", async () => { + const f = fixture(); await profile(f.home, "work"); + f.cli("quickstart", "--scope", "all", "--adapter", "codex", "--profile", "work", "--codex-home", f.codexHome, "--no-start"); + const inspection = inspectWorkspaceHooks(f.home); + expect(workspaceBindingStatus(inspection.bindings[0]!, null)).toBe("configured; check from a project"); + }); +}); + +it("releases the setup lock after its owning process is killed", async () => { + const f = fixture(); + const moduleFile = JSON.stringify(path.join(repo, "dist/runtime/workspace-hooks.js")); + const pathsFile = JSON.stringify(path.join(repo, "dist/runtime/runtime-persistence.js")); + const code = ` + const { withWorkspaceSetupLock } = require(${moduleFile}); + const { resolveRuntimePaths } = require(${pathsFile}); + withWorkspaceSetupLock(resolveRuntimePaths(process.argv[1]), async () => { + console.log("locked"); + await new Promise(() => {}); + }); + `; + const child = spawn(process.execPath, ["-e", code, f.home], { stdio: ["ignore", "pipe", "pipe"] }); + try { + await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("lock startup timed out")), 4000); child.stdout.on("data", data => { if (String(data).includes("locked")) { clearTimeout(timer); resolve(); } }); child.once("exit", code => { clearTimeout(timer); reject(new Error(`lock owner exited ${code}`)); }); }); + const exited = new Promise(resolve => child.once("exit", resolve)); child.kill("SIGKILL"); await exited; + const retry = spawnSync(process.execPath, ["-e", `const {withWorkspaceSetupLock}=require(${JSON.stringify(path.join(repo,"dist/runtime/workspace-hooks.js"))});const {resolveRuntimePaths}=require(${JSON.stringify(path.join(repo,"dist/runtime/runtime-persistence.js"))});withWorkspaceSetupLock(resolveRuntimePaths(process.argv[1]),async()=>console.log('recovered'));`, f.home], { encoding: "utf8", timeout: 5000 }); + expect(retry.status, retry.stderr).toBe(0); expect(retry.stdout).toContain("recovered"); + } finally { if (child.exitCode === null && child.signalCode === null) { const exit = new Promise(resolve => child.once("exit", resolve)); child.kill("SIGKILL"); await exit; } } +}, 12000); diff --git a/tests/runtime/profile-selection.test.ts b/tests/runtime/profile-selection.test.ts index 7e43858..f4e6ff4 100644 --- a/tests/runtime/profile-selection.test.ts +++ b/tests/runtime/profile-selection.test.ts @@ -29,7 +29,7 @@ describe("project profile selection", () => { it.each([ ["codex", "__onlybots_codex_adapter__", loadCodexAdapterConfig], ["claude-code", "__onlybots_claude_code_adapter__", loadClaudeCodeAdapterConfig], - ] as const)("selects the environment profile for installed %s hooks without rewriting saved config", (host, marker, load) => { + ] as const)("preserves the installed %s hook profile despite leftover environment variables", (host, marker, load) => { const root = mkdtempSync(path.join(os.tmpdir(), "crosshead-profile-")); roots.push(root); mkdirSync(path.join(root, "onlybots")); @@ -38,22 +38,28 @@ describe("project profile selection", () => { writeFileSync(file, saved); vi.stubEnv("CROSSHEAD_PROFILE", "crosshead"); vi.stubEnv("ONLYBOTS_PROFILE", "legacy"); - expect(load(undefined, root)).toMatchObject({ profileName: "crosshead", runtimeHome: "/saved-runtime" }); - expect(load("/explicit-runtime", root)).toMatchObject({ profileName: "crosshead", runtimeHome: "/explicit-runtime" }); + expect(load(undefined, root)).toMatchObject({ profileName: "default", runtimeHome: "/saved-runtime" }); + expect(load("/explicit-runtime", root)).toMatchObject({ profileName: "default", runtimeHome: "/explicit-runtime" }); vi.stubEnv("CROSSHEAD_PROFILE", ""); - expect(load(undefined, root).profileName).toBe("legacy"); + expect(load(undefined, root).profileName).toBe("default"); vi.stubEnv("ONLYBOTS_PROFILE", ""); expect(load(undefined, root).profileName).toBe("default"); expect(readFileSync(file, "utf8")).toBe(saved); }); - it("passes the environment profile through the CLI while preserving an explicit --profile", async () => { + it("leaves extension argv untouched and recognizes explicit profile forms", async () => { vi.stubEnv("CROSSHEAD_PROFILE", "crosshead"); vi.spyOn(process.stdout, "write").mockImplementation(() => true); const probe = vi.fn(async () => "ok"); await runOnlyBotsCtlCli(["probe"], { commands: { probe } }); - expect(probe).toHaveBeenLastCalledWith(["--profile", "crosshead"]); + expect(probe).toHaveBeenLastCalledWith([]); await runOnlyBotsCtlCli(["probe", "--profile", "explicit"], { commands: { probe } }); expect(probe).toHaveBeenLastCalledWith(["--profile", "explicit"]); + await runOnlyBotsCtlCli(["probe", "--profile=explicit"], { commands: { probe } }); + expect(probe).toHaveBeenLastCalledWith(["--profile=explicit"]); + const output = vi.mocked(process.stdout.write); + output.mockClear(); + await runOnlyBotsCtlCli([]); + expect(String(output.mock.calls.at(-1)?.[0])).toContain("Crosshead CLI"); }); }); From e2882f017a8523bbbba31ad8bc1cc64bbcd17f23 Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Sat, 14 Nov 2026 03:11:46 -0500 Subject: [PATCH 3/3] Make scoped setup recovery and continuity migration safe --- README.md | 27 ++- src/runtime/claude-code-adapter.ts | 6 +- src/runtime/cli.ts | 9 +- src/runtime/cli/core.ts | 4 +- src/runtime/cli/quickstart.ts | 3 + src/runtime/cli/workspace.ts | 37 +++- src/runtime/codex-adapter.ts | 4 +- src/runtime/daemon-kernel-lock.ts | 15 ++ src/runtime/host-adapter-base.ts | 106 +---------- src/runtime/local-hot-memory.ts | 56 +++++- src/runtime/local-runtime.ts | 2 + src/runtime/workspace-hooks.ts | 132 +++++++++----- src/runtime/workspace-identity.ts | 27 +++ src/runtime/workspace-memory.ts | 109 +++++++++++ tests/runtime/hot-sync-acceptance.test.ts | 16 +- tests/runtime/onboarding-upgrade.test.ts | 213 +++++++++++++++++++++- tests/runtime/workspace-setup.test.ts | 6 +- 17 files changed, 596 insertions(+), 176 deletions(-) create mode 100644 src/runtime/workspace-identity.ts create mode 100644 src/runtime/workspace-memory.ts diff --git a/README.md b/README.md index 9070d4f..e5cedbc 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,14 @@ crosshead workspace list crosshead workspace remove --workspace-root ../other-project ``` +A bare `crosshead quickstart` in a terminal opens the setup wizard. Existing +argument-based quickstart commands retain their legacy behavior; use an explicit +`--scope` to select project, all-project, or manual setup from scripts. + +The first `workspace add` on a legacy installation previews the change from +user-wide coverage and asks before applying it. For noninteractive use, review +`--dry-run` and pass `--replace-user-hooks` to acknowledge that coverage change. + Project setup writes local hook settings for each selected host. Codex settings are excluded locally through Git's info/exclude file. When a repository already tracks its Codex hooks, Crosshead uses a directory-restricted user registration @@ -158,7 +166,24 @@ before creating another profile. If several profiles already shared an unowned store, review its `state/local-hot-memory/*.json` files to establish ownership. Scoped setup retains matching subdirectory and symlink history. Its preview shows adoption of earlier unscoped checkpoints when one project is selected -for an existing profile. Later unscoped captures require an explicit workspace. +for an existing profile. Eligible local history is copied into current workspace +partitions before a new session writes them. Once a scope is replaced, its flat +archive stays available for recovery and no longer participates in recall. +Later unscoped captures require an explicit workspace. + +If workspace settings are damaged, `crosshead workspace list`, `workspace check`, +and `doctor` report the problem while automatic memory stays paused. Run: + +```sh +crosshead workspace repair --dry-run +crosshead workspace repair +crosshead quickstart --scope project --adapter both --profile work +``` + +Repair preserves the exact damaged policy in a private adjacent backup and sets +manual scope. It keeps memory files and host settings intact. Review the new +setup before enabling automatic hooks again. `doctor` also reports missing +project directories without dropping the remaining runtime diagnostics. `crosshead uninstall` removes the runtime. Uninstall the plugin (`claude plugin uninstall crosshead@crosshead`, `codex plugin remove diff --git a/src/runtime/claude-code-adapter.ts b/src/runtime/claude-code-adapter.ts index 2893fc8..9fda079 100644 --- a/src/runtime/claude-code-adapter.ts +++ b/src/runtime/claude-code-adapter.ts @@ -59,7 +59,7 @@ import { type HostAdapterTurnPrepareRequest, type HostAdapterTurnPrepareResult, } from "./host-adapter-base"; -import { resolveClaudeCodeBinaryPath, resolveClaudeCodeHostHome, resolveCrossheadProfile } from "./runtime-env"; +import { resolveClaudeCodeBinaryPath, resolveClaudeCodeHostHome } from "./runtime-env"; import { PLUGIN_DISPATCH_CAPABILITY, renderHostHookGroups, type HostHookEvent } from "./host-hook-spec"; import { claudeLoadedSettingsFiles, @@ -123,6 +123,8 @@ export type ClaudeCodeAdapterPaths = { }; export type ClaudeCodeAdapterInstallOptions = { + /** Release updates refresh both hosts together after reinstalling their wrappers. */ + deferWorkspaceHookRefresh?: boolean; home?: string; hostHome?: string; workspaceRoot?: string; @@ -732,7 +734,7 @@ export const installClaudeCodeAdapter = async ( updatedAt: nowIso(), }); - if (scopedPolicy) await refreshWorkspaceHooks(runtimeLayout.home); + if (scopedPolicy && !options.deferWorkspaceHookRefresh) await refreshWorkspaceHooks(runtimeLayout.home); return { adapter: existingRecord, diff --git a/src/runtime/cli.ts b/src/runtime/cli.ts index 700d74c..b7de8e9 100644 --- a/src/runtime/cli.ts +++ b/src/runtime/cli.ts @@ -19,7 +19,7 @@ import { handoffFromCli } from "./cli/handoff"; import { memoryFromCli, workspaceFromCli } from "./cli/memory"; import { messagesFromCli } from "./cli/messages"; import { publicFromCli } from "./cli/public"; -import { quickstartFromCli } from "./cli/quickstart"; +import { shouldGuideQuickstart, quickstartFromCli } from "./cli/quickstart"; import { buildRuntimeBillingRemediation, formatRuntimeBillingRemediation } from "./billing-remediation"; import { HostedOnlyBotsRequestError } from "./hosted-client"; import { assertSecureNodeRuntimeVersion } from "./node-runtime-version"; @@ -216,9 +216,7 @@ export const runOnlyBotsCtlCli = async ( case "quickstart": case "onboard": { const { flags } = parseArgs(rest); - const adapter = flags.adapter ?? flags.host; - const guided = !adapter && !flags.scope && !flags["hook-mode"] && !flags.json && !flags["dry-run"] && - Boolean(process.stdin.isTTY && process.stderr.isTTY); + const guided = shouldGuideQuickstart(argv.slice(1), Boolean(process.stdin.isTTY && process.stderr.isTTY)); const scoped = guided || flags.scope !== undefined; const scripts = { cliScriptPath, @@ -317,8 +315,9 @@ export const runOnlyBotsCtlCli = async ( "crosshead update [adapter] [--home ] [--channel stable|beta|canary] [--version ]", "crosshead rollback [adapter] --to [--home ]", "crosshead doctor [--home ] [--workspace-root ] [--json]", - "crosshead workspace add --workspace-root --adapter codex|claude-code|both --profile [--source-excerpts on|off] [--dry-run] [--json]", + "crosshead workspace add --workspace-root --adapter codex|claude-code|both --profile [--source-excerpts on|off] [--replace-user-hooks] [--dry-run] [--json]", "crosshead workspace remove --workspace-root [--adapter codex|claude-code|both] [--dry-run] [--json]", + "crosshead workspace repair [--dry-run] [--json] [--home ]", "crosshead workspace list|check [--workspace-root ] [--json]", "crosshead logs [--home ] [--lines 50]", "crosshead events [--home ] [--lines 50] [--json]", diff --git a/src/runtime/cli/core.ts b/src/runtime/cli/core.ts index bd5c4a1..6168c7c 100644 --- a/src/runtime/cli/core.ts +++ b/src/runtime/cli/core.ts @@ -208,7 +208,9 @@ export const doctorFromCli = async (argv: string[]): Promise => { const workspaceRoot = typeof flags["workspace-root"] === "string" ? flags["workspace-root"] : process.env.CLAUDE_PROJECT_DIR || process.cwd(); const workspaceHooks = inspectWorkspaceHooks(home, workspaceRoot); - const hookChecks = workspaceHooks.scope === "legacy" + const hookChecks = workspaceHooks.error || workspaceHooks.workspaceError + ? [{ id: "workspace_hooks", ok: false, message: [workspaceHooks.error, workspaceHooks.workspaceError].filter(Boolean).join("; ") }] + : workspaceHooks.scope === "legacy" ? inspectHostHookRegistrations(home, workspaceRoot) : workspaceHooks.bindings.length ? workspaceHooks.bindings.map((binding) => ({ diff --git a/src/runtime/cli/quickstart.ts b/src/runtime/cli/quickstart.ts index 56791f0..56c0d5c 100644 --- a/src/runtime/cli/quickstart.ts +++ b/src/runtime/cli/quickstart.ts @@ -308,3 +308,6 @@ export const quickstartFromCli = async (argv: string[], paths: QuickstartCliPath }; return flags.json ? JSON.stringify(result, null, 2) : formatQuickstart(result); }; + +/** Only an argument-free invocation opts into the terminal onboarding wizard. */ +export const shouldGuideQuickstart = (args: string[], terminal: boolean): boolean => terminal && args.length === 0; diff --git a/src/runtime/cli/workspace.ts b/src/runtime/cli/workspace.ts index 8cddc6d..960f083 100644 --- a/src/runtime/cli/workspace.ts +++ b/src/runtime/cli/workspace.ts @@ -5,7 +5,7 @@ import { parseArgs, shellQuote } from "../adapter-utils"; import type { HookHost } from "../host-hook-spec"; import { loadAdapterRecord, loadProfile, loadRuntimeConfig, resolveRuntimePaths } from "../runtime-persistence"; import { - applyWorkspaceHookPlan, assertWorkspacePlanCurrent, inspectWorkspaceHooks, planWorkspaceHooks, readWorkspaceHookPolicy, summarizeWorkspaceHookPlan, + applyWorkspaceHookPlan, assertWorkspacePlanCurrent, inspectWorkspaceHooks, planWorkspaceHooks, readWorkspaceHookPolicy, repairWorkspaceHookPolicy, summarizeWorkspaceHookPlan, withWorkspaceSetupLock, workspaceBindingStatus, workspaceProjectRoot, type HookScope, type WorkspaceHookTarget, } from "../workspace-hooks"; import { isRuntimeProfileName } from "../runtime-env"; @@ -37,6 +37,10 @@ const planOptions = (flags: Flags) => ({ home: str(flags, "home"), codexHome: str(flags, "codex-home"), claudeHome: str(flags, "claude-home"), }); const homeArgs = (home?: string): string[] => home ? ["--home", home] : []; +const profileSnapshot = (paths: ReturnType, name: string): string | null => { + const profile = loadProfile(paths, name); + return profile ? JSON.stringify([profile.npub, profile.apiBase, profile.memoryMode, profile.workspaceScope]) : null; +}; const describePlan = (plan: ReturnType): string => [ `Crosshead scope: ${plan.scope === "project" ? "selected projects" : plan.scope === "all" ? "all projects" : "manual commands only"}`, @@ -48,6 +52,7 @@ const describePlan = (plan: ReturnType): stri ].join("\n"); export const formatWorkspaceHookInspection = (inspection: ReturnType): string => { + if (inspection.error || inspection.workspaceError) return [inspection.error, inspection.workspaceError].filter(Boolean).join("\n"); if (inspection.scope === "legacy") return "Crosshead uses the existing host setup. Run crosshead quickstart to choose a scope."; if (inspection.bindings.length === 0) return "No automatic Crosshead hooks apply here."; return inspection.bindings.map((binding) => { @@ -70,18 +75,24 @@ export const workspaceHooksFromCli = async (argv: string[]): Promise => const command = positionals[0]; const options = planOptions(flags); const directory = str(flags, "workspace-root") ?? process.cwd(); + if (command === "repair") { + const result = await repairWorkspaceHookPolicy(options.home, enabled(flags, "dry-run")); + return flags.json ? JSON.stringify(result, null, 2) : [result.backup ? `${result.applied ? "Preserved damaged settings at" : "Would preserve damaged settings at"} ${result.backup}` : "", result.message].filter(Boolean).join("\n"); + } if (command === "list" || command === "check") { const inspection = inspectWorkspaceHooks(options.home, command === "check" ? directory : undefined); return flags.json ? JSON.stringify(inspection, null, 2) : formatWorkspaceHookInspection(inspection); } - if (command !== "add" && command !== "remove") throw new Error("Choose workspace add, remove, list, check, or link"); + if (command !== "add" && command !== "remove") throw new Error("Choose workspace add, remove, list, check, repair, or link"); const hosts = selectedHosts(str(flags, "adapter") ?? (command === "remove" ? "both" : "")); const paths = resolveRuntimePaths(options.home); const profile = str(flags, "profile") ?? loadRuntimeConfig(paths).defaultProfile; if (command === "add" && (!profile || !isRuntimeProfileName(profile) || !loadProfile(paths, profile))) { throw new Error("Choose an existing --profile, or run crosshead quickstart to create one."); } + const selectedProfileBefore = profile ? profileSnapshot(paths, profile) : null; const current = inspectWorkspaceHooks(options.home); + if (current.error) throw new Error(current.error); if (command === "add" && current.scope === "all") { throw new Error("All-project hooks are active. Use crosshead quickstart --scope project to preview changing scope."); } @@ -95,8 +106,20 @@ export const workspaceHooksFromCli = async (argv: string[]): Promise => }); const preview = summarizeWorkspaceHookPlan(plan); if (enabled(flags, "dry-run")) return flags.json ? JSON.stringify({ applied: false, ...preview }, null, 2) : describePlan(preview); + if (command === "add" && current.scope === "legacy" && !enabled(flags, "replace-user-hooks")) { + const message = describePlan(preview) + "\nThis changes legacy user-wide coverage to selected projects."; + if (!process.stdin.isTTY || !process.stderr.isTTY) throw new Error(message + " Review --dry-run first, then pass --replace-user-hooks or use crosshead quickstart --scope project."); + const terminal = createInterface({ input: process.stdin, output: process.stderr }); + try { + process.stderr.write(message + "\n"); + if (!/^y(es)?$/i.test((await terminal.question("Replace user-wide coverage? [no] ")).trim())) return "Setup cancelled."; + } finally { terminal.close(); } + } if (!existsSync(path.join(paths.bin, "crosshead"))) throw new Error("Install the Crosshead CLI first with crosshead install."); - await withWorkspaceSetupLock(paths, async () => applyWorkspaceHookPlan(plan)); + await withWorkspaceSetupLock(paths, async () => { + if (command === "add" && profileSnapshot(paths, profile!) !== selectedProfileBefore) throw new Error("Profile changed since preview. Run workspace add again."); + applyWorkspaceHookPlan(plan); + }); const inspection = inspectWorkspaceHooks(options.home, existsSync(directory) ? directory : undefined); return flags.json ? JSON.stringify({ applied: true, ...inspection }, null, 2) : formatWorkspaceHookInspection(inspection); }; @@ -178,11 +201,7 @@ export const scopedQuickstartFromCli = async (flags: Flags, scripts: QuickstartC }]; const names = [...new Set(targets.map((target) => target.profile))]; if (names.some((name) => !isRuntimeProfileName(name))) throw new Error("Profile names must use 1–128 letters, numbers, dots, underscores, plus signs or hyphens."); - const profileSnapshot = (name: string) => { - const profile = loadProfile(paths, name); - return profile ? JSON.stringify([profile.npub, profile.apiBase, profile.memoryMode, profile.workspaceScope]) : null; - }; - const profilesBefore = new Map(names.map((name) => [name, profileSnapshot(name)])); + const profilesBefore = new Map(names.map((name) => [name, profileSnapshot(paths, name)])); const newProfiles = names.filter((name) => profilesBefore.get(name) === null); const apiBase = str(flags, "api-base") ?? str(flags, "apiBase") ?? (guided ? "https://crosshead.sh" : undefined); if (newProfiles.length > 0 && !apiBase) throw new Error("Supply --api-base to create a new profile."); @@ -211,7 +230,7 @@ export const scopedQuickstartFromCli = async (flags: Flags, scripts: QuickstartC if (flow && !await flow.confirm(runtimePlan + profilePlan + describePlan(preview))) return "Setup cancelled."; const daemon = await withWorkspaceSetupLock(paths, async () => { assertWorkspacePlanCurrent(plan); - if (names.some((name) => profileSnapshot(name) !== profilesBefore.get(name))) { + if (names.some((name) => profileSnapshot(paths, name) !== profilesBefore.get(name))) { throw new Error("A selected profile changed since preview. Run setup again to review it."); } const args = homeArgs(options.home); diff --git a/src/runtime/codex-adapter.ts b/src/runtime/codex-adapter.ts index 36001e5..0c1be67 100644 --- a/src/runtime/codex-adapter.ts +++ b/src/runtime/codex-adapter.ts @@ -127,6 +127,8 @@ export type CodexAdapterPaths = { }; export type CodexAdapterInstallOptions = { + /** Release updates refresh both hosts together after reinstalling their wrappers. */ + deferWorkspaceHookRefresh?: boolean; home?: string; hostHome?: string; codexBinary?: string; @@ -766,7 +768,7 @@ export const installCodexAdapter = async (options: CodexAdapterInstallOptions): updatedAt: nowIso(), }); - if (scopedPolicy) await refreshWorkspaceHooks(runtimeLayout.home); + if (scopedPolicy && !options.deferWorkspaceHookRefresh) await refreshWorkspaceHooks(runtimeLayout.home); return { adapter: existingRecord, diff --git a/src/runtime/daemon-kernel-lock.ts b/src/runtime/daemon-kernel-lock.ts index b6693cd..372168d 100644 --- a/src/runtime/daemon-kernel-lock.ts +++ b/src/runtime/daemon-kernel-lock.ts @@ -29,3 +29,18 @@ export const acquireDaemonKernelLock = async (home: string, state: string): Prom const suffix = createHash("sha256").update("onlybots-local-runtime-daemon").digest("hex").slice(0, 24); return acquireLocalHostedBridge(state, suffix); }; + +/** Linux abstract sockets need no external lock program or stale socket cleanup. */ +export const acquireWorkspaceSetupKernelLock = async (home: string): Promise => { + if (process.platform !== "linux") return acquireLoopbackDaemonLock(home, "workspace-setup"); + const server = createServer(socket => socket.destroy()); + const address = "\0crosshead-workspace-setup:" + createHash("sha256").update(home).digest("hex"); + await new Promise((resolve, reject) => { + server.once("error", () => reject(new Error("A workspace setup is already in progress for this runtime."))); + server.listen({ path: address, exclusive: true }, resolve); + }); + return { alive: () => server.listening, close: async () => { + if (!server.listening) return; + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + } }; +}; diff --git a/src/runtime/host-adapter-base.ts b/src/runtime/host-adapter-base.ts index 169c49e..3253710 100644 --- a/src/runtime/host-adapter-base.ts +++ b/src/runtime/host-adapter-base.ts @@ -1,7 +1,7 @@ -import { workspaceProjectRoot } from "./workspace-hooks"; +import { filterHotMemorySnapshotForWorkspace } from "./workspace-memory"; +export { filterHotMemorySnapshotForWorkspace } from "./workspace-memory"; import { existsSync } from "fs"; import { nowIso, renderInjectedPrompt, shellQuote } from "./adapter-utils"; -import { workspaceHotScope } from "./workspace-hot-memory"; import path from "path"; import { HOT_CONTEXT_LIMIT, HOT_FULL_RENDER_LIMIT, renderHotCheckpoints, selectHotCheckpoints } from "./hot-continuity"; import { CHECKPOINT_PAGE_CHARS, inlineCheckpointPages, paginateCheckpoint, saveCheckpointPages } from "./checkpoint-pages"; @@ -189,110 +189,8 @@ export type HostAdapterRememberRequest = { tags?: string[]; }; -const isRecord = (value: unknown): value is Record => - Boolean(value) && typeof value === "object" && !Array.isArray(value); - -const parseJsonRecord = (value: string): Record | null => { - try { - const parsed = JSON.parse(value) as unknown; - return isRecord(parsed) ? parsed : null; - } catch { - return null; - } -}; - -const parseHotMemoryEntryContent = (entry: Record): Record | null => { - if (isRecord(entry.content)) { - return entry.content; - } - return typeof entry.content === "string" ? parseJsonRecord(entry.content) : null; -}; - -const hasHotMemoryEntryContent = (entry: Record): boolean => { - if (isRecord(entry.content)) { - return true; - } - return typeof entry.content === "string" && entry.content.trim().length > 0; -}; - -const getWorkspaceId = (value: Record | null): string | null => { - const workspaceId = value?.workspaceId ?? value?.workspace_id; - return typeof workspaceId === "string" && workspaceId.trim().length > 0 ? workspaceId.trim() : null; -}; - const emptyHotMemorySnapshot = (): Record => ({ scope: "all", scopes: [] }); -export const filterHotMemorySnapshotForWorkspace = ( - hotMemory: Record | null, - workspaceId: string, - workspaceOnly = false, - legacyUnscoped = false, - legacyBefore?: string -): Record | null => { - if (!hotMemory) { - return null; - } - if (workspaceOnly && workspaceId.startsWith("repo:")) { - const target = workspaceId.slice(5); - const equivalent = (id: string | null, observedAt?: unknown): boolean => { - if (id === workspaceId) return true; - if (!id) return legacyUnscoped && Boolean(legacyBefore) && typeof observedAt === "string" && - Date.parse(observedAt) <= Date.parse(legacyBefore!); - if (!id.startsWith("repo:") || !path.isAbsolute(id.slice(5))) return false; - try { return workspaceProjectRoot(id.slice(5)) === target; } catch { return false; } - }; - const normalize = (entry: Record): Record | null => { - const content = parseHotMemoryEntryContent(entry); - if (!content || !equivalent(getWorkspaceId(content), entry.updated_at ?? content.updatedAt ?? content.occurredAt)) return null; - const checkpoints = Array.isArray(content.continuityCheckpoints) - ? content.continuityCheckpoints.filter(isRecord).filter(checkpoint => equivalent(getWorkspaceId(checkpoint), checkpoint.occurredAt ?? entry.updated_at ?? content.updatedAt)) - .map(checkpoint => ({ ...checkpoint, workspaceId })) : undefined; - return { ...entry, content: JSON.stringify({ ...content, workspaceId, - ...(checkpoints ? { continuityCheckpoints: checkpoints } : {}) }) }; - }; - hotMemory = Array.isArray(hotMemory.scopes) - ? { ...hotMemory, scopes: hotMemory.scopes.filter(isRecord).map(normalize).filter(isRecord) } - : normalize(hotMemory); - if (!hotMemory) return null; - } - const scopedEntries = Array.isArray(hotMemory.scopes) ? hotMemory.scopes.filter(isRecord) : null; - if (!scopedEntries) { - if (!hasHotMemoryEntryContent(hotMemory)) { - return null; - } - const entryWorkspaceId = getWorkspaceId(parseHotMemoryEntryContent(hotMemory)); - return entryWorkspaceId === workspaceId || (!workspaceOnly && !entryWorkspaceId) ? hotMemory : null; - } - const scopes = scopedEntries.filter((entry) => { - if (!hasHotMemoryEntryContent(entry)) { - return false; - } - const entryWorkspaceId = getWorkspaceId(parseHotMemoryEntryContent(entry)); - return entryWorkspaceId === workspaceId || (!workspaceOnly && !entryWorkspaceId); - }); - if (scopes.length === 0) { - return null; - } - // The legacy runtime alias and its retained workspace partition describe one - // document. Inject the newest version once, under the readable runtime name. - const selected = new Map>(); - const other: Record[] = []; - for (const entry of scopes) { - const content = parseHotMemoryEntryContent(entry); - const runtime = typeof content?.runtime === "string" ? content.runtime : null; - if (!runtime || getWorkspaceId(content) !== workspaceId || - ![runtime, workspaceHotScope(runtime, workspaceId)].includes(String(entry.scope))) { - other.push(entry); - continue; - } - const previous = selected.get(runtime); - const updated = Date.parse(String(content?.updatedAt)) || 0; - const previousUpdated = Date.parse(String(parseHotMemoryEntryContent(previous ?? {})?.updatedAt)) || 0; - if (!previous || updated >= previousUpdated) selected.set(runtime, { ...entry, scope: runtime }); - } - return { ...hotMemory, scopes: [...other, ...selected.values()] }; -}; - export abstract class HostRuntimeAdapterBase { protected abstract readonly identity: HostAdapterIdentity; protected abstract readonly client: LocalRuntimeClient; diff --git a/src/runtime/local-hot-memory.ts b/src/runtime/local-hot-memory.ts index 365f87d..4d29b42 100644 --- a/src/runtime/local-hot-memory.ts +++ b/src/runtime/local-hot-memory.ts @@ -1,3 +1,5 @@ +import { filterHotMemorySnapshotForWorkspace } from "./workspace-memory"; +import { workspaceHotScope } from "./workspace-hot-memory"; import { createHash } from "crypto"; import { existsSync, readFileSync, readdirSync, statSync } from "fs"; import path from "path"; @@ -118,7 +120,7 @@ export const readLocalHotMemory = (stateDir: string, scope?: string | null, owne if (owner && canReadLegacy(stateDir, owner)) { const legacy = readLocalHotMemory(stateDir, HOT_SCOPE_ALL).scopes as Array>; const ownedScopes = new Set(scopes.map(entry => entry.scope)); - scopes.push(...legacy.map(entry => ownedScopes.has(entry.scope) ? { ...entry, scope: `legacy:${entry.scope}` } : entry)); + scopes.push(...legacy.filter(entry => !ownedScopes.has(entry.scope))); } return { scope: HOT_SCOPE_ALL, scopes }; } @@ -153,3 +155,55 @@ export const writeLocalHotMemory = (stateDir: string, content: string, scope?: s writeManagedFile(filePath, content, stateDir); return readLocalHotMemory(stateDir, normalizedScope, owner); }; + +/** Fingerprint the complete source set, including newly arrived partitions. */ +export const localHotMemoryAdoptionFingerprint = (stateDir: string, owner: HotMemoryOwner): string => { + const directories = [localHotMemoryDir(stateDir), localHotMemoryDir(stateDir, owner)]; + const source = directories.map(directory => existsSync(directory) + ? readdirSync(directory).filter(name => name.endsWith(".json")).sort().map(name => { + const file = path.join(directory, name); + return [name, statSync(file).mtime.toISOString(), createHash("sha256").update(readFileSync(file)).digest("hex")]; + }) : []); + const marker = legacyOwnerFile(stateDir); + return createHash("sha256").update(JSON.stringify([source, existsSync(marker) ? readFileSync(marker, "utf8") : null])).digest("hex"); +}; + +/** Copy eligible history into current workspace partitions before the first new session. + * Original flat archives remain untouched and cease being a fallback after replacement. */ +export const planLocalHotMemoryAdoption = ( + stateDir: string, owner: HotMemoryOwner, workspaceId: string, unscoped: boolean, before?: string +) => { + const fingerprint = localHotMemoryAdoptionFingerprint(stateDir, owner); + const raw = readLocalHotMemory(stateDir, "all", owner); + const entries = (raw.scopes ?? []) as Array>; + const tagged = entries.map(entry => { + const owned = localHotMemoryFile(localHotMemoryDir(stateDir, owner), String(entry.scope)); + const source = existsSync(owned) ? owned : localHotMemoryFile(localHotMemoryDir(stateDir), String(entry.scope)); + return { ...entry, adoptionSource: source, adoptionBefore: String(entry.content ?? "") }; + }); + const snapshot = filterHotMemorySnapshotForWorkspace({ ...raw, scopes: tagged }, workspaceId, true, unscoped, before); + const sources = new Map(); + const planned = new Map(); + for (const entry of (snapshot?.scopes ?? []) as Array>) { + const content = String(entry.content ?? ""); + let state: Record; + try { state = JSON.parse(content); } catch { continue; } + const runtime = typeof state.runtime === "string" ? state.runtime : null; + const scopes = new Set([String(entry.scope), ...(runtime ? [workspaceHotScope(runtime, workspaceId)!] : [])]); + const time = Date.parse(String(state.updatedAt ?? entry.updated_at)) || 0; + for (const scope of scopes) { + const file = localHotMemoryFile(localHotMemoryDir(stateDir, owner), normalizeHotScope(scope)); + if (existsSync(file)) continue; + if (!planned.has(file) || time > planned.get(file)!.time) { + planned.set(file, { file, content, time }); + const source = String(entry.adoptionSource); + sources.set(source, { file: source, before: String(entry.adoptionBefore) }); + } + } + } + if (planned.size && !existsSync(legacyOwnerFile(stateDir)) && canReadLegacy(stateDir, owner)) { + const file = legacyOwnerFile(stateDir); + planned.set(file, { file, content: JSON.stringify({ name: owner.name, npub: owner.npub }), time: 0 }); + } + return { files: [...planned.values()].map(({ file, content }) => ({ file, content })), sources: [...sources.values()], snapshot: { stateDir, owner: { name: owner.name, npub: owner.npub }, fingerprint } }; +}; diff --git a/src/runtime/local-runtime.ts b/src/runtime/local-runtime.ts index 7c6942b..0b0fd6e 100644 --- a/src/runtime/local-runtime.ts +++ b/src/runtime/local-runtime.ts @@ -1692,6 +1692,7 @@ export const reapplyManagedRuntimeRelease = async ( }; const result = await nextCodex.installCodexAdapter({ home: paths.home, + deferWorkspaceHookRefresh: true, hostHome: spec.hostHome ?? undefined, profileName: spec.profileName, codexBinary: spec.codexBinary ?? undefined, @@ -1710,6 +1711,7 @@ export const reapplyManagedRuntimeRelease = async ( }; const result = await nextClaude.installClaudeCodeAdapter({ home: paths.home, + deferWorkspaceHookRefresh: true, hostHome: spec.hostHome ?? undefined, workspaceRoot: spec.workspaceRoot ?? undefined, profileName: spec.profileName, diff --git a/src/runtime/workspace-hooks.ts b/src/runtime/workspace-hooks.ts index 6245432..bbae233 100644 --- a/src/runtime/workspace-hooks.ts +++ b/src/runtime/workspace-hooks.ts @@ -1,13 +1,15 @@ import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs"; import path from "node:path"; import { assertManagedFilePath, shellQuote, writeManagedFile } from "./adapter-utils"; import { HOST_HOOK_EVENTS, PLUGIN_DISPATCH_CAPABILITY, renderHostHookGroups, type HookHost } from "./host-hook-spec"; import { homedir } from "node:os"; -import { acquireLocalHostedBridge } from "./local-hosted-bridge"; -import { acquireLoopbackDaemonLock } from "./daemon-kernel-lock"; +import { acquireWorkspaceSetupKernelLock } from "./daemon-kernel-lock"; +import { workspaceDirectoryPath as directoryPath, workspaceRepositoryRoot as repositoryRoot, workspaceProjectRoot } from "./workspace-identity"; +export { workspaceProjectRoot } from "./workspace-identity"; import { loadProfile, resolveRuntimePaths, type RuntimePaths } from "./runtime-persistence"; +import { isLocalOnlyRuntimeProfile, localHotMemoryAdoptionFingerprint, planLocalHotMemoryAdoption } from "./local-hot-memory"; import { isRuntimeProfileName, resolveClaudeCodeHostHome, resolveCodexHostHome } from "./runtime-env"; export type HookScope = "project" | "all" | "manual"; @@ -56,41 +58,16 @@ export const readWorkspaceHookPolicy = (paths: RuntimePaths): HookPolicy | null const text = readText(file); if (text === null) return null; let data: unknown; - try { data = JSON.parse(text); } catch { throw new Error(`Invalid Crosshead workspace hook settings in ${file}`); } + try { data = JSON.parse(text); } catch { throw new Error(`Invalid Crosshead workspace hook settings in ${file}. Run crosshead workspace repair --home ${shellQuote(paths.home)} to preserve the damaged file and pause automatic hooks.`); } if (!object(data) || data.version !== 1 || !Array.isArray(data.bindings) || !data.bindings.every(isBinding) || (data.scope !== "project" && data.scope !== "all" && data.scope !== "manual") || (data.scope === "manual" && data.bindings.length > 0) || data.bindings.some((binding) => (binding.root === null) !== (data.scope === "all"))) { - throw new Error("Invalid Crosshead workspace hook settings. Run crosshead workspace list to inspect the configuration."); + throw new Error(`Invalid Crosshead workspace hook settings in ${file}. Run crosshead workspace repair --home ${shellQuote(paths.home)} to preserve the damaged file and pause automatic hooks.`); } return { version: 1, scope: data.scope, bindings: data.bindings }; }; -const repositoryRoot = (directory: string): string | null => { - for (let current = directory;; current = path.dirname(current)) { - const git = path.join(current, ".git"); - if (existsSync(path.join(git, "HEAD"))) return current; - try { - const info = statSync(git); - if (info.isFile() && info.size <= 4096 && readText(git)?.startsWith("gitdir: ")) return current; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - if (path.dirname(current) === current) return null; - } -}; - -const directoryPath = (directory: string): string => { - const resolved = realpathSync(directory); - if (!statSync(resolved).isDirectory()) throw new Error(`Expected a directory: ${directory}`); - return resolved; -}; - -export const workspaceProjectRoot = (directory: string): string => { - const resolved = directoryPath(directory); - return repositoryRoot(resolved) ?? resolved; -}; - const appliesIn = (binding: WorkspaceHookBinding, directory: string): boolean => { if (binding.root === null) return true; const relative = path.relative(binding.root, directory); @@ -140,7 +117,7 @@ const hostHome = (host: HookHost, options: { codexHome?: string; claudeHome?: st host === "codex" ? resolveCodexHostHome(options.codexHome) : resolveClaudeCodeHostHome(options.claudeHome); const hookPrefix = (paths: RuntimePaths, host: HookHost): string => - `${shellQuote(path.join(paths.bin, "crosshead"))} host-hook ${host} `; + `${shellQuote(path.join(realHome(paths.home), "bin", "crosshead"))} host-hook ${host} `; const invokesLegacyHook = (command: string, wrapper: string, runtimeHomes: string[], host: HookHost): boolean => { const executable = [shellQuote(wrapper), wrapper].find((token) => command.startsWith(`${token} `)); @@ -161,9 +138,26 @@ const invokesLegacyHook = (command: string, wrapper: string, runtimeHomes: strin })); }; -const bindingCommands = (paths: RuntimePaths, binding: WorkspaceHookBinding) => +const bindingCommands = (paths: RuntimePaths, binding: WorkspaceHookBinding, includeDisabled = false) => renderHostHookGroups(binding.host, (event) => - `${hookPrefix(paths, binding.host)}${event} --home ${shellQuote(paths.home)} --binding ${binding.id} --source-excerpts ${binding.sourceExcerpts ? "on" : "off"}`); + `${hookPrefix(paths, binding.host)}${event} --home ${shellQuote(realHome(paths.home))} --binding ${binding.id} --source-excerpts ${binding.sourceExcerpts ? "on" : "off"}`) + .filter(([event]) => includeDisabled || binding.sourceExcerpts || (event !== "PostToolUse" && event !== "PostToolUseFailure")); + +const invokesScopedHook = (command: string, paths: RuntimePaths, host: HookHost): boolean => { + // Accept only the exact generated shell form, with both paths resolving to + // this runtime. This also recognizes earlier installs through a home symlink. + const quoted = "'(?:[^']|'\"'\"')*'"; + const events = HOST_HOOK_EVENTS.map(({ event }) => event).join("|"); + const match = new RegExp(`^(${quoted}) host-hook ${host} (${events}) --home (${quoted}) --binding [a-f0-9-]{36}( --source-excerpts (on|off))?$`).exec(command); + if (!match) return false; + const unquote = (token: string) => token.slice(1, -1).replace(/'"'"'/g, "'"); + const executable = unquote(match[1]!), home = unquote(match[3]!); + if (shellQuote(executable) !== match[1] || shellQuote(home) !== match[3] || !path.isAbsolute(home) || !path.isAbsolute(executable)) return false; + try { + return realpathSync(home) === realpathSync(paths.home) && + realpathSync(executable) === realpathSync(path.join(paths.bin, "crosshead")); + } catch { return false; } +}; const settings = (file: string, text: string | null): Settings => { if (text === null) return {}; @@ -211,13 +205,18 @@ export const workspaceHookStateFile = (paths: RuntimePaths, binding: WorkspaceHo export const inspectWorkspaceHooks = (home?: string, directory?: string) => { const paths = resolveRuntimePaths(home); - const policy = readWorkspaceHookPolicy(paths); - const resolved = directory ? directoryPath(directory) : null; - const bindings = (policy?.bindings ?? []).filter((binding) => !resolved || + let policy: HookPolicy | null = null; + let error: string | null = null; + let workspaceError: string | null = null; + let resolved: string | null = null; + try { policy = readWorkspaceHookPolicy(paths); } catch (caught) { error = String((caught as Error).message); } + try { resolved = directory ? directoryPath(directory) : null; } + catch { workspaceError = `Workspace directory is unavailable: ${directory}`; } + const bindings = (workspaceError ? [] : policy?.bindings ?? []).filter((binding) => !resolved || workspaceHookForInvocation(policy, binding.host, binding.id, resolved) !== null); return { - scope: policy?.scope ?? "legacy", - workspace: resolved, + scope: error ? "invalid" as const : policy?.scope ?? "legacy", + error, workspaceError, workspace: resolved, bindings: bindings.map((binding) => { const file = hookFile(binding); let configured = false; @@ -309,6 +308,8 @@ export const planWorkspaceHooks = (options: PlanOptions) => { } const edits = new Map(); + const guards: Array<{ file: string; root: string; before: string }> = []; + const sourceSnapshots: Array["snapshot"]> = []; const settingsFiles = new Map(); for (const binding of [...(previous?.bindings ?? []), ...policy.bindings]) { settingsFiles.set(hookFile(binding), { root: binding.root && binding.registration !== "user" ? binding.root : binding.hostHome, host: binding.host }); @@ -334,12 +335,12 @@ export const planWorkspaceHooks = (options: PlanOptions) => { const original = JSON.stringify(data); const owned = new Set([...(previous?.bindings ?? []), ...next] .filter((binding) => hookFile(binding) === file) - .flatMap((binding) => bindingCommands(paths, binding).flatMap(([, group]) => { + .flatMap((binding) => bindingCommands(paths, binding, true).flatMap(([, group]) => { const command = group.hooks[0]!.command; return [command, command.replace(/ --source-excerpts (on|off)$/, "")]; }))); removeCommands(data, (command) => { - if (owned.has(command)) return true; + if (owned.has(command) || invokesScopedHook(command, paths, host)) return true; const legacy = ["crosshead", "onlybots"].some((name) => runtimeHomes.some((home) => invokesLegacyHook(command, path.join(home, "bin", `${name}-${host}-hook`), runtimeHomes, host))); if (legacy && !retired.includes(file)) retired.push(file); @@ -383,12 +384,28 @@ export const planWorkspaceHooks = (options: PlanOptions) => { if (before !== after) edits.set(file, { file, root: paths.home, before, after, executable: true }); } } + // Adopt eligible history once, before SessionStart can replace the runtime alias. + // The migration is part of the same preview and guarded settings plan. + const adopted = new Set(); + for (const binding of policy.bindings) { + const profile = loadProfile(paths, binding.profile); + if (!binding.root || !profile || !isLocalOnlyRuntimeProfile(profile) || previous?.bindings.some(entry => entry.id === binding.id)) continue; + const key = JSON.stringify([binding.profile, binding.root]); + if (adopted.has(key)) continue; + adopted.add(key); + const adoption = planLocalHotMemoryAdoption(paths.state, profile, `repo:${binding.root}`, binding.legacyUnscoped === true, binding.legacyBefore); + for (const item of adoption.files) { + if (!edits.has(item.file)) edits.set(item.file, { file: item.file, root: paths.home, before: null, after: item.content }); + } + guards.push(...adoption.sources.map(source => ({ ...source, root: paths.home }))); + sourceSnapshots.push(adoption.snapshot); + } const file = policyFile(paths); const before = readText(file); const after = `${JSON.stringify(policy, null, 2)}\n`; if (before !== after) edits.set(file, { file, root: paths.home, before, after }); for (const edit of edits.values()) assertManagedFilePath(edit.file, edit.root); - return { paths, policy, edits: [...edits.values()], retired }; + return { paths, policy, edits: [...edits.values()], guards, sourceSnapshots, retired }; }; const realHome = (home: string): string => { @@ -405,17 +422,20 @@ export const summarizeWorkspaceHookPlan = (plan: WorkspaceHookPlan) => ({ }); export const assertWorkspacePlanCurrent = (plan: WorkspaceHookPlan): void => { - for (const edit of plan.edits) { + for (const edit of [...plan.edits, ...plan.guards]) { assertManagedFilePath(edit.file, edit.root); if (readText(edit.file) !== edit.before) throw new Error(`Settings changed since preview: ${edit.file}. Run setup again.`); } + for (const snapshot of plan.sourceSnapshots) { + if (localHotMemoryAdoptionFingerprint(snapshot.stateDir, snapshot.owner) !== snapshot.fingerprint) { + throw new Error("Local memory changed since preview. Run setup again."); + } + } }; export const withWorkspaceSetupLock = async (paths: RuntimePaths, operation: () => Promise): Promise => { mkdirSync(paths.config, { recursive: true, mode: 0o700 }); - const lock = process.platform === "darwin" - ? await acquireLoopbackDaemonLock(realpathSync(paths.home), "workspace-setup") - : await acquireLocalHostedBridge(paths.config, createHash("sha256").update("crosshead-workspace-setup").digest("hex").slice(0, 24)); + const lock = await acquireWorkspaceSetupKernelLock(realpathSync(paths.home)); try { return await operation(); } finally { await lock.close(); } }; @@ -476,3 +496,25 @@ export const refreshWorkspaceHooks = async (home: string): Promise => { applyWorkspaceHookPlan(planWorkspaceHooks({ home, scope: policy.scope, targets: [], refresh: true })); }); }; + +/** Recovery keeps the exact damaged bytes and leaves all automatic hooks paused. */ +export const repairWorkspaceHookPolicy = async (home?: string, dryRun = false) => { + const paths = resolveRuntimePaths(home); + const repair = () => { + const file = policyFile(paths); + assertManagedFilePath(file, paths.home); + try { + const policy = readWorkspaceHookPolicy(paths); + return { applied: false, backup: null as string | null, scope: policy?.scope ?? "legacy", message: "Workspace settings do not need repair." }; + } catch { + const original = readFileSync(file); + const backup = `${file}.invalid-${randomUUID()}.json`; + if (!dryRun) { + writeManagedFile(backup, original, paths.home); + writeManagedFile(file, JSON.stringify({ version: 1, scope: "manual", bindings: [] }, null, 2) + "\n", paths.home); + } + return { applied: !dryRun, backup, scope: "manual", message: "Automatic hooks are paused. Run crosshead quickstart --scope project to review and configure selected projects again." }; + } + }; + return dryRun ? repair() : withWorkspaceSetupLock(paths, async () => repair()); +}; diff --git a/src/runtime/workspace-identity.ts b/src/runtime/workspace-identity.ts new file mode 100644 index 0000000..8d545f0 --- /dev/null +++ b/src/runtime/workspace-identity.ts @@ -0,0 +1,27 @@ +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import path from "node:path"; + +export const workspaceRepositoryRoot = (directory: string): string | null => { + for (let current = directory;; current = path.dirname(current)) { + const git = path.join(current, ".git"); + if (existsSync(path.join(git, "HEAD"))) return current; + try { + const info = statSync(git); + if (info.isFile() && info.size <= 4096 && readFileSync(git, "utf8").startsWith("gitdir: ")) return current; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (path.dirname(current) === current) return null; + } +}; + +export const workspaceDirectoryPath = (directory: string): string => { + const resolved = realpathSync(directory); + if (!statSync(resolved).isDirectory()) throw new Error(`Expected a directory: ${directory}`); + return resolved; +}; + +export const workspaceProjectRoot = (directory: string): string => { + const resolved = workspaceDirectoryPath(directory); + return workspaceRepositoryRoot(resolved) ?? resolved; +}; diff --git a/src/runtime/workspace-memory.ts b/src/runtime/workspace-memory.ts new file mode 100644 index 0000000..b3dad3b --- /dev/null +++ b/src/runtime/workspace-memory.ts @@ -0,0 +1,109 @@ +import path from "node:path"; +import { workspaceProjectRoot } from "./workspace-identity"; +import { workspaceHotScope } from "./workspace-hot-memory"; + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + +const parseJsonRecord = (value: string): Record | null => { + try { + const parsed = JSON.parse(value) as unknown; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +}; + +const parseHotMemoryEntryContent = (entry: Record): Record | null => { + if (isRecord(entry.content)) { + return entry.content; + } + return typeof entry.content === "string" ? parseJsonRecord(entry.content) : null; +}; + +const hasHotMemoryEntryContent = (entry: Record): boolean => { + if (isRecord(entry.content)) { + return true; + } + return typeof entry.content === "string" && entry.content.trim().length > 0; +}; + +const getWorkspaceId = (value: Record | null): string | null => { + const workspaceId = value?.workspaceId ?? value?.workspace_id; + return typeof workspaceId === "string" && workspaceId.trim().length > 0 ? workspaceId.trim() : null; +}; + +export const filterHotMemorySnapshotForWorkspace = ( + hotMemory: Record | null, + workspaceId: string, + workspaceOnly = false, + legacyUnscoped = false, + legacyBefore?: string +): Record | null => { + if (!hotMemory) { + return null; + } + if (workspaceOnly && workspaceId.startsWith("repo:")) { + const target = workspaceId.slice(5); + const roots = new Map(); + const equivalent = (id: string | null, observedAt?: unknown): boolean => { + if (id === workspaceId) return true; + if (!id) return legacyUnscoped && Boolean(legacyBefore) && typeof observedAt === "string" && + Date.parse(observedAt) <= Date.parse(legacyBefore!); + if (!id.startsWith("repo:") || !path.isAbsolute(id.slice(5))) return false; + if (!roots.has(id)) { + try { roots.set(id, workspaceProjectRoot(id.slice(5)) === target); } catch { roots.set(id, false); } + } + return roots.get(id)!; + }; + const normalize = (entry: Record): Record | null => { + const content = parseHotMemoryEntryContent(entry); + if (!content || !equivalent(getWorkspaceId(content), entry.updated_at ?? content.updatedAt ?? content.occurredAt)) return null; + const checkpoints = Array.isArray(content.continuityCheckpoints) + ? content.continuityCheckpoints.filter(isRecord).filter(checkpoint => equivalent(getWorkspaceId(checkpoint), checkpoint.occurredAt ?? entry.updated_at ?? content.updatedAt)) + .map(checkpoint => ({ ...checkpoint, workspaceId })) : undefined; + return { ...entry, content: JSON.stringify({ ...content, workspaceId, + ...(checkpoints ? { continuityCheckpoints: checkpoints } : {}) }) }; + }; + hotMemory = Array.isArray(hotMemory.scopes) + ? { ...hotMemory, scopes: hotMemory.scopes.filter(isRecord).map(normalize).filter(isRecord) } + : normalize(hotMemory); + if (!hotMemory) return null; + } + const scopedEntries = Array.isArray(hotMemory.scopes) ? hotMemory.scopes.filter(isRecord) : null; + if (!scopedEntries) { + if (!hasHotMemoryEntryContent(hotMemory)) { + return null; + } + const entryWorkspaceId = getWorkspaceId(parseHotMemoryEntryContent(hotMemory)); + return entryWorkspaceId === workspaceId || (!workspaceOnly && !entryWorkspaceId) ? hotMemory : null; + } + const scopes = scopedEntries.filter((entry) => { + if (!hasHotMemoryEntryContent(entry)) { + return false; + } + const entryWorkspaceId = getWorkspaceId(parseHotMemoryEntryContent(entry)); + return entryWorkspaceId === workspaceId || (!workspaceOnly && !entryWorkspaceId); + }); + if (scopes.length === 0) { + return null; + } + // The legacy runtime alias and its retained workspace partition describe one + // document. Inject the newest version once, under the readable runtime name. + const selected = new Map>(); + const other: Record[] = []; + for (const entry of scopes) { + const content = parseHotMemoryEntryContent(entry); + const runtime = typeof content?.runtime === "string" ? content.runtime : null; + if (!runtime || getWorkspaceId(content) !== workspaceId || + ![runtime, workspaceHotScope(runtime, workspaceId)].includes(String(entry.scope))) { + other.push(entry); + continue; + } + const previous = selected.get(runtime); + const updated = Date.parse(String(content?.updatedAt)) || 0; + const previousUpdated = Date.parse(String(parseHotMemoryEntryContent(previous ?? {})?.updatedAt)) || 0; + if (!previous || updated >= previousUpdated) selected.set(runtime, { ...entry, scope: runtime }); + } + return { ...hotMemory, scopes: [...other, ...selected.values()] }; +}; diff --git a/tests/runtime/hot-sync-acceptance.test.ts b/tests/runtime/hot-sync-acceptance.test.ts index 267d651..05c44d2 100644 --- a/tests/runtime/hot-sync-acceptance.test.ts +++ b/tests/runtime/hot-sync-acceptance.test.ts @@ -1,5 +1,6 @@ +import { filterHotMemorySnapshotForWorkspace } from "../../src/runtime/host-adapter-base"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, statSync, truncateSync, symlinkSync } from "node:fs"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, realpathSync, statSync, truncateSync, symlinkSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -186,6 +187,19 @@ describe("local-first hot-memory acceptance", () => { expect(entries(otherIdentity)).toEqual([]); expect(JSON.stringify([...cloud.rows.values()])).not.toMatch(/repo:\/|secret|github.com/); }); + it("retains explicitly linked cross-device checkpoints through scoped workspace filtering", async () => { + const cloud = new Cloud(), id = identity(); + const make = () => new HotSyncManager({ stateDir: home(), origin: "https://crosshead.sh", ...id, profileName: "work" }, cloud.client(id.npub)); + const a = make(), b = make(), rootA = realpathSync(home()), rootB = realpathSync(home()); + a.link(rootA, "shared-project"); b.link(rootB, "shared-project"); + a.capture("ignored", { ...checkpoint("remote-finding"), workspaceId: `repo:${rootA}` }); + await a.flush(); await b.flush(); + const received = b.read("all"); + const scoped = filterHotMemorySnapshotForWorkspace(received, `repo:${rootB}`, true); + expect(JSON.stringify(scoped)).toContain("finding remote-finding"); + expect(JSON.stringify(scoped)).not.toContain(rootA); + expect(filterHotMemorySnapshotForWorkspace(received, `repo:${realpathSync(home())}`, true)).toBeNull(); + }); it("keeps the durable offline backlog beyond upload and view limits, including restart", async () => { const cloud = new Cloud(), id = identity(), stateDir = home(), client = cloud.client(id.npub); const config = { stateDir, origin: "https://crosshead.sh", ...id, namespace: "default", scope: "codex" }; diff --git a/tests/runtime/onboarding-upgrade.test.ts b/tests/runtime/onboarding-upgrade.test.ts index c847702..7a4eaa4 100644 --- a/tests/runtime/onboarding-upgrade.test.ts +++ b/tests/runtime/onboarding-upgrade.test.ts @@ -1,3 +1,7 @@ +import { workspaceHotScope } from "../../src/runtime/workspace-hot-memory"; +import { shellQuote } from "../../src/runtime/adapter-utils"; +import * as workspaceHooks from "../../src/runtime/workspace-hooks"; +import { workspaceHooksFromCli } from "../../src/runtime/cli/workspace"; import { spawn, spawnSync } from "node:child_process"; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -9,10 +13,12 @@ import { generateRuntimeIdentity, installRuntimeCore, reapplyManagedRuntimeRelea import { readDaemonOwnershipState } from "../../src/runtime/daemon-ownership"; import { resolveRuntimePaths } from "../../src/runtime/runtime-persistence"; import { readLocalHotMemory, writeLocalHotMemory } from "../../src/runtime/local-hot-memory"; -import { inspectWorkspaceHooks, workspaceBindingStatus } from "../../src/runtime/workspace-hooks"; +import { inspectWorkspaceHooks, refreshWorkspaceHooks, workspaceBindingStatus } from "../../src/runtime/workspace-hooks"; import { filterHotMemorySnapshotForWorkspace } from "../../src/runtime/host-adapter-base"; import { HostedOnlyBotsClient } from "../../src/runtime/hosted-client"; import { isRuntimeProfileName } from "../../src/runtime/runtime-env"; +import { shouldGuideQuickstart } from "../../src/runtime/cli/quickstart"; +import * as workspaceIdentity from "../../src/runtime/workspace-identity"; import { runOnlyBotsCodexHookCli } from "../../src/runtime/codex-hook"; import { runOnlyBotsClaudeCodeHookCli } from "../../src/runtime/claude-code-hook"; @@ -76,11 +82,14 @@ describe("legacy onboarding upgrades", () => { cpSync(path.join(repo, "dist"), path.join(nextRelease, "dist"), { recursive: true }); symlinkSync(path.join(repo, "node_modules"), path.join(nextRelease, "node_modules"), "dir"); const nextScript = (name: string) => path.join(nextRelease, "dist/runtime", name); + const nextScoped = require(nextScript("workspace-hooks.js")); + const refreshed = vi.spyOn(nextScoped, "refreshWorkspaceHooks"); await reapplyManagedRuntimeRelease(paths, { baseUrl: "http://127.0.0.1:0", releaseId: "fixture", releaseUrl: "", releaseSha256: "", releaseRoot: nextRelease, cliScriptPath: nextScript("cli.js"), daemonScriptPath: nextScript("daemon.js"), localRuntimeModulePath: nextScript("local-runtime.js"), codexAdapterModulePath: nextScript("codex-adapter.js"), claudeCodeAdapterModulePath: nextScript("claude-code-adapter.js"), piAdapterModulePath: nextScript("pi-adapter.js"), }, [{ name: "codex", profileName: "work", hostHome: f.codexHome, codexBinary: "/bin/true" }, { name: "claude-code", profileName: "work", hostHome: f.claudeHome, workspaceRoot: f.project, claudeBinary: "/bin/true" }]); + expect(refreshed).toHaveBeenCalledTimes(1); expect(readFileSync(path.join(paths.bin, "crosshead-codex"), "utf8")).toContain(nextScript("codex-bridge.js")); expect(readFileSync(path.join(paths.bin, "crosshead-claude-code"), "utf8")).toContain(nextScript("claude-code-bridge.js")); await updateRuntimeAdapterVersion(f.home, "codex", "0.3.0"); @@ -185,6 +194,208 @@ describe("legacy onboarding upgrades", () => { expect(warning).toHaveBeenCalledOnce(); }); + it.each(["{partial", JSON.stringify({ version: 9, scope: "project", bindings: [] })])("repairs invalid policy through the CLI while preserving its exact bytes: %s", async damaged => { + const f = fixture(); await profile(f.home, "work"); + f.cli("quickstart", "--scope", "project", "--adapter", "both", "--profile", "work", "--codex-home", f.codexHome, "--claude-home", f.claudeHome, "--no-start"); + const paths = resolveRuntimePaths(f.home), file = path.join(paths.config, "workspace-hooks.json"); + const settingsFile = f.hooks("codex").file; + const oldSettings = readFileSync(settingsFile, "utf8"); + writeFileSync(file, damaged); + for (const command of ["list", "check"]) expect(JSON.parse(f.cli("workspace", command, "--json")).error).toContain("workspace repair"); + const doctor = JSON.parse(f.cli("doctor", "--profile", "work", "--json")); + expect(doctor.checks.find((check: { id: string }) => check.id === "workspace_hooks")).toMatchObject({ ok: false }); + expect(doctor.checks.length).toBeGreaterThan(1); + expect(f.run("install", "codex", "--profile", "work", "--codex-home", f.codexHome, "--codex-bin", "/bin/true").stderr).toContain("workspace repair"); + expect(f.run("uninstall", "codex").stderr).toContain("workspace repair"); + const preview = JSON.parse(f.cli("workspace", "repair", "--dry-run", "--json")); + expect(preview.applied).toBe(false); expect(existsSync(preview.backup)).toBe(false); + expect(readFileSync(file, "utf8")).toBe(damaged); + const repaired = JSON.parse(f.cli("workspace", "repair", "--json")); + expect(repaired.applied).toBe(true); expect(readFileSync(repaired.backup, "utf8")).toBe(damaged); + expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ version: 1, scope: "manual", bindings: [] }); + expect(readFileSync(settingsFile, "utf8")).toBe(oldSettings); + f.cli("quickstart", "--scope", "project", "--adapter", "both", "--profile", "work", "--codex-home", f.codexHome, "--claude-home", f.claudeHome, "--no-start"); + expect(JSON.parse(readFileSync(settingsFile, "utf8")).hooks.SessionStart).toHaveLength(1); + expect(f.hooks("codex").configured).toBe(true); + f.cli("install", "codex", "--profile", "work", "--codex-home", f.codexHome, "--codex-bin", "/bin/true"); + f.cli("uninstall", "codex"); + expect(existsSync(path.join(f.codexHome, "onlybots/codex-adapter.json"))).toBe(false); + }); + + it("requires explicit consent before workspace add changes legacy user-wide coverage", async () => { + const f = fixture(); await profile(f.home, "work"); + await installRuntimeCore({ home: f.home, cliScriptPath: cliFile, daemonScriptPath: daemonFile }); + const global = path.join(f.codexHome, "hooks.json"); + const legacy = `'${f.home}/bin/onlybots-codex-hook' SessionStart --home '${f.home}' --codex-home '${f.codexHome}'`; + const text = JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: "command", command: legacy }] }] } }); + writeFileSync(global, text); + const args = ["workspace", "add", "--workspace-root", f.project, "--adapter", "codex", "--profile", "work", "--codex-home", f.codexHome]; + expect(JSON.parse(f.cli(...args, "--dry-run", "--json")).replacesUserHooksIn).toEqual([global]); + const rejected = f.run(...args); + expect(rejected.status).toBe(1); expect(rejected.stderr).toContain("--replace-user-hooks"); + expect(readFileSync(global, "utf8")).toBe(text); + expect(existsSync(path.join(f.home, "config/workspace-hooks.json"))).toBe(false); + f.cli(...args, "--replace-user-hooks"); + expect(f.hooks("codex").configured).toBe(true); + }); + + it("rechecks the selected profile identity before applying workspace add", async () => { + const f = fixture(); await profile(f.home, "work"); + await installRuntimeCore({ home: f.home, cliScriptPath: cliFile, daemonScriptPath: daemonFile }); + const originalLock = workspaceHooks.withWorkspaceSetupLock; + vi.spyOn(workspaceHooks, "withWorkspaceSetupLock").mockImplementation(async (paths, operation) => originalLock(paths, async () => { + await profile(f.home, "work"); + return operation(); + })); + await expect(workspaceHooksFromCli(["add", "--home", f.home, "--workspace-root", f.project, "--adapter", "codex", "--profile", "work", "--codex-home", f.codexHome, "--claude-home", f.claudeHome, "--replace-user-hooks"])).rejects.toThrow("Profile changed since preview"); + expect(existsSync(path.join(f.home, "config/workspace-hooks.json"))).toBe(false); + expect(existsSync(path.join(f.project, ".codex/hooks.json"))).toBe(false); + }); + + it("rejects adoption when a still-running legacy writer changes the preview source", async () => { + const f = fixture(); await profile(f.home, "work"); + const paths = resolveRuntimePaths(f.home); + const original = JSON.stringify({ runtime: "codex", continuityCheckpoints: [{ summary: "Preview finding", occurredAt: "2026-01-01T00:00:00Z" }] }); + writeLocalHotMemory(paths.state, original, "codex"); + const plan = workspaceHooks.planWorkspaceHooks({ home: f.home, codexHome: f.codexHome, claudeHome: f.claudeHome, scope: "project", targets: [{ directory: f.project, hosts: ["codex"], profile: "work" }] }); + expect(plan.guards).toHaveLength(1); + expect(plan.guards[0]!.before).toBe(original); + const newer = original.replace("Preview finding", "Newer finding"); + writeLocalHotMemory(paths.state, newer, "codex"); + expect(() => workspaceHooks.applyWorkspaceHookPlan(plan)).toThrow("Settings changed since preview"); + expect(existsSync(path.join(paths.config, "workspace-hooks.json"))).toBe(false); + expect(readFileSync(path.join(paths.state, "local-hot-memory/codex.json"), "utf8")).toBe(newer); + for (const edit of plan.edits) expect(existsSync(edit.file)).toBe(false); + }); + + it("rejects adoption when a newer flat partition arrives after preview", async () => { + const f = fixture(); await profile(f.home, "work"); + const paths = resolveRuntimePaths(f.home), workspaceId = `repo:${f.project}`; + const snapshot = { runtime: "codex", workspaceId, updatedAt: "2026-01-01T00:00:00Z", continuityCheckpoints: [{ workspaceId, summary: "Preview finding", occurredAt: "2026-01-01T00:00:00Z" }] }; + writeLocalHotMemory(paths.state, JSON.stringify(snapshot), "codex"); + const plan = workspaceHooks.planWorkspaceHooks({ home: f.home, codexHome: f.codexHome, claudeHome: f.claudeHome, scope: "project", targets: [{ directory: f.project, hosts: ["codex"], profile: "work" }] }); + expect(plan.sourceSnapshots).toHaveLength(1); + const newer = JSON.stringify({ ...snapshot, updatedAt: "2026-01-02T00:00:00Z" }); + const scope = workspaceHotScope("codex", workspaceId)!; + writeLocalHotMemory(paths.state, newer, scope); + expect(() => workspaceHooks.applyWorkspaceHookPlan(plan)).toThrow("Local memory changed since preview"); + for (const edit of plan.edits) expect(existsSync(edit.file)).toBe(false); + expect(readLocalHotMemory(paths.state, scope).content).toBe(newer); + }); + + it("rejects an initially empty adoption preview when the first legacy checkpoint arrives", async () => { + const f = fixture(); await profile(f.home, "work"); + mkdirSync(path.join(f.project, ".git")); + writeFileSync(path.join(f.project, ".git/HEAD"), "ref: refs/heads/main\n"); + const subdirectory = path.join(f.project, "src"); mkdirSync(subdirectory); + const paths = resolveRuntimePaths(f.home); + const plan = workspaceHooks.planWorkspaceHooks({ home: f.home, codexHome: f.codexHome, claudeHome: f.claudeHome, scope: "project", targets: [{ directory: f.project, hosts: ["codex"], profile: "work" }] }); + expect(plan.guards).toEqual([]); expect(plan.sourceSnapshots).toHaveLength(1); + const arriving = JSON.stringify({ runtime: "codex", workspaceId: `repo:${subdirectory}`, continuityCheckpoints: [{ workspaceId: `repo:${subdirectory}`, summary: "First arriving finding", occurredAt: new Date().toISOString() }] }); + writeLocalHotMemory(paths.state, arriving, "codex"); + expect(() => workspaceHooks.applyWorkspaceHookPlan(plan)).toThrow("Local memory changed since preview"); + for (const edit of plan.edits) expect(existsSync(edit.file)).toBe(false); + expect(readLocalHotMemory(paths.state, "codex").content).toBe(arriving); + }); + + it("uses the new local scope after replacement without resurrecting its flat archive", async () => { + const f = fixture(), identity = await profile(f.home, "work"); + const state = resolveRuntimePaths(f.home).state; + writeLocalHotMemory(state, "obsolete flat checkpoint", "codex"); + expect(JSON.stringify(readLocalHotMemory(state, "all", identity))).toContain("obsolete flat checkpoint"); + writeLocalHotMemory(state, "current checkpoint", "codex", identity); + expect(JSON.stringify(readLocalHotMemory(state, "all", identity))).toContain("current checkpoint"); + expect(JSON.stringify(readLocalHotMemory(state, "all", identity))).not.toContain("obsolete"); + writeLocalHotMemory(state, "", "codex", identity); + expect(JSON.stringify(readLocalHotMemory(state, "all", identity))).not.toContain("obsolete"); + expect(readFileSync(path.join(state, "local-hot-memory/codex.json"), "utf8")).toBe("obsolete flat checkpoint"); + }); + + it("retains daemon diagnostics when a requested or environment workspace is missing", async () => { + const f = fixture(); await profile(f.home, "work"); + f.cli("quickstart", "--scope", "project", "--adapter", "codex", "--profile", "work", "--codex-home", f.codexHome, "--no-start"); + const missing = path.join(f.root, "deleted-project"); + for (const args of [["--workspace-root", missing], []]) { + const result = spawnSync(process.execPath, [cliFile, "doctor", "--home", f.home, "--profile", "work", "--json", ...args], { env: { ...f.env, CLAUDE_PROJECT_DIR: missing }, encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.checks.length).toBeGreaterThan(1); + expect(report.workspaceHooks.workspaceError).toContain(missing); + expect(report.checks.find((check: { id: string }) => check.id === "workspace_hooks").ok).toBe(false); + } + expect(JSON.parse(f.cli("workspace", "check", "--workspace-root", missing, "--json")).workspaceError).toContain(missing); + }); + + it("only guides a bare terminal quickstart, preserving argument-based legacy setup", () => { + expect(shouldGuideQuickstart([], true)).toBe(true); + expect(shouldGuideQuickstart([], false)).toBe(false); + expect(shouldGuideQuickstart(["--api-base", "https://example.invalid", "--profile", "work"], true)).toBe(false); + expect(shouldGuideQuickstart(["--profile=work"], true)).toBe(false); + }); + + it("omits disabled tool hooks and removes prior disabled registrations without touching custom hooks", async () => { + const f = fixture(); await profile(f.home, "work"); + const args = ["workspace", "add", "--workspace-root", f.project, "--adapter", "both", "--profile", "work", "--codex-home", f.codexHome, "--claude-home", f.claudeHome]; + f.cli("quickstart", "--scope", "project", "--adapter", "both", "--profile", "work", "--codex-home", f.codexHome, "--claude-home", f.claudeHome, "--no-start"); + for (const host of ["codex", "claude-code"] as const) { + const file = f.hooks(host).file, data = JSON.parse(readFileSync(file, "utf8")); + expect(data.hooks.PostToolUse).toBeUndefined(); expect(data.hooks.PostToolUseFailure).toBeUndefined(); + const old = structuredClone(data.hooks.SessionStart[0]); + old.hooks[0].command = old.hooks[0].command.replace(" SessionStart ", " PostToolUse "); + data.hooks.PostToolUse = [old, { hooks: [{ type: "command", command: "echo custom" }] }]; + writeFileSync(file, JSON.stringify(data)); + } + await refreshWorkspaceHooks(f.home); + for (const host of ["codex", "claude-code"] as const) expect(JSON.parse(readFileSync(f.hooks(host).file, "utf8")).hooks.PostToolUse).toEqual([{ hooks: [{ type: "command", command: "echo custom" }] }]); + f.cli(...args, "--source-excerpts", "on"); + for (const host of ["codex", "claude-code"] as const) expect(JSON.parse(readFileSync(f.hooks(host).file, "utf8")).hooks.PostToolUse).toHaveLength(2); + f.cli(...args, "--source-excerpts", "off"); + for (const host of ["codex", "claude-code"] as const) expect(JSON.parse(readFileSync(f.hooks(host).file, "utf8")).hooks.PostToolUse).toEqual([{ hooks: [{ type: "command", command: "echo custom" }] }]); + }); + + it("refreshes hooks through runtime-home aliases without duplicating registered work", async () => { + const f = fixture(); await profile(f.home, "work"); + f.cli("quickstart", "--scope", "project", "--adapter", "both", "--profile", "work", "--codex-home", f.codexHome, "--claude-home", f.claudeHome, "--no-start"); + const alias = path.join(f.root, "runtime alias's link"); symlinkSync(f.home, alias); + for (const host of ["codex", "claude-code"] as const) { + const file = f.hooks(host).file; + // Simulate older commands generated under a different spelling of home. + const data = JSON.parse(readFileSync(file, "utf8")); + for (const groups of Object.values(data.hooks) as Array }>>) { + for (const group of groups) for (const handler of group.hooks) { + handler.command = handler.command.split(shellQuote(f.home)).join(shellQuote(alias)) + .split(shellQuote(path.join(f.home, "bin/crosshead"))).join(shellQuote(path.join(alias, "bin/crosshead"))); + } + } + writeFileSync(file, JSON.stringify(data)); + } + await refreshWorkspaceHooks(f.home); + await refreshWorkspaceHooks(alias); + for (const host of ["codex", "claude-code"] as const) { + expect(JSON.parse(readFileSync(f.hooks(host).file, "utf8")).hooks.SessionStart).toHaveLength(1); + expect(f.hooks(host).configured).toBe(true); + expect(inspectWorkspaceHooks(alias, f.project).bindings.find(binding => binding.host === host)!.configured).toBe(true); + } + }); + + it("resolves each distinct legacy workspace once per prompt and does not cache across prompts", () => { + const f = fixture(), alias = `repo:${path.join(f.root, "alias")}`, unrelated = `repo:${path.join(f.root, "elsewhere")}`; + const resolve = vi.spyOn(workspaceIdentity, "workspaceProjectRoot").mockImplementation(root => root === alias.slice(5) ? f.project : "/other"); + const snapshot = { scopes: [{ scope: "codex", content: JSON.stringify({ runtime: "codex", workspaceId: alias, continuityCheckpoints: Array.from({ length: 100 }, (_, i) => ({ workspaceId: i % 2 ? alias : unrelated, summary: String(i) })) }) }] }; + const selected = filterHotMemorySnapshotForWorkspace(snapshot, `repo:${f.project}`, true); + expect(JSON.parse((selected!.scopes as Array<{content:string}>)[0]!.content).continuityCheckpoints).toHaveLength(50); + expect(resolve).toHaveBeenCalledTimes(2); + filterHotMemorySnapshotForWorkspace(snapshot, `repo:${f.project}`, true); + expect(resolve).toHaveBeenCalledTimes(4); + }); + + it("acquires and releases the setup lock without any external executable on PATH", () => { + const f = fixture(); + const code = `const {withWorkspaceSetupLock}=require(${JSON.stringify(path.join(repo, "dist/runtime/workspace-hooks.js"))});const {resolveRuntimePaths}=require(${JSON.stringify(path.join(repo, "dist/runtime/runtime-persistence.js"))});withWorkspaceSetupLock(resolveRuntimePaths(process.argv[1]),async()=>console.log('locked without flock'));`; + const result = spawnSync(process.execPath, ["-e", code, f.home], { env: { PATH: "" }, encoding: "utf8", timeout: 5000 }); + expect(result.status, result.stderr).toBe(0); expect(result.stdout).toContain("locked without flock"); + }); + it("uses one status ladder for unobserved all-project bindings", async () => { const f = fixture(); await profile(f.home, "work"); f.cli("quickstart", "--scope", "all", "--adapter", "codex", "--profile", "work", "--codex-home", f.codexHome, "--no-start"); diff --git a/tests/runtime/workspace-setup.test.ts b/tests/runtime/workspace-setup.test.ts index 9aaa823..b2b3a44 100644 --- a/tests/runtime/workspace-setup.test.ts +++ b/tests/runtime/workspace-setup.test.ts @@ -94,11 +94,7 @@ it("previews and verifies the installed hooks across two projects and profiles", invoke(hook(aCodex, "UserPromptSubmit"), subdirectory, { session_id: "a-codex", turn_id: "one", prompt: "Repair the queue" }); const eventFile = path.join(home, "state/lifecycle-events.jsonl"); const beforeTool = readFileSync(eventFile, "utf8"); - invoke(hook(aCodex, "PostToolUse"), subdirectory, { - session_id: "a-codex", turn_id: "one", tool_name: "Read", - tool_input: { file_path: path.join(projectA, "rules.md") }, - tool_response: { type: "text", file: { filePath: path.join(projectA, "rules.md"), content: "Source excerpts are disabled for this binding." } }, - }); + expect(readJson(aCodex).hooks.PostToolUse).toBeUndefined(); expect(readFileSync(eventFile, "utf8")).toBe(beforeTool); const finding = "Keep the queue ordered by the original receipt ID."; invoke(hook(aCodex, "Stop"), subdirectory, { session_id: "a-codex", turn_id: "one", summary: finding });