@@ -409,6 +409,37 @@ Tasks will automatically reconnect to their agent sessions when viewed.`,
409409 sessionsCleanupCmd .Flags ().BoolP ("force" , "f" , false , "Use SIGKILL instead of SIGTERM to force kill processes" )
410410 sessionsCmd .AddCommand (sessionsCleanupCmd )
411411
412+ sessionsSuspendCmd := & cobra.Command {
413+ Use : "suspend [task-id...]" ,
414+ Short : "Kill agent processes for blocked tasks while preserving resume capability" ,
415+ Long : `Suspend blocked task sessions by killing their agent processes and tmux windows.
416+ The session ID is preserved in the database so tasks can be resumed later via 'ty retry'.
417+
418+ By default, suspends all blocked tasks that have running sessions.
419+ Optionally specify one or more task IDs to suspend specific tasks.
420+
421+ Examples:
422+ ty sessions suspend # Suspend all blocked tasks with running sessions
423+ ty sessions suspend 42 43 # Suspend specific tasks
424+ ty sessions suspend --all # Suspend all tasks with running sessions (not just blocked)` ,
425+ Run : func (cmd * cobra.Command , args []string ) {
426+ all , _ := cmd .Flags ().GetBool ("all" )
427+ var taskIDs []int
428+ for _ , arg := range args {
429+ var id int
430+ if _ , err := fmt .Sscanf (arg , "%d" , & id ); err == nil {
431+ taskIDs = append (taskIDs , id )
432+ } else {
433+ fmt .Fprintln (os .Stderr , errorStyle .Render ("Invalid task ID: " + arg ))
434+ os .Exit (1 )
435+ }
436+ }
437+ suspendSessions (taskIDs , all )
438+ },
439+ }
440+ sessionsSuspendCmd .Flags ().Bool ("all" , false , "Suspend all tasks with running sessions, not just blocked ones" )
441+ sessionsCmd .AddCommand (sessionsSuspendCmd )
442+
412443 rootCmd .AddCommand (sessionsCmd )
413444
414445 // Alias: claudes -> sessions (for backwards compatibility)
@@ -4574,6 +4605,148 @@ func killSession(taskID int) error {
45744605 return nil
45754606}
45764607
4608+ // killSessionAcrossDaemons kills a task's tmux window across all task-daemon-* sessions.
4609+ // Returns true if a window was found and killed.
4610+ func killSessionAcrossDaemons (taskID int ) bool {
4611+ sessionsOut , err := osexec .Command ("tmux" , "list-sessions" , "-F" , "#{session_name}" ).Output ()
4612+ if err != nil {
4613+ return false
4614+ }
4615+
4616+ windowName := fmt .Sprintf ("task-%d" , taskID )
4617+ killed := false
4618+
4619+ for _ , session := range strings .Split (strings .TrimSpace (string (sessionsOut )), "\n " ) {
4620+ if ! strings .HasPrefix (session , "task-daemon-" ) {
4621+ continue
4622+ }
4623+ windowTarget := fmt .Sprintf ("%s:%s" , session , windowName )
4624+ if err := osexec .Command ("tmux" , "list-panes" , "-t" , windowTarget ).Run (); err != nil {
4625+ continue // Window doesn't exist in this session
4626+ }
4627+ if err := osexec .Command ("tmux" , "kill-window" , "-t" , windowTarget ).Run (); err == nil {
4628+ killed = true
4629+ }
4630+ }
4631+ return killed
4632+ }
4633+
4634+ // suspendSessions kills agent processes for tasks while preserving their session IDs
4635+ // so they can be resumed later. If taskIDs is empty, suspends all blocked tasks with
4636+ // running sessions. If all is true, suspends all tasks (not just blocked).
4637+ func suspendSessions (taskIDs []int , all bool ) {
4638+ // Get running sessions
4639+ sessions := getSessions ()
4640+ if len (sessions ) == 0 {
4641+ fmt .Println (dimStyle .Render ("No agent sessions running" ))
4642+ return
4643+ }
4644+
4645+ // Build a set of running task IDs for quick lookup
4646+ runningSessions := make (map [int ]agentSession )
4647+ for _ , s := range sessions {
4648+ runningSessions [s .taskID ] = s
4649+ }
4650+
4651+ // Open database for status checks and tmux ID cleanup
4652+ dbPath := db .DefaultPath ()
4653+ database , err := db .Open (dbPath )
4654+ if err != nil {
4655+ fmt .Fprintln (os .Stderr , errorStyle .Render ("Error opening database: " + err .Error ()))
4656+ os .Exit (1 )
4657+ }
4658+ defer database .Close ()
4659+
4660+ // Determine which tasks to suspend
4661+ var toSuspend []agentSession
4662+ if len (taskIDs ) > 0 {
4663+ // Suspend specific tasks
4664+ for _ , id := range taskIDs {
4665+ s , running := runningSessions [id ]
4666+ if ! running {
4667+ fmt .Fprintf (os .Stderr , "%s\n " , dimStyle .Render (fmt .Sprintf ("task-%d: no running session, skipping" , id )))
4668+ continue
4669+ }
4670+ toSuspend = append (toSuspend , s )
4671+ }
4672+ } else {
4673+ // Suspend based on status
4674+ for _ , s := range sessions {
4675+ if all {
4676+ toSuspend = append (toSuspend , s )
4677+ continue
4678+ }
4679+ // Default: only blocked tasks
4680+ task , err := database .GetTask (int64 (s .taskID ))
4681+ if err != nil || task == nil {
4682+ continue
4683+ }
4684+ if task .Status == db .StatusBlocked {
4685+ toSuspend = append (toSuspend , s )
4686+ }
4687+ }
4688+ }
4689+
4690+ if len (toSuspend ) == 0 {
4691+ if len (taskIDs ) > 0 {
4692+ fmt .Println (dimStyle .Render ("No matching running sessions found" ))
4693+ } else if all {
4694+ fmt .Println (dimStyle .Render ("No running sessions to suspend" ))
4695+ } else {
4696+ fmt .Println (dimStyle .Render ("No blocked tasks with running sessions to suspend" ))
4697+ }
4698+ return
4699+ }
4700+
4701+ // Suspend each task
4702+ fmt .Printf ("%s\n \n " , boldStyle .Render (fmt .Sprintf ("Suspending %d session(s):" , len (toSuspend ))))
4703+
4704+ totalFreedMB := 0
4705+ suspended := 0
4706+ for _ , s := range toSuspend {
4707+ // Kill the tmux window (kills the agent process)
4708+ killed := killSessionAcrossDaemons (s .taskID )
4709+
4710+ // Clear tmux references in DB (window/pane IDs are now stale)
4711+ // but preserve claude_session_id for resume capability
4712+ database .ClearTaskTmuxIDs (int64 (s .taskID ))
4713+
4714+ // Also clear daemon_session since the window is gone
4715+ database .Exec (`UPDATE tasks SET daemon_session = '' WHERE id = ?` , int64 (s .taskID ))
4716+
4717+ title := s .taskTitle
4718+ if len (title ) > 40 {
4719+ title = title [:37 ] + "..."
4720+ }
4721+
4722+ memStr := ""
4723+ if s .memoryMB > 0 {
4724+ memStr = fmt .Sprintf (" (%dMB freed)" , s .memoryMB )
4725+ totalFreedMB += s .memoryMB
4726+ }
4727+
4728+ statusIcon := "✓"
4729+ if ! killed {
4730+ statusIcon = "~"
4731+ }
4732+
4733+ fmt .Printf (" %s %s %s%s\n " ,
4734+ successStyle .Render (statusIcon ),
4735+ successStyle .Render (fmt .Sprintf ("task-%d" , s .taskID )),
4736+ dimStyle .Render (title ),
4737+ dimStyle .Render (memStr ))
4738+ suspended ++
4739+ }
4740+
4741+ fmt .Println ()
4742+ if totalFreedMB > 0 {
4743+ fmt .Println (successStyle .Render (fmt .Sprintf ("Suspended %d session(s), ~%dMB freed" , suspended , totalFreedMB )))
4744+ } else {
4745+ fmt .Println (successStyle .Render (fmt .Sprintf ("Suspended %d session(s)" , suspended )))
4746+ }
4747+ fmt .Println (dimStyle .Render ("Session IDs preserved — use 'ty retry <task-id>' to resume" ))
4748+ }
4749+
45774750// recoverStaleTmuxRefs clears stale daemon_session and tmux_window_id references
45784751// from tasks after a crash or daemon restart. This allows tasks to automatically
45794752// reconnect to their agent sessions when viewed.
0 commit comments