v0.2.65 2bf9508

botcmd.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close