1package agent
2
3import (
4 "bufio"
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "os/exec"
11 "os/signal"
12 "path/filepath"
13 "strings"
14 "syscall"
15 "time"
16
17 "github.com/bnema/zut/packages/agent/modes/bot"
18 "github.com/bnema/zut/packages/agent/modes/telegram"
19 "github.com/bnema/zut/packages/agent/subagents"
20 "github.com/bnema/zut/packages/core"
21 "github.com/bnema/zut/packages/provider"
22 "golang.org/x/term"
23)
24
25// detachChild configures cmd to run in its own process group so tty
26// signals sent to the parent (SIGINT, SIGHUP on logout) don't also
27// reach the detached bot. Platform-specific: setsid on unix, a noop
28// on windows (Go's spawn path already detaches when no console is
29// inherited). See botcmd_unix.go and botcmd_windows.go.
30var detachChild func(cmd *exec.Cmd)
31
32// runBotCommand dispatches `zut <protocol>-bot ...` subcommands via
33// the botSpec registry. Returns true if rawArgs begins with a
34// recognised subcommand, false otherwise. The short alias "tg" (and
35// "mx" for matrix) are also accepted.
36func runBotCommand(rawArgs []string, version string) (handled bool, err error) {
37 if len(rawArgs) == 0 {
38 return false, nil
39 }
40 spec := specFor(rawArgs[0])
41 if spec == nil {
42 return false, nil
43 }
44 sub := ""
45 var tail []string
46 if len(rawArgs) >= 2 {
47 sub = rawArgs[1]
48 tail = rawArgs[2:]
49 }
50 switch sub {
51 case "", "help", "-h", "--help":
52 spec.printHelp()
53 return true, nil
54 case "setup":
55 return true, spec.setup(tail)
56 case "status":
57 return true, spec.status()
58 case "reset":
59 return true, spec.reset()
60 case "run":
61 return true, botRun(spec, tail, version)
62 case "start":
63 return true, botStart(spec, tail)
64 case "stop":
65 return true, botStop(spec)
66 case "logs":
67 return true, botLogs(spec, tail)
68 default:
69 spec.printHelp()
70 return true, fmt.Errorf("unknown %s subcommand %q", spec.subcommand, sub)
71 }
72}
73
74// botStart launches `zut <spec.subcommand> run` as a detached child
75// process, writes its pid to the spec's pid file, and returns
76// immediately. Stdout/stderr of the child are redirected to the spec's
77// log file.
78func botStart(spec *botSpec, rawTail []string) error {
79 // Refuse to start if another bot is already running.
80 if pid, alive, _ := bot.IsRunningAt(spec.pidPath(ZutHome())); alive {
81 return fmt.Errorf("%s is already running (pid %d); use `zut %s stop` first", spec.name, pid, spec.subcommand)
82 }
83 _ = bot.RemovePIDFile(spec.pidPath(ZutHome())) // clear any stale pid file
84
85 if ok, cerr := spec.configured(ZutHome()); cerr != nil {
86 return cerr
87 } else if !ok {
88 return fmt.Errorf("%s is not configured — run `zut %s setup` first", spec.name, spec.subcommand)
89 }
90
91 self, err := os.Executable()
92 if err != nil {
93 return fmt.Errorf("locate zut binary: %w", err)
94 }
95
96 logPath := spec.logPath(ZutHome())
97 if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
98 return err
99 }
100 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
101 if err != nil {
102 return fmt.Errorf("open log file: %w", err)
103 }
104 defer logFile.Close()
105
106 // Refuse to start from a `go run` temp binary: Go deletes the
107 // binary when `go run` exits, which breaks the detached child.
108 // Users hit cryptic tls / exec errors on that path; fail clearly.
109 if strings.Contains(self, string(os.PathSeparator)+"go-build") ||
110 strings.Contains(self, string(os.PathSeparator)+"go-tmp") {
111 return fmt.Errorf("detected `go run` temp binary at %s — run `make install` (or copy ./bin/zut to your PATH) and use the installed binary for `start`", self)
112 }
113
114 // Child argv: same flags the user passed to `zut <sub> start`,
115 // mapped to `zut <sub> run`. Preserves --provider, --model, --cwd, etc.
116 args := append([]string{spec.subcommand, "run"}, rawTail...)
117 cmd := exec.Command(self, args...)
118 cmd.Stdout = logFile
119 cmd.Stderr = logFile
120 cmd.Stdin = nil
121 // Detach: new session / new process group so terminal signals
122 // don't reach the child. Impl lives in botcmd_unix.go /
123 // botcmd_windows.go because Setsid is posix-only.
124 detachChild(cmd)
125
126 if err := cmd.Start(); err != nil {
127 return fmt.Errorf("spawn: %w", err)
128 }
129 if err := bot.WritePIDFile(spec.pidPath(ZutHome()), cmd.Process.Pid); err != nil {
130 _ = cmd.Process.Kill()
131 return fmt.Errorf("write pid: %w", err)
132 }
133 // Don't wait() — detach. OS will reparent the child to init when we exit.
134 go func() { _ = cmd.Process.Release() }()
135
136 fmt.Printf("started zut %s as pid %d (logs: %s)\n", spec.subcommand, cmd.Process.Pid, logPath)
137 fmt.Printf("use `zut %s logs -f` to tail, `zut %s stop` to stop.\n", spec.subcommand, spec.subcommand)
138 return nil
139}
140
141// botStop sends SIGTERM to the running bot (SIGKILL if it doesn't
142// exit within 5s) and cleans up the pid file.
143func botStop(spec *botSpec) error {
144 pid, alive, err := bot.IsRunningAt(spec.pidPath(ZutHome()))
145 if err != nil {
146 return err
147 }
148 if !alive {
149 if pid > 0 {
150 _ = bot.RemovePIDFile(spec.pidPath(ZutHome()))
151 fmt.Printf("no live process; cleared stale pid %d\n", pid)
152 return nil
153 }
154 fmt.Printf("%s is not running\n", spec.name)
155 return nil
156 }
157 if err := bot.StopProcess(pid, 5*time.Second); err != nil {
158 return fmt.Errorf("stop pid %d: %w", pid, err)
159 }
160 _ = bot.RemovePIDFile(spec.pidPath(ZutHome()))
161 fmt.Printf("stopped pid %d\n", pid)
162 return nil
163}
164
165// botLogs prints (or tails with --follow) the bot log file.
166func botLogs(spec *botSpec, rawTail []string) error {
167 follow := false
168 for _, a := range rawTail {
169 if a == "-f" || a == "--follow" {
170 follow = true
171 }
172 }
173 p := spec.logPath(ZutHome())
174 f, err := os.Open(p)
175 if errors.Is(err, os.ErrNotExist) {
176 fmt.Println("no log file at", p)
177 return nil
178 }
179 if err != nil {
180 return err
181 }
182 defer f.Close()
183
184 if _, err := io.Copy(os.Stdout, f); err != nil {
185 return err
186 }
187 if !follow {
188 return nil
189 }
190
191 // Naive tail -f: poll for new bytes until ctrl+c.
192 sigc := make(chan os.Signal, 1)
193 signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
194 defer signal.Stop(sigc)
195 for {
196 select {
197 case <-sigc:
198 return nil
199 case <-time.After(500 * time.Millisecond):
200 if _, err := io.Copy(os.Stdout, f); err != nil {
201 return err
202 }
203 }
204 }
205}
206
207// botRun starts the polling loop in the foreground. Ctrl+C stops it.
208func botRun(spec *botSpec, rawTail []string, version string) (runErr error) {
209 // Parse only a small subset of flags relevant to bot run. We reuse
210 // the main args parser so --provider/--model/--cwd/--api-key/--reasoning
211 // behave the same as in the tui.
212 args, err := ParseArgs(rawTail)
213 if err != nil {
214 return err
215 }
216 // Messages arrive from an external channel without a per-request
217 // confirmation surface, so bot runs never receive native web search.
218 args.WebSearchPolicy = subagents.WebSearchDeny
219
220 // Bot mode always requires credentials (can't pop a /login dialog).
221 resolved, err := Resolve(args, true)
222 if err != nil {
223 return err
224 }
225 defer closeResolvedLSP(resolved)
226
227 if ok, cerr := spec.configured(ZutHome()); cerr != nil {
228 return cerr
229 } else if !ok {
230 return fmt.Errorf("%s is not configured — run `zut %s setup` first", spec.name, spec.subcommand)
231 }
232 adapter, err := spec.newAdapter(ZutHome())
233 if err != nil {
234 return err
235 }
236
237 agent := resolved.NewAgent()
238
239 // Session: optional, same model as the tui. Persist so DMs build on
240 // prior context. --no-session disables.
241 var sess *core.Session
242 if !args.NoSess {
243 s, _, serr := openOrCreateSessionForBot(args, resolved, agent, version)
244 if serr == nil {
245 sess = s
246 agent.OnMessageAppended = func(msg provider.Message) {
247 _ = sess.AppendMessage(msg)
248 }
249 agent.OnUsage = func(u provider.Usage) {
250 _ = sess.AppendUsage(u, u)
251 }
252 agent.OnRetryLifecycle = sess.RecordRetryLifecycle
253 agent.OnTranscriptCompacted = func(msgs []provider.Message) {
254 _ = sess.AppendCompaction(msgs)
255 }
256 defer func() {
257 runErr = errors.Join(runErr, sess.FlushRetryLifecycle(agent.LastTurnUsage(), agent.Cost()))
258 runErr = joinSessionCloseError(runErr, sess)
259 }()
260 } else {
261 fmt.Fprintln(os.Stderr, "session:", serr)
262 }
263 }
264
265 var runner *bot.Runner
266 runner = bot.NewRunner(adapter, agent, bot.Config{
267 ZutHome: ZutHome(),
268 Provider: resolved.Provider,
269 Model: resolved.Model,
270 AuthMethod: resolved.AuthMethod,
271 CWD: args.CWD,
272 RefreshCreds: func() error {
273 // Re-run the same resolver the tui uses so we pick up
274 // refreshed oauth tokens, re-logins, and model switches.
275 // Only the provider client is swapped — tools, sandbox,
276 // system prompt, and transcript stay with the existing agent.
277 next, err := Resolve(args, true)
278 if err != nil {
279 return err
280 }
281 defer closeResolvedLSP(next)
282 agent.Client = next.NewClient()
283 applyResolvedModel(agent, next)
284 runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)
285 return nil
286 },
287 })
288
289 // Record our pid so `zut <sub> status` / `zut <sub> stop` can find us,
290 // regardless of whether we were started directly or via `start`.
291 _ = bot.WritePIDFile(spec.pidPath(ZutHome()), os.Getpid())
292 defer bot.RemovePIDFile(spec.pidPath(ZutHome()))
293
294 // Translate sigterm/sigint into a context cancel so the bot's goroutines
295 // and the currently-running turn wind down cleanly.
296 ctx, cancel := context.WithCancel(context.Background())
297 sigc := make(chan os.Signal, 1)
298 signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
299 go func() {
300 <-sigc
301 cancel()
302 }()
303 defer cancel()
304 return runner.Run(ctx)
305}
306
307// openOrCreateSessionForBot reuses the same logic as interactive mode
308// but never prompts (no TTY picker); falls back to latest or new.
309func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, []any, error) {
310 if args.Continue {
311 if latest := core.LatestSession(ZutHome(), args.CWD); latest != "" {
312 s, msgs, err := core.OpenSession(latest)
313 if err != nil {
314 return nil, nil, err
315 }
316 ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
317 ag.SetMessages(msgs)
318 // Seed the agent-owned plan from the resumed session so show and indexed
319 // updates see the persisted checklist instead of empty core state.
320 ag.SetPlan(s.Meta.PlanSteps())
321 // Route tool-result commits through the session so plan (and goal)
322 // mutations the bot makes are durable, matching the other modes.
323 ag.CommitToolResult = func(_ string, result core.ToolResult) error {
324 return persistToolResultState(nil, s, result)
325 }
326 return s, nil, nil
327 }
328 }
329 s, err := core.NewSession(ZutHome(), args.CWD, r.Provider, r.Model, version)
330 if err == nil {
331 ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
332 // Route tool-result commits through the session so plan (and goal)
333 // mutations the bot makes are durable, matching the other modes.
334 ag.CommitToolResult = func(_ string, result core.ToolResult) error {
335 return persistToolResultState(nil, s, result)
336 }
337 }
338 return s, nil, err
339}
340
341// maskToken returns "123456:ABC...xyz" so copies of zut telegram-bot status can be
342// pasted into bug reports without leaking the full token.
343func maskToken(tok string) string {
344 if len(tok) <= 10 {
345 return "<hidden>"
346 }
347 // telegram tokens look like "123456789:ABCD..." — keep the id, mask the body.
348 i := strings.IndexByte(tok, ':')
349 if i < 0 {
350 return tok[:4] + "..." + tok[len(tok)-4:]
351 }
352 body := tok[i+1:]
353 if len(body) < 8 {
354 return tok[:i+1] + "<hidden>"
355 }
356 return tok[:i+1] + body[:3] + "..." + body[len(body)-3:]
357}
358
359// telegramSpec wires the existing Telegram setup/status/reset flows
360// into the generic dispatcher. Bodies are the pre-refactor botSetup,
361// botStatus, botReset, printBotHelp — unchanged.
362func telegramSpec() *botSpec {
363 return &botSpec{
364 name: "telegram",
365 subcommand: "telegram-bot",
366 aliases: []string{"tg"},
367 pidPath: telegram.PIDPath,
368 logPath: telegram.LogPath,
369 configured: func(zutHome string) (bool, error) {
370 cfg, err := telegram.LoadConfig(zutHome)
371 if err != nil {
372 return false, err
373 }
374 return cfg.BotToken != "", nil
375 },
376 printHelp: printTelegramBotHelp,
377 setup: telegramBotSetup,
378 status: telegramBotStatus,
379 reset: telegramBotReset,
380 newAdapter: func(zutHome string) (bot.BotAdapter, error) {
381 cfg, err := telegram.LoadConfig(zutHome)
382 if err != nil {
383 return nil, err
384 }
385 return telegram.NewAdapter(
386 telegram.NewClient(cfg.BotToken),
387 &cfg,
388 func(c telegram.Config) error { return telegram.SaveConfig(zutHome, c) },
389 ), nil
390 },
391 }
392}
393
394// printTelegramBotHelp prints usage for `zut telegram-bot`.
395func printTelegramBotHelp() {
396 fmt.Fprint(os.Stderr, `zut telegram-bot — telegram bridge
397
398usage:
399 zut telegram-bot setup paste a BotFather token, verify, save
400 zut telegram-bot status show bridge config and whether it's running
401 zut telegram-bot run [flags] run in the foreground (ctrl+c to stop)
402 zut telegram-bot start [flags] launch in background, detach, return immediately
403 zut telegram-bot stop sigterm the running background bot, sigkill if needed
404 zut telegram-bot logs [--follow] tail the background bot's log file
405 zut telegram-bot reset forget token + allowed user
406
407setup flow:
408 1. talk to @BotFather on telegram, /newbot, copy the token
409 2. run "zut telegram-bot setup" and paste the token
410 3. run "zut telegram-bot start" (background) or "zut telegram-bot run" (foreground)
411 4. send /start to your bot from telegram; the first sender claims it
412
413while the bot is running, dm it anything and the message is forwarded
414to the agent the same way it would be from the tui. image attachments
415(photos or image/* documents) are passed to vision-capable models.
416telegram commands the bot handles directly: /help, /status, /stop.
417
418config & state:
419 $ZUT_HOME/bot.json # bot token + paired user (mode 0600)
420 $ZUT_HOME/bot.pid # pid of the running bot (written by run/start)
421 $ZUT_HOME/logs/bot.log # stdout+stderr from "zut telegram-bot start"
422`)
423}
424
425var (
426 botTokenIsTerminal = term.IsTerminal
427 botTokenReadPassword = term.ReadPassword
428)
429
430func readBotToken(in *os.File, out io.Writer) (string, error) {
431 if botTokenIsTerminal(int(in.Fd())) {
432 value, err := botTokenReadPassword(int(in.Fd()))
433 fmt.Fprintln(out)
434 if err != nil {
435 return "", fmt.Errorf("read telegram bot token: %w", err)
436 }
437 return string(value), nil
438 }
439
440 value, err := bufio.NewReader(in).ReadString('\n')
441 if err != nil && !errors.Is(err, io.EOF) {
442 return "", fmt.Errorf("read telegram bot token: %w", err)
443 }
444 return value, nil
445}
446
447// telegramBotSetup interactively reads a bot token without echoing it on a
448// terminal, verifies it via getMe, and saves it.
449func telegramBotSetup(_ []string) error {
450 cfg, err := telegram.LoadConfig(ZutHome())
451 if err != nil {
452 return err
453 }
454
455 fmt.Print("telegram bot token (from @BotFather): ")
456 line, err := readBotToken(os.Stdin, os.Stdout)
457 if err != nil {
458 return err
459 }
460 token := strings.TrimSpace(line)
461 if token == "" {
462 return fmt.Errorf("no token provided")
463 }
464
465 client := telegram.NewClient(token)
466 me, err := client.GetMe(context.Background())
467 if err != nil {
468 return fmt.Errorf("token rejected by telegram: %w", err)
469 }
470 cfg.BotToken = token
471 cfg.BotUsername = me.Username
472 cfg.BotID = me.ID
473 // Any stored pairing might be for a different bot; clear it.
474 cfg.AllowedUserID = 0
475 cfg.LastUpdateID = 0
476 if err := telegram.SaveConfig(ZutHome(), cfg); err != nil {
477 return err
478 }
479 fmt.Printf("\nsaved: @%s (id=%d) to %s\n", me.Username, me.ID, telegram.ConfigPath(ZutHome()))
480 fmt.Println("next: run `zut telegram-bot run`, then send /start to your bot from telegram.")
481 return nil
482}
483
484// telegramBotStatus prints the current bot config without the token, plus
485// whether the background process is alive.
486func telegramBotStatus() error {
487 cfg, err := telegram.LoadConfig(ZutHome())
488 if err != nil {
489 return err
490 }
491 if cfg.BotToken == "" {
492 fmt.Println("telegram: not configured (run `zut telegram-bot setup`)")
493 return nil
494 }
495 maskedTok := maskToken(cfg.BotToken)
496 fmt.Printf("telegram bot: @%s (id=%d)\n", cfg.BotUsername, cfg.BotID)
497 fmt.Printf("token: %s\n", maskedTok)
498 if cfg.AllowedUserID == 0 {
499 fmt.Println("paired with: (unpaired — send /start from telegram to claim)")
500 } else {
501 fmt.Printf("paired with: telegram user id %d\n", cfg.AllowedUserID)
502 }
503 fmt.Printf("last update: %d\n", cfg.LastUpdateID)
504 fmt.Printf("config file: %s\n", telegram.ConfigPath(ZutHome()))
505
506 pid, alive, _ := telegram.IsRunning(ZutHome())
507 switch {
508 case alive:
509 fmt.Printf("process: running (pid %d)\n", pid)
510 case pid > 0:
511 fmt.Printf("process: stopped (stale pid %d in %s)\n", pid, telegram.PIDPath(ZutHome()))
512 default:
513 fmt.Println("process: stopped")
514 }
515 logPath := telegram.LogPath(ZutHome())
516 if fi, err := os.Stat(logPath); err == nil {
517 fmt.Printf("log file: %s (%d bytes)\n", logPath, fi.Size())
518 }
519 return nil
520}
521
522// telegramBotReset wipes the on-disk bot.json entry.
523func telegramBotReset() error {
524 p := telegram.ConfigPath(ZutHome())
525 if _, err := os.Stat(p); os.IsNotExist(err) {
526 fmt.Println("no bot config to reset")
527 return nil
528 }
529 if err := os.Remove(p); err != nil {
530 return err
531 }
532 fmt.Println("removed", p)
533 return nil
534}
535
536// applyResolvedModel adopts a reloaded model's ID and limits together so a
537// model change does not keep the previous model's output budget.
538func applyResolvedModel(agent *core.Agent, next Resolved) {
539 agent.Model = next.Model
540 agent.MaxTokens = next.MaxOutput
541 if next.ContextWindow > 0 {
542 agent.ContextWindow = next.ContextWindow
543 }
544}