Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6d94e80
fix: prevent terminal-features spam on repeated session starts (#366)
asheshgoplani Mar 18, 2026
1e6a523
feat: auto-generate session names as placeholders in new session dialog
naps62 Mar 18, 2026
b3c53a4
chore: remove redundant comment on generatedName field
naps62 Mar 18, 2026
9942ee8
fix: use placeholder for worktree branch with generated name, add tests
naps62 Mar 18, 2026
af1178d
feat(worktree): reuse existing branches and add fzf picker
qzchenwl Mar 18, 2026
772facb
Add happy wrapper support for Claude and Codex
qzchenwl Mar 18, 2026
9565abf
Merge pull request #367 from asheshgoplani/fix/366-terminal-features-…
asheshgoplani Mar 18, 2026
6cf9a6e
Merge pull request #368 from naps62/feature/auto-name
asheshgoplani Mar 18, 2026
1a388f9
Merge pull request #372 from qzchenwl/feature/agent-deck-happy
asheshgoplani Mar 18, 2026
7f304af
Merge pull request #370 from qzchenwl/feature/existing-branch
asheshgoplani Mar 18, 2026
1f3dcf3
fix(tmux): set extended-keys per-session to avoid breaking dashboard …
rhukster Mar 18, 2026
4173768
Merge PR #373: fix(tmux) set extended-keys per-session to avoid break…
asheshgoplani Mar 18, 2026
12c0a65
fix: move cost dashboard from $ to C key, restore $ for error filter …
asheshgoplani Mar 18, 2026
907f950
Merge branch 'main' of https://github.com/asheshgoplani/agent-deck
c2keesey Mar 26, 2026
4fe9809
Merge branch 'main' of https://github.com/asheshgoplani/agent-deck
c2keesey Mar 27, 2026
21a0efc
Merge branch 'main' of https://github.com/asheshgoplani/agent-deck
c2keesey Apr 10, 2026
feb8aad
feat: add opt-in smart heartbeat with state diffing
Apr 13, 2026
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
8 changes: 6 additions & 2 deletions cmd/agent-deck/conductor_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -518,12 +518,16 @@ func handleConductorSetup(profile string, args []string) {
fmt.Println(" [skip] Heartbeat disabled (interval = 0)")
}
} else {
if err := session.InstallHeartbeatScript(name, resolvedProfile); err != nil {
if err := session.InstallHeartbeatScript(name, resolvedProfile, settings.HeartbeatSmart); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to install heartbeat script: %v\n", err)
} else if err := session.InstallHeartbeatDaemon(name, resolvedProfile, interval); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to install heartbeat daemon: %v\n", err)
} else if !*jsonOutput {
fmt.Printf(" [ok] Heartbeat timer installed (every %d min)\n", interval)
mode := ""
if settings.HeartbeatSmart {
mode = ", smart"
}
fmt.Printf(" [ok] Heartbeat timer installed (every %d min%s)\n", interval, mode)
}
}
}
Expand Down
12 changes: 3 additions & 9 deletions cmd/agent-deck/launch_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ func handleLaunch(profile string, args []string) {
// Worktree flags
worktreeBranch := fs.String("w", "", "Create session in git worktree for branch")
worktreeBranchLong := fs.String("worktree", "", "Create session in git worktree for branch")
newBranch := fs.Bool("b", false, "Create new branch (use with --worktree)")
newBranchLong := fs.Bool("new-branch", false, "Create new branch")
newBranch := fs.Bool("b", false, "Create new branch if needed (reuse existing branch when present)")
newBranchLong := fs.Bool("new-branch", false, "Create new branch if needed (reuse existing branch when present)")
worktreeLocation := fs.String("location", "", "Worktree location: sibling, subdirectory, or custom path")

// MCP flag
Expand Down Expand Up @@ -129,7 +129,7 @@ func handleLaunch(profile string, args []string) {
if *worktreeBranchLong != "" {
wtBranch = *worktreeBranchLong
}
createNewBranch := *newBranch || *newBranchLong
_ = *newBranch || *newBranchLong

// Validate --resume-session requires Claude
if *resumeSession != "" {
Expand Down Expand Up @@ -159,12 +159,6 @@ func handleLaunch(profile string, args []string) {
os.Exit(1)
}

branchExists := git.BranchExists(repoRoot, wtBranch)
if createNewBranch && branchExists {
out.Error(fmt.Sprintf("branch '%s' already exists (remove -b flag to use existing branch)", wtBranch), ErrCodeInvalidOperation)
os.Exit(1)
}

wtSettings := session.GetWorktreeSettings()
location := wtSettings.DefaultLocation
if *worktreeLocation != "" {
Expand Down
17 changes: 3 additions & 14 deletions cmd/agent-deck/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -872,8 +872,8 @@ func handleAdd(profile string, args []string) {
// Worktree flags
worktreeBranch := fs.String("w", "", "Create session in git worktree for branch")
worktreeBranchLong := fs.String("worktree", "", "Create session in git worktree for branch")
newBranch := fs.Bool("b", false, "Create new branch (use with --worktree)")
newBranchLong := fs.Bool("new-branch", false, "Create new branch")
newBranch := fs.Bool("b", false, "Create new branch if needed (reuse existing branch when present)")
newBranchLong := fs.Bool("new-branch", false, "Create new branch if needed (reuse existing branch when present)")
worktreeLocation := fs.String("location", "", "Worktree location: sibling, subdirectory, or custom path")

// MCP flag - can be specified multiple times
Expand Down Expand Up @@ -952,7 +952,7 @@ func handleAdd(profile string, args []string) {
if *worktreeBranchLong != "" {
wtBranch = *worktreeBranchLong
}
createNewBranch := *newBranch || *newBranchLong
_ = *newBranch || *newBranchLong

// Merge short and long flags
sessionTitle := mergeFlags(*title, *titleShort)
Expand Down Expand Up @@ -1092,17 +1092,6 @@ func handleAdd(profile string, args []string) {
os.Exit(1)
}

// Check -b flag logic: if -b is passed, branch must NOT exist (user wants new branch)
branchExists := git.BranchExists(repoRoot, wtBranch)
if createNewBranch && branchExists {
fmt.Fprintf(
os.Stderr,
"Error: branch '%s' already exists (remove -b flag to use existing branch)\n",
wtBranch,
)
os.Exit(1)
}

// Determine worktree location: CLI flag overrides config
wtSettings := session.GetWorktreeSettings()
location := wtSettings.DefaultLocation
Expand Down
10 changes: 5 additions & 5 deletions cmd/agent-deck/session_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,8 +383,8 @@ func handleSessionFork(profile string, args []string) {
groupShort := fs.String("g", "", "Group for forked session (short)")
worktreeBranch := fs.String("w", "", "Create fork in git worktree for branch")
worktreeBranchLong := fs.String("worktree", "", "Create fork in git worktree for branch")
newBranch := fs.Bool("b", false, "Create new branch (use with --worktree)")
newBranchLong := fs.Bool("new-branch", false, "Create new branch")
newBranch := fs.Bool("b", false, "Create new branch if needed (reuse existing branch when present)")
newBranchLong := fs.Bool("new-branch", false, "Create new branch if needed (reuse existing branch when present)")
sandbox := fs.Bool("sandbox", false, "Run forked session in Docker sandbox")
sandboxImage := fs.String("sandbox-image", "", "Docker image for sandbox (overrides config default)")

Expand Down Expand Up @@ -472,7 +472,7 @@ func handleSessionFork(profile string, args []string) {
if *worktreeBranchLong != "" {
wtBranch = *worktreeBranchLong
}
createNewBranch := *newBranch || *newBranchLong
_ = *newBranch || *newBranchLong

// Handle worktree creation
var opts *session.ClaudeOptions
Expand All @@ -487,8 +487,8 @@ func handleSessionFork(profile string, args []string) {
os.Exit(1)
}

if !createNewBranch && !git.BranchExists(repoRoot, wtBranch) {
out.Error(fmt.Sprintf("branch '%s' does not exist (use -b to create)", wtBranch), ErrCodeInvalidOperation)
if err := git.ValidateBranchName(wtBranch); err != nil {
out.Error(fmt.Sprintf("invalid branch name: %v", err), ErrCodeInvalidOperation)
os.Exit(1)
}

Expand Down
97 changes: 89 additions & 8 deletions internal/session/conductor.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ type ConductorSettings struct {
// Default: 15
HeartbeatInterval int `toml:"heartbeat_interval"`

// HeartbeatSmart enables state-diffing for heartbeat scripts: the script
// snapshots session state on each tick and only forwards a check-in to the
// conductor when the snapshot differs from the previous run. This avoids
// waking the conductor when nothing has changed.
// Default: false (every timer tick sends a heartbeat)
HeartbeatSmart bool `toml:"heartbeat_smart"`

// Profiles is the list of agent-deck profiles to manage
// Kept for backward compat but ignored after migration to meta.json-based discovery
Profiles []string `toml:"profiles"`
Expand Down Expand Up @@ -526,16 +533,26 @@ func SetupConductorWithAgent(name, profile, agent string, heartbeatEnabled bool,
}

// InstallHeartbeatScript writes the heartbeat.sh script for a conductor.
// This is a standalone heartbeat that works without Telegram.
func InstallHeartbeatScript(name, profile string) error {
// This is a standalone heartbeat that works without Telegram. When smart is
// true, the state-diffing variant is installed: it snapshots session state on
// each tick and only sends a check-in when the snapshot has changed.
func InstallHeartbeatScript(name, profile string, smart bool) error {
dir, err := ConductorNameDir(name)
if err != nil {
return err
}
profile = normalizeConductorProfile(profile)

script := strings.ReplaceAll(conductorHeartbeatScript, "{NAME}", name)
template := conductorHeartbeatScript
if smart {
template = conductorSmartHeartbeatScript
}

script := strings.ReplaceAll(template, "{NAME}", name)
script = strings.ReplaceAll(script, "{PROFILE}", profile)
if smart {
script = strings.ReplaceAll(script, "{STATE_FILE}", filepath.Join(dir, "heartbeat.state"))
}
if profile == DefaultProfile {
// For default profile, omit -p flag entirely
script = strings.ReplaceAll(script, `-p "$PROFILE" `, "")
Expand Down Expand Up @@ -742,6 +759,55 @@ if [ "$STATUS" = "idle" ] || [ "$STATUS" = "waiting" ]; then
fi
`

// conductorSmartHeartbeatScript is the state-diffing variant of the heartbeat
// script. It snapshots the per-session title:status pairs in the profile and
// only proceeds with a check-in if the snapshot has changed since the last
// run. Snapshots are stored in heartbeat.state next to the script.
//
// JSON parsing uses awk to stay portable across GNU and BSD (macOS). The
// agent-deck `list --json` output is pretty-printed with one field per line,
// so a streaming awk pass that pairs each "title" with the next "status" is
// reliable as long as the InstanceData struct keeps Title before Status.
const conductorSmartHeartbeatScript = `#!/bin/bash
# Smart heartbeat for conductor: {NAME} (profile: {PROFILE})
# Sends a check-in only when session state has changed since the last tick.

SESSION="conductor-{NAME}"
PROFILE="{PROFILE}"
STATE_FILE="{STATE_FILE}"

# Check if conductor is enabled
if ! agent-deck -p "$PROFILE" conductor status --json 2>/dev/null | grep -q '"enabled".*true'; then
exit 0
fi

# Snapshot session state: sorted "title:status" pairs, excluding this conductor.
CURRENT=$(agent-deck -p "$PROFILE" list --json 2>/dev/null | awk '
/"title":/ { sub(/.*"title":[[:space:]]*"/, ""); sub(/".*/, ""); t=$0; next }
/"status":/ { sub(/.*"status":[[:space:]]*"/, ""); sub(/".*/, ""); if (t != "") { print t":"$0; t="" } }
' | grep -v "^$SESSION:" | sort)

PREVIOUS=""
if [ -f "$STATE_FILE" ]; then
PREVIOUS=$(cat "$STATE_FILE" 2>/dev/null)
fi

if [ "$CURRENT" = "$PREVIOUS" ]; then
exit 0
fi

# State changed — persist the new snapshot before sending so a crash mid-send
# doesn't cause us to re-fire forever.
printf '%s\n' "$CURRENT" > "$STATE_FILE.tmp" 2>/dev/null && mv "$STATE_FILE.tmp" "$STATE_FILE" 2>/dev/null

# Only send if the conductor session is idle or waiting
STATUS=$(agent-deck -p "$PROFILE" session show "$SESSION" --json 2>/dev/null | awk -F'"' '/"status"/{print $4; exit}')

if [ "$STATUS" = "idle" ] || [ "$STATUS" = "waiting" ]; then
agent-deck -p "$PROFILE" session send "$SESSION" "Heartbeat: Check sessions in your group ({NAME}). List any that are waiting, auto-respond where safe, and report what needs my attention." --no-wait -q
fi
`

// conductorHeartbeatPlistTemplate is the launchd plist for a per-conductor heartbeat timer
const conductorHeartbeatPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
Expand Down Expand Up @@ -1135,13 +1201,18 @@ func MigrateConductorLearnings() ([]string, error) {
}

// MigrateConductorHeartbeatScripts refreshes managed heartbeat scripts to the
// current template without touching custom user-authored scripts.
// current template without touching custom user-authored scripts. It honours
// the heartbeat_smart conductor setting when choosing between the standard and
// state-diffing templates, so toggling the setting and re-running setup (or
// triggering a migration pass) swaps existing scripts in place.
func MigrateConductorHeartbeatScripts() ([]string, error) {
conductors, err := ListConductors()
if err != nil {
return nil, err
}

smart := GetConductorSettings().HeartbeatSmart

var migrated []string
for _, meta := range conductors {
dir, err := ConductorNameDir(meta.Name)
Expand All @@ -1150,9 +1221,18 @@ func MigrateConductorHeartbeatScripts() ([]string, error) {
}

scriptPath := filepath.Join(dir, "heartbeat.sh")
expected := strings.ReplaceAll(conductorHeartbeatScript, "{NAME}", meta.Name)
expected = strings.ReplaceAll(expected, "{PROFILE}", normalizeConductorProfile(meta.Profile))
if normalizeConductorProfile(meta.Profile) == DefaultProfile {
profile := normalizeConductorProfile(meta.Profile)

template := conductorHeartbeatScript
if smart {
template = conductorSmartHeartbeatScript
}
expected := strings.ReplaceAll(template, "{NAME}", meta.Name)
expected = strings.ReplaceAll(expected, "{PROFILE}", profile)
if smart {
expected = strings.ReplaceAll(expected, "{STATE_FILE}", filepath.Join(dir, "heartbeat.state"))
}
if profile == DefaultProfile {
expected = strings.ReplaceAll(expected, `-p "$PROFILE" `, "")
}

Expand All @@ -1167,7 +1247,8 @@ func MigrateConductorHeartbeatScripts() ([]string, error) {
}

existingStr := string(existing)
managedScript := strings.Contains(existingStr, "# Heartbeat for conductor:") &&
managedScript := (strings.Contains(existingStr, "# Heartbeat for conductor:") ||
strings.Contains(existingStr, "# Smart heartbeat for conductor:")) &&
strings.Contains(existingStr, `SESSION="conductor-`)
if !managedScript {
continue
Expand Down
Loading