diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 0ea6066..31d8dad 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -1034,7 +1034,7 @@ func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace cronService := cron.NewCronService(cronStorePath, nil) // Create and register CronTool - cronTool := tools.NewCronTool(cronService, agentLoop, msgBus) + cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace) agentLoop.RegisterTool(cronTool) // Set the onJob handler diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 9434ed8..841db0f 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -25,6 +25,7 @@ type CronSchedule struct { type CronPayload struct { Kind string `json:"kind"` Message string `json:"message"` + Command string `json:"command,omitempty"` Deliver bool `json:"deliver"` Channel string `json:"channel,omitempty"` To string `json:"to,omitempty"` @@ -358,6 +359,20 @@ func (cs *CronService) AddJob(name string, schedule CronSchedule, message string return &job, nil } +func (cs *CronService) UpdateJob(job *CronJob) error { + cs.mu.Lock() + defer cs.mu.Unlock() + + for i := range cs.store.Jobs { + if cs.store.Jobs[i].ID == job.ID { + cs.store.Jobs[i] = *job + cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli() + return cs.saveStoreUnsafe() + } + } + return fmt.Errorf("job not found") +} + func (cs *CronService) RemoveJob(jobID string) bool { cs.mu.Lock() defer cs.mu.Unlock() diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 53570a3..438b4f4 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -1,4 +1,4 @@ -package tools + package tools import ( "context" @@ -21,17 +21,19 @@ type CronTool struct { cronService *cron.CronService executor JobExecutor msgBus *bus.MessageBus + execTool *ExecTool channel string chatID string mu sync.RWMutex } // NewCronTool creates a new CronTool -func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus) *CronTool { +func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string) *CronTool { return &CronTool{ cronService: cronService, executor: executor, msgBus: msgBus, + execTool: NewExecTool(workspace, false), } } @@ -42,7 +44,7 @@ func (t *CronTool) Name() string { // Description returns the tool description func (t *CronTool) Description() string { - return "Schedule reminders and tasks. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am)." + return "Schedule reminders, tasks, or system commands. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules. Use 'command' to execute shell commands directly." } // Parameters returns the tool parameters schema @@ -57,7 +59,11 @@ func (t *CronTool) Parameters() map[string]interface{} { }, "message": map[string]interface{}{ "type": "string", - "description": "The reminder/task message to display when triggered (required for add)", + "description": "The reminder/task message to display when triggered. If 'command' is used, this describes what the command does.", + }, + "command": map[string]interface{}{ + "type": "string", + "description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.", }, "at_seconds": map[string]interface{}{ "type": "integer", @@ -165,6 +171,15 @@ func (t *CronTool) addJob(args map[string]interface{}) (string, error) { deliver = d } + command, _ := args["command"].(string) + if command != "" { + // Commands must be processed by agent/exec tool, so deliver must be false (or handled specifically) + // Actually, let's keep deliver=false to let the system know it's not a simple chat message + // But for our new logic in ExecuteJob, we can handle it regardless of deliver flag if Payload.Command is set. + // However, logically, it's not "delivered" to chat directly as is. + deliver = false + } + // Truncate message for job name (max 30 chars) messagePreview := utils.Truncate(message, 30) @@ -179,6 +194,12 @@ func (t *CronTool) addJob(args map[string]interface{}) (string, error) { if err != nil { return fmt.Sprintf("Error adding job: %v", err), nil } + + if command != "" { + job.Payload.Command = command + // Need to save the updated payload + t.cronService.UpdateJob(job) + } return fmt.Sprintf("Created job '%s' (id: %s)", job.Name, job.ID), nil } @@ -252,6 +273,27 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { chatID = "direct" } + // Execute command if present + if job.Payload.Command != "" { + args := map[string]interface{}{ + "command": job.Payload.Command, + } + + output, err := t.execTool.Execute(ctx, args) + if err != nil { + output = fmt.Sprintf("Error executing scheduled command: %v", err) + } else { + output = fmt.Sprintf("Scheduled command '%s' executed:\n%s", job.Payload.Command, output) + } + + t.msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: output, + }) + return "ok" + } + // If deliver=true, send message directly without agent processing if job.Payload.Deliver { t.msgBus.PublishOutbound(bus.OutboundMessage{ diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 9e5d03c..562a327 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -13,6 +13,7 @@ import ( "time" ) + type ExecTool struct { workingDir string timeout time.Duration @@ -94,7 +95,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (st var cmd *exec.Cmd if runtime.GOOS == "windows" { - cmd = exec.CommandContext(cmdCtx, "cmd", "/c", command) + cmd = exec.CommandContext(cmdCtx, "powershell", "-NoProfile", "-NonInteractive", "-Command", command) } else { cmd = exec.CommandContext(cmdCtx, "sh", "-c", command) }