diff --git a/plugins/codex/hooks/hooks.json b/plugins/codex/hooks/hooks.json index 19e33b81..37e3e1d8 100644 --- a/plugins/codex/hooks/hooks.json +++ b/plugins/codex/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-lifecycle-hook.mjs\" SessionStart", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/session-start-env.sh\"", "timeout": 5 } ] diff --git a/plugins/codex/scripts/session-start-env.sh b/plugins/codex/scripts/session-start-env.sh new file mode 100755 index 00000000..08654bc6 --- /dev/null +++ b/plugins/codex/scripts/session-start-env.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# SessionStart hook: export session ID and plugin data dir to CLAUDE_ENV_FILE. +# Replaces the Node.js session-lifecycle-hook.mjs for the SessionStart path +# to avoid ~100ms V8 startup overhead on every session. + +set -euo pipefail + +INPUT=$(cat) +SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) + +[ -z "${CLAUDE_ENV_FILE:-}" ] && exit 0 + +shell_escape() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\"\\'\"'/g")" +} + +if [ -n "$SESSION_ID" ]; then + printf 'export CODEX_COMPANION_SESSION_ID=%s\n' "$(shell_escape "$SESSION_ID")" >> "$CLAUDE_ENV_FILE" +fi + +if [ -n "${CLAUDE_PLUGIN_DATA:-}" ]; then + printf 'export CLAUDE_PLUGIN_DATA=%s\n' "$(shell_escape "$CLAUDE_PLUGIN_DATA")" >> "$CLAUDE_ENV_FILE" +fi + +exit 0 diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 90408372..ea730f2c 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -535,6 +535,114 @@ test("task --resume-last ignores running tasks from other Claude sessions", () = assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./); }); +const SESSION_START_BASH = path.join(PLUGIN_ROOT, "scripts", "session-start-env.sh"); + +test("bash session start hook exports the same env vars as the Node.js version", () => { + const repo = makeTempDir(); + const envFile = path.join(makeTempDir(), "claude-env.sh"); + fs.writeFileSync(envFile, "", "utf8"); + const pluginDataDir = makeTempDir(); + + const result = run("bash", [SESSION_START_BASH], { + cwd: repo, + env: { + ...process.env, + CLAUDE_ENV_FILE: envFile, + CLAUDE_PLUGIN_DATA: pluginDataDir + }, + input: JSON.stringify({ + session_id: "sess-bash-test", + cwd: repo + }) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal( + fs.readFileSync(envFile, "utf8"), + `export CODEX_COMPANION_SESSION_ID='sess-bash-test'\nexport CLAUDE_PLUGIN_DATA='${pluginDataDir}'\n` + ); +}); + +test("bash session start hook handles single quotes in session id", () => { + const envFile = path.join(makeTempDir(), "claude-env.sh"); + fs.writeFileSync(envFile, "", "utf8"); + + const result = run("bash", [SESSION_START_BASH], { + env: { + ...process.env, + CLAUDE_ENV_FILE: envFile + }, + input: JSON.stringify({ + session_id: "O'Brien's \"test\"" + }) + }); + + assert.equal(result.status, 0, result.stderr); + const content = fs.readFileSync(envFile, "utf8"); + assert.match(content, /CODEX_COMPANION_SESSION_ID=/); + // Verify the escaped value roundtrips correctly via shell eval + const script = path.join(makeTempDir(), "roundtrip.sh"); + fs.writeFileSync(script, `${content}\nprintf '%s' "$CODEX_COMPANION_SESSION_ID"`, { mode: 0o755 }); + const roundtrip = run("bash", [script]); + assert.equal(roundtrip.stdout, "O'Brien's \"test\""); +}); + +test("bash session start hook exits cleanly without CLAUDE_ENV_FILE", () => { + const result = run("bash", [SESSION_START_BASH], { + env: { + ...process.env, + CLAUDE_ENV_FILE: "" + }, + input: JSON.stringify({ + session_id: "sess-no-env-file" + }) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, ""); +}); + +test("bash session start hook produces identical output to the Node.js version", () => { + const repo = makeTempDir(); + const nodeEnvFile = path.join(makeTempDir(), "node-env.sh"); + const bashEnvFile = path.join(makeTempDir(), "bash-env.sh"); + fs.writeFileSync(nodeEnvFile, "", "utf8"); + fs.writeFileSync(bashEnvFile, "", "utf8"); + const pluginDataDir = makeTempDir(); + const inputJson = JSON.stringify({ + hook_event_name: "SessionStart", + session_id: "sess-parity-check", + cwd: repo + }); + + const nodeResult = run("node", [SESSION_HOOK, "SessionStart"], { + cwd: repo, + env: { + ...process.env, + CLAUDE_ENV_FILE: nodeEnvFile, + CLAUDE_PLUGIN_DATA: pluginDataDir + }, + input: inputJson + }); + + const bashResult = run("bash", [SESSION_START_BASH], { + cwd: repo, + env: { + ...process.env, + CLAUDE_ENV_FILE: bashEnvFile, + CLAUDE_PLUGIN_DATA: pluginDataDir + }, + input: inputJson + }); + + assert.equal(nodeResult.status, 0, nodeResult.stderr); + assert.equal(bashResult.status, 0, bashResult.stderr); + assert.equal( + fs.readFileSync(bashEnvFile, "utf8"), + fs.readFileSync(nodeEnvFile, "utf8") + ); +}); + test("session start hook exports the Claude session id and plugin data dir for later commands", () => { const repo = makeTempDir(); const envFile = path.join(makeTempDir(), "claude-env.sh");