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
31 changes: 31 additions & 0 deletions plugins/codex-utilities/docs/thread-title-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,42 @@ The proposed name is currently the last path component of `cwd`, truncated to
`CODEX_UTILITIES_THREAD_TITLE_MAX_PREFIX_LENGTH` characters. The default maximum
is `48`.

Projectless Codex chat directories are treated specially. If `cwd` appears under
the default Codex chat root:

```text
~/Documents/Codex/YYYY-MM-DD/<thread-directory>
```

the hook skips prefixing by default so projectless chats keep Codex's generated
title. Override the root with `CODEX_UTILITIES_PROJECTLESS_ROOT`. To opt into a
shared projectless prefix such as `Chat`, set
`CODEX_UTILITIES_PROJECTLESS_THREAD_PREFIX`.

The thread id candidate is read from `thread_id`, `threadId`, `session_id`, then
`sessionId`. Current Codex hook docs describe `session_id`; keep rename mode
disabled by default until a live GUI new-thread test confirms that value is the
same id accepted by `thread/name/set`.

A live projectless thread created after trusting the plugin hook produced this
`SessionStart` payload shape:

```json
{
"session_id": "019e9e4e-e0c5-7591-ac1d-51c09ef83faa",
"transcript_path": "~/.codex/sessions/2026/06/06/rollout-2026-06-06T15-00-30-019e9e4e-e0c5-7591-ac1d-51c09ef83faa.jsonl",
"cwd": "~/Documents/Codex/2026-06-06/codex-utilities-projectless-hook-test",
"hook_event_name": "SessionStart",
"model": "gpt-5.5",
"permission_mode": "default",
"source": "startup"
}
```

The payload did not include an explicit saved-project or projectless marker, so
the projectless rule is path-based until Codex exposes richer thread metadata to
hooks.

## Rename Transport

The preferred next transport is Codex App Server `thread/name/set`. The
Expand Down
69 changes: 59 additions & 10 deletions plugins/codex-utilities/scripts/session-start-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,16 @@ async function handleThreadTitle(payload, config) {
};
}

const prefix = titlePrefixFromPayload(payload, config);
if (!prefix) {
const prefixPlan = titlePrefixPlanFromPayload(payload, config);
if (!prefixPlan.prefix) {
return {
...base,
action: "skipped",
reason: "SessionStart payload did not include a usable cwd for title prefixing.",
reason: prefixPlan.reason,
};
}

const proposedName = prefix;
const proposedName = prefixPlan.prefix;
const planned = { ...base, action: "planned", threadId, proposedName };
if (config.mode === "dry-run") {
return planned;
Expand Down Expand Up @@ -84,6 +84,12 @@ function readConfig() {
process.env.CODEX_UTILITIES_THREAD_TITLE_MAX_PREFIX_LENGTH,
48,
);
const projectlessRoot =
process.env.CODEX_UTILITIES_PROJECTLESS_ROOT ??
path.join(os.homedir(), "Documents", "Codex");
const projectlessThreadPrefix = optionalTrimmedStringFromEnv(
process.env.CODEX_UTILITIES_PROJECTLESS_THREAD_PREFIX,
);
const timeoutMs = positiveIntegerFromEnv(
process.env.CODEX_UTILITIES_APP_SERVER_TIMEOUT_MS,
1500,
Expand All @@ -96,6 +102,8 @@ function readConfig() {
mode,
payloadLogPath: path.join(dataDir, "session-start.jsonl"),
pluginVersion: readPluginVersion(pluginRoot),
projectlessRoot,
projectlessThreadPrefix,
socketPath,
timeoutMs,
};
Expand Down Expand Up @@ -127,6 +135,14 @@ function positiveIntegerFromEnv(rawValue, fallback) {
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}

function optionalTrimmedStringFromEnv(rawValue) {
if (typeof rawValue !== "string") {
return null;
}
const trimmed = rawValue.trim();
return trimmed ? trimmed : null;
}

function threadIdFromPayload(payload) {
for (const key of ["thread_id", "threadId", "session_id", "sessionId"]) {
if (typeof payload[key] === "string" && payload[key].trim()) {
Expand All @@ -136,17 +152,50 @@ function threadIdFromPayload(payload) {
return null;
}

function titlePrefixFromPayload(payload, config) {
function titlePrefixPlanFromPayload(payload, config) {
if (typeof payload.cwd !== "string") {
return null;
return {
prefix: null,
reason: "SessionStart payload did not include a usable cwd for title prefixing.",
};
}
if (isProjectlessCodexChatCwd(payload.cwd, config.projectlessRoot)) {
if (config.projectlessThreadPrefix) {
return {
prefix: truncateTitlePrefix(config.projectlessThreadPrefix, config.maxPrefixLength),
reason: null,
};
}
return {
prefix: null,
reason:
"SessionStart cwd looks like a projectless Codex chat directory, and no projectless title prefix is configured.",
};
}
const prefix = path.basename(payload.cwd).replace(/\s+/g, " ").trim();
if (!prefix) {
return null;
return {
prefix: null,
reason: "SessionStart payload cwd did not include a usable final path component.",
};
}
return {
prefix: truncateTitlePrefix(prefix, config.maxPrefixLength),
reason: null,
};
}

function isProjectlessCodexChatCwd(cwd, projectlessRoot) {
const relativePath = path.relative(path.resolve(projectlessRoot), path.resolve(cwd));
if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
return false;
}
return prefix.length > config.maxPrefixLength
? prefix.slice(0, config.maxPrefixLength).trim()
: prefix;
const [datePart] = relativePath.split(path.sep);
return /^\d{4}-\d{2}-\d{2}$/.test(datePart);
}

function truncateTitlePrefix(prefix, maxPrefixLength) {
return prefix.length > maxPrefixLength ? prefix.slice(0, maxPrefixLength).trim() : prefix;
}

async function setThreadName({ socketPath, threadId, name, timeoutMs }) {
Expand Down