Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ Example:
- `once` (optional): boolean. Run the hook once per session, then remove it. Forwarded to Claude Code (honored in skill frontmatter; accepted but ignored in settings files) and Qwen Code http hooks.
- `continueOnBlock` (optional): boolean. Feed a blocking hook's rejection reason back to the model and continue the turn instead of ending it. Forwarded to Claude Code.
- `commandWindows` (optional): a Windows-only override for `command`, so one hook set can be cross-platform. Forwarded to Codex CLI command hooks (`.codex/hooks.json`), which is the only tool that accepts it.
- `additionalContextLimit` (optional): a non-negative integer. The token threshold above which the tool writes the hook's additional context to a file and passes that path instead of the text itself (upstream default 2500). Forwarded to Codex CLI command hooks (`.codex/hooks.json`), which is the only tool that accepts it.
- `statusMessage` (optional): the progress text shown while the hook runs. Forwarded to Qwen Code (command and http hooks) and to Codex CLI command hooks.
- `if` (optional): a single permission rule (same syntax as `settings.json` permission rules, e.g. `"Bash(rm *)"`) that filters a hook by tool arguments in addition to the tool name. Forwarded to Claude Code, where it is evaluated only on tool events (`preToolUse`, `postToolUse`, `postToolUseFailure`, `permissionRequest`, `permissionDenied`); it round-trips as an opaque string.

Expand Down
115 changes: 115 additions & 0 deletions src/features/hooks/codexcli-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,84 @@ describe("CodexcliHooks", () => {
expect(hook.statusMessage).toBe("Saving notes");
});

it("should emit additionalContextLimit", async () => {
// The token threshold above which Codex saves the hook's additional
// context to a file and passes the path instead of the text (default
// 2500). https://learn.chatgpt.com/docs/hooks
const rulesyncHooks = new RulesyncHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
sessionStart: [{ command: "./scripts/context.sh", additionalContextLimit: 8000 }],
},
}),
}),
);

const codexHooks = await CodexcliHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks,
validate: true,
});

const hook = JSON.parse(codexHooks.getFileContent()).hooks.SessionStart[0].hooks[0];
expect(hook.additionalContextLimit).toBe(8000);
});

it("should emit a zero additionalContextLimit and drop a non-numeric one", async () => {
// Zero is meaningful (always spill to a file), so unlike the string
// passthrough's empty-string rule it must survive. A non-numeric value —
// `null` is what JSON.stringify writes for a non-finite number — is
// dropped rather than emitted into a file Codex would reject.
const rulesyncHooks = new RulesyncHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
sessionStart: [
{ command: "./scripts/zero.sh", additionalContextLimit: 0 },
{ command: "./scripts/bad.sh", additionalContextLimit: null },
],
},
}),
validate: false,
}),
);

const codexHooks = await CodexcliHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks,
validate: true,
});

const hooks = JSON.parse(codexHooks.getFileContent()).hooks.SessionStart[0].hooks;
expect(hooks[0].additionalContextLimit).toBe(0);
expect(hooks[1]).not.toHaveProperty("additionalContextLimit");
});

it("should round-trip additionalContextLimit back to canonical", async () => {
const rulesyncHooks = new RulesyncHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
sessionStart: [{ command: "./scripts/context.sh", additionalContextLimit: 8000 }],
},
}),
}),
);

const codexHooks = await CodexcliHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks,
validate: true,
});

expect(codexHooks.toRulesyncHooks().getJson().hooks.sessionStart?.[0]).toEqual({
type: "command",
command: "./scripts/context.sh",
additionalContextLimit: 8000,
});
});

it("should convert subagentStart, subagentStop, and preCompact to PascalCase", async () => {
const rulesyncHooks = new RulesyncHooks(
createMockAiFileParams({
Expand Down Expand Up @@ -362,6 +440,43 @@ describe("CodexcliHooks", () => {
});
});

it("should import additionalContextLimit and ignore a non-numeric one", () => {
const codexHooks = new CodexcliHooks(
createMockAiFileParams({
relativeDirPath: ".codex",
relativeFilePath: "hooks.json",
fileContent: JSON.stringify({
hooks: {
SessionStart: [
{
hooks: [
{
type: "command",
command: "./scripts/context.sh",
additionalContextLimit: 8000,
},
{
type: "command",
command: "./scripts/other.sh",
additionalContextLimit: "8000",
},
],
},
],
},
}),
}),
);

const imported = codexHooks.toRulesyncHooks().getJson().hooks.sessionStart;
expect(imported?.[0]).toEqual({
type: "command",
command: "./scripts/context.sh",
additionalContextLimit: 8000,
});
expect(imported?.[1]).toEqual({ type: "command", command: "./scripts/other.sh" });
});

it("should convert Codex CLI format to canonical format", () => {
const codexHooks = new CodexcliHooks(
createMockAiFileParams({
Expand Down
7 changes: 7 additions & 0 deletions src/features/hooks/codexcli-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ const CODEXCLI_CONVERTER_CONFIG: ToolHooksConverterConfig = {
{ canonical: "commandWindows", tool: "commandWindows" },
{ canonical: "statusMessage", tool: "statusMessage" },
],
// `additionalContextLimit` is the token threshold above which Codex saves the
// hook's additional context to a file and passes the path instead of the text
// (default 2500). Per-handler field of `.codex/hooks.json`, same name on
// either side. https://learn.chatgpt.com/docs/hooks
numberPassthroughFields: [
{ canonical: "additionalContextLimit", tool: "additionalContextLimit" },
],
};

/**
Expand Down
61 changes: 60 additions & 1 deletion src/features/hooks/tool-hooks-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ export type ToolHooksConverterConfig = {
/** Emit only on `command` hooks, for a field the tool documents there only. */
readonly commandOnly?: boolean;
}>;
/**
* Per-hook number fields to carry through the round-trip, each mapping a
* canonical {@link HookDefinitionSchema} number field to its tool-side field
* name. Only finite numbers are emitted on export and imported back, so a
* `NaN`/`Infinity` (which JSON cannot represent) or a numeric string can't
* leak into a config the tool would reject. Any narrower constraint (an
* integer, a non-negative one) belongs on the canonical field's schema, which
* is what an authored value is validated against.
*/
numberPassthroughFields?: ReadonlyArray<{
readonly canonical: "additionalContextLimit";
readonly tool: string;
/** Emit only on `command` hooks, for a field the tool documents there only. */
readonly commandOnly?: boolean;
}>;
/**
* Per-hook string fields to carry through the round-trip, each mapping a
* canonical {@link HookDefinitionSchema} string field to its tool-side field
Expand Down Expand Up @@ -281,6 +296,48 @@ function importBooleanPassthroughFields({
);
}

/**
* Emit the configured number passthrough fields on the tool side, mapping each
* canonical field name to its (possibly renamed) tool field name. Only finite
* numbers are carried through.
*/
function emitNumberPassthroughFields({
def,
hookType,
converterConfig,
}: {
def: HooksConfig["hooks"][string][number];
hookType: HookType;
converterConfig: ToolHooksConverterConfig;
}): Record<string, number> {
return Object.fromEntries(
(converterConfig.numberPassthroughFields ?? [])
.filter(({ canonical, commandOnly }) => {
if (commandOnly === true && hookType !== "command") return false;
return Number.isFinite(def[canonical]);
})
.map(({ canonical, tool }) => [tool, def[canonical] as number]),
);
}

/**
* Import the configured number passthrough fields back into canonical fields,
* reversing {@link emitNumberPassthroughFields}. Only finite numbers are read.
*/
function importNumberPassthroughFields({
h,
converterConfig,
}: {
h: Record<string, unknown>;
converterConfig: ToolHooksConverterConfig;
}): Record<string, number> {
return Object.fromEntries(
(converterConfig.numberPassthroughFields ?? [])
.filter(({ tool }) => Number.isFinite(h[tool]))
.map(({ canonical, tool }) => [canonical, h[tool] as number]),
);
}

/**
* Emit the configured string passthrough fields on the tool side, mapping each
* canonical field name to its (possibly renamed) tool field name. Only non-empty
Expand Down Expand Up @@ -513,10 +570,11 @@ function buildToolHooks({
}
const command = applyCommandPrefix({ def, converterConfig });
hooks.push({
// Spread the boolean and string passthrough fields first so the
// Spread the boolean, number and string passthrough fields first so the
// explicitly-handled core fields below always win: a misconfigured `tool`
// name (e.g. mapping onto "type"/"command") can never silently shadow them.
...emitBooleanPassthroughFields({ def, hookType, converterConfig }),
...emitNumberPassthroughFields({ def, hookType, converterConfig }),
...emitStringPassthroughFields({ def, hookType, converterConfig }),
...emitArrayPassthroughFields({ def, hookType, converterConfig }),
type: hookType,
Expand Down Expand Up @@ -748,6 +806,7 @@ function toolHookToCanonical({
...(converterConfig.passthroughFields?.includes("description") &&
typeof h.description === "string" && { description: h.description }),
...importBooleanPassthroughFields({ h, converterConfig }),
...importNumberPassthroughFields({ h, converterConfig }),
...importStringPassthroughFields({ h, converterConfig }),
...importArrayPassthroughFields({ h, converterConfig, logger }),
...importGroupPassthroughFields({ rawEntry, converterConfig }),
Expand Down
2 changes: 1 addition & 1 deletion src/generated/docs-content.ts

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions src/types/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ export const HookDefinitionSchema = z.looseObject({
// `.codex/hooks.json`, which is the file rulesync writes.
// https://learn.chatgpt.com/docs/hooks
commandWindows: z.optional(safeString),
// Codex CLI command hooks: the token threshold above which Codex writes the
// hook's additional context to a file and passes the path instead of the
// text itself. Defaults to 2500 upstream. Constrained to a non-negative
// integer because a token count is one: a fractional or negative value would
// be emitted verbatim and could make Codex reject the whole hooks file.
// https://learn.chatgpt.com/docs/hooks
additionalContextLimit: z.optional(z.int().check(nonnegative())),
// Claude Code command hooks: `asyncRewake` runs the hook in the background
// and wakes Claude on exit code 2 (it implies `async`).
// https://code.claude.com/docs/en/hooks
Expand Down
23 changes: 16 additions & 7 deletions src/types/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -795,13 +795,22 @@ const CodexBasePermissionProfileSchema = z.enum(CODEX_BASE_PERMISSION_PROFILES);
* `base_permission_profile` it is consumed by the profile builder, not
* written as a top-level config key.
*
* Two surfaces are deliberately NOT authorable here so the override can never
* clobber a feature-owned key: `mcp_servers.*` per-MCP gating is owned by the
* MCP feature (`codexcli-mcp.ts` already writes the `mcp_servers` tables in the
* same `config.toml`), and `permissions` / `default_permissions` are owned by
* the canonical model. Any such key placed in the override is skipped with a
* warning. Kept `looseObject` (verbatim passthrough) so future top-level Codex
* config keys can be authored without Rulesync modeling each one.
* The keys written to `config.toml` are an **allowlist**, not verbatim
* passthrough: only `CODEXCLI_OVERRIDE_KEYS`
* (`src/constants/codexcli-paths.ts` — `approval_policy`, `sandbox_mode`,
* `sandbox_workspace_write`, `apps`, `approvals_reviewer`) are emitted, and
* `computeCodexcliOverridePatch` skips anything else with a warning.
* `base_permission_profile` and `git_write_rules` are consumed by the profile
* builder rather than written, as described above, and `permission` is the
* tool-scoped canonical block, which `RulesyncPermissions.forTarget` strips out
* of the override before it ever reaches the patch. The allowlist is what keeps
* the override from clobbering a feature-owned key: `mcp_servers.*` per-MCP
* gating is owned by the MCP feature (`codexcli-mcp.ts` already writes the
* `mcp_servers` tables in the same `config.toml`), and `permissions` /
* `default_permissions` are owned by the canonical model. The schema itself is
* `looseObject` so an unmodeled key parses (and is then reported rather than
* rejected outright); supporting a new top-level Codex config key means adding
* it to `CODEXCLI_OVERRIDE_KEYS`.
*
* @see https://developers.openai.com/codex/config-reference
* @see https://developers.openai.com/codex/permissions
Expand Down
Loading