feat(hooks): wire sessionStart and sessionEnd lifecycle hooks#158
feat(hooks): wire sessionStart and sessionEnd lifecycle hooks#158raykao wants to merge 4 commits intoChrisRomp:mainfrom
Conversation
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>
There was a problem hiding this comment.
Pull request overview
Wires previously-defined sessionStart/sessionEnd hook types into SessionManager so workspace hook automation can run at session lifecycle boundaries.
Changes:
- Fire
sessionStarthooks after session creation (createNewSession) and resume (attachSession) in a non-blocking, best-effort manner. - Fire
sessionEndhooks 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.
| // 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}`); | ||
| } | ||
| } |
There was a problem hiding this comment.
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);
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>
|
Addressed both suggestions from the code review in commit 1e69de5: 1. Event ordering in 2. Stale hooks cache — |
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>
There was a problem hiding this comment.
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:sessionEndhook resolution/cache invalidation usesresolveWorkingDirectory(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 withexistingId(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.
| // 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); |
There was a problem hiding this comment.
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.
| */ | ||
| 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'; |
There was a problem hiding this comment.
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.
| const shell = cmd.bash ? (process.platform === 'win32' ? 'bash' : '/bin/bash') : 'powershell'; | |
| const shell = cmd.bash ? 'bash' : 'powershell'; |
|
Additional fix discovered during local end-to-end validation (commit 85ed66c):
In nvm-managed Node.js environments, the subprocess PATH may not include directories where |
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>
|
Addressed review feedback (commit 8c29c9e): Added |
|
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
On beads.agent.md in templates/: The template lives in Additional:
|
Wires
sessionStartandsessionEndintosession-manager.ts— they were defined, tested, and silently never called. See #157 for full context, rationale, and validation results.Changes
src/core/session-manager.tssessionStart: fired non-blocking after session ready increateNewSession()andattachSession()sessionEnd: awaited beforedestroySession()innewSession()andreloadSession()/newand/reloadso hooks.json changes are picked upsrc/core/hooks-loader.ts/bin/bash(absolute path) on non-Windows — preventsENOENTin nvm environments where subprocess PATH may not includebashWhy sessionEnd is awaited, sessionStart is not
sessionEndmust complete beforedestroySession()— hook may be writing a backup.sessionStartfires 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-gitcommitted to git branch on session end.Relates to: #157