Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 45 additions & 10 deletions scripts/local-host-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,6 @@ function assert(condition, message) {
}
}

function compactableOutput(prefix, count) {
return Array.from({ length: count }, (_, index) => `${prefix}/example-${index + 1}.json`).join("\n");
}

function postToolUsePayload(command, toolResponse) {
return `${JSON.stringify({
hook_event_name: "PostToolUse",
Expand Down Expand Up @@ -126,13 +122,28 @@ async function runCodexE2E() {
});
const report = JSON.parse(doctor.stdout);
assert(report.status === "ok", `expected Codex doctor status ok, got ${doctor.stdout}`);
const hookEnv = {
CODEX_HOME: codexHome,
// Keep the authoritative-vs-normalized assertions independent of a developer's shell env.
TOKENJUICE_NO_OMISSION: "",
};

const payload = postToolUsePayload(
"find src/rules -maxdepth 2 -type f | head -n 40",
compactableOutput("src/rules", 40),
"git status",
[
"On branch pr-65478-security-fix",
"Your branch and 'origin/pr-65478-security-fix' have diverged,",
"and have 8 and 642 different commits each, respectively.",
"",
"Changes not staged for commit:",
"\tmodified: src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts",
"\tmodified: src/agents/pi-embedded-runner/run/attempt.test.ts",
"",
"no changes added to commit",
].join("\n"),
);
const hook = await run(process.execPath, [distCliPath, "codex-post-tool-use"], {
env: { CODEX_HOME: codexHome },
env: hookEnv,
input: payload,
});

Expand All @@ -141,11 +152,35 @@ async function runCodexE2E() {
const additionalContext = output.hookSpecificOutput?.additionalContext;
assert(output.hookSpecificOutput?.hookEventName === "PostToolUse", "expected Codex PostToolUse output");
assert(typeof additionalContext === "string", "expected Codex additionalContext");
assert(additionalContext.includes("40 matches"), "expected Codex hook output to contain compacted match count");
assert(additionalContext.includes("src/rules/example-1.json"), "expected Codex hook output to include compacted paths");
assert(additionalContext.includes("tokenjuice wrap --raw -- <command>"), "expected Codex hook output to include raw rerun hint");
assert(additionalContext.includes("Changes not staged:"), "expected Codex hook output to retain status context");
assert(
additionalContext.includes("M: src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts"),
"expected Codex hook output to include compacted status paths",
);
assert(!additionalContext.includes("and have 8 and 642"), "expected Codex hook output to omit noisy branch details");
assert(
!additionalContext.includes("tokenjuice wrap --raw -- <command>"),
"expected non-authoritative Codex rewrites to avoid a raw rerun hint",
);
assert(!hook.stdout.includes("\"decision\""), "Codex hook feedback must not emit JSON decision:block output");

const authoritativeHook = await run(process.execPath, [distCliPath, "codex-post-tool-use"], {
env: hookEnv,
input: postToolUsePayload(
"git log --oneline",
Array.from(
{ length: 40 },
(_, index) => `${(index + 1).toString(16).padStart(7, "a")} feat: commit ${index}`,
).join("\n"),
),
});
const authoritativeOutput = JSON.parse(authoritativeHook.stdout);
const authoritativeContext = authoritativeOutput.hookSpecificOutput?.additionalContext;
assert(
authoritativeContext?.includes("tokenjuice wrap --raw -- <command>"),
"expected authoritative Codex omissions to retain a raw recovery hint",
);

return {
version: version.stdout.trim(),
doctor: report.status,
Expand Down
15 changes: 12 additions & 3 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1298,7 +1298,10 @@ async function runInstall(args: ParsedArgs): Promise<number> {
}

if (target === "codex") {
const result = await installCodexHook(undefined, { local: args.local });
const result = await installCodexHook(undefined, {
local: args.local,
...(args.noOmit ? { noOmit: true } : {}),
});
if (args.format === "json") {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return 0;
Expand Down Expand Up @@ -5056,7 +5059,10 @@ async function runDoctor(args: ParsedArgs): Promise<number> {
}

if (args.positionals[0] === "codex") {
const report = await doctorCodexHook(undefined, { local: args.local });
const report = await doctorCodexHook(undefined, {
local: args.local,
...(args.noOmit ? { noOmit: true } : {}),
});

if (args.format === "json") {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
Expand Down Expand Up @@ -7372,7 +7378,10 @@ async function main(argv = process.argv.slice(2)): Promise<number> {
case "stats":
return await runStats(args);
case "codex-post-tool-use":
return await runCodexPostToolUseHook(await readStdin(args.maxInputBytes));
return await runCodexPostToolUseHook(
await readStdin(args.maxInputBytes),
{ noOmit: args.noOmit },
);
case "claude-code-pre-tool-use":
return await runClaudeCodePreToolUseHook(await readStdin(args.maxInputBytes), args.wrapLauncher);
case "claude-code-post-tool-use":
Expand Down
64 changes: 51 additions & 13 deletions src/hosts/codex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import packageJson from "../../../package.json" with { type: "json" };

import { stripLeadingCdPrefix } from "../../core/command.js";
import { storeArtifactMetadata } from "../../core/artifacts.js";
import type { CompactionMetadata } from "../../core/compaction-metadata.js";
import { readNoOmissionFromEnv } from "../../core/env.js";
import { compactBashResult, getOutputAwareInspectionSkipReason } from "../../core/integrations/compact-bash-result.js";
import { classifyOnly } from "../../core/reduce.js";
import { countTextChars, stripAnsi } from "../../core/text.js";
Expand Down Expand Up @@ -100,6 +102,7 @@ export type CodexHookCommandOptions = {
local?: boolean;
binaryPath?: string;
nodePath?: string;
noOmit?: boolean;
/**
* Override for the config.toml consulted when reporting the
* `codex_hooks` feature-flag state. Defaults to `~/.codex/config.toml`.
Expand Down Expand Up @@ -370,22 +373,30 @@ async function buildCodexHookCommand(options: CodexHookCommandOptions = {}): Pro
throw new Error("unable to resolve tokenjuice binary path for codex install");
}

let command: string | undefined;
if (!options.local) {
const installedBinaryPath = await resolveInstalledTokenjuicePath();
if (installedBinaryPath) {
return `${shellQuote(installedBinaryPath)} codex-post-tool-use`;
command = `${shellQuote(installedBinaryPath)} codex-post-tool-use`;
}
}

if (binaryPath.endsWith(".js")) {
return `${shellQuote(nodePath)} ${shellQuote(binaryPath)} codex-post-tool-use`;
if (!command) {
command = binaryPath.endsWith(".js")
? `${shellQuote(nodePath)} ${shellQuote(binaryPath)} codex-post-tool-use`
: `${shellQuote(binaryPath)} codex-post-tool-use`;
}

return `${shellQuote(binaryPath)} codex-post-tool-use`;
// Codex launches hooks from its own process, which may not inherit environment variables
// loaded by the Bash tool's login shell. Snapshot no-omit into the command at install time.
return options.noOmit || readNoOmissionFromEnv() ? `${command} --no-omit` : command;
}

function getCodexFixCommand(local = false): string {
return local ? "tokenjuice install codex --local" : TOKENJUICE_CODEX_FIX_COMMAND;
function getCodexFixCommand(local = false, noOmit = false): string {
return [
local ? "tokenjuice install codex --local" : TOKENJUICE_CODEX_FIX_COMMAND,
...(noOmit ? ["--no-omit"] : []),
].join(" ");
}

async function pathExists(path: string): Promise<boolean> {
Expand Down Expand Up @@ -731,12 +742,27 @@ function commandRequestsTokenjuiceRawBypass(command: string): boolean {
return optionArgs.includes("--raw") || optionArgs.includes("--full");
}

function buildCodexFeedback(inlineText: string, rawRefId?: string): string {
function buildCodexFeedback(
inlineText: string,
rawRefId?: string,
compaction?: CompactionMetadata,
): string {
if (compaction?.authoritative !== true) {
// A rewrite without an authoritative omission marker is already usable as-is. Advertising a
// raw rerun for every normalized result trains the agent to discard compacted context even
// when the reducer did not flag recoverable detail.
return inlineText;
}

return `${inlineText}\n\n${buildCompactionHint(rawRefId)}`;
}

function buildCodexReplacementOutput(inlineText: string, rawRefId?: string): Record<string, unknown> {
const feedback = buildCodexFeedback(inlineText, rawRefId);
function buildCodexReplacementOutput(
inlineText: string,
rawRefId?: string,
compaction?: CompactionMetadata,
): Record<string, unknown> {
const feedback = buildCodexFeedback(inlineText, rawRefId, compaction);
return {
hookSpecificOutput: {
hookEventName: "PostToolUse",
Expand Down Expand Up @@ -831,8 +857,9 @@ export async function doctorCodexHook(
hooksPath = getDefaultHooksPath(),
options: CodexHookCommandOptions = {},
): Promise<CodexDoctorReport> {
const noOmit = options.noOmit || readNoOmissionFromEnv();
const expectedCommand = await buildCodexHookCommand(options);
const installFixCommand = getCodexFixCommand(options.local);
const installFixCommand = getCodexFixCommand(options.local, noOmit);
let fixCommand = installFixCommand;
const { config, exists } = await readHooksConfig(hooksPath);
const detectedCommand = findTokenjuiceCodexHookCommand(config);
Expand Down Expand Up @@ -895,7 +922,7 @@ export async function doctorCodexHook(
}
if (options.local && await detectStaleLocalBuild(checkedPaths)) {
issues.push("local Codex hook target is older than the source tree");
fixCommand = "pnpm build && tokenjuice install codex --local";
fixCommand = `pnpm build && ${getCodexFixCommand(true, noOmit)}`;
}
if (!featureFlag.enabled) {
issues.push(
Expand Down Expand Up @@ -1078,7 +1105,10 @@ async function recordImmediateHookStats(
);
}

export async function runCodexPostToolUseHook(rawText: string): Promise<number> {
export async function runCodexPostToolUseHook(
rawText: string,
options: { noOmit?: boolean } = {},
): Promise<number> {
let payload: CodexPostToolUsePayload;
try {
payload = JSON.parse(rawText) as CodexPostToolUsePayload;
Expand All @@ -1087,10 +1117,12 @@ export async function runCodexPostToolUseHook(rawText: string): Promise<number>
}

const command = payload.tool_input?.command;
const noOmit = options.noOmit || readNoOmissionFromEnv();
const debug: Record<string, unknown> = {
hookEvent: payload.hook_event_name,
toolName: payload.tool_name,
command,
noOmit,
rewrote: false,
};

Expand Down Expand Up @@ -1159,6 +1191,7 @@ export async function runCodexPostToolUseHook(rawText: string): Promise<number>
...(typeof payload.cwd === "string" && payload.cwd.trim() ? { cwd: payload.cwd } : {}),
...(typeof exitCode === "number" ? { exitCode } : {}),
...(typeof maxInlineChars === "number" ? { maxInlineChars } : {}),
...(noOmit ? { noOmit: true } : {}),
storeRaw,
metadata: {
source: "codex-post-tool-use",
Expand All @@ -1179,14 +1212,19 @@ export async function runCodexPostToolUseHook(rawText: string): Promise<number>
debug.savedChars = savedChars;
debug.ratio = result.stats.ratio;
debug.matchedReducer = result.classification.matchedReducer;
debug.compaction = result.compaction;
}

if (outcome.action === "keep") {
await writeHookDebug({ ...debug, skipped: outcome.reason });
return 0;
}

process.stdout.write(`${JSON.stringify(buildCodexReplacementOutput(outcome.result.inlineText, outcome.result.rawRef?.id))}\n`);
process.stdout.write(`${JSON.stringify(buildCodexReplacementOutput(
outcome.result.inlineText,
outcome.result.rawRef?.id,
outcome.result.compaction,
))}\n`);
await writeHookDebug({ ...debug, rewrote: true });
return 0;
} catch (error) {
Expand Down
1 change: 1 addition & 0 deletions test/cli/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe("parseArgs", () => {
it("parses --no-omit for reduce and wrap", () => {
expect(parseArgs(["reduce", "--no-omit"]).noOmit).toBe(true);
expect(parseArgs(["wrap", "--no-omit", "--", "echo", "hi"]).noOmit).toBe(true);
expect(parseArgs(["codex-post-tool-use", "--no-omit"]).noOmit).toBe(true);
});
});

Expand Down
Loading