v0.3.59 e54d007

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

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 477

↑↓ moveenter openesc close