v0.25.0 c3476a8

schedule.go RawHistoryFolder

1package tools
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"fmt"
8	"io"
9	"strings"
10	"time"
11
12	"github.com/bnema/zut/packages/agent/scheduler"
13	"github.com/bnema/zut/packages/core"
14	"github.com/bnema/zut/packages/provider"
15)
16
17const scheduleToolSchema = `{"type":"object","properties":{"action":{"type":"string","enum":["add","list","cancel"],"description":"Scheduling operation."},"cron":{"type":"string","description":"Five-field cron expression, for example '0 9 * * 1-5'. Required for add."},"message":{"type":"string","description":"Prompt to run in this session when due. Required for add."},"id":{"type":"string","description":"Task ID. Required for cancel."}},"required":["action"],"additionalProperties":false}`
18
19// ScheduleToolName is the stable built-in scheduler tool name.
20const ScheduleToolName = "schedule"
21
22// ScheduleTool manages process-lifetime scheduled follow-up prompts. Its
23// host-provided session callback prevents an agent from selecting another
24// session as a task target.
25type ScheduleTool struct {
26	Engine    *scheduler.Engine
27	SessionID func() string
28	Location  func() *time.Location
29}
30
31type scheduleToolArgs struct {
32	Action  string `json:"action"`
33	Cron    string `json:"cron,omitempty"`
34	Message string `json:"message,omitempty"`
35	ID      string `json:"id,omitempty"`
36}
37
38func (t *ScheduleTool) Name() string { return ScheduleToolName }
39
40func (t *ScheduleTool) Description() string {
41	return "Create, list, or cancel in-process cron follow-ups for the current session. Scheduled work runs only while this zut process remains open."
42}
43
44func (t *ScheduleTool) Schema() json.RawMessage { return json.RawMessage(scheduleToolSchema) }
45
46func (t *ScheduleTool) Execute(_ context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
47	if t == nil || t.Engine == nil || t.SessionID == nil {
48		return scheduleToolError("scheduler is unavailable"), nil
49	}
50	var args scheduleToolArgs
51	decoder := json.NewDecoder(bytes.NewReader(raw))
52	decoder.DisallowUnknownFields()
53	if err := decoder.Decode(&args); err != nil {
54		return scheduleToolError("invalid arguments"), nil
55	}
56	if err := decoder.Decode(&struct{}{}); err != io.EOF {
57		return scheduleToolError("invalid arguments"), nil
58	}
59	args.Action = strings.TrimSpace(args.Action)
60	args.Cron = strings.TrimSpace(args.Cron)
61	args.Message = strings.TrimSpace(args.Message)
62	args.ID = strings.TrimSpace(args.ID)
63
64	switch args.Action {
65	case "add":
66		if args.Cron == "" || args.Message == "" {
67			return scheduleToolError("cron and message are required when adding a task"), nil
68		}
69		location := time.Local
70		if t.Location != nil && t.Location() != nil {
71			location = t.Location()
72		}
73		task, err := t.Engine.Add(scheduler.NewTaskInput{
74			SessionID: t.SessionID(),
75			Cron:      args.Cron,
76			Message:   args.Message,
77			Location:  location,
78		})
79		if err != nil {
80			return scheduleToolError(err.Error()), nil
81		}
82		return scheduleToolResult(fmt.Sprintf("scheduled %s for %s (%s)", task.ID, task.NextRun.Format(time.RFC3339), task.Timezone))
83	case "list":
84		tasks := t.Engine.List()
85		currentSession := t.SessionID()
86		lines := make([]string, 0, len(tasks))
87		for _, task := range tasks {
88			if task.SessionID != currentSession {
89				continue
90			}
91			line := fmt.Sprintf("%s  %s  next %s", task.ID, task.Cron, task.NextRun.Format(time.RFC3339))
92			if task.LastError != "" {
93				line += "  last error: " + task.LastError
94			}
95			lines = append(lines, line)
96		}
97		if len(lines) == 0 {
98			return scheduleToolResult("no scheduled tasks for this session")
99		}
100		return scheduleToolResult(strings.Join(lines, "\n"))
101	case "cancel":
102		if args.ID == "" {
103			return scheduleToolError("id is required when cancelling a task"), nil
104		}
105		task, ok := t.Engine.Get(args.ID)
106		if !ok || task.SessionID != t.SessionID() {
107			return scheduleToolError("scheduled task not found in this session"), nil
108		}
109		t.Engine.Cancel(args.ID)
110		return scheduleToolResult("cancelled scheduled task " + args.ID)
111	default:
112		return scheduleToolError("action must be add, list, or cancel"), nil
113	}
114}
115
116func scheduleToolResult(text string) (core.ToolResult, error) {
117	return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}}, nil
118}
119
120func scheduleToolError(text string) core.ToolResult {
121	return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, IsError: true}
122}

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