Skip to content

feat(hooks): wire sessionStart and sessionEnd lifecycle hooks#158

Open
raykao wants to merge 4 commits intoChrisRomp:mainfrom
raykao:feat/session-lifecycle-hooks
Open

feat(hooks): wire sessionStart and sessionEnd lifecycle hooks#158
raykao wants to merge 4 commits intoChrisRomp:mainfrom
raykao:feat/session-lifecycle-hooks

Conversation

@raykao
Copy link
Copy Markdown

@raykao raykao commented Mar 26, 2026

Wires sessionStart and sessionEnd into session-manager.ts — they were defined, tested, and silently never called. See #157 for full context, rationale, and validation results.

Changes

src/core/session-manager.ts

  • sessionStart: fired non-blocking after session ready in createNewSession() and attachSession()
  • sessionEnd: awaited before destroySession() in newSession() and reloadSession()
  • Hooks cache invalidated on /new and /reload so hooks.json changes are picked up

src/core/hooks-loader.ts

  • Use /bin/bash (absolute path) on non-Windows — prevents ENOENT in nvm environments where subprocess PATH may not include bash

Why sessionEnd is awaited, sessionStart is not

sessionEnd must complete before destroySession() — hook may be writing a backup. sessionStart fires after the session is live — awaiting it would delay the first response.

Tests

All 578 existing tests pass. Validated end-to-end: hooks fired correctly, no warnings, bd backup export-git committed to git branch on session end.

Relates to: #157

sessionStart and sessionEnd hook types were already defined in hooks-loader.ts
and mapped in HOOK_TYPE_MAP, but were never called in session-manager.ts.

This change wires them at the correct lifecycle points:

- sessionStart: fired after session is ready in createNewSession() and
  attachSession() (both new session and resume paths). Non-blocking —
  runs async so it does not delay the session becoming available.

- sessionEnd: fired before destroySession() in newSession() (/new command)
  and reloadSession() (/reload command). Awaited so hooks can complete
  before the session is torn down (e.g. backup, summary, cleanup).

Both hooks receive { sessionId, channelId } as input and handle errors
gracefully (best-effort, logged as warnings).

Relates to: ChrisRomp#157

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Wires previously-defined sessionStart/sessionEnd hook types into SessionManager so workspace hook automation can run at session lifecycle boundaries.

Changes:

  • Fire sessionStart hooks after session creation (createNewSession) and resume (attachSession) in a non-blocking, best-effort manner.
  • Fire sessionEnd hooks before teardown in /new (newSession) and /reload (reloadSession), awaiting completion and logging failures as warnings.
Comments suppressed due to low confidence (2)

src/core/session-manager.ts:705

  • In reloadSession(), the session event listener is unsubscribed before awaiting the sessionEnd hook. Because channelSessions still points at the existing session until destroySession runs, a concurrent sendMessage() during this window will reuse the cached session (ensureSession() sees bridge.getSession()) but no longer has attachSessionEvents wired, so responses/usage events can be dropped. Consider firing sessionEnd before unsubscribing, or otherwise marking the session as “reloading” so ensureSession/sendMessage will wait or re-attach events during teardown.
    const unsub = this.sessionUnsubscribes.get(existingId);
    if (unsub) { unsub(); this.sessionUnsubscribes.delete(existingId); }

    // Fire sessionEnd hook before teardown (best-effort)
    const reloadWorkingDirectory = this.resolveWorkingDirectory(channelId);
    const reloadRawHooks = await this.resolveHooks(reloadWorkingDirectory);
    const reloadHooks = this.wrapHooksWithAsk(reloadRawHooks, channelId);

src/core/session-manager.ts:706

  • resolveHooks() caches hooks per workingDirectory, so reloadSession() (and newSession()) will use potentially stale hooks.json contents when firing sessionEnd (and later sessionStart) after a hooks.json edit. Since /reload is intended to pick up workspace config changes, consider bypassing/invalidating the workspaceHooks cache for this workingDirectory on /reload (and possibly /new) before resolving hooks for these lifecycle events.
    // Fire sessionEnd hook before teardown (best-effort)
    const reloadWorkingDirectory = this.resolveWorkingDirectory(channelId);
    const reloadRawHooks = await this.resolveHooks(reloadWorkingDirectory);
    const reloadHooks = this.wrapHooksWithAsk(reloadRawHooks, channelId);
    if (reloadHooks?.onSessionEnd) {

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/core/session-manager.ts Outdated
Comment on lines +656 to +666
// Fire sessionEnd hook before teardown (best-effort)
const workingDirectory = this.resolveWorkingDirectory(channelId);
const rawHooks = await this.resolveHooks(workingDirectory);
const hooks = this.wrapHooksWithAsk(rawHooks, channelId);
if (hooks?.onSessionEnd) {
try {
await hooks.onSessionEnd({ sessionId: existingId, channelId }, { sessionId: existingId });
} catch (err: any) {
log.warn(`sessionEnd hook failed: ${err?.message ?? err}`);
}
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sessionEnd hook firing logic is duplicated between newSession() and reloadSession() (resolveWorkingDirectory → resolveHooks → wrapHooksWithAsk → try/await hook). Consider extracting a small helper (e.g., fireSessionEndHook(channelId, sessionId)) so future changes (timeouts, logging context, cache invalidation) stay consistent across call sites.

This issue also appears in the following locations of the same file:

  • line 699
  • line 702

See below for a potential fix:

  private async fireSessionEndHook(channelId: string, sessionId: string): Promise<void> {
    // Fire sessionEnd hook before teardown (best-effort)
    const workingDirectory = this.resolveWorkingDirectory(channelId);
    const rawHooks = await this.resolveHooks(workingDirectory);
    const hooks = this.wrapHooksWithAsk(rawHooks, channelId);
    if (hooks?.onSessionEnd) {
      try {
        await hooks.onSessionEnd({ sessionId, channelId }, { sessionId });
      } catch (err: any) {
        log.warn(`sessionEnd hook failed: ${err?.message ?? err}`);
      }
    }
  }

  async newSession(channelId: string): Promise<string> {
    // Clean up existing session
    const existingId = this.channelSessions.get(channelId);
    if (existingId) {
      await this.fireSessionEndHook(channelId, existingId);

Copilot uses AI. Check for mistakes.
Two issues identified in code review of ChrisRomp#158:

1. reloadSession() unsubscribed event listeners before awaiting sessionEnd.
   During the await window, a concurrent sendMessage() could reuse the cached
   session but with no event listeners, silently dropping response/usage events.
   Fix: fire sessionEnd before unsubscribing so events remain wired during the hook.

2. resolveHooks() caches hooks per workingDirectory. Since /reload and /new are
   intended to pick up workspace config changes (including hooks.json edits), the
   cached hooks could be stale at the point sessionEnd/sessionStart fire.
   Fix: invalidate the workspaceHooks cache entry before resolving hooks in both
   newSession() and reloadSession().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@raykao
Copy link
Copy Markdown
Author

raykao commented Mar 26, 2026

Addressed both suggestions from the code review in commit 1e69de5:

1. Event ordering in reloadSession()sessionEnd now fires before unsub() so event listeners remain wired during the hook await, preventing concurrent sendMessage() calls from dropping response/usage events.

2. Stale hooks cacheworkspaceHooks.delete(workingDirectory) is called before resolveHooks() in both newSession() and reloadSession(), ensuring /new and /reload always read the latest hooks.json from disk.

When copilot-bridge spawns hook scripts, it hardcoded 'bash' as the shell
command. In some environments (e.g. nvm-managed Node.js on Linux), the
subprocess PATH may not include the directories where bash lives, causing
spawn to fail with ENOENT.

Fix: use '/bin/bash' (absolute path) on non-Windows platforms. Windows
continues to use 'bash' (relies on Git Bash or WSL being on PATH).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 26, 2026 04:25
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

src/core/session-manager.ts:706

  • Same issue as /new: sessionEnd hook resolution/cache invalidation uses resolveWorkingDirectory(channelId) at call time. If the channel’s working directory changes between session start and /reload, this can run the wrong workspace’s sessionEnd hooks. Consider using the workingDirectory associated with existingId (tracked at create/resume time) when firing sessionEnd.
    const reloadWorkingDirectory = this.resolveWorkingDirectory(channelId);
    this.workspaceHooks.delete(reloadWorkingDirectory);
    const reloadRawHooks = await this.resolveHooks(reloadWorkingDirectory);
    const reloadHooks = this.wrapHooksWithAsk(reloadRawHooks, channelId);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/core/session-manager.ts Outdated
Comment on lines +656 to +660
// Fire sessionEnd hook before teardown (best-effort). Invalidate hooks cache
// so /new picks up any hooks.json edits since the session was created.
const workingDirectory = this.resolveWorkingDirectory(channelId);
this.workspaceHooks.delete(workingDirectory);
const rawHooks = await this.resolveHooks(workingDirectory);
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sessionEnd hook loading is based on resolveWorkingDirectory(channelId) at the time /new is invoked. If the user changed the workspace override/config and then runs /new (which is suggested elsewhere in this file), this will fire sessionEnd using the new workspace’s hooks.json rather than the workspace the ending session actually ran in. Consider tracking the workingDirectory used when the session was created/resumed (e.g., map sessionId→workingDirectory) and using that for hook cache invalidation + resolveHooks() here.

This issue also appears on line 703 of the same file.

Copilot uses AI. Check for mistakes.
Comment thread src/core/hooks-loader.ts
*/
async function executeHookCommand(cmd: HookCommand, input: any, baseDir: string): Promise<any | undefined> {
const shell = cmd.bash ? 'bash' : 'powershell';
const shell = cmd.bash ? (process.platform === 'win32' ? 'bash' : '/bin/bash') : 'powershell';
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoding the bash executable to /bin/bash on non-Windows can be a portability regression: it bypasses the user’s PATH and can force an older bash (notably macOS ships bash 3.2 at /bin/bash), breaking hooks that rely on bash 4+ features. Prefer resolving bash from PATH (or using /usr/bin/env bash) so the configured environment determines which bash runs.

Suggested change
const shell = cmd.bash ? (process.platform === 'win32' ? 'bash' : '/bin/bash') : 'powershell';
const shell = cmd.bash ? 'bash' : 'powershell';

Copilot uses AI. Check for mistakes.
@raykao
Copy link
Copy Markdown
Author

raykao commented Mar 26, 2026

Additional fix discovered during local end-to-end validation (commit 85ed66c):

fix(hooks): use absolute path /bin/bash to avoid ENOENT on spawn

In nvm-managed Node.js environments, the subprocess PATH may not include directories where bash lives, causing spawn bash ENOENT. Fixed by using /bin/bash (absolute path) on non-Windows platforms.

resolveWorkingDirectory(channelId) reads live channel config, which may
change between session creation and /reload or /new. If a channel's
working directory is updated while a session is live, sessionEnd would
fire against the new workspace's hooks instead of the original session's.

Fix: add sessionWorkingDirectories map (sessionId → workingDirectory),
populated at createNewSession() and attachSession() time. newSession()
and reloadSession() now read from this map with a fallback to
resolveWorkingDirectory() for safety.

Map is cleaned up on destroySession() to avoid leaking session entries.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@raykao
Copy link
Copy Markdown
Author

raykao commented Mar 26, 2026

Addressed review feedback (commit 8c29c9e):

Added sessionWorkingDirectories map (sessionId → workingDirectory) populated at session create (createNewSession) and resume (attachSession) time. newSession() and reloadSession() now read from this map with a fallback to resolveWorkingDirectory(channelId). Map is cleaned up on destroy. This ensures sessionEnd always fires against the workspace the session was originally created in, regardless of live channel config changes.

@ChrisRomp
Copy link
Copy Markdown
Owner

Thanks for this, @raykao! The lifecycle hooks are a solid addition. A few things before this can merge:

CCR threads (3 unresolved):

Copilot code review left 3 comments. Two are marked outdated (looks like you may have addressed them with the sessionWorkingDirectories map), and one is current. If you've addressed them, please resolve the threads or reply so we can track status.

  1. Duplicate sessionEnd logic (outdated) - CCR suggests extracting a fireSessionEndHook() helper. The hook-firing block is repeated in both newSession() and reloadSession(). Even with the workspace-tracking fix, the duplication remains. A small private method would keep this DRY.

  2. Workspace tracking for sessionEnd (outdated) - CCR flagged that resolveWorkingDirectory(channelId) at teardown time might not match the session's original workspace. Your sessionWorkingDirectories map addresses this. If so, please resolve the thread.

  3. /bin/bash hardcode (current) - The change from 'bash' to '/bin/bash' bypasses PATH and forces macOS's ancient bash 3.2 at that path. The original 'bash' is better since it lets the user's PATH determine which bash runs. Academically: on Windows, hooks use the powershell branch, so the bash path is moot there. But there's no guard if bash isn't installed at all (spawn would just fail). Something to consider but not a blocker for this PR.

On beads.agent.md in templates/:

The template lives in templates/agents/ which could imply it ships as part of the default agent set. Since it only activates if manually copied to a workspace and invoked via /agent beads, it's not a runtime risk. But we're considering adding a docs/recipes/ folder for opt-in workflow guides. This might be a better home for it alongside the docs/beads.md guide, rather than in templates/agents/. Thoughts?

Additional:

  • No tests for the new session manager changes. At minimum, a test verifying sessionEnd fires on /new and that sessionWorkingDirectories cleans up would be good to have.

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