diff --git a/.github/workflows/title-routing.yml b/.github/workflows/title-routing.yml new file mode 100644 index 0000000..3438424 --- /dev/null +++ b/.github/workflows/title-routing.yml @@ -0,0 +1,27 @@ +name: Title routing + +on: + pull_request: + paths: + - "skills/estack-better-title/**" + - "tests/estack-better-title/**" + - ".github/workflows/title-routing.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: "24" + - run: node --test tests/estack-better-title/test-routing.cjs diff --git a/AGENTS.md b/AGENTS.md index bc75249..9cdf62c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ Two things I care enough about to name explicitly: publishing and syncing. Publi | `docs/manual-publish.md` | Publishing manually from the CLI when GitHub Actions are unavailable — prerequisite settings to re-enable, token bypass, cleanup checklist | | `docs/changelog-maintenance.md` | Updating `CHANGELOG.md` — when and how to write unreleased entries, how to promote them on publish | | `docs/research/codex-claude-cli-orchestration-findings.md` | Editing `estack-drive-cli-agent` — the full research record behind it (CLI flag semantics, sourced footguns, openai-codex plugin failure autopsy, prior art) | +| `docs/research/codex-session-titles-findings.md` | Editing `estack-better-title`, or making any skill work in both Claude Code and Codex — host detection, the `codex app-server` rename protocol, and what Codex does and doesn't support in SKILL.md | - **Skills in the pack:** `estack-active-learning-tutor`, `estack-better-title`, `estack-book-extractor`, `estack-chris-voss`, `estack-claude-md-optimizer`, `estack-cold-message-writer`, `estack-customer-discovery`, `estack-doc-review-viewer`, `estack-drive-cli-agent`, `estack-email-writer`, `estack-flight-planner`, `estack-github-issue-tracker`, `estack-leadership-coach`, `estack-migrate-claude-session-history`, `estack-notify`, `estack-pdf-to-md`, `estack-pr-description`, `estack-productivity-prioritization-coach`, `estack-prompt-builder-coach`, `estack-purdue-stickers`, `estack-read-agent-history`, `estack-repo-search`, `estack-sponsorship-offer-builder`, `estack-vscode-file-recovery` - **Hooks in the pack:** `estack-claude-startup.js`, `estack-codex-startup.js`, `estack-startup-update-core.js`, `estack-statusline.js`, `repo-search-nudge.js` diff --git a/CHANGELOG.md b/CHANGELOG.md index f95fa65..c95108d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- `estack-better-title` skill (2.0.2) — detects Claude Code or Codex at startup and routes title reads and renames through one entry point. Codex uses its app-server API; Claude retains append-only transcript updates. Hosts without a choice tool use numbered title options in chat. + +### Fixed +- `estack-better-title` skill — requests host-tool approval for Codex startup and rename when `CODEX_HOME` is outside sandbox write roots, and explains permission failures without changing homes or retrying another transport. +- `estack-better-title` skill — recognizes Claude Agent SDK session ids and explains unknown hosts, nested hosts, missing or conflicting ids, and backend read failures. Host overrides retain the safeguard against renaming another session. +- `estack-better-title` skill — preserves Unicode and literal title whitespace, keeps startup errors visible to the agent, and configures explicit invocation for Codex. + --- ## [1.1.1] - 2026-10-19 diff --git a/README.md b/README.md index b47dcb8..accdc88 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ This installs skills to `~/.agents/skills/` and symlinks them into `~/.claude/sk | Skill | Command | Description | |-------|---------|-------------| | **Active Learning Tutor** | `/estack-active-learning-tutor` | Tutors a student through exam preparation using active learning — questioning, gap diagnosis, and concept mastery tracking | -| **Better Title** | `/estack-better-title` | Renames Claude Code chat sessions — a short subject you can skim, plus project and keywords you can grep | +| **Better Title** | `/estack-better-title` | Renames Claude Code or Codex chat sessions — a short subject you can skim, plus project and keywords you can grep | | **Book Extractor** | `/estack-book-extractor` | Turns a book or doc set (PDF, EPUB, DOCX, HTML, RTF, TXT/MD) into a standalone, on-demand Agent Skill — deterministic text extraction, then per-chapter reference files, glossary, cheatsheet, and a master index generated by the agent, installed outside the e-stack pack | | **Chris Voss** | `/estack-chris-voss` | Applies negotiation principles from *Never Split the Difference* when preparing to negotiate with an outside counterpart; hard conversations with your own team go to Leadership Coach, and the message itself to Email Writer or Cold Message Writer | | **CLAUDE.md Optimizer** | `/estack-claude-md-optimizer` | Creates, audits, and maintains concise CLAUDE.md / AGENTS.md instructions, preserving project conventions and the chosen canonical file while applying authorized edits | diff --git a/docs/research/codex-session-titles-findings.md b/docs/research/codex-session-titles-findings.md new file mode 100644 index 0000000..b60c164 --- /dev/null +++ b/docs/research/codex-session-titles-findings.md @@ -0,0 +1,200 @@ +# Codex session titles — how the rename actually works + +The research record behind `estack-better-title` 2.0.2, which adds Codex support. +The original observations below came from codex-cli 0.153.4 on Windows +(2026-09-06). They are historical unless explicitly reverified below. + +## Current audit — 2026-10-19 + +Verified with **codex-cli 0.162.1 on Windows**: + +- Generated the installed app-server JSON schema. `thread/read` accepts + `threadId` and `includeTurns`; its thread response has a nullable `name`. +- Started an app-server with a disposable `CODEX_HOME`, created a throwaway + thread without a model turn, then round-tripped `thread/name/set` and + `thread/read`. The skill helper also read, renamed, and read that thread + through separate server processes. No existing user chat was renamed. +- Both `CODEX_THREAD_ID` and `CODEX_SESSION_ID` are present in this host. +- `node --test tests/estack-better-title/test-routing.cjs` verifies Claude + transcript writes in temporary homes, host detection, nested overrides, + stale-id rejection, and a simulated Codex protocol over real process pipes. + +The audit supersedes two old assumptions: a host override must never disable +session-id validation, and finding frontmatter strings in a binary does not +prove that a host enforces those permissions. Permission behavior and UI repaint +latency were not runtime-tested. Claude was tested against disposable transcript +fixtures, not an active Claude UI. The proxy path was simulated; only the private +stdio transport was exercised against the real Codex binary. + +The current implementation stops on backend read errors instead of treating a +missing thread or malformed response as an unnamed session. It preserves split +UTF-8 output and literal title whitespace. A nested Claude marker without an id +still counts as an ambiguous host, and conflicting id aliases are rejected. + + +Current official docs were fetched on 2026-10-19: + +- [Claude Code skill context injection](https://code.claude.com/docs/en/skills#inject-dynamic-context) + documents both inline and fenced shell blocks. A nonzero injected command + aborts skill loading, so the entry block preserves the diagnostic as text and + prints `SESSION_CHECK_FAILED` while allowing the agent to explain it. Direct + calls to `session.sh` retain their nonzero exit codes. +- [Codex skill invocation policy](https://learn.chatgpt.com/docs/build-skills) + documents `agents/openai.yaml` with `policy.allow_implicit_invocation: false`. + This supplies the Codex equivalent of the Claude-only + `disable-model-invocation` setting. + +## Sandbox approval boundary + +The real-app-server round trip above was run through an approved escalated shell +with a disposable home. It did not establish that a private server can start +inside a workspace-only sandbox with the user's ordinary `CODEX_HOME`. +[Codex issue 52759](https://github.com/openai/codex/issues/52759), fetched during +the PR review on 2026-10-19, reports readonly startup database and operation-not- +permitted failures from a nested CLI. It corroborates this limitation rather +than a title-specific reproduction. + +The skill now instructs the agent to request host-tool approval before startup +and rename when `CODEX_HOME` is outside writable roots. Permission-denied server +output produces `APPROVAL_REQUIRED`; it does not fall through to another +transport. The script cannot grant approval itself. A regression fixture +verifies this diagnostic and stop behavior; a denied real user home was not +modified to manufacture a reproduction. + +## Host detection + +Codex exports both of these into every shell it runs a command in, set to the +same value: + +``` +CODEX_SESSION_ID=01a0773b-bc21-7420-8afe-9945c2309c91 +CODEX_THREAD_ID=01a0773b-bc21-7420-8afe-9945c2309c91 +``` + +Claude Code exports `CLAUDE_CODE_SESSION_ID` and `CLAUDECODE=1`. It does **not** +always export `CLAUDE_SESSION_ID` — under the Claude Agent SDK (which is what +third-party harnesses embed) only `CLAUDE_CODE_SESSION_ID` is present. The old +skill read `${CLAUDE_SESSION_ID}` via SKILL.md interpolation, which is why the +id could come back blank there. + +Host variables identify the session in ordinary tool shells. When one CLI drives the other (`codex exec` from Claude Code, or +`claude -p` from Codex), the inner agent inherits the outer agent's environment +and both sets are visible at once. There is no reliable way to tell which is the +inner one from the environment alone, so `lib/host.sh` treats that as an error +and asks rather than guessing. + +## Historical storage observations (not reverified in the current audit) + +`~/.codex/state_.sqlite`, table `threads`, and there are two relevant columns: + +| Column | What it is | +|---|---| +| `title` | Falls back to the first user message. Not the user-facing name. | +| `name` | The user-set thread name. `NULL` until something sets it. This is the one. | + +The `` in the filename is a schema generation that Codex bumps +(`state_5.sqlite` at the time of writing), and the table gained columns as +recently as this version — `name`, `is_pinned`, `project_id`, and +`thread_section_id` all sit after the original block. `~/.codex/session_index.jsonl` +(`{id, thread_name, updated_at}`) is a **legacy** index, last written 2026-08-29 +on this machine; do not write it. + +**Do not write the database.** A live Codex process owns it, the filename moves +with the schema generation, and the columns are not a stable interface. Use the +protocol below instead. + +## The protocol + +`codex app-server` speaks newline-delimited JSON-RPC. The relevant methods: + +| Method | Params | Notes | +|---|---|---| +| `initialize` | `{clientInfo: {name, version}}` | Must be first. Reply carries `codexHome`, platform info. | +| `initialized` | — | A notification, not a request. Send it before anything else. | +| `thread/read` | `{threadId, includeTurns: false}` | Returns `{thread}` with `id` and `name`, straight from the state DB. Unknown id errors with `thread not loaded: `. **This is the read to use.** | +| `thread/name/set` | `{threadId, name}` | Sets the name. Result is `{}`. Also emits a `thread/name/updated` notification. Unknown id errors with `no rollout found for thread id `. | +| `thread/list` | `{limit, sortKey, sortDirection, sourceKinds, useStateDbOnly, cursor}` | Returns `{data, nextCursor, backwardsCursor}`; each thread carries `id` and `name`. Not needed for a by-id read, see below. | + +Generate the full schema for any of this with: + +```bash +codex app-server generate-json-schema --out # v1/ and v2/ subdirs +``` + +### Gotchas + +1. **Use `thread/read`, not `thread/list`, for a by-id lookup.** The first + version of the script paged `thread/list` because the method table did not + list a direct read; the audit found `thread/read` in the generated schema. + If you ever do need `thread/list`, two traps: it has no by-id filter, and it + **hides `exec` and subagent threads by default**. The default `sourceKinds` + filter covers the interactive kinds only; pass every kind explicitly (`cli, + vscode, exec, appServer, subAgent, subAgentReview, subAgentCompact, + subAgentThreadSpawn, subAgentOther, unknown`) or a thread that is plainly in + the database comes back "not found". +2. **Every `codex` process start costs ~3.5 s on this machine**, before it does + anything. `codex --version` alone measured 3.6 s. Budget one spawn per + operation, not one per attempt. + +### Two transports + +- `codex app-server proxy` — forwards to the app-server daemon already serving + the live session, so a running UI repaints immediately. When no daemon is + listening it fails with `failed to connect to socket at + ~/.codex/app-server-control/app-server-control.sock`, but only after the full + ~3.5 s process start, and that is the common case for a plain CLI session + (the `app-server-control/` directory did not even exist here). +- `codex app-server` — starts a private stdio server. Writes the same state, but + a session that is already open won't repaint until the thread list reloads. + +`scripts/codex-title.js` checks for the control socket on disk first and only +tries the proxy when it is there, so the no-daemon case is one spawn instead of +two. A JSON-RPC error from a method the server actually answered is surfaced +as-is rather than retried on the other transport; only a failure of the +connection itself (spawn error, exit before the handshake, timeout) falls +through. + +## Historical skill-loader observations (permission claims superseded) + +| | Claude Code | Codex | +|---|---|---| +| Reads skills from | `~/.claude/skills/` (symlink to `~/.agents/skills/`) | `~/.agents/skills/` | +| `allowed-tools` permissions | Host-specific; verify behavior | Not established by the old binary string-table probe; do not promise a permission grant | +| Interpolates `${CLAUDE_SESSION_ID}` / `${CLAUDE_SKILL_DIR}` in the body | yes | **no** | +| Runs ` ```! ` auto-run blocks on load | yes | **no** — the string does not appear in the binary | +| Choice tools | Use a choice tool when exposed | Tool availability varies by host and mode; fall back to numbered chat options | + +The practical consequence for any cross-host skill: put the work in a script, +have the script find its own directory from `$BASH_SOURCE`, and have it print +whatever the model needs — including the exact follow-up command with paths and +ids already filled in. Then the ` ```! ` block is a nice-to-have on Claude Code +and a plain "run this first" instruction everywhere else. + +## Nested hosts + +When one CLI drives the other (`codex exec` from Claude Code, or `claude -p` +from Codex) both variable sets are visible and `lib/host.sh` exits 2. The way +out is `ESTACK_BETTER_TITLE_HOST=claude|codex`, which is read *before* the +both-visible guard and skips it. The first version of the script checked in the +other order, so the escape hatch its own error message named could never be +reached; that is what the audit caught. Keep the override first. It selects only a host; missing, conflicting, invalid, +or stale session ids still fail before any write. + +## Re-verifying + +```bash +# Does Codex still export a thread id into command shells? +codex exec --skip-git-repo-check -s read-only \ + "Run exactly this and paste raw stdout: env | sort" < /dev/null | grep CODEX_ + +# Is the state schema still shaped this way? +python -c "import sqlite3,os,glob;p=sorted(glob.glob(os.path.expanduser('~/.codex/state_*.sqlite')))[-1];\ +print(sqlite3.connect('file:'+p.replace(os.sep,'/')+'?mode=ro',uri=True).execute(\ +\"select sql from sqlite_master where name='threads'\").fetchone()[0])" + +# Run deterministic fixture tests without accessing live session stores. +node --test tests/estack-better-title/test-routing.cjs + +# For a live round trip, first start a throwaway thread in a disposable +# CODEX_HOME. Never substitute an existing user chat for a test thread. +``` diff --git a/docs/skill-authoring.md b/docs/skill-authoring.md index f6ea876..7ce1260 100644 --- a/docs/skill-authoring.md +++ b/docs/skill-authoring.md @@ -271,13 +271,15 @@ configuration details belong in the integration that uses them. ### Auto-run commands -Use `` ```! `` (triple backtick + `!`) code blocks in SKILL.md to run shell commands automatically when the skill is loaded. The output is presented to the model before it processes the rest of the skill. +Use `` ```! `` (triple backtick + `!`) code blocks in SKILL.md for Claude Code dynamic context injection. The output is presented to the model before it processes the rest of the skill. Other hosts need an explicit instruction to run the command themselves; this supersedes the earlier host-independent auto-run guidance. [Claude Code documents the syntax and failure behavior](https://code.claude.com/docs/en/skills#inject-dynamic-context). Use this for setup tasks, environment checks, or gathering context that the skill needs upfront. **Example:** - `skills/estack-repo-search/SKILL.md` — clones and indexes a repo on load +For a diagnostic whose failure the agent must explain, preserve the error output and return success from the injected block (for example, `check-command || printf 'CHECK_FAILED: stop and explain the error above.\n'`). Otherwise a nonzero exit aborts skill loading before the agent receives its instructions. Keep the underlying script nonzero on failure for direct callers. + ### Prefix transformations and stored state The installer rewrites skill names and explicit `skills/` paths when diff --git a/scripts/check-paths.cjs b/scripts/check-paths.cjs index 1584512..7d312c4 100644 --- a/scripts/check-paths.cjs +++ b/scripts/check-paths.cjs @@ -49,10 +49,13 @@ const ALLOWED_PREFIXES = [ '~/.claude/settings', // Claude's own config (read) '~/.claude/hooks', // hook install location '~/.claude-backups', // Claude's backups (read) - '~/.codex', // Codex's data, read-only for us — no skill writes here, - // so the whole subtree is allowed. ~/.claude is not: - // it gets named subpaths only, because state has been - // parked there before. + '~/.codex', // Codex's data, read-only for us — no skill writes to + // this path, so the whole subtree is allowed. ~/.claude + // is not: it gets named subpaths only, because state has + // been parked there before. estack-better-title does + // change a Codex thread's name, but through the + // `codex app-server` JSON-RPC API rather than by writing + // Codex's state database behind its back. '~/.config/Code', // VS Code history (read) '~/.config/Cursor', // Cursor history (read) '~/Library/Application', // macOS app support (read) diff --git a/skills/estack-better-title/SKILL.md b/skills/estack-better-title/SKILL.md index b57c95f..3d1d795 100644 --- a/skills/estack-better-title/SKILL.md +++ b/skills/estack-better-title/SKILL.md @@ -1,11 +1,13 @@ --- name: estack-better-title -version: 1.3.1 -description: (better-title) Suggest better chat session titles and rename the session +version: 2.0.2 +description: >- + (better-title) Suggest better chat session titles and rename the session, in + Claude Code or Codex. disable-model-invocation: true allowed-tools: + - Bash(bash */scripts/session.sh*) - Bash(bash */scripts/rename.sh*) - - Bash(bash */scripts/current-title.sh*) - Bash(gh --version) - Bash(gh issue create *) - Bash(python3 -c *) @@ -14,25 +16,52 @@ allowed-tools: # Better Title -The current session ID is: ${CLAUDE_SESSION_ID} +## Start here -## Current title +This skill requires Bash and Node.js and runs in both Claude Code and Codex. Which one it is decides where the +session id comes from and how the title gets written, so the first step is +always this command. It reports the host, the session id, the title the session +has now, and the exact rename command to use later. ```! -SID="${CLAUDE_SESSION_ID}" -[ -n "$SID" ] || SID="$CLAUDE_CODE_SESSION_ID" DIR="${CLAUDE_SKILL_DIR}" -[ -n "$DIR" ] || DIR="$HOME/.claude/skills/estack-better-title" -bash "$DIR/scripts/current-title.sh" "$SID" +[ -f "$DIR/scripts/session.sh" ] || DIR="$HOME/.agents/skills/estack-better-title" +[ -f "$DIR/scripts/session.sh" ] || DIR="$HOME/.claude/skills/estack-better-title" +bash "$DIR/scripts/session.sh" || printf '\nSESSION_CHECK_FAILED: stop and follow the diagnostic above.\n' ``` -If the output above shows a current title, follow **Re-titling an existing session** below — it has the decision procedure for what to keep and what to replace. If it says no title is set, draft from scratch. +If you are reading this in a host that does not run that block automatically, +run it yourself now, before anything else. Nothing below works without it. -If you need to re-check the title later — e.g. the user paused mid-flow and asked you to finish the rename on a later turn — run the same lookup manually (it's pre-approved in this skill's `allowed-tools`): +**If it reports an error or `SESSION_CHECK_FAILED`, stop and follow the message +it printed.** Each failure mode says what it checked and what to work out before +telling the user anything. Do not invent a session id and do not try to rename +anything until it reports a host. -```bash -bash "${CLAUDE_SKILL_DIR}/scripts/current-title.sh" "${CLAUDE_SESSION_ID}" -``` +If it reported a current title, follow **Re-titling an existing session** below — +it has the decision procedure for what to keep and what to replace. If it says +no title is set, draft from scratch. + +Re-run the same command whenever you need the id or the title again, for example +when the user paused mid-flow and asked you to finish the rename a turn later. +The host's tool permissions still apply. + +## Codex sandbox access + +Before running the startup command in Codex, check the tool sandbox's writable +roots. A private `codex app-server` starts under the same sandbox and can write +startup/state files in `CODEX_HOME` (normally `~/.codex`) even for `thread/read`. +If that directory is outside the writable roots, request the required access +through the host tool's approval/escalation mechanism for the startup command +and again for the later rename command. On a Codex shell tool that exposes it, +use `sandbox_permissions: "require_escalated"` with a justification naming the +session-title operation. The skill cannot grant this permission itself. + +If startup or rename reports `APPROVAL_REQUIRED`, stop drafting and rerun the +same operation using that approval mechanism. Keep `CODEX_HOME` and the session +id unchanged. If approval is denied or unavailable, explain the limitation and +stop. Never disable the sandbox, redirect to another home, or write the state +database directly to get around the denial. ## Your task @@ -142,35 +171,53 @@ When `AskUserQuestion` is available, present the 3 options with a single-select - Each option's `description` is the **full title**, all zones, exactly as it will be written. The user picks on the subject and confirms on the whole string. - The user can also select "Other" (provided automatically) to give feedback -Because the label is only zone 1, two options whose subjects are near-identical look like the same choice. Make the three subjects meaningfully different from each other, not three phrasings of one idea. +**If you don't**, write the three options in chat as a numbered list. Each entry shows the subject on its own line and the full title underneath, then ask the user to reply with a number, or with feedback. Same information, same choice — just typed instead of picked. + +Either way, because the user chooses on zone 1, two options whose subjects are near-identical look like the same choice. Make the three subjects meaningfully different from each other, not three phrasings of one idea. -When that tool is unavailable, list the three full titles in chat and let the user reply with a number or an exact title. Do not make a user who already supplied an exact title choose again. +Do not make a user who already supplied an exact title choose again. ## Interaction loop -- If the user selects one of the 3 titles, use that title. -- If the user selects "Other" and provides feedback (e.g. "shorter", "more specific", "mention X"), generate 3 new suggestions incorporating their feedback and present again via `AskUserQuestion`. -- Keep iterating until the user selects a title or gives you an exact title. +- If the user picks one of the 3 titles, use that title. +- If the user gives feedback instead (e.g. "shorter", "more specific", "mention X"), generate 3 new suggestions incorporating it and present again the same way. +- Keep iterating until the user picks a title or gives you an exact title. ## Renaming -Once the user has chosen a title, run the rename script using a quoted heredoc to pass the title safely: +Once the user has chosen a title, run the rename command **exactly as the start-here +command printed it**, with `` replaced by the actual title. It already +has the right script path and the right session id filled in for this host. Keep +the quoted heredoc (`<<'__ESTACK_TITLE__'`): it stops the shell from interpreting +anything in the title, so quotes, apostrophes, dollar signs, and backticks all pass +through literally. After running, confirm the rename succeeded. -```bash -bash "${CLAUDE_SKILL_DIR}/scripts/rename.sh" "${CLAUDE_SESSION_ID}" <<'__CLAUDE_TITLE__' - -__CLAUDE_TITLE__ -``` +`rename.sh` refuses to run if the id you pass is not this session's id, so a stale +id from an earlier turn fails loudly instead of renaming the wrong session. If +that happens, re-run the start-here command and use the id it prints. + +**When the new title shows up** depends on the host: -Replace `` with the actual chosen title. The quoted heredoc (`<<'__CLAUDE_TITLE__'`) prevents the shell from interpreting any special characters in the title — quotes, apostrophes, dollar signs, backticks, etc. are all passed through literally. After running, confirm the rename succeeded. +- **Claude Code** — the live UI border won't update until the next session resume. The persisted title shows in the session list and on the next `/resume`. +- **Codex** — the rename goes through the app-server. If the daemon serving the live session is reachable, the UI picks it up right away; otherwise it lands in Codex's thread store and shows once the thread list is reloaded. -**Important:** The live UI border won't update until the next session resume — the persisted title will show in the session list and on next `/resume`. +### If the rename fails or is blocked -### If the append fails or is blocked +Both host paths already handle their own retries: on Claude Code, the append +retries a locked write with exponential backoff (Windows can hold a transient +exclusive lock on the session file it's actively writing to — the retry window +runs up to ~16 seconds to reliably outlast it, since the live CLI writes to that +same file on nearly every turn). On Codex it goes through the app-server daemon +when one is running and a private stdio app-server otherwise. -`rename.sh` already retries a locked append with exponential backoff (Windows can hold a transient exclusive lock on the session file it's actively writing to — the retry window runs up to ~16 seconds to reliably outlast it, since the live CLI writes to that same file on nearly every turn). If it still fails after all retries, or a permission prompt interrupts this exact command, do NOT go inspect, open, or probe the session `.jsonl` directly to figure out why — that departs from this skill's single sanctioned append operation and is far more likely to need manual approval or get blocked outright than the retry itself. Just tell the user the rename didn't go through and suggest running it again in a few seconds. +If it still fails, or a permission prompt interrupts this exact command, do NOT +go inspect, open, or probe the host's session store directly to figure out why — +not the Claude `.jsonl` transcript, not Codex's state database. That departs from +this skill's sanctioned operations and is far more likely to need manual approval +or get blocked outright than the retry itself. Just tell the user the rename +didn't go through and suggest running it again in a few seconds. -This skill's `allowed-tools` frontmatter pre-approves the rename command (and the feedback-section commands), so on the turn that invoked the skill it runs without a permission prompt. That grant clears when the user sends their next chat message — so if the title is chosen on a later turn (e.g. the user replied in chat instead of picking an `AskUserQuestion` option), the rename may prompt. If that's a recurring annoyance, the user can add a persistent allow rule scoped to just this invocation to `settings.json`: +Tool permissions depend on the host and its configuration; `allowed-tools` is not a cross-host permission grant. On Claude Code, the user can add a persistent allow rule scoped to this invocation in `settings.json` if repeated prompts are a problem: ```json { diff --git a/skills/estack-better-title/agents/openai.yaml b/skills/estack-better-title/agents/openai.yaml new file mode 100644 index 0000000..5b1f887 --- /dev/null +++ b/skills/estack-better-title/agents/openai.yaml @@ -0,0 +1,2 @@ +policy: + allow_implicit_invocation: false diff --git a/skills/estack-better-title/scripts/claude-rename.sh b/skills/estack-better-title/scripts/claude-rename.sh new file mode 100644 index 0000000..fd106c1 --- /dev/null +++ b/skills/estack-better-title/scripts/claude-rename.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Renames a Claude Code session by appending title entries (append-only, never rewrites) +# Codex threads are renamed by codex-title.js instead; rename.sh routes between +# them. Usage: claude-rename.sh +# or: pass the title on stdin to claude-rename.sh <session-id> + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/claude.sh +. "$SCRIPT_DIR/lib/claude.sh" + +SESSION_ID="${1-}" +TITLE="${2-}" + +# If no title argument, read from stdin (supports heredoc input) +if [[ -z "$TITLE" ]]; then + TITLE="$(cat)" +fi + +if [[ -z "$SESSION_ID" || -z "$TITLE" ]]; then + echo "Error: Usage: claude-rename.sh <session-id> <title>" >&2 + echo " or: pass the title on stdin to claude-rename.sh <session-id>" >&2 + exit 1 +fi + +# Reject multiline titles (JSONL entries must be single lines) +if [[ "$TITLE" == *$'\n'* || "$TITLE" == *$'\r'* ]]; then + echo "Error: title must be a single line" >&2 + exit 1 +fi + +# Use Node's JSON.stringify for safe escaping of all special characters +TITLE_JSON="$(node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' "$TITLE")" + +SESSION_FILE="$(estack_claude_session_file "$SESSION_ID")" + +if [[ -z "$SESSION_FILE" ]]; then + echo "Error: Could not find session file for ${SESSION_ID}" >&2 + exit 1 +fi + +# Build the exact lines we intend to append +CUSTOM_LINE="$(printf '{"type":"custom-title","customTitle":%s,"sessionId":"%s"}' "$TITLE_JSON" "$SESSION_ID")" +AGENT_LINE="$(printf '{"type":"agent-name","agentName":%s,"sessionId":"%s"}' "$TITLE_JSON" "$SESSION_ID")" + +# Check if both last entries already match (idempotent — skip if already current) +LAST_CUSTOM="$(grep '^{"type":"custom-title"' "$SESSION_FILE" | tail -n 1 || true)" +LAST_AGENT="$(grep '^{"type":"agent-name"' "$SESSION_FILE" | tail -n 1 || true)" + +if [[ "$LAST_CUSTOM" == "$CUSTOM_LINE" && "$LAST_AGENT" == "$AGENT_LINE" ]]; then + echo "Session already named: ${TITLE}" + exit 0 +fi + +# Append-only: never rewrite the file, just add new entries at the end. +# On Windows, the live Claude Code process can hold a transient exclusive lock +# on its own session file while it writes ("Device or resource busy") — retry +# with backoff instead of failing on the first collision. Capture stderr +# (instead of discarding it) so a *permanent* failure — bad path, permissions, +# disk full — is reported accurately rather than misreported as a transient +# lock after silently burning through every retry. +# +# The live CLI writes to this same file on essentially every turn/tool call, +# so in a busy session the lock recurs faster than a short retry window can +# outlast (a flat 6 x 0.3s ~= 1.8s total was measured to fail repeatedly in +# practice). Backoff is exponential, capped at 4s per attempt, ~16s total — +# long enough to reliably clear the real-world lock duration while still +# resolving in under a second in the common case. +BACKOFF=(0.3 0.6 1 1.5 2 3 4 4) +MAX_ATTEMPTS=${#BACKOFF[@]} +attempt=1 +APPEND_ERR="$(mktemp)" +until { printf '%s\n%s\n' "$CUSTOM_LINE" "$AGENT_LINE" >> "$SESSION_FILE"; } 2>"$APPEND_ERR"; do + if [[ $attempt -ge $MAX_ATTEMPTS ]]; then + LAST_ERR="$(cat "$APPEND_ERR")" + rm -f "$APPEND_ERR" + echo "Error: could not append to the session file after ${MAX_ATTEMPTS} attempts." >&2 + echo "Last error: ${LAST_ERR}" >&2 + if [[ "$LAST_ERR" == *[Bb]usy* ]]; then + echo "This looks like a transient Windows file lock — try the rename again in a few seconds." >&2 + else + echo "This doesn't look like a transient lock — check the session file path and permissions." >&2 + fi + exit 1 + fi + sleep "${BACKOFF[$((attempt - 1))]}" + attempt=$((attempt + 1)) +done +rm -f "$APPEND_ERR" + +echo "Renamed session to: ${TITLE}" diff --git a/skills/estack-better-title/scripts/current-title.sh b/skills/estack-better-title/scripts/claude-title.sh similarity index 60% rename from skills/estack-better-title/scripts/current-title.sh rename to skills/estack-better-title/scripts/claude-title.sh index acb8716..c5a7ffe 100644 --- a/skills/estack-better-title/scripts/current-title.sh +++ b/skills/estack-better-title/scripts/claude-title.sh @@ -1,18 +1,22 @@ #!/usr/bin/env bash -# Prints the session's current custom title, or a "no title" message. -# Usage: current-title.sh <session-id> +# Prints a Claude Code session's current custom title, or a "no title" message. +# Codex threads are handled by codex-title.js instead; session.sh picks between +# them. Usage: claude-title.sh <session-id> set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/claude.sh +. "$SCRIPT_DIR/lib/claude.sh" + SESSION_ID="${1-}" if [[ -z "$SESSION_ID" ]]; then - echo "Error: Usage: current-title.sh <session-id>" >&2 + echo "Error: Usage: claude-title.sh <session-id>" >&2 exit 1 fi -# Find the session JSONL file (same lookup rename.sh uses) -SESSION_FILE=$(find "$HOME/.claude/projects/" -maxdepth 2 -name "${SESSION_ID}.jsonl" -type f 2>/dev/null | head -1) +SESSION_FILE="$(estack_claude_session_file "$SESSION_ID")" LAST_CUSTOM="" if [[ -n "$SESSION_FILE" ]]; then diff --git a/skills/estack-better-title/scripts/codex-title.js b/skills/estack-better-title/scripts/codex-title.js new file mode 100644 index 0000000..c4b5781 --- /dev/null +++ b/skills/estack-better-title/scripts/codex-title.js @@ -0,0 +1,215 @@ +#!/usr/bin/env node +// Reads and sets the name of a Codex thread through the Codex app-server. +// +// Usage: codex-title.js get <thread-id> +// codex-title.js set <thread-id> (the name is read from stdin) +// +// Codex stores a thread's user-facing name in its own state database. We never +// touch that file: writing it directly would race the running Codex process and +// break the first time Codex bumps its schema. Instead we speak the documented +// app-server JSON-RPC protocol (`thread/read`, `thread/name/set`), which is the +// same path the Codex UI uses. +// +// Two transports: +// 1. `codex app-server proxy` — forwards to the app-server daemon that is +// already serving the live session, so a running UI sees the new name. +// Only tried when the daemon's control socket is on disk: without it the +// proxy is a full codex process start (~3.5 s) that is known in advance +// to fail. +// 2. `codex app-server` — a private stdio server. It writes the same state, +// but a session that is already open won't repaint until it is reloaded. +// A transport failure (spawn error, exit before the handshake, timeout) falls +// through to the next transport. A JSON-RPC error from a method that was +// actually answered is surfaced as-is; retrying it elsewhere gets the same +// answer, slower. + +'use strict'; + +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const TIMEOUT_MS = 25000; + +function fail(message) { + process.stderr.write('Error: ' + message + '\n'); + process.exit(1); +} + +// Thrown for failures of the connection itself, as opposed to a method the +// server answered with an error. connect() only falls through on these. +class TransportError extends Error {} + +function codexHome() { + return process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); +} + +function daemonSocketPresent() { + return fs.existsSync(path.join(codexHome(), 'app-server-control', 'app-server-control.sock')); +} + +// One request/response conversation with an app-server over stdio. The data +// handler only routes replies to their waiters; `run` drives the sequence with +// `call(method, params)` and `notify(method)`, and the server is torn down +// once it resolves or rejects. +function withAppServer(command, run) { + return new Promise((resolve, reject) => { + const options = { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }; + // Windows npm installs expose codex.cmd. Keep the shell command constant; + // thread ids and titles travel only through JSON on stdin. + const child = process.platform === 'win32' + ? spawn(command, { ...options, shell: true }) + : spawn('codex', command.split(' ').slice(1), options); + + let nextId = 0; + const pending = new Map(); + let buffer = ''; + let stderr = ''; + let settled = false; + let handshakeDone = false; + + const finish = (err, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { child.stdin.end(); } catch { /* already gone */ } + child.kill(); + if (err) reject(err); else resolve(value); + }; + + const timer = setTimeout( + () => finish(new TransportError(`${command} timed out after ${TIMEOUT_MS / 1000}s`)), + TIMEOUT_MS, + ); + + const send = (msg) => child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n'); + const notify = (method, params) => send({ method, params }); + const call = (method, params) => new Promise((res, rej) => { + const id = ++nextId; + pending.set(id, { res, rej }); + send({ id, method, params }); + }); + + // Preserve multi-byte titles even when a UTF-8 character spans chunks. + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdin.on('error', (err) => finish(new TransportError(err.message))); + child.stdout.on('error', (err) => finish(new TransportError(err.message))); + child.stdout.on('data', (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + const waiter = msg.id != null && pending.get(msg.id); + if (!waiter) continue; // a server-initiated notification + pending.delete(msg.id); + if (msg.error) waiter.rej(new Error(msg.error.message || JSON.stringify(msg.error))); + else waiter.res(msg.result); + } + }); + + child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + child.on('error', (err) => finish(new TransportError(err.message))); + child.on('exit', (code) => { + if (settled) return; + finish(new TransportError(`${command} exited with code ${code}: ${stderr.trim() || 'no output'}`)); + }); + + (async () => { + try { + await call('initialize', { + clientInfo: { name: 'estack-better-title', version: '1.0.0' }, + }); + } catch (err) { + // A JSON-RPC rejection answered the handshake: changing transports + // cannot fix it. Stream/exit/timeout handlers tag transport failures. + handshakeDone = true; + throw err; + } + handshakeDone = true; + // The protocol wants this notification before any other request is served. + notify('initialized'); + return run(call); + })().then((value) => finish(null, value), (err) => { + // An exit or stream error after the handshake but before the method + // reply is still a transport failure; the exit handler tags it. + finish(err instanceof TransportError || !handshakeDone ? new TransportError(err.message) : err); + }); + }); +} + +async function connect(run) { + const transports = daemonSocketPresent() + ? ['codex app-server proxy', 'codex app-server'] + : ['codex app-server']; + const errors = []; + for (const command of transports) { + try { + return await withAppServer(command, run); + } catch (err) { + if (/readonly database|read-only|permission denied|operation not permitted|access is denied|EACCES|EPERM/i.test(err.message)) { + fail( + 'APPROVAL_REQUIRED: Codex app-server startup needs access to CODEX_HOME, even for a title read.\n' + + 'Agent: rerun this same operation through the host tool approval/escalation mechanism.\n' + + 'Keep the current CODEX_HOME and session id; do not disable sandbox protections or edit the database.\n' + + 'If approval is unavailable or denied, explain the limitation and stop.\n' + err.message, + ); + } + if (!(err instanceof TransportError)) fail(err.message); + errors.push(`${command}: ${err.message}`); + } + } + return fail( + 'could not reach the Codex app-server.\n' + + errors.map((e) => ' ' + e).join('\n') + '\n' + + 'Check that the `codex` CLI is on PATH (`codex --version`).', + ); +} + +async function getName(call, threadId) { + const { thread } = await call('thread/read', { threadId, includeTurns: false }); + if (!thread || thread.id !== threadId || (thread.name != null && typeof thread.name !== 'string')) { + throw new Error('invalid thread/read response for the current session'); + } + return thread.name || ''; +} + +async function main() { + const [mode, threadId] = process.argv.slice(2); + if (!mode || !threadId) { + fail('Usage: codex-title.js get|set <thread-id> (set reads the name from stdin)'); + } + + if (mode === 'get') { + const name = await connect((call) => getName(call, threadId)); + process.stdout.write(name ? `Current title: ${name}\n` : 'No current title is set for this session.\n'); + return; + } + + if (mode === 'set') { + const name = (await readStdin()).replace(/\r?\n$/, ''); + if (!name) fail('no title on stdin'); + if (/[\r\n]/.test(name)) fail('title must be a single line'); + await connect((call) => call('thread/name/set', { threadId, name })); + process.stdout.write(`Renamed session to: ${name}\n`); + return; + } + + fail(`unknown mode "${mode}" — expected get or set`); +} + +function readStdin() { + return new Promise((resolve) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { data += chunk; }); + process.stdin.on('end', () => resolve(data)); + }); +} + +main().catch((err) => fail(err.message)); diff --git a/skills/estack-better-title/scripts/lib/claude.sh b/skills/estack-better-title/scripts/lib/claude.sh new file mode 100644 index 0000000..d75ad2b --- /dev/null +++ b/skills/estack-better-title/scripts/lib/claude.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Shared Claude Code helpers for estack-better-title. Source it; don't run it. + +# Prints the path of a Claude Code session's transcript, or nothing if it is +# not on disk. Both claude-title.sh and claude-rename.sh look the session up +# this way, so a change to Claude's project layout lands in one place. +estack_claude_session_file() { + find "$HOME/.claude/projects/" -maxdepth 2 -name "$1.jsonl" -type f 2>/dev/null | head -1 +} diff --git a/skills/estack-better-title/scripts/lib/host.sh b/skills/estack-better-title/scripts/lib/host.sh new file mode 100644 index 0000000..e62c657 --- /dev/null +++ b/skills/estack-better-title/scripts/lib/host.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Shared host detection for estack-better-title. Source it; don't run it. +# +# Every host names its own session differently and stores the title somewhere +# else, so both entry scripts (session.sh, rename.sh) start here and branch on +# the result rather than assuming Claude Code. +# +# Sets on success: +# ESTACK_HOST claude | codex +# ESTACK_SESSION_ID the id to read or rename +# ESTACK_HOST_LABEL human-readable host name +# +# Exits non-zero with an explanation aimed at the agent when it cannot tell. + +# Claude Code exports CLAUDE_CODE_SESSION_ID in every shell it spawns. +# CLAUDE_SESSION_ID is the name SKILL.md interpolation uses and is not always in +# the environment (it is absent under the Agent SDK), so check both. +CLAUDE_ID="${CLAUDE_SESSION_ID:-}" +[ -n "$CLAUDE_ID" ] || CLAUDE_ID="${CLAUDE_CODE_SESSION_ID:-}" + +# Codex exports both of these, set to the same thread id. +CODEX_ID="${CODEX_THREAD_ID:-}" +[ -n "$CODEX_ID" ] || CODEX_ID="${CODEX_SESSION_ID:-}" + +# CLAUDECODE marks a Claude Code shell even when the id is missing, which lets +# "right host, no id" report differently from "no idea what host this is". +CLAUDE_MARKER="${CLAUDECODE:-}" + +# An explicit override is read first. It exists for the nested case below and +# for testing. It selects only the host; the session id still must match +# that host's environment so stale ids cannot rename another session. +ESTACK_HOST_EXPLICIT=0 +case "${ESTACK_BETTER_TITLE_HOST:-}" in + claude) ESTACK_HOST=claude; ESTACK_HOST_EXPLICIT=1 ;; + codex) ESTACK_HOST=codex; ESTACK_HOST_EXPLICIT=1 ;; + '') ESTACK_HOST='' ;; + *) + echo "Error: ESTACK_BETTER_TITLE_HOST must be 'claude' or 'codex', got '${ESTACK_BETTER_TITLE_HOST}'." >&2 + exit 1 + ;; +esac + +if [ "$ESTACK_HOST_EXPLICIT" = 0 ] && { [ -n "$CLAUDE_ID" ] || [ -n "$CLAUDE_MARKER" ]; } && [ -n "$CODEX_ID" ]; then + cat >&2 <<EOF +Error: both a Claude Code session and a Codex thread are visible in this shell. + + Claude Code session: $CLAUDE_ID + Codex thread: $CODEX_ID + +That normally means one CLI is driving the other (for example Claude Code +running \`codex exec\`, or Codex running \`claude -p\`), so the inner agent +inherits the outer agent's environment and this skill cannot tell which +session the user is actually looking at. + +Do not guess. Ask the user which session they want renamed, then re-run with +ESTACK_BETTER_TITLE_HOST set to that host, which skips this check: + + ESTACK_BETTER_TITLE_HOST=codex bash <skill-dir>/scripts/session.sh + ESTACK_BETTER_TITLE_HOST=codex bash <skill-dir>/scripts/rename.sh <session-id> <<'__ESTACK_TITLE__' + <chosen title> + __ESTACK_TITLE__ +EOF + exit 2 +fi + +if [ -z "$ESTACK_HOST" ]; then + if [ -n "$CLAUDE_ID" ] || [ -n "$CLAUDE_MARKER" ]; then + ESTACK_HOST=claude + elif [ -n "$CODEX_ID" ]; then + ESTACK_HOST=codex + fi +fi + +if [ -z "$ESTACK_HOST" ]; then + cat >&2 <<'EOF' +Error: this skill could not tell which agent it is running in. + +It looks for these environment variables and found none of them: + CLAUDE_SESSION_ID / CLAUDE_CODE_SESSION_ID / CLAUDECODE -> Claude Code + CODEX_THREAD_ID / CODEX_SESSION_ID -> Codex + +estack-better-title only knows how to rename a Claude Code session or a Codex +thread, because renaming means writing to that host's own session store. It has +no way to title a session in a host it does not recognize. + +Agent: work out what is actually going on before telling the user anything. +Useful checks: + - `env | grep -iE 'claude|codex'` - is any session variable set at all? + - Are you a subagent or a headless run? Those often have no session to rename. + - Is this a third-party harness that embeds one of these CLIs? If so it may + strip the session variables, and the user has to rename the chat in that + tool's own UI instead. + +Then tell the user which of those it is, in one sentence, and what they can do: +rename in their host's UI, or re-run this skill from a normal interactive +Claude Code or Codex session. +EOF + exit 3 +fi + +if [ "$ESTACK_HOST" = claude ]; then + ESTACK_SESSION_ID="$CLAUDE_ID" + ESTACK_HOST_LABEL="Claude Code" +else + ESTACK_SESSION_ID="$CODEX_ID" + ESTACK_HOST_LABEL="Codex" +fi + +if [ -z "$ESTACK_SESSION_ID" ]; then + cat >&2 <<EOF +Error: this looks like a ${ESTACK_HOST_LABEL} shell, but no session id is set. + +Claude Code sets CLAUDE_CODE_SESSION_ID and Codex sets CODEX_THREAD_ID in every +shell they spawn. One of those is missing here, so there is no session to rename. + +Agent: this usually means the command ran somewhere other than the host's own +tool call - a detached shell, a nested process that dropped the environment, or +a harness that does not forward it. Check with \`env | grep -iE 'claude|codex'\`, +then tell the user this session cannot be renamed from here and why. +EOF + exit 4 +fi + +# Aliases should identify the same session. Refuse inconsistent harness state. +if { [ "$ESTACK_HOST" = claude ] && [ -n "${CLAUDE_SESSION_ID:-}" ] && [ -n "${CLAUDE_CODE_SESSION_ID:-}" ] && [ "$CLAUDE_SESSION_ID" != "$CLAUDE_CODE_SESSION_ID" ]; } || + { [ "$ESTACK_HOST" = codex ] && [ -n "${CODEX_THREAD_ID:-}" ] && [ -n "${CODEX_SESSION_ID:-}" ] && [ "$CODEX_THREAD_ID" != "$CODEX_SESSION_ID" ]; }; then + echo "Error: conflicting session ids for $ESTACK_HOST_LABEL. Check the host environment before retrying." >&2 + exit 2 +fi + +# Both supported hosts use UUIDs. Validate before any transcript path lookup. +if [[ ! "$ESTACK_SESSION_ID" =~ ^[[:xdigit:]]{8}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{12}$ ]]; then + echo "Error: invalid session id for $ESTACK_HOST_LABEL. Check the host environment before retrying." >&2 + exit 1 +fi + +export ESTACK_HOST ESTACK_SESSION_ID ESTACK_HOST_LABEL ESTACK_HOST_EXPLICIT diff --git a/skills/estack-better-title/scripts/rename.sh b/skills/estack-better-title/scripts/rename.sh index cb62be1..f5f5d57 100644 --- a/skills/estack-better-title/scripts/rename.sh +++ b/skills/estack-better-title/scripts/rename.sh @@ -1,88 +1,65 @@ #!/usr/bin/env bash -# Renames a Claude Code session by appending title entries (append-only, never rewrites) +# Renames the current session, whichever agent that session belongs to. +# # Usage: rename.sh <session-id> <title> # or: pass the title on stdin to rename.sh <session-id> +# +# This stays the single rename entry point on purpose: it is the path named in +# the skill's allowed-tools and in any allow rule a user has added to +# settings.json, so the host routing happens behind it rather than by calling a +# different script per agent. -set -euo pipefail +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SESSION_ID="${1-}" TITLE="${2-}" -# If no title argument, read from stdin (supports heredoc input) -if [[ -z "$TITLE" ]]; then +# No title argument means it is on stdin (the heredoc form the skill uses). +if [ -z "$TITLE" ]; then TITLE="$(cat)" fi -if [[ -z "$SESSION_ID" || -z "$TITLE" ]]; then +if [ -z "$SESSION_ID" ] || [ -z "$TITLE" ]; then echo "Error: Usage: rename.sh <session-id> <title>" >&2 echo " or: pass the title on stdin to rename.sh <session-id>" >&2 exit 1 fi -# Reject multiline titles (JSONL entries must be single lines) -if [[ "$TITLE" == *$'\n'* || "$TITLE" == *$'\r'* ]]; then - echo "Error: title must be a single line" >&2 - exit 1 -fi - -# Use Node's JSON.stringify for safe escaping of all special characters -TITLE_JSON="$(node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' "$TITLE")" +# Validated once here, at the entry point, so both hosts reject the same input +# with the same message. A Claude transcript line and a Codex thread name are +# both single-line values. +case "$TITLE" in + *$'\n'*|*$'\r'*) + echo "Error: title must be a single line" >&2 + exit 1 + ;; +esac -# Find the session JSONL file -SESSION_FILE=$(find "$HOME/.claude/projects/" -maxdepth 2 -name "${SESSION_ID}.jsonl" -type f 2>/dev/null | head -1) +# shellcheck source=lib/host.sh +. "$SCRIPT_DIR/lib/host.sh" || exit $? -if [[ -z "$SESSION_FILE" ]]; then - echo "Error: Could not find session file for ${SESSION_ID}" >&2 - exit 1 -fi +if [ "$SESSION_ID" != "$ESTACK_SESSION_ID" ]; then + cat >&2 <<EOF +Error: refusing to rename - the id passed in is not this session's id. -# Build the exact lines we intend to append -CUSTOM_LINE="$(printf '{"type":"custom-title","customTitle":%s,"sessionId":"%s"}' "$TITLE_JSON" "$SESSION_ID")" -AGENT_LINE="$(printf '{"type":"agent-name","agentName":%s,"sessionId":"%s"}' "$TITLE_JSON" "$SESSION_ID")" + passed in: $SESSION_ID + this session: $ESTACK_SESSION_ID ($ESTACK_HOST_LABEL) -# Check if both last entries already match (idempotent — skip if already current) -LAST_CUSTOM="$(grep '^{"type":"custom-title"' "$SESSION_FILE" | tail -n 1 || true)" -LAST_AGENT="$(grep '^{"type":"agent-name"' "$SESSION_FILE" | tail -n 1 || true)" +Renaming the wrong session is silent and hard to notice, so this is a hard stop. +A stale id usually means the id was carried over from an earlier turn or copied +from another session. Re-run scripts/session.sh to get the current one. -if [[ "$LAST_CUSTOM" == "$CUSTOM_LINE" && "$LAST_AGENT" == "$AGENT_LINE" ]]; then - echo "Session already named: ${TITLE}" - exit 0 +The host override selects a host, not a different session. Run the skill +from the session you want to rename. +EOF + exit 1 fi -# Append-only: never rewrite the file, just add new entries at the end. -# On Windows, the live Claude Code process can hold a transient exclusive lock -# on its own session file while it writes ("Device or resource busy") — retry -# with backoff instead of failing on the first collision. Capture stderr -# (instead of discarding it) so a *permanent* failure — bad path, permissions, -# disk full — is reported accurately rather than misreported as a transient -# lock after silently burning through every retry. -# -# The live CLI writes to this same file on essentially every turn/tool call, -# so in a busy session the lock recurs faster than a short retry window can -# outlast (a flat 6 x 0.3s ~= 1.8s total was measured to fail repeatedly in -# practice). Backoff is exponential, capped at 4s per attempt, ~16s total — -# long enough to reliably clear the real-world lock duration while still -# resolving in under a second in the common case. -BACKOFF=(0.3 0.6 1 1.5 2 3 4 4) -MAX_ATTEMPTS=${#BACKOFF[@]} -attempt=1 -APPEND_ERR="$(mktemp)" -until { printf '%s\n%s\n' "$CUSTOM_LINE" "$AGENT_LINE" >> "$SESSION_FILE"; } 2>"$APPEND_ERR"; do - if [[ $attempt -ge $MAX_ATTEMPTS ]]; then - LAST_ERR="$(cat "$APPEND_ERR")" - rm -f "$APPEND_ERR" - echo "Error: could not append to the session file after ${MAX_ATTEMPTS} attempts." >&2 - echo "Last error: ${LAST_ERR}" >&2 - if [[ "$LAST_ERR" == *[Bb]usy* ]]; then - echo "This looks like a transient Windows file lock — try the rename again in a few seconds." >&2 - else - echo "This doesn't look like a transient lock — check the session file path and permissions." >&2 - fi - exit 1 - fi - sleep "${BACKOFF[$((attempt - 1))]}" - attempt=$((attempt + 1)) -done -rm -f "$APPEND_ERR" - -echo "Renamed session to: ${TITLE}" +# Both leaves take the title on stdin, one shape for both hosts. +if [ "$ESTACK_HOST" = claude ]; then + printf '%s' "$TITLE" | bash "$SCRIPT_DIR/claude-rename.sh" "$SESSION_ID" +else + printf '%s' "$TITLE" | node "$SCRIPT_DIR/codex-title.js" set "$SESSION_ID" +fi diff --git a/skills/estack-better-title/scripts/session.sh b/skills/estack-better-title/scripts/session.sh new file mode 100644 index 0000000..61bf38a --- /dev/null +++ b/skills/estack-better-title/scripts/session.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Entry point. Works out which agent this is running in, then reports the +# session id, the title it currently has, and the exact command that will +# rename it. +# +# SKILL.md runs this first so the rest of the skill never has to care whether +# the host is Claude Code or Codex, and never has to depend on Claude Code's +# ${CLAUDE_SESSION_ID} / ${CLAUDE_SKILL_DIR} interpolation, which Codex does not +# do. Usage: session.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=lib/host.sh +. "$SCRIPT_DIR/lib/host.sh" || exit $? + +if [ "$ESTACK_HOST" = claude ]; then + TITLE_OUT="$(bash "$SCRIPT_DIR/claude-title.sh" "$ESTACK_SESSION_ID" 2>&1)" + TITLE_STATUS=$? +else + TITLE_OUT="$(node "$SCRIPT_DIR/codex-title.js" get "$ESTACK_SESSION_ID" 2>&1)" + TITLE_STATUS=$? +fi + +echo "HOST: $ESTACK_HOST_LABEL" +echo "SESSION_ID: $ESTACK_SESSION_ID" + +if [ $TITLE_STATUS -ne 0 ]; then + echo "CURRENT_TITLE: could not be read" >&2 + echo "$TITLE_OUT" >&2 + echo "Stop and explain the read failure to the user before retrying." >&2 + exit "$TITLE_STATUS" +else + echo "$TITLE_OUT" +fi + +cat <<EOF + +Rename with (single quoted heredoc, so the title is passed through literally): + +bash "$SCRIPT_DIR/rename.sh" "$ESTACK_SESSION_ID" <<'__ESTACK_TITLE__' +<chosen title> +__ESTACK_TITLE__ +EOF diff --git a/tests/estack-better-title/fake-codex.cjs b/tests/estack-better-title/fake-codex.cjs new file mode 100644 index 0000000..8d5eda5 --- /dev/null +++ b/tests/estack-better-title/fake-codex.cjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +'use strict'; +const fs = require('node:fs'); +const readline = require('node:readline'); +const mode = process.env.TITLE_TEST_MODE; +const proxy = process.argv.includes('proxy'); +fs.appendFileSync(process.env.TITLE_TEST_LOG, JSON.stringify({ start: true, proxy }) + '\n'); +if (mode === 'fallback' && proxy) process.exit(2); +if (mode === 'permission-denied') { process.stderr.write('startup maintenance: attempt to write a readonly database\n'); process.exit(1); } +readline.createInterface({ input: process.stdin }).on('line', (line) => { + const msg = JSON.parse(line); + fs.appendFileSync(process.env.TITLE_TEST_LOG, line + '\n'); + if (!msg.id) return; + if (msg.method === 'initialize') { + if (mode === 'handshake-error') return send({ id: msg.id, error: { message: 'unsupported client' } }); + return send({ id: msg.id, result: {} }); + } + if (mode === 'method-error') return send({ id: msg.id, error: { message: 'thread not loaded: test' } }); + if (mode === 'bad-response') return send({ id: msg.id, result: { thread: { id: 'wrong', name: null } } }); + if (msg.method === 'thread/read') { + const name = mode === 'unicode' ? 'Café 🚀 東京' : null; + return send({ id: msg.id, result: { thread: { id: msg.params.threadId, name } } }, mode === 'unicode'); + } + if (msg.method === 'thread/name/set') return send({ id: msg.id, result: {} }); +}); +function send(value, split = false) { + const bytes = Buffer.from(JSON.stringify(value) + '\n'); + if (!split) return process.stdout.write(bytes); + const pos = bytes.indexOf(Buffer.from('🚀')) + 1; + process.stdout.write(bytes.subarray(0, pos)); + setTimeout(() => process.stdout.write(bytes.subarray(pos)), 40); +} diff --git a/tests/estack-better-title/test-routing.cjs b/tests/estack-better-title/test-routing.cjs new file mode 100644 index 0000000..aded0c2 --- /dev/null +++ b/tests/estack-better-title/test-routing.cjs @@ -0,0 +1,132 @@ +'use strict'; +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const scripts = path.resolve(__dirname, '../../skills/estack-better-title/scripts'); +const id = '01234567-1234-4234-8234-0123456789ab'; +const other = '01234567-1234-4234-8234-0123456789ac'; +const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'; +function sandbox(t, extra = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'estack-title-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const env = { ...process.env, HOME: dir, USERPROFILE: dir, CODEX_HOME: path.join(dir, '.codex') }; + for (const key of ['CLAUDE_SESSION_ID', 'CLAUDE_CODE_SESSION_ID', 'CLAUDECODE', 'CODEX_THREAD_ID', 'CODEX_SESSION_ID', 'ESTACK_BETTER_TITLE_HOST']) delete env[key]; + Object.assign(env, extra); + return { dir, env }; +} +function shell(env, script, args = [], input) { + return spawnSync(bash, [path.join(scripts, script), ...args], { env, input, encoding: 'utf8', timeout: 15000 }); +} +function success(result) { assert.equal(result.error, undefined); assert.equal(result.status, 0, result.stderr + result.stdout); } +function transcript(dir, session = id) { + const project = path.join(dir, '.claude/projects/test'); + fs.mkdirSync(project, { recursive: true }); + const file = path.join(project, session + '.jsonl'); + fs.writeFileSync(file, '{"type":"user","message":"fixture"}\n'); + return file; +} +for (const [label, vars, code, message] of [ + ['unknown host', {}, 3, /could not tell which agent/], + ['Claude marker without id', { CLAUDECODE: '1' }, 4, /no session id/], + ['nested hosts', { CLAUDE_CODE_SESSION_ID: id, CODEX_THREAD_ID: id }, 2, /both a Claude/], + ['nested marker', { CLAUDECODE: '1', CODEX_THREAD_ID: id }, 2, /both a Claude/], + ['invalid override', { ESTACK_BETTER_TITLE_HOST: 'other' }, 1, /must be 'claude' or 'codex'/], + ['override without id', { ESTACK_BETTER_TITLE_HOST: 'codex' }, 4, /no session id/], + ['conflicting Codex aliases', { CODEX_THREAD_ID: id, CODEX_SESSION_ID: other }, 2, /conflicting session ids/], + ['conflicting Claude aliases', { CLAUDE_SESSION_ID: id, CLAUDE_CODE_SESSION_ID: other }, 2, /conflicting session ids/], + ['invalid session id', { CLAUDE_SESSION_ID: '../*' }, 1, /invalid session id/], +]) test(label, (t) => { + const { env } = sandbox(t, vars); + const r = shell(env, 'session.sh'); + assert.equal(r.status, code, r.stderr); assert.match(r.stderr, message); assert.doesNotMatch(r.stdout, /Rename with/); +}); +for (const alias of ['CLAUDE_SESSION_ID', 'CLAUDE_CODE_SESSION_ID']) test('Claude routing via ' + alias, (t) => { + const { dir, env } = sandbox(t, { [alias]: id }); + const file = transcript(dir); + success(shell(env, 'session.sh')); + const title = 'Café 🚀 - quotes " and $HOME `literal`'; + success(shell(env, 'rename.sh', [id], title + '\n')); + const rows = fs.readFileSync(file, 'utf8').trim().split('\n').map(JSON.parse); + assert.equal(rows.at(-2).customTitle, title); assert.equal(rows.at(-1).agentName, title); + const before = fs.readFileSync(file, 'utf8'); + success(shell(env, 'rename.sh', [id], title + '\n')); + assert.equal(fs.readFileSync(file, 'utf8'), before, 'rename is idempotent'); + const read = shell(env, 'session.sh'); success(read); assert.ok(read.stdout.includes(title)); +}); +for (const override of [false, true]) test('stale id rejected with override=' + override, (t) => { + const { dir, env } = sandbox(t, { CLAUDE_CODE_SESSION_ID: id, ...(override ? { CODEX_THREAD_ID: other, ESTACK_BETTER_TITLE_HOST: 'claude' } : {}) }); + const file = transcript(dir, other); const before = fs.readFileSync(file, 'utf8'); + const r = shell(env, 'rename.sh', [other], 'stale'); + assert.equal(r.status, 1); assert.match(r.stderr, /refusing to rename/); assert.equal(fs.readFileSync(file, 'utf8'), before); +}); +test('nested host override selects current Claude session', (t) => { + const { dir, env } = sandbox(t, { CLAUDE_CODE_SESSION_ID: id, CODEX_THREAD_ID: other, ESTACK_BETTER_TITLE_HOST: 'claude' }); + transcript(dir); const r = shell(env, 'session.sh'); success(r); assert.match(r.stdout, /HOST: Claude Code/); + success(shell(env, 'rename.sh', [id], 'selected')); +}); +for (const title of ['', 'line1\nline2', 'line1\rline2']) test('invalid title ' + JSON.stringify(title), (t) => { + const { dir, env } = sandbox(t, { CLAUDE_CODE_SESSION_ID: id }); + const file = transcript(dir); const before = fs.readFileSync(file, 'utf8'); + const r = shell(env, 'rename.sh', [id], title); + assert.equal(r.status, 1); assert.equal(fs.readFileSync(file, 'utf8'), before); +}); +function mock(t, mode, proxy = false) { + const box = sandbox(t, { TITLE_TEST_MODE: mode, CODEX_THREAD_ID: id }); + const bin = path.join(box.dir, 'bin'); fs.mkdirSync(bin); + box.env.TITLE_TEST_LOG = path.join(box.dir, 'requests.jsonl'); + const fixture = path.join(__dirname, 'fake-codex.cjs'); + if (process.platform === 'win32') fs.writeFileSync(path.join(bin, 'codex.cmd'), `@"${process.execPath}" "${fixture}" %*\r\n`); + else { fs.copyFileSync(fixture, path.join(bin, 'codex')); fs.chmodSync(path.join(bin, 'codex'), 0o755); } + for (const key of Object.keys(box.env)) if (key.toLowerCase() === 'path') delete box.env[key]; + box.env.PATH = bin + path.delimiter + process.env.PATH; + if (proxy) { + const socket = path.join(box.env.CODEX_HOME, 'app-server-control/app-server-control.sock'); + fs.mkdirSync(path.dirname(socket), { recursive: true }); fs.writeFileSync(socket, ''); + } + box.run = (mode = 'get', title) => spawnSync(process.execPath, [path.join(scripts, 'codex-title.js'), mode, id], { env: box.env, input: title, encoding: 'utf8', timeout: 15000 }); + box.log = () => fs.readFileSync(box.env.TITLE_TEST_LOG, 'utf8').trim().split('\n').map(JSON.parse); + return box; +} +test('Codex read preserves fragmented Unicode and handshake order', (t) => { + const box = mock(t, 'unicode'); const r = box.run(); success(r); assert.match(r.stdout, /Café 🚀 東京/); + assert.deepEqual(box.log().filter(x => x.method).map(x => x.method), ['initialize', 'initialized', 'thread/read']); + assert.equal(box.log().filter(x => x.start).length, 1); +}); +test('Codex set preserves literal whitespace and shell characters', (t) => { + const box = mock(t, 'normal'); const title = ' Café 🚀 $HOME `echo no` "quoted" '; + success(box.run('set', title + '\n')); + assert.equal(box.log().find(x => x.method === 'thread/name/set').params.name, title); +}); +test('Codex null title is a valid empty title', (t) => { const box = mock(t, 'normal'); const r = box.run(); success(r); assert.match(r.stdout, /No current title/); }); +test('transport failure falls back from proxy', (t) => { + const box = mock(t, 'fallback', true); success(box.run()); assert.equal(box.log().filter(x => x.start).length, 2); +}); +for (const mode of ['method-error', 'handshake-error', 'bad-response']) test(mode + ' fails without retry', (t) => { + const box = mock(t, mode, true); const r = box.run(); assert.equal(r.status, 1, r.stderr); assert.equal(box.log().filter(x => x.start).length, 1); +}); +test('Codex read failure stops entry without printing a rename command', (t) => { + const box = mock(t, 'method-error'); const r = shell(box.env, 'session.sh'); assert.equal(r.status, 1); assert.doesNotMatch(r.stdout, /Rename with/); assert.match(r.stderr, /thread not loaded/); +}); +for (const alias of ['CODEX_THREAD_ID', 'CODEX_SESSION_ID']) test('Codex entry routing via ' + alias, (t) => { + const box = mock(t, 'normal'); delete box.env.CODEX_THREAD_ID; box.env[alias] = id; + const r = shell(box.env, 'session.sh'); success(r); assert.match(r.stdout, /HOST: Codex/); assert.match(r.stdout, /Rename with/); +}); + +test('startup block preserves detection errors for the agent', (t) => { + const { env } = sandbox(t, { CLAUDE_SKILL_DIR: path.dirname(scripts) }); + const skill = fs.readFileSync(path.join(scripts, '../SKILL.md'), 'utf8'); + const block = skill.match(/```!\n([\s\S]*?)\n```/)[1]; + const r = spawnSync(bash, ['-c', block], { env, encoding: 'utf8', timeout: 15000 }); + success(r); assert.match(r.stdout, /SESSION_CHECK_FAILED/); assert.match(r.stderr, /could not tell which agent/); assert.doesNotMatch(r.stdout, /Rename with/); +}); + +test('sandbox denial requests host approval without retrying another transport', (t) => { + const box = mock(t, 'permission-denied', true); + const r = shell(box.env, 'session.sh'); + assert.equal(r.status, 1); assert.match(r.stderr, /APPROVAL_REQUIRED/); + assert.match(r.stderr, /approval\/escalation/); assert.match(r.stderr, /Keep the current CODEX_HOME and session id/); + assert.doesNotMatch(r.stdout, /Rename with/); assert.equal(box.log().filter(x => x.start).length, 1); +});