detached a9d674e

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/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}

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