diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 32d565b..a5bb75f 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cc", - "version": "1.4.2", + "version": "1.5.0", "description": "Claude Code Plugin for Codex. Delegate code reviews, investigations, and tracked tasks to Claude Code from inside Codex.", "author": { "name": "Sendbird, Inc.", diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d120b..dd6b2fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v1.5.0 + +- Restore the `SessionEnd` hook. Codex 0.146 dispatches `SessionEnd` for root sessions, so the teardown removed in v1.3.0 as non-dispatched now runs again: it reaps background jobs whose process died and clears this session's current-session marker instead of leaving it to age out after seven days. Teardown stays inside Codex's few-second budget — it never kills or waits on live processes, so detached jobs keep running and the `UserPromptSubmit` sweeper still covers them. Codex trusts hooks one by one, so the new `SessionEnd` hook arrives untrusted and stays inert until you approve it in the Codex hooks browser; the already-trusted `SessionStart`, `Stop`, and `UserPromptSubmit` entries are unaffected, because both the trust key index and the hash are per hook. +- Stop requiring `[features].plugin_hooks`. Upstream retired that flag (`Stage::Removed`), and native plugin hooks now ride on `[features].hooks` alone. Setup requires only `hooks = true` and strips a leftover `plugin_hooks` line from `~/.codex/config.toml`, the same way it already upgrades the legacy `codex_hooks` alias. +- Correct the review skills' description of `request_user_input`. Omitting `[tools] experimental_request_user_input` leaves the tool enabled; only an explicit `false` hides it in an interactive thread. The conditional ask is unchanged — it still depends on the thread actually having a question tool. + ## v1.4.2 - Refuse companion delegation from Codex threads that are themselves driven by Claude Code. The reverse-direction plugin (Claude Code → Codex) spawns a bare `codex app-server` that inherits `~/.codex`, so its headless review threads see this plugin's skills and delegated the review back to Claude Code — looping the work between the two assistants and burning minutes on `wait`-tool spins with narration in place of findings. Session hooks now stamp `hostOrigin: "claude-code"` on the current-session marker when Claude Code host env markers (`CLAUDECODE` / `CLAUDE_CODE_ENTRYPOINT`) reach them, and `review`, `adversarial-review`, and `task` refuse delegation from such threads with explicit instructions to perform the work directly in that thread. Interactive sessions (env session id present), background forwarding children owned by a different session, and unstamped state all stay open, so the gate fails open everywhere the loop cannot occur. diff --git a/README.md b/README.md index e822a40..e47b098 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ $cc:setup --disable-review-gate # turn it off ``` Setup checks Claude Code availability, native plugin hook feature gates, and review-gate state. If Claude Code isn't installed, it offers to install it. -This is also the repair path for marketplace-installed copies of the plugin: `$cc:setup` confirms `[features].hooks = true` and `[features].plugin_hooks = true`, trusts this plugin's current native hook hashes, and allows sandboxed writes to Codex's injected marketplace-qualified plugin-data root plus the legacy roots needed for one-time migration. If those writable roots were just added, restart Codex and rerun setup before changing the review gate. +This is also the repair path for marketplace-installed copies of the plugin: `$cc:setup` confirms `[features].hooks = true`, trusts this plugin's current native hook hashes, and allows sandboxed writes to Codex's injected marketplace-qualified plugin-data root plus the legacy roots needed for one-time migration. If those writable roots were just added, restart Codex and rerun setup before changing the review gate. ## Background Jobs @@ -308,7 +308,7 @@ Then install `cc` from the Sendbird marketplace inside Codex, and run: $cc:setup ``` -Marketplace/plugin install places the plugin under Codex's plugin cache. `$cc:setup` verifies Claude Code, confirms `[features].hooks = true` plus `[features].plugin_hooks = true`, and trusts the current `hooks/hooks.json` hook hashes from the active plugin cache. +Marketplace/plugin install places the plugin under Codex's plugin cache. `$cc:setup` verifies Claude Code, confirms `[features].hooks = true`, and trusts the current `hooks/hooks.json` hook hashes from the active plugin cache. ### npx helper @@ -362,7 +362,7 @@ claude auth login Re-run install and restart Codex. This plugin expects Codex plugin support and no longer installs local skill-wrapper fallbacks. **Hooks not firing** -Check that `hooks = true` and `plugin_hooks = true` are set in `~/.codex/config.toml` under `[features]`. Run `$cc:setup` to verify and auto-repair the feature gates plus this plugin's hook trust hashes, then restart Codex if those flags were just changed. +Check that `hooks = true` is set in `~/.codex/config.toml` under `[features]`. Run `$cc:setup` to verify and auto-repair the feature gate plus this plugin's hook trust hashes, then restart Codex if that flag was just changed. **A background job finished but I did not get the result nudge** Use: diff --git a/hooks/hooks.json b/hooks/hooks.json index 5219cbd..5f252be 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -12,6 +12,19 @@ ] } ], + "SessionEnd": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node \"$PLUGIN_ROOT/hooks/session-lifecycle-hook.mjs\"", + "timeout": 3, + "statusMessage": "Closing Claude Code bridge" + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/hooks/session-lifecycle-hook.mjs b/hooks/session-lifecycle-hook.mjs index 62cd3cc..fd07f03 100644 --- a/hooks/session-lifecycle-hook.mjs +++ b/hooks/session-lifecycle-hook.mjs @@ -9,6 +9,7 @@ * Session lifecycle hook for Codex — Claude Code bridge. * * SessionStart: Exports CLAUDE_COMPANION_SESSION_ID via CLAUDE_ENV_FILE. + * SessionEnd: Reaps dead background jobs and drops the session marker. * * No broker lifecycle — Claude Code uses direct CLI invocation. */ @@ -21,7 +22,11 @@ import { fileURLToPath } from "node:url"; import { readHookInput } from "./lib/hook-input.mjs"; import { detectExternalHostOrigin } from "./lib/host-origin.mjs"; import { cleanupAfterOfficialUninstall } from "./lib/plugin-install-guard.mjs"; -import { setCurrentSession } from "../scripts/lib/state.mjs"; +import { + clearCurrentSession, + listJobs, + setCurrentSession, +} from "../scripts/lib/state.mjs"; import { SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs"; export { SESSION_ID_ENV }; @@ -68,6 +73,22 @@ function handleSessionStart(input) { } } +function handleSessionEnd(input) { + const cwd = input.cwd || process.cwd(); + if (isNestedCodexSession(input.session_id)) { + return; + } + try { + // listJobs() runs the PID-reuse-safe stale job reaper. Codex caps SessionEnd + // at a few seconds, so teardown never kills or waits on live processes: + // detached jobs keep running and the UserPromptSubmit sweeper picks them up. + listJobs(cwd); + } catch { + // Best effort only — teardown must not fail the session shutdown. + } + clearCurrentSession(cwd, input.session_id ?? null); +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -79,6 +100,10 @@ async function main() { } const eventName = process.argv[2] ?? input.hook_event_name ?? ""; + if (eventName === "SessionEnd") { + handleSessionEnd(input); + return; + } if (eventName === "SessionStart" || !eventName) { // Default to SessionStart (Codex invokes this on session start) handleSessionStart(input); diff --git a/package-lock.json b/package-lock.json index 9215c96..40c8e14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-plugin-codex", - "version": "1.4.2", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-plugin-codex", - "version": "1.4.2", + "version": "1.5.0", "license": "Apache-2.0", "bin": { "cc-plugin-codex": "scripts/installer-cli.mjs" diff --git a/package.json b/package.json index 0608d75..bf8873c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-plugin-codex", - "version": "1.4.2", + "version": "1.5.0", "description": "Claude Code Plugin for Codex by Sendbird", "type": "module", "author": { diff --git a/scripts/claude-companion.mjs b/scripts/claude-companion.mjs index 179f834..2a16c9e 100644 --- a/scripts/claude-companion.mjs +++ b/scripts/claude-companion.mjs @@ -640,7 +640,7 @@ async function handleSetup(argv) { if (configureNativePluginHooks()) { actionsTaken.push( - "Enabled native Codex plugin hooks via [features].hooks and [features].plugin_hooks." + "Enabled native Codex plugin hooks via [features].hooks." ); actionsTaken.push("Restart Codex if this session started before the feature change."); } diff --git a/scripts/installer-cli.mjs b/scripts/installer-cli.mjs index b43b590..c00a8fb 100755 --- a/scripts/installer-cli.mjs +++ b/scripts/installer-cli.mjs @@ -233,7 +233,7 @@ async function installOrUpdate() { console.log(`Installed ${PLUGIN_NAME} from ${marketplaceConfig.source} into the Codex plugin cache.`); if (hooksChanged) { - console.log("Enabled [features].hooks and [features].plugin_hooks in ~/.codex/config.toml."); + console.log("Enabled [features].hooks in ~/.codex/config.toml."); } if (writableRootChanged) { console.log(`Allowed plugin state writes under ${pluginDataRoots.join(", ")}.`); diff --git a/scripts/lib/codex-config.mjs b/scripts/lib/codex-config.mjs index 687846d..542f2e8 100644 --- a/scripts/lib/codex-config.mjs +++ b/scripts/lib/codex-config.mjs @@ -10,7 +10,10 @@ function normalizeTrailingNewline(text) { return `${String(text).replace(/\s*$/, "")}\n`; } -const REQUIRED_NATIVE_HOOK_FEATURES = ["hooks", "plugin_hooks"]; +const REQUIRED_NATIVE_HOOK_FEATURES = ["hooks"]; +// Upstream Codex retired these feature flags; `codex_hooks` became `hooks` and +// `plugin_hooks` folded into it. Leaving them set keeps dead keys in user config. +const OBSOLETE_NATIVE_HOOK_FEATURES = ["codex_hooks", "plugin_hooks"]; const WRITABLE_ROOTS_KEY = "sandbox_workspace_write.writable_roots"; const MAX_CONFIG_WRITE_ATTEMPTS = 3; @@ -220,7 +223,7 @@ export function ensureNativePluginHooksEnabled(content) { if (inFeatures) { const featureMatch = trimmed.match(/^([A-Za-z0-9_.-]+)\s*=/); const featureKey = featureMatch?.[1] ?? null; - if (featureKey === "codex_hooks") { + if (OBSOLETE_NATIVE_HOOK_FEATURES.includes(featureKey)) { changed = true; continue; } diff --git a/skills/adversarial-review/SKILL.md b/skills/adversarial-review/SKILL.md index 546a747..07878e3 100644 --- a/skills/adversarial-review/SKILL.md +++ b/skills/adversarial-review/SKILL.md @@ -44,7 +44,7 @@ Execution mode rules: - Then ask the user once which execution mode to use, offering two options with the recommended one first and its label suffixed `(Recommended)`: - `Wait for results` - `Run in background` -- Use a question tool for that ask only when this thread actually has one. Codex exposes `request_user_input` only behind `[tools] experimental_request_user_input`, and it does not exist in non-interactive threads. If you have no question tool but a user is reading this thread, ask in your own reply and stop there. In a non-interactive thread with no user to answer, skip the ask and proceed with the recommended mode. Never spin on a wait or collaboration tool looking for a picker this thread does not have. +- Use a question tool for that ask only when this thread actually has one. Codex exposes `request_user_input` by default in interactive threads and hides it only when `[tools] experimental_request_user_input = false`, and it does not exist in non-interactive threads. If you have no question tool but a user is reading this thread, ask in your own reply and stop there. In a non-interactive thread with no user to answer, skip the ask and proceed with the recommended mode. Never spin on a wait or collaboration tool looking for a picker this thread does not have. Argument handling: - Preserve the user's arguments exactly. diff --git a/skills/review/SKILL.md b/skills/review/SKILL.md index fbc8b00..37e2c2c 100644 --- a/skills/review/SKILL.md +++ b/skills/review/SKILL.md @@ -45,7 +45,7 @@ Execution mode rules: - Then ask the user once which execution mode to use, offering two options with the recommended one first and its label suffixed `(Recommended)`: - `Wait for results` - `Run in background` -- Use a question tool for that ask only when this thread actually has one. Codex exposes `request_user_input` only behind `[tools] experimental_request_user_input`, and it does not exist in non-interactive threads. If you have no question tool but a user is reading this thread, ask in your own reply and stop there. In a non-interactive thread with no user to answer, skip the ask and proceed with the recommended mode. Never spin on a wait or collaboration tool looking for a picker this thread does not have. +- Use a question tool for that ask only when this thread actually has one. Codex exposes `request_user_input` by default in interactive threads and hides it only when `[tools] experimental_request_user_input = false`, and it does not exist in non-interactive threads. If you have no question tool but a user is reading this thread, ask in your own reply and stop there. In a non-interactive thread with no user to answer, skip the ask and proceed with the recommended mode. Never spin on a wait or collaboration tool looking for a picker this thread does not have. Argument handling: - Preserve the user's arguments exactly. diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index 1582ab1..595ad01 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -19,7 +19,7 @@ Workflow: - If it reports that Claude Code is unavailable and `npm` is available, ask whether to install Claude Code now. - If the user agrees, run `npm install -g @anthropic-ai/claude-code` and rerun setup. - If Claude Code is already installed or `npm` is unavailable, do not ask about installation. -- If setup reports missing native plugin hook features or hook trust, rerun setup once. The companion repairs `[features].hooks`, `[features].plugin_hooks`, and this plugin's native hook trust hashes itself. +- If setup reports missing native plugin hook features or hook trust, rerun setup once. The companion repairs `[features].hooks` and this plugin's native hook trust hashes itself. - If setup adds the plugin-data destination or legacy migration roots to the writable-root list, do not retry in the same Codex session. Tell the user to restart Codex and rerun the same setup command; any requested review-gate change is deliberately deferred until that restart. - After the decision flow is complete, run the final user-facing command without `--json`: `node "/scripts/claude-companion.mjs" setup $ARGUMENTS` diff --git a/tests/codex-config.test.mjs b/tests/codex-config.test.mjs index 8481b78..94f8faa 100644 --- a/tests/codex-config.test.mjs +++ b/tests/codex-config.test.mjs @@ -9,7 +9,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { ensureCodexWritableRoot } from "../scripts/lib/codex-config.mjs"; +import { + ensureCodexWritableRoot, + ensureNativePluginHooksEnabled, + nativePluginHooksStatus, +} from "../scripts/lib/codex-config.mjs"; const originalExecutable = process.env.CC_PLUGIN_CODEX_EXECUTABLE; const originalArgs = process.env.CC_PLUGIN_CODEX_APP_SERVER_ARGS_JSON; @@ -156,3 +160,28 @@ rl.on("line", (line) => { ]); assert.equal(await ensureCodexWritableRoot(root, "/target"), false); }); + +it("requires only [features].hooks and strips the retired plugin_hooks gate", () => { + const enabled = ensureNativePluginHooksEnabled( + "[features]\nhooks = true\nplugin_hooks = true\n" + ); + + assert.equal(enabled.changed, true); + assert.match(enabled.content, /hooks = true/); + assert.doesNotMatch(enabled.content, /plugin_hooks/); + assert.equal(nativePluginHooksStatus(enabled.content).installed, true); + + const clean = ensureNativePluginHooksEnabled(enabled.content); + assert.equal(clean.changed, false); +}); + +it("reports native hook status from [features].hooks alone", () => { + assert.deepEqual(nativePluginHooksStatus("[features]\nhooks = true\n"), { + installed: true, + missing: [], + }); + assert.deepEqual(nativePluginHooksStatus("[features]\nplugin_hooks = true\n"), { + installed: false, + missing: ["hooks"], + }); +}); diff --git a/tests/e2e/codex-skills-e2e.test.mjs b/tests/e2e/codex-skills-e2e.test.mjs index 3cf1d31..1604c82 100644 --- a/tests/e2e/codex-skills-e2e.test.mjs +++ b/tests/e2e/codex-skills-e2e.test.mjs @@ -192,7 +192,7 @@ function installHooks(testEnv) { const config = fs.readFileSync(configFile, "utf8"); assert.ok(!fs.existsSync(hooksFile), "native plugin hooks should not install global hooks"); assert.match(config, /hooks = true/); - assert.match(config, /plugin_hooks = true/); + assert.doesNotMatch(config, /plugin_hooks/); } function createLocalMarketplaceFixture(testEnv) { @@ -2154,7 +2154,7 @@ describe("Codex direct-skill E2E", () => { const config = fs.readFileSync(path.join(testEnv.codexHome, "config.toml"), "utf8"); assert.ok(!fs.existsSync(hooksFile), "setup should not install global hooks"); assert.match(config, /hooks = true/); - assert.match(config, /plugin_hooks = true/); + assert.doesNotMatch(config, /plugin_hooks/); } finally { await provider.close(); cleanupEnvironment(testEnv); @@ -2197,7 +2197,152 @@ describe("Codex direct-skill E2E", () => { const config = fs.readFileSync(path.join(testEnv.codexHome, "config.toml"), "utf8"); assert.ok(!fs.existsSync(hooksFile)); assert.match(config, /hooks = true/); - assert.match(config, /plugin_hooks = true/); + assert.doesNotMatch(config, /plugin_hooks/); + } finally { + await provider.close(); + cleanupEnvironment(testEnv); + } + }); +}); + +function startPlainProvider() { + const server = http.createServer((req, res) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + if (req.method === "GET" && req.url === "/v1/models") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ object: "list", data: [{ id: "mock-model", object: "model" }] })); + return; + } + if (req.method !== "POST" || req.url !== "/v1/responses") { + res.writeHead(404); + res.end("not found"); + return; + } + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end( + formatSse([ + eventCreated("resp-plain"), + eventAssistantMessage("msg-plain", "ok"), + eventCompleted("resp-plain"), + ]) + ); + }); + }); + + return { + listen() { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve(server.address().port)); + }); + }, + close() { + return new Promise((resolve) => { + server.close(() => resolve()); + }); + }, + }; +} + +// Codex trusts hooks one by one: a hook only runs once its current hash is +// recorded under [hooks.state]. Mirrors upstream's own hooks/list -> +// config/batchWrite recipe so the dispatch assertions below exercise real hooks. +function trustPluginHooks(testEnv, cwd) { + const clientPath = path.join(PROJECT_ROOT, "scripts", "lib", "codex-app-server.mjs"); + const script = ` +import { callCodexAppServer } from ${JSON.stringify(clientPath)}; +const cwd = ${JSON.stringify(cwd)}; +const listed = await callCodexAppServer({ cwd, method: "hooks/list", params: { cwds: [cwd] } }); +const hooks = (listed.data ?? []).flatMap((entry) => entry.hooks ?? []); +const state = Object.fromEntries(hooks.map((hook) => [hook.key, { trusted_hash: hook.currentHash }])); +if (Object.keys(state).length > 0) { + await callCodexAppServer({ + cwd, + method: "config/batchWrite", + params: { + edits: [{ keyPath: "hooks.state", value: state, mergeStrategy: "upsert" }], + filePath: null, + expectedVersion: null, + reloadUserConfig: true, + }, + }); +} +process.stdout.write(JSON.stringify(hooks.map((hook) => hook.eventName))); +`; + const result = spawnSync(process.execPath, ["--input-type=module", "-e", script], { + cwd, + env: testEnv.env, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + return JSON.parse(result.stdout); +} + +function readDirNames(dir) { + try { + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + } catch { + return []; + } +} + +function pluginStateWorkspaceDirs(testEnv) { + const dataRoot = path.join(testEnv.codexHome, "plugins", "data"); + return readDirNames(dataRoot).flatMap((pluginDir) => { + const stateRoot = path.join(dataRoot, pluginDir, "state"); + return readDirNames(stateRoot).map((workspace) => path.join(stateRoot, workspace)); + }); +} + +describe("native hook dispatch", () => { + it("runs the installed plugin's SessionEnd hook and drops the session marker", async (t) => { + if (!codexAvailable()) { + t.skip("codex CLI is not available in this environment"); + return; + } + + const testEnv = createEnvironment(); + // A nested session id would make SessionStart skip the marker this asserts on. + delete testEnv.env.CLAUDE_COMPANION_SESSION_ID; + const workspaceDir = path.join(testEnv.rootDir, "session-end-workspace"); + fs.mkdirSync(workspaceDir, { recursive: true }); + setupGitWorkspace(workspaceDir); + installPlugin(testEnv); + + const provider = startPlainProvider(); + testEnv.providerPort = await provider.listen(); + writeConfigToml(testEnv, testEnv.providerPort); + + try { + const trusted = trustPluginHooks(testEnv, workspaceDir); + assert.ok( + trusted.includes("sessionEnd"), + `Codex should discover the plugin SessionEnd hook, saw ${JSON.stringify(trusted)}` + ); + + const execResult = await runCodexExec(testEnv, "Reply with exactly: ok", { + cwd: workspaceDir, + }); + assert.equal(execResult.status, 0, execResult.stderr || execResult.stdout); + + const stateDirs = pluginStateWorkspaceDirs(testEnv); + // SessionStart creates the workspace state dir and writes the marker; + // only SessionEnd removes the marker, so an existing dir without one + // proves Codex dispatched SessionEnd to this plugin. + assert.ok( + stateDirs.length > 0, + "session hooks should have created a workspace state directory" + ); + for (const stateDir of stateDirs) { + assert.ok( + !fs.existsSync(path.join(stateDir, "current-session.json")), + `SessionEnd should have cleared the session marker in ${stateDir}` + ); + } } finally { await provider.close(); cleanupEnvironment(testEnv); diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index 3412826..a03c819 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -313,6 +313,7 @@ function writeStaleTurnBaseline(testEnv, sessionId) { describe("hooks", () => { it("native plugin hook events stay within upstream Codex hook event names", () => { + // codex-rs/hooks/src/lib.rs HOOK_EVENT_NAMES const upstreamHookEventNames = new Set([ "PreToolUse", "PermissionRequest", @@ -320,8 +321,12 @@ describe("hooks", () => { "PreCompact", "PostCompact", "SessionStart", + "SessionEnd", "UserPromptSubmit", + "SubagentStart", + "SubagentStop", "Stop", + "Interrupt", ]); const hooksConfig = JSON.parse(fs.readFileSync(HOOKS_JSON, "utf8")); const pluginHookEvents = Object.keys(hooksConfig.hooks ?? {}); @@ -762,6 +767,93 @@ describe("hooks", () => { } }); + it("session end reaps dead background jobs, keeps live ones, and drops the session marker", () => { + const testEnv = createHookEnvironment(); + + try { + const deadPid = spawnSync(process.execPath, ["-e", ""]).pid; + // Older than REAP_GRACE_MS so the reaper actually inspects the PID. + const createdAt = "2026-04-04T01:00:00Z"; + writeStateJob(testEnv, "dead-job", { + id: "dead-job", + status: "running", + sessionId: "ending-session", + pid: deadPid, + createdAt, + }); + writeStateJob(testEnv, "live-job", { + id: "live-job", + status: "running", + sessionId: "ending-session", + pid: process.pid, + createdAt, + }); + runHook( + SESSION_HOOK, + [], + { cwd: testEnv.workspaceDir, session_id: "ending-session" }, + testEnv.env + ); + + runHook( + SESSION_HOOK, + [], + { + cwd: testEnv.workspaceDir, + session_id: "ending-session", + hook_event_name: "SessionEnd", + reason: "other", + }, + testEnv.env + ); + + assert.equal(readStateJob(testEnv, "dead-job").status, "failed"); + const liveJob = readStateJob(testEnv, "live-job"); + assert.equal(liveJob.status, "running"); + assert.equal(liveJob.pid, process.pid); + assert.equal( + fs.existsSync( + path.join( + stateDirFor(testEnv.homeDir, testEnv.workspaceDir), + "current-session.json" + ) + ), + false + ); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + + it("session end from another session leaves the current session marker alone", () => { + const testEnv = createHookEnvironment(); + + try { + runHook( + SESSION_HOOK, + [], + { cwd: testEnv.workspaceDir, session_id: "active-session" }, + testEnv.env + ); + + runHook( + SESSION_HOOK, + [], + { + cwd: testEnv.workspaceDir, + session_id: "other-session", + hook_event_name: "SessionEnd", + reason: "other", + }, + testEnv.env + ); + + assert.equal(readCurrentSessionMarker(testEnv).sessionId, "active-session"); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + it("stop-review hook blocks unknown Claude completion states even if partial output looks like ALLOW", () => { const testEnv = createHookEnvironment(); diff --git a/tests/install-hooks.test.mjs b/tests/install-hooks.test.mjs index 61b96db..22f90ae 100644 --- a/tests/install-hooks.test.mjs +++ b/tests/install-hooks.test.mjs @@ -59,7 +59,7 @@ describe("install-hooks.mjs", () => { const config = fs.readFileSync(configFile, "utf8"); assert.match(config, /\[features\]/); assert.match(config, /hooks = true/); - assert.match(config, /plugin_hooks = true/); + assert.doesNotMatch(config, /plugin_hooks/); assert.match(result.stdout, /native Codex plugin hooks/i); }); @@ -80,7 +80,7 @@ describe("install-hooks.mjs", () => { assert.match(config, /\[features\]/); assert.match(config, /hooks = true/); - assert.match(config, /plugin_hooks = true/); + assert.doesNotMatch(config, /plugin_hooks/); assert.doesNotMatch(config, /codex_hooks/); assert.match(result.stdout, /Enabled native Codex plugin hooks/i); }); diff --git a/tests/installer-cli.test.mjs b/tests/installer-cli.test.mjs index 3510eec..3cfe93a 100644 --- a/tests/installer-cli.test.mjs +++ b/tests/installer-cli.test.mjs @@ -847,7 +847,7 @@ describe("installer-cli", () => { assert.match(config, /\[plugins\."cc@sendbird"\]/); assert.match(config, /hooks = true/); - assert.match(config, /plugin_hooks = true/); + assert.doesNotMatch(config, /plugin_hooks/); assert.ok(!fs.existsSync(legacyInstallDir), "installer should not create a stable local plugin root"); assert.ok(!fs.existsSync(hooksFile), "installer should not write global hooks.json"); assert.ok(fs.existsSync(cachedReviewSkill)); diff --git a/tests/skills-contracts.test.mjs b/tests/skills-contracts.test.mjs index 55c5778..096eebb 100644 --- a/tests/skills-contracts.test.mjs +++ b/tests/skills-contracts.test.mjs @@ -433,7 +433,7 @@ test("setup skill repairs native plugin hook feature gates before the final setu assert.match(setup, /missing native plugin hook features/i); assert.match(setup, /hook trust/i); assert.match(setup, /\[features\]\.hooks/i); - assert.match(setup, /\[features\]\.plugin_hooks/i); + assert.doesNotMatch(setup, /plugin_hooks/i); assert.match(setup, /native hook trust hashes/i); assert.match(setup, /plugin-data destination .* writable-root list/i); assert.match(setup, /restart Codex and rerun the same setup command/i);