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
5 changes: 5 additions & 0 deletions cmd/agent-deck/launch_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func handleLaunch(profile string, args []string) {
parent := fs.String("parent", "", "Parent session (creates sub-session, inherits group)")
parentShort := fs.String("p", "", "Parent session (short)")
noParent := fs.Bool("no-parent", false, "Disable automatic parent linking")
noTransitionNotify := fs.Bool("no-transition-notify", false, "Suppress transition event notifications to parent session")
jsonOutput := fs.Bool("json", false, "Output as JSON")
quiet := fs.Bool("quiet", false, "Minimal output")
quietShort := fs.Bool("q", false, "Minimal output (short)")
Expand Down Expand Up @@ -269,6 +270,10 @@ func handleLaunch(profile string, args []string) {
newInstance.SetParentWithPath(parentInstance.ID, parentInstance.ProjectPath)
}

if *noTransitionNotify {
newInstance.NoTransitionNotify = true
}

if sessionCommandInput != "" {
newInstance.Tool = firstNonEmpty(sessionCommandTool, detectTool(sessionCommandInput))
newInstance.Command = sessionCommandResolved
Expand Down
6 changes: 6 additions & 0 deletions cmd/agent-deck/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,7 @@ func handleAdd(profile string, args []string) {
parent := fs.String("parent", "", "Parent session (creates sub-session, inherits group)")
parentShort := fs.String("p", "", "Parent session (short)")
noParent := fs.Bool("no-parent", false, "Disable automatic parent linking (use 'session set-parent' later to link manually)")
noTransitionNotify := fs.Bool("no-transition-notify", false, "Suppress transition event notifications to parent session")
quickCreate := fs.Bool("quick", false, "Auto-generate session name (adjective-noun)")
quickCreateShort := fs.Bool("Q", false, "Auto-generate session name (short)")
jsonOutput := fs.Bool("json", false, "Output as JSON")
Expand Down Expand Up @@ -1212,6 +1213,11 @@ func handleAdd(profile string, args []string) {
newInstance.SetParentWithPath(parentInstance.ID, parentInstance.ProjectPath)
}

// Suppress transition notifications if requested
if *noTransitionNotify {
newInstance.NoTransitionNotify = true
}

// Set command if provided
if sessionCommandInput != "" {
newInstance.Tool = firstNonEmpty(sessionCommandTool, detectTool(sessionCommandInput))
Expand Down
90 changes: 90 additions & 0 deletions cmd/agent-deck/session_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ func handleSession(profile string, args []string) {
handleSessionSetParent(profile, args[1:])
case "unset-parent":
handleSessionUnsetParent(profile, args[1:])
case "set-transition-notify":
handleSessionSetTransitionNotify(profile, args[1:])
case "set":
handleSessionSet(profile, args[1:])
case "send":
Expand Down Expand Up @@ -79,6 +81,7 @@ func printSessionHelp() {
fmt.Println(" output <id> Get the last response from a session")
fmt.Println(" set-parent <id> <parent> Link session as sub-session of parent")
fmt.Println(" unset-parent <id> Remove sub-session link")
fmt.Println(" set-transition-notify <id> <on|off> Enable/disable transition notifications")
fmt.Println()
fmt.Println("Global Options:")
fmt.Println(" -p, --profile <name> Use specific profile")
Expand All @@ -95,6 +98,8 @@ func printSessionHelp() {
fmt.Println(" agent-deck session show my-project --json")
fmt.Println(" agent-deck session set-parent sub-task main-project # Make sub-task a sub-session")
fmt.Println(" agent-deck session unset-parent sub-task # Remove sub-session link")
fmt.Println(" agent-deck session set-transition-notify worker off # Suppress notifications")
fmt.Println(" agent-deck session set-transition-notify worker on # Re-enable notifications")
fmt.Println(" agent-deck session output my-project # Get last response from session")
fmt.Println(" agent-deck session output my-project --json # Get response as JSON")
fmt.Println()
Expand Down Expand Up @@ -723,6 +728,7 @@ func handleSessionShow(profile string, args []string) {
"group": inst.GroupPath,
"parent_session_id": inst.ParentSessionID,
"parent_project_path": inst.ParentProjectPath,
"no_transition_notify": inst.NoTransitionNotify,
"tool": inst.Tool,
"created_at": inst.CreatedAt.Format(time.RFC3339),
}
Expand Down Expand Up @@ -794,6 +800,9 @@ func handleSessionShow(profile string, args []string) {
}
}

if inst.NoTransitionNotify {
sb.WriteString("Notify: transition events suppressed\n")
}
sb.WriteString(fmt.Sprintf("Created: %s\n", inst.CreatedAt.Format("2006-01-02 15:04:05")))

if !inst.LastAccessedAt.IsZero() {
Expand Down Expand Up @@ -1310,6 +1319,87 @@ func handleSessionUnsetParent(profile string, args []string) {
)
}

// handleSessionSetTransitionNotify enables or disables transition notifications for a session
func handleSessionSetTransitionNotify(profile string, args []string) {
fs := flag.NewFlagSet("session set-transition-notify", flag.ExitOnError)
jsonOutput := fs.Bool("json", false, "Output as JSON")
quiet := fs.Bool("quiet", false, "Minimal output")
quietShort := fs.Bool("q", false, "Minimal output (short)")

fs.Usage = func() {
fmt.Println("Usage: agent-deck session set-transition-notify <session> <on|off>")
fmt.Println()
fmt.Println("Enable or disable transition event notifications for a session.")
fmt.Println("When off, the transition daemon will not send tmux messages to the")
fmt.Println("parent session when this session changes status (e.g., running → waiting).")
fmt.Println("This does not affect the parent link itself.")
fmt.Println()
fmt.Println("Options:")
fs.PrintDefaults()
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" agent-deck session set-transition-notify worker off")
fmt.Println(" agent-deck session set-transition-notify worker on")
}

if err := fs.Parse(normalizeArgs(fs, args)); err != nil {
os.Exit(1)
}

if fs.NArg() < 2 {
fs.Usage()
os.Exit(1)
}

sessionID := fs.Arg(0)
value := strings.ToLower(strings.TrimSpace(fs.Arg(1)))
quietMode := *quiet || *quietShort
out := NewCLIOutput(*jsonOutput, quietMode)

var suppress bool
switch value {
case "on":
suppress = false
case "off":
suppress = true
default:
out.Error(fmt.Sprintf("invalid value %q: must be 'on' or 'off'", value), ErrCodeInvalidOperation)
os.Exit(1)
}

storage, instances, groupsData, err := loadSessionData(profile)
if err != nil {
out.Error(err.Error(), ErrCodeNotFound)
os.Exit(1)
}

inst, errMsg, errCode := ResolveSession(sessionID, instances)
if inst == nil {
out.Error(errMsg, errCode)
os.Exit(2)
return
}

inst.NoTransitionNotify = suppress

groupTree := session.NewGroupTreeWithGroups(instances, groupsData)
if err := storage.SaveWithGroups(instances, groupTree); err != nil {
out.Error(fmt.Sprintf("failed to save: %v", err), ErrCodeInvalidOperation)
os.Exit(1)
}

stateStr := "on"
if suppress {
stateStr = "off"
}
out.Success(fmt.Sprintf("Transition notifications for '%s': %s", inst.Title, stateStr), map[string]interface{}{
"success": true,
"session_id": inst.ID,
"session_title": inst.Title,
"no_transition_notify": suppress,
})
}

// handleSessionSend sends a message to a running session
// Waits for the agent to be ready before sending (Claude, Gemini, etc.)
func handleSessionSend(profile string, args []string) {
Expand Down
1 change: 1 addition & 0 deletions internal/session/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ type Instance struct {
ParentSessionID string `json:"parent_session_id,omitempty"` // Links to parent session (makes this a sub-session)
ParentProjectPath string `json:"parent_project_path,omitempty"` // Parent's project path (for --add-dir access)
IsConductor bool `json:"is_conductor,omitempty"` // True if this session is a conductor orchestrator
NoTransitionNotify bool `json:"no_transition_notify,omitempty"` // Suppress transition event dispatch for this session

// Git worktree support
WorktreePath string `json:"worktree_path,omitempty"` // Path to worktree (if session is in worktree)
Expand Down
15 changes: 10 additions & 5 deletions internal/session/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ type InstanceData struct {
GroupPath string `json:"group_path"`
Order int `json:"order"`
ParentSessionID string `json:"parent_session_id,omitempty"` // Links to parent session (sub-session support)
IsConductor bool `json:"is_conductor,omitempty"` // True if this session is a conductor orchestrator
Command string `json:"command"`
IsConductor bool `json:"is_conductor,omitempty"` // True if this session is a conductor orchestrator
NoTransitionNotify bool `json:"no_transition_notify,omitempty"` // Suppress transition event dispatch
Command string `json:"command"`
Wrapper string `json:"wrapper,omitempty"`
Tool string `json:"tool"`
Status Status `json:"status"`
Expand Down Expand Up @@ -314,9 +315,10 @@ func (s *Storage) SaveWithGroups(instances []*Instance, groupTree *GroupTree) er
TmuxSession: tmuxName,
CreatedAt: inst.CreatedAt,
LastAccessed: inst.LastAccessedAt,
ParentSessionID: inst.ParentSessionID,
IsConductor: inst.IsConductor,
WorktreePath: inst.WorktreePath,
ParentSessionID: inst.ParentSessionID,
IsConductor: inst.IsConductor,
NoTransitionNotify: inst.NoTransitionNotify,
WorktreePath: inst.WorktreePath,
WorktreeRepo: inst.WorktreeRepoRoot,
WorktreeBranch: inst.WorktreeBranch,
ToolData: toolData,
Expand Down Expand Up @@ -454,6 +456,7 @@ func (s *Storage) LoadLite() ([]*InstanceData, []*GroupData, error) {
Order: r.Order,
ParentSessionID: r.ParentSessionID,
IsConductor: r.IsConductor,
NoTransitionNotify: r.NoTransitionNotify,
Command: r.Command,
Wrapper: r.Wrapper,
Tool: r.Tool,
Expand Down Expand Up @@ -551,6 +554,7 @@ func (s *Storage) LoadWithGroups() ([]*Instance, []*GroupData, error) {
Order: r.Order,
ParentSessionID: r.ParentSessionID,
IsConductor: r.IsConductor,
NoTransitionNotify: r.NoTransitionNotify,
Command: r.Command,
Wrapper: r.Wrapper,
Tool: r.Tool,
Expand Down Expand Up @@ -756,6 +760,7 @@ func (s *Storage) convertToInstances(data *StorageData) ([]*Instance, []*GroupDa
Order: instData.Order,
ParentSessionID: instData.ParentSessionID,
IsConductor: instData.IsConductor,
NoTransitionNotify: instData.NoTransitionNotify,
Command: instData.Command,
Wrapper: instData.Wrapper,
Tool: instData.Tool,
Expand Down
8 changes: 8 additions & 0 deletions internal/session/transition_daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ func (d *TransitionDaemon) syncProfile(profile string) time.Duration {
}

prev := d.lastStatus[profile]
notifyEnabled := GetNotificationsSettings().GetTransitionEventsEnabled()
for id, to := range statuses {
from := normalizeStatusString(prev[id])
if !ShouldNotifyTransition(from, to) {
Expand All @@ -169,6 +170,9 @@ func (d *TransitionDaemon) syncProfile(profile string) time.Duration {
if inst == nil {
continue
}
if !notifyEnabled || inst.NoTransitionNotify {
continue
}
event := TransitionNotificationEvent{
ChildSessionID: id,
ChildTitle: inst.Title,
Expand Down Expand Up @@ -311,11 +315,15 @@ func (d *TransitionDaemon) emitHookTransitionCandidates(
if len(candidates) == 0 {
return
}
notifyEnabled := GetNotificationsSettings().GetTransitionEventsEnabled()
for id, candidate := range candidates {
inst := byID[id]
if inst == nil {
continue
}
if !notifyEnabled || inst.NoTransitionNotify {
continue
}

to := normalizeStatusString(candidate.ToStatus)
if curr := normalizeStatusString(current[id]); curr != "" {
Expand Down
5 changes: 5 additions & 0 deletions internal/session/transition_notifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ func (n *TransitionNotifier) dispatch(event TransitionNotificationEvent) Transit
return event
}

if child.NoTransitionNotify {
event.DeliveryResult = transitionDeliveryDropped
return event
}

parent := resolveParentNotificationTarget(child, byID)
if parent == nil {
event.DeliveryResult = transitionDeliveryDropped
Expand Down
72 changes: 72 additions & 0 deletions internal/session/transition_notifier_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package session

import (
"encoding/json"
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -192,3 +194,73 @@ func TestIsCodexTerminalHookEvent(t *testing.T) {
t.Fatal("thread.started should not be terminal")
}
}

func TestSyncProfileSkipsWhenInstanceNoTransitionNotify(t *testing.T) {
child := &Instance{
ID: "child-1",
Title: "worker",
ParentSessionID: "parent-1",
NoTransitionNotify: true,
}
parent := &Instance{
ID: "parent-1",
Title: "orchestrator",
Status: StatusWaiting,
}
byID := map[string]*Instance{
"child-1": child,
"parent-1": parent,
}

// The child has NoTransitionNotify=true, so even though the transition
// is valid (running→waiting), resolveParentNotificationTarget should
// still return a parent — but the daemon guard should skip dispatch.
// We test the guard logic indirectly: the parent resolution works,
// meaning the guard is the only thing preventing dispatch.
got := resolveParentNotificationTarget(child, byID)
if got == nil {
t.Fatal("parent should be resolvable (guard is in daemon, not here)")
}

// Verify the flag is set correctly
if !child.NoTransitionNotify {
t.Fatal("NoTransitionNotify should be true")
}
}

func TestInstanceNoTransitionNotifyJSONRoundTrip(t *testing.T) {
inst := &Instance{
ID: "test-1",
Title: "test",
NoTransitionNotify: true,
}

data, err := json.Marshal(inst)
if err != nil {
t.Fatalf("marshal: %v", err)
}

// Verify field is present in JSON
if !strings.Contains(string(data), `"no_transition_notify":true`) {
t.Fatalf("expected no_transition_notify in JSON, got: %s", data)
}

// Verify omitempty: false value should be omitted
inst2 := &Instance{ID: "test-2", Title: "test2"}
data2, err := json.Marshal(inst2)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(data2), "no_transition_notify") {
t.Fatalf("no_transition_notify should be omitted when false, got: %s", data2)
}

// Round-trip
var decoded Instance
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !decoded.NoTransitionNotify {
t.Fatal("NoTransitionNotify should be true after round-trip")
}
}
15 changes: 15 additions & 0 deletions internal/session/userconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,21 @@ type NotificationsConfig struct {
// Minimal shows a compact icon+count summary instead of session names: ● 2 │ ◐ 3 │ ○ 1
// When true, key bindings (Ctrl+b 1-6) are disabled. ShowAll is ignored. (default: false)
Minimal bool `toml:"minimal"`

// TransitionEvents controls whether the transition daemon sends tmux messages
// to parent sessions when a child transitions (e.g., running → waiting).
// Default: true (nil = true). Set to false to suppress dispatch globally.
// Per-session override: Instance.NoTransitionNotify
TransitionEvents *bool `toml:"transition_events"`
}

// GetTransitionEventsEnabled returns whether transition event dispatch is enabled.
// Defaults to true when unset (nil).
func (n NotificationsConfig) GetTransitionEventsEnabled() bool {
if n.TransitionEvents == nil {
return true
}
return *n.TransitionEvents
}

// InstanceSettings configures multiple agent-deck instance behavior
Expand Down
Loading
Loading