Merge pull request #100 from multica-ai/Bohan-J/cron-job-tool

feat(cron): add cron job scheduling system with Desktop UI
This commit is contained in:
Bohan Jiang 2026-02-06 15:46:41 +08:00 committed by GitHub
commit 91aa433f34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 3840 additions and 69 deletions

View file

@ -190,6 +190,11 @@ interface ElectronAPI {
saveApiKey: (providerId: string, apiKey: string) => Promise<{ ok: boolean; error?: string }>
importOAuth: (providerId: string) => Promise<{ ok: boolean; expiresAt?: number; error?: string }>
}
cron: {
list: () => Promise<unknown[]>
toggle: (jobId: string) => Promise<{ ok: boolean }>
remove: (jobId: string) => Promise<{ ok: boolean }>
}
localChat: {
subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }>
unsubscribe: (agentId: string) => Promise<{ ok: boolean }>

View file

@ -14,6 +14,7 @@ const TOOL_GROUPS: Record<string, string[]> = {
'group:web': ['web_search', 'web_fetch'],
'group:memory': ['memory_search'],
'group:subagent': ['sessions_spawn'],
'group:cron': ['cron'],
}
// All known tool names (for display when agent not available)
@ -23,6 +24,7 @@ const ALL_KNOWN_TOOLS = [
...TOOL_GROUPS['group:web'],
...TOOL_GROUPS['group:memory'],
...TOOL_GROUPS['group:subagent'],
...TOOL_GROUPS['group:cron'],
]
/**

View file

@ -0,0 +1,68 @@
/**
* Cron IPC handlers for Electron main process.
*
* These handlers expose CronService operations to the renderer process
* for the Cron Jobs management page.
*/
import { ipcMain } from 'electron'
import { getCronService, formatSchedule } from '../../../../src/cron/index.js'
/**
* Register all Cron-related IPC handlers.
*/
export function registerCronIpcHandlers(): void {
/**
* List all cron jobs with formatted display fields.
*/
ipcMain.handle('cron:list', async () => {
const service = getCronService()
const jobs = service.list()
return jobs.map((job) => ({
id: job.id,
name: job.name,
description: job.description,
enabled: job.enabled,
schedule: formatSchedule(job.schedule),
sessionTarget: job.sessionTarget,
nextRunAt: job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : null,
lastStatus: job.state.lastStatus ?? null,
lastRunAt: job.state.lastRunAtMs ? new Date(job.state.lastRunAtMs).toISOString() : null,
lastDurationMs: job.state.lastDurationMs ?? null,
lastError: job.state.lastError ?? null,
}))
})
/**
* Toggle a cron job's enabled status.
*/
ipcMain.handle('cron:toggle', async (_event, jobId: string) => {
const service = getCronService()
const job = service.get(jobId)
if (!job) {
return { error: `Job not found: ${jobId}` }
}
const updated = service.update(jobId, { enabled: !job.enabled })
if (!updated) {
return { error: `Failed to update job: ${jobId}` }
}
return {
id: updated.id,
enabled: updated.enabled,
}
})
/**
* Remove a cron job.
*/
ipcMain.handle('cron:remove', async (_event, jobId: string) => {
const service = getCronService()
const removed = service.remove(jobId)
if (!removed) {
return { error: `Job not found: ${jobId}` }
}
return { ok: true }
})
}

View file

@ -6,12 +6,14 @@ export { registerSkillsIpcHandlers } from './skills.js'
export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation } from './hub.js'
export { registerProfileIpcHandlers } from './profile.js'
export { registerProviderIpcHandlers } from './provider.js'
export { registerCronIpcHandlers } from './cron.js'
import { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
import { registerSkillsIpcHandlers } from './skills.js'
import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js'
import { registerProfileIpcHandlers } from './profile.js'
import { registerProviderIpcHandlers } from './provider.js'
import { registerCronIpcHandlers } from './cron.js'
/**
* Register all IPC handlers.
@ -23,6 +25,7 @@ export function registerAllIpcHandlers(): void {
registerSkillsIpcHandlers()
registerProfileIpcHandlers()
registerProviderIpcHandlers()
registerCronIpcHandlers()
}
/**

View file

@ -201,6 +201,13 @@ const electronAPI = {
ipcRenderer.invoke('provider:importOAuth', providerId),
},
// Cron jobs management
cron: {
list: () => ipcRenderer.invoke('cron:list'),
toggle: (jobId: string) => ipcRenderer.invoke('cron:toggle', jobId),
remove: (jobId: string) => ipcRenderer.invoke('cron:remove', jobId),
},
// Local chat (direct IPC, no Gateway required)
localChat: {
/** Subscribe to agent events for local direct chat */

View file

@ -3,6 +3,7 @@ import Layout from './pages/layout'
import HomePage from './pages/home'
import ToolsPage from './pages/tools'
import SkillsPage from './pages/skills'
import CronsPage from './pages/crons'
const router = createHashRouter([
{
@ -13,6 +14,7 @@ const router = createHashRouter([
{ path: 'chat' },
{ path: 'tools', element: <ToolsPage /> },
{ path: 'skills', element: <SkillsPage /> },
{ path: 'crons', element: <CronsPage /> },
],
},
])

View file

@ -0,0 +1,215 @@
import { useState } from 'react'
import { Switch } from '@multica/ui/components/ui/switch'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import {
RotateClockwiseIcon,
Delete02Icon,
Loading03Icon,
Time04Icon,
CheckmarkCircle02Icon,
CancelCircleIcon,
AlertCircleIcon,
} from '@hugeicons/core-free-icons'
import type { CronJobInfo } from '../hooks/use-cron-jobs'
interface CronJobListProps {
jobs: CronJobInfo[]
loading: boolean
error: string | null
onToggleJob: (jobId: string) => Promise<void>
onRemoveJob: (jobId: string) => Promise<void>
onRefresh: () => Promise<void>
}
function StatusBadge({ status }: { status: CronJobInfo['lastStatus'] }) {
if (!status) {
return (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
no runs
</span>
)
}
const config = {
ok: { icon: CheckmarkCircle02Icon, className: 'text-emerald-600', label: 'ok' },
error: { icon: CancelCircleIcon, className: 'text-destructive', label: 'error' },
skipped: { icon: AlertCircleIcon, className: 'text-yellow-600', label: 'skipped' },
}[status]
return (
<span className={`flex items-center gap-1 text-xs ${config.className}`}>
<HugeiconsIcon icon={config.icon} className="size-3.5" />
{config.label}
</span>
)
}
function formatRelativeTime(isoString: string): string {
const date = new Date(isoString)
const now = Date.now()
const diffMs = date.getTime() - now
if (Math.abs(diffMs) < 60_000) return 'just now'
const absMs = Math.abs(diffMs)
const minutes = Math.floor(absMs / 60_000)
const hours = Math.floor(absMs / 3_600_000)
const days = Math.floor(absMs / 86_400_000)
const unit = days > 0 ? `${days}d` : hours > 0 ? `${hours}h` : `${minutes}m`
return diffMs > 0 ? `in ${unit}` : `${unit} ago`
}
export function CronJobList({
jobs,
loading,
error,
onToggleJob,
onRemoveJob,
onRefresh,
}: CronJobListProps) {
const [togglingJobs, setTogglingJobs] = useState<Set<string>>(new Set())
const [removingJobs, setRemovingJobs] = useState<Set<string>>(new Set())
const handleToggle = async (jobId: string) => {
setTogglingJobs((prev) => new Set(prev).add(jobId))
try {
await onToggleJob(jobId)
} finally {
setTogglingJobs((prev) => {
const next = new Set(prev)
next.delete(jobId)
return next
})
}
}
const handleRemove = async (jobId: string) => {
setRemovingJobs((prev) => new Set(prev).add(jobId))
try {
await onRemoveJob(jobId)
} finally {
setRemovingJobs((prev) => {
const next = new Set(prev)
next.delete(jobId)
return next
})
}
}
if (loading && jobs.length === 0) {
return (
<div className="flex items-center justify-center py-12">
<HugeiconsIcon icon={Loading03Icon} className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading cron jobs...</span>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="text-sm text-muted-foreground">
{jobs.filter((j) => j.enabled).length} of {jobs.length} jobs enabled
</div>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
className="gap-1.5"
disabled={loading}
>
<HugeiconsIcon
icon={loading ? Loading03Icon : RotateClockwiseIcon}
className={`size-4 ${loading ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
</div>
{/* Error */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Empty state */}
{jobs.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<HugeiconsIcon icon={Time04Icon} className="size-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">No scheduled tasks</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Use the cron tool in Chat to create one.
</p>
</div>
)}
{/* Job list */}
{jobs.length > 0 && (
<div className="border rounded-lg divide-y">
{jobs.map((job) => {
const isToggling = togglingJobs.has(job.id)
const isRemoving = removingJobs.has(job.id)
return (
<div
key={job.id}
className="flex items-center gap-4 px-4 py-3 hover:bg-muted/20 transition-colors"
>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{job.name}</span>
<StatusBadge status={job.lastStatus} />
</div>
<div className="flex items-center gap-3 mt-0.5 text-xs text-muted-foreground">
<span className="font-mono">{job.schedule}</span>
{job.nextRunAt && job.enabled && (
<span>next: {formatRelativeTime(job.nextRunAt)}</span>
)}
{job.lastRunAt && (
<span>last: {formatRelativeTime(job.lastRunAt)}</span>
)}
</div>
{job.lastError && (
<p className="text-xs text-destructive mt-0.5 truncate">{job.lastError}</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => handleRemove(job.id)}
disabled={isRemoving}
>
{isRemoving ? (
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin" />
) : (
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
)}
</Button>
{isToggling && (
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin text-muted-foreground" />
)}
<Switch
checked={job.enabled}
onCheckedChange={() => handleToggle(job.id)}
disabled={isToggling}
/>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
export default CronJobList

View file

@ -11,6 +11,8 @@ import {
ArrowDown01Icon,
ArrowUp01Icon,
Loading03Icon,
Time04Icon,
UserMultipleIcon,
} from '@hugeicons/core-free-icons'
import type { ToolInfo, ToolGroup } from '../hooks/use-tools'
@ -20,6 +22,8 @@ const GROUP_ICONS: Record<string, typeof FolderOpenIcon> = {
runtime: CodeIcon,
web: GlobalIcon,
memory: AiBrainIcon,
subagent: UserMultipleIcon,
cron: Time04Icon,
other: CodeIcon,
}

View file

@ -0,0 +1,107 @@
import { useState, useEffect, useCallback } from 'react'
export interface CronJobInfo {
id: string
name: string
description?: string
enabled: boolean
schedule: string
sessionTarget: string
nextRunAt: string | null
lastStatus: 'ok' | 'error' | 'skipped' | null
lastRunAt: string | null
lastDurationMs: number | null
lastError: string | null
}
export interface UseCronJobsReturn {
jobs: CronJobInfo[]
loading: boolean
error: string | null
toggleJob: (jobId: string) => Promise<void>
removeJob: (jobId: string) => Promise<void>
refresh: () => Promise<void>
}
export function useCronJobs(): UseCronJobsReturn {
const [jobs, setJobs] = useState<CronJobInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchJobs = useCallback(async () => {
try {
setLoading(true)
setError(null)
const result = window.electronAPI
? await window.electronAPI.cron.list()
: await window.ipcRenderer.invoke('cron:list')
if (Array.isArray(result)) {
setJobs(result)
} else {
setError('Invalid response from cron:list')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch cron jobs')
setJobs([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchJobs()
}, [fetchJobs])
const toggleJob = useCallback(async (jobId: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.cron.toggle(jobId)
: await window.ipcRenderer.invoke('cron:toggle', jobId)
const typed = result as { error?: string; id?: string; enabled?: boolean }
if (typed.error) {
setError(typed.error)
return
}
setJobs((prev) =>
prev.map((job) =>
job.id === jobId ? { ...job, enabled: typed.enabled ?? !job.enabled } : job
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to toggle job')
}
}, [])
const removeJob = useCallback(async (jobId: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.cron.remove(jobId)
: await window.ipcRenderer.invoke('cron:remove', jobId)
const typed = result as { error?: string; ok?: boolean }
if (typed.error) {
setError(typed.error)
return
}
setJobs((prev) => prev.filter((job) => job.id !== jobId))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove job')
}
}, [])
return {
jobs,
loading,
error,
toggleJob,
removeJob,
refresh: fetchJobs,
}
}
export default useCronJobs

View file

@ -31,6 +31,8 @@ const TOOL_DESCRIPTIONS: Record<string, string> = {
memory_set: 'Store a memory value',
memory_delete: 'Delete a memory value',
memory_list: 'List all memory keys',
memory_search: 'Search memory files for keywords',
cron: 'Create and manage scheduled tasks',
}
// Group display names
@ -39,6 +41,8 @@ const GROUP_NAMES: Record<string, string> = {
runtime: 'Runtime',
web: 'Web',
memory: 'Memory',
subagent: 'Subagent',
cron: 'Cron',
other: 'Other',
}

View file

@ -0,0 +1,43 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@multica/ui/components/ui/card'
import { useCronJobs } from '../hooks/use-cron-jobs'
import { CronJobList } from '../components/cron-job-list'
export default function CronsPage() {
const {
jobs,
loading,
error,
toggleJob,
removeJob,
refresh,
} = useCronJobs()
return (
<div className="max-w-4xl mx-auto">
<Card>
<CardHeader>
<CardTitle>Cron Jobs</CardTitle>
<CardDescription>
View and manage scheduled tasks. Create new jobs by asking the Agent in Chat.
</CardDescription>
</CardHeader>
<CardContent>
<CronJobList
jobs={jobs}
loading={loading}
error={error}
onToggleJob={toggleJob}
onRemoveJob={removeJob}
onRefresh={refresh}
/>
</CardContent>
</Card>
</div>
)
}

View file

@ -8,6 +8,7 @@ import {
CodeIcon,
PlugIcon,
Comment01Icon,
Time04Icon,
} from '@hugeicons/core-free-icons'
import { cn } from '@multica/ui/lib/utils'
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
@ -18,6 +19,7 @@ const tabs = [
{ path: '/chat', label: 'Chat', icon: Comment01Icon },
{ path: '/tools', label: 'Tools', icon: CodeIcon },
{ path: '/skills', label: 'Skills', icon: PlugIcon },
{ path: '/crons', label: 'Cron', icon: Time04Icon },
]
export default function Layout() {

View file

@ -0,0 +1,416 @@
# Auto Memory Refresh 实现方案
## 概述
在上下文压缩compaction发生之前自动触发一个特殊的 agent turn让 agent 分析即将被删除的消息,提取关键信息并写入 memory 文件,防止重要上下文丢失。
## OpenClaw 的实现分析
### 核心机制
OpenClaw 采用 **Pre-compaction Memory Flush** 策略:
```
Session 运行中token 累积
totalTokens >= (contextWindow - reserveTokens - softThreshold)?
↓ YES
触发 Memory Flush Turn特殊的 agent 对话轮次)
Agent 分析会话,将重要信息保存到 memory/YYYY-MM-DD.md
然后才执行 Compaction删除旧消息
```
### 关键设计点
1. **Soft Threshold软阈值**
- 默认值:`4000 tokens`
- 触发条件:`totalTokens >= contextWindow - reserveTokens - softThreshold`
- 在真正达到 compaction 阈值之前就触发 memory flush
2. **Memory Flush Prompt**
```
Pre-compaction memory flush. Store durable memories now
(use memory/YYYY-MM-DD.md; create memory/ if needed).
If nothing to store, reply with [SILENT].
```
3. **防重复机制**
- 使用 `memoryFlushCompactionCount` 追踪
- 确保每次 compaction 周期只触发一次 flush
4. **Memory 文件结构**
```
~/.super-multica/agent-profiles/<profile-id>/
├── memory.md # 主 memory 文件
└── memory/
├── 2024-01-15.md # 日期分片
├── 2024-01-16.md
└── topics/
└── project-x.md # 主题分片
```
---
## Super Multica 实现方案
### Phase 1: 核心实现
#### 1.1 新增配置项
**文件:** `src/agent/session/session-manager.ts`
```typescript
export type SessionManagerOptions = {
// ... existing options ...
// Memory Flush 配置
/** 是否启用自动 memory flush默认true */
enableMemoryFlush?: boolean | undefined;
/** Memory flush 软阈值(在 compaction 前多少 tokens 触发),默认 4000 */
memoryFlushSoftTokens?: number | undefined;
};
```
#### 1.2 新增 Memory Flush 模块
**文件:** `src/agent/memory/memory-flush.ts`
```typescript
/** Memory flush 配置 */
export type MemoryFlushSettings = {
/** 软阈值tokens在达到 compaction 阈值前多少 tokens 触发 */
softThresholdTokens: number;
/** Memory flush 使用的系统 prompt */
systemPrompt: string;
/** Memory flush 使用的用户 prompt */
userPrompt: string;
};
export const DEFAULT_MEMORY_FLUSH_SETTINGS: MemoryFlushSettings = {
softThresholdTokens: 4000,
systemPrompt: `You are in a pre-compaction memory flush turn. The session is approaching context limit and old messages will be deleted soon.
Your task: Review the conversation and extract any important information that should be preserved in long-term memory. Focus on:
- User preferences and settings
- Key decisions made
- Important technical details or solutions
- Project-specific knowledge
- Anything the user would want remembered in future sessions
Use the memory_write tool to save important information. If there's nothing worth saving, respond with [SILENT].`,
userPrompt: `[SYSTEM] Pre-compaction memory flush triggered. Please review recent conversation and save any important information to memory before context compression occurs.`,
};
/** 检查是否应该触发 memory flush */
export function shouldRunMemoryFlush(params: {
currentTokens: number;
contextWindowTokens: number;
reserveTokens: number;
softThresholdTokens: number;
lastMemoryFlushCompactionCount?: number;
currentCompactionCount: number;
}): boolean {
const {
currentTokens,
contextWindowTokens,
reserveTokens,
softThresholdTokens,
lastMemoryFlushCompactionCount,
currentCompactionCount,
} = params;
// 如果当前 compaction 周期已经 flush 过,不再触发
if (lastMemoryFlushCompactionCount === currentCompactionCount) {
return false;
}
// 计算 flush 阈值
const flushThreshold = contextWindowTokens - reserveTokens - softThresholdTokens;
return currentTokens >= flushThreshold;
}
```
#### 1.3 扩展 SessionEntry 类型
**文件:** `src/agent/session/types.ts`
```typescript
export type SessionMeta = {
// ... existing fields ...
/** 上次 memory flush 的时间戳 */
memoryFlushAt?: number;
/** 上次 memory flush 时的 compaction 计数 */
memoryFlushCompactionCount?: number;
/** Compaction 次数 */
compactionCount?: number;
};
```
#### 1.4 修改 Agent Runner
**文件:** `src/agent/runner.ts`
```typescript
// 在 maybeCompact 之前检查并执行 memory flush
private async maybeCompact() {
const messages = this.agent.state.messages.slice();
// Phase 0: Check if memory flush is needed
if (this.enableMemoryFlush) {
const shouldFlush = shouldRunMemoryFlush({
currentTokens: this.estimateCurrentTokens(messages),
contextWindowTokens: this.contextWindowGuard.tokens,
reserveTokens: this.reserveTokens,
softThresholdTokens: this.memoryFlushSoftTokens,
lastMemoryFlushCompactionCount: this.session.getMeta()?.memoryFlushCompactionCount,
currentCompactionCount: this.session.getCompactionCount(),
});
if (shouldFlush) {
await this.runMemoryFlush();
}
}
// 继续原有的 compaction 逻辑...
if (!this.session.needsCompaction(messages)) return;
// ...
}
private async runMemoryFlush() {
this.emitMulticaEvent({ type: "memory_flush_start" });
try {
// 创建一个临时的 agent turn 来执行 memory flush
const flushResult = await this.executeMemoryFlushTurn();
// 更新 session metadata
this.session.saveMeta({
...this.session.getMeta(),
memoryFlushAt: Date.now(),
memoryFlushCompactionCount: this.session.getCompactionCount(),
});
this.emitMulticaEvent({
type: "memory_flush_end",
saved: flushResult.memoriesSaved,
});
} catch (error) {
console.error("[Agent] Memory flush failed:", error);
// Memory flush 失败不阻塞 compaction
}
}
private async executeMemoryFlushTurn(): Promise<{ memoriesSaved: number }> {
// 使用特殊的 system prompt 和 user prompt
// 让 agent 分析当前会话并保存重要信息
// 只允许使用 memory_write 工具
const originalSystemPrompt = this.agent.state.systemPrompt;
const originalTools = this.agent.state.tools;
try {
// 临时切换到 memory flush 模式
this.agent.setSystemPrompt(this.memoryFlushSettings.systemPrompt);
this.agent.setTools([this.memoryWriteTool]); // 只允许 memory_write
// 执行一个 agent turn
await this.agent.run(this.memoryFlushSettings.userPrompt);
// 统计保存了多少 memory
return { memoriesSaved: this.countMemoryWriteCalls() };
} finally {
// 恢复原始设置
this.agent.setSystemPrompt(originalSystemPrompt);
this.agent.setTools(originalTools);
}
}
```
#### 1.5 新增 Memory Flush Events
**文件:** `src/agent/events.ts`
```typescript
/** Memory flush 开始事件 */
export type MemoryFlushStartEvent = {
type: "memory_flush_start";
};
/** Memory flush 结束事件 */
export type MemoryFlushEndEvent = {
type: "memory_flush_end";
/** 保存的 memory 条目数 */
saved: number;
};
/** Union of all Multica-specific events */
export type MulticaEvent =
| CompactionStartEvent
| CompactionEndEvent
| MemoryFlushStartEvent
| MemoryFlushEndEvent;
```
---
### Phase 2: Memory 文件管理
#### 2.1 Memory 文件结构
```
~/.super-multica/agent-profiles/<profile-id>/
├── identity.md # 身份设定(已有)
├── memory.md # 主 memory 文件(已有)
├── memory/ # Memory 分片目录(新增)
│ ├── 2024-01-15.md # 日期分片
│ ├── 2024-01-16.md
│ └── ...
└── sessions/ # Session 记录(已有)
```
#### 2.2 Memory Write Tool 增强
**文件:** `src/agent/tools/memory/memory-write.ts`
```typescript
// 支持写入日期分片
export function resolveMemoryPath(
profileDir: string,
targetFile?: string
): string {
if (!targetFile) {
// 默认写入今天的日期分片
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
const memoryDir = path.join(profileDir, 'memory');
ensureDirSync(memoryDir);
return path.join(memoryDir, `${today}.md`);
}
// 支持指定文件
if (targetFile.startsWith('memory/')) {
return path.join(profileDir, targetFile);
}
return path.join(profileDir, 'memory.md');
}
```
---
### Phase 3: 前端集成
#### 3.1 SDK 事件类型
**文件:** `packages/sdk/src/types.ts`
```typescript
export type LocalChatEvent =
| MessageStartEvent
| MessageUpdateEvent
| MessageEndEvent
| ToolExecutionStartEvent
| ToolExecutionEndEvent
| CompactionStartEvent
| CompactionEndEvent
| MemoryFlushStartEvent // 新增
| MemoryFlushEndEvent; // 新增
```
#### 3.2 Zustand Store
**文件:** `packages/store/src/agent-store.ts`
```typescript
interface AgentState {
// ... existing state ...
/** 是否正在进行 memory flush */
memoryFlushing: boolean;
/** 上次 memory flush 信息 */
lastMemoryFlush: {
timestamp: number;
saved: number;
} | null;
}
```
#### 3.3 Desktop UI 提示
在 compaction 提示之前显示 "正在保存重要记忆..."
---
## 实现顺序
1. **Step 1**: 新增 `memory-flush.ts` 模块,定义类型和判断逻辑
2. **Step 2**: 扩展 `SessionMeta` 类型,添加 flush 相关字段
3. **Step 3**: 新增 `MemoryFlushStartEvent``MemoryFlushEndEvent` 事件
4. **Step 4**: 修改 `Agent` 类,添加 `runMemoryFlush` 方法
5. **Step 5**: 修改 `maybeCompact` 流程,在 compaction 前检查并执行 flush
6. **Step 6**: 增强 `memory_write` tool支持日期分片
7. **Step 7**: SDK 和 Store 集成
8. **Step 8**: Desktop UI 提示
---
## 配置项汇总
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `enableMemoryFlush` | boolean | true | 是否启用自动 memory flush |
| `memoryFlushSoftTokens` | number | 4000 | 在 compaction 阈值前多少 tokens 触发 |
---
## 与现有功能的关系
```
Token 累积
达到 Memory Flush 阈值? ──────────────────────┐
↓ YES │
Memory Flush Turn新功能
├─ Agent 分析会话 │
├─ 调用 memory_write 保存重要信息 │
└─ 更新 memoryFlushCompactionCount │
↓ │
达到 Compaction 阈值? ←───────────────────────┘
↓ YES ↓ NO
┌─────────────────────────────┐ │
│ Tool Result Pruning已实现│ │
│ Soft-trim / Hard-clear │ │
└─────────────────────────────┘ │
↓ │
Message Compaction │
├─ 删除旧消息 │
└─ 或生成摘要 │
↓ │
继续会话 ←──────────────────────────────────┘
```
---
## 风险和注意事项
1. **Token 消耗**: Memory flush turn 本身会消耗 tokens需要控制 prompt 长度
2. **循环触发**: 需要 `memoryFlushCompactionCount` 防止重复触发
3. **Tool 限制**: Flush turn 应该只允许 `memory_write`,防止执行其他操作
4. **超时处理**: Flush turn 需要有超时机制,不能阻塞太久
5. **静默响应**: 如果没有需要保存的内容agent 应该返回 `[SILENT]` 跳过
---
## 测试计划
1. **单元测试**: `shouldRunMemoryFlush` 判断逻辑
2. **集成测试**: Memory flush turn 执行流程
3. **E2E 测试**: 完整的 flush → compaction 流程
4. **边界测试**:
- 连续多次 compaction 只触发一次 flush
- Flush 失败不阻塞 compaction
- Agent 返回 [SILENT] 时正常跳过

View file

@ -0,0 +1,823 @@
# Cron Job Tool 实现方案
## 概述
Cron Job Tool 允许 Agent 创建定时任务,在指定时间或周期性地执行操作。这对于提醒、定期检查、自动化工作流等场景非常有用。
## OpenClaw 实现分析
### 核心架构
```
┌─────────────────────────────────────────────────────────────┐
│ CronService │
├─────────────────────────────────────────────────────────────┤
│ start() → 加载 jobs, 计算下次运行时间, 启动 timer │
│ add() → 创建任务, 计算 schedule, 持久化 │
│ update() → 修改任务, 重新计算 schedule │
│ remove() → 删除任务 │
│ run() → 立即执行任务 │
│ list() → 列出所有任务 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Timer Loop │
├─────────────────────────────────────────────────────────────┤
│ armTimer(nextWakeAtMs) │
│ ↓ │
│ onTimer() → runDueJobs() → executeJob() │
│ ↓ │
│ Update state, re-arm timer │
└─────────────────────────────────────────────────────────────┘
```
### Job 类型
**Schedule 类型:**
1. **at** - 一次性任务(指定时间戳)
2. **every** - 固定间隔(如每 30 分钟)
3. **cron** - 标准 cron 表达式5 字段 + 可选时区)
**Session Target**
1. **main** - 注入到主会话(作为系统事件)
2. **isolated** - 在独立会话中运行 agent turn
**Payload 类型:**
1. **systemEvent** - 注入文本到主会话
2. **agentTurn** - 在独立会话中执行 agent可指定 model/thinking
### 存储结构
```
~/.openclaw/cron/
├── jobs.json # 所有任务定义
├── jobs.json.bak # 备份
└── runs/
├── <jobId-1>.jsonl # 任务1的运行历史
└── <jobId-2>.jsonl # 任务2的运行历史
```
---
## Super Multica 实现方案
### Phase 1: 核心数据结构
#### 1.1 Job 类型定义
**文件:** `src/cron/types.ts`
```typescript
import type { v7 as uuidv7 } from "uuid";
/** Cron 任务调度类型 */
export type CronSchedule =
| { kind: "at"; atMs: number } // 一次性(时间戳)
| { kind: "every"; everyMs: number; anchorMs?: number } // 固定间隔
| { kind: "cron"; expr: string; tz?: string }; // Cron 表达式
/** 任务执行目标 */
export type CronSessionTarget = "main" | "isolated";
/** 唤醒模式 */
export type CronWakeMode = "next-heartbeat" | "now";
/** 任务载荷 */
export type CronPayload =
| {
kind: "system-event";
text: string; // 注入到主会话的文本
}
| {
kind: "agent-turn";
message: string; // Agent 执行的 prompt
model?: string; // 可选 model override
thinkingLevel?: string; // 可选 thinking level
timeoutSeconds?: number; // 超时时间
};
/** 任务运行状态 */
export type CronJobState = {
nextRunAtMs?: number; // 下次运行时间
runningAtMs?: number; // 正在运行的时间戳(锁)
lastRunAtMs?: number; // 上次运行时间
lastStatus?: "ok" | "error" | "skipped";
lastError?: string;
lastDurationMs?: number;
};
/** Cron 任务定义 */
export type CronJob = {
id: string; // UUID
name: string; // 用户友好名称
description?: string; // 描述
enabled: boolean; // 是否启用
deleteAfterRun?: boolean; // 运行后自动删除(一次性任务)
createdAtMs: number;
updatedAtMs: number;
schedule: CronSchedule;
sessionTarget: CronSessionTarget;
wakeMode: CronWakeMode;
payload: CronPayload;
state: CronJobState;
};
/** 创建任务的输入 */
export type CronJobInput = Omit<CronJob, "id" | "createdAtMs" | "updatedAtMs" | "state">;
/** 运行日志条目 */
export type CronRunLogEntry = {
ts: number;
jobId: string;
action: "run" | "skip" | "error";
status: "ok" | "error" | "skipped";
error?: string;
summary?: string;
durationMs?: number;
nextRunAtMs?: number;
};
```
#### 1.2 存储层
**文件:** `src/cron/store.ts`
```typescript
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import path from "path";
import type { CronJob, CronRunLogEntry } from "./types.js";
const DEFAULT_CRON_DIR = path.join(
process.env["HOME"] ?? ".",
".super-multica",
"cron"
);
export class CronStore {
private readonly jobsPath: string;
private readonly runsDir: string;
private jobs: Map<string, CronJob> = new Map();
constructor(baseDir: string = DEFAULT_CRON_DIR) {
this.jobsPath = path.join(baseDir, "jobs.json");
this.runsDir = path.join(baseDir, "runs");
this.ensureDirs();
}
private ensureDirs() {
const dir = path.dirname(this.jobsPath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
if (!existsSync(this.runsDir)) mkdirSync(this.runsDir, { recursive: true });
}
load(): CronJob[] {
if (!existsSync(this.jobsPath)) return [];
const data = JSON.parse(readFileSync(this.jobsPath, "utf-8"));
this.jobs = new Map(data.jobs.map((j: CronJob) => [j.id, j]));
return Array.from(this.jobs.values());
}
save() {
const jobs = Array.from(this.jobs.values());
// Backup first
if (existsSync(this.jobsPath)) {
writeFileSync(this.jobsPath + ".bak", readFileSync(this.jobsPath));
}
writeFileSync(this.jobsPath, JSON.stringify({ jobs }, null, 2));
}
get(id: string): CronJob | undefined {
return this.jobs.get(id);
}
set(job: CronJob) {
this.jobs.set(job.id, job);
this.save();
}
delete(id: string): boolean {
const deleted = this.jobs.delete(id);
if (deleted) this.save();
return deleted;
}
list(filter?: { enabled?: boolean }): CronJob[] {
let jobs = Array.from(this.jobs.values());
if (filter?.enabled !== undefined) {
jobs = jobs.filter((j) => j.enabled === filter.enabled);
}
return jobs;
}
// Run log methods
appendRunLog(jobId: string, entry: CronRunLogEntry) {
const logPath = path.join(this.runsDir, `${jobId}.jsonl`);
const line = JSON.stringify(entry) + "\n";
writeFileSync(logPath, line, { flag: "a" });
}
getRunLogs(jobId: string, limit = 50): CronRunLogEntry[] {
const logPath = path.join(this.runsDir, `${jobId}.jsonl`);
if (!existsSync(logPath)) return [];
const lines = readFileSync(logPath, "utf-8").trim().split("\n");
return lines.slice(-limit).map((l) => JSON.parse(l));
}
}
```
---
### Phase 2: Cron Service
**文件:** `src/cron/service.ts`
```typescript
import { v7 as uuidv7 } from "uuid";
import Croner from "croner";
import type { CronJob, CronJobInput, CronSchedule } from "./types.js";
import { CronStore } from "./store.js";
export class CronService {
private store: CronStore;
private timer: NodeJS.Timeout | null = null;
private running = false;
constructor(store?: CronStore) {
this.store = store ?? new CronStore();
}
/** 启动服务 */
async start() {
if (this.running) return;
this.running = true;
this.store.load();
this.recomputeAllSchedules();
this.armTimer();
}
/** 停止服务 */
stop() {
this.running = false;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
/** 获取服务状态 */
status() {
const jobs = this.store.list({ enabled: true });
const nextWake = Math.min(
...jobs.map((j) => j.state.nextRunAtMs ?? Infinity)
);
return {
running: this.running,
jobCount: jobs.length,
nextWakeAtMs: nextWake === Infinity ? null : nextWake,
};
}
/** 列出任务 */
list(filter?: { enabled?: boolean }) {
return this.store.list(filter);
}
/** 添加任务 */
add(input: CronJobInput): CronJob {
const now = Date.now();
const job: CronJob = {
...input,
id: uuidv7(),
createdAtMs: now,
updatedAtMs: now,
state: {},
};
this.computeNextRun(job);
this.store.set(job);
this.armTimer();
return job;
}
/** 更新任务 */
update(id: string, patch: Partial<CronJobInput>): CronJob | null {
const job = this.store.get(id);
if (!job) return null;
Object.assign(job, patch, { updatedAtMs: Date.now() });
if (patch.schedule) {
this.computeNextRun(job);
}
this.store.set(job);
this.armTimer();
return job;
}
/** 删除任务 */
remove(id: string): boolean {
return this.store.delete(id);
}
/** 立即运行任务 */
async run(id: string, force = false): Promise<{ ok: boolean; reason?: string }> {
const job = this.store.get(id);
if (!job) return { ok: false, reason: "Job not found" };
if (!job.enabled && !force) return { ok: false, reason: "Job disabled" };
await this.executeJob(job);
return { ok: true };
}
/** 获取运行日志 */
getRunLogs(id: string) {
return this.store.getRunLogs(id);
}
// === Private Methods ===
private computeNextRun(job: CronJob) {
const now = Date.now();
let nextMs: number;
switch (job.schedule.kind) {
case "at":
nextMs = job.schedule.atMs;
break;
case "every":
const anchor = job.schedule.anchorMs ?? now;
const interval = job.schedule.everyMs;
const elapsed = now - anchor;
const periods = Math.ceil(elapsed / interval);
nextMs = anchor + periods * interval;
break;
case "cron":
const cron = Croner(job.schedule.expr, {
timezone: job.schedule.tz,
});
const next = cron.nextRun();
nextMs = next ? next.getTime() : now + 86400000; // fallback 1 day
break;
}
job.state.nextRunAtMs = nextMs;
}
private recomputeAllSchedules() {
for (const job of this.store.list({ enabled: true })) {
this.computeNextRun(job);
this.store.set(job);
}
}
private armTimer() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const jobs = this.store.list({ enabled: true });
const nextWake = Math.min(
...jobs.map((j) => j.state.nextRunAtMs ?? Infinity)
);
if (nextWake === Infinity) return;
const delay = Math.max(0, nextWake - Date.now());
this.timer = setTimeout(() => this.onTimer(), delay);
}
private async onTimer() {
const now = Date.now();
const dueJobs = this.store
.list({ enabled: true })
.filter((j) => (j.state.nextRunAtMs ?? Infinity) <= now);
for (const job of dueJobs) {
await this.executeJob(job);
}
this.armTimer();
}
private async executeJob(job: CronJob) {
const startMs = Date.now();
job.state.runningAtMs = startMs;
this.store.set(job);
try {
// TODO: 实际执行逻辑
// - systemEvent: 注入到主会话
// - agentTurn: 在独立会话中运行 agent
console.log(`[Cron] Executing job: ${job.name} (${job.id})`);
// 模拟执行
await new Promise((r) => setTimeout(r, 100));
// 更新状态
job.state.lastRunAtMs = startMs;
job.state.lastStatus = "ok";
job.state.lastDurationMs = Date.now() - startMs;
job.state.runningAtMs = undefined;
// 一次性任务处理
if (job.schedule.kind === "at") {
if (job.deleteAfterRun) {
this.store.delete(job.id);
} else {
job.enabled = false;
}
} else {
this.computeNextRun(job);
}
this.store.set(job);
this.store.appendRunLog(job.id, {
ts: startMs,
jobId: job.id,
action: "run",
status: "ok",
durationMs: job.state.lastDurationMs,
nextRunAtMs: job.state.nextRunAtMs,
});
} catch (error) {
job.state.lastRunAtMs = startMs;
job.state.lastStatus = "error";
job.state.lastError = String(error);
job.state.lastDurationMs = Date.now() - startMs;
job.state.runningAtMs = undefined;
this.computeNextRun(job);
this.store.set(job);
this.store.appendRunLog(job.id, {
ts: startMs,
jobId: job.id,
action: "error",
status: "error",
error: String(error),
durationMs: job.state.lastDurationMs,
});
}
}
}
```
---
### Phase 3: Agent Tool
**文件:** `src/agent/tools/cron/cron-tool.ts`
```typescript
import type { Tool } from "@mariozechner/pi-agent-core";
import { CronService } from "../../../cron/service.js";
let cronService: CronService | null = null;
export function getCronService(): CronService {
if (!cronService) {
cronService = new CronService();
cronService.start();
}
return cronService;
}
export const cronTool: Tool = {
name: "cron",
description: `Create, manage, and execute scheduled tasks (cron jobs).
## Actions
### list
List all cron jobs.
\`\`\`json
{ "action": "list", "enabled": true }
\`\`\`
### add
Create a new cron job.
\`\`\`json
{
"action": "add",
"name": "Daily reminder",
"schedule": { "kind": "cron", "expr": "0 9 * * *", "tz": "Asia/Shanghai" },
"sessionTarget": "main",
"wakeMode": "now",
"payload": { "kind": "system-event", "text": "Check your todos!" }
}
\`\`\`
Schedule types:
- \`{ "kind": "at", "atMs": 1704067200000 }\` - One-time at timestamp
- \`{ "kind": "every", "everyMs": 3600000 }\` - Every hour
- \`{ "kind": "cron", "expr": "0 9 * * *", "tz": "Asia/Shanghai" }\` - Cron expression
### update
Update an existing job.
\`\`\`json
{ "action": "update", "jobId": "xxx", "enabled": false }
\`\`\`
### remove
Delete a job.
\`\`\`json
{ "action": "remove", "jobId": "xxx" }
\`\`\`
### run
Execute a job immediately.
\`\`\`json
{ "action": "run", "jobId": "xxx", "force": true }
\`\`\`
### status
Get cron service status.
\`\`\`json
{ "action": "status" }
\`\`\`
`,
parameters: {
type: "object",
properties: {
action: {
type: "string",
enum: ["list", "add", "update", "remove", "run", "status"],
description: "The action to perform",
},
// list
enabled: { type: "boolean", description: "Filter by enabled status" },
// add
name: { type: "string", description: "Job name" },
description: { type: "string", description: "Job description" },
schedule: { type: "object", description: "Schedule configuration" },
sessionTarget: {
type: "string",
enum: ["main", "isolated"],
description: "Where to run the job",
},
wakeMode: {
type: "string",
enum: ["next-heartbeat", "now"],
description: "When to wake after job",
},
payload: { type: "object", description: "Job payload" },
deleteAfterRun: { type: "boolean", description: "Delete after one-time run" },
// update/remove/run
jobId: { type: "string", description: "Job ID" },
// run
force: { type: "boolean", description: "Force run even if disabled" },
},
required: ["action"],
},
execute: async (params: Record<string, unknown>) => {
const service = getCronService();
const action = params["action"] as string;
switch (action) {
case "status":
return JSON.stringify(service.status(), null, 2);
case "list":
const jobs = service.list({
enabled: params["enabled"] as boolean | undefined,
});
return JSON.stringify(jobs, null, 2);
case "add":
const newJob = service.add({
name: params["name"] as string,
description: params["description"] as string | undefined,
enabled: true,
deleteAfterRun: params["deleteAfterRun"] as boolean | undefined,
schedule: params["schedule"] as any,
sessionTarget: (params["sessionTarget"] as any) ?? "main",
wakeMode: (params["wakeMode"] as any) ?? "next-heartbeat",
payload: params["payload"] as any,
});
return `Created job: ${newJob.name} (${newJob.id})\nNext run: ${new Date(newJob.state.nextRunAtMs!).toISOString()}`;
case "update":
const updated = service.update(params["jobId"] as string, params as any);
if (!updated) return "Job not found";
return `Updated job: ${updated.name}`;
case "remove":
const removed = service.remove(params["jobId"] as string);
return removed ? "Job removed" : "Job not found";
case "run":
const result = await service.run(
params["jobId"] as string,
params["force"] as boolean
);
return result.ok ? "Job executed" : `Failed: ${result.reason}`;
default:
return `Unknown action: ${action}`;
}
},
};
```
---
### Phase 4: CLI 命令
**文件:** `src/agent/cli/commands/cron.ts`
```typescript
import { Command } from "commander";
import { getCronService } from "../../tools/cron/cron-tool.js";
export function registerCronCommands(program: Command) {
const cron = program.command("cron").description("Manage cron jobs");
cron
.command("status")
.description("Show cron service status")
.action(() => {
const service = getCronService();
const status = service.status();
console.log("Cron Service Status:");
console.log(` Running: ${status.running}`);
console.log(` Jobs: ${status.jobCount}`);
if (status.nextWakeAtMs) {
console.log(` Next wake: ${new Date(status.nextWakeAtMs).toISOString()}`);
}
});
cron
.command("list")
.description("List all cron jobs")
.option("--enabled", "Show only enabled jobs")
.option("--disabled", "Show only disabled jobs")
.action((opts) => {
const service = getCronService();
const enabled = opts.enabled ? true : opts.disabled ? false : undefined;
const jobs = service.list({ enabled });
if (jobs.length === 0) {
console.log("No cron jobs found.");
return;
}
for (const job of jobs) {
console.log(`\n${job.enabled ? "✓" : "✗"} ${job.name} (${job.id})`);
console.log(` Schedule: ${formatSchedule(job.schedule)}`);
console.log(` Target: ${job.sessionTarget}`);
if (job.state.nextRunAtMs) {
console.log(` Next run: ${new Date(job.state.nextRunAtMs).toISOString()}`);
}
if (job.state.lastStatus) {
console.log(` Last run: ${job.state.lastStatus} (${job.state.lastDurationMs}ms)`);
}
}
});
cron
.command("add")
.description("Add a new cron job")
.requiredOption("-n, --name <name>", "Job name")
.option("--at <time>", "One-time at ISO timestamp or relative (e.g., '10m', '2h')")
.option("--every <interval>", "Repeat interval (e.g., '30m', '1h', '1d')")
.option("--cron <expr>", "Cron expression (5-field)")
.option("--tz <timezone>", "Timezone for cron expression")
.option("--message <text>", "System event text or agent prompt")
.option("--isolated", "Run in isolated session")
.option("--delete-after-run", "Delete after one-time run")
.action((opts) => {
const service = getCronService();
let schedule;
if (opts.at) {
schedule = { kind: "at" as const, atMs: parseTime(opts.at) };
} else if (opts.every) {
schedule = { kind: "every" as const, everyMs: parseInterval(opts.every) };
} else if (opts.cron) {
schedule = { kind: "cron" as const, expr: opts.cron, tz: opts.tz };
} else {
console.error("Must specify --at, --every, or --cron");
return;
}
const job = service.add({
name: opts.name,
enabled: true,
deleteAfterRun: opts.deleteAfterRun,
schedule,
sessionTarget: opts.isolated ? "isolated" : "main",
wakeMode: "now",
payload: {
kind: "system-event",
text: opts.message ?? "Cron job triggered",
},
});
console.log(`Created job: ${job.name} (${job.id})`);
console.log(`Next run: ${new Date(job.state.nextRunAtMs!).toISOString()}`);
});
// ... more commands: run, remove, enable, disable, logs
}
function formatSchedule(schedule: any): string {
switch (schedule.kind) {
case "at":
return `at ${new Date(schedule.atMs).toISOString()}`;
case "every":
return `every ${schedule.everyMs}ms`;
case "cron":
return `cron "${schedule.expr}"${schedule.tz ? ` (${schedule.tz})` : ""}`;
}
}
function parseTime(s: string): number {
// Handle relative times like "10m", "2h"
const match = s.match(/^(\d+)([smhd])$/);
if (match) {
const [, num, unit] = match;
const ms = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
}[unit]!;
return Date.now() + parseInt(num) * ms;
}
return new Date(s).getTime();
}
function parseInterval(s: string): number {
return parseTime(s) - Date.now();
}
```
---
## 实现顺序
| Phase | 内容 | 优先级 |
|-------|------|--------|
| **1** | 类型定义 + 存储层 | P0 |
| **2** | CronService 核心逻辑 | P0 |
| **3** | Agent Tool (cron) | P0 |
| **4** | CLI 命令 (multica cron) | P1 |
| **5** | 独立会话执行 (isolated agent turn) | P1 |
| **6** | Hub 集成 (Gateway API) | P2 |
| **7** | Desktop UI 管理界面 | P2 |
---
## 与 OpenClaw 的差异
| 特性 | OpenClaw | Super Multica (建议) |
|------|----------|---------------------|
| 存储位置 | `~/.openclaw/cron/` | `~/.super-multica/cron/` |
| 独立会话 | 完整实现 | Phase 1 先实现 main session |
| 消息投递 | 支持 WhatsApp/Telegram 等 | 暂不实现 |
| Gateway API | 完整实现 | Phase 2 实现 |
| 并发控制 | maxConcurrentRuns | 暂时单线程执行 |
---
## 使用示例
```bash
# CLI: 10分钟后提醒
multica cron add --name "Reminder" --at "10m" --message "Time to take a break!"
# CLI: 每天早上9点北京时间
multica cron add --name "Morning check" --cron "0 9 * * *" --tz "Asia/Shanghai" \
--message "Good morning! Check your tasks."
# CLI: 每30分钟
multica cron add --name "Health check" --every "30m" --message "System health check"
# Agent Tool 调用
{
"action": "add",
"name": "Daily standup reminder",
"schedule": { "kind": "cron", "expr": "55 9 * * 1-5", "tz": "Asia/Shanghai" },
"sessionTarget": "main",
"payload": { "kind": "system-event", "text": "Standup meeting in 5 minutes!" }
}
```
---
## 依赖
需要添加的依赖:
```bash
pnpm add croner # Cron 表达式解析库
```
---
## 风险和注意事项
1. **进程生命周期**: Desktop app 关闭后 timer 停止,需要重启时恢复
2. **时区处理**: 使用 `croner` 库正确处理时区
3. **并发安全**: 文件操作需要加锁防止竞争
4. **内存泄漏**: 确保 timer 正确清理
5. **错误恢复**: Job 执行失败不应影响其他 job

View file

@ -30,7 +30,10 @@
"license": "ISC",
"packageManager": "pnpm@10.28.2",
"pnpm": {
"onlyBuiltDependencies": ["electron", "esbuild"]
"onlyBuiltDependencies": [
"electron",
"esbuild"
]
},
"devDependencies": {
"@types/node": "catalog:",
@ -57,6 +60,7 @@
"@nestjs/serve-static": "^5.0.4",
"@nestjs/websockets": "^11.1.12",
"@sinclair/typebox": "^0.34.41",
"croner": "^10.0.1",
"fast-glob": "^3.3.3",
"json5": "^2.2.3",
"linkedom": "^0.18.12",

143
pnpm-lock.yaml generated
View file

@ -34,13 +34,13 @@ importers:
dependencies:
'@mariozechner/pi-agent-core':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-ai':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-coding-agent':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mozilla/readability':
specifier: ^0.6.0
version: 0.6.0
@ -68,6 +68,9 @@ importers:
'@sinclair/typebox':
specifier: ^0.34.41
version: 0.34.48
croner:
specifier: ^10.0.1
version: 10.0.1
fast-glob:
specifier: ^3.3.3
version: 3.3.3
@ -288,7 +291,7 @@ importers:
version: 8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-router:
specifier: ~6.0.23
version: 6.0.23(w7u2e3gmpia3npio76ytuzityu)
version: 6.0.23(52130e04a12e6efd1d6e48b3f2ad01c3)
expo-splash-screen:
specifier: ~31.0.13
version: 31.0.13(expo@54.0.33)
@ -1905,89 +1908,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@ -2152,24 +2171,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@mariozechner/clipboard-linux-riscv64-gnu@0.3.0':
resolution: {integrity: sha512-4BC08CIaOXSSAGRZLEjqJmQfioED8ohAzwt0k2amZPEbH96YKoBNorq5EdwPf5VT+odS0DeyCwhwtxokRLZIvQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@mariozechner/clipboard-linux-x64-gnu@0.3.0':
resolution: {integrity: sha512-GpNY5Y9nOzr0Vt0Qi5U88qwe6piiIHk44kSMexl8ns90LluN5UTNYmyfi7Xq3/lmPZCpnB2xvBTYbsXCxnopIA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@mariozechner/clipboard-linux-x64-musl@0.3.0':
resolution: {integrity: sha512-+PnR48/x9GMY5Kh8BLjzHMx6trOegMtxAuqTM9X/bhV3QuW6sLLd7nojDHSGj/ZueK6i0tcQxvOrgNLozVtNDA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@mariozechner/clipboard-win32-arm64-msvc@0.3.0':
resolution: {integrity: sha512-+dy2vZ1Ph4EYj0cotB+bVUVk/uKl2bh9LOp/zlnFqoCCYDN6sm+L0VyIOPPo3hjoEVdGpHe1MUxp3qG/OLwXgg==}
@ -2331,24 +2354,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.6':
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.6':
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.6':
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.6':
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
@ -2812,66 +2839,79 @@ packages:
resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.57.0':
resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.57.0':
resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.57.0':
resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.57.0':
resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.57.0':
resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.57.0':
resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.57.0':
resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.57.0':
resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.57.0':
resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.57.0':
resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.57.0':
resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.57.0':
resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.57.0':
resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==}
@ -3196,24 +3236,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@ -3728,41 +3772,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@ -4585,6 +4637,10 @@ packages:
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
croner@10.0.1:
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
engines: {node: '>=18.0'}
cross-fetch@3.2.0:
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
@ -6457,48 +6513,56 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-gnu@1.30.2:
resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.27.0:
resolution: {integrity: sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.27.0:
resolution: {integrity: sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.27.0:
resolution: {integrity: sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.27.0:
resolution: {integrity: sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==}
@ -9376,12 +9440,6 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.0.2
'@anthropic-ai/sdk@0.71.2(zod@3.25.76)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 3.25.76
'@anthropic-ai/sdk@0.71.2(zod@4.3.6)':
dependencies:
json-schema-to-ts: 3.1.1
@ -10910,7 +10968,7 @@ snapshots:
wrap-ansi: 7.0.0
ws: 8.18.3
optionalDependencies:
expo-router: 6.0.23(w7u2e3gmpia3npio76ytuzityu)
expo-router: 6.0.23(52130e04a12e6efd1d6e48b3f2ad01c3)
react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)
transitivePeerDependencies:
- bufferutil
@ -11159,17 +11217,6 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
'@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))':
dependencies:
google-auth-library: 10.5.0
ws: 8.18.3
optionalDependencies:
'@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@3.25.76)
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
'@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))':
dependencies:
google-auth-library: 10.5.0
@ -11510,19 +11557,6 @@ snapshots:
std-env: 3.10.0
yoctocolors: 2.1.2
'@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
dependencies:
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-tui': 0.50.3
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
dependencies:
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
@ -11536,30 +11570,6 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@3.25.76)
'@aws-sdk/client-bedrock-runtime': 3.978.0
'@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))
'@mistralai/mistralai': 1.10.0
'@sinclair/typebox': 0.34.48
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
chalk: 5.6.2
openai: 6.10.0(ws@8.18.3)(zod@3.25.76)
partial-json: 0.1.7
proxy-agent: 6.5.0
undici: 7.19.2
zod-to-json-schema: 3.25.1(zod@3.25.76)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.6)
@ -11584,12 +11594,12 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
'@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
dependencies:
'@mariozechner/clipboard': 0.3.0
'@mariozechner/jiti': 2.6.5
'@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-tui': 0.50.3
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2
@ -14321,6 +14331,8 @@ snapshots:
crelt@1.0.6: {}
croner@10.0.1: {}
cross-fetch@3.2.0:
dependencies:
node-fetch: 2.7.0
@ -15291,7 +15303,7 @@ snapshots:
react: 19.1.0
react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)
expo-router@6.0.23(w7u2e3gmpia3npio76ytuzityu):
expo-router@6.0.23(52130e04a12e6efd1d6e48b3f2ad01c3):
dependencies:
'@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@expo/schema-utils': 0.1.8
@ -17693,11 +17705,6 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
openai@6.10.0(ws@8.18.3)(zod@3.25.76):
optionalDependencies:
ws: 8.18.3
zod: 3.25.76
openai@6.10.0(ws@8.18.3)(zod@4.3.6):
optionalDependencies:
ws: 8.18.3

View file

@ -0,0 +1,466 @@
/**
* Cron command - Manage scheduled tasks
*
* Usage:
* multica cron status Show cron service status
* multica cron list List all jobs
* multica cron add <options> Add a new job
* multica cron run <id> Run a job immediately
* multica cron enable <id> Enable a job
* multica cron disable <id> Disable a job
* multica cron remove <id> Remove a job
* multica cron logs <id> Show job run logs
*/
import { cyan, yellow, green, dim, red, brightCyan } from "../colors.js";
import {
getCronService,
formatSchedule,
formatDuration,
parseTimeInput,
parseIntervalInput,
isValidCronExpr,
type CronSchedule,
type CronJobInput,
} from "../../../cron/index.js";
type Command = "status" | "list" | "add" | "run" | "enable" | "disable" | "remove" | "logs" | "help";
function printHelp() {
console.log(`
${brightCyan("Cron")} - Scheduled Task Management
${cyan("Usage:")} multica cron <command> [options]
${cyan("Commands:")}
${yellow("status")} Show cron service status
${yellow("list")} List all scheduled jobs
${yellow("add")} [options] Create a new scheduled job
${yellow("run")} <id> Run a job immediately
${yellow("enable")} <id> Enable a disabled job
${yellow("disable")} <id> Disable a job (keeps schedule)
${yellow("remove")} <id> Delete a job
${yellow("logs")} <id> Show run history for a job
${yellow("help")} Show this help
${cyan("Add Options:")}
${yellow("-n, --name")} <name> Job name (required)
${yellow("--at")} <time> One-time at ISO timestamp or relative (e.g., "10m", "2h")
${yellow("--every")} <interval> Repeat interval (e.g., "30m", "1h", "1d")
${yellow("--cron")} <expr> Cron expression (5-field, e.g., "0 9 * * *")
${yellow("--tz")} <timezone> Timezone for cron expression (e.g., "Asia/Shanghai")
${yellow("--message")} <text> Message to inject or prompt for agent
${yellow("--isolated")} Run in isolated session (default: main)
${yellow("--delete-after-run")} Delete after one-time run completes
${cyan("Examples:")}
${dim("# Show service status")}
multica cron status
${dim("# 10 minutes from now (one-shot)")}
multica cron add -n "Reminder" --at "10m" --message "Time to take a break!"
${dim("# Every day at 9am Beijing time")}
multica cron add -n "Morning check" --cron "0 9 * * *" --tz "Asia/Shanghai" \\
--message "Good morning! Check your tasks."
${dim("# Every 30 minutes")}
multica cron add -n "Health check" --every "30m" --message "System health check"
${dim("# Run a job now")}
multica cron run abc12345
${dim("# View job logs")}
multica cron logs abc12345
`);
}
function cmdStatus() {
const service = getCronService();
const status = service.status();
console.log(`\n${brightCyan("Cron Service Status")}\n`);
console.log(` ${cyan("Running:")} ${status.running ? green("Yes") : red("No")}`);
console.log(` ${cyan("Enabled:")} ${status.enabled ? green("Yes") : red("No")}`);
console.log(` ${cyan("Jobs:")} ${status.jobCount} total, ${status.enabledJobCount} enabled`);
if (status.nextWakeAtMs) {
const nextWake = new Date(status.nextWakeAtMs);
const relativeMs = status.nextWakeAtMs - Date.now();
console.log(` ${cyan("Next wake:")} ${nextWake.toLocaleString()} (in ${formatDuration(relativeMs)})`);
} else {
console.log(` ${cyan("Next wake:")} ${dim("none scheduled")}`);
}
console.log(` ${cyan("Store:")} ${dim(status.storePath)}`);
console.log("");
}
function cmdList(args: string[]) {
const service = getCronService();
const showEnabled = args.includes("--enabled");
const showDisabled = args.includes("--disabled");
let filter: { enabled?: boolean } | undefined;
if (showEnabled) filter = { enabled: true };
else if (showDisabled) filter = { enabled: false };
const jobs = service.list(filter);
if (jobs.length === 0) {
console.log("\nNo cron jobs found.");
console.log(`${dim("Create one with:")} multica cron add -n "Name" --at "10m" --message "Hello"`);
return;
}
console.log(`\n${brightCyan("Scheduled Jobs")}\n`);
for (const job of jobs) {
const statusIcon = job.enabled ? green("✓") : red("✗");
const shortId = job.id.slice(0, 8);
console.log(`${statusIcon} ${yellow(job.name)} ${dim(`(${shortId})`)}`);
console.log(` ${cyan("Schedule:")} ${formatSchedule(job.schedule)}`);
console.log(` ${cyan("Target:")} ${job.sessionTarget}`);
if (job.state.nextRunAtMs) {
const nextRun = new Date(job.state.nextRunAtMs);
const relativeMs = job.state.nextRunAtMs - Date.now();
if (relativeMs > 0) {
console.log(` ${cyan("Next run:")} ${nextRun.toLocaleString()} ${dim(`(in ${formatDuration(relativeMs)})`)}`);
} else {
console.log(` ${cyan("Next run:")} ${dim("pending execution")}`);
}
}
if (job.state.lastRunAtMs) {
const lastRun = new Date(job.state.lastRunAtMs);
const statusColor = job.state.lastStatus === "ok" ? green : job.state.lastStatus === "error" ? red : yellow;
console.log(` ${cyan("Last run:")} ${lastRun.toLocaleString()} ${statusColor(`[${job.state.lastStatus}]`)} ${dim(`(${formatDuration(job.state.lastDurationMs ?? 0)})`)}`);
if (job.state.lastError) {
console.log(` ${red("Error:")} ${job.state.lastError}`);
}
}
console.log("");
}
console.log(dim(`Total: ${jobs.length} job(s)`));
}
function cmdAdd(args: string[]) {
const service = getCronService();
// Parse arguments
let name: string | undefined;
let at: string | undefined;
let every: string | undefined;
let cronExpr: string | undefined;
let tz: string | undefined;
let message: string | undefined;
let isolated = false;
let deleteAfterRun = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case "-n":
case "--name":
name = args[++i];
break;
case "--at":
at = args[++i];
break;
case "--every":
every = args[++i];
break;
case "--cron":
cronExpr = args[++i];
break;
case "--tz":
tz = args[++i];
break;
case "--message":
message = args[++i];
break;
case "--isolated":
isolated = true;
break;
case "--delete-after-run":
deleteAfterRun = true;
break;
}
}
// Validate
if (!name) {
console.error(`${red("Error:")} --name is required`);
console.error(`${dim("Usage:")} multica cron add -n "Job name" --at "10m" --message "Hello"`);
process.exit(1);
}
if (!message) {
console.error(`${red("Error:")} --message is required`);
process.exit(1);
}
// Parse schedule
let schedule: CronSchedule;
if (at) {
const atMs = parseTimeInput(at);
if (!atMs) {
console.error(`${red("Error:")} Invalid time format: ${at}`);
console.error(`${dim("Examples:")} "10m", "2h", "2024-12-31T23:59:00Z"`);
process.exit(1);
}
schedule = { kind: "at", atMs };
} else if (every) {
const everyMs = parseIntervalInput(every);
if (!everyMs) {
console.error(`${red("Error:")} Invalid interval format: ${every}`);
console.error(`${dim("Examples:")} "30s", "5m", "2h", "1d"`);
process.exit(1);
}
schedule = { kind: "every", everyMs };
} else if (cronExpr) {
if (!isValidCronExpr(cronExpr, tz)) {
console.error(`${red("Error:")} Invalid cron expression: ${cronExpr}`);
console.error(`${dim("Format:")} "minute hour day month weekday" (e.g., "0 9 * * *")`);
process.exit(1);
}
// Only include tz if it's defined (exactOptionalPropertyTypes)
schedule = tz ? { kind: "cron", expr: cronExpr, tz } : { kind: "cron", expr: cronExpr };
} else {
console.error(`${red("Error:")} Must specify --at, --every, or --cron`);
process.exit(1);
}
// Create job
const input: CronJobInput = {
name,
enabled: true,
deleteAfterRun,
schedule,
sessionTarget: isolated ? "isolated" : "main",
wakeMode: "now",
payload: {
kind: "system-event",
text: message,
},
};
const job = service.add(input);
console.log(`\n${green("Created job:")} ${job.name} ${dim(`(${job.id.slice(0, 8)})`)}`);
console.log(` ${cyan("Schedule:")} ${formatSchedule(job.schedule)}`);
if (job.state.nextRunAtMs) {
const nextRun = new Date(job.state.nextRunAtMs);
console.log(` ${cyan("Next run:")} ${nextRun.toLocaleString()}`);
}
console.log("");
}
async function cmdRun(args: string[]) {
const service = getCronService();
const jobId = args[0];
const force = args.includes("--force");
if (!jobId) {
console.error(`${red("Error:")} Job ID is required`);
console.error(`${dim("Usage:")} multica cron run <id> [--force]`);
process.exit(1);
}
// Find job by partial ID
const jobs = service.list();
const matches = jobs.filter((j) => j.id.startsWith(jobId));
if (matches.length === 0) {
console.error(`${red("Error:")} Job not found: ${jobId}`);
process.exit(1);
}
if (matches.length > 1) {
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
for (const j of matches) {
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
}
console.error("Please provide a more specific ID.");
process.exit(1);
}
const job = matches[0]!;
console.log(`Running job: ${job.name} (${job.id.slice(0, 8)})...`);
const result = await service.run(job.id, force);
if (result.ok) {
console.log(`${green("Success:")} Job executed`);
} else {
console.error(`${red("Error:")} ${result.reason}`);
process.exit(1);
}
}
function cmdEnableDisable(args: string[], enabled: boolean) {
const service = getCronService();
const jobId = args[0];
if (!jobId) {
console.error(`${red("Error:")} Job ID is required`);
process.exit(1);
}
// Find job by partial ID
const jobs = service.list();
const matches = jobs.filter((j) => j.id.startsWith(jobId));
if (matches.length === 0) {
console.error(`${red("Error:")} Job not found: ${jobId}`);
process.exit(1);
}
if (matches.length > 1) {
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
for (const j of matches) {
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
}
process.exit(1);
}
const job = matches[0]!;
service.update(job.id, { enabled });
const action = enabled ? "Enabled" : "Disabled";
console.log(`${green(action + ":")} ${job.name} (${job.id.slice(0, 8)})`);
}
function cmdRemove(args: string[]) {
const service = getCronService();
const jobId = args[0];
if (!jobId) {
console.error(`${red("Error:")} Job ID is required`);
process.exit(1);
}
// Find job by partial ID
const jobs = service.list();
const matches = jobs.filter((j) => j.id.startsWith(jobId));
if (matches.length === 0) {
console.error(`${red("Error:")} Job not found: ${jobId}`);
process.exit(1);
}
if (matches.length > 1) {
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
for (const j of matches) {
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
}
process.exit(1);
}
const job = matches[0]!;
service.remove(job.id);
console.log(`${green("Removed:")} ${job.name} (${job.id.slice(0, 8)})`);
}
function cmdLogs(args: string[]) {
const service = getCronService();
const jobId = args[0];
const limitArg = args.indexOf("--limit");
const limitStr = limitArg !== -1 ? args[limitArg + 1] : undefined;
const limit = limitStr ? parseInt(limitStr, 10) : 20;
if (!jobId) {
console.error(`${red("Error:")} Job ID is required`);
console.error(`${dim("Usage:")} multica cron logs <id> [--limit N]`);
process.exit(1);
}
// Find job by partial ID
const jobs = service.list();
const matches = jobs.filter((j) => j.id.startsWith(jobId));
if (matches.length === 0) {
console.error(`${red("Error:")} Job not found: ${jobId}`);
process.exit(1);
}
if (matches.length > 1) {
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
for (const j of matches) {
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
}
process.exit(1);
}
const job = matches[0]!;
const logs = service.getRunLogs(job.id, limit);
console.log(`\n${brightCyan("Run Logs:")} ${job.name} ${dim(`(${job.id.slice(0, 8)})`)}\n`);
if (logs.length === 0) {
console.log(dim("No run logs found."));
return;
}
for (const log of logs) {
const timestamp = new Date(log.ts).toLocaleString();
const statusColor = log.status === "ok" ? green : log.status === "error" ? red : yellow;
const duration = log.durationMs ? formatDuration(log.durationMs) : "-";
console.log(` ${dim(timestamp)} ${statusColor(`[${log.status}]`)} ${dim(`(${duration})`)}`);
if (log.error) {
console.log(` ${red("Error:")} ${log.error}`);
}
if (log.summary) {
console.log(` ${dim(log.summary)}`);
}
}
console.log(`\n${dim(`Showing ${logs.length} most recent entries`)}`);
}
export async function cronCommand(args: string[]): Promise<void> {
const command = (args[0] || "help") as Command;
const restArgs = args.slice(1);
if (args.includes("--help") || args.includes("-h")) {
printHelp();
return;
}
// Ensure service is started
const service = getCronService();
await service.start();
switch (command) {
case "status":
cmdStatus();
break;
case "list":
cmdList(restArgs);
break;
case "add":
cmdAdd(restArgs);
break;
case "run":
await cmdRun(restArgs);
break;
case "enable":
cmdEnableDisable(restArgs, true);
break;
case "disable":
cmdEnableDisable(restArgs, false);
break;
case "remove":
cmdRemove(restArgs);
break;
case "logs":
cmdLogs(restArgs);
break;
case "help":
default:
printHelp();
break;
}
}

View file

@ -11,6 +11,7 @@
* multica skills <cmd> Skills management
* multica tools <cmd> Tool policy inspection
* multica credentials <cmd> Credentials management
* multica cron <cmd> Scheduled task management
* multica dev [service] Development servers
* multica help Show help
*/
@ -29,6 +30,7 @@ const subcommands: Record<string, () => Promise<SubcommandHandler>> = {
tools: async () => (await import("./commands/tools.js")).toolsCommand,
credentials: async () => (await import("./commands/credentials.js")).credentialsCommand,
dev: async () => (await import("./commands/dev.js")).devCommand,
cron: async () => (await import("./commands/cron.js")).cronCommand,
};
function printHelp() {
@ -44,6 +46,7 @@ ${cyan("Usage:")}
${yellow("multica skills")} <command> Manage skills
${yellow("multica tools")} <command> Inspect tool policies
${yellow("multica credentials")} <command> Manage credentials
${yellow("multica cron")} <command> Manage scheduled tasks
${yellow("multica dev")} [service] Start development servers
${yellow("multica help")} Show this help
@ -85,6 +88,16 @@ ${cyan("Commands:")}
show Show credential paths
edit Open credentials in editor
${green("cron")}
status Show cron service status
list List all scheduled jobs
add [options] Create a new scheduled job
run <id> Run a job immediately
enable <id> Enable a job
disable <id> Disable a job
remove <id> Delete a job
logs <id> Show job run logs
${green("dev")}
${dim("(default)")} Start all services (gateway + console + web)
gateway Start gateway only (:3000)

View file

@ -8,6 +8,7 @@ import { createGlobTool } from "./tools/glob.js";
import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn.js";
import { createMemorySearchTool } from "./tools/memory-search.js";
import { createCronTool } from "./tools/cron/index.js";
import { filterTools } from "./tools/policy.js";
import { isMulticaError, isRetryableError } from "../shared/errors.js";
import type { ExecApprovalCallback } from "./tools/exec-approval-types.js";
@ -107,6 +108,8 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
const webFetchTool = createWebFetchTool();
const webSearchTool = createWebSearchTool();
const cronTool = createCronTool();
const tools: AgentTool<any>[] = [
...baseTools,
execTool as AgentTool<any>,
@ -114,6 +117,7 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
globTool as AgentTool<any>,
webFetchTool as AgentTool<any>,
webSearchTool as AgentTool<any>,
cronTool as AgentTool<any>,
];
// Add memory_search tool if profileDir is provided

View file

@ -0,0 +1,422 @@
/**
* Cron Tool for Agent
*
* Allows agents to create, manage, and execute scheduled tasks.
* Based on OpenClaw's implementation (MIT License)
*/
import { Type } from "@sinclair/typebox";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import {
getCronService,
formatSchedule,
formatDuration,
parseTimeInput,
parseIntervalInput,
isValidCronExpr,
type CronSchedule,
type CronJobInput,
} from "../../../cron/index.js";
// NOTE: Avoid Type.Union([Type.Literal(...)]) which compiles to anyOf.
// Some providers reject anyOf in tool schemas; a flat string enum is safer.
function stringEnum<T extends readonly string[]>(values: T, options: { description?: string } = {}) {
return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
}
const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "logs"] as const;
// Flattened schema: runtime validates per-action requirements.
const CronSchema = Type.Object({
action: stringEnum(CRON_ACTIONS),
enabled: Type.Optional(Type.Boolean({ description: "Filter by enabled status (for list action)" })),
name: Type.Optional(Type.String({ description: "Job name (for add action)" })),
description: Type.Optional(Type.String({ description: "Job description (for add action)" })),
schedule: Type.Optional(Type.Object({
kind: stringEnum(["at", "every", "cron"] as const),
at: Type.Optional(Type.String({ description: "Time for one-shot (ISO 8601 or relative like '10m')" })),
every: Type.Optional(Type.String({ description: "Interval (e.g., '30m', '2h')" })),
expr: Type.Optional(Type.String({ description: "Cron expression (5-field)" })),
tz: Type.Optional(Type.String({ description: "Timezone for cron expression" })),
})),
sessionTarget: stringEnum(["main", "isolated"] as const, { description: "Where to run: main session or isolated" }),
payload: Type.Optional(Type.Object({
kind: stringEnum(["system-event", "agent-turn"] as const),
text: Type.Optional(Type.String({ description: "Text for system-event" })),
message: Type.Optional(Type.String({ description: "Prompt for agent-turn" })),
timeoutSeconds: Type.Optional(Type.Number({ description: "Timeout for agent-turn" })),
})),
deleteAfterRun: Type.Optional(Type.Boolean({ description: "Delete after one-time run" })),
wakeMode: stringEnum(["next-heartbeat", "now"] as const, { description: "When to wake after job execution" }),
jobId: Type.Optional(Type.String({ description: "Job ID (for update/remove/run/logs actions)" })),
force: Type.Optional(Type.Boolean({ description: "Force run even if disabled (for run action)" })),
limit: Type.Optional(Type.Number({ description: "Number of log entries to return (for logs action)" })),
});
type CronArgs = {
action: "status" | "list" | "add" | "update" | "remove" | "run" | "logs";
enabled?: boolean;
name?: string;
description?: string;
schedule?: {
kind: "at" | "every" | "cron";
at?: string;
every?: string;
expr?: string;
tz?: string;
};
sessionTarget?: "main" | "isolated";
payload?: {
kind: "system-event" | "agent-turn";
text?: string;
message?: string;
timeoutSeconds?: number;
};
deleteAfterRun?: boolean;
wakeMode?: "next-heartbeat" | "now";
jobId?: string;
force?: boolean;
limit?: number;
};
export type CronResult = {
success: boolean;
message: string;
data?: unknown;
};
/** Parse schedule from tool parameters */
function parseSchedule(schedule: CronArgs["schedule"]): CronSchedule | { error: string } {
if (!schedule) {
return { error: "schedule is required" };
}
switch (schedule.kind) {
case "at": {
const at = schedule.at;
if (!at) {
return { error: "schedule.at is required for kind='at'" };
}
const atMs = parseTimeInput(at);
if (!atMs) {
return { error: `Invalid time format: ${at}` };
}
return { kind: "at", atMs };
}
case "every": {
const every = schedule.every;
if (!every) {
return { error: "schedule.every is required for kind='every'" };
}
const everyMs = parseIntervalInput(every);
if (!everyMs) {
return { error: `Invalid interval format: ${every}` };
}
return { kind: "every", everyMs };
}
case "cron": {
const expr = schedule.expr;
if (!expr) {
return { error: "schedule.expr is required for kind='cron'" };
}
const tz = schedule.tz;
if (!isValidCronExpr(expr, tz)) {
return { error: `Invalid cron expression: ${expr}` };
}
// Only include tz if defined (exactOptionalPropertyTypes)
if (tz) {
return { kind: "cron", expr, tz };
}
return { kind: "cron", expr };
}
default:
return { error: `Unknown schedule kind: ${schedule.kind}` };
}
}
const TOOL_DESCRIPTION = `Manage cron jobs (status/list/add/update/remove/run/logs).
ACTIONS:
- status: Check cron scheduler status
- list: List jobs (use enabled:true/false to filter)
- add: Create job (requires name, schedule, payload, sessionTarget)
- update: Modify job (requires jobId, plus fields to update)
- remove: Delete job (requires jobId)
- run: Trigger job immediately (requires jobId, optional force:true)
- logs: Get job run history (requires jobId, optional limit)
SCHEDULE TYPES (schedule.kind):
- "at": One-shot at time
{ "kind": "at", "at": "10m" } or { "kind": "at", "at": "2024-12-31T23:59:00Z" }
- "every": Recurring interval
{ "kind": "every", "every": "30m" }
- "cron": Cron expression
{ "kind": "cron", "expr": "0 9 * * *", "tz": "Asia/Shanghai" }
PAYLOAD TYPES (payload.kind):
- "system-event": Injects text into main session (like a reminder, triggers main agent to respond)
{ "kind": "system-event", "text": "<message>" }
- "agent-turn": Spawns an isolated agent that can use ALL tools (exec, write, web_fetch, etc.) to autonomously complete a task
{ "kind": "agent-turn", "message": "<prompt>", "timeoutSeconds": 300 }
USE "agent-turn" when the job needs to perform actions (run commands, write files, fetch data, etc.).
USE "system-event" when the job only needs to remind/notify the user in the current chat.
CRITICAL CONSTRAINTS:
- sessionTarget="main" REQUIRES payload.kind="system-event"
- sessionTarget="isolated" REQUIRES payload.kind="agent-turn"
- Default sessionTarget is "main", default wakeMode is "now"`;
/** Create the cron tool */
export function createCronTool(): AgentTool<typeof CronSchema, CronResult> {
return {
name: "cron",
label: "Cron",
description: TOOL_DESCRIPTION,
parameters: CronSchema,
execute: async (_toolCallId, args) => {
const { action } = args as CronArgs;
const service = getCronService();
try {
switch (action) {
case "status": {
const status = service.status();
const output = JSON.stringify({
running: status.running,
enabled: status.enabled,
jobCount: status.jobCount,
enabledJobCount: status.enabledJobCount,
nextWakeAt: status.nextWakeAtMs ? new Date(status.nextWakeAtMs).toISOString() : null,
storePath: status.storePath,
}, null, 2);
return {
content: [{ type: "text", text: output }],
details: { success: true, message: "Status retrieved", data: status },
};
}
case "list": {
const params = args as CronArgs;
const filter = params.enabled !== undefined ? { enabled: params.enabled } : undefined;
const jobs = service.list(filter);
const formatted = jobs.map((job) => ({
id: job.id,
name: job.name,
enabled: job.enabled,
schedule: formatSchedule(job.schedule),
sessionTarget: job.sessionTarget,
nextRunAt: job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : null,
lastStatus: job.state.lastStatus,
lastRunAt: job.state.lastRunAtMs ? new Date(job.state.lastRunAtMs).toISOString() : null,
}));
const output = JSON.stringify(formatted, null, 2);
return {
content: [{ type: "text", text: output }],
details: { success: true, message: `Found ${jobs.length} job(s)`, data: formatted },
};
}
case "add": {
const params = args as CronArgs;
if (!params.name) {
return {
content: [{ type: "text", text: "Error: name is required" }],
details: { success: false, message: "name is required" },
};
}
const schedule = parseSchedule(params.schedule);
if ("error" in schedule) {
return {
content: [{ type: "text", text: `Error: ${schedule.error}` }],
details: { success: false, message: schedule.error },
};
}
if (!params.payload) {
return {
content: [{ type: "text", text: "Error: payload is required" }],
details: { success: false, message: "payload is required" },
};
}
const { payload } = params;
let jobPayload;
if (payload.kind === "system-event") {
if (!payload.text) {
return {
content: [{ type: "text", text: "Error: payload.text is required for system-event" }],
details: { success: false, message: "payload.text is required for system-event" },
};
}
jobPayload = { kind: "system-event" as const, text: payload.text };
} else if (payload.kind === "agent-turn") {
if (!payload.message) {
return {
content: [{ type: "text", text: "Error: payload.message is required for agent-turn" }],
details: { success: false, message: "payload.message is required for agent-turn" },
};
}
const agentPayload: { kind: "agent-turn"; message: string; timeoutSeconds?: number } = {
kind: "agent-turn",
message: payload.message,
};
if (payload.timeoutSeconds !== undefined) {
agentPayload.timeoutSeconds = payload.timeoutSeconds;
}
jobPayload = agentPayload;
} else {
return {
content: [{ type: "text", text: `Error: Unknown payload kind` }],
details: { success: false, message: "Unknown payload kind" },
};
}
const input: CronJobInput = {
name: params.name,
enabled: true,
schedule,
sessionTarget: params.sessionTarget ?? "main",
wakeMode: params.wakeMode ?? "now",
payload: jobPayload,
};
if (params.description !== undefined) {
input.description = params.description;
}
if (params.deleteAfterRun !== undefined) {
input.deleteAfterRun = params.deleteAfterRun;
}
const job = service.add(input);
const output = `Created job: ${job.name} (${job.id})\nSchedule: ${formatSchedule(job.schedule)}\nNext run: ${job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : "none"}`;
return {
content: [{ type: "text", text: output }],
details: { success: true, message: "Job created", data: job },
};
}
case "update": {
const params = args as CronArgs;
if (!params.jobId) {
return {
content: [{ type: "text", text: "Error: jobId is required" }],
details: { success: false, message: "jobId is required" },
};
}
const patch: Record<string, unknown> = {};
if (params.name !== undefined) patch.name = params.name;
if (params.description !== undefined) patch.description = params.description;
if (params.enabled !== undefined) patch.enabled = params.enabled;
if (params.schedule !== undefined) {
const schedule = parseSchedule(params.schedule);
if ("error" in schedule) {
return {
content: [{ type: "text", text: `Error: ${schedule.error}` }],
details: { success: false, message: schedule.error },
};
}
patch.schedule = schedule;
}
const updated = service.update(params.jobId, patch);
if (!updated) {
return {
content: [{ type: "text", text: `Error: Job not found: ${params.jobId}` }],
details: { success: false, message: "Job not found" },
};
}
return {
content: [{ type: "text", text: `Updated job: ${updated.name} (${updated.id})` }],
details: { success: true, message: "Job updated", data: updated },
};
}
case "remove": {
const params = args as CronArgs;
if (!params.jobId) {
return {
content: [{ type: "text", text: "Error: jobId is required" }],
details: { success: false, message: "jobId is required" },
};
}
const removed = service.remove(params.jobId);
if (!removed) {
return {
content: [{ type: "text", text: `Error: Job not found: ${params.jobId}` }],
details: { success: false, message: "Job not found" },
};
}
return {
content: [{ type: "text", text: `Removed job: ${params.jobId}` }],
details: { success: true, message: "Job removed" },
};
}
case "run": {
const params = args as CronArgs;
if (!params.jobId) {
return {
content: [{ type: "text", text: "Error: jobId is required" }],
details: { success: false, message: "jobId is required" },
};
}
const result = await service.run(params.jobId, params.force);
if (!result.ok) {
return {
content: [{ type: "text", text: `Error: ${result.reason}` }],
details: { success: false, message: result.reason ?? "Run failed" },
};
}
return {
content: [{ type: "text", text: "Job executed successfully" }],
details: { success: true, message: "Job executed" },
};
}
case "logs": {
const params = args as CronArgs;
if (!params.jobId) {
return {
content: [{ type: "text", text: "Error: jobId is required" }],
details: { success: false, message: "jobId is required" },
};
}
const logs = service.getRunLogs(params.jobId, params.limit);
const formatted = logs.map((log) => ({
timestamp: new Date(log.ts).toISOString(),
status: log.status,
duration: log.durationMs ? formatDuration(log.durationMs) : undefined,
error: log.error,
summary: log.summary,
}));
const output = JSON.stringify(formatted, null, 2);
return {
content: [{ type: "text", text: output }],
details: { success: true, message: `Found ${logs.length} log entries`, data: formatted },
};
}
default:
return {
content: [{ type: "text", text: `Error: Unknown action: ${action}` }],
details: { success: false, message: `Unknown action: ${action}` },
};
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text", text: `Error: ${message}` }],
details: { success: false, message },
};
}
},
};
}

View file

@ -0,0 +1,5 @@
/**
* Cron Tools
*/
export { createCronTool } from "./cron-tool.js";

View file

@ -36,6 +36,9 @@ export const TOOL_GROUPS: Record<string, string[]> = {
// Subagent tools
"group:subagent": ["sessions_spawn"],
// Cron/scheduling tools
"group:cron": ["cron"],
// All core tools
"group:core": [
"read",

View file

@ -7,6 +7,7 @@ export { createExecTool } from "./exec.js";
export { createProcessTool } from "./process.js";
export { createGlobTool } from "./glob.js";
export { createWebFetchTool, createWebSearchTool } from "./web/index.js";
export { createCronTool } from "./cron/index.js";
// Tool groups
export {

139
src/cron/execute.ts Normal file
View file

@ -0,0 +1,139 @@
/**
* Cron Job Execution
*
* Handles the actual execution of cron job payloads.
* Based on OpenClaw's implementation (MIT License)
*/
import type { CronJob } from "./types.js";
import { getHub, isHubInitialized } from "../hub/hub-singleton.js";
/** Execution result */
export type ExecutionResult = {
summary?: string;
error?: string;
};
/**
* Execute a cron job payload.
*
* For system-event: Injects text into the main session
* For agent-turn: Creates an isolated agent turn
*/
export async function executeCronJob(job: CronJob): Promise<ExecutionResult> {
const { payload } = job;
switch (payload.kind) {
case "system-event":
return executeSystemEvent(job);
case "agent-turn":
return executeAgentTurn(job);
default:
return { error: `Unknown payload kind: ${(payload as { kind: string }).kind}` };
}
}
/**
* Execute a system-event payload.
* Injects the text into the main session as a system message.
*/
async function executeSystemEvent(job: CronJob): Promise<ExecutionResult> {
if (!isHubInitialized()) {
return { error: "Hub not available" };
}
const hub = getHub();
const payload = job.payload as { kind: "system-event"; text: string };
// Get the list of active agents
const agentIds = hub.listAgents();
if (agentIds.length === 0) {
return { error: "No active agents" };
}
// For now, inject into the first (main) agent
// TODO: Support targeting specific agent by ID
const agentId = agentIds[0]!;
const agent = hub.getAgent(agentId);
if (!agent || agent.closed) {
return { error: `Agent ${agentId} not found or closed` };
}
// Format the cron message with metadata
const cronMessage = `[CRON] ${job.name}: ${payload.text}`;
try {
// Write to agent (non-blocking, will be processed in queue)
agent.write(cronMessage);
// Wait for the agent to process the message
await agent.waitForIdle();
return { summary: `Injected message into agent ${agentId.slice(0, 8)}` };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
/**
* Execute an agent-turn payload.
* Creates an isolated subagent to run the task.
*/
async function executeAgentTurn(job: CronJob): Promise<ExecutionResult> {
if (!isHubInitialized()) {
return { error: "Hub not available" };
}
const hub = getHub();
const payload = job.payload as {
kind: "agent-turn";
message: string;
model?: string;
thinkingLevel?: string;
timeoutSeconds?: number;
};
// Generate a unique session ID for this isolated run
const sessionId = `cron-${job.id}-${Date.now()}`;
try {
// Create isolated subagent
// TODO: Support model/thinkingLevel override
const agent = hub.createSubagent(sessionId, {
profileId: "default",
});
// Set up timeout if specified
const timeoutMs = (payload.timeoutSeconds ?? 300) * 1000; // default 5 minutes
let timeoutHandle: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<ExecutionResult>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Cron job timed out after ${payload.timeoutSeconds}s`));
}, timeoutMs);
});
// Execute the agent turn
const executePromise = (async (): Promise<ExecutionResult> => {
const cronMessage = `[CRON Job: ${job.name}]\n\n${payload.message}`;
agent.write(cronMessage);
await agent.waitForIdle();
return { summary: `Completed agent turn in isolated session ${sessionId.slice(0, 16)}` };
})();
// Race between execution and timeout
const result = await Promise.race([executePromise, timeoutPromise]);
// Clear timeout
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
// Close the subagent
agent.close();
return result;
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}

39
src/cron/index.ts Normal file
View file

@ -0,0 +1,39 @@
/**
* Cron Module
*
* Provides scheduled task functionality for Super Multica.
*/
export type {
CronSchedule,
CronSessionTarget,
CronWakeMode,
CronPayload,
CronJobState,
CronJob,
CronJobInput,
CronJobPatch,
CronRunLogEntry,
CronConfig,
} from "./types.js";
export {
computeNextRunAtMs,
isValidCronExpr,
parseTimeInput,
parseIntervalInput,
formatSchedule,
formatDuration,
} from "./schedule.js";
export { CronStore } from "./store.js";
export {
CronService,
getCronService,
shutdownCronService,
type CronJobExecutor,
type CronServiceStatus,
} from "./service.js";
export { executeCronJob, type ExecutionResult } from "./execute.js";

187
src/cron/schedule.ts Normal file
View file

@ -0,0 +1,187 @@
/**
* Cron Schedule Computation
*
* Based on OpenClaw's implementation (MIT License)
*/
import { Cron } from "croner";
import type { CronSchedule } from "./types.js";
/**
* Compute the next run time for a schedule.
*
* @param schedule - The schedule configuration
* @param nowMs - Current time in milliseconds (default: Date.now())
* @returns Next run time in ms, or undefined if no future run
*/
export function computeNextRunAtMs(
schedule: CronSchedule,
nowMs: number = Date.now(),
): number | undefined {
switch (schedule.kind) {
case "at":
// One-shot: return the timestamp if it's in the future
return schedule.atMs > nowMs ? schedule.atMs : undefined;
case "every": {
// Fixed interval: compute next occurrence
const everyMs = Math.max(1, Math.floor(schedule.everyMs));
const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs));
if (nowMs < anchor) return anchor;
const elapsed = nowMs - anchor;
const steps = Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs));
return anchor + steps * everyMs;
}
case "cron": {
// Cron expression: use croner to compute next run
const expr = schedule.expr.trim();
if (!expr) return undefined;
try {
const tz = schedule.tz?.trim();
const cron = tz ? new Cron(expr, { timezone: tz }) : new Cron(expr);
const next = cron.nextRun(new Date(nowMs));
return next ? next.getTime() : undefined;
} catch (error) {
console.error(`[Cron] Invalid cron expression: ${expr}`, error);
return undefined;
}
}
}
}
/**
* Validate a cron expression.
*
* @param expr - Cron expression (5-field)
* @param tz - Optional timezone
* @returns true if valid, false otherwise
*/
export function isValidCronExpr(expr: string, tz?: string): boolean {
try {
const timezone = tz?.trim();
if (timezone) {
new Cron(expr.trim(), { timezone });
} else {
new Cron(expr.trim());
}
return true;
} catch {
return false;
}
}
/**
* Parse a human-readable time string into milliseconds.
*
* Supports:
* - Relative: "10s", "5m", "2h", "1d"
* - ISO 8601: "2024-01-15T09:00:00Z"
* - Unix timestamp (if numeric)
*
* @param input - Time string
* @param nowMs - Current time for relative calculations
* @returns Timestamp in ms, or undefined if invalid
*/
export function parseTimeInput(input: string, nowMs: number = Date.now()): number | undefined {
const trimmed = input.trim();
// Check for relative time (e.g., "10m", "2h")
const relativeMatch = trimmed.match(/^(\d+(?:\.\d+)?)\s*([smhd])$/i);
if (relativeMatch) {
const [, numStr, unit] = relativeMatch;
const num = parseFloat(numStr!);
const multipliers: Record<string, number> = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
const ms = multipliers[unit!.toLowerCase()];
if (ms !== undefined) {
return nowMs + num * ms;
}
}
// Check for numeric (unix timestamp in ms or seconds)
if (/^\d+$/.test(trimmed)) {
const num = parseInt(trimmed, 10);
// If it looks like seconds (before year 2100), convert to ms
if (num < 4102444800) {
return num * 1000;
}
return num;
}
// Try ISO 8601 date parsing
const date = new Date(trimmed);
if (!isNaN(date.getTime())) {
return date.getTime();
}
return undefined;
}
/**
* Parse an interval string into milliseconds.
*
* Supports: "30s", "5m", "2h", "1d", or raw milliseconds
*
* @param input - Interval string
* @returns Interval in ms, or undefined if invalid
*/
export function parseIntervalInput(input: string): number | undefined {
const trimmed = input.trim();
// Check for duration format (e.g., "30m", "2h")
const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*([smhd])$/i);
if (match) {
const [, numStr, unit] = match;
const num = parseFloat(numStr!);
const multipliers: Record<string, number> = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
const ms = multipliers[unit!.toLowerCase()];
if (ms !== undefined) {
return num * ms;
}
}
// Check for raw milliseconds
if (/^\d+$/.test(trimmed)) {
return parseInt(trimmed, 10);
}
return undefined;
}
/**
* Format a schedule for display.
*/
export function formatSchedule(schedule: CronSchedule): string {
switch (schedule.kind) {
case "at":
return `at ${new Date(schedule.atMs).toISOString()}`;
case "every":
return `every ${formatDuration(schedule.everyMs)}`;
case "cron":
return `cron "${schedule.expr}"${schedule.tz ? ` (${schedule.tz})` : ""}`;
}
}
/**
* Format milliseconds as human-readable duration.
*/
export function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60 * 1000) return `${Math.round(ms / 1000)}s`;
if (ms < 60 * 60 * 1000) return `${Math.round(ms / (60 * 1000))}m`;
if (ms < 24 * 60 * 60 * 1000) return `${Math.round(ms / (60 * 60 * 1000))}h`;
return `${Math.round(ms / (24 * 60 * 60 * 1000))}d`;
}

432
src/cron/service.ts Normal file
View file

@ -0,0 +1,432 @@
/**
* Cron Service
*
* Manages scheduled jobs with timer-based execution.
* Based on OpenClaw's implementation (MIT License)
*/
import { v7 as uuidv7 } from "uuid";
import type {
CronJob,
CronJobInput,
CronJobPatch,
CronJobState,
CronRunLogEntry,
CronConfig,
} from "./types.js";
import { CronStore } from "./store.js";
import { computeNextRunAtMs } from "./schedule.js";
/** Callback for job execution */
export type CronJobExecutor = (job: CronJob) => Promise<{ summary?: string; error?: string }>;
/** Service status */
export type CronServiceStatus = {
running: boolean;
enabled: boolean;
storePath: string;
jobCount: number;
enabledJobCount: number;
nextWakeAtMs: number | null;
};
/** Default stuck job timeout (2 hours) */
const STUCK_JOB_TIMEOUT_MS = 2 * 60 * 60 * 1000;
export class CronService {
private readonly store: CronStore;
private readonly config: CronConfig;
private timer: NodeJS.Timeout | null = null;
private running = false;
private executor: CronJobExecutor | null = null;
constructor(config: CronConfig = {}) {
this.config = {
enabled: config.enabled ?? true,
maxConcurrentRuns: config.maxConcurrentRuns ?? 1,
...config,
};
this.store = new CronStore(config.storePath);
}
/**
* Set the job executor callback.
* This is called when a job needs to be executed.
*/
setExecutor(executor: CronJobExecutor): void {
this.executor = executor;
}
/**
* Start the cron service.
* Loads jobs from disk, computes schedules, and starts the timer.
*/
async start(): Promise<void> {
if (this.running) return;
if (!this.config.enabled) {
console.log("[CronService] Cron is disabled by config");
return;
}
this.running = true;
console.log("[CronService] Starting...");
// Load jobs and compute next run times
const jobs = this.store.load();
console.log(`[CronService] Loaded ${jobs.length} jobs`);
// Recompute all schedules
this.recomputeAllSchedules();
// Clear any stuck jobs (running for > 2 hours)
this.clearStuckJobs();
// Arm timer for next job
this.armTimer();
console.log("[CronService] Started");
}
/**
* Stop the cron service.
*/
stop(): void {
if (!this.running) return;
this.running = false;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
console.log("[CronService] Stopped");
}
/**
* Get service status.
*/
status(): CronServiceStatus {
const allJobs = this.store.list();
const enabledJobs = this.store.list({ enabled: true });
const nextWake = enabledJobs.reduce((min, job) => {
const next = job.state.nextRunAtMs;
return next !== undefined && next < min ? next : min;
}, Infinity);
return {
running: this.running,
enabled: this.config.enabled ?? true,
storePath: this.store.getStorePath(),
jobCount: allJobs.length,
enabledJobCount: enabledJobs.length,
nextWakeAtMs: nextWake === Infinity ? null : nextWake,
};
}
/**
* List jobs with optional filter.
*/
list(filter?: { enabled?: boolean }): CronJob[] {
return this.store.list(filter);
}
/**
* Get a job by ID.
*/
get(id: string): CronJob | undefined {
return this.store.get(id);
}
/**
* Add a new job.
*/
add(input: CronJobInput): CronJob {
const now = Date.now();
const job: CronJob = {
...input,
id: uuidv7(),
createdAtMs: now,
updatedAtMs: now,
state: {},
};
// Compute initial next run time
this.computeNextRun(job);
this.store.set(job);
console.log(`[CronService] Added job: ${job.name} (${job.id}), next run: ${job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : "none"}`);
// Re-arm timer in case this job runs sooner
if (this.running) {
this.armTimer();
}
return job;
}
/**
* Update an existing job.
*/
update(id: string, patch: CronJobPatch): CronJob | null {
const job = this.store.get(id);
if (!job) return null;
// Apply patch
Object.assign(job, patch, { updatedAtMs: Date.now() });
// Recompute schedule if changed
if (patch.schedule || patch.enabled !== undefined) {
this.computeNextRun(job);
}
this.store.set(job);
console.log(`[CronService] Updated job: ${job.name} (${job.id})`);
// Re-arm timer
if (this.running) {
this.armTimer();
}
return job;
}
/**
* Remove a job.
*/
remove(id: string): boolean {
const job = this.store.get(id);
if (!job) return false;
const deleted = this.store.delete(id);
if (deleted) {
console.log(`[CronService] Removed job: ${job.name} (${id})`);
}
return deleted;
}
/**
* Run a job immediately.
*
* @param id - Job ID
* @param force - Run even if disabled
*/
async run(id: string, force = false): Promise<{ ok: boolean; reason?: string }> {
const job = this.store.get(id);
if (!job) {
return { ok: false, reason: "Job not found" };
}
if (!job.enabled && !force) {
return { ok: false, reason: "Job is disabled" };
}
if (job.state.runningAtMs) {
return { ok: false, reason: "Job is already running" };
}
await this.executeJob(job);
return { ok: true };
}
/**
* Get run logs for a job.
*/
getRunLogs(id: string, limit?: number): CronRunLogEntry[] {
return this.store.getRunLogs(id, limit);
}
// === Private Methods ===
/**
* Compute next run time for a job.
*/
private computeNextRun(job: CronJob): void {
if (!job.enabled) {
job.state.nextRunAtMs = undefined;
return;
}
const now = Date.now();
const nextMs = computeNextRunAtMs(job.schedule, now);
job.state.nextRunAtMs = nextMs;
}
/**
* Recompute schedules for all enabled jobs.
*/
private recomputeAllSchedules(): void {
for (const job of this.store.list({ enabled: true })) {
this.computeNextRun(job);
this.store.set(job);
}
}
/**
* Clear stuck jobs (running for too long).
*/
private clearStuckJobs(): void {
const now = Date.now();
for (const job of this.store.list()) {
if (job.state.runningAtMs && now - job.state.runningAtMs > STUCK_JOB_TIMEOUT_MS) {
console.warn(`[CronService] Clearing stuck job: ${job.name} (${job.id})`);
job.state.runningAtMs = undefined;
job.state.lastStatus = "error";
job.state.lastError = "Job was stuck (running > 2 hours)";
this.store.set(job);
}
}
}
/**
* Arm the timer for the next due job.
*/
private armTimer(): void {
if (!this.running) return;
// Clear existing timer
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
// Find next wake time
const enabledJobs = this.store.list({ enabled: true });
const nextWake = enabledJobs.reduce((min, job) => {
const next = job.state.nextRunAtMs;
return next !== undefined && next < min ? next : min;
}, Infinity);
if (nextWake === Infinity) {
// No jobs to run
return;
}
const delay = Math.max(0, nextWake - Date.now());
this.timer = setTimeout(() => this.onTimer(), delay);
}
/**
* Timer callback: run all due jobs.
*/
private async onTimer(): Promise<void> {
if (!this.running) return;
const now = Date.now();
const dueJobs = this.store
.list({ enabled: true })
.filter((j) => {
const next = j.state.nextRunAtMs;
return next !== undefined && next <= now && !j.state.runningAtMs;
});
for (const job of dueJobs) {
try {
await this.executeJob(job);
} catch (error) {
console.error(`[CronService] Error executing job ${job.id}:`, error);
}
}
// Re-arm timer for next batch
this.armTimer();
}
/**
* Execute a single job.
*/
private async executeJob(job: CronJob): Promise<void> {
const startMs = Date.now();
console.log(`[CronService] Executing job: ${job.name} (${job.id})`);
// Mark as running
job.state.runningAtMs = startMs;
this.store.set(job);
let status: "ok" | "error" = "ok";
let error: string | undefined;
let summary: string | undefined;
try {
if (this.executor) {
const result = await this.executor(job);
summary = result.summary;
if (result.error) {
status = "error";
error = result.error;
}
} else {
// No executor set, just log
console.log(`[CronService] Job ${job.id} payload:`, job.payload);
}
} catch (err) {
status = "error";
error = err instanceof Error ? err.message : String(err);
console.error(`[CronService] Job ${job.id} failed:`, err);
}
const durationMs = Date.now() - startMs;
// Update job state
job.state.runningAtMs = undefined;
job.state.lastRunAtMs = startMs;
job.state.lastStatus = status;
job.state.lastError = error;
job.state.lastDurationMs = durationMs;
// Handle one-shot jobs
if (job.schedule.kind === "at") {
if (status === "ok" && job.deleteAfterRun) {
this.store.delete(job.id);
console.log(`[CronService] Deleted one-shot job: ${job.name} (${job.id})`);
} else {
job.enabled = false;
job.state.nextRunAtMs = undefined;
this.store.set(job);
}
} else {
// Compute next run for recurring jobs
this.computeNextRun(job);
this.store.set(job);
}
// Append run log
this.store.appendRunLog(job.id, {
ts: startMs,
jobId: job.id,
action: status === "ok" ? "run" : "error",
status,
error,
summary,
durationMs,
nextRunAtMs: job.state.nextRunAtMs,
});
console.log(`[CronService] Job ${job.id} completed: ${status} (${durationMs}ms)`);
}
}
// === Singleton ===
let cronServiceInstance: CronService | null = null;
/**
* Get or create the singleton CronService instance.
*/
export function getCronService(config?: CronConfig): CronService {
if (!cronServiceInstance) {
cronServiceInstance = new CronService(config);
}
return cronServiceInstance;
}
/**
* Shutdown the singleton CronService.
*/
export function shutdownCronService(): void {
if (cronServiceInstance) {
cronServiceInstance.stop();
cronServiceInstance = null;
}
}

215
src/cron/store.ts Normal file
View file

@ -0,0 +1,215 @@
/**
* Cron Job Storage
*
* Persists jobs to JSON file and run logs to JSONL files.
* Based on OpenClaw's implementation (MIT License)
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, renameSync } from "fs";
import path from "path";
import type { CronJob, CronRunLogEntry } from "./types.js";
/** Default cron storage directory */
const DEFAULT_CRON_DIR = path.join(
process.env["HOME"] ?? ".",
".super-multica",
"cron",
);
/** Store data structure */
type StoreData = {
version: number;
jobs: CronJob[];
};
const STORE_VERSION = 1;
export class CronStore {
private readonly jobsPath: string;
private readonly runsDir: string;
private jobs: Map<string, CronJob> = new Map();
private loaded = false;
constructor(baseDir: string = DEFAULT_CRON_DIR) {
this.jobsPath = path.join(baseDir, "jobs.json");
this.runsDir = path.join(baseDir, "runs");
}
/** Ensure directories exist */
private ensureDirs() {
const dir = path.dirname(this.jobsPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
if (!existsSync(this.runsDir)) {
mkdirSync(this.runsDir, { recursive: true });
}
}
/** Load jobs from disk */
load(): CronJob[] {
this.ensureDirs();
if (!existsSync(this.jobsPath)) {
this.jobs = new Map();
this.loaded = true;
return [];
}
try {
const raw = readFileSync(this.jobsPath, "utf-8");
const data: StoreData = JSON.parse(raw);
// Validate version
if (data.version !== STORE_VERSION) {
console.warn(`[CronStore] Store version mismatch: ${data.version} vs ${STORE_VERSION}`);
}
this.jobs = new Map(data.jobs.map((j) => [j.id, j]));
this.loaded = true;
return Array.from(this.jobs.values());
} catch (error) {
console.error("[CronStore] Failed to load jobs:", error);
this.jobs = new Map();
this.loaded = true;
return [];
}
}
/** Save jobs to disk */
save(): void {
this.ensureDirs();
const data: StoreData = {
version: STORE_VERSION,
jobs: Array.from(this.jobs.values()),
};
// Write to temp file first, then rename (atomic)
const tmpPath = this.jobsPath + ".tmp";
const bakPath = this.jobsPath + ".bak";
try {
writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8");
// Backup existing file
if (existsSync(this.jobsPath)) {
writeFileSync(bakPath, readFileSync(this.jobsPath));
}
// Rename temp to actual (atomic on most filesystems)
renameSync(tmpPath, this.jobsPath);
} catch (error) {
console.error("[CronStore] Failed to save jobs:", error);
throw error;
}
}
/** Ensure store is loaded */
private ensureLoaded() {
if (!this.loaded) {
this.load();
}
}
/** Get a job by ID */
get(id: string): CronJob | undefined {
this.ensureLoaded();
return this.jobs.get(id);
}
/** Set (create or update) a job */
set(job: CronJob): void {
this.ensureLoaded();
this.jobs.set(job.id, job);
this.save();
}
/** Delete a job by ID */
delete(id: string): boolean {
this.ensureLoaded();
const deleted = this.jobs.delete(id);
if (deleted) {
this.save();
}
return deleted;
}
/** List all jobs, optionally filtered */
list(filter?: { enabled?: boolean }): CronJob[] {
this.ensureLoaded();
let jobs = Array.from(this.jobs.values());
if (filter?.enabled !== undefined) {
jobs = jobs.filter((j) => j.enabled === filter.enabled);
}
// Sort by next run time
jobs.sort((a, b) => {
const aNext = a.state.nextRunAtMs ?? Infinity;
const bNext = b.state.nextRunAtMs ?? Infinity;
return aNext - bNext;
});
return jobs;
}
/** Get job count */
count(filter?: { enabled?: boolean }): number {
return this.list(filter).length;
}
// === Run Log Methods ===
/** Append a run log entry */
appendRunLog(jobId: string, entry: CronRunLogEntry): void {
this.ensureDirs();
const logPath = path.join(this.runsDir, `${jobId}.jsonl`);
const line = JSON.stringify(entry) + "\n";
appendFileSync(logPath, line, "utf-8");
}
/** Get run logs for a job */
getRunLogs(jobId: string, limit = 50): CronRunLogEntry[] {
const logPath = path.join(this.runsDir, `${jobId}.jsonl`);
if (!existsSync(logPath)) {
return [];
}
try {
const content = readFileSync(logPath, "utf-8").trim();
if (!content) return [];
const lines = content.split("\n");
const entries = lines
.slice(-limit)
.map((line) => {
try {
return JSON.parse(line) as CronRunLogEntry;
} catch {
return null;
}
})
.filter((e): e is CronRunLogEntry => e !== null);
return entries;
} catch (error) {
console.error(`[CronStore] Failed to read run logs for ${jobId}:`, error);
return [];
}
}
/** Clear run logs for a job */
clearRunLogs(jobId: string): void {
const logPath = path.join(this.runsDir, `${jobId}.jsonl`);
if (existsSync(logPath)) {
writeFileSync(logPath, "", "utf-8");
}
}
/** Get the store path (for status display) */
getStorePath(): string {
return this.jobsPath;
}
}

116
src/cron/types.ts Normal file
View file

@ -0,0 +1,116 @@
/**
* Cron Job Types
*
* Based on OpenClaw's implementation (MIT License)
*/
/** Cron schedule: one-shot, interval, or cron expression */
export type CronSchedule =
| { kind: "at"; atMs: number }
| { kind: "every"; everyMs: number; anchorMs?: number }
| { kind: "cron"; expr: string; tz?: string };
/** Where to run the job */
export type CronSessionTarget = "main" | "isolated";
/** When to wake after job execution */
export type CronWakeMode = "next-heartbeat" | "now";
/** Job payload: what to execute */
export type CronPayload =
| {
kind: "system-event";
/** Text to inject into main session */
text: string;
}
| {
kind: "agent-turn";
/** Message/prompt for the agent */
message: string;
/** Optional model override (e.g., "anthropic/claude-3-opus") */
model?: string;
/** Optional thinking level override */
thinkingLevel?: string;
/** Timeout in seconds */
timeoutSeconds?: number;
};
/** Runtime state of a job */
export type CronJobState = {
/** Next scheduled run (ms since epoch) */
nextRunAtMs?: number | undefined;
/** Currently running (lock marker, ms since epoch) */
runningAtMs?: number | undefined;
/** Last completed run (ms since epoch) */
lastRunAtMs?: number | undefined;
/** Last run status */
lastStatus?: "ok" | "error" | "skipped" | undefined;
/** Last error message */
lastError?: string | undefined;
/** Last run duration in ms */
lastDurationMs?: number | undefined;
};
/** Cron job definition */
export type CronJob = {
/** Unique identifier (UUIDv7) */
id: string;
/** User-friendly name */
name: string;
/** Optional description */
description?: string;
/** Whether the job is enabled */
enabled: boolean;
/** Delete after successful one-shot run */
deleteAfterRun?: boolean;
/** Creation timestamp (ms) */
createdAtMs: number;
/** Last update timestamp (ms) */
updatedAtMs: number;
/** When to run */
schedule: CronSchedule;
/** Where to run (main session or isolated) */
sessionTarget: CronSessionTarget;
/** Wake mode after execution */
wakeMode: CronWakeMode;
/** What to execute */
payload: CronPayload;
/** Runtime state */
state: CronJobState;
};
/** Input for creating a new job (without auto-generated fields) */
export type CronJobInput = Omit<CronJob, "id" | "createdAtMs" | "updatedAtMs" | "state">;
/** Input for updating an existing job */
export type CronJobPatch = Partial<Omit<CronJob, "id" | "createdAtMs" | "updatedAtMs">>;
/** Run log entry */
export type CronRunLogEntry = {
/** Timestamp (ms) */
ts: number;
/** Job ID */
jobId: string;
/** Action taken */
action: "run" | "skip" | "error";
/** Result status */
status: "ok" | "error" | "skipped";
/** Error message if failed */
error?: string | undefined;
/** Summary of execution (for agent-turn) */
summary?: string | undefined;
/** Duration in ms */
durationMs?: number | undefined;
/** Next scheduled run */
nextRunAtMs?: number | undefined;
};
/** Cron service configuration */
export type CronConfig = {
/** Whether cron is enabled (default: true) */
enabled?: boolean;
/** Custom store path */
storePath?: string;
/** Max concurrent job runs (default: 1) */
maxConcurrentRuns?: number;
};

View file

@ -30,6 +30,7 @@ import { evaluateCommandSafety, requiresApproval } from "../agent/tools/exec-saf
import { addAllowlistEntry, recordAllowlistUse, matchAllowlist } from "../agent/tools/exec-allowlist.js";
import type { ExecApprovalCallback, ExecApprovalConfig, ApprovalResult, ExecApprovalRequest } from "../agent/tools/exec-approval-types.js";
import { readProfileConfig, writeProfileConfig } from "../agent/profile/storage.js";
import { getCronService, shutdownCronService, executeCronJob } from "../cron/index.js";
export class Hub {
private readonly agents = new Map<string, AsyncAgent>();
@ -100,11 +101,24 @@ export class Hub {
// Restore subagent registry from persistent state
initSubagentRegistry();
// Initialize and start cron service
this.initCronService();
this.client = this.createClient(this.url);
this.client.connect();
this.restoreAgents();
}
/** Initialize cron service with executor */
private initCronService(): void {
const cronService = getCronService();
cronService.setExecutor(executeCronJob);
cronService.start().catch((err) => {
console.error("[Hub] Failed to start cron service:", err);
});
console.log("[Hub] Cron service initialized");
}
/** Restore agents from persistent storage */
private restoreAgents(): void {
const records = loadAgentRecords();
@ -508,6 +522,9 @@ export class Hub {
}
shutdown(): void {
// Stop cron service
shutdownCronService();
// Finalize subagent registry before closing agents
shutdownSubagentRegistry();