Skip to content

Conversation

@lgandecki
Copy link

Summary

Adds support for SessionStart hooks from Claude Code plugins, enabling plugins like claude-mem to inject context at session start - matching the behavior in Claude Code.

Changes

  • types.ts: Add SessionStart to ClaudeHookEvent union and ClaudeHooksConfig interface, plus SessionStartInput/SessionStartOutput interfaces
  • session-start.ts (new): Executor for SessionStart hooks that wraps output in <session-start-hook> tags
  • config.ts: Add loadPluginHooksConfigs() to load hooks from ~/.claude/plugins/*/hooks/hooks.json and merge with settings.json hooks
  • config-loader.ts: Add SessionStart to DisabledHooksConfig interface
  • index.ts: Wire SessionStart execution to session.created event via injectHookMessage()
  • loader.ts: Fix replace()replaceAll() for ${CLAUDE_PLUGIN_ROOT} variable resolution (bug: only first occurrence was replaced)

How It Works

  1. On session.created event, executeSessionStartHooks() is called
  2. Plugin hooks are loaded from ~/.claude/plugins/installed_plugins.json and merged with settings.json hooks
  3. Each SessionStart hook command is executed with cwd set to the project directory
  4. Hook output is wrapped in <session-start-hook> tags and injected into the session

Testing

Tested with claude-mem's native hooks - memory context is successfully injected at session start.

Related

- Add SessionStart to ClaudeHookEvent union and ClaudeHooksConfig
- Add SessionStartInput/SessionStartOutput interfaces in types.ts
- Create session-start.ts executor that wraps output in <session-start-hook> tags
- Wire SessionStart execution to session.created event in index.ts
- Add loadPluginHooksConfigs() to load hooks from ~/.claude/plugins/
- Fix replace() -> replaceAll() for ${CLAUDE_PLUGIN_ROOT} variable resolution

This enables Claude Code plugins like claude-mem to inject context at
session start, matching the behavior in Claude Code.
@github-actions
Copy link
Contributor

github-actions bot commented Dec 30, 2025

All contributors have signed the CLA. Thank you! ✅
Posted by the CLA Assistant Lite bot.

@lgandecki
Copy link
Author

I have read the CLA Document and I hereby sign the CLA

@greptile-apps
Copy link

greptile-apps bot commented Dec 30, 2025

Greptile Summary

Adds SessionStart hook support to enable plugins like claude-mem to inject context at session initialization, matching Claude Code behavior.

Key Changes:

  • Introduced SessionStart hook type with corresponding input/output interfaces in types.ts
  • Created new session-start.ts executor that runs hook commands and wraps output in <session-start-hook> tags
  • Extended config.ts with loadPluginHooksConfigs() to discover and load hooks from ~/.claude/plugins/*/hooks/hooks.json
  • Integrated SessionStart execution into the session.created event handler in index.ts, injecting results via injectHookMessage()
  • Fixed bug in loader.ts: changed replace() to replaceAll() to properly resolve all instances of ${CLAUDE_PLUGIN_ROOT} variable

Implementation follows existing patterns:

  • Hook execution pattern matches other hook types (PreToolUse, PostToolUse, etc.)
  • Plugin loading respects enabledPlugins from settings.json
  • Path resolution uses consistent ${CLAUDE_PLUGIN_ROOT} variable substitution
  • Handles both JSON-formatted and raw text hook outputs gracefully
  • Supports hook disabling via extended config

The implementation is clean, well-structured, and integrates seamlessly with the existing hook infrastructure.

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk
  • The implementation is well-structured and follows existing patterns throughout the codebase. All changes are additive with no breaking modifications. The code properly handles errors, respects plugin enablement settings, and includes a valuable bug fix for path resolution. The SessionStart hook integrates cleanly with the existing hook infrastructure and has been tested with claude-mem plugin.
  • No files require special attention

Important Files Changed

Filename Overview
src/hooks/claude-code-hooks/session-start.ts New executor for SessionStart hooks that executes hook commands, wraps output in tags, and handles both JSON and raw text responses gracefully
src/hooks/claude-code-hooks/config.ts Added loadPluginHooksConfigs() to load hooks from ~/.claude/plugins/*/hooks/hooks.json with proper path resolution and plugin enablement checking
src/hooks/claude-code-hooks/index.ts Wired SessionStart execution to session.created event, injecting hook output into session via injectHookMessage()

Sequence Diagram

sequenceDiagram
    participant OC as OpenCode
    participant Hook as ClaudeCodeHooks
    participant Config as config.ts
    participant Exec as session-start.ts
    participant Plugin as Plugin Hook Command
    participant Injector as injectHookMessage()

    OC->>Hook: event: session.created
    Note over Hook: Extract sessionID & directory
    
    Hook->>Hook: Check if SessionStart disabled
    
    Hook->>Config: loadClaudeHooksConfig()
    Config->>Config: Load from settings.json files
    Config->>Config: loadPluginHooksConfigs()
    Config->>Config: Read installed_plugins.json
    Config->>Config: Check enabledPlugins
    Config->>Config: Read hooks/hooks.json from each plugin
    Config->>Config: Resolve ${CLAUDE_PLUGIN_ROOT} paths
    Config->>Config: Merge all hooks configs
    Config-->>Hook: ClaudeHooksConfig
    
    Hook->>Exec: executeSessionStartHooks(ctx, config)
    Exec->>Exec: Find matching SessionStart hooks
    
    loop For each hook command
        Exec->>Plugin: Execute hook command with stdin
        Note over Plugin: Receives SessionStartInput JSON:<br/>{session_id, cwd, transcript_path}
        Plugin->>Plugin: Process context (e.g. load memory)
        Plugin-->>Exec: stdout (JSON or raw text)
        Exec->>Exec: Parse output & wrap in tags
        Note over Exec: <session-start-hook><br/>context<br/></session-start-hook>
    end
    
    Exec-->>Hook: SessionStartResult{context[], elapsedMs, hookName}
    
    alt context.length > 0
        Hook->>Injector: injectHookMessage(sessionID, hookContent, {path})
        Injector->>Injector: Create message & part files
        Injector-->>Hook: success
        Hook->>Hook: Log injection status
    end
    
    Hook-->>OC: Complete
Loading

@greptile-apps
Copy link

greptile-apps bot commented Dec 30, 2025

Greptile found no issues!

From now on, if a review finishes and we haven't found any issues, we will not post anything, but you can confirm that we reviewed your changes in the status check section.

This feature can be toggled off in your Code Review Settings by deselecting "Create a status check for each PR".

@lgandecki
Copy link
Author

funny thing is, it was oh my opencode just in the background deciding to create this PR! crazy. I would be more hesitant but maybe it made a right decision, it seems to work for me. relates to: #137 #69 ( I see it discusses that this is not possible, so maybe I missed something) #74

@lgandecki lgandecki marked this pull request as draft December 30, 2025 07:49
@lgandecki
Copy link
Author

Ok, in fact, the idle/session end might not be working fully yet. Let me investigate a bit more

…mprovements

- Add SessionEnd hook support for session cleanup
- Add session state file tracking (~/.claude-mem/opencode-sessions.json)
- Implement 60s delayed Stop hook matching Claude Code's idle_prompt behavior
- Fix transcript format to match Claude Code (message.content wrapper)
- Add message.part.updated handler to record assistant text responses
- Clean up session tracking on session.deleted
github-actions bot added a commit that referenced this pull request Dec 30, 2025
@code-yeongyu
Copy link
Owner

@sisyphus-dev-ai please review this, because as far as i know, session start and session end cannot be implemented well cuz opencode have not sufficient hook event for that. investigate and comment on here- including some hacky way too

ulw

@sisyphus-dev-ai sisyphus-dev-ai added the sisyphus: working Sisyphus is currently working on this label Dec 30, 2025
@sisyphus-dev-ai
Copy link
Collaborator

👀 Looking into this now. Will investigate OpenCode's hook event system and comment back with findings on session start/end implementation feasibility.

@sisyphus-dev-ai
Copy link
Collaborator

Investigation Complete: SessionStart/SessionEnd Hook Feasibility

I've analyzed the PR implementation against both OpenCode's event system and Claude Code's hook specifications. Here's the complete breakdown:


OpenCode Events Available (from source analysis)

Event Status Input Data
session.created ✅ Available { info: { id, directory, time.created, ... } }
session.deleted ✅ Available { info: { id, directory, ... } }
session.status ✅ Available { sessionID, status: { type: "idle" | "busy" | "retry" } }
session.idle ⚠️ DEPRECATED { sessionID }
session.error ✅ Available { sessionID, error }
session.compacted ✅ Available { sessionID }

Claude Code Hook Events vs PR Implementation

Claude Code Event PR Mapping Assessment
SessionStart session.created Works well
SessionEnd session.deleted ⚠️ Semantic mismatch
Stop session.idle + 60s delay ⚠️ Hacky but functional

Detailed Analysis

1. SessionStart → session.created

This works correctly. OpenCode's session.created fires when a new session is created, providing full session info. This is semantically equivalent to Claude Code's SessionStart with source: "startup".

Gap: Claude Code's SessionStart also fires with:

  • source: "resume" - when resuming an existing session
  • source: "compact" - after session compaction
  • source: "clear" - after clearing context

OpenCode doesn't emit session.created for these cases. The PR's implementation works for new sessions but won't trigger for session resume/compact/clear.

2. SessionEnd → session.deleted ⚠️

This has semantic issues:

  • Claude Code's SessionEnd fires on: "clear", "logout", "prompt_input_exit", "other"
  • OpenCode's session.deleted fires only when the session is explicitly deleted from the system

The Gap: If a user just closes the TUI or exits without deleting the session, OpenCode sessions persist. session.deleted will never fire. This means:

  • Sessions that are "abandoned" (user closes terminal) won't trigger SessionEnd hooks
  • Only explicit session deletion (via TUI or API) triggers the event

Workaround in PR: The session state file (~/.claude-mem/opencode-sessions.json) tracks sessions and can detect orphaned sessions via getOrphanedSessions(). This is clever but requires periodic cleanup.

3. Stop → session.idle + 60s delay ⚠️

The PR implements a 60-second delayed Stop hook, which is the hacky workaround you mentioned.

How it works:

  1. On session.idle event, schedule a Stop hook with 60s delay
  2. If any activity occurs during that 60s (user message, tool execution), cancel the pending hook
  3. If no activity after 60s, execute the Stop hooks

Issues:

  1. session.idle is DEPRECATED - OpenCode's source marks it as deprecated in favor of session.status with type: "idle". Should migrate to:

    if (event.type === "session.status" && event.properties.status.type === "idle")
  2. Timing mismatch - Claude Code's Stop fires immediately when the agent finishes. The 60s delay is to match Claude Code's "idle_prompt" notification behavior, but actual Stop hooks in Claude Code don't have this delay.

  3. False negatives - If the session truly ends (user closes TUI) before 60s, the Stop hook might never fire.


Hacky Workarounds Present in PR

Workaround Purpose Effectiveness
60s delay timer Match Claude Code's idle_prompt behavior ⚠️ Works but not exact semantic match
Activity cancellation Prevent false Stop triggers ✅ Good
Session state file Track session lifecycle externally ✅ Clever
Orphan detection Handle abandoned sessions ✅ Good for cleanup

Recommendations

  1. Migrate from session.idle to session.status - The deprecated event may be removed in future OpenCode versions:

    if (event.type === "session.status") {
      const status = event.properties.status as { type: string }
      if (status.type === "idle") {
        // Handle idle
      }
    }
  2. Document the semantic differences - Users expecting exact Claude Code behavior should know:

    • SessionEnd only fires on explicit session deletion
    • Stop hooks have a 60s delay before firing
    • SessionStart doesn't fire on resume/compact/clear
  3. Consider alternative for SessionEnd - Could potentially hook into:

    • server.instance.disposed for cleanup when OpenCode shuts down
    • Or accept this limitation and rely on orphan session cleanup
  4. The 60s delay - This is actually reasonable. It matches Claude Code's idle_prompt notification timing. Users who need immediate Stop hook execution can use the existing session.idle trigger without the delay by bypassing the scheduler.


Verdict

Can SessionStart/SessionEnd be implemented "well"?

Partially yes, partially no:

Feature Can implement well? Notes
SessionStart (new sessions) ✅ Yes Maps cleanly to session.created
SessionStart (resume/compact) ❌ No OpenCode doesn't emit events for these
SessionEnd (explicit delete) ✅ Yes Maps to session.deleted
SessionEnd (implicit close) ❌ No No event for TUI close/exit
Stop (with delay) ⚠️ Hacky Works with 60s delay heuristic

The PR's approach is the best possible given OpenCode's current event system. The session state tracking is a smart workaround for the missing lifecycle events.


Code Quality Notes

The implementation looks solid:

  • ✅ Clean separation of concerns (session-start.ts, session-end.ts, session-state.ts)
  • ✅ Activity tracking to prevent false triggers
  • ✅ Orphan session detection for cleanup
  • ⚠️ Should use session.status instead of deprecated session.idle
  • ⚠️ The replace()replaceAll() fix is good

Overall: This PR represents the best-effort implementation given OpenCode's limitations. The hacky workarounds are well-designed and handle edge cases reasonably.

@sisyphus-dev-ai sisyphus-dev-ai removed the sisyphus: working Sisyphus is currently working on this label Dec 30, 2025
@lgandecki
Copy link
Author

@code-yeongyu yeah, right intuition, some of the things beyond control of this plugin unfortunately.. I'm still working on this but decided to make it in this way:

  • external script watching the session, if enough time passed, and it wasn't processed, that means someone closed opencode before the 60s passed.
  • for resuming sessions I will just see if a session is created after first message from the user is sent

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants