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; diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 232b2dc..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,6 +656,21 @@ export class SessionManager { // Clean up existing session const existingId = this.channelSessions.get(channelId); if (existingId) { + // 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); + 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 { @@ -660,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); @@ -682,11 +701,29 @@ export class SessionManager { return this.createNewSession(channelId); } + // Fire sessionEnd hook before detaching events — events must still be wired + // during the await so concurrent sendMessage() calls don't silently drop responses. + // 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); + if (reloadHooks?.onSessionEnd) { + try { + await reloadHooks.onSessionEnd({ sessionId: existingId, channelId }, { sessionId: existingId }); + } catch (err: any) { + log.warn(`sessionEnd hook failed: ${err?.message ?? err}`); + } + } + // 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 */ } + this.sessionWorkingDirectories.delete(existingId); // Re-read global MCP servers so /reload picks up user-level config changes this.mcpServers = loadMcpServers(); @@ -1646,10 +1683,18 @@ 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); + // 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; } @@ -1702,8 +1747,16 @@ 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) + 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 => { 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 +```