From ae2aeb59ab52152e37b23e1dd74e9b9c93f115d2 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Wed, 25 Mar 2026 23:41:50 -0400 Subject: [PATCH 1/4] feat(hooks): wire sessionStart and sessionEnd lifecycle hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: https://github.com/ChrisRomp/copilot-bridge/issues/157 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/core/session-manager.ts | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 232b2dc..f7b530d 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -653,6 +653,18 @@ export class SessionManager { // Clean up existing session const existingId = this.channelSessions.get(channelId); if (existingId) { + // 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}`); + } + } + const unsub = this.sessionUnsubscribes.get(existingId); if (unsub) { unsub(); this.sessionUnsubscribes.delete(existingId); } try { @@ -686,6 +698,19 @@ export class SessionManager { // in-memory state (including MCP connections), allowing a clean re-init. 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); + if (reloadHooks?.onSessionEnd) { + try { + await reloadHooks.onSessionEnd({ sessionId: existingId, channelId }, { sessionId: existingId }); + } catch (err: any) { + log.warn(`sessionEnd hook failed: ${err?.message ?? err}`); + } + } + try { await this.bridge.destroySession(existingId); } catch { /* best-effort */ } // Re-read global MCP servers so /reload picks up user-level config changes @@ -1650,6 +1675,13 @@ export class SessionManager { this.attachSessionEvents(session, channelId); + // Fire sessionStart hook (best-effort, non-blocking) + if (hooks?.onSessionStart) { + hooks.onSessionStart({ sessionId, channelId }, { sessionId }).catch((err: any) => { + log.warn(`sessionStart hook failed: ${err?.message ?? err}`); + }); + } + log.info(`Created session ${sessionId} for channel ${channelId} (model: ${usedModel})`); return sessionId; } @@ -1704,6 +1736,13 @@ export class SessionManager { this.sessionChannels.set(sessionId, channelId); this.attachSessionEvents(session, channelId); + // Fire sessionStart hook (best-effort, non-blocking) + if (hooks?.onSessionStart) { + hooks.onSessionStart({ sessionId, channelId }, { sessionId }).catch((err: any) => { + log.warn(`sessionStart hook failed: ${err?.message ?? err}`); + }); + } + // Cache context window tokens for /context display (best-effort, non-blocking) const resumeModel = prefs.model; this.bridge.listModels(getConfig().providers).then(models => { From 1e69de595bdebc4744bdebc048dc5b0b2e236010 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Wed, 25 Mar 2026 23:52:35 -0400 Subject: [PATCH 2/4] =?UTF-8?q?fix(hooks):=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20event=20ordering=20and=20stale=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues identified in code review of #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> --- src/core/session-manager.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index f7b530d..feda7b7 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -653,8 +653,10 @@ export class SessionManager { // Clean up existing session const existingId = this.channelSessions.get(channelId); if (existingId) { - // Fire sessionEnd hook before teardown (best-effort) + // 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); const hooks = this.wrapHooksWithAsk(rawHooks, channelId); if (hooks?.onSessionEnd) { @@ -694,13 +696,12 @@ export class SessionManager { return this.createNewSession(channelId); } - // Detach event listeners and disconnect so the CLI subprocess tears down - // in-memory state (including MCP connections), allowing a clean re-init. - const unsub = this.sessionUnsubscribes.get(existingId); - if (unsub) { unsub(); this.sessionUnsubscribes.delete(existingId); } - - // Fire sessionEnd hook before teardown (best-effort) + // Fire sessionEnd hook before detaching events — events must still be wired + // during the await so concurrent sendMessage() calls don't silently drop responses. + // /reload is intended to pick up workspace config changes, so invalidate the hooks + // cache first to ensure we read the latest hooks.json. const reloadWorkingDirectory = this.resolveWorkingDirectory(channelId); + this.workspaceHooks.delete(reloadWorkingDirectory); const reloadRawHooks = await this.resolveHooks(reloadWorkingDirectory); const reloadHooks = this.wrapHooksWithAsk(reloadRawHooks, channelId); if (reloadHooks?.onSessionEnd) { @@ -711,6 +712,11 @@ export class SessionManager { } } + // Detach event listeners and disconnect so the CLI subprocess tears down + // in-memory state (including MCP connections), allowing a clean re-init. + const unsub = this.sessionUnsubscribes.get(existingId); + if (unsub) { unsub(); this.sessionUnsubscribes.delete(existingId); } + try { await this.bridge.destroySession(existingId); } catch { /* best-effort */ } // Re-read global MCP servers so /reload picks up user-level config changes From 85ed66ce2833b1097ccb5fe14ba9f311730cfbbc Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Thu, 26 Mar 2026 00:25:02 -0400 Subject: [PATCH 3/4] fix(hooks): use absolute path /bin/bash to avoid ENOENT on spawn 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> --- src/core/hooks-loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/hooks-loader.ts b/src/core/hooks-loader.ts index fdcfc82..42cf443 100644 --- a/src/core/hooks-loader.ts +++ b/src/core/hooks-loader.ts @@ -164,7 +164,7 @@ function parseHooksConfig(filePath: string): Map { * Input is piped as JSON to stdin, output parsed from stdout. */ async function executeHookCommand(cmd: HookCommand, input: any, baseDir: string): Promise { - const shell = cmd.bash ? 'bash' : 'powershell'; + const shell = cmd.bash ? (process.platform === 'win32' ? 'bash' : '/bin/bash') : 'powershell'; const script = cmd.bash ?? cmd.powershell!; const cwd = cmd.cwd ? path.resolve(baseDir, cmd.cwd) : baseDir; const timeoutMs = (cmd.timeoutSec ?? 30) * 1000; From 8c29c9e1b38bbd53f1c16f83156307b0b6ce546d Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Thu, 26 Mar 2026 01:15:03 -0400 Subject: [PATCH 4/4] fix(hooks): track session working directory at creation time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- src/core/session-manager.ts | 20 ++++-- templates/agents/beads.agent.md | 111 ++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 templates/agents/beads.agent.md diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index feda7b7..a6296ca 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -424,6 +424,9 @@ export class SessionManager { private sessionSkillDirs = new Map>(); // channelId → skill dir paths // Loaded session hooks per workspace (cached after first load) private workspaceHooks = new Map(); + // Working directory captured at session create/resume time (sessionId → workingDirectory) + // Used to fire sessionEnd hooks against the correct workspace even if channel config changes. + private sessionWorkingDirectories = new Map(); // sessionId → workingDirectory // Pending plan exit requests (one per channel) private pendingPlanExit = new Map(); // Debounce timers for plan_changed events @@ -653,9 +656,10 @@ export class SessionManager { // Clean up existing session const existingId = this.channelSessions.get(channelId); if (existingId) { - // 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); + // Use the working directory captured at session creation time, not the current channel config. + // Channel config may have changed since the session started — sessionEnd should fire against + // the workspace the session was actually running in. + const workingDirectory = this.sessionWorkingDirectories.get(existingId) ?? this.resolveWorkingDirectory(channelId); this.workspaceHooks.delete(workingDirectory); const rawHooks = await this.resolveHooks(workingDirectory); const hooks = this.wrapHooksWithAsk(rawHooks, channelId); @@ -674,6 +678,7 @@ export class SessionManager { } catch { /* best-effort */ } this.channelSessions.delete(channelId); this.sessionChannels.delete(existingId); + this.sessionWorkingDirectories.delete(existingId); this.contextUsage.delete(channelId); this.contextWindowTokens.delete(channelId); this.lastMessageUserIds.delete(channelId); @@ -698,9 +703,9 @@ export class SessionManager { // Fire sessionEnd hook before detaching events — events must still be wired // during the await so concurrent sendMessage() calls don't silently drop responses. - // /reload is intended to pick up workspace config changes, so invalidate the hooks - // cache first to ensure we read the latest hooks.json. - const reloadWorkingDirectory = this.resolveWorkingDirectory(channelId); + // Use the working directory captured at session creation time — channel config may + // have changed since then. Invalidate cache so we read the latest hooks.json. + const reloadWorkingDirectory = this.sessionWorkingDirectories.get(existingId) ?? this.resolveWorkingDirectory(channelId); this.workspaceHooks.delete(reloadWorkingDirectory); const reloadRawHooks = await this.resolveHooks(reloadWorkingDirectory); const reloadHooks = this.wrapHooksWithAsk(reloadRawHooks, channelId); @@ -718,6 +723,7 @@ export class SessionManager { if (unsub) { unsub(); this.sessionUnsubscribes.delete(existingId); } try { await this.bridge.destroySession(existingId); } catch { /* best-effort */ } + this.sessionWorkingDirectories.delete(existingId); // Re-read global MCP servers so /reload picks up user-level config changes this.mcpServers = loadMcpServers(); @@ -1677,6 +1683,7 @@ export class SessionManager { const sessionId = session.sessionId; this.channelSessions.set(channelId, sessionId); this.sessionChannels.set(sessionId, channelId); + this.sessionWorkingDirectories.set(sessionId, workingDirectory); // capture at create time setChannelSession(channelId, sessionId); this.attachSessionEvents(session, channelId); @@ -1740,6 +1747,7 @@ export class SessionManager { this.sessionSkillDirs.set(channelId, new Set(skillDirectories)); this.channelSessions.set(channelId, sessionId); this.sessionChannels.set(sessionId, channelId); + this.sessionWorkingDirectories.set(sessionId, workingDirectory); // capture at resume time this.attachSessionEvents(session, channelId); // Fire sessionStart hook (best-effort, non-blocking) diff --git a/templates/agents/beads.agent.md b/templates/agents/beads.agent.md new file mode 100644 index 0000000..b8dbcf2 --- /dev/null +++ b/templates/agents/beads.agent.md @@ -0,0 +1,111 @@ +--- +name: Beads Task Memory +description: Persistent task tracking for this workspace using bd (Beads). Use this skill to create, track, and close tasks across sessions. +--- + +# Beads Task Memory Skill + +This workspace uses [Beads](https://github.com/steveyegge/beads) (`bd`) for persistent, structured task memory backed by [Dolt](https://github.com/dolthub/dolt). Tasks survive session restarts and can be shared across multiple bots via Dolt sync. + +## Prerequisites + +- `bd` CLI installed: `npm install -g @beads/bd` or `brew install beads` +- `bd init --quiet --stealth` run in the workspace directory +- `BEADS_DIR` and `BEADS_ACTOR` set in workspace `.env` (auto-injected by copilot-bridge) + +## Session Start Workflow + +Run at the beginning of every session to recover context: + +```bash +bd prime # Print workflow context and pending work +bd ready --json # List tasks with no open blockers (what to work on next) +``` + +## Task Operations + +### Creating tasks + +```bash +bd create --title="Short descriptive title" --description="Why this exists and what done looks like" --type=task --priority=2 +``` + +- Types: `task`, `feature`, `bug`, `epic`, `chore`, `decision` +- Priority: `0` (critical) → `4` (backlog). Use numbers, not words. +- For descriptions with special chars, pipe via stdin: `echo "description" | bd create --title="Title" --stdin` +- **NEVER** use `bd edit` — it opens `$EDITOR` and blocks the agent. + +### Claiming and progressing work + +```bash +bd update --claim # Atomically claim a task (sets assignee + in_progress) +bd update --status=done # Update status without closing +bd update --notes="..." # Add notes inline +``` + +### Closing tasks + +```bash +bd close --reason="What was done" +bd close # Close multiple at once +``` + +### Viewing and searching + +```bash +bd show # Full task details + audit trail +bd list # All open issues +bd list --status=in_progress # Active work +bd search "keyword" # Full-text search +bd stats # Project health summary +``` + +### Dependencies + +```bash +bd dep add # child depends on parent (parent blocks child) +bd blocked # Show all blocked issues +``` + +### Persistent knowledge + +```bash +bd remember "key insight or decision" # Store cross-session knowledge +bd memories "keyword" # Search stored knowledge +``` + +**Call `bd remember` at the moment of discovery — not at the end of the session.** Dolt commits immediately on every write, so memories survive any shutdown. Batching to the end risks losing them if the session is interrupted. + +Trigger `bd remember` whenever: +- A non-obvious decision is made (e.g. "use X not Y because Z") +- A gotcha, failure mode, or workaround is found +- A config value or path turns out to be critical or surprising +- Any fact that would take >5 minutes to re-discover next session + +Format: a concise, self-contained sentence — include the *why*, not just the *what*. + +## Session End Workflow + +Close completed tasks and back up before ending a session: + +```bash +bd close ... # Close all completed work +bd backup export-git # Snapshot to git branch (zero-infrastructure backup) +``` + +## When to Use Beads + +- Any task that spans multiple tool calls or may be interrupted mid-session +- Multi-step work with subtasks (use epics + child tasks) +- Decisions that should be recorded for future sessions +- Anything you'd otherwise write to `MEMORY.md` + +When Beads is available, prefer `bd remember` over `MEMORY.md` for persistent knowledge — Beads is queryable, versioned, and concurrency-safe. + +## Troubleshooting + +```bash +bd doctor # Check Dolt server health +bd dolt start # Manually start Dolt server if needed +bd dolt status # Check server status +```