detached 8d00c1b

feat(agent): add in-process cron scheduler

commit
8d00c1be3f372d9542c0fa1b0546501e1b833a43
author
brice · · 2026-08-15 14:42 UTC
parent
d7aa01a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +1032 -10
M README.md +2 -1
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
99
1010 - one static binary.
1111 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.
12-- nine core built-in tools (read, write, edit, bash, create_worktree, grep, lsp, web_search, and update_goal); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries.
12+- ten core built-in tools (read, write, edit, bash, create_worktree, grep, lsp, web_search, update_goal, and schedule); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries.
1313 - three run modes (interactive tui, print, json).
1414 - built-in telegram bot.
1515 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).
@@ -255,6 +255,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
255255 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).
256256 - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
257257 - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, or mark the active goal `done` or `blocked`. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.
258+- `schedule`: add, list, or cancel recurring five-field cron prompts for the current session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot.
258259
259260 When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress.
260261
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
9 9
10 - one static binary.10 - one static binary.
11 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.11 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.
12-- nine core built-in tools (read, write, edit, bash, create_worktree, grep, lsp, web_search, and update_goal); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries.12+- ten core built-in tools (read, write, edit, bash, create_worktree, grep, lsp, web_search, update_goal, and schedule); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries.
13 - three run modes (interactive tui, print, json).13 - three run modes (interactive tui, print, json).
14 - built-in telegram bot.14 - built-in telegram bot.
15 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).15 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).
@@ -255,6 +255,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
255 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).255 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).
256 - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).256 - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
257 - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, or mark the active goal `done` or `blocked`. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.257 - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, or mark the active goal `done` or `blocked`. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.
258+- `schedule`: add, list, or cancel recurring five-field cron prompts for the current session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot.
258 259
259 When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress.260 When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress.
260 261
M packages/agent/cli.go +73 -5
@@ -19,6 +19,7 @@ import (
1919 "github.com/bnema/zut/packages/agent/extensions"
2020 "github.com/bnema/zut/packages/agent/extproto"
2121 "github.com/bnema/zut/packages/agent/modes"
22+ "github.com/bnema/zut/packages/agent/scheduler"
2223 "github.com/bnema/zut/packages/agent/skills"
2324 "github.com/bnema/zut/packages/agent/subagents"
2425 "github.com/bnema/zut/packages/agent/tools"
@@ -1484,6 +1485,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
14841485 var sessionTransitionMu sync.RWMutex
14851486 activeProvider := r.Provider
14861487 activeModel := r.Model
1488+ var sess *core.Session
1489+ scheduleEngine := scheduler.NewEngine(nil)
14871490
14881491 // Build the extension manager BEFORE the agent so we can fold
14891492 // extension-defined tools into the registry. Attach the interactive
@@ -1665,6 +1668,30 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
16651668 return a
16661669 }
16671670
1671+ scheduleTool := &tools.ScheduleTool{
1672+ Engine: scheduleEngine,
1673+ SessionID: func() string {
1674+ persistMu.Lock()
1675+ defer persistMu.Unlock()
1676+ if sess == nil {
1677+ return ""
1678+ }
1679+ return sess.ID
1680+ },
1681+ Location: func() *time.Location { return time.Local },
1682+ }
1683+ schedulerAllowed := !args.NoTools && (!args.ToolsSet && len(args.Tools) == 0 || toolListContains(args.Tools, tools.ScheduleToolName))
1684+ installSchedulerTool := func(registry core.Registry) core.Registry {
1685+ if schedulerAllowed {
1686+ registry[tools.ScheduleToolName] = scheduleTool
1687+ }
1688+ return registry
1689+ }
1690+ installSchedulerTool(r.ToolRegistry)
1691+ prepareInteractiveRegistryWithScheduler := func(registry core.Registry) core.Registry {
1692+ return prepareInteractiveRegistry(installSchedulerTool(registry))
1693+ }
1694+
16681695 var ag *core.Agent
16691696 buildAgent := func() (*core.Agent, string, string, error) {
16701697 resolved, err := Resolve(args, true)
@@ -1676,6 +1703,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
16761703 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
16771704 runtime.SetFastMode(resolved.FastMode)
16781705 resolved.MergeExtensionTools(extToolAdapter)
1706+ installSchedulerTool(resolved.ToolRegistry)
16791707 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
16801708 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
16811709 }
@@ -1698,6 +1726,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
16981726 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
16991727 runtime.SetFastMode(resolved.FastMode)
17001728 resolved.MergeExtensionTools(extToolAdapter)
1729+ installSchedulerTool(resolved.ToolRegistry)
17011730 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
17021731 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
17031732 }
@@ -1728,6 +1757,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17281757 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
17291758 runtime.SetFastMode(resolved.FastMode)
17301759 resolved.MergeExtensionTools(extToolAdapter)
1760+ installSchedulerTool(resolved.ToolRegistry)
17311761 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
17321762 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
17331763 }
@@ -1747,12 +1777,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17471777 if current == nil {
17481778 return
17491779 }
1750- if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil && iv != nil {
1780+ if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv); err != nil && iv != nil {
17511781 iv.ReportError(err)
17521782 }
17531783 })
17541784
1755- var sess *core.Session
17561785 var sessBaselineMsgs int // messages already on disk when current session opened
17571786 var sessionTitlePending bool
17581787 if !args.NoSess && ag != nil {
@@ -2476,14 +2505,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
24762505 _ = extMgr.Reload(context.Background(), 2*time.Second)
24772506 }
24782507 if current != nil {
2479- if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil {
2508+ if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv); err != nil {
24802509 iv.ReportError(err)
24812510 }
24822511 }
24832512 },
24842513 RefreshTools: func() error {
24852514 current := liveInteractiveAgent(iv, ag)
2486- webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)
2515+ webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv)
24872516 if err != nil {
24882517 if !errors.Is(err, errInteractiveAgentChanged) {
24892518 setWebSearchAvailable(false)
@@ -2496,7 +2525,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
24962525 SetWebSearchAvailable: setWebSearchAvailable,
24972526 RefreshPrompt: func() error {
24982527 current := liveInteractiveAgent(iv, ag)
2499- webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)
2528+ webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv)
25002529 if err != nil {
25012530 return err
25022531 }
@@ -2682,6 +2711,45 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
26822711 },
26832712 })
26842713 extHooks.attachInteractive(iv)
2714+ schedulerCtx, cancelScheduler := context.WithCancel(ctx)
2715+ defer cancelScheduler()
2716+ go func() {
2717+ if err := scheduleEngine.Run(schedulerCtx, func(task scheduler.Task) error {
2718+ // Keep a background session run mutually exclusive with /session
2719+ // transitions. The active target uses Interactive's distinct-turn
2720+ // ingress instead, so it never injects into an in-flight model loop.
2721+ sessionTransitionMu.RLock()
2722+ defer sessionTransitionMu.RUnlock()
2723+ persistMu.Lock()
2724+ activeTarget := sess != nil && sess.ID == task.SessionID
2725+ persistMu.Unlock()
2726+ if activeTarget {
2727+ return iv.SubmitFollowUp(schedulerCtx, task.Message)
2728+ }
2729+ // A resumed background agent must schedule any follow-up work against
2730+ // the transcript it resumed, not whichever session is visible in the
2731+ // interactive TUI while it runs.
2732+ backgroundRegistry := func(registry core.Registry) core.Registry {
2733+ if schedulerAllowed {
2734+ registry[tools.ScheduleToolName] = &tools.ScheduleTool{
2735+ Engine: scheduleEngine,
2736+ SessionID: func() string { return task.SessionID },
2737+ Location: func() *time.Location { return time.Local },
2738+ }
2739+ }
2740+ return registry
2741+ }
2742+ err := runScheduledSession(schedulerCtx, task, args, r, extMgr, backgroundRegistry)
2743+ if err != nil {
2744+ iv.Notify("scheduler", "error", fmt.Sprintf("scheduled task %s failed: %v", task.ID, err))
2745+ } else {
2746+ iv.Notify("scheduler", "success", fmt.Sprintf("scheduled task %s completed in session %s", task.ID, task.SessionID))
2747+ }
2748+ return err
2749+ }); err != nil && schedulerCtx.Err() == nil {
2750+ iv.ReportError(fmt.Errorf("scheduler stopped: %w", err))
2751+ }
2752+ }()
26852753 go func() {
26862754 <-reloadDone
26872755 for _, reloadErr := range reloadErrs {
@@ -19,6 +19,7 @@ import (
19 "github.com/bnema/zut/packages/agent/extensions"19 "github.com/bnema/zut/packages/agent/extensions"
20 "github.com/bnema/zut/packages/agent/extproto"20 "github.com/bnema/zut/packages/agent/extproto"
21 "github.com/bnema/zut/packages/agent/modes"21 "github.com/bnema/zut/packages/agent/modes"
22+ "github.com/bnema/zut/packages/agent/scheduler"
22 "github.com/bnema/zut/packages/agent/skills"23 "github.com/bnema/zut/packages/agent/skills"
23 "github.com/bnema/zut/packages/agent/subagents"24 "github.com/bnema/zut/packages/agent/subagents"
24 "github.com/bnema/zut/packages/agent/tools"25 "github.com/bnema/zut/packages/agent/tools"
@@ -1484,6 +1485,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1484 var sessionTransitionMu sync.RWMutex1485 var sessionTransitionMu sync.RWMutex
1485 activeProvider := r.Provider1486 activeProvider := r.Provider
1486 activeModel := r.Model1487 activeModel := r.Model
1488+ var sess *core.Session
1489+ scheduleEngine := scheduler.NewEngine(nil)
1487 1490
1488 // Build the extension manager BEFORE the agent so we can fold1491 // Build the extension manager BEFORE the agent so we can fold
1489 // extension-defined tools into the registry. Attach the interactive1492 // extension-defined tools into the registry. Attach the interactive
@@ -1665,6 +1668,30 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1665 return a1668 return a
1666 }1669 }
1667 1670
1671+ scheduleTool := &tools.ScheduleTool{
1672+ Engine: scheduleEngine,
1673+ SessionID: func() string {
1674+ persistMu.Lock()
1675+ defer persistMu.Unlock()
1676+ if sess == nil {
1677+ return ""
1678+ }
1679+ return sess.ID
1680+ },
1681+ Location: func() *time.Location { return time.Local },
1682+ }
1683+ schedulerAllowed := !args.NoTools && (!args.ToolsSet && len(args.Tools) == 0 || toolListContains(args.Tools, tools.ScheduleToolName))
1684+ installSchedulerTool := func(registry core.Registry) core.Registry {
1685+ if schedulerAllowed {
1686+ registry[tools.ScheduleToolName] = scheduleTool
1687+ }
1688+ return registry
1689+ }
1690+ installSchedulerTool(r.ToolRegistry)
1691+ prepareInteractiveRegistryWithScheduler := func(registry core.Registry) core.Registry {
1692+ return prepareInteractiveRegistry(installSchedulerTool(registry))
1693+ }
1694+
1668 var ag *core.Agent1695 var ag *core.Agent
1669 buildAgent := func() (*core.Agent, string, string, error) {1696 buildAgent := func() (*core.Agent, string, string, error) {
1670 resolved, err := Resolve(args, true)1697 resolved, err := Resolve(args, true)
@@ -1676,6 +1703,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1676 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)1703 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1677 runtime.SetFastMode(resolved.FastMode)1704 runtime.SetFastMode(resolved.FastMode)
1678 resolved.MergeExtensionTools(extToolAdapter)1705 resolved.MergeExtensionTools(extToolAdapter)
1706+ installSchedulerTool(resolved.ToolRegistry)
1679 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)1707 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1680 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1708 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1681 }1709 }
@@ -1698,6 +1726,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1698 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)1726 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1699 runtime.SetFastMode(resolved.FastMode)1727 runtime.SetFastMode(resolved.FastMode)
1700 resolved.MergeExtensionTools(extToolAdapter)1728 resolved.MergeExtensionTools(extToolAdapter)
1729+ installSchedulerTool(resolved.ToolRegistry)
1701 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)1730 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1702 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1731 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1703 }1732 }
@@ -1728,6 +1757,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1728 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)1757 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1729 runtime.SetFastMode(resolved.FastMode)1758 runtime.SetFastMode(resolved.FastMode)
1730 resolved.MergeExtensionTools(extToolAdapter)1759 resolved.MergeExtensionTools(extToolAdapter)
1760+ installSchedulerTool(resolved.ToolRegistry)
1731 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)1761 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1732 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1762 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1733 }1763 }
@@ -1747,12 +1777,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1747 if current == nil {1777 if current == nil {
1748 return1778 return
1749 }1779 }
1750- if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil && iv != nil {1780+ if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv); err != nil && iv != nil {
1751 iv.ReportError(err)1781 iv.ReportError(err)
1752 }1782 }
1753 })1783 })
1754 1784
1755- var sess *core.Session
1756 var sessBaselineMsgs int // messages already on disk when current session opened1785 var sessBaselineMsgs int // messages already on disk when current session opened
1757 var sessionTitlePending bool1786 var sessionTitlePending bool
1758 if !args.NoSess && ag != nil {1787 if !args.NoSess && ag != nil {
@@ -2476,14 +2505,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2476 _ = extMgr.Reload(context.Background(), 2*time.Second)2505 _ = extMgr.Reload(context.Background(), 2*time.Second)
2477 }2506 }
2478 if current != nil {2507 if current != nil {
2479- if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil {2508+ if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv); err != nil {
2480 iv.ReportError(err)2509 iv.ReportError(err)
2481 }2510 }
2482 }2511 }
2483 },2512 },
2484 RefreshTools: func() error {2513 RefreshTools: func() error {
2485 current := liveInteractiveAgent(iv, ag)2514 current := liveInteractiveAgent(iv, ag)
2486- webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)2515+ webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv)
2487 if err != nil {2516 if err != nil {
2488 if !errors.Is(err, errInteractiveAgentChanged) {2517 if !errors.Is(err, errInteractiveAgentChanged) {
2489 setWebSearchAvailable(false)2518 setWebSearchAvailable(false)
@@ -2496,7 +2525,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2496 SetWebSearchAvailable: setWebSearchAvailable,2525 SetWebSearchAvailable: setWebSearchAvailable,
2497 RefreshPrompt: func() error {2526 RefreshPrompt: func() error {
2498 current := liveInteractiveAgent(iv, ag)2527 current := liveInteractiveAgent(iv, ag)
2499- webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)2528+ webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv)
2500 if err != nil {2529 if err != nil {
2501 return err2530 return err
2502 }2531 }
@@ -2682,6 +2711,45 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2682 },2711 },
2683 })2712 })
2684 extHooks.attachInteractive(iv)2713 extHooks.attachInteractive(iv)
2714+ schedulerCtx, cancelScheduler := context.WithCancel(ctx)
2715+ defer cancelScheduler()
2716+ go func() {
2717+ if err := scheduleEngine.Run(schedulerCtx, func(task scheduler.Task) error {
2718+ // Keep a background session run mutually exclusive with /session
2719+ // transitions. The active target uses Interactive's distinct-turn
2720+ // ingress instead, so it never injects into an in-flight model loop.
2721+ sessionTransitionMu.RLock()
2722+ defer sessionTransitionMu.RUnlock()
2723+ persistMu.Lock()
2724+ activeTarget := sess != nil && sess.ID == task.SessionID
2725+ persistMu.Unlock()
2726+ if activeTarget {
2727+ return iv.SubmitFollowUp(schedulerCtx, task.Message)
2728+ }
2729+ // A resumed background agent must schedule any follow-up work against
2730+ // the transcript it resumed, not whichever session is visible in the
2731+ // interactive TUI while it runs.
2732+ backgroundRegistry := func(registry core.Registry) core.Registry {
2733+ if schedulerAllowed {
2734+ registry[tools.ScheduleToolName] = &tools.ScheduleTool{
2735+ Engine: scheduleEngine,
2736+ SessionID: func() string { return task.SessionID },
2737+ Location: func() *time.Location { return time.Local },
2738+ }
2739+ }
2740+ return registry
2741+ }
2742+ err := runScheduledSession(schedulerCtx, task, args, r, extMgr, backgroundRegistry)
2743+ if err != nil {
2744+ iv.Notify("scheduler", "error", fmt.Sprintf("scheduled task %s failed: %v", task.ID, err))
2745+ } else {
2746+ iv.Notify("scheduler", "success", fmt.Sprintf("scheduled task %s completed in session %s", task.ID, task.SessionID))
2747+ }
2748+ return err
2749+ }); err != nil && schedulerCtx.Err() == nil {
2750+ iv.ReportError(fmt.Errorf("scheduler stopped: %w", err))
2751+ }
2752+ }()
2685 go func() {2753 go func() {
2686 <-reloadDone2754 <-reloadDone
2687 for _, reloadErr := range reloadErrs {2755 for _, reloadErr := range reloadErrs {
M packages/agent/modes/interactive.go +10 -0
@@ -612,6 +612,11 @@ func encodeCompactHandoff(state compactContinuationState) json.RawMessage {
612612 return encoded
613613 }
614614
615+type scheduledFollowUp struct {
616+ text string
617+ accepted chan error
618+}
619+
615620 type Interactive struct {
616621 cfg InteractiveConfig
617622 view *tui.View
@@ -728,6 +733,11 @@ type Interactive struct {
728733 // above the status bar as "sliding in: ..." chips.
729734 queued []string
730735
736+ // scheduled holds prompt-only follow-ups injected by the in-process
737+ // scheduler. Unlike agent.QueueMessage, these always wait until the
738+ // current turn has completed before starting their own turn.
739+ scheduled []scheduledFollowUp
740+
731741 // runCtx is the top-level context passed to Run(). Follow-up turns
732742 // drained from `queued` are started against this context so they
733743 // survive past the ctx of the key event that enqueued them.
@@ -612,6 +612,11 @@ func encodeCompactHandoff(state compactContinuationState) json.RawMessage {
612 return encoded612 return encoded
613 }613 }
614 614
615+type scheduledFollowUp struct {
616+ text string
617+ accepted chan error
618+}
619+
615 type Interactive struct {620 type Interactive struct {
616 cfg InteractiveConfig621 cfg InteractiveConfig
617 view *tui.View622 view *tui.View
@@ -728,6 +733,11 @@ type Interactive struct {
728 // above the status bar as "sliding in: ..." chips.733 // above the status bar as "sliding in: ..." chips.
729 queued []string734 queued []string
730 735
736+ // scheduled holds prompt-only follow-ups injected by the in-process
737+ // scheduler. Unlike agent.QueueMessage, these always wait until the
738+ // current turn has completed before starting their own turn.
739+ scheduled []scheduledFollowUp
740+
731 // runCtx is the top-level context passed to Run(). Follow-up turns741 // runCtx is the top-level context passed to Run(). Follow-up turns
732 // drained from `queued` are started against this context so they742 // drained from `queued` are started against this context so they
733 // survive past the ctx of the key event that enqueued them.743 // survive past the ctx of the key event that enqueued them.
M packages/agent/modes/interactive_host.go +30 -0
@@ -201,6 +201,36 @@ func (i *Interactive) SubmitSlash(text string) {
201201 func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
202202 i.submitOrQueue(text, images, true)
203203 }
204+
205+// SubmitFollowUp submits a scheduler-owned prompt without interpreting shell
206+// escapes or steering an active agent loop. A busy interactive waits for its
207+// current turn to end, then starts this prompt as a distinct follow-up turn.
208+func (i *Interactive) SubmitFollowUp(ctx context.Context, text string) error {
209+ text = strings.TrimSpace(text)
210+ if text == "" {
211+ return fmt.Errorf("scheduled prompt is empty")
212+ }
213+ i.mu.Lock()
214+ if i.agent == nil {
215+ i.mu.Unlock()
216+ return fmt.Errorf("no agent running; log in first")
217+ }
218+ if i.busy {
219+ accepted := make(chan error, 1)
220+ i.scheduled = append(i.scheduled, scheduledFollowUp{text: text, accepted: accepted})
221+ i.mu.Unlock()
222+ i.invalidate()
223+ select {
224+ case err := <-accepted:
225+ return err
226+ case <-ctx.Done():
227+ return ctx.Err()
228+ }
229+ }
230+ i.mu.Unlock()
231+ i.startTurn(i.runCtx, text)
232+ return nil
233+}
204234 func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, userInput bool) {
205235 if cmd, ok := shellEscapeCommand(text); ok {
206236 i.startShellEscape(i.runCtx, cmd)
@@ -201,6 +201,36 @@ func (i *Interactive) SubmitSlash(text string) {
201 func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {201 func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
202 i.submitOrQueue(text, images, true)202 i.submitOrQueue(text, images, true)
203 }203 }
204+
205+// SubmitFollowUp submits a scheduler-owned prompt without interpreting shell
206+// escapes or steering an active agent loop. A busy interactive waits for its
207+// current turn to end, then starts this prompt as a distinct follow-up turn.
208+func (i *Interactive) SubmitFollowUp(ctx context.Context, text string) error {
209+ text = strings.TrimSpace(text)
210+ if text == "" {
211+ return fmt.Errorf("scheduled prompt is empty")
212+ }
213+ i.mu.Lock()
214+ if i.agent == nil {
215+ i.mu.Unlock()
216+ return fmt.Errorf("no agent running; log in first")
217+ }
218+ if i.busy {
219+ accepted := make(chan error, 1)
220+ i.scheduled = append(i.scheduled, scheduledFollowUp{text: text, accepted: accepted})
221+ i.mu.Unlock()
222+ i.invalidate()
223+ select {
224+ case err := <-accepted:
225+ return err
226+ case <-ctx.Done():
227+ return ctx.Err()
228+ }
229+ }
230+ i.mu.Unlock()
231+ i.startTurn(i.runCtx, text)
232+ return nil
233+}
204 func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, userInput bool) {234 func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, userInput bool) {
205 if cmd, ok := shellEscapeCommand(text); ok {235 if cmd, ok := shellEscapeCommand(text); ok {
206 i.startShellEscape(i.runCtx, cmd)236 i.startShellEscape(i.runCtx, cmd)
M packages/agent/modes/interactive_turn.go +18 -4
@@ -337,14 +337,22 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
337337 next, i.queued = i.queued[0], i.queued[1:]
338338 hasNext = true
339339 }
340- // If the turn was cancelled or errored, drop the queue so the
340+ // If the turn was cancelled or errored, drop ordinary input so the
341341 // user isn't bombarded with stale messages after an interrupt.
342+ // Scheduler follow-ups deliberately remain: they are distinct turns
343+ // whose due time must not let them steer this failed turn.
342344 if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
343345 i.queued = nil
344346 if i.agent != nil {
345347 i.agent.DrainQueuedMessages()
346348 }
347349 }
350+ var scheduled scheduledFollowUp
351+ var hasScheduled bool
352+ if !awaitingPre && !hasNext && len(i.scheduled) > 0 {
353+ scheduled, i.scheduled = i.scheduled[0], i.scheduled[1:]
354+ hasScheduled = true
355+ }
348356 // Decide whether the next thing to do is an auto-compaction.
349357 // Only fires when the turn completed cleanly AND no host-side
350358 // or agent-side queued messages are waiting (otherwise a queued
@@ -371,7 +379,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
371379 handoff, persistHandoff = i.resetCompactContinuationLocked()
372380 }
373381 }
374- if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
382+ if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || hasScheduled || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
375383 handoff, persistHandoff = i.resetCompactContinuationLocked()
376384 }
377385 // The agent run can finish before the paced final text reaches the
@@ -381,8 +389,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
381389 if recoverContextOverflow || shouldAutoCompact {
382390 i.resetStreamingStateLocked()
383391 }
384- alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)
385- i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
392+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)
393+ i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
386394 i.mu.Unlock()
387395 if persistHandoff {
388396 i.persistCompactHandoff(handoff)
@@ -402,6 +410,12 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
402410 switch {
403411 case hasNext:
404412 i.startTurn(parent, next)
413+ case hasScheduled:
414+ // The scheduler holds the session transition read lock until this
415+ // acknowledgement, so accepting here binds the prompt to the agent
416+ // that just completed rather than a subsequently loaded session.
417+ scheduled.accepted <- nil
418+ i.startTurn(parent, scheduled.text)
405419 case continueQueued:
406420 i.startTurnRequest(parent, "", nil, true, false)
407421 case continueStatusRescue:
@@ -337,14 +337,22 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
337 next, i.queued = i.queued[0], i.queued[1:]337 next, i.queued = i.queued[0], i.queued[1:]
338 hasNext = true338 hasNext = true
339 }339 }
340- // If the turn was cancelled or errored, drop the queue so the340+ // If the turn was cancelled or errored, drop ordinary input so the
341 // user isn't bombarded with stale messages after an interrupt.341 // user isn't bombarded with stale messages after an interrupt.
342+ // Scheduler follow-ups deliberately remain: they are distinct turns
343+ // whose due time must not let them steer this failed turn.
342 if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {344 if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
343 i.queued = nil345 i.queued = nil
344 if i.agent != nil {346 if i.agent != nil {
345 i.agent.DrainQueuedMessages()347 i.agent.DrainQueuedMessages()
346 }348 }
347 }349 }
350+ var scheduled scheduledFollowUp
351+ var hasScheduled bool
352+ if !awaitingPre && !hasNext && len(i.scheduled) > 0 {
353+ scheduled, i.scheduled = i.scheduled[0], i.scheduled[1:]
354+ hasScheduled = true
355+ }
348 // Decide whether the next thing to do is an auto-compaction.356 // Decide whether the next thing to do is an auto-compaction.
349 // Only fires when the turn completed cleanly AND no host-side357 // Only fires when the turn completed cleanly AND no host-side
350 // or agent-side queued messages are waiting (otherwise a queued358 // or agent-side queued messages are waiting (otherwise a queued
@@ -371,7 +379,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
371 handoff, persistHandoff = i.resetCompactContinuationLocked()379 handoff, persistHandoff = i.resetCompactContinuationLocked()
372 }380 }
373 }381 }
374- if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {382+ if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || hasScheduled || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
375 handoff, persistHandoff = i.resetCompactContinuationLocked()383 handoff, persistHandoff = i.resetCompactContinuationLocked()
376 }384 }
377 // The agent run can finish before the paced final text reaches the385 // The agent run can finish before the paced final text reaches the
@@ -381,8 +389,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
381 if recoverContextOverflow || shouldAutoCompact {389 if recoverContextOverflow || shouldAutoCompact {
382 i.resetStreamingStateLocked()390 i.resetStreamingStateLocked()
383 }391 }
384- alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)392+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)
385- i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact393+ i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
386 i.mu.Unlock()394 i.mu.Unlock()
387 if persistHandoff {395 if persistHandoff {
388 i.persistCompactHandoff(handoff)396 i.persistCompactHandoff(handoff)
@@ -402,6 +410,12 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
402 switch {410 switch {
403 case hasNext:411 case hasNext:
404 i.startTurn(parent, next)412 i.startTurn(parent, next)
413+ case hasScheduled:
414+ // The scheduler holds the session transition read lock until this
415+ // acknowledgement, so accepting here binds the prompt to the agent
416+ // that just completed rather than a subsequently loaded session.
417+ scheduled.accepted <- nil
418+ i.startTurn(parent, scheduled.text)
405 case continueQueued:419 case continueQueued:
406 i.startTurnRequest(parent, "", nil, true, false)420 i.startTurnRequest(parent, "", nil, true, false)
407 case continueStatusRescue:421 case continueStatusRescue:
A packages/agent/scheduler/cron.go +208 -0
@@ -0,0 +1,208 @@
1+// Package scheduler provides portable, in-process calendar scheduling.
2+package scheduler
3+
4+import (
5+ "fmt"
6+ "strconv"
7+ "strings"
8+ "time"
9+)
10+
11+// Cron is a five-field cron schedule (minute, hour, day of month, month, day
12+// of week). It evaluates calendar times in the location captured when the
13+// schedule is created. A repeated local minute during a DST fall-back matches
14+// twice; a local minute skipped during a spring-forward does not match.
15+type Cron struct {
16+ minute field
17+ hour field
18+ day field
19+ month field
20+ week field
21+ loc *time.Location
22+}
23+
24+type field struct {
25+ values []bool
26+ wildcard bool
27+}
28+
29+var cronMacros = map[string]string{
30+ "@yearly": "0 0 1 1 *",
31+ "@annually": "0 0 1 1 *",
32+ "@monthly": "0 0 1 * *",
33+ "@weekly": "0 0 * * 0",
34+ "@daily": "0 0 * * *",
35+ "@midnight": "0 0 * * *",
36+ "@hourly": "0 * * * *",
37+}
38+
39+var monthNames = map[string]int{
40+ "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
41+ "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
42+}
43+
44+var weekdayNames = map[string]int{
45+ "sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6,
46+}
47+
48+// ParseCron parses a conventional five-field cron expression. Month and
49+// weekday names use the English three-letter abbreviations. The supplied
50+// location is retained by the schedule, so later changes to time.Local do not
51+// change an existing task's calendar semantics.
52+func ParseCron(expression string, location *time.Location) (Cron, error) {
53+ if location == nil {
54+ return Cron{}, fmt.Errorf("cron timezone is required")
55+ }
56+ expression = strings.ToLower(strings.TrimSpace(expression))
57+ if replacement, ok := cronMacros[expression]; ok {
58+ expression = replacement
59+ }
60+ parts := strings.Fields(expression)
61+ if len(parts) != 5 {
62+ return Cron{}, fmt.Errorf("cron requires five fields (minute hour day-of-month month day-of-week)")
63+ }
64+ minute, err := parseField(parts[0], 0, 59, nil, false)
65+ if err != nil {
66+ return Cron{}, fmt.Errorf("invalid cron minute: %w", err)
67+ }
68+ hour, err := parseField(parts[1], 0, 23, nil, false)
69+ if err != nil {
70+ return Cron{}, fmt.Errorf("invalid cron hour: %w", err)
71+ }
72+ day, err := parseField(parts[2], 1, 31, nil, false)
73+ if err != nil {
74+ return Cron{}, fmt.Errorf("invalid cron day-of-month: %w", err)
75+ }
76+ month, err := parseField(parts[3], 1, 12, monthNames, false)
77+ if err != nil {
78+ return Cron{}, fmt.Errorf("invalid cron month: %w", err)
79+ }
80+ week, err := parseField(parts[4], 0, 7, weekdayNames, true)
81+ if err != nil {
82+ return Cron{}, fmt.Errorf("invalid cron day-of-week: %w", err)
83+ }
84+ return Cron{minute: minute, hour: hour, day: day, month: month, week: week, loc: location}, nil
85+}
86+
87+// Next returns the first schedule occurrence strictly after after. An invalid
88+// calendar expression such as 31 February has no occurrence and returns the
89+// zero time.
90+func (c Cron) Next(after time.Time) time.Time {
91+ if c.loc == nil {
92+ return time.Time{}
93+ }
94+ candidate := after.In(c.loc).Truncate(time.Minute).Add(time.Minute)
95+ // Eight years covers every leap-year placement while keeping impossible
96+ // expressions bounded. Normal schedules resolve in a handful of steps.
97+ const maxMinutes = 8 * 366 * 24 * 60
98+ for range maxMinutes {
99+ if c.matches(candidate) {
100+ return candidate
101+ }
102+ candidate = candidate.Add(time.Minute)
103+ }
104+ return time.Time{}
105+}
106+
107+func (c Cron) matches(t time.Time) bool {
108+ if !c.minute.matches(t.Minute()) || !c.hour.matches(t.Hour()) ||
109+ !c.month.matches(int(t.Month())) {
110+ return false
111+ }
112+ dom := c.day.matches(t.Day())
113+ dow := c.week.matches(int(t.Weekday()))
114+ // Traditional cron treats restricted day-of-month and day-of-week fields
115+ // as an OR. When either is *, the other field controls matching.
116+ if !c.day.wildcard && !c.week.wildcard {
117+ return dom || dow
118+ }
119+ return dom && dow
120+}
121+
122+func (f field) matches(value int) bool {
123+ return value >= 0 && value < len(f.values) && f.values[value]
124+}
125+
126+func parseField(raw string, min, max int, aliases map[string]int, sundaySeven bool) (field, error) {
127+ out := field{values: make([]bool, max+1), wildcard: raw == "*"}
128+ if raw == "" {
129+ return field{}, fmt.Errorf("empty field")
130+ }
131+ for _, part := range strings.Split(raw, ",") {
132+ if part == "" {
133+ return field{}, fmt.Errorf("empty list item")
134+ }
135+ base, step, hasStep, err := splitStep(part)
136+ if err != nil {
137+ return field{}, err
138+ }
139+ start, end := min, max
140+ switch {
141+ case base == "*":
142+ case strings.Contains(base, "-"):
143+ pieces := strings.Split(base, "-")
144+ if len(pieces) != 2 || pieces[0] == "" || pieces[1] == "" {
145+ return field{}, fmt.Errorf("invalid range %q", base)
146+ }
147+ start, err = parseCronValue(pieces[0], min, max, aliases, sundaySeven)
148+ if err != nil {
149+ return field{}, err
150+ }
151+ end, err = parseCronValue(pieces[1], min, max, aliases, sundaySeven)
152+ if err != nil {
153+ return field{}, err
154+ }
155+ if start > end {
156+ return field{}, fmt.Errorf("range %q descends", base)
157+ }
158+ default:
159+ if hasStep {
160+ return field{}, fmt.Errorf("step requires * or a range")
161+ }
162+ start, err = parseCronValue(base, min, max, aliases, sundaySeven)
163+ if err != nil {
164+ return field{}, err
165+ }
166+ end = start
167+ }
168+ if !hasStep {
169+ step = 1
170+ }
171+ for value := start; value <= end; value += step {
172+ out.values[value] = true
173+ }
174+ }
175+ return out, nil
176+}
177+
178+func splitStep(part string) (base string, step int, hasStep bool, err error) {
179+ pieces := strings.Split(part, "/")
180+ if len(pieces) == 1 {
181+ return part, 0, false, nil
182+ }
183+ if len(pieces) != 2 || pieces[0] == "" || pieces[1] == "" {
184+ return "", 0, false, fmt.Errorf("invalid step %q", part)
185+ }
186+ step, err = strconv.Atoi(pieces[1])
187+ if err != nil || step <= 0 {
188+ return "", 0, false, fmt.Errorf("invalid step %q", part)
189+ }
190+ return pieces[0], step, true, nil
191+}
192+
193+func parseCronValue(raw string, min, max int, aliases map[string]int, sundaySeven bool) (int, error) {
194+ if value, ok := aliases[raw]; ok {
195+ return value, nil
196+ }
197+ value, err := strconv.Atoi(raw)
198+ if err != nil {
199+ return 0, fmt.Errorf("%q is not a number", raw)
200+ }
201+ if sundaySeven && value == 7 {
202+ return 0, nil
203+ }
204+ if value < min || value > max {
205+ return 0, fmt.Errorf("%d is outside %d-%d", value, min, max)
206+ }
207+ return value, nil
208+}
@@ -0,0 +1,208 @@
1+// Package scheduler provides portable, in-process calendar scheduling.
2+package scheduler
3+
4+import (
5+ "fmt"
6+ "strconv"
7+ "strings"
8+ "time"
9+)
10+
11+// Cron is a five-field cron schedule (minute, hour, day of month, month, day
12+// of week). It evaluates calendar times in the location captured when the
13+// schedule is created. A repeated local minute during a DST fall-back matches
14+// twice; a local minute skipped during a spring-forward does not match.
15+type Cron struct {
16+ minute field
17+ hour field
18+ day field
19+ month field
20+ week field
21+ loc *time.Location
22+}
23+
24+type field struct {
25+ values []bool
26+ wildcard bool
27+}
28+
29+var cronMacros = map[string]string{
30+ "@yearly": "0 0 1 1 *",
31+ "@annually": "0 0 1 1 *",
32+ "@monthly": "0 0 1 * *",
33+ "@weekly": "0 0 * * 0",
34+ "@daily": "0 0 * * *",
35+ "@midnight": "0 0 * * *",
36+ "@hourly": "0 * * * *",
37+}
38+
39+var monthNames = map[string]int{
40+ "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
41+ "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
42+}
43+
44+var weekdayNames = map[string]int{
45+ "sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6,
46+}
47+
48+// ParseCron parses a conventional five-field cron expression. Month and
49+// weekday names use the English three-letter abbreviations. The supplied
50+// location is retained by the schedule, so later changes to time.Local do not
51+// change an existing task's calendar semantics.
52+func ParseCron(expression string, location *time.Location) (Cron, error) {
53+ if location == nil {
54+ return Cron{}, fmt.Errorf("cron timezone is required")
55+ }
56+ expression = strings.ToLower(strings.TrimSpace(expression))
57+ if replacement, ok := cronMacros[expression]; ok {
58+ expression = replacement
59+ }
60+ parts := strings.Fields(expression)
61+ if len(parts) != 5 {
62+ return Cron{}, fmt.Errorf("cron requires five fields (minute hour day-of-month month day-of-week)")
63+ }
64+ minute, err := parseField(parts[0], 0, 59, nil, false)
65+ if err != nil {
66+ return Cron{}, fmt.Errorf("invalid cron minute: %w", err)
67+ }
68+ hour, err := parseField(parts[1], 0, 23, nil, false)
69+ if err != nil {
70+ return Cron{}, fmt.Errorf("invalid cron hour: %w", err)
71+ }
72+ day, err := parseField(parts[2], 1, 31, nil, false)
73+ if err != nil {
74+ return Cron{}, fmt.Errorf("invalid cron day-of-month: %w", err)
75+ }
76+ month, err := parseField(parts[3], 1, 12, monthNames, false)
77+ if err != nil {
78+ return Cron{}, fmt.Errorf("invalid cron month: %w", err)
79+ }
80+ week, err := parseField(parts[4], 0, 7, weekdayNames, true)
81+ if err != nil {
82+ return Cron{}, fmt.Errorf("invalid cron day-of-week: %w", err)
83+ }
84+ return Cron{minute: minute, hour: hour, day: day, month: month, week: week, loc: location}, nil
85+}
86+
87+// Next returns the first schedule occurrence strictly after after. An invalid
88+// calendar expression such as 31 February has no occurrence and returns the
89+// zero time.
90+func (c Cron) Next(after time.Time) time.Time {
91+ if c.loc == nil {
92+ return time.Time{}
93+ }
94+ candidate := after.In(c.loc).Truncate(time.Minute).Add(time.Minute)
95+ // Eight years covers every leap-year placement while keeping impossible
96+ // expressions bounded. Normal schedules resolve in a handful of steps.
97+ const maxMinutes = 8 * 366 * 24 * 60
98+ for range maxMinutes {
99+ if c.matches(candidate) {
100+ return candidate
101+ }
102+ candidate = candidate.Add(time.Minute)
103+ }
104+ return time.Time{}
105+}
106+
107+func (c Cron) matches(t time.Time) bool {
108+ if !c.minute.matches(t.Minute()) || !c.hour.matches(t.Hour()) ||
109+ !c.month.matches(int(t.Month())) {
110+ return false
111+ }
112+ dom := c.day.matches(t.Day())
113+ dow := c.week.matches(int(t.Weekday()))
114+ // Traditional cron treats restricted day-of-month and day-of-week fields
115+ // as an OR. When either is *, the other field controls matching.
116+ if !c.day.wildcard && !c.week.wildcard {
117+ return dom || dow
118+ }
119+ return dom && dow
120+}
121+
122+func (f field) matches(value int) bool {
123+ return value >= 0 && value < len(f.values) && f.values[value]
124+}
125+
126+func parseField(raw string, min, max int, aliases map[string]int, sundaySeven bool) (field, error) {
127+ out := field{values: make([]bool, max+1), wildcard: raw == "*"}
128+ if raw == "" {
129+ return field{}, fmt.Errorf("empty field")
130+ }
131+ for _, part := range strings.Split(raw, ",") {
132+ if part == "" {
133+ return field{}, fmt.Errorf("empty list item")
134+ }
135+ base, step, hasStep, err := splitStep(part)
136+ if err != nil {
137+ return field{}, err
138+ }
139+ start, end := min, max
140+ switch {
141+ case base == "*":
142+ case strings.Contains(base, "-"):
143+ pieces := strings.Split(base, "-")
144+ if len(pieces) != 2 || pieces[0] == "" || pieces[1] == "" {
145+ return field{}, fmt.Errorf("invalid range %q", base)
146+ }
147+ start, err = parseCronValue(pieces[0], min, max, aliases, sundaySeven)
148+ if err != nil {
149+ return field{}, err
150+ }
151+ end, err = parseCronValue(pieces[1], min, max, aliases, sundaySeven)
152+ if err != nil {
153+ return field{}, err
154+ }
155+ if start > end {
156+ return field{}, fmt.Errorf("range %q descends", base)
157+ }
158+ default:
159+ if hasStep {
160+ return field{}, fmt.Errorf("step requires * or a range")
161+ }
162+ start, err = parseCronValue(base, min, max, aliases, sundaySeven)
163+ if err != nil {
164+ return field{}, err
165+ }
166+ end = start
167+ }
168+ if !hasStep {
169+ step = 1
170+ }
171+ for value := start; value <= end; value += step {
172+ out.values[value] = true
173+ }
174+ }
175+ return out, nil
176+}
177+
178+func splitStep(part string) (base string, step int, hasStep bool, err error) {
179+ pieces := strings.Split(part, "/")
180+ if len(pieces) == 1 {
181+ return part, 0, false, nil
182+ }
183+ if len(pieces) != 2 || pieces[0] == "" || pieces[1] == "" {
184+ return "", 0, false, fmt.Errorf("invalid step %q", part)
185+ }
186+ step, err = strconv.Atoi(pieces[1])
187+ if err != nil || step <= 0 {
188+ return "", 0, false, fmt.Errorf("invalid step %q", part)
189+ }
190+ return pieces[0], step, true, nil
191+}
192+
193+func parseCronValue(raw string, min, max int, aliases map[string]int, sundaySeven bool) (int, error) {
194+ if value, ok := aliases[raw]; ok {
195+ return value, nil
196+ }
197+ value, err := strconv.Atoi(raw)
198+ if err != nil {
199+ return 0, fmt.Errorf("%q is not a number", raw)
200+ }
201+ if sundaySeven && value == 7 {
202+ return 0, nil
203+ }
204+ if value < min || value > max {
205+ return 0, fmt.Errorf("%d is outside %d-%d", value, min, max)
206+ }
207+ return value, nil
208+}
A packages/agent/scheduler/cron_test.go +80 -0
@@ -0,0 +1,80 @@
1+package scheduler
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestParseCronNext(t *testing.T) {
9+ loc := time.FixedZone("UTC+2", 2*60*60)
10+ schedule, err := ParseCron("*/15 9-10 * * 1-5", loc)
11+ if err != nil {
12+ t.Fatalf("ParseCron() error = %v", err)
13+ }
14+
15+ after := time.Date(2026, time.August, 14, 9, 14, 45, 0, loc) // Friday
16+ want := time.Date(2026, time.August, 14, 9, 15, 0, 0, loc)
17+ if got := schedule.Next(after); !got.Equal(want) {
18+ t.Fatalf("Next() = %v, want %v", got, want)
19+ }
20+}
21+
22+func TestParseCronDayOfMonthOrDayOfWeek(t *testing.T) {
23+ loc := time.UTC
24+ schedule, err := ParseCron("0 9 15 * 1", loc)
25+ if err != nil {
26+ t.Fatalf("ParseCron() error = %v", err)
27+ }
28+
29+ // Vixie cron semantics: when both fields are restricted, either match is
30+ // sufficient. Monday 2026-06-08 is not the fifteenth.
31+ after := time.Date(2026, time.June, 7, 9, 0, 0, 0, loc)
32+ want := time.Date(2026, time.June, 8, 9, 0, 0, 0, loc)
33+ if got := schedule.Next(after); !got.Equal(want) {
34+ t.Fatalf("Next() = %v, want %v", got, want)
35+ }
36+}
37+
38+func TestParseCronDSTTransitions(t *testing.T) {
39+ loc, err := time.LoadLocation("America/New_York")
40+ if err != nil {
41+ t.Fatalf("LoadLocation() error = %v", err)
42+ }
43+ fallBack, err := ParseCron("30 1 * * *", loc)
44+ if err != nil {
45+ t.Fatalf("ParseCron() error = %v", err)
46+ }
47+ first := fallBack.Next(time.Date(2026, time.November, 1, 0, 59, 0, 0, loc))
48+ second := fallBack.Next(first)
49+ if first.Hour() != 1 || first.Minute() != 30 || second.Hour() != 1 || second.Minute() != 30 || first.Equal(second) {
50+ t.Fatalf("fall-back occurrences = %v, %v; want two distinct 01:30 occurrences", first, second)
51+ }
52+
53+ springForward, err := ParseCron("30 2 * * *", loc)
54+ if err != nil {
55+ t.Fatalf("ParseCron() error = %v", err)
56+ }
57+ got := springForward.Next(time.Date(2026, time.March, 8, 1, 59, 0, 0, loc))
58+ want := time.Date(2026, time.March, 9, 2, 30, 0, 0, loc)
59+ if !got.Equal(want) {
60+ t.Fatalf("spring-forward Next() = %v, want %v", got, want)
61+ }
62+}
63+
64+func TestParseCronRejectsInvalidField(t *testing.T) {
65+ if _, err := ParseCron("61 * * * *", time.UTC); err == nil {
66+ t.Fatal("ParseCron() error = nil, want invalid minute error")
67+ }
68+}
69+
70+func TestParseCronSupportsMacro(t *testing.T) {
71+ schedule, err := ParseCron("@daily", time.UTC)
72+ if err != nil {
73+ t.Fatalf("ParseCron() error = %v", err)
74+ }
75+ got := schedule.Next(time.Date(2026, time.August, 14, 23, 59, 0, 0, time.UTC))
76+ want := time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC)
77+ if !got.Equal(want) {
78+ t.Fatalf("Next() = %v, want %v", got, want)
79+ }
80+}
@@ -0,0 +1,80 @@
1+package scheduler
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestParseCronNext(t *testing.T) {
9+ loc := time.FixedZone("UTC+2", 2*60*60)
10+ schedule, err := ParseCron("*/15 9-10 * * 1-5", loc)
11+ if err != nil {
12+ t.Fatalf("ParseCron() error = %v", err)
13+ }
14+
15+ after := time.Date(2026, time.August, 14, 9, 14, 45, 0, loc) // Friday
16+ want := time.Date(2026, time.August, 14, 9, 15, 0, 0, loc)
17+ if got := schedule.Next(after); !got.Equal(want) {
18+ t.Fatalf("Next() = %v, want %v", got, want)
19+ }
20+}
21+
22+func TestParseCronDayOfMonthOrDayOfWeek(t *testing.T) {
23+ loc := time.UTC
24+ schedule, err := ParseCron("0 9 15 * 1", loc)
25+ if err != nil {
26+ t.Fatalf("ParseCron() error = %v", err)
27+ }
28+
29+ // Vixie cron semantics: when both fields are restricted, either match is
30+ // sufficient. Monday 2026-06-08 is not the fifteenth.
31+ after := time.Date(2026, time.June, 7, 9, 0, 0, 0, loc)
32+ want := time.Date(2026, time.June, 8, 9, 0, 0, 0, loc)
33+ if got := schedule.Next(after); !got.Equal(want) {
34+ t.Fatalf("Next() = %v, want %v", got, want)
35+ }
36+}
37+
38+func TestParseCronDSTTransitions(t *testing.T) {
39+ loc, err := time.LoadLocation("America/New_York")
40+ if err != nil {
41+ t.Fatalf("LoadLocation() error = %v", err)
42+ }
43+ fallBack, err := ParseCron("30 1 * * *", loc)
44+ if err != nil {
45+ t.Fatalf("ParseCron() error = %v", err)
46+ }
47+ first := fallBack.Next(time.Date(2026, time.November, 1, 0, 59, 0, 0, loc))
48+ second := fallBack.Next(first)
49+ if first.Hour() != 1 || first.Minute() != 30 || second.Hour() != 1 || second.Minute() != 30 || first.Equal(second) {
50+ t.Fatalf("fall-back occurrences = %v, %v; want two distinct 01:30 occurrences", first, second)
51+ }
52+
53+ springForward, err := ParseCron("30 2 * * *", loc)
54+ if err != nil {
55+ t.Fatalf("ParseCron() error = %v", err)
56+ }
57+ got := springForward.Next(time.Date(2026, time.March, 8, 1, 59, 0, 0, loc))
58+ want := time.Date(2026, time.March, 9, 2, 30, 0, 0, loc)
59+ if !got.Equal(want) {
60+ t.Fatalf("spring-forward Next() = %v, want %v", got, want)
61+ }
62+}
63+
64+func TestParseCronRejectsInvalidField(t *testing.T) {
65+ if _, err := ParseCron("61 * * * *", time.UTC); err == nil {
66+ t.Fatal("ParseCron() error = nil, want invalid minute error")
67+ }
68+}
69+
70+func TestParseCronSupportsMacro(t *testing.T) {
71+ schedule, err := ParseCron("@daily", time.UTC)
72+ if err != nil {
73+ t.Fatalf("ParseCron() error = %v", err)
74+ }
75+ got := schedule.Next(time.Date(2026, time.August, 14, 23, 59, 0, 0, time.UTC))
76+ want := time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC)
77+ if !got.Equal(want) {
78+ t.Fatalf("Next() = %v, want %v", got, want)
79+ }
80+}
A packages/agent/scheduler/scheduler.go +272 -0
@@ -0,0 +1,272 @@
1+package scheduler
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sort"
7+ "strings"
8+ "sync"
9+ "time"
10+
11+ "github.com/google/uuid"
12+)
13+
14+// Task is one recurring prompt owned by a session branch. Tasks are kept only
15+// in process memory; callers are responsible for stopping the engine with the
16+// host process.
17+type Task struct {
18+ ID string
19+ SessionID string
20+ Cron string
21+ Timezone string
22+ Message string
23+ CreatedAt time.Time
24+ NextRun time.Time
25+ LastRun time.Time
26+ RunCount int
27+ LastError string
28+
29+ schedule Cron
30+}
31+
32+// NewTaskInput describes a task to add to an Engine. Location is captured at
33+// creation rather than consulting time.Local during later evaluations.
34+type NewTaskInput struct {
35+ SessionID string
36+ Cron string
37+ Message string
38+ Location *time.Location
39+}
40+
41+// Engine serializes task scheduling and execution through one timer loop. Its
42+// callback runs outside the engine lock but is intentionally called one task
43+// at a time so a session cannot receive overlapping scheduled turns.
44+type Engine struct {
45+ mu sync.Mutex
46+ now func() time.Time
47+ tasks map[string]Task
48+ wake chan struct{}
49+}
50+
51+// NewEngine creates an empty scheduler. now is injectable for tests; nil uses
52+// time.Now.
53+func NewEngine(now func() time.Time) *Engine {
54+ if now == nil {
55+ now = time.Now
56+ }
57+ return &Engine{now: now, tasks: make(map[string]Task), wake: make(chan struct{}, 1)}
58+}
59+
60+// Add validates and records a new recurring task.
61+func (e *Engine) Add(input NewTaskInput) (Task, error) {
62+ if e == nil {
63+ return Task{}, fmt.Errorf("scheduler is not initialized")
64+ }
65+ input.SessionID = strings.TrimSpace(input.SessionID)
66+ input.Cron = strings.TrimSpace(input.Cron)
67+ input.Message = strings.TrimSpace(input.Message)
68+ if input.SessionID == "" {
69+ return Task{}, fmt.Errorf("session ID is required")
70+ }
71+ if input.Message == "" {
72+ return Task{}, fmt.Errorf("scheduled message is required")
73+ }
74+ if input.Location == nil {
75+ return Task{}, fmt.Errorf("schedule timezone is required")
76+ }
77+ schedule, err := ParseCron(input.Cron, input.Location)
78+ if err != nil {
79+ return Task{}, err
80+ }
81+ now := e.now()
82+ next := schedule.Next(now)
83+ if next.IsZero() {
84+ return Task{}, fmt.Errorf("cron has no occurrence in the next eight years")
85+ }
86+ task := Task{
87+ ID: uuid.NewString(),
88+ SessionID: input.SessionID,
89+ Cron: input.Cron,
90+ Timezone: input.Location.String(),
91+ Message: input.Message,
92+ CreatedAt: now,
93+ NextRun: next,
94+ schedule: schedule,
95+ }
96+ e.mu.Lock()
97+ e.tasks[task.ID] = task
98+ e.mu.Unlock()
99+ e.signalWake()
100+ return task, nil
101+}
102+
103+// Cancel removes a scheduled task. It returns false when the ID is unknown.
104+func (e *Engine) Cancel(id string) bool {
105+ if e == nil {
106+ return false
107+ }
108+ e.mu.Lock()
109+ _, ok := e.tasks[id]
110+ if ok {
111+ delete(e.tasks, id)
112+ }
113+ e.mu.Unlock()
114+ if ok {
115+ e.signalWake()
116+ }
117+ return ok
118+}
119+
120+// Get returns a task snapshot by ID.
121+func (e *Engine) Get(id string) (Task, bool) {
122+ if e == nil {
123+ return Task{}, false
124+ }
125+ e.mu.Lock()
126+ defer e.mu.Unlock()
127+ task, ok := e.tasks[id]
128+ return task, ok
129+}
130+
131+// List returns snapshots ordered by next execution and then ID.
132+func (e *Engine) List() []Task {
133+ if e == nil {
134+ return nil
135+ }
136+ e.mu.Lock()
137+ tasks := make([]Task, 0, len(e.tasks))
138+ for _, task := range e.tasks {
139+ tasks = append(tasks, task)
140+ }
141+ e.mu.Unlock()
142+ sort.Slice(tasks, func(i, j int) bool {
143+ if tasks[i].NextRun.Equal(tasks[j].NextRun) {
144+ return tasks[i].ID < tasks[j].ID
145+ }
146+ return tasks[i].NextRun.Before(tasks[j].NextRun)
147+ })
148+ return tasks
149+}
150+
151+// Run waits for due tasks until ctx is cancelled. The executor is called
152+// synchronously for each due task, which prevents a scheduled task from
153+// overlapping a prior scheduled turn.
154+func (e *Engine) Run(ctx context.Context, executor func(Task) error) error {
155+ if e == nil {
156+ return fmt.Errorf("scheduler is not initialized")
157+ }
158+ if executor == nil {
159+ return fmt.Errorf("scheduler executor is required")
160+ }
161+ for {
162+ now := e.now()
163+ e.dispatchDue(now, executor)
164+ next, ok := e.nextRun()
165+ if !ok {
166+ select {
167+ case <-ctx.Done():
168+ return nil
169+ case <-e.wake:
170+ }
171+ continue
172+ }
173+ wait := time.Until(next)
174+ if wait < 0 {
175+ wait = 0
176+ }
177+ timer := time.NewTimer(wait)
178+ select {
179+ case <-ctx.Done():
180+ if !timer.Stop() {
181+ select {
182+ case <-timer.C:
183+ default:
184+ }
185+ }
186+ return nil
187+ case <-e.wake:
188+ if !timer.Stop() {
189+ select {
190+ case <-timer.C:
191+ default:
192+ }
193+ }
194+ case <-timer.C:
195+ }
196+ }
197+}
198+
199+// dispatchDue executes all tasks due at or before now. Run is its only
200+// production caller; keeping it private preserves Engine's single executor
201+// ownership and prevents concurrent duplicate dispatches.
202+func (e *Engine) dispatchDue(now time.Time, executor func(Task) error) {
203+ if e == nil || executor == nil {
204+ return
205+ }
206+ for _, task := range e.takeDue(now) {
207+ err := executor(task)
208+ e.finish(task.ID, task.NextRun, err)
209+ }
210+}
211+
212+func (e *Engine) takeDue(now time.Time) []Task {
213+ e.mu.Lock()
214+ defer e.mu.Unlock()
215+ due := make([]Task, 0)
216+ for _, task := range e.tasks {
217+ if !task.NextRun.After(now) {
218+ due = append(due, task)
219+ }
220+ }
221+ sort.Slice(due, func(i, j int) bool {
222+ if due[i].NextRun.Equal(due[j].NextRun) {
223+ return due[i].ID < due[j].ID
224+ }
225+ return due[i].NextRun.Before(due[j].NextRun)
226+ })
227+ return due
228+}
229+
230+func (e *Engine) finish(id string, scheduledFor time.Time, err error) {
231+ e.mu.Lock()
232+ defer e.mu.Unlock()
233+ task, ok := e.tasks[id]
234+ if !ok || !task.NextRun.Equal(scheduledFor) {
235+ return // cancelled or replaced while the executor ran
236+ }
237+ task.LastRun = scheduledFor
238+ task.RunCount++
239+ if err != nil {
240+ task.LastError = err.Error()
241+ } else {
242+ task.LastError = ""
243+ }
244+ // Calculate from completion time, rather than repeatedly from the prior
245+ // deadline, so a sleeping machine or a slow task never creates a burst of
246+ // stale catch-up executions.
247+ task.NextRun = task.schedule.Next(e.now())
248+ if task.NextRun.IsZero() {
249+ delete(e.tasks, id)
250+ return
251+ }
252+ e.tasks[id] = task
253+}
254+
255+func (e *Engine) nextRun() (time.Time, bool) {
256+ e.mu.Lock()
257+ defer e.mu.Unlock()
258+ var next time.Time
259+ for _, task := range e.tasks {
260+ if next.IsZero() || task.NextRun.Before(next) {
261+ next = task.NextRun
262+ }
263+ }
264+ return next, !next.IsZero()
265+}
266+
267+func (e *Engine) signalWake() {
268+ select {
269+ case e.wake <- struct{}{}:
270+ default:
271+ }
272+}
@@ -0,0 +1,272 @@
1+package scheduler
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sort"
7+ "strings"
8+ "sync"
9+ "time"
10+
11+ "github.com/google/uuid"
12+)
13+
14+// Task is one recurring prompt owned by a session branch. Tasks are kept only
15+// in process memory; callers are responsible for stopping the engine with the
16+// host process.
17+type Task struct {
18+ ID string
19+ SessionID string
20+ Cron string
21+ Timezone string
22+ Message string
23+ CreatedAt time.Time
24+ NextRun time.Time
25+ LastRun time.Time
26+ RunCount int
27+ LastError string
28+
29+ schedule Cron
30+}
31+
32+// NewTaskInput describes a task to add to an Engine. Location is captured at
33+// creation rather than consulting time.Local during later evaluations.
34+type NewTaskInput struct {
35+ SessionID string
36+ Cron string
37+ Message string
38+ Location *time.Location
39+}
40+
41+// Engine serializes task scheduling and execution through one timer loop. Its
42+// callback runs outside the engine lock but is intentionally called one task
43+// at a time so a session cannot receive overlapping scheduled turns.
44+type Engine struct {
45+ mu sync.Mutex
46+ now func() time.Time
47+ tasks map[string]Task
48+ wake chan struct{}
49+}
50+
51+// NewEngine creates an empty scheduler. now is injectable for tests; nil uses
52+// time.Now.
53+func NewEngine(now func() time.Time) *Engine {
54+ if now == nil {
55+ now = time.Now
56+ }
57+ return &Engine{now: now, tasks: make(map[string]Task), wake: make(chan struct{}, 1)}
58+}
59+
60+// Add validates and records a new recurring task.
61+func (e *Engine) Add(input NewTaskInput) (Task, error) {
62+ if e == nil {
63+ return Task{}, fmt.Errorf("scheduler is not initialized")
64+ }
65+ input.SessionID = strings.TrimSpace(input.SessionID)
66+ input.Cron = strings.TrimSpace(input.Cron)
67+ input.Message = strings.TrimSpace(input.Message)
68+ if input.SessionID == "" {
69+ return Task{}, fmt.Errorf("session ID is required")
70+ }
71+ if input.Message == "" {
72+ return Task{}, fmt.Errorf("scheduled message is required")
73+ }
74+ if input.Location == nil {
75+ return Task{}, fmt.Errorf("schedule timezone is required")
76+ }
77+ schedule, err := ParseCron(input.Cron, input.Location)
78+ if err != nil {
79+ return Task{}, err
80+ }
81+ now := e.now()
82+ next := schedule.Next(now)
83+ if next.IsZero() {
84+ return Task{}, fmt.Errorf("cron has no occurrence in the next eight years")
85+ }
86+ task := Task{
87+ ID: uuid.NewString(),
88+ SessionID: input.SessionID,
89+ Cron: input.Cron,
90+ Timezone: input.Location.String(),
91+ Message: input.Message,
92+ CreatedAt: now,
93+ NextRun: next,
94+ schedule: schedule,
95+ }
96+ e.mu.Lock()
97+ e.tasks[task.ID] = task
98+ e.mu.Unlock()
99+ e.signalWake()
100+ return task, nil
101+}
102+
103+// Cancel removes a scheduled task. It returns false when the ID is unknown.
104+func (e *Engine) Cancel(id string) bool {
105+ if e == nil {
106+ return false
107+ }
108+ e.mu.Lock()
109+ _, ok := e.tasks[id]
110+ if ok {
111+ delete(e.tasks, id)
112+ }
113+ e.mu.Unlock()
114+ if ok {
115+ e.signalWake()
116+ }
117+ return ok
118+}
119+
120+// Get returns a task snapshot by ID.
121+func (e *Engine) Get(id string) (Task, bool) {
122+ if e == nil {
123+ return Task{}, false
124+ }
125+ e.mu.Lock()
126+ defer e.mu.Unlock()
127+ task, ok := e.tasks[id]
128+ return task, ok
129+}
130+
131+// List returns snapshots ordered by next execution and then ID.
132+func (e *Engine) List() []Task {
133+ if e == nil {
134+ return nil
135+ }
136+ e.mu.Lock()
137+ tasks := make([]Task, 0, len(e.tasks))
138+ for _, task := range e.tasks {
139+ tasks = append(tasks, task)
140+ }
141+ e.mu.Unlock()
142+ sort.Slice(tasks, func(i, j int) bool {
143+ if tasks[i].NextRun.Equal(tasks[j].NextRun) {
144+ return tasks[i].ID < tasks[j].ID
145+ }
146+ return tasks[i].NextRun.Before(tasks[j].NextRun)
147+ })
148+ return tasks
149+}
150+
151+// Run waits for due tasks until ctx is cancelled. The executor is called
152+// synchronously for each due task, which prevents a scheduled task from
153+// overlapping a prior scheduled turn.
154+func (e *Engine) Run(ctx context.Context, executor func(Task) error) error {
155+ if e == nil {
156+ return fmt.Errorf("scheduler is not initialized")
157+ }
158+ if executor == nil {
159+ return fmt.Errorf("scheduler executor is required")
160+ }
161+ for {
162+ now := e.now()
163+ e.dispatchDue(now, executor)
164+ next, ok := e.nextRun()
165+ if !ok {
166+ select {
167+ case <-ctx.Done():
168+ return nil
169+ case <-e.wake:
170+ }
171+ continue
172+ }
173+ wait := time.Until(next)
174+ if wait < 0 {
175+ wait = 0
176+ }
177+ timer := time.NewTimer(wait)
178+ select {
179+ case <-ctx.Done():
180+ if !timer.Stop() {
181+ select {
182+ case <-timer.C:
183+ default:
184+ }
185+ }
186+ return nil
187+ case <-e.wake:
188+ if !timer.Stop() {
189+ select {
190+ case <-timer.C:
191+ default:
192+ }
193+ }
194+ case <-timer.C:
195+ }
196+ }
197+}
198+
199+// dispatchDue executes all tasks due at or before now. Run is its only
200+// production caller; keeping it private preserves Engine's single executor
201+// ownership and prevents concurrent duplicate dispatches.
202+func (e *Engine) dispatchDue(now time.Time, executor func(Task) error) {
203+ if e == nil || executor == nil {
204+ return
205+ }
206+ for _, task := range e.takeDue(now) {
207+ err := executor(task)
208+ e.finish(task.ID, task.NextRun, err)
209+ }
210+}
211+
212+func (e *Engine) takeDue(now time.Time) []Task {
213+ e.mu.Lock()
214+ defer e.mu.Unlock()
215+ due := make([]Task, 0)
216+ for _, task := range e.tasks {
217+ if !task.NextRun.After(now) {
218+ due = append(due, task)
219+ }
220+ }
221+ sort.Slice(due, func(i, j int) bool {
222+ if due[i].NextRun.Equal(due[j].NextRun) {
223+ return due[i].ID < due[j].ID
224+ }
225+ return due[i].NextRun.Before(due[j].NextRun)
226+ })
227+ return due
228+}
229+
230+func (e *Engine) finish(id string, scheduledFor time.Time, err error) {
231+ e.mu.Lock()
232+ defer e.mu.Unlock()
233+ task, ok := e.tasks[id]
234+ if !ok || !task.NextRun.Equal(scheduledFor) {
235+ return // cancelled or replaced while the executor ran
236+ }
237+ task.LastRun = scheduledFor
238+ task.RunCount++
239+ if err != nil {
240+ task.LastError = err.Error()
241+ } else {
242+ task.LastError = ""
243+ }
244+ // Calculate from completion time, rather than repeatedly from the prior
245+ // deadline, so a sleeping machine or a slow task never creates a burst of
246+ // stale catch-up executions.
247+ task.NextRun = task.schedule.Next(e.now())
248+ if task.NextRun.IsZero() {
249+ delete(e.tasks, id)
250+ return
251+ }
252+ e.tasks[id] = task
253+}
254+
255+func (e *Engine) nextRun() (time.Time, bool) {
256+ e.mu.Lock()
257+ defer e.mu.Unlock()
258+ var next time.Time
259+ for _, task := range e.tasks {
260+ if next.IsZero() || task.NextRun.Before(next) {
261+ next = task.NextRun
262+ }
263+ }
264+ return next, !next.IsZero()
265+}
266+
267+func (e *Engine) signalWake() {
268+ select {
269+ case e.wake <- struct{}{}:
270+ default:
271+ }
272+}
A packages/agent/scheduler/scheduler_test.go +85 -0
@@ -0,0 +1,85 @@
1+package scheduler
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+)
9+
10+func TestEngineDispatchDueReschedulesTask(t *testing.T) {
11+ loc := time.UTC
12+ now := time.Date(2026, time.August, 15, 12, 0, 0, 0, loc)
13+ engine := NewEngine(func() time.Time { return now })
14+ created, err := engine.Add(NewTaskInput{
15+ SessionID: "session-1",
16+ Cron: "* * * * *",
17+ Message: "check status",
18+ Location: loc,
19+ })
20+ if err != nil {
21+ t.Fatalf("Add() error = %v", err)
22+ }
23+
24+ var executed []Task
25+ now = now.Add(time.Minute)
26+ engine.dispatchDue(now, func(task Task) error {
27+ executed = append(executed, task)
28+ return nil
29+ })
30+ if len(executed) != 1 || executed[0].ID != created.ID {
31+ t.Fatalf("executed = %#v, want task %q", executed, created.ID)
32+ }
33+
34+ tasks := engine.List()
35+ if len(tasks) != 1 {
36+ t.Fatalf("List() length = %d, want 1", len(tasks))
37+ }
38+ if want := now.Add(time.Minute); !tasks[0].NextRun.Equal(want) {
39+ t.Fatalf("NextRun = %v, want %v", tasks[0].NextRun, want)
40+ }
41+ if !tasks[0].LastRun.Equal(now) || tasks[0].RunCount != 1 {
42+ t.Fatalf("task after execution = %#v", tasks[0])
43+ }
44+}
45+
46+func TestEngineRecordsExecutionFailure(t *testing.T) {
47+ now := time.Date(2026, time.August, 15, 12, 0, 0, 0, time.UTC)
48+ engine := NewEngine(func() time.Time { return now })
49+ task, err := engine.Add(NewTaskInput{SessionID: "session-1", Cron: "* * * * *", Message: "run", Location: time.UTC})
50+ if err != nil {
51+ t.Fatalf("Add() error = %v", err)
52+ }
53+
54+ engine.dispatchDue(now.Add(time.Minute), func(Task) error { return errors.New("provider unavailable") })
55+ got, ok := engine.Get(task.ID)
56+ if !ok {
57+ t.Fatal("Get() did not return scheduled task")
58+ }
59+ if got.LastError != "provider unavailable" {
60+ t.Fatalf("LastError = %q", got.LastError)
61+ }
62+}
63+
64+func TestEngineCancel(t *testing.T) {
65+ engine := NewEngine(nil)
66+ task, err := engine.Add(NewTaskInput{SessionID: "session-1", Cron: "@hourly", Message: "run", Location: time.UTC})
67+ if err != nil {
68+ t.Fatalf("Add() error = %v", err)
69+ }
70+ if !engine.Cancel(task.ID) {
71+ t.Fatal("Cancel() = false, want true")
72+ }
73+ if _, ok := engine.Get(task.ID); ok {
74+ t.Fatal("Get() found cancelled task")
75+ }
76+}
77+
78+func TestEngineRunStopsWithContext(t *testing.T) {
79+ engine := NewEngine(nil)
80+ ctx, cancel := context.WithCancel(context.Background())
81+ cancel()
82+ if err := engine.Run(ctx, func(Task) error { return nil }); err != nil {
83+ t.Fatalf("Run() error = %v", err)
84+ }
85+}
@@ -0,0 +1,85 @@
1+package scheduler
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+)
9+
10+func TestEngineDispatchDueReschedulesTask(t *testing.T) {
11+ loc := time.UTC
12+ now := time.Date(2026, time.August, 15, 12, 0, 0, 0, loc)
13+ engine := NewEngine(func() time.Time { return now })
14+ created, err := engine.Add(NewTaskInput{
15+ SessionID: "session-1",
16+ Cron: "* * * * *",
17+ Message: "check status",
18+ Location: loc,
19+ })
20+ if err != nil {
21+ t.Fatalf("Add() error = %v", err)
22+ }
23+
24+ var executed []Task
25+ now = now.Add(time.Minute)
26+ engine.dispatchDue(now, func(task Task) error {
27+ executed = append(executed, task)
28+ return nil
29+ })
30+ if len(executed) != 1 || executed[0].ID != created.ID {
31+ t.Fatalf("executed = %#v, want task %q", executed, created.ID)
32+ }
33+
34+ tasks := engine.List()
35+ if len(tasks) != 1 {
36+ t.Fatalf("List() length = %d, want 1", len(tasks))
37+ }
38+ if want := now.Add(time.Minute); !tasks[0].NextRun.Equal(want) {
39+ t.Fatalf("NextRun = %v, want %v", tasks[0].NextRun, want)
40+ }
41+ if !tasks[0].LastRun.Equal(now) || tasks[0].RunCount != 1 {
42+ t.Fatalf("task after execution = %#v", tasks[0])
43+ }
44+}
45+
46+func TestEngineRecordsExecutionFailure(t *testing.T) {
47+ now := time.Date(2026, time.August, 15, 12, 0, 0, 0, time.UTC)
48+ engine := NewEngine(func() time.Time { return now })
49+ task, err := engine.Add(NewTaskInput{SessionID: "session-1", Cron: "* * * * *", Message: "run", Location: time.UTC})
50+ if err != nil {
51+ t.Fatalf("Add() error = %v", err)
52+ }
53+
54+ engine.dispatchDue(now.Add(time.Minute), func(Task) error { return errors.New("provider unavailable") })
55+ got, ok := engine.Get(task.ID)
56+ if !ok {
57+ t.Fatal("Get() did not return scheduled task")
58+ }
59+ if got.LastError != "provider unavailable" {
60+ t.Fatalf("LastError = %q", got.LastError)
61+ }
62+}
63+
64+func TestEngineCancel(t *testing.T) {
65+ engine := NewEngine(nil)
66+ task, err := engine.Add(NewTaskInput{SessionID: "session-1", Cron: "@hourly", Message: "run", Location: time.UTC})
67+ if err != nil {
68+ t.Fatalf("Add() error = %v", err)
69+ }
70+ if !engine.Cancel(task.ID) {
71+ t.Fatal("Cancel() = false, want true")
72+ }
73+ if _, ok := engine.Get(task.ID); ok {
74+ t.Fatal("Get() found cancelled task")
75+ }
76+}
77+
78+func TestEngineRunStopsWithContext(t *testing.T) {
79+ engine := NewEngine(nil)
80+ ctx, cancel := context.WithCancel(context.Background())
81+ cancel()
82+ if err := engine.Run(ctx, func(Task) error { return nil }); err != nil {
83+ t.Fatalf("Run() error = %v", err)
84+ }
85+}
A packages/agent/scheduler_runtime.go +85 -0
@@ -0,0 +1,85 @@
1+package agent
2+
3+import (
4+ "context"
5+ "fmt"
6+ "path/filepath"
7+ "strings"
8+ "sync"
9+
10+ "github.com/bnema/zut/packages/agent/extensions"
11+ "github.com/bnema/zut/packages/agent/scheduler"
12+ "github.com/bnema/zut/packages/core"
13+ "github.com/bnema/zut/packages/provider"
14+)
15+
16+// runScheduledSession reconstructs an inactive session in the current zut
17+// process and appends the resulting turn to its existing transcript. The
18+// caller serializes this with session transitions before invoking it.
19+func runScheduledSession(ctx context.Context, task scheduler.Task, args Args, base Resolved, extMgr *extensions.Manager, prepareRegistry func(core.Registry) core.Registry) error {
20+ path, err := core.FindManagedSessionByID(ctx, filepath.Join(ZutHome(), "sessions"), task.SessionID)
21+ if err != nil {
22+ return fmt.Errorf("find scheduled session %q: %w", task.SessionID, err)
23+ }
24+ if path == "" {
25+ return fmt.Errorf("scheduled session %q no longer exists", task.SessionID)
26+ }
27+ sess, messages, err := core.OpenSession(path)
28+ if err != nil {
29+ return fmt.Errorf("open scheduled session: %w", err)
30+ }
31+ defer sess.Close()
32+
33+ providerName := strings.TrimSpace(sess.Meta.Provider)
34+ if providerName == "" {
35+ providerName = base.Provider
36+ }
37+ model := strings.TrimSpace(sess.Meta.Model)
38+ if model == "" {
39+ model = base.Model
40+ }
41+ ag, _, _, err := buildNonInteractiveSessionAgentWithRegistry(ctx, args, base, extMgr, providerName, model, prepareRegistry)
42+ if err != nil {
43+ return fmt.Errorf("build scheduled session agent: %w", err)
44+ }
45+ ag.SetMessages(trimMessagesForResume(messages, 100))
46+ if cumulative, lastTurn, usageErr := core.SessionUsageDetail(path); usageErr == nil {
47+ ag.SeedCost(cumulative)
48+ ag.SeedLastTurnUsage(lastTurn)
49+ }
50+ ag.SetSessionTimeContext(sess.Meta.Started, sess.Meta.Timezone, sess.Meta.TimezoneOffset)
51+ var appendMu sync.Mutex
52+ var appendErr error
53+ recordAppendError := func(err error) {
54+ if err == nil {
55+ return
56+ }
57+ appendMu.Lock()
58+ defer appendMu.Unlock()
59+ if appendErr == nil {
60+ appendErr = err
61+ }
62+ }
63+ ag.OnMessageAppended = func(message provider.Message) {
64+ recordAppendError(sess.AppendMessage(message))
65+ }
66+ ag.OnUsage = func(cumulative provider.Usage) {
67+ recordAppendError(sess.AppendUsage(cumulative, cumulative))
68+ }
69+ ag.OnTranscriptCompacted = func(messages []provider.Message) {
70+ recordAppendError(sess.AppendCompaction(messages))
71+ }
72+ if err := ag.Prompt(ctx, task.Message, nil, func(core.AgentEvent) {}); err != nil {
73+ return err
74+ }
75+ appendMu.Lock()
76+ err = appendErr
77+ appendMu.Unlock()
78+ if err != nil {
79+ return fmt.Errorf("append scheduled transcript: %w", err)
80+ }
81+ if err := sess.Flush(); err != nil {
82+ return fmt.Errorf("flush scheduled session: %w", err)
83+ }
84+ return nil
85+}
@@ -0,0 +1,85 @@
1+package agent
2+
3+import (
4+ "context"
5+ "fmt"
6+ "path/filepath"
7+ "strings"
8+ "sync"
9+
10+ "github.com/bnema/zut/packages/agent/extensions"
11+ "github.com/bnema/zut/packages/agent/scheduler"
12+ "github.com/bnema/zut/packages/core"
13+ "github.com/bnema/zut/packages/provider"
14+)
15+
16+// runScheduledSession reconstructs an inactive session in the current zut
17+// process and appends the resulting turn to its existing transcript. The
18+// caller serializes this with session transitions before invoking it.
19+func runScheduledSession(ctx context.Context, task scheduler.Task, args Args, base Resolved, extMgr *extensions.Manager, prepareRegistry func(core.Registry) core.Registry) error {
20+ path, err := core.FindManagedSessionByID(ctx, filepath.Join(ZutHome(), "sessions"), task.SessionID)
21+ if err != nil {
22+ return fmt.Errorf("find scheduled session %q: %w", task.SessionID, err)
23+ }
24+ if path == "" {
25+ return fmt.Errorf("scheduled session %q no longer exists", task.SessionID)
26+ }
27+ sess, messages, err := core.OpenSession(path)
28+ if err != nil {
29+ return fmt.Errorf("open scheduled session: %w", err)
30+ }
31+ defer sess.Close()
32+
33+ providerName := strings.TrimSpace(sess.Meta.Provider)
34+ if providerName == "" {
35+ providerName = base.Provider
36+ }
37+ model := strings.TrimSpace(sess.Meta.Model)
38+ if model == "" {
39+ model = base.Model
40+ }
41+ ag, _, _, err := buildNonInteractiveSessionAgentWithRegistry(ctx, args, base, extMgr, providerName, model, prepareRegistry)
42+ if err != nil {
43+ return fmt.Errorf("build scheduled session agent: %w", err)
44+ }
45+ ag.SetMessages(trimMessagesForResume(messages, 100))
46+ if cumulative, lastTurn, usageErr := core.SessionUsageDetail(path); usageErr == nil {
47+ ag.SeedCost(cumulative)
48+ ag.SeedLastTurnUsage(lastTurn)
49+ }
50+ ag.SetSessionTimeContext(sess.Meta.Started, sess.Meta.Timezone, sess.Meta.TimezoneOffset)
51+ var appendMu sync.Mutex
52+ var appendErr error
53+ recordAppendError := func(err error) {
54+ if err == nil {
55+ return
56+ }
57+ appendMu.Lock()
58+ defer appendMu.Unlock()
59+ if appendErr == nil {
60+ appendErr = err
61+ }
62+ }
63+ ag.OnMessageAppended = func(message provider.Message) {
64+ recordAppendError(sess.AppendMessage(message))
65+ }
66+ ag.OnUsage = func(cumulative provider.Usage) {
67+ recordAppendError(sess.AppendUsage(cumulative, cumulative))
68+ }
69+ ag.OnTranscriptCompacted = func(messages []provider.Message) {
70+ recordAppendError(sess.AppendCompaction(messages))
71+ }
72+ if err := ag.Prompt(ctx, task.Message, nil, func(core.AgentEvent) {}); err != nil {
73+ return err
74+ }
75+ appendMu.Lock()
76+ err = appendErr
77+ appendMu.Unlock()
78+ if err != nil {
79+ return fmt.Errorf("append scheduled transcript: %w", err)
80+ }
81+ if err := sess.Flush(); err != nil {
82+ return fmt.Errorf("flush scheduled session: %w", err)
83+ }
84+ return nil
85+}
A packages/agent/tools/schedule.go +122 -0
@@ -0,0 +1,122 @@
1+package tools
2+
3+import (
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+
17+const 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.
20+const 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.
25+type ScheduleTool struct {
26+ Engine *scheduler.Engine
27+ SessionID func() string
28+ Location func() *time.Location
29+}
30+
31+type 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+
38+func (t *ScheduleTool) Name() string { return ScheduleToolName }
39+
40+func (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+
44+func (t *ScheduleTool) Schema() json.RawMessage { return json.RawMessage(scheduleToolSchema) }
45+
46+func (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+
116+func scheduleToolResult(text string) (core.ToolResult, error) {
117+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}}, nil
118+}
119+
120+func scheduleToolError(text string) core.ToolResult {
121+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, IsError: true}
122+}
@@ -0,0 +1,122 @@
1+package tools
2+
3+import (
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+
17+const 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.
20+const 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.
25+type ScheduleTool struct {
26+ Engine *scheduler.Engine
27+ SessionID func() string
28+ Location func() *time.Location
29+}
30+
31+type 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+
38+func (t *ScheduleTool) Name() string { return ScheduleToolName }
39+
40+func (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+
44+func (t *ScheduleTool) Schema() json.RawMessage { return json.RawMessage(scheduleToolSchema) }
45+
46+func (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+
116+func scheduleToolResult(text string) (core.ToolResult, error) {
117+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}}, nil
118+}
119+
120+func scheduleToolError(text string) core.ToolResult {
121+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, IsError: true}
122+}
A packages/agent/tools/schedule_test.go +47 -0
@@ -0,0 +1,47 @@
1+package tools
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/zut/packages/agent/scheduler"
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
11+)
12+
13+func TestScheduleToolScopesTasksToCurrentSession(t *testing.T) {
14+ now := time.Date(2026, time.August, 15, 12, 0, 0, 0, time.UTC)
15+ engine := scheduler.NewEngine(func() time.Time { return now })
16+ tool := &ScheduleTool{
17+ Engine: engine,
18+ SessionID: func() string { return "session-a" },
19+ Location: func() *time.Location { return time.UTC },
20+ }
21+
22+ result, err := tool.Execute(nil, json.RawMessage(`{"action":"add","cron":"@hourly","message":"check build"}`), nil)
23+ if err != nil || result.IsError {
24+ t.Fatalf("Execute(add) = %#v, %v", result, err)
25+ }
26+ if got := resultText(result); got == "" {
27+ t.Fatal("Execute(add) returned no confirmation")
28+ }
29+
30+ otherSession, err := engine.Add(scheduler.NewTaskInput{SessionID: "session-b", Cron: "@hourly", Message: "hidden", Location: time.UTC})
31+ if err != nil {
32+ t.Fatalf("Engine.Add() error = %v", err)
33+ }
34+ result, err = tool.Execute(nil, json.RawMessage(`{"action":"cancel","id":"`+otherSession.ID+`"}`), nil)
35+ if err != nil || !result.IsError {
36+ t.Fatalf("Execute(cancel other session) = %#v, %v; want scoped error", result, err)
37+ }
38+}
39+
40+func resultText(result core.ToolResult) string {
41+ for _, content := range result.Content {
42+ if text, ok := content.(provider.TextBlock); ok {
43+ return text.Text
44+ }
45+ }
46+ return ""
47+}
@@ -0,0 +1,47 @@
1+package tools
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/zut/packages/agent/scheduler"
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
11+)
12+
13+func TestScheduleToolScopesTasksToCurrentSession(t *testing.T) {
14+ now := time.Date(2026, time.August, 15, 12, 0, 0, 0, time.UTC)
15+ engine := scheduler.NewEngine(func() time.Time { return now })
16+ tool := &ScheduleTool{
17+ Engine: engine,
18+ SessionID: func() string { return "session-a" },
19+ Location: func() *time.Location { return time.UTC },
20+ }
21+
22+ result, err := tool.Execute(nil, json.RawMessage(`{"action":"add","cron":"@hourly","message":"check build"}`), nil)
23+ if err != nil || result.IsError {
24+ t.Fatalf("Execute(add) = %#v, %v", result, err)
25+ }
26+ if got := resultText(result); got == "" {
27+ t.Fatal("Execute(add) returned no confirmation")
28+ }
29+
30+ otherSession, err := engine.Add(scheduler.NewTaskInput{SessionID: "session-b", Cron: "@hourly", Message: "hidden", Location: time.UTC})
31+ if err != nil {
32+ t.Fatalf("Engine.Add() error = %v", err)
33+ }
34+ result, err = tool.Execute(nil, json.RawMessage(`{"action":"cancel","id":"`+otherSession.ID+`"}`), nil)
35+ if err != nil || !result.IsError {
36+ t.Fatalf("Execute(cancel other session) = %#v, %v; want scoped error", result, err)
37+ }
38+}
39+
40+func resultText(result core.ToolResult) string {
41+ for _, content := range result.Content {
42+ if text, ok := content.(provider.TextBlock); ok {
43+ return text.Text
44+ }
45+ }
46+ return ""
47+}

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