Files
backnotprop__plannotator/apps/hook/server/codex-session.ts
Michael Ramos 26ca4e0275 Single-source skills (core/extra), replace Claude Code commands with skills, de-hardcode installers (#850)
* feat: single-source skills into core/extra, replace Claude commands with skills, de-hardcode installers

- apps/skills/core/{review,annotate,last,archive}: single authoritative
  source for the always-installed skills (archive is new); all carry
  disable-model-invocation + agents/openai.yaml sidecars
- apps/skills/extra/{compound,setup-goal,visual-explainer}: no longer
  default-installed (except Kiro); installers print an
  `npx skills add backnotprop/plannotator/apps/skills/extra` suggestion
- Claude Code: apps/hook/commands/ deleted, command heredocs removed;
  core skills in ~/.claude/skills are the slash commands now
- Installers: OpenCode/Gemini command files copied from an extended
  sparse checkout instead of heredocs; install.cmd gains the previously
  missing OpenCode command install; aggressive cleanup of legacy
  ~/.claude/commands and ~/.codex/skills artifacts
- Codex: core skills install to ~/.agents/skills (official path);
  ~/.codex/skills install removed
- Pi: extension no longer bundles skills; #670 settings filter removed

* fix: review findings — old-tag soft guards, cmd replace-not-merge, plugin-update hint, frontmatter test

- install.sh: a --version tag predating apps/skills/core no longer aborts
  the whole copy subshell (which also skipped OpenCode/Gemini commands);
  core skills now soft-skip with an accurate message, matching ps1/cmd
- install.sh: subshell failure message no longer claims "git required"
  when git was present (clone/network errors get their own wording)
- install.cmd: pre-remove skill dirs before xcopy so upgrades replace
  rather than merge (stale files from renamed/deleted skill files no
  longer linger; parity with sh/ps1)
- all installers + docs: tell upgraders to run /plugin marketplace update
  so the plugin's old namespaced plannotator:* commands disappear (#817)
- install.test.ts: assert every core SKILL.md sets
  disable-model-invocation: true — the load-bearing line that keeps core
  skills out of Pi's system prompt (#842 regression guard)

* test: pin old-tag soft-guard behavior, dedupe core-skill list in tests

* fix: interrogation review findings — cross-installer diagnostic parity

- install.ps1/install.cmd: emit the "predates the core/extra skill
  layout" diagnostic on old pinned tags instead of silently skipping
  core skills (parity with install.sh)
- install.ps1: clone/network failure no longer claims "git required"
  (git was already verified present); the outer catch now reports the
  actual exception
- install.sh: "Installed OpenCode/Gemini commands" echoes are guarded
  on the copy actually having a source, so old pinned tags don't print
  false success (ps1/cmd already gated this way)
- AGENTS.md: opencode-plugin commands/ comment now reflects all four
  command stubs
- install.test.ts: shared test asserts the soft-skip diagnostic exists
  in all three installers and pins ps1's honest failure wording

* fix: respect CODEX_HOME for Codex home directory (#852)

Codex stores config and state under $CODEX_HOME when set, falling back
to ~/.codex (developers.openai.com/codex/config-advanced). Plannotator
hardcoded ~/.codex in two places:

- runtime: codex-session.ts scanned ~/.codex/sessions for rollout
  files, so `plannotator last` failed with "No rendered assistant
  message found" when CODEX_HOME pointed elsewhere. Now resolved the
  same way copilot-session.ts handles COPILOT_HOME and session-log.ts
  handles CLAUDE_CONFIG_DIR.
- installers: detection, config.toml/hooks.json paths, manual-setup
  instructions, and the stale-skills cleanup now derive from
  CODEX_HOME in all three scripts.

Tests: codex-session.test.ts covers rollout discovery under a
CODEX_HOME temp dir; install.test.ts asserts all three installers
respect the variable and that the fallback is the only hardcoded
~/.codex path left in install.sh.

* fix: hard-fail skill install, guard command cleanup, one-time extras migration

External review triage on PR #850 surfaced two real installer issues:

P1 — commands deleted before replacement: the Claude command cleanup
ran before the git-gated skill install, so a missing git, a failed
clone, or an old pinned tag deleted the user's slash commands and
installed nothing (a regression — the old installer needed no git).
Now:
- missing git is a hard failure before anything is touched ("install
  git, then run this installer again")
- a failed fetch is a hard failure ("something went wrong — run the
  installer again") instead of a silent skip
- the legacy command cleanup runs AFTER the install and only removes a
  command file when its same-name replacement skill exists on disk
- old pinned tags keep the soft-skip (no deletion happens, commands
  survive, CI e2e against old tags stays green)

P2 — recurring extras deletion: the extras cleanup ran on every
invocation, deleting copies users reinstalled via the suggested
`npx skills add` (the copies are byte-identical, so only provenance
can tell them apart). The cleanup is now a one-time migration recorded
in a migrations ledger under the Plannotator data dir
(<PLANNOTATOR_DATA_DIR|~/.plannotator>/migrations/), the same
record-what-you-did pattern package managers use.

All three installers (sh/ps1/cmd) updated in parity; tests pin the
guard condition, the ledger gating, and the hard-fail messages.

* test: tripwire — install.cmd must never contain /dev/null redirects

* fix: every skill sets disable-model-invocation — no exceptions

Maintainer rule: all Plannotator skills are user-invoked, never
model-auto-invoked. setup-goal (missing since #665) and the three Kiro
skills now carry the flag. The frontmatter test scans every SKILL.md in
apps/skills/core, apps/skills/extra, and apps/kiro-cli/skills
dynamically — with a floor of 10 — so a future skill cannot ship
without it.

* docs: git is a hard installer requirement; clarify post-gate sections complete on re-run

* docs: align ps1/cmd comments with hard-fail semantics

* feat: guided install — extras opt-in via skills CLI, model-invocation picker

Interactive terminals get a two-question wizard on first run:
1. Install the extra skills? Yes delegates to `npx skills add
   backnotprop/plannotator/apps/skills/extra` (its UI picks the agents),
   wired to /dev/tty so piped curl|bash installs still work. Skipped
   when extras already exist on disk.
2. Make any skills callable by the model? Yes opens a space-toggle
   checkbox (sh/ps1) or numbered toggles (cmd), listing all skills if
   extras were chosen, core-only otherwise. Chosen skills get
   disable-model-invocation stripped from their INSTALLED copies and the
   Codex sidecar's allow_implicit_invocation flipped — re-applied every
   run since installs replace skill folders. Repo sources stay locked.

Answers persist to <data dir>/install-prefs (shared format across all
three installers) and re-runs reuse them silently; --reconfigure
re-opens the wizard. Automation is untouched: no terminal means no
prompts and today's defaults; --extras/--no-extras/--model-invocable/
--non-interactive give scripts explicit control.

* fix: self-review of guided install — cmd pipe expansion bug, flag/wizard interplay

- install.cmd: the checkbox preselection used `echo !var! | findstr` —
  each side of a cmd pipe runs in a child WITHOUT delayed expansion, so
  the saved choices passed through as literal !var! text and
  preselection never matched. Replaced with a substring-replace
  containment test (no pipe).
- all three: a wizard question whose answer was already provided by a
  CLI flag (--extras/--no-extras/--model-invocable) is no longer asked
  and then silently overridden — the flag pre-answers it.
- install.cmd: unknown-option usage line now lists the wizard flags.

* feat: guided install question 3 — install Glimpse (native window)

glimpseui (third-party npm package, PR #840) gives Plannotator a native
WebView window instead of a browser tab; the runtime already
auto-detects it on PATH, so a global install is all that's needed.

- Wizard asks "Install Glimpse?" (default yes) after the skills
  questions; skipped when glimpseui is already on PATH
- Yes runs `npm install -g glimpseui` (bun fallback on sh/ps1; printed
  instruction when neither exists) — wizard or explicit flag only,
  silent re-runs never install software
- --glimpse / --no-glimpse flags for automation; choice persisted to
  install-prefs like the others
- docs + tests updated (glimpse detection, install command, flags, and
  persist-condition assertions across all three installers)

* fix: self-review of Glimpse question — cmd bun fallback, stale usage text

* fix: merge-window hardening — guard Codex cleanup, remove old-installer junk dirs

plannotator.ai serves install.sh live from main (public/ symlink,
deployed on push), while the script fetches repo files at the LATEST
RELEASE TAG. Between merging the core/extra restructure and cutting the
release that ships it, the live script runs against the old-layout tag.
Two hazards in that window:

1. The Codex stale-skill cleanup removed working ~/.codex/skills with
   no successor installed (core skills soft-skip on old tags). Now the
   cleanup runs AFTER the install and removes a core skill only once
   its replacement exists in ~/.agents/skills — same guard the Claude
   command cleanup uses. The compound/setup-goal stale copies stay
   unconditional (never Codex's to begin with).

2. The reverse combo (cached OLD script + NEW release tag) wholesale-
   copies apps/skills/* and leaves junk core/ and extra/ directory
   copies in ~/.claude/skills. Never valid skill names — all three
   installers now remove them on every run.

* fix: glimpseui is a devDependency — consumers never use it from node_modules

PR #840 added glimpseui to dependencies in @plannotator/server and
@plannotator/pi-extension, but nothing imports it: both runtimes detect
the CLI on PATH (Bun.which / a manual PATH walk) and spawn it. The dep
only ever mattered in repo development, where `bun run` prepends
node_modules/.bin to PATH. For consumers it was inert download weight —
OpenCode plugin installs and `pi install` pulled a third-party package
that could never be detected (Pi's loader does not expose
node_modules/.bin; verified). Moved to devDependencies in both: dev
flows keep working, published packages stop shipping it. The sanctioned
end-user path is the guided installer's global `npm install -g
glimpseui`.

* fix: clean stale plugin command files from the installed plugin checkout (#817)

The installer already manages hooks.json inside
~/.claude/plugins/marketplaces/plannotator/apps/hook/, so the earlier
"don't reach into plugin storage" rationale for leaving the old
namespaced plannotator:* command files there was inconsistent. All
three installers now remove them — same replacement-skill guard as the
bare ~/.claude/commands cleanup — making the #817 duplicate menu
entries die on a single installer run + restart instead of waiting for
/plugin marketplace update. Hints/docs reworded accordingly.

* Revert "fix: clean stale plugin command files from the installed plugin checkout (#817)"

This reverts commit 3df37da92c.

* fix: Windows CI — assert the new no-commands contract; gate cmd wizard on a real console

The cmd e2e CI step still asserted the OLD contract (installer writes
~/.claude/commands/plannotator-*.md with the ! prefix). Commands are
dead; the step now guards the NEW contract: a fresh install must write
NO plannotator command files. The Gemini TOML assertions stay — they
now verify the verbatim checkout copy delivers intact files (the tag
the e2e pins, v0.17.1, contains apps/gemini/commands).

The failure also exposed that install.cmd ran the wizard on redirected
stdin (set /p falls through to defaults at EOF) — and Q3's default
being yes meant CI silently ran `npm install -g glimpseui`. cmd now
probes for a real console via `timeout /t 0` (errors when stdin is
redirected), matching sh's /dev/tty and ps1's IsInputRedirected gates:
no console, no wizard, no wizard-only installs.
2026-06-05 09:23:45 -07:00

471 lines
13 KiB
TypeScript

/**
* Codex Session Parser
*
* Extracts the last rendered assistant message from a Codex rollout file.
* Codex stores sessions at $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl
* (default ~/.codex when CODEX_HOME is unset)
*
* Detection: Codex injects CODEX_THREAD_ID into every spawned process.
* The thread ID is the UUID in the rollout filename.
*
* Rollout format (JSONL, one object per line):
* {"timestamp":"...","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"..."}]}}
* {"timestamp":"...","type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"...","call_id":"..."}}
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
// --- Types ---
type CodexPlanSource = "plan-item" | "assistant-message";
interface RolloutEntry {
timestamp?: string;
type: string;
payload?: {
type?: string;
role?: string;
content?: { type: string; text?: string }[];
turn_id?: string;
item?: {
type?: string;
text?: string;
[key: string]: unknown;
};
[key: string]: unknown;
};
}
interface CodexPlanCandidate {
index: number;
text: string;
source: CodexPlanSource;
}
export interface CodexPlanResult {
text: string;
source: CodexPlanSource;
}
export interface GetLastCodexMessageOptions {
beforeActiveTurn?: boolean;
}
export interface GetLatestCodexPlanOptions {
turnId?: string;
stopHookActive?: boolean;
}
const TURN_START_TYPES = new Set(["task_started", "turn_started"]);
const TURN_COMPLETE_TYPES = new Set(["task_complete", "turn_completed"]);
const PROPOSED_PLAN_RE = /<proposed_plan>([\s\S]*?)<\/proposed_plan>/gi;
// --- Rollout File Discovery ---
/**
* Resolve the Codex home directory. Codex stores config and state under
* $CODEX_HOME when set, falling back to ~/.codex
* (https://developers.openai.com/codex/config-advanced#config-and-state-locations).
* Same pattern as COPILOT_HOME in copilot-session.ts and CLAUDE_CONFIG_DIR
* in session-log.ts. (#852)
*/
function codexHome(): string {
return process.env.CODEX_HOME || join(homedir(), ".codex");
}
/**
* Find the Codex rollout file for a given thread ID.
* The thread ID is the UUID portion of the filename:
* rollout-<timestamp>-<uuid>.jsonl
*
* Scans $CODEX_HOME/sessions/ (default ~/.codex/sessions/) for a matching file.
*/
export function findCodexRolloutByThreadId(threadId: string): string | null {
const sessionsDir = join(codexHome(), "sessions");
try {
// Walk YYYY/MM/DD directories in reverse order (most recent first)
const years = readdirSync(sessionsDir).sort().reverse();
for (const year of years) {
const yearDir = join(sessionsDir, year);
if (!isDir(yearDir)) continue;
const months = readdirSync(yearDir).sort().reverse();
for (const month of months) {
const monthDir = join(yearDir, month);
if (!isDir(monthDir)) continue;
const days = readdirSync(monthDir).sort().reverse();
for (const day of days) {
const dayDir = join(monthDir, day);
if (!isDir(dayDir)) continue;
const files = readdirSync(dayDir);
for (const file of files) {
if (file.endsWith(".jsonl") && file.includes(threadId)) {
return join(dayDir, file);
}
}
}
}
}
} catch {
return null;
}
return null;
}
function isDir(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
// --- Message Extraction ---
function parseRolloutEntries(rolloutPath: string): RolloutEntry[] {
const content = readFileSync(rolloutPath, "utf-8");
if (!content.trim()) return [];
return content
.split(/\r?\n/)
.filter(Boolean)
.flatMap((line) => {
try {
return [JSON.parse(line) as RolloutEntry];
} catch {
return [];
}
});
}
function getMessageText(
entry: RolloutEntry,
allowedContentTypes: readonly string[]
): string | null {
if (entry.type !== "response_item") return null;
if (entry.payload?.type !== "message") return null;
const contentBlocks = entry.payload?.content;
if (!Array.isArray(contentBlocks)) return null;
const textParts = contentBlocks
.filter((block) => allowedContentTypes.includes(block.type))
.map((block) => (typeof block.text === "string" ? block.text.trim() : ""))
.filter(Boolean);
if (textParts.length === 0) return null;
return textParts.join("\n");
}
function extractLastProposedPlan(text: string): string | null {
const matches = Array.from(text.matchAll(PROPOSED_PLAN_RE));
const latest = matches.at(-1)?.[1]?.trim();
return latest || null;
}
function normalizePlan(text: string): string {
return text.replace(/\r\n/g, "\n").trim();
}
function findLastIndex(
entries: RolloutEntry[],
predicate: (entry: RolloutEntry) => boolean
): number {
for (let i = entries.length - 1; i >= 0; i--) {
if (predicate(entries[i])) return i;
}
return -1;
}
function findTurnStartIndex(entries: RolloutEntry[], turnId?: string): number {
const matchingTurnStart = findLastIndex(
entries,
(entry) =>
entry.type === "event_msg" &&
TURN_START_TYPES.has(entry.payload?.type || "") &&
(!turnId || entry.payload?.turn_id === turnId)
);
if (matchingTurnStart !== -1) return matchingTurnStart;
const matchingTurnContext = findLastIndex(
entries,
(entry) =>
entry.type === "turn_context" &&
(!turnId || entry.payload?.turn_id === turnId)
);
if (matchingTurnContext !== -1) return matchingTurnContext;
const lastTurnStart = findLastIndex(
entries,
(entry) =>
entry.type === "event_msg" &&
TURN_START_TYPES.has(entry.payload?.type || "")
);
if (lastTurnStart !== -1) return lastTurnStart;
const lastTurnContext = findLastIndex(
entries,
(entry) => entry.type === "turn_context"
);
return lastTurnContext === -1 ? 0 : lastTurnContext;
}
function findActiveTurnStartIndex(entries: RolloutEntry[]): number {
const latestTurnStart = findLastIndex(
entries,
(entry) =>
entry.type === "event_msg" &&
TURN_START_TYPES.has(entry.payload?.type || "")
);
if (latestTurnStart === -1) return -1;
const latestTurnComplete = findLastIndex(
entries,
(entry) =>
entry.type === "event_msg" &&
TURN_COMPLETE_TYPES.has(entry.payload?.type || "")
);
return latestTurnStart > latestTurnComplete ? latestTurnStart : -1;
}
function isHookPromptMessage(entry: RolloutEntry): boolean {
if (entry.type !== "response_item") return false;
if (entry.payload?.type !== "message") return false;
if (entry.payload?.role !== "user") return false;
const messageText = getMessageText(entry, ["input_text"]);
return !!messageText?.includes("<hook_prompt");
}
function findLastHookPromptIndex(
entries: RolloutEntry[],
startIndex: number
): number {
for (let i = entries.length - 1; i >= Math.max(startIndex, 0); i--) {
if (isHookPromptMessage(entries[i])) return i;
}
return -1;
}
function getPlanItemText(
entry: RolloutEntry,
turnId?: string
): string | null {
if (entry.type !== "event_msg") return null;
if (entry.payload?.type !== "item_completed") return null;
if (turnId && entry.payload?.turn_id !== turnId) return null;
const itemType = entry.payload?.item?.type;
if (itemType !== "Plan" && itemType !== "plan") return null;
const text = entry.payload?.item?.text;
return typeof text === "string" && text.trim() ? text.trim() : null;
}
function getAssistantProposedPlanText(entry: RolloutEntry): string | null {
if (entry.type !== "response_item") return null;
if (entry.payload?.type !== "message") return null;
if (entry.payload?.role !== "assistant") return null;
const messageText = getMessageText(entry, ["output_text"]);
if (!messageText) return null;
return extractLastProposedPlan(messageText);
}
function collectPlanCandidates(
entries: RolloutEntry[],
startIndex: number,
turnId?: string
): CodexPlanCandidate[] {
const candidates: CodexPlanCandidate[] = [];
for (let i = Math.max(startIndex, 0); i < entries.length; i++) {
const entry = entries[i];
const planItemText = getPlanItemText(entry, turnId);
if (planItemText) {
candidates.push({ index: i, text: planItemText, source: "plan-item" });
}
const assistantPlanText = getAssistantProposedPlanText(entry);
if (assistantPlanText) {
candidates.push({
index: i,
text: assistantPlanText,
source: "assistant-message",
});
}
}
return candidates;
}
function pickLatestPreferredPlan(
candidates: CodexPlanCandidate[]
): CodexPlanCandidate | null {
const latestPlanItem = [...candidates]
.reverse()
.find((candidate) => candidate.source === "plan-item");
if (latestPlanItem) return latestPlanItem;
return candidates.at(-1) || null;
}
/**
* Extract the last assistant message from a Codex rollout file.
*
* Walks backward through the JSONL, finds the last entry where:
* type === "response_item"
* payload.type === "message"
* payload.role === "assistant"
*
* Extracts output_text blocks from payload.content.
*/
export function getLastCodexMessage(
rolloutPath: string,
options: GetLastCodexMessageOptions = {}
): { text: string } | null {
const entries = parseRolloutEntries(rolloutPath);
const activeTurnStart = options.beforeActiveTurn
? findActiveTurnStartIndex(entries)
: -1;
const endIndex = activeTurnStart === -1 ? entries.length - 1 : activeTurnStart - 1;
// Walk backward
for (let i = endIndex; i >= 0; i--) {
const entry = entries[i];
if (entry.type !== "response_item") continue;
if (entry.payload?.type !== "message") continue;
if (entry.payload?.role !== "assistant") continue;
const messageText = getMessageText(entry, ["output_text"]);
if (messageText) return { text: messageText };
}
return null;
}
/**
* Extract up to `limit` of the most recent assistant messages from a Codex
* rollout file. Returned newest-first.
*
* Used by the picker UI to let users choose among recent messages rather
* than always defaulting to the newest transcript entry — which is incorrect
* after a /rewind.
*/
export interface CodexRecentMessage {
messageId: string;
text: string;
timestamp?: string;
}
export function getRecentCodexMessages(
rolloutPath: string,
limit: number,
options: GetLastCodexMessageOptions = {}
): CodexRecentMessage[] {
if (limit <= 0) return [];
const entries = parseRolloutEntries(rolloutPath);
const activeTurnStart = options.beforeActiveTurn
? findActiveTurnStartIndex(entries)
: -1;
const endIndex = activeTurnStart === -1 ? entries.length - 1 : activeTurnStart - 1;
const messages: CodexRecentMessage[] = [];
for (let i = endIndex; i >= 0; i--) {
if (messages.length >= limit) break;
const entry = entries[i];
if (entry.type !== "response_item") continue;
if (entry.payload?.type !== "message") continue;
if (entry.payload?.role !== "assistant") continue;
const text = getMessageText(entry, ["output_text"]);
if (!text) continue;
// Codex doesn't expose a stable message id in the rollout format we read,
// so fall back to an index-based id. Stable within a single rollout read.
messages.push({
messageId: `codex-msg-${i}`,
text,
timestamp: entry.timestamp,
});
}
return messages;
}
/**
* Extract the latest Codex plan from a rollout file.
*
* Primary source: persisted completed TurnItem::Plan events.
* Fallback source: raw assistant response_item messages that still contain a
* <proposed_plan> block in the rollout transcript.
*
* When stopHookActive is true, this only returns a changed post-feedback plan:
* - no plan after the last hook prompt => null
* - identical plan after the last hook prompt => null
*/
export function getLatestCodexPlan(
rolloutPath: string,
options: GetLatestCodexPlanOptions = {}
): CodexPlanResult | null {
const entries = parseRolloutEntries(rolloutPath);
if (entries.length === 0) return null;
const turnStartIndex = findTurnStartIndex(entries, options.turnId);
const candidates = collectPlanCandidates(
entries,
turnStartIndex,
options.turnId
);
if (candidates.length === 0) return null;
if (!options.stopHookActive) {
const latestPlan = pickLatestPreferredPlan(candidates);
return latestPlan
? { text: latestPlan.text, source: latestPlan.source }
: null;
}
const lastHookPromptIndex = findLastHookPromptIndex(entries, turnStartIndex);
if (lastHookPromptIndex === -1) {
const latestPlan = pickLatestPreferredPlan(candidates);
return latestPlan
? { text: latestPlan.text, source: latestPlan.source }
: null;
}
const plansAfterHookPrompt = candidates.filter(
(candidate) => candidate.index > lastHookPromptIndex
);
if (plansAfterHookPrompt.length === 0) return null;
const latestAfterHookPrompt = pickLatestPreferredPlan(plansAfterHookPrompt);
if (!latestAfterHookPrompt) return null;
const plansBeforeHookPrompt = candidates.filter(
(candidate) => candidate.index < lastHookPromptIndex
);
const latestBeforeHookPrompt = pickLatestPreferredPlan(plansBeforeHookPrompt);
if (
latestBeforeHookPrompt &&
normalizePlan(latestBeforeHookPrompt.text) ===
normalizePlan(latestAfterHookPrompt.text)
) {
return null;
}
return {
text: latestAfterHookPrompt.text,
source: latestAfterHookPrompt.source,
};
}