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 3 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,
 required diff inspection, companion asset workflow, GitHub release creation,
 tagging, verification, and npm publish order.
 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
 ## Pre-Push Code Review
 
 
 Before pushing changes to the repository, when makes sense run a code review to catch issues like:
 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
 - Redundant function calls
 - Race conditions
 - Race conditions
 - Logic errors
 - Logic errors
+- Cache safety: prompt-prefix rewrites, volatile content outside the
+  trailing zone, or injections bypassing `src/hooks/cache-safe-injection.ts`
 
 
 ## Repository Map
 ## Repository Map
 
 

+ 43 - 2
docs/cache-verification.md

@@ -1,8 +1,11 @@
 # Cache verification
 # 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
 - **Deterministic payload verification** proves that this plugin projects a
   stable provider payload under a controlled local capture server. It does not
   stable provider payload under a controlled local capture server. It does not
   measure a provider cache.
   measure a provider cache.
@@ -10,6 +13,44 @@ should not be conflated.
   provider-backed OpenCode server. It is opt-in, provider-specific, and useful
   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
   for comparing explicitly controlled arms; it is not a CI check or a general
   cache guarantee.
   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
 ## 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();
       console.log();
       printInfo('Or if already installed, add it to your PATH:');
       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/.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 };
     return { ok: false };
   }
   }

+ 2 - 4
src/cli/system.ts

@@ -74,10 +74,8 @@ function getOpenCodePaths(
 ): string[] {
 ): string[] {
   const home = environment.HOME || environment.USERPROFILE || '';
   const home = environment.HOME || environment.USERPROFILE || '';
   const isWindows = process.platform === 'win32';
   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
   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 { createApplyPatchHook } from './apply-patch';
 export type { AutoUpdateCheckerOptions } from './auto-update-checker';
 export type { AutoUpdateCheckerOptions } from './auto-update-checker';
 export { createAutoUpdateCheckerHook } 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 { createChatHeadersHook } from './chat-headers';
 export { createDeepworkCommandHook } from './deepwork';
 export { createDeepworkCommandHook } from './deepwork';
 export { createDelegateTaskRetryHook } from './delegate-task-retry/hook';
 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 { PHASE_REMINDER } from '../../config/constants';
 import { isInternalInitiatorPart } from '../../utils';
 import { isInternalInitiatorPart } from '../../utils';
-import { isRecord } from '../../utils/guards';
+import {
+  appendTaggedSyntheticPart,
+  isTaggedPart,
+} from '../cache-safe-injection';
 import {
 import {
   findLatestUserMessage,
   findLatestUserMessage,
   isUserMessageWithParts,
   isUserMessageWithParts,
@@ -19,11 +22,7 @@ export { PHASE_REMINDER };
 export const PHASE_REMINDER_METADATA_KEY = 'oh-my-opencode-slim.phaseReminder';
 export const PHASE_REMINDER_METADATA_KEY = 'oh-my-opencode-slim.phaseReminder';
 
 
 export function hasPhaseReminder(part: MessagePart): boolean {
 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 {
 interface PhaseReminderOptions {
@@ -81,11 +80,9 @@ export function createPhaseReminderHook(options: PhaseReminderOptions = {}) {
           continue;
           continue;
         }
         }
 
 
-        message.parts.push({
-          type: 'text',
-          synthetic: true,
+        appendTaggedSyntheticPart(message, {
           text: PHASE_REMINDER,
           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 { PHASE_REMINDER } from '../../config/constants';
 import { isInternalInitiatorPart } from '../../utils';
 import { isInternalInitiatorPart } from '../../utils';
+import { appendTaggedSyntheticPart } from '../cache-safe-injection';
 import {
 import {
   hasPhaseReminder,
   hasPhaseReminder,
   PHASE_REMINDER_METADATA_KEY,
   PHASE_REMINDER_METADATA_KEY,
@@ -65,11 +66,9 @@ export function createPostFileToolNudgeHook(
       if (!coordinator.consumePending(sessionID)) return;
       if (!coordinator.consumePending(sessionID)) return;
       if (hasReminder) return;
       if (hasReminder) return;
       // This transform must run before phase-reminder so this metadata deduplicates.
       // This transform must run before phase-reminder so this metadata deduplicates.
-      message.parts.push({
-        type: 'text',
-        synthetic: true,
+      appendTaggedSyntheticPart(message, {
         text: PHASE_REMINDER,
         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';
 } from '../../utils';
 import { isRecord as isObjectRecord } from '../../utils/guards';
 import { isRecord as isObjectRecord } from '../../utils/guards';
 import { log } from '../../utils/logger';
 import { log } from '../../utils/logger';
+import {
+  appendTrailingVolatileMessage,
+  stripTaggedContent,
+} from '../cache-safe-injection';
 import { isFailoverError } from '../foreground-fallback/index';
 import { isFailoverError } from '../foreground-fallback/index';
 import type { SessionLifecycle } from '../session-lifecycle';
 import type { SessionLifecycle } from '../session-lifecycle';
 import {
 import {
-  isMessageWithParts,
   isUserMessageWithParts,
   isUserMessageWithParts,
   type MessagePart,
   type MessagePart,
   type MessageWithParts,
   type MessageWithParts,
@@ -651,14 +654,6 @@ export function createTaskSessionManagerHook(
     terminalJobsInjectedByParent.delete(parentSessionID);
     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(
   async function injectBackgroundJobBoard(
     _input: Record<string, never>,
     _input: Record<string, never>,
     output: { messages?: unknown },
     output: { messages?: unknown },
@@ -667,13 +662,7 @@ export function createTaskSessionManagerHook(
 
 
     // Strip previously injected board content: parts attached to real
     // Strip previously injected board content: parts attached to real
     // messages (legacy placement) and whole synthetic board messages.
     // 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) {
     for (let i = messages.length - 1; i >= 0; i -= 1) {
       const message = messages[i];
       const message = messages[i];
@@ -703,20 +692,17 @@ export function createTaskSessionManagerHook(
       // would invalidate the provider prompt cache for everything after
       // would invalidate the provider prompt cache for everything after
       // it. A trailing message keeps board churn at the end of the
       // it. A trailing message keeps board churn at the end of the
       // prompt, where it only costs itself.
       // prompt, where it only costs itself.
-      messages.push({
-        info: {
+      appendTrailingVolatileMessage(
+        messages,
+        {
           ...message.info,
           ...message.info,
           id: `${message.info.id}-background-job-board`,
           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;
       return;
     }
     }
   }
   }

+ 7 - 0
src/index.ts

@@ -27,6 +27,7 @@ import { CouncilManager } from './council';
 import {
 import {
   createApplyPatchHook,
   createApplyPatchHook,
   createAutoUpdateCheckerHook,
   createAutoUpdateCheckerHook,
+  createCacheMonitorHook,
   createChatHeadersHook,
   createChatHeadersHook,
   createDeepworkCommandHook,
   createDeepworkCommandHook,
   createDelegateTaskRetryHook,
   createDelegateTaskRetryHook,
@@ -131,6 +132,10 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     return {};
     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
   // Declare variables that must survive the try/catch for the return
   // closure. These are set inside the try block.
   // closure. These are set inside the try block.
   let config: ReturnType<typeof loadPluginConfig>;
   let config: ReturnType<typeof loadPluginConfig>;
@@ -869,6 +874,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     },
     },
 
 
     event: async (input) => {
     event: async (input) => {
+      await cacheMonitor.event(input);
+
       const event = input.event as {
       const event = input.event as {
         type: string;
         type: string;
         properties?: {
         properties?: {

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

@@ -10,17 +10,12 @@ describe('isTruthyEnvValue', () => {
     expect(isTruthyEnvValue(value)).toBe(true);
     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', () => {
 describe('isPluginDisabledByEnv', () => {