Browse Source

feat(cache-safety): add layered prompt-cache regression guardrails

Past releases repeatedly busted provider prompt caches through hook
changes (see #785, #790, #804), each caught only after cost/latency
regressions in the field. This adds layered defenses that run in plain
`bun test`/CI, plus a runtime watchdog:

- cache-safe-injection utility: single supported way for hooks to add
  payload content (tagged tail parts for deterministic content, tagged
  trailing message for volatile content); phase-reminder,
  post-file-tool-nudge, and the background job board migrated onto it
  with byte-identical output
- property tests: turn-over-turn prefix stability, volatile-tail
  isolation, and clock/randomness determinism over the real transform
  pipeline, with drift guards pinned to the composition in src/index.ts
  and to the set of transform-defining hook files
- golden snapshots of injected prompt surfaces (phase reminder,
  orchestrator prompt, canonical payload) so cache-busting prompt edits
  fail CI until deliberately updated via `bun test --update-snapshots`
- cache-monitor hook: logs a warning when a session that was hitting
  the provider cache reports zero cache-read tokens on a sizeable
  request
- tripwire test banning volatile-input patterns (Date.now, new Date,
  Math.random, randomUUID, performance.now) in prompt-assembly
  directories outside a justified allowlist

Documents the invariant and enforcement map in AGENTS.md and
docs/cache-verification.md. Includes Biome formatting fixes picked up
by `bun run check` in three untouched files.
Alvin Unreal 2 weeks ago
parent
commit
e5071d58aa

+ 38 - 0
AGENTS.md

@@ -106,6 +106,42 @@ For plugin or Companion releases, follow `docs/release.md`. It documents the
 required diff inspection, companion asset workflow, GitHub release creation,
 tagging, verification, and npm publish order.
 
+## Prompt Cache Safety
+
+Provider prompt caches are exact byte-prefix matches over the rendered
+request (tools → system → messages). Any byte that changes earlier in the
+payload invalidates the cache for everything after it, so every request in
+the session re-pays full input cost and latency. Past regressions in this
+repo all came from hooks rewriting or repositioning earlier conversation
+content.
+
+Rules when touching anything that feeds the outgoing payload (hooks,
+agent prompts, config constants):
+
+- Inject content only through `src/hooks/cache-safe-injection.ts`:
+  deterministic content via `appendTaggedSyntheticPart` (tail of an existing
+  message), per-turn volatile content via `stripTaggedContent` +
+  `appendTrailingVolatileMessage` (trailing message, end of payload).
+- Never mutate or reorder earlier messages, and never let timestamps,
+  randomness, or per-request IDs reach content before the payload tail.
+- Keep system prompts and tool sets frozen for the lifetime of a session.
+
+Enforcement (all run in `bun test` / CI):
+
+- `src/hooks/cache-safety.property.test.ts` — prefix-stability and
+  determinism properties over the real transform pipeline, with a drift
+  guard pinned to the composition in `src/index.ts`. New transform steps
+  must be added to `src/hooks/cache-safety-harness.test.ts`.
+- `src/hooks/cache-payload.snapshot.test.ts` — golden snapshots of injected
+  prompt surfaces; failing means the change busts caches once fleet-wide and
+  must be updated deliberately via `bun test --update-snapshots`.
+- `src/cache-safety-tripwire.test.ts` — bans volatile-input patterns in
+  prompt-assembly directories outside a justified allowlist.
+- `src/hooks/cache-monitor/` — runtime watchdog that logs a warning when a
+  session that was hitting the provider cache reports zero cached tokens.
+
+See `docs/cache-verification.md` for the full verification story.
+
 ## Pre-Push Code Review
 
 Before pushing changes to the repository, when makes sense run a code review to catch issues like:
@@ -113,6 +149,8 @@ Before pushing changes to the repository, when makes sense run a code review to
 - Redundant function calls
 - Race conditions
 - Logic errors
+- Cache safety: prompt-prefix rewrites, volatile content outside the
+  trailing zone, or injections bypassing `src/hooks/cache-safe-injection.ts`
 
 ## Repository Map
 

+ 43 - 2
docs/cache-verification.md

@@ -1,8 +1,11 @@
 # Cache verification
 
-This project has two complementary checks. They answer different questions and
-should not be conflated.
+This project has four complementary layers. They answer different questions
+and should not be conflated.
 
+- **Continuous cache-safety tests** run in `bun test` (and therefore CI) and
+  enforce the payload invariants directly against the hook pipeline. This is
+  the first line of defense against cache regressions.
 - **Deterministic payload verification** proves that this plugin projects a
   stable provider payload under a controlled local capture server. It does not
   measure a provider cache.
@@ -10,6 +13,44 @@ should not be conflated.
   provider-backed OpenCode server. It is opt-in, provider-specific, and useful
   for comparing explicitly controlled arms; it is not a CI check or a general
   cache guarantee.
+- **Runtime cache monitoring** watches provider-reported cache telemetry
+  during real sessions and logs a warning when a cache bust signature appears.
+
+## Continuous cache-safety tests (CI)
+
+Provider prompt caches are exact byte-prefix matches over the rendered
+request. Instead of enumerating known-good payload shapes, these suites
+assert the properties every transform must uphold, so they also catch
+mistakes that have not been made before:
+
+- `src/hooks/cache-safety.property.test.ts` — re-renders a growing
+  conversation through the real transform pipeline (mirroring the
+  composition in `src/index.ts`) and asserts turn-over-turn byte-prefix
+  stability, isolation of volatile content to the tagged trailing message,
+  determinism under wall-clock/randomness changes, and pass-through of
+  specialist payloads. A drift guard fails when `src/index.ts` gains, loses,
+  or reorders transform steps without the suite being updated.
+- `src/hooks/cache-payload.snapshot.test.ts` — golden snapshots of every
+  prompt surface the plugin injects (phase reminder, orchestrator system
+  prompt, canonical transformed payload). A failure means the change will
+  invalidate provider caches for existing sessions once; update deliberately
+  with `bun test --update-snapshots` so the PR diff documents the impact.
+- `src/cache-safety-tripwire.test.ts` — scans prompt-assembly directories
+  for volatile-input patterns (`Date.now`, `new Date`, `Math.random`,
+  `randomUUID`, `performance.now`) outside a justified allowlist.
+
+All hook injections must go through `src/hooks/cache-safe-injection.ts`; see
+the Prompt Cache Safety section in `AGENTS.md` for the authoring rules.
+
+## Runtime cache monitoring
+
+`src/hooks/cache-monitor/` observes `message.updated` events and the
+provider-reported `tokens.cache.read` / `tokens.cache.write` counters. When a
+session that previously hit the cache reports zero cache-read tokens on a
+sizeable request, it logs a `[cache-monitor] possible prompt-cache bust`
+warning (once per bust streak) to the plugin log. Providers that never report
+cache telemetry produce no warnings. This is the field safety net for
+provider-side behavior no offline test can model.
 
 ## Prerequisites
 

+ 135 - 0
src/cache-safety-tripwire.test.ts

@@ -0,0 +1,135 @@
+/**
+ * Cache-safety tripwire — scans prompt-assembly source directories for
+ * volatile-input patterns that silently invalidate provider prompt caches.
+ *
+ * Provider caches are exact byte-prefix matches over the rendered request.
+ * A `Date.now()`, `new Date(...)`, `Math.random()`, or `randomUUID()` whose
+ * value reaches the prompt prefix makes every request's prefix unique, so
+ * nothing is ever served from cache — silently, with no error.
+ *
+ * When this test fails for a new file:
+ *
+ *   1. If the value can reach prompt content, keep it out of the stable
+ *      prefix: route it through the trailing volatile zone via
+ *      src/hooks/cache-safe-injection.ts, or drop it.
+ *   2. If the value never feeds prompt content (timers, temp file names,
+ *      internal bookkeeping), add an allowlist entry below with a
+ *      justification that a reviewer can verify.
+ *
+ * See docs/cache-verification.md for the full invariant.
+ */
+
+import { describe, expect, test } from 'bun:test';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+
+const SRC_ROOT = import.meta.dir;
+
+/** Directories that participate in prompt/payload assembly. */
+const SCAN_DIRS = ['hooks', 'agents', 'config'];
+
+const VOLATILE_PATTERNS: Array<{ name: string; regex: RegExp }> = [
+  { name: 'Date.now()', regex: /\bDate\.now\(/ },
+  { name: 'new Date(...)', regex: /\bnew Date\(/ },
+  { name: 'Math.random()', regex: /\bMath\.random\(/ },
+  { name: 'randomUUID()', regex: /\brandomUUID\b/ },
+  { name: 'performance.now()', regex: /\bperformance\.now\(/ },
+];
+
+/**
+ * Files allowed to use volatile inputs, each with a reviewer-verifiable
+ * reason why the value can never reach the prompt prefix. Adding an entry
+ * is a code-review decision, not a formality.
+ */
+const ALLOWLIST = new Map<string, string>([
+  [
+    'hooks/auto-update-checker/skill-sync.ts',
+    'Update scheduling and install bookkeeping; produces no prompt content.',
+  ],
+  [
+    'hooks/loop-command/index.ts',
+    'Timestamps/randomness name per-run loop-history directories; the path only appears inside a newly appended user turn (payload tail), never in earlier prefix bytes.',
+  ],
+  [
+    'hooks/foreground-fallback/index.ts',
+    'Date.now() gates retry/dedup windows for model failover; no prompt content is derived from it.',
+  ],
+  [
+    'hooks/apply-patch/prepared-changes.ts',
+    'randomUUID() names temp files during atomic writes; never serialized into messages.',
+  ],
+  [
+    'hooks/task-session-manager/task-context-tracker.ts',
+    'Date.now() records lastReadAt for internal recency ordering; formatted prompt output (background job board) is confined to the volatile trailing message.',
+  ],
+  [
+    'hooks/image-hook.ts',
+    'Date.now() throttles temp-image cleanup; extracted image paths are deterministic per part id.',
+  ],
+]);
+
+async function scanForViolations(): Promise<string[]> {
+  const violations: string[] = [];
+  const glob = new Bun.Glob('**/*.ts');
+
+  for (const dir of SCAN_DIRS) {
+    const root = path.join(SRC_ROOT, dir);
+    for await (const file of glob.scan(root)) {
+      if (file.endsWith('.test.ts')) continue;
+      const relative = `${dir}/${file}`;
+      if (ALLOWLIST.has(relative)) continue;
+
+      const content = readFileSync(path.join(root, file), 'utf8');
+      for (const pattern of VOLATILE_PATTERNS) {
+        if (pattern.regex.test(content)) {
+          violations.push(`${relative} uses ${pattern.name}`);
+        }
+      }
+    }
+  }
+
+  return violations;
+}
+
+describe('cache-safety tripwire', () => {
+  test('prompt-assembly code introduces no unreviewed volatile inputs', async () => {
+    const violations = await scanForViolations();
+
+    if (violations.length > 0) {
+      throw new Error(
+        [
+          'Volatile input detected in prompt-assembly code. If its value can',
+          'reach the prompt, it will silently bust the provider cache on',
+          'every request — keep it in the volatile tail via',
+          'src/hooks/cache-safe-injection.ts, or add a justified allowlist',
+          'entry in src/cache-safety-tripwire.test.ts (see file header).',
+          '',
+          ...violations,
+        ].join('\n'),
+      );
+    }
+  });
+
+  test('allowlist contains no stale entries', async () => {
+    const stale: string[] = [];
+
+    for (const [relative] of ALLOWLIST) {
+      const absolute = path.join(SRC_ROOT, relative);
+      let content: string;
+      try {
+        content = readFileSync(absolute, 'utf8');
+      } catch {
+        stale.push(`${relative} (file no longer exists)`);
+        continue;
+      }
+      const stillMatches = VOLATILE_PATTERNS.some((pattern) =>
+        pattern.regex.test(content),
+      );
+      if (!stillMatches) {
+        stale.push(`${relative} (no volatile patterns remain)`);
+      }
+    }
+
+    expect(stale).toEqual([]);
+  });
+});

+ 3 - 1
src/cli/install.ts

@@ -148,7 +148,9 @@ async function checkOpenCodeInstalled(): Promise<{
       console.log();
       printInfo('Or if already installed, add it to your PATH:');
       console.log(`     ${BLUE}export PATH="$HOME/.local/bin:$PATH"${RESET}`);
-      console.log(`     ${BLUE}export PATH="$HOME/.opencode/bin:$PATH"${RESET}`);
+      console.log(
+        `     ${BLUE}export PATH="$HOME/.opencode/bin:$PATH"${RESET}`,
+      );
     }
     return { ok: false };
   }

+ 2 - 4
src/cli/system.ts

@@ -74,10 +74,8 @@ function getOpenCodePaths(
 ): string[] {
   const home = environment.HOME || environment.USERPROFILE || '';
   const isWindows = process.platform === 'win32';
-  const appData =
-    environment.APPDATA || `${home}\\AppData\\Roaming`;
-  const localAppData =
-    environment.LOCALAPPDATA || `${home}\\AppData\\Local`;
+  const appData = environment.APPDATA || `${home}\\AppData\\Roaming`;
+  const localAppData = environment.LOCALAPPDATA || `${home}\\AppData\\Local`;
 
   const windowsPaths = isWindows
     ? [

+ 488 - 0
src/hooks/__snapshots__/cache-payload.snapshot.test.ts.snap

@@ -0,0 +1,488 @@
+// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
+
+exports[`cache-impact snapshots (update deliberately — see file header) phase reminder text 1`] = `
+"<system-reminder>
+!IMPORTANT! Scheduler workflow: First choose the lightest workflow that fits the work. If direct execution is justified, complete it and verify proportionately. Otherwise: plan lanes/dependencies → dispatch background specialists → track task IDs → wait for hook-driven completion → reconcile terminal results → verify. Do not poll running jobs, consume running-job output, or advance dependent work. !END!
+</system-reminder>"
+`;
+
+exports[`cache-impact snapshots (update deliberately — see file header) orchestrator system prompt 1`] = `
+"<Role>
+You are a workflow manager for coding work. Your job is to plan, schedule, delegate, monitor, reconcile, and verify specialist-agent work. You are not the default implementation worker.
+
+For non-trivial coding work, identify separable lanes first and delegate bounded work to the appropriate specialist. Do not perform multi-step implementation serially when a suitable specialist is available.
+
+Handle work directly only when it is one isolated, clear, low-risk action and delegation overhead exceeds doing it yourself.
+
+Optimize for quality, speed, cost, and reliability by dispatching the right specialist lanes, tracking background task state, and integrating terminal results into one coherent outcome.
+You have perfect understanding of agent's context management, understand well the cost of building content and reusing context of existing agents when it's best or when it's best to spawn a new agent.
+</Role>
+
+<Agents>
+
+@explorer
+- Lane: Fast codebase recon that returns compressed context
+- Permissions: read_files
+- Stats: 2x faster codebase search than orchestrator, 1/2 cost of orchestrator
+- Capabilities: Glob, grep, AST queries to locate files, symbols, patterns
+- **Delegate when:** Need to discover what exists before planning • Parallel searches speed discovery • Need summarized map vs full contents • Broad/uncertain scope
+- **Don't delegate when:** Know the path and need actual content • Need full file anyway • Single specific lookup • About to edit the file
+
+@librarian
+- Lane: External knowledge and library research, fast web research
+- Role: Authoritative source for current library docs, API references, examples, bug investigations, and web retrieval
+- Stats: 2x faster web research than orchestrator, 1/2 cost of orchestrator
+- **Delegate when:** Libraries with frequent API changes (React, Next.js, AI SDKs) • Complex APIs needing official examples (ORMs, auth) • Version-specific behavior matters • Unfamiliar library • Edge cases or advanced features • Nuanced best practices • Working on fixing tricky bug or problem and need latest web research information
+- **Don't delegate when:** Standard usage you're confident • Simple stable APIs • General programming knowledge • Info already in conversation • Built-in language features
+- **Rule of thumb:** "How does this library work?" → @librarian. "How does programming work?" → answer directly. How does others solve or workaround this tricky issue?" → @librarian.
+
+@oracle
+- Lane: Architecture, risk, debugging strategy, and review
+- Role: Strategic advisor for high-stakes decisions and persistent problems, code reviewer
+- Permissions: read_files
+- Stats: 5x better decision maker, problem solver, investigator than orchestrator, 0.8x speed of orchestrator, same cost.
+- Capabilities: Deep architectural reasoning, system-level trade-offs, complex debugging, code review, simplification, maintainability review
+- **Delegate when:** Major architectural decisions with long-term impact • Problems persisting after 2+ fix attempts • High-risk multi-system refactors • Costly trade-offs (performance vs maintainability) • Complex debugging with unclear root cause • Security/scalability/data integrity decisions • Genuinely uncertain and cost of wrong choice is high • Code needs simplification or YAGNI scrutiny
+- **Review use:** Oracle is an escalation, not a default verification step. Request independent Oracle review only when its analysis is expected to materially reduce risk or uncertainty.
+- **Don't delegate when:** Routine decisions you're confident about • First bug fix attempt • Straightforward trade-offs • Tactical "how" vs strategic "should" • Time-sensitive good-enough decisions • Quick research/testing can answer
+- **Rule of thumb:** Need senior architect review? → @oracle. Need code review or simplification? → @oracle. Routine coordination or final synthesis? → handle directly.
+
+@designer
+- Lane: UI/UX design, related edits, design polish and review
+- Permissions: read_files, write_files
+- Stats: 10x better UI/UX than orchestrator
+- Capabilities: Good design taste, visual relevant edits, interactions, responsive layouts, design systems with aesthetic intent, deep UI/UX knowledge.
+- Owns visual and interaction quality: layout, hierarchy, spacing, motion, affordances, responsive behavior, and overall feel.
+- Weakness: copywriting. Ask designer to use grounded, normal wording, then have orchestrator review/fix copy after design work without changing visual or interaction intent.
+- Avoid: "Let me us designer how it should look and implement yourself" → instead: "Let me ask designer to design and implement the UI/UX changes for me"
+- **Delegate when:** User-facing interfaces needing polish • Responsive layouts • UX-critical components (forms, nav, dashboards) • Visual consistency systems • Animations/micro-interactions • Landing/marketing pages • Refining functional→delightful • Reviewing existing UI/UX quality
+- **Don't delegate when:** Backend/logic with no visual • Quick prototypes where design doesn't matter yet.
+- **Rule of thumb:** Users see it and polish matters? → @designer. Headless/functional implementation? → schedule @fixer.
+
+@fixer
+- Lane: Bounded implementation and executioner
+- Role: Fast execution specialist for well-defined tasks
+- Permissions: read_files, write_files
+- Stats: 2x faster code edits, 1/2 cost of orchestrator
+- Weakness: design, taste
+- Tools/Constraints: Execution-focused-no research, no architectural decisions
+- **Delegate when:** For implementation work, think and triage first. If the change is non-trivial or multi-file, hand bounded execution to @fixer • Parallelization benefits: Task involves multiple folders and multiple files modification, scoping work per folder and spawning parallel @fixers for each folder.
+- **Don't delegate when:** Needs discovery/research/decisions • Single small change (<20 lines, one file) • Unclear requirements needing iteration • Explaining to fixer > doing • Tight integration with your current work • Requires design taste, visual hierarchy, interaction polish, responsive layout decisions, animation/motion, component feel, or UI copy/design trade-offs
+- **Rule of thumb:** Headless/mechanical implementation → @fixer. User-visible design or polish → @designer. If @designer already set direction, @fixer may only do bounded mechanical follow-up that preserves that design exactly.
+
+@council
+- Lane: High-stakes multi-model decision support
+- Role: Multi-LLM consensus engine that runs several councillors, synthesizes their views, and returns a structured council report.
+- Permissions: Read files
+- Stats: 3x slower than orchestrator, 3x or more cost of orchestrator
+- Capabilities: Runs multiple models in parallel, compares their answers, resolves disagreements, and produces a final synthesized answer plus councillor details and consensus summary.
+- **Delegate when:** Critical decisions need multiple independent perspectives • High-stakes architectural/security/data-integrity choices • Ambiguous problems where disagreement is useful signal • You want confidence beyond a single model • The user explicitly asks for council/consensus/multiple opinions.
+- **Don't delegate when:** Straightforward tasks you're confident about • Speed matters more than confidence • Routine implementation/debugging • A single specialist is clearly the right tool • You only need current docs/search/code review rather than multi-model consensus.
+- **How to call:** Send the full question/task and relevant context. Be explicit about what decision, trade-off, or answer the council should resolve. Do not ask council to do routine code edits.
+- **Result handling:** Council returns a structured response that may include: synthesized Council Response, individual Councillor Details, and Council Summary/confidence. Preserve that structure when the user asked for council output. Do not pretend the council only returned a final answer. If you need to act on the council result, first briefly state the council's recommendation, then proceed.
+- **Rule of thumb:** Need second/third opinions from different models? → @council. Need one expert lane? → use the specialist. Need final synthesis? → handle directly.
+
+@observer
+- Lane: Visual/media analysis isolated from orchestrator context
+- Role: Visual analysis specialist for images, PDFs, and diagrams
+- Permissions: Read files
+- Stats: Saves main context tokens - Observer processes raw files, returns structured observations
+- Capabilities: Interprets images, screenshots, PDFs, and diagrams via native read tool; extracts UI elements, layouts, text, relationships
+- **Delegate when:** Need to analyze a multimedia file• Extract information
+- **Don't delegate when:** Plain text files that Read can handle directly • Files that need editing afterward (need literal content from Read)
+- **Rule of thumb:** Even if your model supports vision, delegate visual analysis to @observer - it isolates large image/PDF bytes from your context window, returning only concise structured text. Need exact file contents for routing? → Read only the minimal context yourself.
+- **IMPORTANT:** When delegating to @observer, always include the **full file path** in the prompt so it can read the file. Example: "Analyze the screenshot at /path/to/file.png - describe the UI elements and error messages."
+
+</Agents>
+
+<Workflow>
+
+## 1. Understand
+Parse request: explicit requirements + implicit needs.
+
+## 2. Path Selection
+Evaluate approach by: quality, speed and cost.
+Choose the path that optimizes all four.
+
+## 3. Delegation Check
+Review available agents and lane rules. Before beginning non-trivial work, identify which parts can proceed independently.
+
+**Routing threshold:**
+- Handle directly only for one isolated, clear, low-risk action where delegation would cost more than execution.
+- Never handle UI/design work directly — layout, styling, visual hierarchy, responsive behavior, animation, and component feel always route to @designer.
+- For multi-step implementation, broad discovery, external research, or complex debugging, delegate to the suitable specialist.
+- If two or more parts can proceed independently, dispatch them in parallel before starting dependent work.
+- Do not delegate merely because an agent exists. Do not keep substantive work entirely in the orchestrator merely because each individual step seems easy.
+
+**Dispatch efficiency:**
+- Reference paths/lines, don't paste files (\`src/app.ts:42\` not full contents)
+- Brief user on delegation goal before each call
+- Record task IDs, state, and advisory ownership/dependency labels
+- Do not immediately wait after spawning independent background tasks unless the next step truly depends on their result
+- Reconcile results, resolve conflicts, and gate dependent lanes
+
+**File Operations Rules**:
+- Prefer dedicated file tools for normal code work: glob/grep/ast_grep_search for discovery, read for file contents, and edit/write/apply_patch for targeted source changes.
+- Use bash for execution and automation: git, package managers, tests, builds, scripts, diagnostics, and shell-native filesystem operations.
+- Shell is acceptable for bulk or mechanical filesystem changes when it is clearer or safer than many individual edits (for example: truncate generated logs, remove build artifacts, batch rename/move files), especially when the user explicitly asks for that shell operation.
+- Before destructive or broad shell operations, verify the target set and quote paths. Prefer a dry-run/listing first when practical.
+- Do not use cat/head/tail/sed/awk only to read code into context; use read/grep unless a shell pipeline is genuinely the better diagnostic.
+
+## 4. Plan and Parallelize
+When the routing threshold calls for delegation, build a short work graph before dispatching:
+- Independent lanes that can run now
+- Dependency-ordered lanes that must wait
+- Advisory ownership for write-capable lanes
+- Verification/review lanes that run after implementation
+
+### Todo Continuity
+- When the user adds a new task while a todo list exists, append the new task to the end of the existing todo list instead of replacing the list.
+- Preserve existing todo order, statuses, and priorities unless the user explicitly asks to reprioritize, cancel, or replace them.
+- Finish the current in-progress task before starting the newly appended task unless the current task is blocked or the user explicitly overrides the order.
+
+Can tasks be split into background specialist work?
+- Multiple @explorer searches across different domains?
+- @explorer + @librarian research in parallel?
+- Multiple @fixer instances for faster, scoped implementation?
+- @observer + @explorer in parallel (visual analysis + code search)?
+
+Balance: respect dependencies, avoid parallelizing what must be sequential, and avoid overlapping write ownership.
+
+### Background Task Discipline
+- Prefer \`task(..., background: true)\` for delegated work that can run independently.
+- For work already chosen for delegation, launch independent specialist lanes in the background so the orchestrator stays unblocked and can reconcile results when they return.
+- Track each task's specialist, objective, task/session ID, and file/topic ownership.
+- Continue orchestration only on non-overlapping work; otherwise briefly report what was launched and stop.
+- Before local edits or another writer task, compare against running task scopes.
+- Parallel background tasks are allowed only when their write scopes do not conflict.
+- Before final response, reconcile any terminal jobs shown in the Background Job Board.
+- Use \`cancel_task\` only when the user asks, or when a running lane is obsolete, wrong, or conflicts with a safer replacement plan.
+- Cancellation is not rollback: if cancelling a writer, inspect and reconcile partial file changes before launching a replacement lane.
+
+### Design Handoff Discipline
+- When @designer completes UI/UX work, treat layout, spacing, hierarchy, motion, color, affordances, and component feel as intentional design output.
+- Do not later simplify, normalize, or refactor it in ways that flatten the design.
+- The orchestrator should review and improve user-facing copy after designer work, because designer copy may be weak.
+- Copy edits must preserve the designer's visual structure and interaction intent.
+- If follow-up work is purely mechanical and preserves the design exactly, @fixer can handle it. If it requires visual judgment or changes the feel, route it back to @designer.
+
+### Session Reuse
+- Smartly reuse an available specialist session - context reuse saves time and tokens
+- When too much unrelated, and really needed, start a fresh session with the specialist
+- If multiple remembered sessions fit, prefer the most recently used matching session.
+- Prefer re-uses over creating new sessions all the time
+- When reusing a specialist session, you MUST pass the existing session or alias in the task tool's \`task_id\` argument. Saying "reuse" in prose is not enough.
+- If the Background Job Board lists \`fix-1 / ses_abc / fixer\`, call task with \`subagent_type: "fixer"\` and \`task_id: "fix-1"\` or \`task_id: "ses_abc"\`.
+- Do not leave \`task_id\` empty when intending to reuse; omitted or empty \`task_id\` creates a new specialist session.
+
+## 6. Verify
+- Define the observable success criteria from the user's request.
+- Choose the minimum verification that produces meaningful evidence for the change's scope, risk, uncertainty, and potential impact.
+- Start with the narrowest relevant validation. Broaden verification only when integration scope, uncertainty, risk, or a failed focused check justifies it.
+- Do not run project-wide checks by habit or merely because files changed.
+- Do not treat verification as a fixed checklist; select evidence that can actually confirm the requested behavior.
+- Request independent review only when its expected risk reduction justifies its coordination cost.
+- Report what was verified and any material remaining uncertainty.
+
+</Workflow>
+
+<Communication>
+
+## Clarity Over Assumptions
+- If request is vague or has multiple valid interpretations, ask a targeted question before proceeding
+- Don't guess at critical details (file paths, API choices, architectural decisions)
+- Do make reasonable assumptions for minor details and state them briefly
+- When user input is required before work can continue—including clarification, permission, or command output—use the \`question\` tool rather than leaving an ordinary assistant prompt waiting. Enable custom input, request a concise pasted response or command output, and provide a small bounded set of options whenever the tool schema requires options.
+- For ordinary dialogue that does not block work, answer normally and do not use the question tool gratuitously.
+
+## Concise Execution
+- Answer directly, no preamble
+- Don't summarize what you did unless asked
+- Don't explain code unless asked
+- One-word answers are fine when appropriate
+- Default to the minimum response that fully resolves the user's request; expand only when detail is necessary or the user asks for it.
+- Do not restate the user's request or narrate routine work.
+- Brief delegation notices: "Checking docs via @librarian..." not "I'm going to delegate to @librarian because..."
+
+## No Flattery
+Never: "Great question!" "Excellent idea!" "Smart choice!" or any praise of user input.
+
+## Honest Pushback
+When user's approach seems problematic:
+- State concern + alternative concisely
+- Ask if they want to proceed anyway
+- Don't lecture, don't blindly implement
+
+## Example
+**Bad:** "Great question! Let me think about the best approach here. I'm going to delegate to @librarian to check the latest Next.js documentation for the App Router, and then I'll implement the solution for you."
+
+**Good:** "Checking Next.js App Router docs via @librarian..."
+[continues scheduling or integration]
+
+</Communication>
+"
+`;
+
+exports[`cache-impact snapshots (update deliberately — see file header) transformed payload for the canonical conversation fixture 1`] = `
+[
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m01",
+      "role": "user",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": "set up the project",
+        "type": "text",
+      },
+      {
+        "metadata": {
+          "oh-my-opencode-slim.phaseReminder": true,
+        },
+        "synthetic": true,
+        "text": 
+"<system-reminder>
+!IMPORTANT! Scheduler workflow: First choose the lightest workflow that fits the work. If direct execution is justified, complete it and verify proportionately. Otherwise: plan lanes/dependencies → dispatch background specialists → track task IDs → wait for hook-driven completion → reconcile terminal results → verify. Do not poll running jobs, consume running-job output, or advance dependent work. !END!
+</system-reminder>"
+,
+        "type": "text",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m02",
+      "role": "assistant",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": "Reading the manifest first.",
+        "type": "text",
+      },
+      {
+        "callID": "m02-call",
+        "state": {
+          "input": {
+            "filePath": "/tmp/cache-safety-fixture/package.json",
+          },
+          "output": "{"name":"fixture"}",
+          "status": "completed",
+        },
+        "tool": "read",
+        "type": "tool",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m03",
+      "role": "user",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": "now add tests",
+        "type": "text",
+      },
+      {
+        "metadata": {
+          "oh-my-opencode-slim.phaseReminder": true,
+        },
+        "synthetic": true,
+        "text": 
+"<system-reminder>
+!IMPORTANT! Scheduler workflow: First choose the lightest workflow that fits the work. If direct execution is justified, complete it and verify proportionately. Otherwise: plan lanes/dependencies → dispatch background specialists → track task IDs → wait for hook-driven completion → reconcile terminal results → verify. Do not poll running jobs, consume running-job output, or advance dependent work. !END!
+</system-reminder>"
+,
+        "type": "text",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m04",
+      "role": "assistant",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": "Delegating test work.",
+        "type": "text",
+      },
+      {
+        "callID": "m04-call",
+        "state": {
+          "input": {
+            "filePath": "/tmp/cache-safety-fixture/package.json",
+          },
+          "output": "{"name":"fixture"}",
+          "status": "completed",
+        },
+        "tool": "read",
+        "type": "tool",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "explorer",
+      "id": "m05",
+      "role": "user",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": "specialist context",
+        "type": "text",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m06",
+      "role": "user",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "metadata": {
+          "oh-my-opencode-slim.internalInitiator": true,
+        },
+        "synthetic": true,
+        "text": 
+"continue coordinating remaining todos
+<!-- SLIM_INTERNAL_INITIATOR -->"
+,
+        "type": "text",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m07",
+      "role": "user",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": 
+"also consider skills
+<available_skills>
+<skill>
+<name>some-skill</name>
+<description>demo</description>
+</skill>
+</available_skills>"
+,
+        "type": "text",
+      },
+      {
+        "metadata": {
+          "oh-my-opencode-slim.phaseReminder": true,
+        },
+        "synthetic": true,
+        "text": 
+"<system-reminder>
+!IMPORTANT! Scheduler workflow: First choose the lightest workflow that fits the work. If direct execution is justified, complete it and verify proportionately. Otherwise: plan lanes/dependencies → dispatch background specialists → track task IDs → wait for hook-driven completion → reconcile terminal results → verify. Do not poll running jobs, consume running-job output, or advance dependent work. !END!
+</system-reminder>"
+,
+        "type": "text",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m08",
+      "role": "assistant",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": "Wrapping up.",
+        "type": "text",
+      },
+      {
+        "callID": "m08-call",
+        "state": {
+          "input": {
+            "filePath": "/tmp/cache-safety-fixture/package.json",
+          },
+          "output": "{"name":"fixture"}",
+          "status": "completed",
+        },
+        "tool": "read",
+        "type": "tool",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m09",
+      "role": "user",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": "final adjustments please",
+        "type": "text",
+      },
+      {
+        "metadata": {
+          "oh-my-opencode-slim.phaseReminder": true,
+        },
+        "synthetic": true,
+        "text": 
+"<system-reminder>
+!IMPORTANT! Scheduler workflow: First choose the lightest workflow that fits the work. If direct execution is justified, complete it and verify proportionately. Otherwise: plan lanes/dependencies → dispatch background specialists → track task IDs → wait for hook-driven completion → reconcile terminal results → verify. Do not poll running jobs, consume running-job output, or advance dependent work. !END!
+</system-reminder>"
+,
+        "type": "text",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m09-background-job-board",
+      "role": "user",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "metadata": {
+          "oh-my-opencode-slim.backgroundJobBoard": true,
+        },
+        "synthetic": true,
+        "text": 
+"<system-reminder>
+### Background Job Board
+SENTINEL: background-job-board-v2
+Do not poll running jobs. Wait for hook-driven completion, or use cancel_task only for explicit cancellation. Reconcile terminal jobs before final response.
+Completed or reconciled sessions are reusable by alias for the same specialist/context.
+Timed-out running sessions are recoverable by alias for safe resume after a live busy signal.
+Cancelled or errored sessions are not reusable.
+
+#### Active / Unreconciled
+- exp-1 / task-snapshot / explorer / running
+  Objective: snapshot fixture job
+
+#### Reusable Sessions
+- none
+</system-reminder>"
+,
+        "type": "text",
+      },
+    ],
+  },
+]
+`;

+ 223 - 0
src/hooks/cache-monitor/index.test.ts

@@ -0,0 +1,223 @@
+import { describe, expect, test } from 'bun:test';
+import { createCacheMonitorHook } from './index';
+
+interface Warning {
+  message: string;
+  data: unknown;
+}
+
+function createHarness() {
+  const warnings: Warning[] = [];
+  const hook = createCacheMonitorHook({
+    logger: (message, data) => warnings.push({ message, data }),
+  });
+  return { hook, warnings };
+}
+
+function assistantMessageEvent(options: {
+  sessionID?: string;
+  messageID: string;
+  input: number;
+  cacheRead: number;
+  cacheWrite?: number;
+  completed?: boolean;
+}) {
+  return {
+    event: {
+      type: 'message.updated',
+      properties: {
+        info: {
+          role: 'assistant',
+          sessionID: options.sessionID ?? 'ses_monitor',
+          id: options.messageID,
+          time: options.completed === false ? {} : { completed: 1_700_000_000 },
+          tokens: {
+            input: options.input,
+            output: 100,
+            reasoning: 0,
+            cache: {
+              read: options.cacheRead,
+              write: options.cacheWrite ?? 0,
+            },
+          },
+        },
+      },
+    },
+  };
+}
+
+describe('createCacheMonitorHook', () => {
+  test('warns when a cache-hitting session drops to zero cache reads', async () => {
+    const { hook, warnings } = createHarness();
+
+    await hook.event(
+      assistantMessageEvent({
+        messageID: 'a1',
+        input: 8000,
+        cacheRead: 0,
+        cacheWrite: 7000,
+      }),
+    );
+    await hook.event(
+      assistantMessageEvent({ messageID: 'a2', input: 500, cacheRead: 9000 }),
+    );
+    expect(warnings).toHaveLength(0);
+
+    await hook.event(
+      assistantMessageEvent({ messageID: 'a3', input: 12000, cacheRead: 0 }),
+    );
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0].message).toContain('prompt-cache bust');
+    expect(warnings[0].data).toMatchObject({
+      sessionID: 'ses_monitor',
+      requestNumber: 3,
+      inputTokens: 12000,
+      previousCacheRead: 9000,
+    });
+  });
+
+  test('warns once per bust streak, re-arming after a cache hit', async () => {
+    const { hook, warnings } = createHarness();
+
+    await hook.event(
+      assistantMessageEvent({ messageID: 'b1', input: 8000, cacheRead: 6000 }),
+    );
+    await hook.event(
+      assistantMessageEvent({ messageID: 'b2', input: 9000, cacheRead: 0 }),
+    );
+    await hook.event(
+      assistantMessageEvent({ messageID: 'b3', input: 9500, cacheRead: 0 }),
+    );
+    expect(warnings).toHaveLength(1);
+
+    await hook.event(
+      assistantMessageEvent({ messageID: 'b4', input: 9500, cacheRead: 9000 }),
+    );
+    await hook.event(
+      assistantMessageEvent({ messageID: 'b5', input: 9600, cacheRead: 0 }),
+    );
+    expect(warnings).toHaveLength(2);
+  });
+
+  test('stays silent for providers that never report cache tokens', async () => {
+    const { hook, warnings } = createHarness();
+
+    for (const id of ['c1', 'c2', 'c3']) {
+      await hook.event(
+        assistantMessageEvent({ messageID: id, input: 20000, cacheRead: 0 }),
+      );
+    }
+
+    expect(warnings).toHaveLength(0);
+  });
+
+  test('stays silent on the first request and on tiny prompts', async () => {
+    const { hook, warnings } = createHarness();
+
+    // First request of a session writes the cache; zero reads are expected.
+    await hook.event(
+      assistantMessageEvent({
+        messageID: 'd1',
+        input: 30000,
+        cacheRead: 0,
+        cacheWrite: 29000,
+      }),
+    );
+    // Small prompts sit below provider minimum cacheable prefixes.
+    await hook.event(
+      assistantMessageEvent({ messageID: 'd2', input: 900, cacheRead: 0 }),
+    );
+
+    expect(warnings).toHaveLength(0);
+  });
+
+  test('ignores streaming updates and duplicate completion events', async () => {
+    const { hook, warnings } = createHarness();
+
+    await hook.event(
+      assistantMessageEvent({ messageID: 'e1', input: 8000, cacheRead: 5000 }),
+    );
+    await hook.event(
+      assistantMessageEvent({
+        messageID: 'e2',
+        input: 8000,
+        cacheRead: 0,
+        completed: false,
+      }),
+    );
+    // Same completed message delivered twice must count once.
+    await hook.event(
+      assistantMessageEvent({ messageID: 'e3', input: 9000, cacheRead: 0 }),
+    );
+    await hook.event(
+      assistantMessageEvent({ messageID: 'e3', input: 9000, cacheRead: 0 }),
+    );
+
+    expect(warnings).toHaveLength(1);
+  });
+
+  test('tracks sessions independently and forgets deleted sessions', async () => {
+    const { hook, warnings } = createHarness();
+
+    await hook.event(
+      assistantMessageEvent({
+        sessionID: 's-a',
+        messageID: 'f1',
+        input: 8000,
+        cacheRead: 5000,
+      }),
+    );
+    await hook.event(
+      assistantMessageEvent({
+        sessionID: 's-b',
+        messageID: 'f2',
+        input: 8000,
+        cacheRead: 0,
+        cacheWrite: 0,
+      }),
+    );
+    expect(warnings).toHaveLength(0);
+
+    await hook.event({
+      event: {
+        type: 'session.deleted',
+        properties: { info: { id: 's-a' } },
+      },
+    });
+    // After deletion the session history is gone; a zero-read request looks
+    // like a fresh session again and must not warn.
+    await hook.event(
+      assistantMessageEvent({
+        sessionID: 's-a',
+        messageID: 'f3',
+        input: 8000,
+        cacheRead: 0,
+      }),
+    );
+    expect(warnings).toHaveLength(0);
+  });
+
+  test('fails open on malformed events', async () => {
+    const { hook, warnings } = createHarness();
+
+    await hook.event({ event: null });
+    await hook.event({ event: { type: 'message.updated' } });
+    await hook.event({
+      event: {
+        type: 'message.updated',
+        properties: {
+          info: {
+            role: 'assistant',
+            sessionID: 's',
+            id: 'x',
+            time: { completed: 1 },
+            tokens: { input: 'NaN' },
+          },
+        },
+      },
+    });
+
+    expect(warnings).toHaveLength(0);
+  });
+});

+ 179 - 0
src/hooks/cache-monitor/index.ts

@@ -0,0 +1,179 @@
+/**
+ * Cache monitor — runtime watchdog for provider prompt-cache busts.
+ *
+ * Offline tests prove the plugin projects a byte-stable payload, but only
+ * the provider knows whether a cache prefix was actually reused. OpenCode
+ * surfaces per-request cache telemetry on assistant messages
+ * (`tokens.cache.read` / `tokens.cache.write`); this hook watches those
+ * numbers and logs a loud warning when a session that previously enjoyed
+ * cache hits suddenly reports zero cached tokens on a sizeable request —
+ * the field signature of a mid-session prompt-prefix change.
+ *
+ * Observation only: it never mutates messages or state, and it fails open
+ * on any unexpected event shape.
+ */
+
+import { isRecord } from '../../utils/guards';
+import { log } from '../../utils/logger';
+
+/**
+ * Requests below this input size are ignored: tiny prompts sit under
+ * provider minimum-cacheable-prefix thresholds and legitimately report
+ * zero cached tokens.
+ */
+const MIN_INPUT_TOKENS_FOR_WARNING = 2048;
+const MAX_TRACKED_SESSIONS = 256;
+const MAX_TRACKED_MESSAGES_PER_SESSION = 512;
+
+interface SessionCacheState {
+  completedRequests: number;
+  everReportedCache: boolean;
+  lastCacheRead: number;
+  warnedSinceLastHit: boolean;
+  processedMessageIDs: Set<string>;
+}
+
+export interface CacheMonitorOptions {
+  logger?: (message: string, data?: unknown) => void;
+}
+
+interface CompletedAssistantMessage {
+  sessionID: string;
+  messageID: string;
+  inputTokens: number;
+  cacheRead: number;
+  cacheWrite: number;
+}
+
+function asFiniteNumber(value: unknown): number | undefined {
+  return typeof value === 'number' && Number.isFinite(value)
+    ? value
+    : undefined;
+}
+
+function parseCompletedAssistantMessage(
+  event: unknown,
+): CompletedAssistantMessage | undefined {
+  if (!isRecord(event) || event.type !== 'message.updated') return undefined;
+  const properties = isRecord(event.properties) ? event.properties : undefined;
+  const info =
+    properties && isRecord(properties.info) ? properties.info : undefined;
+  if (info?.role !== 'assistant') return undefined;
+  if (typeof info.sessionID !== 'string' || typeof info.id !== 'string') {
+    return undefined;
+  }
+
+  // Only completed requests carry final token accounting; message.updated
+  // also fires while streaming.
+  const time = isRecord(info.time) ? info.time : undefined;
+  if (!time || time.completed === undefined || time.completed === null) {
+    return undefined;
+  }
+
+  const tokens = isRecord(info.tokens) ? info.tokens : undefined;
+  if (!tokens) return undefined;
+  const cache = isRecord(tokens.cache) ? tokens.cache : undefined;
+  const inputTokens = asFiniteNumber(tokens.input);
+  const cacheRead = asFiniteNumber(cache?.read);
+  const cacheWrite = asFiniteNumber(cache?.write);
+  if (
+    inputTokens === undefined ||
+    cacheRead === undefined ||
+    cacheWrite === undefined
+  ) {
+    return undefined;
+  }
+
+  return {
+    sessionID: info.sessionID,
+    messageID: info.id,
+    inputTokens,
+    cacheRead,
+    cacheWrite,
+  };
+}
+
+function deletedSessionID(event: unknown): string | undefined {
+  if (!isRecord(event) || event.type !== 'session.deleted') return undefined;
+  const properties = isRecord(event.properties) ? event.properties : undefined;
+  const info =
+    properties && isRecord(properties.info) ? properties.info : undefined;
+  return info && typeof info.id === 'string' ? info.id : undefined;
+}
+
+export function createCacheMonitorHook(options: CacheMonitorOptions = {}) {
+  const logger = options.logger ?? log;
+  const sessions = new Map<string, SessionCacheState>();
+
+  function getSessionState(sessionID: string): SessionCacheState {
+    const existing = sessions.get(sessionID);
+    if (existing) return existing;
+
+    if (sessions.size >= MAX_TRACKED_SESSIONS) {
+      const oldest = sessions.keys().next().value;
+      if (oldest !== undefined) sessions.delete(oldest);
+    }
+    const state: SessionCacheState = {
+      completedRequests: 0,
+      everReportedCache: false,
+      lastCacheRead: 0,
+      warnedSinceLastHit: false,
+      processedMessageIDs: new Set(),
+    };
+    sessions.set(sessionID, state);
+    return state;
+  }
+
+  function observe(message: CompletedAssistantMessage): void {
+    const state = getSessionState(message.sessionID);
+    if (state.processedMessageIDs.has(message.messageID)) return;
+    if (state.processedMessageIDs.size >= MAX_TRACKED_MESSAGES_PER_SESSION) {
+      state.processedMessageIDs.clear();
+    }
+    state.processedMessageIDs.add(message.messageID);
+    state.completedRequests += 1;
+
+    const busted =
+      state.completedRequests >= 2 &&
+      state.everReportedCache &&
+      message.cacheRead === 0 &&
+      message.inputTokens >= MIN_INPUT_TOKENS_FOR_WARNING;
+
+    if (busted && !state.warnedSinceLastHit) {
+      state.warnedSinceLastHit = true;
+      logger(
+        '[cache-monitor] possible prompt-cache bust: a session that was hitting the provider cache reported 0 cache-read tokens. A prompt-prefix byte likely changed mid-session — see docs/cache-verification.md.',
+        {
+          sessionID: message.sessionID,
+          requestNumber: state.completedRequests,
+          inputTokens: message.inputTokens,
+          previousCacheRead: state.lastCacheRead,
+        },
+      );
+    }
+
+    if (message.cacheRead > 0) state.warnedSinceLastHit = false;
+    state.everReportedCache =
+      state.everReportedCache ||
+      message.cacheRead > 0 ||
+      message.cacheWrite > 0;
+    state.lastCacheRead = message.cacheRead;
+  }
+
+  return {
+    event: async (input: { event: unknown }): Promise<void> => {
+      try {
+        const deleted = deletedSessionID(input.event);
+        if (deleted) {
+          sessions.delete(deleted);
+          return;
+        }
+
+        const message = parseCompletedAssistantMessage(input.event);
+        if (message) observe(message);
+      } catch {
+        // Observation only — never let telemetry break event handling.
+      }
+    },
+  };
+}

+ 58 - 0
src/hooks/cache-payload.snapshot.test.ts

@@ -0,0 +1,58 @@
+/**
+ * Golden snapshots of the prompt surfaces this plugin injects into the
+ * provider payload prefix.
+ *
+ * Any byte change to these surfaces invalidates the provider prompt cache
+ * for every existing session the next time it sends a request — the change
+ * may still be worth it, but it must be deliberate, not incidental. When one
+ * of these tests fails:
+ *
+ *   1. Confirm the payload change is intentional and worth a one-time,
+ *      fleet-wide cache re-warm (cost + latency on the first request of
+ *      every active session).
+ *   2. Update the snapshot with `bun test --update-snapshots` and let the
+ *      snapshot diff document the cache impact in the PR.
+ *
+ * Never update these snapshots to silence a failure you can't explain.
+ */
+
+import { describe, expect, test } from 'bun:test';
+import { buildOrchestratorPrompt } from '../agents/orchestrator';
+import { PHASE_REMINDER } from '../config/constants';
+import {
+  buildHistory,
+  createPipeline,
+  FIXTURE_NOW,
+  renderTurn,
+  SESSION_ID,
+} from './cache-safety-harness.test';
+
+describe('cache-impact snapshots (update deliberately — see file header)', () => {
+  test('phase reminder text', () => {
+    expect(PHASE_REMINDER).toMatchSnapshot();
+  });
+
+  test('orchestrator system prompt', () => {
+    const prompt = buildOrchestratorPrompt(new Set());
+    // Deterministic across invocations — a mismatch here means something
+    // volatile (time, randomness, environment) leaked into the prompt.
+    expect(buildOrchestratorPrompt(new Set())).toBe(prompt);
+    expect(prompt).toMatchSnapshot();
+  });
+
+  test('transformed payload for the canonical conversation fixture', async () => {
+    const pipeline = createPipeline();
+    pipeline.board.registerLaunch({
+      taskID: 'task-snapshot',
+      parentSessionID: SESSION_ID,
+      agent: 'explorer',
+      description: 'snapshot fixture job',
+      now: FIXTURE_NOW,
+    });
+
+    const history = buildHistory();
+    const output = await renderTurn(pipeline, history, history.length - 1);
+
+    expect(output.messages).toMatchSnapshot();
+  });
+});

+ 152 - 0
src/hooks/cache-safe-injection.test.ts

@@ -0,0 +1,152 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  appendTaggedSyntheticPart,
+  appendTrailingVolatileMessage,
+  createTaggedSyntheticPart,
+  hasTaggedPart,
+  isTaggedPart,
+  isVolatileTaggedMessage,
+  stripTaggedContent,
+} from './cache-safe-injection';
+import type { MessageWithParts } from './types';
+
+const KEY = 'oh-my-opencode-slim.testTag';
+
+function userMessage(text: string): MessageWithParts {
+  return {
+    info: { role: 'user', agent: 'orchestrator', sessionID: 's1', id: 'm1' },
+    parts: [{ type: 'text', text }],
+  };
+}
+
+describe('createTaggedSyntheticPart', () => {
+  test('builds a synthetic text part with the tag winning over extras', () => {
+    const part = createTaggedSyntheticPart({
+      text: 'hello',
+      metadataKey: KEY,
+      extraMetadata: { other: 1, [KEY]: false },
+    });
+
+    expect(part).toEqual({
+      type: 'text',
+      synthetic: true,
+      text: 'hello',
+      metadata: { other: 1, [KEY]: true },
+    });
+  });
+});
+
+describe('isTaggedPart / hasTaggedPart', () => {
+  test('recognizes only synthetic parts carrying the exact tag', () => {
+    const tagged = createTaggedSyntheticPart({ text: 'x', metadataKey: KEY });
+    expect(isTaggedPart(tagged, KEY)).toBe(true);
+    expect(isTaggedPart(tagged, 'other-key')).toBe(false);
+    expect(isTaggedPart({ type: 'text', text: 'x' }, KEY)).toBe(false);
+    expect(
+      isTaggedPart({ type: 'text', text: 'x', metadata: { [KEY]: true } }, KEY),
+    ).toBe(false);
+    expect(isTaggedPart(undefined, KEY)).toBe(false);
+  });
+
+  test('hasTaggedPart scans all parts of a message', () => {
+    const message = userMessage('hi');
+    expect(hasTaggedPart(message, KEY)).toBe(false);
+    appendTaggedSyntheticPart(message, { text: 'r', metadataKey: KEY });
+    expect(hasTaggedPart(message, KEY)).toBe(true);
+  });
+});
+
+describe('appendTaggedSyntheticPart', () => {
+  test('appends at the tail without touching existing parts', () => {
+    const message = userMessage('original');
+    const before = JSON.stringify(message.parts[0]);
+
+    appendTaggedSyntheticPart(message, { text: 'reminder', metadataKey: KEY });
+
+    expect(message.parts).toHaveLength(2);
+    expect(JSON.stringify(message.parts[0])).toBe(before);
+    expect(isTaggedPart(message.parts[1], KEY)).toBe(true);
+  });
+});
+
+describe('stripTaggedContent', () => {
+  test('removes tagged parts from real messages and drops emptied synthetic messages', () => {
+    const real = userMessage('keep me');
+    appendTaggedSyntheticPart(real, { text: 'legacy', metadataKey: KEY });
+    const messages: unknown[] = [real];
+    appendTrailingVolatileMessage(
+      messages,
+      { role: 'user', id: 'm1-tag' },
+      { text: 'volatile', metadataKey: KEY },
+    );
+
+    stripTaggedContent(messages, KEY);
+
+    expect(messages).toHaveLength(1);
+    expect((messages[0] as MessageWithParts).parts).toHaveLength(1);
+    expect((messages[0] as MessageWithParts).parts[0].text).toBe('keep me');
+  });
+
+  test('leaves messages without the tag byte-identical', () => {
+    const real = userMessage('untouched');
+    const other = userMessage('also untouched');
+    appendTaggedSyntheticPart(other, {
+      text: 'different tag',
+      metadataKey: 'other-key',
+    });
+    const messages: unknown[] = [real, other];
+    const before = JSON.stringify(messages);
+
+    stripTaggedContent(messages, KEY);
+
+    expect(JSON.stringify(messages)).toBe(before);
+  });
+
+  test('preserves messages that were already empty', () => {
+    const empty: MessageWithParts = {
+      info: { role: 'user' },
+      parts: [],
+    };
+    const messages: unknown[] = [empty];
+
+    stripTaggedContent(messages, KEY);
+
+    expect(messages).toHaveLength(1);
+  });
+});
+
+describe('appendTrailingVolatileMessage / isVolatileTaggedMessage', () => {
+  test('appends a synthetic message at the end and marks it volatile', () => {
+    const real = userMessage('turn');
+    const messages: unknown[] = [real];
+
+    appendTrailingVolatileMessage(
+      messages,
+      { role: 'user', agent: 'orchestrator', sessionID: 's1', id: 'm1-board' },
+      { text: 'board', metadataKey: KEY },
+    );
+
+    expect(messages).toHaveLength(2);
+    expect(isVolatileTaggedMessage(messages[1], KEY)).toBe(true);
+    expect(isVolatileTaggedMessage(messages[0], KEY)).toBe(false);
+    expect(isVolatileTaggedMessage(messages[1], 'other-key')).toBe(false);
+  });
+
+  test('strip-then-append keeps at most one instance, always trailing', () => {
+    const real = userMessage('turn');
+    const messages: unknown[] = [real];
+
+    for (const text of ['board v1', 'board v2']) {
+      stripTaggedContent(messages, KEY);
+      appendTrailingVolatileMessage(
+        messages,
+        { role: 'user', id: 'm1-board' },
+        { text, metadataKey: KEY },
+      );
+    }
+
+    expect(messages).toHaveLength(2);
+    const trailing = messages[1] as MessageWithParts;
+    expect(trailing.parts[0].text).toBe('board v2');
+  });
+});

+ 139 - 0
src/hooks/cache-safe-injection.ts

@@ -0,0 +1,139 @@
+/**
+ * Cache-safe prompt injection helpers.
+ *
+ * Provider prompt caches are exact byte-prefix matches over the rendered
+ * request (tools → system → messages). Any transform that rewrites or
+ * reorders earlier conversation content invalidates the cache for everything
+ * after the first changed byte, so every later request in the session re-pays
+ * full input cost and latency.
+ *
+ * These helpers are the single supported way for hooks to add content to the
+ * outgoing payload:
+ *
+ * - `appendTaggedSyntheticPart` appends deterministic content at the tail of
+ *   an existing message. Safe because re-running the transform on the next
+ *   turn reproduces the same bytes at the same position.
+ * - `stripTaggedContent` + `appendTrailingVolatileMessage` own content that
+ *   changes between turns (job boards, status blocks): strip every previously
+ *   injected occurrence, then re-append one synthetic message at the very end
+ *   of the payload, so churn only ever costs the tail of the prompt.
+ *
+ * Rules the helpers encode (and the cache-safety property tests enforce):
+ * never mutate or reorder earlier messages, never inject unmarked parts, and
+ * never put timestamps or randomness into content injected before the tail.
+ * See docs/cache-verification.md.
+ */
+
+import { isRecord } from '../utils/guards';
+import {
+  isMessageWithParts,
+  type MessageInfo,
+  type MessagePart,
+  type MessageWithParts,
+} from './types';
+
+export interface TaggedSyntheticPartSpec {
+  /** Text content of the injected part. */
+  text: string;
+  /**
+   * Metadata key marking the part as plugin-injected. Used for dedupe and
+   * strip-before-reappend; must be stable for the lifetime of the feature.
+   */
+  metadataKey: string;
+  /** Additional metadata merged into the part (the tag key always wins). */
+  extraMetadata?: Record<string, unknown>;
+}
+
+/** Build a synthetic text part tagged with the given metadata key. */
+export function createTaggedSyntheticPart(
+  spec: TaggedSyntheticPartSpec,
+): MessagePart {
+  return {
+    type: 'text',
+    synthetic: true,
+    text: spec.text,
+    metadata: { ...(spec.extraMetadata ?? {}), [spec.metadataKey]: true },
+  };
+}
+
+/** True when the part is a synthetic part tagged with the metadata key. */
+export function isTaggedPart(part: unknown, metadataKey: string): boolean {
+  return (
+    isRecord(part) &&
+    part.synthetic === true &&
+    isRecord(part.metadata) &&
+    part.metadata[metadataKey] === true
+  );
+}
+
+/** True when any part of the message carries the tag. */
+export function hasTaggedPart(
+  message: MessageWithParts,
+  metadataKey: string,
+): boolean {
+  return message.parts.some((part) => isTaggedPart(part, metadataKey));
+}
+
+/**
+ * Append deterministic content as a tagged synthetic part at the message
+ * tail. The content must be a pure function of session-stable inputs so the
+ * next turn's transform reproduces identical bytes at the same position.
+ */
+export function appendTaggedSyntheticPart(
+  message: MessageWithParts,
+  spec: TaggedSyntheticPartSpec,
+): void {
+  message.parts.push(createTaggedSyntheticPart(spec));
+}
+
+/**
+ * Remove every part tagged with the metadata key across all messages and
+ * drop messages this empties (covers both legacy in-message placement and
+ * whole synthetic trailing messages).
+ */
+export function stripTaggedContent(
+  messages: unknown[],
+  metadataKey: string,
+): void {
+  for (let i = messages.length - 1; i >= 0; i -= 1) {
+    const message = messages[i];
+    if (!isMessageWithParts(message)) continue;
+    const hadParts = message.parts.length > 0;
+    message.parts = message.parts.filter(
+      (part) => !isTaggedPart(part, metadataKey),
+    );
+    if (hadParts && message.parts.length === 0) messages.splice(i, 1);
+  }
+}
+
+/**
+ * Append volatile content as its own synthetic message at the very end of
+ * the payload. Call `stripTaggedContent` first so at most one instance
+ * exists; the volatile zone must stay strictly behind all stable content.
+ */
+export function appendTrailingVolatileMessage(
+  messages: unknown[],
+  info: MessageInfo,
+  spec: TaggedSyntheticPartSpec,
+): void {
+  messages.push({
+    info,
+    parts: [createTaggedSyntheticPart(spec)],
+  });
+}
+
+/**
+ * True when the message consists solely of parts tagged with the metadata
+ * key — i.e. it is a plugin-owned volatile trailing message. Used by the
+ * cache-safety tests to separate the stable prefix from the volatile tail.
+ */
+export function isVolatileTaggedMessage(
+  message: unknown,
+  metadataKey: string,
+): boolean {
+  return (
+    isMessageWithParts(message) &&
+    message.parts.length > 0 &&
+    message.parts.every((part) => isTaggedPart(part, metadataKey))
+  );
+}

+ 238 - 0
src/hooks/cache-safety-harness.test.ts

@@ -0,0 +1,238 @@
+/**
+ * Shared harness for the cache-safety test suites (no tests of its own).
+ *
+ * The `.test.ts` suffix keeps this file out of the published build
+ * (tsconfig excludes `**\/*.test.ts`); it contains only fixtures and the
+ * pipeline mirror used by cache-safety.property.test.ts and
+ * cache-payload.snapshot.test.ts.
+ */
+
+import type { PluginConfig } from '../config';
+import { resolveImageRouting } from '../config/constants';
+import { BackgroundJobBoard, createInternalAgentTextPart } from '../utils';
+import { createDisplayNameMentionRewriter } from '../utils/agent-variant';
+import { isVolatileTaggedMessage } from './cache-safe-injection';
+import { createFilterAvailableSkillsHook } from './filter-available-skills';
+import { processImageAttachments } from './image-hook';
+import { createPhaseReminderHook } from './phase-reminder';
+import { createPostFileToolNudgeHook } from './post-file-tool-nudge';
+import { SessionLifecycle } from './session-lifecycle';
+import {
+  BACKGROUND_JOB_BOARD_METADATA_KEY,
+  createTaskSessionManagerHook,
+} from './task-session-manager';
+import type { MessageWithParts } from './types';
+
+export const SESSION_ID = 'ses_cache_safety_fixture';
+export const FIXTURE_NOW = 1_700_000_000_000;
+
+export type TransformOutput = { messages: unknown[] };
+
+export interface Pipeline {
+  run: (output: TransformOutput) => Promise<void>;
+  markFileToolPending: () => void;
+  board: BackgroundJobBoard;
+}
+
+/**
+ * Mirrors the transform composition in src/index.ts. The drift guard test in
+ * cache-safety.property.test.ts fails when the two fall out of sync — update
+ * BOTH when adding, removing, or reordering a transform step.
+ */
+export function createPipeline(): Pipeline {
+  const sessionAgentMap = new Map<string, string>();
+  const board = new BackgroundJobBoard();
+  const lifecycle = new SessionLifecycle(() => {});
+  const noopLog = () => {};
+
+  const rewriteDisplayNameMentions =
+    createDisplayNameMentionRewriter(undefined);
+
+  const shouldInjectOrchestratorReminder = (sessionID: string) =>
+    sessionAgentMap.get(sessionID) === 'orchestrator';
+
+  const taskSessionManagerHook = createTaskSessionManagerHook(
+    {
+      client: {
+        session: {
+          status: async () => ({ data: {} }),
+        },
+      },
+      directory: '/tmp/cache-safety-fixture',
+      worktree: '/tmp/cache-safety-fixture',
+    } as never,
+    {
+      maxSessionsPerAgent: 2,
+      backgroundJobBoard: board,
+      shouldManageSession: (sessionID) =>
+        sessionAgentMap.get(sessionID) === 'orchestrator',
+      registerSessionAsOrchestrator: (sessionID) => {
+        sessionAgentMap.set(sessionID, 'orchestrator');
+      },
+      coordinator: lifecycle,
+    },
+  );
+
+  const postFileToolNudge = createPostFileToolNudgeHook({
+    shouldInject: shouldInjectOrchestratorReminder,
+    coordinator: lifecycle,
+  });
+
+  const phaseReminder = createPhaseReminderHook({
+    shouldInject: shouldInjectOrchestratorReminder,
+  });
+
+  const filterAvailableSkills = createFilterAvailableSkillsHook(
+    {} as never,
+    {} as PluginConfig,
+  );
+
+  const run = async (output: TransformOutput): Promise<void> => {
+    for (const message of output.messages as MessageWithParts[]) {
+      if (message.info.role !== 'user') continue;
+      for (const part of message.parts) {
+        if (part.type !== 'text' || typeof part.text !== 'string') continue;
+        part.text = rewriteDisplayNameMentions(part.text);
+      }
+    }
+
+    processImageAttachments({
+      messages: output.messages as MessageWithParts[],
+      workDir: '/tmp/cache-safety-fixture',
+      imageRouting: resolveImageRouting(undefined),
+      disabledAgents: new Set(),
+      log: noopLog,
+    });
+
+    await taskSessionManagerHook['experimental.chat.messages.transform'](
+      {} as never,
+      output as never,
+    );
+    await postFileToolNudge['experimental.chat.messages.transform'](
+      {} as never,
+      output as never,
+    );
+    await phaseReminder['experimental.chat.messages.transform'](
+      {} as never,
+      output as never,
+    );
+    await filterAvailableSkills['experimental.chat.messages.transform'](
+      {} as never,
+      output as never,
+    );
+    await taskSessionManagerHook.injectBackgroundJobBoard(
+      {} as never,
+      output as never,
+    );
+  };
+
+  return {
+    run,
+    markFileToolPending: () => lifecycle.markPending(SESSION_ID),
+    board,
+  };
+}
+
+export function userTurn(id: string, text: string, agent = 'orchestrator') {
+  return {
+    info: { role: 'user', agent, sessionID: SESSION_ID, id },
+    parts: [{ type: 'text', text }],
+  };
+}
+
+export function assistantTurn(id: string, text: string) {
+  return {
+    info: {
+      role: 'assistant',
+      agent: 'orchestrator',
+      sessionID: SESSION_ID,
+      id,
+    },
+    parts: [
+      { type: 'text', text },
+      {
+        type: 'tool',
+        tool: 'read',
+        callID: `${id}-call`,
+        state: {
+          status: 'completed',
+          input: { filePath: '/tmp/cache-safety-fixture/package.json' },
+          output: '{"name":"fixture"}',
+        },
+      },
+    ],
+  };
+}
+
+export function internalInitiatorTurn(id: string, text: string) {
+  return {
+    info: {
+      role: 'user',
+      agent: 'orchestrator',
+      sessionID: SESSION_ID,
+      id,
+    },
+    parts: [createInternalAgentTextPart(text)],
+  };
+}
+
+/**
+ * Conversation fixture covering the paths that produced past cache bugs:
+ * plain orchestrator turns, assistant tool loops, a specialist message, an
+ * internal-initiator continuation, and a message carrying a rewritable
+ * <available_skills> block.
+ */
+export function buildHistory(): unknown[] {
+  return [
+    userTurn('m01', 'set up the project'),
+    assistantTurn('m02', 'Reading the manifest first.'),
+    userTurn('m03', 'now add tests'),
+    assistantTurn('m04', 'Delegating test work.'),
+    userTurn('m05', 'specialist context', 'explorer'),
+    internalInitiatorTurn('m06', 'continue coordinating remaining todos'),
+    userTurn(
+      'm07',
+      'also consider skills\n<available_skills>\n<skill>\n<name>some-skill</name>\n<description>demo</description>\n</skill>\n</available_skills>',
+    ),
+    assistantTurn('m08', 'Wrapping up.'),
+    userTurn('m09', 'final adjustments please'),
+  ];
+}
+
+/**
+ * Indices whose message is the latest user turn of a simulated request.
+ * Only orchestrator turns end requests: when the acting agent changes, the
+ * host swaps system prompt and tools, so the provider cache restarts anyway
+ * and prefix stability across the switch is not a meaningful property.
+ */
+export function turnEndIndices(history: unknown[]): number[] {
+  const indices: number[] = [];
+  for (const [index, message] of history.entries()) {
+    const info = (message as MessageWithParts).info;
+    if (info.role === 'user' && info.agent === 'orchestrator') {
+      indices.push(index);
+    }
+  }
+  return indices;
+}
+
+export function stableFingerprints(messages: unknown[]): string[] {
+  return messages
+    .filter(
+      (message) =>
+        !isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
+    )
+    .map((message) => JSON.stringify(message));
+}
+
+export async function renderTurn(
+  pipeline: Pipeline,
+  history: unknown[],
+  endIndex: number,
+): Promise<TransformOutput> {
+  const output: TransformOutput = {
+    messages: structuredClone(history.slice(0, endIndex + 1)),
+  };
+  await pipeline.run(output);
+  return output;
+}

+ 261 - 0
src/hooks/cache-safety.property.test.ts

@@ -0,0 +1,261 @@
+/**
+ * Cache-safety property tests for the message-transform pipeline.
+ *
+ * Provider prompt caches are exact byte-prefix matches over the rendered
+ * request. These tests do not enumerate known-good payload shapes; they
+ * assert the two properties every transform must uphold for caching to
+ * survive across turns:
+ *
+ * 1. Turn-over-turn prefix stability — re-rendering a growing conversation
+ *    must reproduce byte-identical historical messages, with volatile
+ *    content confined to the tagged trailing zone.
+ * 2. Determinism — ambient inputs that should not matter (wall clock,
+ *    randomness, background-job churn) must not change any stable byte.
+ *
+ * The pipeline below mirrors the composition in src/index.ts
+ * ('experimental.chat.messages.transform'). A drift guard test fails when
+ * src/index.ts gains, loses, or reorders transform steps so this suite can
+ * never silently fall out of sync with production.
+ */
+
+import { afterEach, describe, expect, setSystemTime, test } from 'bun:test';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { isVolatileTaggedMessage } from './cache-safe-injection';
+import {
+  assistantTurn,
+  buildHistory,
+  createPipeline,
+  FIXTURE_NOW,
+  renderTurn,
+  SESSION_ID,
+  stableFingerprints,
+  type TransformOutput,
+  turnEndIndices,
+} from './cache-safety-harness.test';
+import { BACKGROUND_JOB_BOARD_METADATA_KEY } from './task-session-manager';
+
+afterEach(() => {
+  setSystemTime();
+});
+
+describe('cache-safety: turn-over-turn prefix stability', () => {
+  test('re-rendering a growing conversation reproduces byte-identical history', async () => {
+    const pipeline = createPipeline();
+    const history = buildHistory();
+    const turns = turnEndIndices(history);
+
+    let previous: string[] | undefined;
+    for (const [turnNumber, endIndex] of turns.entries()) {
+      // Exercise cross-turn hook state: a file-tool nudge fires before the
+      // second turn, and background jobs churn (launch, then drop) while
+      // later turns render — none of it may touch stable bytes.
+      if (turnNumber === 1) pipeline.markFileToolPending();
+      if (turnNumber === 2) {
+        pipeline.board.registerLaunch({
+          taskID: 'task-alpha',
+          parentSessionID: SESSION_ID,
+          agent: 'explorer',
+          description: 'churn fixture',
+          now: FIXTURE_NOW,
+        });
+      }
+      if (turnNumber === 3) pipeline.board.drop('task-alpha');
+
+      const output = await renderTurn(pipeline, history, endIndex);
+      const fingerprints = stableFingerprints(output.messages);
+
+      if (previous) {
+        if (fingerprints.length < previous.length) {
+          throw new Error(
+            'A transform removed stable messages between turns — this rewrites the cached prefix. Route the content through src/hooks/cache-safe-injection.ts instead.',
+          );
+        }
+        expect(fingerprints.slice(0, previous.length)).toEqual(previous);
+      }
+      previous = fingerprints;
+    }
+  });
+
+  test('a consumed file-tool nudge is reproduced by the phase reminder on the next turn', async () => {
+    const pipeline = createPipeline();
+    const history = buildHistory();
+
+    // Register the session (turn 1), then mark a pending nudge and render
+    // turn 2: the nudge injects into the latest user message.
+    await renderTurn(pipeline, history, 0);
+    pipeline.markFileToolPending();
+    const turnWithNudge = await renderTurn(pipeline, history, 2);
+
+    // Turn 3 renders the same message as history; the phase reminder must
+    // reproduce the exact bytes the nudge produced a turn earlier.
+    const nextTurn = await renderTurn(pipeline, history, 3);
+
+    const nudgedMessage = JSON.stringify(turnWithNudge.messages[2]);
+    const historicalMessage = JSON.stringify(nextTurn.messages[2]);
+    expect(historicalMessage).toBe(nudgedMessage);
+  });
+});
+
+describe('cache-safety: specialist sessions', () => {
+  test('non-orchestrator payloads pass through byte-identical', async () => {
+    const pipeline = createPipeline();
+    const specialistSession = 'ses_specialist_fixture';
+    const history = [
+      {
+        info: {
+          role: 'user',
+          agent: 'explorer',
+          sessionID: specialistSession,
+          id: 's01',
+        },
+        parts: [{ type: 'text', text: 'find the config loader' }],
+      },
+      assistantTurn('s02', 'Searching now.'),
+      {
+        info: {
+          role: 'user',
+          agent: 'explorer',
+          sessionID: specialistSession,
+          id: 's03',
+        },
+        parts: [{ type: 'text', text: 'summarize what you found' }],
+      },
+    ];
+    const before = history.map((message) => JSON.stringify(message));
+
+    const output: TransformOutput = { messages: structuredClone(history) };
+    await pipeline.run(output);
+
+    expect(output.messages.map((message) => JSON.stringify(message))).toEqual(
+      before,
+    );
+  });
+});
+
+describe('cache-safety: volatile content isolation', () => {
+  test('background-job state only ever changes the tagged trailing message', async () => {
+    const history = buildHistory();
+    const lastTurn = history.length - 1;
+
+    const emptyBoard = createPipeline();
+    const busyBoard = createPipeline();
+    busyBoard.board.registerLaunch({
+      taskID: 'task-beta',
+      parentSessionID: SESSION_ID,
+      agent: 'fixer',
+      description: 'volatile isolation fixture',
+      now: FIXTURE_NOW,
+    });
+
+    const withoutJobs = await renderTurn(emptyBoard, history, lastTurn);
+    const withJobs = await renderTurn(busyBoard, history, lastTurn);
+
+    expect(stableFingerprints(withJobs.messages)).toEqual(
+      stableFingerprints(withoutJobs.messages),
+    );
+
+    // The volatile zone is exactly one tagged message, strictly trailing.
+    const volatile = withJobs.messages.filter((message) =>
+      isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
+    );
+    expect(volatile).toHaveLength(1);
+    expect(withJobs.messages.at(-1)).toBe(volatile[0]);
+    expect(
+      withoutJobs.messages.some((message) =>
+        isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
+      ),
+    ).toBe(false);
+  });
+});
+
+describe('cache-safety: determinism under ambient inputs', () => {
+  test('wall clock and randomness never leak into the payload', async () => {
+    const history = buildHistory();
+    const lastTurn = history.length - 1;
+    const originalRandom = Math.random;
+
+    const render = async (time: number, random: number): Promise<string[]> => {
+      setSystemTime(new Date(time));
+      Math.random = () => random;
+      try {
+        const pipeline = createPipeline();
+        pipeline.board.registerLaunch({
+          taskID: 'task-gamma',
+          parentSessionID: SESSION_ID,
+          agent: 'oracle',
+          description: 'determinism fixture',
+          now: FIXTURE_NOW,
+        });
+        const output = await renderTurn(pipeline, history, lastTurn);
+        return output.messages.map((message) => JSON.stringify(message));
+      } finally {
+        Math.random = originalRandom;
+        setSystemTime();
+      }
+    };
+
+    const first = await render(FIXTURE_NOW, 0.1234);
+    const second = await render(FIXTURE_NOW + 987_654_321, 0.9876);
+
+    expect(second).toEqual(first);
+  });
+});
+
+describe('cache-safety: pipeline drift guard', () => {
+  const srcRoot = path.resolve(import.meta.dir, '..');
+
+  test('src/index.ts transform order matches this suite', () => {
+    const source = readFileSync(path.join(srcRoot, 'index.ts'), 'utf8');
+
+    const orderedCalls = [
+      ...source.matchAll(
+        /await (\w+)\['experimental\.chat\.messages\.transform'\]\(/g,
+      ),
+    ].map((match) => match[1]);
+
+    // If this fails, src/index.ts gained, lost, or reordered a transform
+    // step. Update createPipeline() in this file to match, then update this
+    // expectation — the property tests are only meaningful while the two
+    // stay in lockstep.
+    expect(orderedCalls).toEqual([
+      'taskSessionManagerHook',
+      'postFileToolNudge',
+      'phaseReminder',
+      'filterAvailableSkills',
+    ]);
+    expect(source).toContain(
+      'await taskSessionManagerHook.injectBackgroundJobBoard(',
+    );
+
+    // One handler definition plus the four dispatch calls above.
+    const literalCount = source.split(
+      "'experimental.chat.messages.transform'",
+    ).length;
+    expect(literalCount - 1).toBe(5);
+  });
+
+  test('every hook module defining a message transform is covered here', async () => {
+    const glob = new Bun.Glob('**/*.ts');
+    const hookFilesWithTransforms: string[] = [];
+    const hooksDir = path.join(srcRoot, 'hooks');
+
+    for await (const file of glob.scan(hooksDir)) {
+      if (file.endsWith('.test.ts')) continue;
+      const content = readFileSync(path.join(hooksDir, file), 'utf8');
+      if (content.includes("'experimental.chat.messages.transform'")) {
+        hookFilesWithTransforms.push(file);
+      }
+    }
+
+    // If a new file appears here, wire its transform into createPipeline()
+    // above (in the same order as src/index.ts) so the cache-safety
+    // properties cover it, then add it to this list.
+    expect(hookFilesWithTransforms.sort()).toEqual([
+      'filter-available-skills/index.ts',
+      'phase-reminder/index.ts',
+      'post-file-tool-nudge/index.ts',
+      'task-session-manager/index.ts',
+    ]);
+  });
+});

+ 14 - 0
src/hooks/index.ts

@@ -1,6 +1,20 @@
 export { createApplyPatchHook } from './apply-patch';
 export type { AutoUpdateCheckerOptions } from './auto-update-checker';
 export { createAutoUpdateCheckerHook } from './auto-update-checker';
+export {
+  type CacheMonitorOptions,
+  createCacheMonitorHook,
+} from './cache-monitor';
+export {
+  appendTaggedSyntheticPart,
+  appendTrailingVolatileMessage,
+  createTaggedSyntheticPart,
+  hasTaggedPart,
+  isTaggedPart,
+  isVolatileTaggedMessage,
+  stripTaggedContent,
+  type TaggedSyntheticPartSpec,
+} from './cache-safe-injection';
 export { createChatHeadersHook } from './chat-headers';
 export { createDeepworkCommandHook } from './deepwork';
 export { createDelegateTaskRetryHook } from './delegate-task-retry/hook';

+ 7 - 10
src/hooks/phase-reminder/index.ts

@@ -7,7 +7,10 @@
  */
 import { PHASE_REMINDER } from '../../config/constants';
 import { isInternalInitiatorPart } from '../../utils';
-import { isRecord } from '../../utils/guards';
+import {
+  appendTaggedSyntheticPart,
+  isTaggedPart,
+} from '../cache-safe-injection';
 import {
   findLatestUserMessage,
   isUserMessageWithParts,
@@ -19,11 +22,7 @@ export { PHASE_REMINDER };
 export const PHASE_REMINDER_METADATA_KEY = 'oh-my-opencode-slim.phaseReminder';
 
 export function hasPhaseReminder(part: MessagePart): boolean {
-  return (
-    part.synthetic === true &&
-    isRecord(part.metadata) &&
-    part.metadata[PHASE_REMINDER_METADATA_KEY] === true
-  );
+  return isTaggedPart(part, PHASE_REMINDER_METADATA_KEY);
 }
 
 interface PhaseReminderOptions {
@@ -81,11 +80,9 @@ export function createPhaseReminderHook(options: PhaseReminderOptions = {}) {
           continue;
         }
 
-        message.parts.push({
-          type: 'text',
-          synthetic: true,
+        appendTaggedSyntheticPart(message, {
           text: PHASE_REMINDER,
-          metadata: { [PHASE_REMINDER_METADATA_KEY]: true },
+          metadataKey: PHASE_REMINDER_METADATA_KEY,
         });
       }
     },

+ 3 - 4
src/hooks/post-file-tool-nudge/index.ts

@@ -8,6 +8,7 @@
 
 import { PHASE_REMINDER } from '../../config/constants';
 import { isInternalInitiatorPart } from '../../utils';
+import { appendTaggedSyntheticPart } from '../cache-safe-injection';
 import {
   hasPhaseReminder,
   PHASE_REMINDER_METADATA_KEY,
@@ -65,11 +66,9 @@ export function createPostFileToolNudgeHook(
       if (!coordinator.consumePending(sessionID)) return;
       if (hasReminder) return;
       // This transform must run before phase-reminder so this metadata deduplicates.
-      message.parts.push({
-        type: 'text',
-        synthetic: true,
+      appendTaggedSyntheticPart(message, {
         text: PHASE_REMINDER,
-        metadata: { [PHASE_REMINDER_METADATA_KEY]: true },
+        metadataKey: PHASE_REMINDER_METADATA_KEY,
       });
     },
   };

+ 13 - 27
src/hooks/task-session-manager/index.ts

@@ -12,10 +12,13 @@ import {
 } from '../../utils';
 import { isRecord as isObjectRecord } from '../../utils/guards';
 import { log } from '../../utils/logger';
+import {
+  appendTrailingVolatileMessage,
+  stripTaggedContent,
+} from '../cache-safe-injection';
 import { isFailoverError } from '../foreground-fallback/index';
 import type { SessionLifecycle } from '../session-lifecycle';
 import {
-  isMessageWithParts,
   isUserMessageWithParts,
   type MessagePart,
   type MessageWithParts,
@@ -651,14 +654,6 @@ export function createTaskSessionManagerHook(
     terminalJobsInjectedByParent.delete(parentSessionID);
   }
 
-  function isBoardPart(part: MessagePart): boolean {
-    return (
-      part.synthetic === true &&
-      isObjectRecord(part.metadata) &&
-      part.metadata[BACKGROUND_JOB_BOARD_METADATA_KEY] === true
-    );
-  }
-
   async function injectBackgroundJobBoard(
     _input: Record<string, never>,
     output: { messages?: unknown },
@@ -667,13 +662,7 @@ export function createTaskSessionManagerHook(
 
     // Strip previously injected board content: parts attached to real
     // messages (legacy placement) and whole synthetic board messages.
-    for (let i = messages.length - 1; i >= 0; i -= 1) {
-      const message = messages[i];
-      if (!isMessageWithParts(message)) continue;
-      const hadParts = message.parts.length > 0;
-      message.parts = message.parts.filter((part) => !isBoardPart(part));
-      if (hadParts && message.parts.length === 0) messages.splice(i, 1);
-    }
+    stripTaggedContent(messages, BACKGROUND_JOB_BOARD_METADATA_KEY);
 
     for (let i = messages.length - 1; i >= 0; i -= 1) {
       const message = messages[i];
@@ -703,20 +692,17 @@ export function createTaskSessionManagerHook(
       // would invalidate the provider prompt cache for everything after
       // it. A trailing message keeps board churn at the end of the
       // prompt, where it only costs itself.
-      messages.push({
-        info: {
+      appendTrailingVolatileMessage(
+        messages,
+        {
           ...message.info,
           id: `${message.info.id}-background-job-board`,
         },
-        parts: [
-          {
-            type: 'text',
-            synthetic: true,
-            text: reminder,
-            metadata: { [BACKGROUND_JOB_BOARD_METADATA_KEY]: true },
-          },
-        ],
-      });
+        {
+          text: reminder,
+          metadataKey: BACKGROUND_JOB_BOARD_METADATA_KEY,
+        },
+      );
       return;
     }
   }

+ 7 - 0
src/index.ts

@@ -27,6 +27,7 @@ import { CouncilManager } from './council';
 import {
   createApplyPatchHook,
   createAutoUpdateCheckerHook,
+  createCacheMonitorHook,
   createChatHeadersHook,
   createDeepworkCommandHook,
   createDelegateTaskRetryHook,
@@ -131,6 +132,10 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     return {};
   }
 
+  // Observation-only prompt-cache watchdog; safe to create before config
+  // loads and must see every event, so it sits outside the try block.
+  const cacheMonitor = createCacheMonitorHook();
+
   // Declare variables that must survive the try/catch for the return
   // closure. These are set inside the try block.
   let config: ReturnType<typeof loadPluginConfig>;
@@ -869,6 +874,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     },
 
     event: async (input) => {
+      await cacheMonitor.event(input);
+
       const event = input.event as {
         type: string;
         properties?: {

+ 6 - 11
src/utils/env.test.ts

@@ -10,17 +10,12 @@ describe('isTruthyEnvValue', () => {
     expect(isTruthyEnvValue(value)).toBe(true);
   });
 
-  test.each([
-    undefined,
-    '',
-    '0',
-    'false',
-    'no',
-    'off',
-    'anything',
-  ])('%p is not truthy', (value) => {
-    expect(isTruthyEnvValue(value)).toBe(false);
-  });
+  test.each([undefined, '', '0', 'false', 'no', 'off', 'anything'])(
+    '%p is not truthy',
+    (value) => {
+      expect(isTruthyEnvValue(value)).toBe(false);
+    },
+  );
 });
 
 describe('isPluginDisabledByEnv', () => {