Skip to content

[FEATURE]:Session lifecycle context hooks for persistent plugin state #28695

Description

@vuldin

Feature hasn't been suggested before.

  • I have verified this feature I'm about to request hasn't been suggested before.

Describe the enhancement you want to request

Related Issues

This builds on prior requests for session lifecycle hooks:

Problem

Today, OpenCode plugins can react to events (via event hook) and intercept tool calls, but they have no structured way to maintain persistent context across a session. The current workarounds have serious limitations:

  1. AGENTS.md / SKILL.md instructions: Loaded once at the start and progressively forgotten as context grows. After compaction, behavioral rules are ~100% lost (as noted in [FEATURE]: Plugin hooks should be able to inject AI-visible messages into conversation context #17412).
  2. Subagent prompts: Injected into subagents only, not the main session.
  3. experimental.chat.messages.transform: Can mutate messages but is too low-level; plugins must manually add context into system messages.
  4. Tool-based context: Plugins can emit tool results, but these appear mid-conversation and don't establish upfront context for the turn.

Proposed Solution

Add three lifecycle hooks that give plugins a channel to inject context into the model's system prompt, with clear persistence semantics:

Hook When it fires Persistence Use case
session.start Session creation Persistent across all turns Load project state once (open tasks, git status, recent commits)
prompt.submit Before each LLM turn Ephemeral (this turn only) Ephemeral retrieval for the current prompt (semantic search, relevant file summaries)
session.stop After successful turn completion Replaces persistent context Update state based on turn outcome (extract facts, mark tasks complete, track progress)

Why three hooks?

session.start gives plugins a bootstrap point. Without this, a plugin must either 1) lazy-load context on first tool call (which is too late since the first prompt has already gone to the model), or 2) run expensive setup on every prompt.submit (wasteful). Issue #5409 identified this exact gap: loading dev context (git status, package.json, directory tree) currently requires manual work before each session.

prompt.submit provides an ephemeral channel. Persistent context from session.start eventually goes stale, so prompt.submit lets plugins inject fresh, query-specific context for this turn only. This is critical for retrieval-based plugins since semantic search results for the current user query should not persist into the next turn, but the search results should still be visible to the model.

session.stop closes the loop. After a turn completes, plugins often need to update their understanding of the world (for example, "the user just reviewed 3 files, mark them as seen"). Without a stop hook plugins must either 1) fire writes on every tool call (races, partial state), or 2) scan the entire conversation history on the next prompt.submit (expensive). session.stop fires once, with the turn outcome (text, toolCalls, finishReason), letting plugins atomically update persistent state.

How they work together

Session creation
  → session.start hook → sets persistent context ["Project: 5 open tasks"]
        ↓
  User sends message
  → prompt.submit hook → ephemeral context ["Relevant: src/auth.ts"]
  → System prompt = [persistent] + [ephemeral] + [normal instructions]
        ↓
  LLM turn completes
  → session.stop hook → updates persistent context ["Project: 4 open tasks"]
        ↓
  Next user message
  → prompt.submit hook → new ephemeral context ["Relevant: src/user.ts"]
  → System prompt = [updated persistent] + [new ephemeral] + [normal instructions]

The persistent context survives compaction because it's stored on the session row, not in the message history. The ephemeral context is injected per-turn and does not bloat the session state.

Context budget and truncation

Plugins compete for the same context window. A lifecycle.maxInjectedContextChars config option (default 4000) caps total injected context per turn. If plugins exceed the limit, context is truncated with a logged warning. This prevents a misbehaving plugin from silently consuming the entire context window.

Claude Code parallel

Claude Code provides SessionStart, PreToolUse, PostToolUse, and Stop hooks. This proposal covers the same session-level lifecycle (SessionStart -> session.start, Stop -> session.stop) and adds an explicit prompt.submit hook for per-turn ephemeral context that Claude Code handles via PreToolUse/PostToolUse message injection. The goal is parity for plugins that need behavioral enforcement across a session's lifetime.

Concrete Use Cases

Context-aware project assistant:

  • session.start: Load open GitHub issues, recent commits, and active branch into persistent context
  • prompt.submit: Search embeddings of the codebase for files semantically related to the current user query, inject summaries
  • session.stop: Update a local "task state" file based on what the model did, persist a summary back into session.start context for next turn

Planning-with-files skill (as benchmarked in #17412):

  • session.start: Inject "You must maintain task_plan.md, findings.md, and progress.md"
  • prompt.submit: Load current state of the three planning files as ephemeral context
  • session.stop: Verify the three files were updated; if not, update persistent context with a reminder for the next turn

Why not existing mechanisms?

Mechanism Limitation How hooks fix it
AGENTS.md Forgotten after compaction Persistent context is stored outside message history
event hook Fire-and-forget, can't inject context Hooks mutate output that is explicitly injected into system prompt
Tool results Appear mid-conversation, no upfront context Context is prepended to system prompt before the turn
experimental.chat.messages.transform Requires manual message splicing, fragile First-class, typed, bounded channel

Implementation Sketch

  1. Add plugin_context JSON column to session table
  2. Add session.start hook at Session.create() — output context persisted to plugin_context
  3. Add prompt.submit hook in SessionPrompt before handle.process() — output context prepended to system array for this turn only
  4. Add session.stop hook after LLM loop exits normally — output context replaces plugin_context
  5. Add lifecycle.maxInjectedContextChars config option
  6. Add limitPluginContext() utility with truncation + warning log
  7. Add clearContext flag on session.stop output for explicit wipe
  8. Regenerate SDK types, add tests, add docs

All hooks are wrapped in Effect.catchCause — failures are logged and swallowed, never blocking the session.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions