@@ -20,6 +20,7 @@ import (
2020 "github.com/BackendStack21/kode/internal/render"
2121 "github.com/BackendStack21/kode/internal/session"
2222 "github.com/BackendStack21/kode/internal/telegram"
23+ toolpkg "github.com/BackendStack21/kode/internal/tool"
2324)
2425
2526// chatMu serializes agent processing per chat to prevent same-chat message
@@ -197,30 +198,103 @@ func telegramCmd(args []string) error {
197198 ), nil
198199 }
199200
200- // Handle /prune [days] — clean up old sessions.
201+ // Handle /prune [days] — clean up old sessions and plans .
201202 if cmdName == "prune" {
202203 days := 30
203204 if strings .TrimSpace (argsStr ) != "" {
204205 if d , err := strconv .Atoi (strings .TrimSpace (argsStr )); err == nil && d > 0 {
205206 days = d
206207 } else {
207- return "❗ Usage: `/prune [days]`\n \n Example: `/prune 7` to remove sessions older than 7 days." , nil
208+ return "❗ Usage: `/prune [days]`\n \n Example: `/prune 7` to remove sessions and plans older than 7 days." , nil
208209 }
209210 }
210- removed , err := sessionManager .PruneSessions (days )
211+ sessionsRemoved , err := sessionManager .PruneSessions (days )
211212 if err != nil {
212213 return fmt .Sprintf ("❌ Failed to prune sessions: %v" , err ), nil
213214 }
214- if removed == 0 {
215- return fmt .Sprintf ("📋 *Prune* — No sessions older than %d days found." , days ), nil
215+ plansRemoved , err := sessionManager .PrunePlans (days )
216+ if err != nil {
217+ return fmt .Sprintf ("❌ Failed to prune plans: %v" , err ), nil
218+ }
219+ total := sessionsRemoved + plansRemoved
220+ if total == 0 {
221+ return fmt .Sprintf ("📋 *Prune* — Nothing older than %d days found." , days ), nil
222+ }
223+ var b strings.Builder
224+ b .WriteString (fmt .Sprintf ("🧹 *Pruned* — Removed items older than %d days:\n \n " , days ))
225+ if sessionsRemoved > 0 {
226+ b .WriteString (fmt .Sprintf ("• %d session(s)\n " , sessionsRemoved ))
216227 }
217- return fmt .Sprintf ("🧹 *Pruned* — Removed %d session(s) older than %d days." , removed , days ), nil
228+ if plansRemoved > 0 {
229+ b .WriteString (fmt .Sprintf ("• %d plan(s)\n " , plansRemoved ))
230+ }
231+ return b .String (), nil
232+ }
233+
234+ // Handle /plan <description> — dispatch to agent for plan generation.
235+ if cmdName == "plan" {
236+ description := strings .TrimSpace (argsStr )
237+ if description == "" {
238+ return "❗ Usage: `/plan <description>`\n \n Example: `/plan Add user authentication with OAuth2`" , nil
239+ }
240+ slug := telegram .Slugify (description )
241+ prompt := fmt .Sprintf (
242+ "Create a detailed implementation plan for: %s\n \n " +
243+ "Save the plan as a markdown file to `~/.odek/plans/%s.md`. " +
244+ "The plan should include:\n " +
245+ "- Overview and goals\n " +
246+ "- Architecture / design\n " +
247+ "- Implementation steps (bite-sized tasks)\n " +
248+ "- File paths and key code locations\n " +
249+ "- Testing strategy\n \n " +
250+ "Use your write_file tool to save the plan." ,
251+ description , slug ,
252+ )
253+ go handleChatMessage (chatID , prompt , bot , handler , sessionManager ,
254+ resolved , systemMessage , handlerLog )
255+ return fmt .Sprintf ("📝 *Planning* `%s`…\n \n _Generating plan for: %s_" , slug , description ), nil
256+ }
257+
258+ // Handle /plan-resume — inject most recent plan into session context.
259+ if cmdName == "plan_resume" {
260+ slug , content , err := telegram .MostRecentPlan ()
261+ if err != nil {
262+ return fmt .Sprintf ("❌ %v" , err ), nil
263+ }
264+ // Inject the plan as a system-level context message.
265+ cs , err := sessionManager .GetOrCreate (chatID )
266+ if err != nil {
267+ return fmt .Sprintf ("❌ Failed to get session: %v" , err ), nil
268+ }
269+ contextMsg := fmt .Sprintf (
270+ "[Plan loaded: %s]\n \n %s\n \n ---\n Continue working on this plan. " +
271+ "Use your tools to implement the next step." ,
272+ slug , content ,
273+ )
274+ cs .Messages = append (cs .Messages , llm.Message {Role : "user" , Content : contextMsg })
275+ cs .LastActive = time .Now ()
276+ if err := sessionManager .Save (chatID , cs .Messages ); err != nil {
277+ return fmt .Sprintf ("❌ Failed to save session: %v" , err ), nil
278+ }
279+ return fmt .Sprintf ("📋 *Plan loaded*: `%s`\n \n _Injected into session context. Send a message to continue._" , slug ), nil
218280 }
219281
220282 return cmd .Handler (argsStr )
221283 }
222284
223285 handler .OnCallbackQuery = func (chatID int64 , data string ) (string , error ) {
286+ // Route clarify callbacks — the user clicked Yes/No on a clarify question.
287+ if strings .HasPrefix (data , "clarify:" ) {
288+ answer := strings .TrimPrefix (data , "clarify:" )
289+ if ch , ok := sessionManager .GetClarifyChannel (chatID ); ok {
290+ select {
291+ case ch <- answer :
292+ default :
293+ // Channel full or closed — clarify already resolved.
294+ }
295+ }
296+ return "✅ Got it, thanks!" , nil
297+ }
224298 return "" , nil // approval callbacks are routed by the approver
225299 }
226300
@@ -362,7 +436,8 @@ func handleChatMessage(
362436
363437 // ── Typing Indicator ────────────────────────────────────────────
364438 // Send "typing" action every 4s while the agent runs (Telegram shows
365- // it for ~5s). Stops when the goroutine's context is cancelled.
439+ // it for ~5s). Fire-and-forget so a hanging HTTP call doesn't block
440+ // the ticker and permanently stop the indicator.
366441 typingDone := make (chan struct {})
367442 defer close (typingDone )
368443 go func () {
@@ -371,7 +446,7 @@ func handleChatMessage(
371446 for {
372447 select {
373448 case <- ticker .C :
374- bot .SendChatAction (chatID , "typing" )
449+ go bot .SendChatAction (chatID , "typing" )
375450 case <- typingDone :
376451 return
377452 }
@@ -399,6 +474,41 @@ func handleChatMessage(
399474 var allToolsMu sync.Mutex
400475 allTools := make (map [string ]int )
401476
477+ // ── Clarify Tool ───────────────────────────────────────────────
478+ // Wire the clarify tool with a Telegram-native answer function.
479+ // When the agent calls clarify(question), the bot sends an inline
480+ // keyboard message and blocks until the user responds.
481+ agentTools := append ([]odek.Tool {}, tools ... )
482+ agentTools = append (agentTools , toolpkg .NewClarifyTool (func (question string ) (string , error ) {
483+ ch := make (chan string , 1 )
484+ sessionManager .SetClarifyChannel (chatID , ch )
485+ defer sessionManager .DeleteClarifyChannel (chatID )
486+
487+ // Send the question with Yes/No buttons.
488+ replyMarkup := & telegram.InlineKeyboardMarkup {
489+ InlineKeyboard : [][]telegram.InlineKeyboardButton {
490+ {
491+ {Text : "Yes" , CallbackData : "clarify:yes" },
492+ {Text : "No" , CallbackData : "clarify:no" },
493+ },
494+ },
495+ }
496+ if _ , err := bot .SendMessage (chatID , "❓ " + question ,
497+ & telegram.SendOpts {ReplyMarkup : replyMarkup , ParseMode : "Markdown" }); err != nil {
498+ return "" , fmt .Errorf ("clarify: send message: %w" , err )
499+ }
500+
501+ // Wait for the user to click a button (or timeout).
502+ select {
503+ case answer := <- ch :
504+ return answer , nil
505+ case <- time .After (10 * time .Minute ):
506+ return "" , fmt .Errorf ("clarify: timed out waiting for response" )
507+ case <- typingDone :
508+ return "" , fmt .Errorf ("clarify: task cancelled by /stop" )
509+ }
510+ }))
511+
402512 agentCfg := odek.Config {
403513 Model : resolved .Model ,
404514 BaseURL : resolved .BaseURL ,
@@ -407,7 +517,7 @@ func handleChatMessage(
407517 SystemMessage : systemMessage ,
408518 NoProjectFile : resolved .NoAgents ,
409519 Thinking : resolved .Thinking ,
410- Tools : tools ,
520+ Tools : agentTools ,
411521 Renderer : rend ,
412522 ToolEventHandler : func (event string , name string , data string ) {
413523 traceMu .Lock ()
0 commit comments