Skip to content

Instantly share code, notes, and snippets.

@johnlindquist
Last active January 13, 2026 17:22
Show Gist options
  • Select an option

  • Save johnlindquist/9b06a09125b03c59de53a397bbdf45dd to your computer and use it in GitHub Desktop.

Select an option

Save johnlindquist/9b06a09125b03c59de53a397bbdf45dd to your computer and use it in GitHub Desktop.
WezTerm + Claude Code: Multi-Session Workflows with BSP Layouts
#!/bin/bash
# BSP Split: Find largest pane IN CURRENT TAB and bisect it
# Get current pane from environment (set by WezTerm for shells running in panes)
if [ -n "$WEZTERM_PANE" ]; then
CURRENT_PANE="$WEZTERM_PANE"
else
# Fallback: use the pane passed as argument
CURRENT_PANE="$1"
fi
if [ -z "$CURRENT_PANE" ]; then
echo "Error: No WEZTERM_PANE env var and no pane ID argument" >&2
exit 1
fi
# Get tab ID for current pane
CURRENT_TAB=$(wezterm cli list --format json | jq -r --arg pane "$CURRENT_PANE" '
.[] | select(.pane_id == ($pane | tonumber)) | .tab_id
')
if [ -z "$CURRENT_TAB" ] || [ "$CURRENT_TAB" = "null" ]; then
echo "Error: Could not find tab for pane $CURRENT_PANE" >&2
exit 1
fi
# Get largest pane in current tab only
LARGEST=$(wezterm cli list --format json | jq -r --arg tab "$CURRENT_TAB" '
map(select(.tab_id == ($tab | tonumber) and .is_zoomed == false)) |
sort_by(-(.size.rows * .size.cols)) |
.[0] |
"\(.pane_id) \(.size.cols) \(.size.rows)"
')
PANE_ID=$(echo "$LARGEST" | awk '{print $1}')
COLS=$(echo "$LARGEST" | awk '{print $2}')
ROWS=$(echo "$LARGEST" | awk '{print $3}')
RATIO=$(echo "$COLS $ROWS" | awk '{printf "%.2f", $1/$2}')
if (( $(echo "$RATIO > 2.0" | bc -l) )); then
wezterm cli split-pane --pane-id "$PANE_ID" --right
else
wezterm cli split-pane --pane-id "$PANE_ID" --bottom
fi
name description allowed-tools
session
Start a new Claude session in a WezTerm pane with an initial prompt. Use when user says "start a session", "new claude session", "spawn claude with prompt", "power session" (bypasses permissions), "start a swarm", "swarm session", "coordinated sessions", or wants to run claude with a starting task in a new pane. Swarm mode enables multiple sessions to coordinate via shared state files.
Bash(wezterm:*), Bash(mkdir:*), Bash(cat:*), Bash(printf:*), Bash(date:*), Bash(basename:*), Bash(jq:*)

Dynamic Context

  • Current directory name: !basename "$PWD"
  • Timestamp: !date +%Y%m%d-%H%M%S
  • WEZTERM_PANE: !echo $WEZTERM_PANE

Claude Session in New Pane

Spawn a new Claude Code session in a WezTerm pane with context from the current conversation.

When to Use

  • User wants to delegate a subtask to another Claude instance
  • Current task has a parallelizable component
  • User wants to explore a tangent without losing current context
  • Breaking a complex task into concurrent sessions

Power Session Mode

If user says "power session", add --dangerously-skip-permissions flag:

printf 'claude --dangerously-skip-permissions "$(cat /tmp/claude-session-prompt.md)"\n' | wezterm cli send-text --pane-id "$NEW_PANE"

This bypasses all permission checks. Only use in trusted directories.

Prompt File Naming

Use the dynamic context values above to create prompt files:

# Pattern: /tmp/claude-sessions/<cwd>-<timestamp>-<purpose>.md
mkdir -p /tmp/claude-sessions
PROMPT_FILE="/tmp/claude-sessions/${CWD_NAME}-${TIMESTAMP}-${PURPOSE}.md"

Where CWD_NAME and TIMESTAMP come from the Dynamic Context section above, and PURPOSE is a descriptive slug for the task.

Setup: Create BSP Split Script

Run once to create the BSP split helper:

cat > /tmp/bsp-split.sh << 'SCRIPT'
#!/bin/bash
# BSP Split: Find largest pane IN CURRENT TAB and bisect it

# Get current pane from WEZTERM_PANE env var (set by WezTerm for shells)
if [ -z "$WEZTERM_PANE" ]; then
  echo "Error: WEZTERM_PANE not set" >&2
  exit 1
fi

# Get tab ID for current pane
CURRENT_TAB=$(wezterm cli list --format json | jq -r --arg pane "$WEZTERM_PANE" '
  .[] | select(.pane_id == ($pane | tonumber)) | .tab_id
')

# Get largest pane in current tab only
LARGEST=$(wezterm cli list --format json | jq -r --arg tab "$CURRENT_TAB" '
  map(select(.tab_id == ($tab | tonumber) and .is_zoomed == false)) |
  sort_by(-(.size.rows * .size.cols)) |
  .[0] |
  "\(.pane_id) \(.size.cols) \(.size.rows)"
')

PANE_ID=$(echo "$LARGEST" | awk '{print $1}')
COLS=$(echo "$LARGEST" | awk '{print $2}')
ROWS=$(echo "$LARGEST" | awk '{print $3}')

RATIO=$(echo "$COLS $ROWS" | awk '{printf "%.2f", $1/$2}')
if (( $(echo "$RATIO > 2.0" | bc -l) )); then
  wezterm cli split-pane --pane-id "$PANE_ID" --right
else
  wezterm cli split-pane --pane-id "$PANE_ID" --bottom
fi
SCRIPT
chmod +x /tmp/bsp-split.sh

Single Session

# 1. Setup prompt file with structured name
mkdir -p /tmp/claude-sessions
CWD_NAME=$(basename "$PWD")
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
PURPOSE="task-name"
PROMPT_FILE="/tmp/claude-sessions/${CWD_NAME}-${TIMESTAMP}-${PURPOSE}.md"

# 2. Write prompt
cat > "$PROMPT_FILE" << 'EOF'
## Context
[Summary of relevant conversation context]

## Task
[What this new session should accomplish]
EOF

# 3. BSP split and run claude
NEW_PANE=$(/tmp/bsp-split.sh)
printf 'claude "$(cat %s)"\n' "$PROMPT_FILE" | wezterm cli send-text --pane-id "$NEW_PANE"

Multiple Parallel Sessions

# Setup
mkdir -p /tmp/claude-sessions
CWD_NAME=$(basename "$PWD")
TIMESTAMP=$(date +%Y%m%d-%H%M%S)

# Create prompt files with structured names
TASKS=("write-tests" "fix-auth" "add-logging" "refactor-utils")
for i in "${!TASKS[@]}"; do
  PROMPT_FILE="/tmp/claude-sessions/${CWD_NAME}-${TIMESTAMP}-${TASKS[$i]}.md"
  cat > "$PROMPT_FILE" << EOF
## Task
${TASKS[$i]}
EOF
  PROMPT_FILES+=("$PROMPT_FILE")
done

# Spawn all with BSP layout
for PROMPT_FILE in "${PROMPT_FILES[@]}"; do
  NEW_PANE=$(/tmp/bsp-split.sh)
  printf 'claude "$(cat %s)"\n' "$PROMPT_FILE" | wezterm cli send-text --pane-id "$NEW_PANE"
done

Simple Split (One Session)

For just one additional session without BSP:

mkdir -p /tmp/claude-sessions
PROMPT_FILE="/tmp/claude-sessions/$(basename $PWD)-$(date +%Y%m%d-%H%M%S)-quick.md"

cat > "$PROMPT_FILE" << 'EOF'
## Task
[Task here]
EOF

PANE_ID=$(wezterm cli split-pane)
printf 'claude "$(cat %s)"\n' "$PROMPT_FILE" | wezterm cli send-text --pane-id "$PANE_ID"

Important: Newline Syntax

Use printf ... | wezterm cli send-text for newlines.

# CORRECT - printf pipe
printf 'command\n' | wezterm cli send-text --pane-id "$PANE_ID"

# WRONG - literal \n gets sent
wezterm cli send-text --pane-id "$PANE_ID" "command\n"

Swarm Mode (Multi-Session Coordination)

When spawning multiple sessions that need to coordinate, use swarm mode. Sessions can track each other's progress via shared state files.

Trigger Words

  • "start a swarm", "swarm session", "coordinated sessions"
  • Or when spawning 2+ related sessions

Enable Swarm Hooks (One-Time Setup)

Add to your project's .claude/settings.json or globally to ~/.claude/settings.json:

{
  "hooks": {
    "SessionStart": ["bun run $HOME/.claude/plugins/claude-swarm/scripts/SessionStart.ts"],
    "UserPromptSubmit": ["bun run $HOME/.claude/plugins/claude-swarm/scripts/UserPromptSubmit.ts"],
    "Stop": ["bun run $HOME/.claude/plugins/claude-swarm/scripts/Stop.ts"]
  }
}

This enables automatic:

  • Child registration in manifest on session start
  • Progress tracking and sibling status injection
  • Completion status updates on session end

Environment Variables (passed to children)

  • CLAUDE_SWARM_ID - Unique swarm identifier
  • CLAUDE_SWARM_DIR - Path to shared state directory
  • CLAUDE_PARENT_SESSION - Parent's session ID (use $CLAUDE_SESSION_ID)
  • CLAUDE_SESSION_ROLE - "parent" or "child"
  • CLAUDE_SESSION_PURPOSE - Task description for this session

Swarm Directory Structure

./sessions/<swarm-id>/
├── manifest.json       # All sessions in this swarm
├── parent-<id>.md      # Parent session status
├── child-<id>-1.md     # Child session 1 status
└── child-<id>-2.md     # Child session 2 status

Initialize Swarm (Parent Does This Once)

# Generate swarm ID
SWARM_ID="$(basename $PWD)-$(date +%Y%m%d-%H%M%S)"
SWARM_DIR="./sessions/$SWARM_ID"
mkdir -p "$SWARM_DIR"

# Create manifest
cat > "$SWARM_DIR/manifest.json" << EOF
{
  "swarm_id": "$SWARM_ID",
  "created_at": "$(date -Iseconds)",
  "swarm_dir": "$SWARM_DIR",
  "parent": {
    "session_id": "${CLAUDE_SESSION_ID:-parent}",
    "purpose": "Coordinate tasks",
    "status": "running"
  },
  "children": []
}
EOF

echo "Swarm initialized: $SWARM_ID"

Spawn Child with Swarm Context

# Create prompt file
PURPOSE="write-tests"
PROMPT_FILE="/tmp/claude-sessions/${CWD_NAME}-${TIMESTAMP}-${PURPOSE}.md"
cat > "$PROMPT_FILE" << 'EOF'
## Context
You are part of a coordinated swarm session.

## Your Task
Write unit tests for the auth module.

## Coordination
- Update your progress in: $CLAUDE_SWARM_DIR/child-$CLAUDE_SESSION_ID.md
- Check sibling progress: ls $CLAUDE_SWARM_DIR/*.md
EOF

# Spawn with environment variables
NEW_PANE=$(/tmp/bsp-split.sh)
printf 'CLAUDE_SWARM_ID=%s CLAUDE_SWARM_DIR=%s CLAUDE_SESSION_ROLE=child CLAUDE_SESSION_PURPOSE="%s" CLAUDE_PARENT_SESSION=%s claude "$(cat %s)"\n' \
  "$SWARM_ID" "$SWARM_DIR" "$PURPOSE" "${CLAUDE_SESSION_ID:-parent}" "$PROMPT_FILE" \
  | wezterm cli send-text --pane-id "$NEW_PANE"

Multiple Swarm Children (Parallel Tasks)

# Setup swarm
SWARM_ID="$(basename $PWD)-$(date +%Y%m%d-%H%M%S)"
SWARM_DIR="./sessions/$SWARM_ID"
mkdir -p "$SWARM_DIR"
CWD_NAME=$(basename "$PWD")
TIMESTAMP=$(date +%Y%m%d-%H%M%S)

# Initialize manifest
cat > "$SWARM_DIR/manifest.json" << EOF
{
  "swarm_id": "$SWARM_ID",
  "created_at": "$(date -Iseconds)",
  "swarm_dir": "$SWARM_DIR",
  "parent": {
    "session_id": "${CLAUDE_SESSION_ID:-parent}",
    "purpose": "Coordinate parallel tasks",
    "status": "running"
  },
  "children": []
}
EOF

# Define tasks
TASKS=("write-tests" "fix-auth" "add-logging")

# Spawn each child
for PURPOSE in "${TASKS[@]}"; do
  PROMPT_FILE="/tmp/claude-sessions/${CWD_NAME}-${TIMESTAMP}-${PURPOSE}.md"
  cat > "$PROMPT_FILE" << EOF
## Swarm Context
- Swarm ID: $SWARM_ID
- Your Role: child
- Your Purpose: $PURPOSE
- Siblings: ${TASKS[*]}

## Instructions
1. Focus on YOUR assigned task: $PURPOSE
2. Update your progress file: \$CLAUDE_SWARM_DIR/child-\$CLAUDE_SESSION_ID.md
3. Check sibling status: cat \$CLAUDE_SWARM_DIR/*.md
4. When complete, update status to "completed"

## Task
$PURPOSE
EOF

  NEW_PANE=$(/tmp/bsp-split.sh)
  printf 'CLAUDE_SWARM_ID=%s CLAUDE_SWARM_DIR=%s CLAUDE_SESSION_ROLE=child CLAUDE_SESSION_PURPOSE="%s" CLAUDE_PARENT_SESSION=%s claude "$(cat %s)"\n' \
    "$SWARM_ID" "$SWARM_DIR" "$PURPOSE" "${CLAUDE_SESSION_ID:-parent}" "$PROMPT_FILE" \
    | wezterm cli send-text --pane-id "$NEW_PANE"
done

Reading Sibling Progress (Any Session Can Do This)

# List all session status files
ls $CLAUDE_SWARM_DIR/*.md

# Read all sibling progress
for f in $CLAUDE_SWARM_DIR/*.md; do
  echo "=== $(basename $f) ==="
  cat "$f"
  echo
done

# Check manifest for session list
cat $CLAUDE_SWARM_DIR/manifest.json | jq .

Update Your Own Progress (Child Sessions)

# Add completed item
cat >> $CLAUDE_SWARM_DIR/child-$CLAUDE_SESSION_ID.md << EOF
- [x] Completed: <task description>
EOF

# Update status to completed
# (The hooks system does this automatically on Stop)

Notes

  • BSP = Binary Space Partitioning (always splits largest pane)
  • Ratio > 2.0 = split right, else split bottom
  • Use << 'EOF' (quoted) to prevent variable expansion in prompts
  • Swarm mode uses ./sessions/ directory in the project root
  • Environment variables are passed inline before the claude command
name description
wezterm-pane
Create a new pane in WezTerm and run commands. Use when user says "create a new pane", "open a pane", "split terminal", "run X in a new pane", or "open X in wezterm".

WezTerm Pane Management

Create panes and run commands with full shell environment (PATH preserved).

Create a New Pane

wezterm cli split-pane

Run a Command in New Pane

Always use two steps to preserve user's PATH:

PANE_ID=$(wezterm cli split-pane)
printf 'your-command\n' | wezterm cli send-text --pane-id "$PANE_ID"

IMPORTANT:

  • Use printf 'cmd\n' | wezterm cli send-text for the newline
  • Never use wezterm cli split-pane -- command (bypasses shell, loses PATH)

Split Direction

wezterm cli split-pane --right      # Horizontal split
wezterm cli split-pane --bottom     # Vertical split (default)
wezterm cli split-pane --left
wezterm cli split-pane --top

With Working Directory

PANE_ID=$(wezterm cli split-pane --cwd /path/to/dir)
printf 'command\n' | wezterm cli send-text --pane-id "$PANE_ID"

Examples

# Open claude in new pane
PANE_ID=$(wezterm cli split-pane)
printf 'claude\n' | wezterm cli send-text --pane-id "$PANE_ID"

# Run dev server to the right
PANE_ID=$(wezterm cli split-pane --right)
printf 'npm run dev\n' | wezterm cli send-text --pane-id "$PANE_ID"

# Open nvim in project directory
PANE_ID=$(wezterm cli split-pane --cwd ~/projects/app)
printf 'nvim .\n' | wezterm cli send-text --pane-id "$PANE_ID"

Multi-Session Swarm Coordination

Enable spawned Claude sessions to coordinate via shared state files.

Overview

When spawning multiple Claude sessions to work on related tasks, swarm mode allows them to:

  • Track each other's progress
  • Avoid duplicating work
  • Know when dependencies are complete
  • Report completion status

Key Concepts

Swarm ID

A unique identifier grouping related sessions (parent + all children). Format: <cwd>-<timestamp> (e.g., myproject-20260113-160000)

Session Roles

  • Parent: The original session that spawns others
  • Child: Spawned sessions that report back to parent

Environment Variables

Pass to child sessions via inline assignment:

  • CLAUDE_SWARM_ID - The swarm identifier
  • CLAUDE_PARENT_SESSION - Parent's session ID
  • CLAUDE_SWARM_DIR - Path to shared state directory
  • CLAUDE_SESSION_ROLE - "parent" or "child"
  • CLAUDE_SESSION_PURPOSE - Task description for this session

Directory Structure

./sessions/<swarm-id>/
├── manifest.json       # All sessions in this swarm
├── parent-<id>.md      # Parent session status
├── child-<id>-1.md     # Child session 1 status
└── child-<id>-2.md     # Child session 2 status

Usage

Initialize Swarm (Parent)

SWARM_ID="$(basename $PWD)-$(date +%Y%m%d-%H%M%S)"
SWARM_DIR="./sessions/$SWARM_ID"
mkdir -p "$SWARM_DIR"

cat > "$SWARM_DIR/manifest.json" << MANIFEST
{
  "swarm_id": "$SWARM_ID",
  "created_at": "$(date -Iseconds)",
  "swarm_dir": "$SWARM_DIR",
  "parent": {
    "session_id": "${CLAUDE_SESSION_ID:-parent}",
    "purpose": "Coordinate tasks",
    "status": "running"
  },
  "children": []
}
MANIFEST

Spawn Child with Swarm Context

PURPOSE="write-tests"
PROMPT_FILE="/tmp/claude-sessions/prompt-$PURPOSE.md"
cat > "$PROMPT_FILE" << 'EOF'
## Context
You are part of a coordinated swarm session.

## Your Task
Write unit tests for the auth module.
EOF

NEW_PANE=$(/tmp/bsp-split.sh)
printf 'CLAUDE_SWARM_ID=%s CLAUDE_SWARM_DIR=%s CLAUDE_SESSION_ROLE=child CLAUDE_SESSION_PURPOSE="%s" claude "$(cat %s)"\n' \
  "$SWARM_ID" "$SWARM_DIR" "$PURPOSE" "$PROMPT_FILE" \
  | wezterm cli send-text --pane-id "$NEW_PANE"

Multiple Parallel Children

TASKS=("write-tests" "fix-auth" "add-logging")

for PURPOSE in "${TASKS[@]}"; do
  # Create task-specific prompt
  PROMPT_FILE="/tmp/claude-sessions/${PURPOSE}.md"
  cat > "$PROMPT_FILE" << PROMPT
## Swarm Context
- Swarm ID: $SWARM_ID
- Your Purpose: $PURPOSE
- Siblings: ${TASKS[*]}

## Task
$PURPOSE
PROMPT

  # Spawn with BSP layout
  NEW_PANE=$(/tmp/bsp-split.sh)
  printf 'CLAUDE_SWARM_ID=%s CLAUDE_SWARM_DIR=%s CLAUDE_SESSION_ROLE=child CLAUDE_SESSION_PURPOSE="%s" claude "$(cat %s)"\n' \
    "$SWARM_ID" "$SWARM_DIR" "$PURPOSE" "$PROMPT_FILE" \
    | wezterm cli send-text --pane-id "$NEW_PANE"
done

Reading Sibling Progress

# List all session files
ls $CLAUDE_SWARM_DIR/*.md

# Read all progress
for f in $CLAUDE_SWARM_DIR/*.md; do
  echo "=== $(basename $f) ==="
  cat "$f"
done

# Check manifest
cat $CLAUDE_SWARM_DIR/manifest.json | jq .

Updating Progress (Children)

# Add completed item
cat >> $CLAUDE_SWARM_DIR/child-$CLAUDE_SESSION_ID.md << EOF
- [x] Completed: <task description>
EOF

Session Status File Format

# Session: child-abc123
Purpose: Write unit tests
Status: running
Started: 2026-01-13T16:00:05Z

## Progress
- [x] Found existing test files
- [x] Analyzed test patterns
- [ ] Writing tests for auth module

## Current Task
Writing unit tests for src/auth/login.ts

## Last Updated
2026-01-13T16:02:30Z

manifest.json Format

{
  "swarm_id": "myproject-20260113-160000",
  "created_at": "2026-01-13T16:00:00Z",
  "swarm_dir": "./sessions/myproject-20260113-160000",
  "parent": {
    "session_id": "abc123",
    "purpose": "Coordinate refactoring tasks",
    "status": "running"
  },
  "children": [
    {
      "session_id": "def456",
      "purpose": "Write unit tests",
      "status": "running",
      "pane_id": 123
    }
  ]
}

Hook Integration (Optional)

For automatic progress tracking, you can use Claude Code hooks. See claude-session-hooks-template for a complete implementation with:

  • SessionStart: Auto-registers child in manifest
  • UserPromptSubmit: Updates progress, injects sibling status
  • Stop: Marks session complete, shows summary

Tips

  1. Use meaningful PURPOSE names - They become file names and help identify sessions
  2. Keep prompts focused - Each child should have a clear, single task
  3. Check siblings before completing - Avoid duplicate work
  4. Use BSP layout - Handles any number of panes gracefully

Claude Swarm Plugin

Multi-session coordination for Claude Code swarms. Enables spawned sessions to track each other's progress via shared state files.

Installation

The plugin is installed at ~/.claude/plugins/claude-swarm/.

To enable swarm hooks for a project, add to your project's .claude/settings.json:

{
  "hooks": {
    "SessionStart": ["bun run $HOME/.claude/plugins/claude-swarm/scripts/SessionStart.ts"],
    "UserPromptSubmit": ["bun run $HOME/.claude/plugins/claude-swarm/scripts/UserPromptSubmit.ts"],
    "Stop": ["bun run $HOME/.claude/plugins/claude-swarm/scripts/Stop.ts"]
  }
}

Or add globally to ~/.claude/settings.json.

Usage

1. Spawn child sessions with environment variables

When spawning a child Claude session, pass these environment variables:

CLAUDE_SWARM_ID="myproject-20260113-123456" \
CLAUDE_SWARM_DIR="./sessions/myproject-20260113-123456" \
CLAUDE_SESSION_ROLE="child" \
CLAUDE_SESSION_PURPOSE="Write unit tests" \
CLAUDE_PARENT_SESSION="parent-session-id" \
claude "Your task prompt"

2. The hooks automatically:

  • SessionStart: Registers child in manifest, creates status file, injects swarm context
  • UserPromptSubmit: Updates status, periodically shows sibling progress
  • Stop: Marks session completed, shows swarm summary

3. Sessions can read each other's progress

# List all session files
ls $CLAUDE_SWARM_DIR/*.md

# Read manifest
cat $CLAUDE_SWARM_DIR/manifest.json

Directory Structure

./sessions/<swarm-id>/
├── manifest.json       # All sessions in this swarm
├── parent-<id>.md      # Parent session status
├── child-<id>-1.md     # Child session 1 status
└── child-<id>-2.md     # Child session 2 status

Environment Variables

Variable Description
CLAUDE_SWARM_ID Unique swarm identifier
CLAUDE_SWARM_DIR Path to shared state directory
CLAUDE_SESSION_ROLE "parent" or "child"
CLAUDE_SESSION_PURPOSE Task description for this session
CLAUDE_PARENT_SESSION Parent's session ID

Testing

# Test SessionStart hook
echo '{"session_id": "test", "cwd": "/tmp", "source": "new"}' | \
  CLAUDE_SWARM_ID=test CLAUDE_SWARM_DIR=/tmp/test-swarm CLAUDE_SESSION_ROLE=child \
  bun run ~/.claude/plugins/claude-swarm/scripts/SessionStart.ts

Files

  • src/swarm.ts - Core coordination utilities
  • scripts/SessionStart.ts - Session registration hook
  • scripts/UserPromptSubmit.ts - Progress tracking hook
  • scripts/Stop.ts - Completion tracking hook
  • hooks/hooks.json - Hook registration (for plugin system)
#!/usr/bin/env bun
/**
* SessionStart hook for swarm coordination
*
* If this session is part of a swarm (CLAUDE_SWARM_ID set):
* - Register in the swarm manifest
* - Create session status file
* - Inject swarm context into conversation
*/
import { getSwarmContext, isSwarmSession, registerChild, formatSiblingProgress, readManifest } from "../src/swarm";
interface SessionStartInput {
session_id: string;
cwd: string;
source: "new" | "resume" | "api";
}
async function main() {
// Read input from stdin
const input: SessionStartInput = await Bun.stdin.json();
const { session_id, cwd, source } = input;
let additionalContext = "";
// Check if this is a swarm session
if (isSwarmSession()) {
const ctx = getSwarmContext();
try {
// Register this child session
if (ctx.sessionRole === "child" && ctx.swarmDir) {
registerChild(
ctx.swarmDir,
session_id,
ctx.sessionPurpose || "Unspecified task",
parseInt(process.env.WEZTERM_PANE || "0")
);
// Get sibling progress
const siblingProgress = formatSiblingProgress(ctx.swarmDir);
additionalContext = `
<swarm-context>
## You are part of a coordinated swarm session
**Swarm ID**: ${ctx.swarmId}
**Your Role**: ${ctx.sessionRole}
**Your Purpose**: ${ctx.sessionPurpose}
**Parent Session**: ${ctx.parentSession}
**Swarm Directory**: ${ctx.swarmDir}
### Instructions for Swarm Sessions
1. Focus on YOUR assigned task: ${ctx.sessionPurpose}
2. Update your progress by writing to: ${ctx.swarmDir}/child-${session_id}.md
3. Check sibling progress in: ${ctx.swarmDir}/*.md
4. When complete, update your status to "completed"
5. If blocked, update status to "blocked" with reason
${siblingProgress}
</swarm-context>
`;
}
} catch (error) {
console.error("Swarm registration error:", error);
}
}
// Output response
const response = {
continue: true,
hookSpecificOutput: {
additionalContext: additionalContext || undefined,
},
};
console.log(JSON.stringify(response));
}
main().catch((error) => {
console.error("SessionStart hook error:", error);
console.log(JSON.stringify({ continue: true }));
process.exit(1);
});
#!/usr/bin/env bun
/**
* Stop hook for swarm coordination
*
* When a session attempts to stop:
* 1. Update status to "completed"
* 2. Optionally check if all siblings are done
*/
import {
getSwarmContext,
isSwarmSession,
updateSessionStatus,
getSiblingStatuses,
} from "../src/swarm";
interface StopInput {
session_id: string;
cwd: string;
}
async function main() {
const input: StopInput = await Bun.stdin.json();
const { session_id } = input;
let additionalContext = "";
let shouldContinue = true; // Allow stop by default
if (isSwarmSession()) {
const ctx = getSwarmContext();
if (ctx.swarmDir && ctx.sessionRole) {
try {
// Mark session as completed
updateSessionStatus(ctx.swarmDir, session_id, ctx.sessionRole, {
status: "completed",
currentTask: "Session completed",
});
// Check sibling statuses
const siblings = getSiblingStatuses(ctx.swarmDir);
const running = siblings.filter((s) => s.status === "running");
const completed = siblings.filter((s) => s.status === "completed");
const blocked = siblings.filter((s) => s.status === "blocked");
additionalContext = `
<swarm-completion>
## Swarm Status Summary
- **Completed**: ${completed.length} sessions
- **Running**: ${running.length} sessions
- **Blocked**: ${blocked.length} sessions
${running.length > 0 ? `**Note**: ${running.length} sibling(s) still running.` : ""}
${blocked.length > 0 ? `**Warning**: ${blocked.length} sibling(s) blocked!` : ""}
Your status has been updated to "completed".
</swarm-completion>
`;
} catch (error) {
console.error("Failed to update completion status:", error);
}
}
}
const response = {
continue: shouldContinue,
hookSpecificOutput: {
additionalContext: additionalContext || undefined,
},
};
console.log(JSON.stringify(response));
}
main().catch((error) => {
console.error("Stop hook error:", error);
console.log(JSON.stringify({ continue: true }));
process.exit(1);
});
/**
* Swarm coordination utilities for multi-session Claude workflows
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "fs";
import { join } from "path";
export interface SessionInfo {
session_id: string;
purpose: string;
status: "pending" | "running" | "completed" | "blocked" | "error";
pane_id?: number;
started_at: string;
updated_at: string;
}
export interface SwarmManifest {
swarm_id: string;
created_at: string;
swarm_dir: string;
parent: SessionInfo;
children: SessionInfo[];
}
/**
* Get swarm context from environment
*/
export function getSwarmContext() {
return {
swarmId: process.env.CLAUDE_SWARM_ID,
swarmDir: process.env.CLAUDE_SWARM_DIR,
parentSession: process.env.CLAUDE_PARENT_SESSION,
sessionRole: process.env.CLAUDE_SESSION_ROLE as "parent" | "child" | undefined,
sessionPurpose: process.env.CLAUDE_SESSION_PURPOSE,
};
}
/**
* Check if this session is part of a swarm
*/
export function isSwarmSession(): boolean {
return !!process.env.CLAUDE_SWARM_ID;
}
/**
* Initialize a new swarm (called by parent)
*/
export function initSwarm(
swarmId: string,
swarmDir: string,
parentSessionId: string,
parentPurpose: string
): SwarmManifest {
mkdirSync(swarmDir, { recursive: true });
const manifest: SwarmManifest = {
swarm_id: swarmId,
created_at: new Date().toISOString(),
swarm_dir: swarmDir,
parent: {
session_id: parentSessionId,
purpose: parentPurpose,
status: "running",
started_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
children: [],
};
writeManifest(swarmDir, manifest);
writeSessionStatus(swarmDir, `parent-${parentSessionId}`, {
purpose: parentPurpose,
status: "running",
progress: [],
currentTask: "Coordinating swarm",
});
return manifest;
}
/**
* Register a child session in the swarm
*/
export function registerChild(
swarmDir: string,
sessionId: string,
purpose: string,
paneId?: number
): void {
const manifest = readManifest(swarmDir);
if (!manifest) {
throw new Error(`No manifest found in ${swarmDir}`);
}
const childInfo: SessionInfo = {
session_id: sessionId,
purpose,
status: "running",
pane_id: paneId,
started_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
// Check if already registered
const existing = manifest.children.find((c) => c.session_id === sessionId);
if (!existing) {
manifest.children.push(childInfo);
writeManifest(swarmDir, manifest);
}
writeSessionStatus(swarmDir, `child-${sessionId}`, {
purpose,
status: "running",
progress: [],
currentTask: "Starting up",
});
}
/**
* Update session status
*/
export function updateSessionStatus(
swarmDir: string,
sessionId: string,
role: "parent" | "child",
updates: {
status?: SessionInfo["status"];
currentTask?: string;
progress?: string[];
addProgress?: string;
}
): void {
const filename = `${role}-${sessionId}`;
const existing = readSessionStatus(swarmDir, filename);
const progress = updates.addProgress
? [...(existing?.progress || []), updates.addProgress]
: updates.progress ?? existing?.progress ?? [];
writeSessionStatus(swarmDir, filename, {
purpose: existing?.purpose || "Unknown",
status: updates.status || existing?.status || "running",
progress,
currentTask: updates.currentTask || existing?.currentTask || "",
});
// Also update manifest
const manifest = readManifest(swarmDir);
if (manifest) {
if (role === "parent") {
manifest.parent.status = updates.status || manifest.parent.status;
manifest.parent.updated_at = new Date().toISOString();
} else {
const child = manifest.children.find((c) => c.session_id === sessionId);
if (child) {
child.status = updates.status || child.status;
child.updated_at = new Date().toISOString();
}
}
writeManifest(swarmDir, manifest);
}
}
/**
* Get all sibling session statuses
*/
export function getSiblingStatuses(swarmDir: string): Array<{
filename: string;
purpose: string;
status: string;
currentTask: string;
progress: string[];
}> {
if (!existsSync(swarmDir)) return [];
const files = readdirSync(swarmDir).filter((f) => f.endsWith(".md"));
return files.map((f) => {
const status = readSessionStatus(swarmDir, f.replace(".md", ""));
return {
filename: f,
purpose: status?.purpose || "Unknown",
status: status?.status || "unknown",
currentTask: status?.currentTask || "",
progress: status?.progress || [],
};
});
}
/**
* Format sibling progress for injection into conversation
*/
export function formatSiblingProgress(swarmDir: string): string {
const siblings = getSiblingStatuses(swarmDir);
if (siblings.length === 0) return "";
let output = "## Swarm Session Progress\n\n";
for (const s of siblings) {
const completedCount = s.progress.filter((p) => p.startsWith("[x]")).length;
const totalCount = s.progress.length;
output += `### ${s.filename.replace(".md", "")}\n`;
output += `- **Purpose**: ${s.purpose}\n`;
output += `- **Status**: ${s.status}\n`;
output += `- **Current**: ${s.currentTask}\n`;
output += `- **Progress**: ${completedCount}/${totalCount} items\n\n`;
}
return output;
}
// ---- File I/O helpers ----
function readManifest(swarmDir: string): SwarmManifest | null {
const path = join(swarmDir, "manifest.json");
if (!existsSync(path)) return null;
return JSON.parse(readFileSync(path, "utf-8"));
}
function writeManifest(swarmDir: string, manifest: SwarmManifest): void {
const path = join(swarmDir, "manifest.json");
writeFileSync(path, JSON.stringify(manifest, null, 2));
}
interface SessionStatus {
purpose: string;
status: string;
progress: string[];
currentTask: string;
}
function readSessionStatus(swarmDir: string, filename: string): SessionStatus | null {
const path = join(swarmDir, `${filename}.md`);
if (!existsSync(path)) return null;
const content = readFileSync(path, "utf-8");
// Simple parsing - extract key fields
const purposeMatch = content.match(/Purpose: (.+)/);
const statusMatch = content.match(/Status: (.+)/);
const currentMatch = content.match(/## Current Task\n(.+)/);
const progressMatch = content.match(/## Progress\n([\s\S]*?)(?=\n## |$)/);
const progress = progressMatch
? progressMatch[1]
.split("\n")
.filter((l) => l.startsWith("- ["))
.map((l) => l.substring(2))
: [];
return {
purpose: purposeMatch?.[1] || "Unknown",
status: statusMatch?.[1] || "unknown",
currentTask: currentMatch?.[1] || "",
progress,
};
}
function writeSessionStatus(swarmDir: string, filename: string, status: SessionStatus): void {
const path = join(swarmDir, `${filename}.md`);
const progressList = status.progress.map((p) => `- ${p}`).join("\n");
const content = `# Session: ${filename}
Purpose: ${status.purpose}
Status: ${status.status}
Started: ${new Date().toISOString()}
## Progress
${progressList || "- [ ] Starting..."}
## Current Task
${status.currentTask}
## Last Updated
${new Date().toISOString()}
`;
writeFileSync(path, content);
}
#!/usr/bin/env bun
/**
* UserPromptSubmit hook for swarm coordination
*
* Periodically injects sibling progress updates into the conversation
* so sessions can be aware of each other's state.
*/
import {
getSwarmContext,
isSwarmSession,
formatSiblingProgress,
updateSessionStatus,
} from "../src/swarm";
interface UserPromptSubmitInput {
session_id: string;
prompt: string;
cwd: string;
}
// Track prompts to inject sibling updates periodically
let promptCount = 0;
const INJECT_EVERY_N_PROMPTS = 5;
async function main() {
const input: UserPromptSubmitInput = await Bun.stdin.json();
const { session_id, prompt } = input;
let additionalContext = "";
if (isSwarmSession()) {
const ctx = getSwarmContext();
promptCount++;
// Update session status (mark as actively working)
if (ctx.swarmDir && ctx.sessionRole) {
try {
updateSessionStatus(ctx.swarmDir, session_id, ctx.sessionRole, {
status: "running",
currentTask: prompt.slice(0, 100) + (prompt.length > 100 ? "..." : ""),
});
} catch (error) {
console.error("Failed to update session status:", error);
}
}
// Inject sibling progress every N prompts
if (promptCount % INJECT_EVERY_N_PROMPTS === 0 && ctx.swarmDir) {
const siblingProgress = formatSiblingProgress(ctx.swarmDir);
if (siblingProgress) {
additionalContext = `
<swarm-update>
${siblingProgress}
**Note**: Check if any sibling completed tasks you depend on.
</swarm-update>
`;
}
}
// Check for keywords that indicate progress
const progressKeywords = ["done", "completed", "finished", "fixed", "implemented"];
const hasProgressKeyword = progressKeywords.some((kw) =>
prompt.toLowerCase().includes(kw)
);
if (hasProgressKeyword && ctx.swarmDir && ctx.sessionRole) {
additionalContext += `
<swarm-reminder>
If you completed a task, update your progress file:
\`\`\`bash
# Add completed item
echo "- [x] <task description>" >> ${ctx.swarmDir}/${ctx.sessionRole}-${session_id}.md
\`\`\`
</swarm-reminder>
`;
}
}
const response = {
continue: true,
hookSpecificOutput: {
additionalContext: additionalContext || undefined,
},
};
console.log(JSON.stringify(response));
}
main().catch((error) => {
console.error("UserPromptSubmit hook error:", error);
console.log(JSON.stringify({ continue: true }));
process.exit(1);
});

WezTerm + Claude Code: Multi-Session Workflows

A guide to spawning multiple Claude Code sessions in WezTerm panes with BSP (Binary Space Partitioning) layouts.

The Problem

When working with Claude Code, you often want to:

  • Delegate subtasks to parallel Claude sessions
  • Explore tangents without losing context
  • Run multiple investigations simultaneously

WezTerm's CLI makes this possible, but there are several gotchas to navigate.

Key Discoveries

1. PATH Preservation

Never use wezterm cli split-pane -- command

This bypasses your shell entirely, losing PATH additions from .zshrc/.bashrc. Tools installed via npm, homebrew, etc. won't be found.

# WRONG - bypasses shell, loses PATH
wezterm cli split-pane -- claude

# CORRECT - two-step approach preserves PATH
PANE_ID=$(wezterm cli split-pane)
printf 'claude\n' | wezterm cli send-text --pane-id "$PANE_ID"

2. Newline Handling

Use printf pipe, not string escapes

# WRONG - sends literal \n
wezterm cli send-text --pane-id "$PANE_ID" "command\n"
wezterm cli send-text --pane-id "$PANE_ID" $'command\n'  # Works alone, breaks in loops

# CORRECT - printf interprets \n properly
printf 'command\n' | wezterm cli send-text --pane-id "$PANE_ID"

# With variables
printf 'claude "$(cat /tmp/prompt-%d.md)"\n' "$i" | wezterm cli send-text --pane-id "$PANE_ID"

3. Tab Detection

is_active returns one pane per tab, not the global focus

Each tab has its own "active pane". Querying is_active == true returns multiple results.

# WRONG - returns active pane from FIRST tab, not YOUR tab
wezterm cli list --format json | jq '[.[] | select(.is_active == true)] | .[0]'

# CORRECT - use WEZTERM_PANE environment variable
CURRENT_TAB=$(wezterm cli list --format json | jq -r --arg pane "$WEZTERM_PANE" '
  .[] | select(.pane_id == ($pane | tonumber)) | .tab_id
')

4. BSP Layout Algorithm

To create balanced pane grids (like i3/bspwm), always split the largest pane:

# Find largest pane by area (rows × cols)
LARGEST=$(wezterm cli list --format json | jq -r --arg tab "$TAB" '
  map(select(.tab_id == ($tab | tonumber))) |
  sort_by(-(.size.rows * .size.cols)) |
  .[0]
')

# Split based on aspect ratio (matches WezTerm's tiled mode)
# ratio > 2.0 = split horizontally, else vertically
RATIO=$(cols / rows)
DIRECTION=$(ratio > 2.0 ? "--right" : "--bottom")

The BSP Split Script

Save as /tmp/bsp-split.sh:

#!/bin/bash
# BSP Split: Find largest pane IN CURRENT TAB and bisect it

# Get current pane from WEZTERM_PANE env var (set by WezTerm for shells)
if [ -z "$WEZTERM_PANE" ]; then
  echo "Error: WEZTERM_PANE not set" >&2
  exit 1
fi

# Get tab ID for current pane
CURRENT_TAB=$(wezterm cli list --format json | jq -r --arg pane "$WEZTERM_PANE" '
  .[] | select(.pane_id == ($pane | tonumber)) | .tab_id
')

# Get largest pane in current tab only
LARGEST=$(wezterm cli list --format json | jq -r --arg tab "$CURRENT_TAB" '
  map(select(.tab_id == ($tab | tonumber) and .is_zoomed == false)) |
  sort_by(-(.size.rows * .size.cols)) |
  .[0] |
  "\(.pane_id) \(.size.cols) \(.size.rows)"
')

PANE_ID=$(echo "$LARGEST" | awk '{print $1}')
COLS=$(echo "$LARGEST" | awk '{print $2}')
ROWS=$(echo "$LARGEST" | awk '{print $3}')

RATIO=$(echo "$COLS $ROWS" | awk '{printf "%.2f", $1/$2}')
if (( $(echo "$RATIO > 2.0" | bc -l) )); then
  wezterm cli split-pane --pane-id "$PANE_ID" --right
else
  wezterm cli split-pane --pane-id "$PANE_ID" --bottom
fi

Usage Examples

Prompt File Organization

Store prompts in /tmp/claude-sessions/ with structured names:

mkdir -p /tmp/claude-sessions

# Pattern: <cwd>-<timestamp>-<purpose>.md
CWD_NAME=$(basename "$PWD")
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
PURPOSE="write-tests"

PROMPT_FILE="/tmp/claude-sessions/${CWD_NAME}-${TIMESTAMP}-${PURPOSE}.md"
# Example: /tmp/claude-sessions/myapp-20260113-153045-write-tests.md

This makes it easy to find and debug session prompts later.

Single Claude Session with Context

# Setup prompt file
mkdir -p /tmp/claude-sessions
PROMPT_FILE="/tmp/claude-sessions/$(basename $PWD)-$(date +%Y%m%d-%H%M%S)-review.md"

cat > "$PROMPT_FILE" << 'EOF'
## Context
We're building a REST API with authentication.
The main files are in src/api/ and src/auth/.

## Task
Review the auth middleware and suggest improvements.
EOF

# Spawn session
NEW_PANE=$(/tmp/bsp-split.sh)
printf 'claude "$(cat %s)"\n' "$PROMPT_FILE" | wezterm cli send-text --pane-id "$NEW_PANE"

Multiple Parallel Sessions

# Setup
mkdir -p /tmp/claude-sessions
CWD_NAME=$(basename "$PWD")
TIMESTAMP=$(date +%Y%m%d-%H%M%S)

# Create prompt files with structured names
TASKS=("write-tests" "add-error-handling" "update-docs" "refactor-utils")
PROMPT_FILES=()
for TASK in "${TASKS[@]}"; do
  PROMPT_FILE="/tmp/claude-sessions/${CWD_NAME}-${TIMESTAMP}-${TASK}.md"
  cat > "$PROMPT_FILE" << EOF
## Task
$TASK
EOF
  PROMPT_FILES+=("$PROMPT_FILE")
done

# Spawn all sessions with BSP layout
for PROMPT_FILE in "${PROMPT_FILES[@]}"; do
  NEW_PANE=$(/tmp/bsp-split.sh)
  printf 'claude "$(cat %s)"\n' "$PROMPT_FILE" | wezterm cli send-text --pane-id "$NEW_PANE"
done

12-Pane Grid for Parallel Hello Worlds

mkdir -p /tmp/claude-sessions
CWD_NAME=$(basename "$PWD")
TIMESTAMP=$(date +%Y%m%d-%H%M%S)

LANGS=("python" "typescript" "rust" "go" "ruby" "bash" "lua" "javascript" "c" "elixir" "haskell" "ocaml")
PROMPT_FILES=()
for LANG in "${LANGS[@]}"; do
  PROMPT_FILE="/tmp/claude-sessions/${CWD_NAME}-${TIMESTAMP}-hello-${LANG}.md"
  cat > "$PROMPT_FILE" << EOF
Write and run a hello world in $LANG.
EOF
  PROMPT_FILES+=("$PROMPT_FILE")
done

# Spawn 12 sessions - creates balanced BSP grid
for PROMPT_FILE in "${PROMPT_FILES[@]}"; do
  NEW_PANE=$(/tmp/bsp-split.sh)
  printf 'claude "$(cat %s)"\n' "$PROMPT_FILE" | wezterm cli send-text --pane-id "$NEW_PANE"
done

WezTerm CLI Reference

Pane Operations

# Create pane (returns pane ID)
wezterm cli split-pane                    # Split current pane (bottom)
wezterm cli split-pane --right            # Split horizontally
wezterm cli split-pane --pane-id 123      # Split specific pane
wezterm cli split-pane --cwd ~/project    # With working directory

# Send text to pane
printf 'command\n' | wezterm cli send-text --pane-id "$PANE_ID"

# List all panes
wezterm cli list --format json

Useful jq Queries

# Count panes in current tab
wezterm cli list --format json | jq --arg tab "$TAB" '
  [.[] | select(.tab_id == ($tab | tonumber))] | length
'

# Get pane distribution across tabs
wezterm cli list --format json | jq '
  group_by(.tab_id) |
  map({tab_id: .[0].tab_id, pane_count: length})
'

# Find largest pane
wezterm cli list --format json | jq '
  sort_by(-(.size.rows * .size.cols)) | .[0]
'

Claude Code Skills

These patterns are packaged as Claude Code skills:

wezterm-pane Skill

Creates panes and runs commands with PATH preserved.

Triggers: "create a new pane", "open a pane", "split terminal", "run X in a new pane"

session Skill

Spawns Claude sessions with conversation context using BSP layout.

Triggers: "start a session", "new claude session", "spawn claude with prompt", "power session"

Power Session Mode

Say "power session" to spawn with --dangerously-skip-permissions:

printf 'claude --dangerously-skip-permissions "$(cat /tmp/prompt.md)"\n' | wezterm cli send-text --pane-id "$PANE"

Requirements

  • WezTerm (with wezterm cli available)
  • jq (for JSON parsing)
  • bc (for floating point comparison in BSP ratio)

Gotchas Summary

Issue Wrong Right
PATH missing split-pane -- cmd split-pane + send-text
Literal \n "cmd\n" or $'cmd\n' in loops printf 'cmd\n' |
Wrong tab is_active == true $WEZTERM_PANE env var
Unbalanced splits Always split current BSP: split largest

License

MIT - Use freely, attribute if you share.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment