From 139471df311c7d646771af0c08944cb102cbbfeb Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 10:32:43 +0000 Subject: [PATCH] Add portable Codex project skills --- .codex/skills/codex-thread-cwd/SKILL.md | 48 ++++++ .../codex-thread-cwd/agents/openai.yaml | 4 + .../scripts/set_thread_cwd.py | 149 ++++++++++++++++++ .../skills/thread-naming/agents/openai.yaml | 4 + 4 files changed, 205 insertions(+) create mode 100644 .codex/skills/codex-thread-cwd/SKILL.md create mode 100644 .codex/skills/codex-thread-cwd/agents/openai.yaml create mode 100755 .codex/skills/codex-thread-cwd/scripts/set_thread_cwd.py create mode 100644 .codex/skills/thread-naming/agents/openai.yaml diff --git a/.codex/skills/codex-thread-cwd/SKILL.md b/.codex/skills/codex-thread-cwd/SKILL.md new file mode 100644 index 0000000..bf250a4 --- /dev/null +++ b/.codex/skills/codex-thread-cwd/SKILL.md @@ -0,0 +1,48 @@ +--- +name: codex-thread-cwd +description: Change a Codex thread working directory/cwd on the Codex side, not in downstream tool databases. Use when asked to change, move, fix, align, or sync a Codex thread workspace/cwd, especially for app-server threads or client integrations where dependent tools should sync from Codex afterward. +--- + +# Codex Thread CWD + +Use this skill to change a Codex thread's stored working directory (`cwd`) in Codex state, then let clients such as a Telegram bot sync from Codex. Do not edit downstream client databases as the source of truth. + +## Required Inputs + +- Target `thread_id`, usually a Codex UUID. If the user refers to a client-specific current thread, inspect that client's state only to discover the Codex `thread_id`; do not edit that client state unless explicitly asked. +- Target absolute `cwd`. Do not assume a machine-specific path; derive it from the user's request, the current repo, or an explicit argument. + +## Preferred Workflow + +1. Resolve the target thread ID. + - For this repo's Telegram bot, read its configured SQLite DB only to discover `sessions.active_thread_id -> threads.codex_thread_id` for the relevant Telegram user. + - If ambiguous, list candidates and ask the user to choose. +2. Confirm the target cwd is absolute and exists, unless the user intentionally wants a path that does not exist yet. +3. Run the bundled script from the skill directory: + +```bash +python3 path/to/codex-thread-cwd/scripts/set_thread_cwd.py THREAD_ID /absolute/new/cwd +``` + +Use `--codex-home`, `--state-db`, or `CODEX_HOME` when the Codex state is not under the current user's default Codex home. + +4. Verify Codex-side state, preferably with app-server `thread/read` if a socket is available. DB-level verification from the script is acceptable when app-server is unavailable. +5. Trigger the dependent tool's normal sync path, such as asking a bot for `/workspace` or `/threads`. Do not manually insert or update downstream workspace rows unless the user explicitly asks for emergency repair. + +## Notes + +- The app-server schema exposes `cwd` on `thread/resume`, `thread/fork`, and `turn/start`; `thread/metadata/update` does not patch `cwd`. +- In observed behavior, `thread/resume` with `cwd` may return the original cwd and not mutate existing stored thread cwd. +- `turn/start.cwd` is schema-supported as an override for that turn and subsequent turns, but it starts a new turn. +- The proven no-turn approach is to update Codex's own `state_*.sqlite` `threads.cwd` row and the first `session_meta.payload.cwd` line in the rollout JSONL. This is Codex-side state, not downstream client state. +- The script preserves a rollout backup by default; it writes `.bak.` unless `--no-backup` is passed. + +## Verification Examples + +Inspect Codex state directly: + +```bash +python3 path/to/codex-thread-cwd/scripts/set_thread_cwd.py THREAD_ID /absolute/new/cwd --verify-only +``` + +Read through app-server and check `result.thread.cwd` equals the requested path. diff --git a/.codex/skills/codex-thread-cwd/agents/openai.yaml b/.codex/skills/codex-thread-cwd/agents/openai.yaml new file mode 100644 index 0000000..8f9b751 --- /dev/null +++ b/.codex/skills/codex-thread-cwd/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Codex Thread CWD" + short_description: "Change Codex thread cwd safely." + default_prompt: "Change the active Codex thread cwd/workspace to the requested directory, then verify app-server or Codex state reports it." diff --git a/.codex/skills/codex-thread-cwd/scripts/set_thread_cwd.py b/.codex/skills/codex-thread-cwd/scripts/set_thread_cwd.py new file mode 100755 index 0000000..24bed31 --- /dev/null +++ b/.codex/skills/codex-thread-cwd/scripts/set_thread_cwd.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Set a Codex app-server thread cwd in Codex-side state. + +Updates: +- /state_*.sqlite threads.cwd for the thread id +- the rollout JSONL first session_meta payload.cwd, using rollout_path from state DB + +This intentionally does not touch downstream client databases. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sqlite3 +import tempfile +import time +from pathlib import Path + + +def codex_home(explicit: str | None) -> Path: + if explicit: + return Path(explicit).expanduser() + return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex").expanduser() + + +def find_state_db(home: Path, explicit: str | None) -> Path: + if explicit: + path = Path(explicit).expanduser() + if not path.exists(): + raise SystemExit(f"state DB does not exist: {path}") + return path + candidates = sorted(home.glob("state_*.sqlite"), key=lambda p: p.stat().st_mtime, reverse=True) + if not candidates: + raise SystemExit(f"no state_*.sqlite found under {home}; pass --codex-home or --state-db") + return candidates[0] + + +def validate_cwd(cwd: str, require_exists: bool) -> str: + path = Path(cwd).expanduser() + if not path.is_absolute(): + raise SystemExit(f"cwd must be absolute: {cwd}") + clean = str(path.resolve() if path.exists() else path) + if clean == "/": + raise SystemExit("refusing to set cwd to filesystem root") + if require_exists and not Path(clean).is_dir(): + raise SystemExit(f"cwd is not an existing directory: {clean}") + return clean + + +def read_thread(conn: sqlite3.Connection, thread_id: str) -> sqlite3.Row: + conn.row_factory = sqlite3.Row + row = conn.execute( + "select id, cwd, rollout_path, updated_at, updated_at_ms from threads where id = ?", + (thread_id,), + ).fetchone() + if row is None: + raise SystemExit(f"thread not found in Codex state DB: {thread_id}") + return row + + +def update_state_db(db: Path, thread_id: str, cwd: str) -> sqlite3.Row: + now_ms = int(time.time() * 1000) + now_s = now_ms // 1000 + conn = sqlite3.connect(str(db), timeout=10) + try: + changed = conn.execute( + "update threads set cwd = ?, updated_at = ?, updated_at_ms = ? where id = ?", + (cwd, now_s, now_ms, thread_id), + ).rowcount + if changed != 1: + raise SystemExit(f"updated {changed} Codex thread rows, expected 1") + conn.commit() + return read_thread(conn, thread_id) + finally: + conn.close() + + +def update_rollout(path: Path, thread_id: str, cwd: str, backup: bool) -> None: + if not path.exists(): + raise SystemExit(f"rollout JSONL does not exist: {path}") + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + if not lines: + raise SystemExit(f"rollout JSONL is empty: {path}") + first = json.loads(lines[0]) + payload = first.get("payload") or {} + if first.get("type") != "session_meta" or payload.get("id") != thread_id: + raise SystemExit(f"first rollout line is not session_meta for {thread_id}: {path}") + payload["cwd"] = cwd + first["payload"] = payload + lines[0] = json.dumps(first, separators=(",", ":")) + "\n" + + if backup: + backup_path = path.with_name(path.name + f".bak.{int(time.time())}") + shutil.copy2(path, backup_path) + print(f"backup: {backup_path}") + + fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", text=True) + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.writelines(lines) + os.chmod(tmp, path.stat().st_mode & 0o777) + os.replace(tmp, path) + finally: + if tmp.exists(): + tmp.unlink() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Set Codex thread cwd in Codex-side state") + parser.add_argument("thread_id") + parser.add_argument("cwd") + parser.add_argument("--codex-home", help="Codex home directory; defaults to CODEX_HOME or ~/.codex") + parser.add_argument("--state-db", help="Path to Codex state_*.sqlite; defaults to newest under Codex home") + parser.add_argument("--allow-missing-cwd", action="store_true", help="Allow cwd path that does not exist yet") + parser.add_argument("--no-backup", action="store_true", help="Do not create rollout JSONL backup") + parser.add_argument("--verify-only", action="store_true", help="Only print current Codex cwd for thread") + args = parser.parse_args() + + home = codex_home(args.codex_home) + db = find_state_db(home, args.state_db) + cwd = validate_cwd(args.cwd, require_exists=not args.allow_missing_cwd) + + conn = sqlite3.connect(str(db), timeout=10) + try: + row = read_thread(conn, args.thread_id) + finally: + conn.close() + + rollout = Path(row["rollout_path"]).expanduser() + print(f"state_db: {db}") + print(f"thread_id: {args.thread_id}") + print(f"before_cwd: {row['cwd']}") + print(f"rollout: {rollout}") + + if args.verify_only: + return 0 if row["cwd"] == cwd else 1 + + after = update_state_db(db, args.thread_id, cwd) + update_rollout(rollout, args.thread_id, cwd, backup=not args.no_backup) + print(f"after_cwd: {after['cwd']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.codex/skills/thread-naming/agents/openai.yaml b/.codex/skills/thread-naming/agents/openai.yaml new file mode 100644 index 0000000..1bdf145 --- /dev/null +++ b/.codex/skills/thread-naming/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Thread Naming" + short_description: "Choose concise Codex thread names." + default_prompt: "Rename the current thread with a short useful title that matches the current task."