Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/core/hooks-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ function parseHooksConfig(filePath: string): Map<string, HookCommand[]> {
* Input is piped as JSON to stdin, output parsed from stdout.
*/
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.
const script = cmd.bash ?? cmd.powershell!;
const cwd = cmd.cwd ? path.resolve(baseDir, cmd.cwd) : baseDir;
const timeoutMs = (cmd.timeoutSec ?? 30) * 1000;
Expand Down
53 changes: 53 additions & 0 deletions src/core/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,9 @@ export class SessionManager {
private sessionSkillDirs = new Map<string, Set<string>>(); // channelId → skill dir paths
// Loaded session hooks per workspace (cached after first load)
private workspaceHooks = new Map<string, SessionHooks | undefined>();
// 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<string, string>(); // sessionId → workingDirectory
// Pending plan exit requests (one per channel)
private pendingPlanExit = new Map<string, PendingPlanExit>();
// Debounce timers for plan_changed events
Expand Down Expand Up @@ -653,13 +656,29 @@ 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 {
await this.bridge.destroySession(existingId);
} 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);
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 => {
Expand Down
111 changes: 111 additions & 0 deletions templates/agents/beads.agent.md
Original file line number Diff line number Diff line change
@@ -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 <id> --claim # Atomically claim a task (sets assignee + in_progress)
bd update <id> --status=done # Update status without closing
bd update <id> --notes="..." # Add notes inline
```

### Closing tasks

```bash
bd close <id> --reason="What was done"
bd close <id1> <id2> <id3> # Close multiple at once
```

### Viewing and searching

```bash
bd show <id> # 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-id> <parent-id> # 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 <id1> <id2> ... # 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
```
Loading