55 "fmt"
66 "os"
77 "os/signal"
8+ "path/filepath"
89 "sort"
910 "strconv"
1011 "strings"
@@ -28,6 +29,15 @@ import (
2829// processed sequentially, preserving session history integrity.
2930var chatMu sync.Map // map[int64]*sync.Mutex
3031
32+ // chatCancels stores per-chat cancel functions. When /stop is received, the
33+ // cancel function is called to interrupt the running agent loop.
34+ var chatCancels sync.Map // map[int64]context.CancelFunc
35+
36+ // chatRunInfos stores the latest IterationInfo for each chat, updated on
37+ // every agent loop iteration. Used by /stop to report a summary of the
38+ // interrupted task.
39+ var chatRunInfos sync.Map // map[int64]loop.IterationInfo
40+
3141// getChatMutex returns the per-chat mutex for the given chat ID.
3242func getChatMutex (chatID int64 ) * sync.Mutex {
3343 v , _ := chatMu .LoadOrStore (chatID , & sync.Mutex {})
@@ -36,6 +46,13 @@ func getChatMutex(chatID int64) *sync.Mutex {
3646
3747// telegramCmd is the entry point for "odek telegram".
3848func telegramCmd (args []string ) error {
49+ // 0. Acquire singleton lock — kill any stale previous instance.
50+ lock , err := acquireLock ()
51+ if err != nil {
52+ return fmt .Errorf ("telegram: %w" , err )
53+ }
54+ defer lock .release ()
55+
3956 // 1. Load config from all sources (file → env).
4057 resolved := config .LoadConfig (config.CLIFlags {})
4158
@@ -285,6 +302,24 @@ func telegramCmd(args []string) error {
285302 return fmt .Sprintf ("📋 *Plan loaded*: `%s`\n \n _Injected into session context. Send a message to continue._" , slug ), nil
286303 }
287304
305+ // Handle /stop — cancel the running agent task and report a summary.
306+ if cmdName == "stop" {
307+ // Cancel the running agent context, if any.
308+ if cancelVal , ok := chatCancels .LoadAndDelete (chatID ); ok {
309+ cancel := cancelVal .(context.CancelFunc )
310+ cancel ()
311+ }
312+ // Retrieve the latest run info for a summary of what was interrupted.
313+ var summary string
314+ if infoVal , ok := chatRunInfos .LoadAndDelete (chatID ); ok {
315+ info := infoVal .(loop.IterationInfo )
316+ summary = formatStopSummary (info )
317+ } else {
318+ summary = "⏹️ No active task to stop."
319+ }
320+ return summary , nil
321+ }
322+
288323 return cmd .Handler (argsStr )
289324 }
290325
@@ -467,7 +502,11 @@ func handleChatMessage(
467502 for {
468503 select {
469504 case <- ticker .C :
470- go bot .SendChatAction (chatID , "typing" )
505+ go func () {
506+ if err := bot .SendChatAction (chatID , "typing" ); err != nil {
507+ fmt .Fprintf (os .Stderr , "odek telegram: sendChatAction failed: %v\n " , err )
508+ }
509+ }()
471510 case <- typingDone :
472511 return
473512 }
@@ -585,9 +624,10 @@ func handleChatMessage(
585624 }
586625 allToolsMu .Unlock ()
587626
588- if info .HasFinalAnswer {
589- runInfo = info
590- }
627+ // Always capture the latest iteration info — used by /stop
628+ // to report a summary of the interrupted task.
629+ runInfo = info
630+ chatRunInfos .Store (chatID , info )
591631 },
592632 }
593633
@@ -598,8 +638,17 @@ func handleChatMessage(
598638 }
599639 defer agent .Close ()
600640
641+ // Create a cancellable context so /stop can interrupt the agent loop.
642+ agentCtx , agentCancel := context .WithCancel (context .Background ())
643+ chatCancels .Store (chatID , agentCancel )
644+ defer func () {
645+ agentCancel ()
646+ chatCancels .LoadAndDelete (chatID )
647+ chatRunInfos .LoadAndDelete (chatID )
648+ }()
649+
601650 // Run the agent with the full message history (multi-turn).
602- response , updatedMessages , err := agent .RunWithMessages (context . Background () , cs .Messages )
651+ response , updatedMessages , err := agent .RunWithMessages (agentCtx , cs .Messages )
603652 if err != nil {
604653 reportError (bot , chatID , "Agent error: " + err .Error ())
605654 return
@@ -710,10 +759,108 @@ func formatTelegramStats(info loop.IterationInfo, toolList []string) string {
710759 )
711760}
712761
762+ // formatStopSummary formats a summary of an interrupted task for the /stop
763+ // command response. It includes turns completed, tokens consumed, tools used,
764+ // and total wall-clock time before cancellation.
765+ func formatStopSummary (info loop.IterationInfo ) string {
766+ toolStr := "none"
767+ if len (info .ToolNames ) > 0 {
768+ // Deduplicate and sort tool names for a clean display.
769+ seen := make (map [string ]struct {}, len (info .ToolNames ))
770+ unique := make ([]string , 0 , len (info .ToolNames ))
771+ for _ , name := range info .ToolNames {
772+ if _ , ok := seen [name ]; ! ok {
773+ seen [name ] = struct {}{}
774+ unique = append (unique , name )
775+ }
776+ }
777+ sort .Strings (unique )
778+ toolStr = strings .Join (unique , ", " )
779+ }
780+
781+ latency := info .TotalLatency .Truncate (time .Second )
782+ iters := fmt .Sprintf ("%d turn" , info .Turn )
783+ if info .Turn != 1 {
784+ iters += "s"
785+ }
786+
787+ return fmt .Sprintf (
788+ "⏹️ *Task Interrupted*\n \n " +
789+ "%s · %d in / %d out · %s — tools: %s" ,
790+ iters , info .InputTokens , info .OutputTokens , latency .String (), toolStr ,
791+ )
792+ }
793+
713794// reportError sends an error message to the given chat and logs to stderr.
714795func reportError (bot * telegram.Bot , chatID int64 , msg string ) {
715796 fmt .Fprintf (os .Stderr , "odek telegram: %s\n " , msg )
716797 if _ , err := bot .SendMessage (chatID , "❌ " + msg , nil ); err != nil {
717798 fmt .Fprintf (os .Stderr , "odek telegram: send error message: %v\n " , err )
718799 }
719800}
801+
802+ // ── Singleton Lock ─────────────────────────────────────────────────────
803+ //
804+ // Prevents two bot instances from polling Telegram simultaneously (which
805+ // causes 409 Conflict errors). Uses a PID file at ~/.odek/telegram.pid.
806+ // On startup, if a stale PID file exists, the old process is killed before
807+ // the new one starts.
808+
809+ type instanceLock struct {
810+ pidFile string
811+ }
812+
813+ // acquireLock reads any existing PID file, kills the old process if still
814+ // alive, then writes the current PID. Returns the lock for deferred release.
815+ func acquireLock () (* instanceLock , error ) {
816+ home , err := os .UserHomeDir ()
817+ if err != nil {
818+ return nil , fmt .Errorf ("home dir: %w" , err )
819+ }
820+ pidFile := filepath .Join (home , ".odek" , "telegram.pid" )
821+
822+ // Ensure parent dir exists.
823+ if err := os .MkdirAll (filepath .Dir (pidFile ), 0755 ); err != nil {
824+ return nil , fmt .Errorf ("mkdir pid: %w" , err )
825+ }
826+
827+ // Read stale PID and kill it.
828+ if data , err := os .ReadFile (pidFile ); err == nil {
829+ oldPID := strings .TrimSpace (string (data ))
830+ if oldPID != "" {
831+ // Check if it's an odek telegram process.
832+ procPath := filepath .Join ("/proc" , oldPID , "cmdline" )
833+ if cmdline , err := os .ReadFile (procPath ); err == nil {
834+ if strings .Contains (string (cmdline ), "odek" ) &&
835+ strings .Contains (string (cmdline ), "telegram" ) {
836+ pid , _ := strconv .Atoi (oldPID )
837+ if pid > 1 {
838+ fmt .Fprintf (os .Stderr , "odek telegram: killing stale instance (PID %d)\n " , pid )
839+ syscall .Kill (pid , syscall .SIGTERM )
840+ // Wait up to 5s for graceful shutdown.
841+ for i := 0 ; i < 50 ; i ++ {
842+ time .Sleep (100 * time .Millisecond )
843+ if err := syscall .Kill (pid , 0 ); err != nil {
844+ break // process gone
845+ }
846+ }
847+ // Force kill if still alive.
848+ syscall .Kill (pid , syscall .SIGKILL )
849+ }
850+ }
851+ }
852+ }
853+ }
854+
855+ // Write our PID.
856+ if err := os .WriteFile (pidFile , []byte (strconv .Itoa (os .Getpid ())+ "\n " ), 0644 ); err != nil {
857+ return nil , fmt .Errorf ("write pid: %w" , err )
858+ }
859+
860+ return & instanceLock {pidFile : pidFile }, nil
861+ }
862+
863+ // release removes the PID file on clean shutdown.
864+ func (l * instanceLock ) release () {
865+ os .Remove (l .pidFile )
866+ }
0 commit comments