Merge remote-tracking branch 'origin/main' into feat/telegram-channel

# Conflicts:
#	apps/desktop/electron/electron-env.d.ts
#	apps/desktop/electron/ipc/index.ts
#	apps/desktop/electron/preload.ts
#	apps/desktop/src/App.tsx
#	apps/desktop/src/pages/layout.tsx
#	src/agent/async-agent.ts
#	src/agent/runner.ts
#	src/hub/hub.ts
This commit is contained in:
Naiyuan Qing 2026-02-09 13:44:08 +08:00
commit 23905daaa1
85 changed files with 7368 additions and 470 deletions

View file

@ -205,6 +205,16 @@ interface ElectronAPI {
stop: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
start: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
}
cron: {
list: () => Promise<unknown[]>
toggle: (jobId: string) => Promise<{ ok: boolean }>
remove: (jobId: string) => Promise<{ ok: boolean }>
}
heartbeat: {
last: () => Promise<unknown>
setEnabled: (enabled: boolean) => Promise<{ ok: boolean; enabled?: boolean; error?: string }>
wake: (reason?: string) => Promise<{ ok: boolean; result?: unknown; error?: string }>
}
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

@ -0,0 +1,39 @@
/**
* Heartbeat IPC handlers for Electron main process.
*/
import { ipcMain } from "electron";
import { getCurrentHub } from "./hub.js";
export function registerHeartbeatIpcHandlers(): void {
ipcMain.handle("heartbeat:last", async () => {
const hub = getCurrentHub();
if (!hub) return null;
return hub.getLastHeartbeat();
});
ipcMain.handle("heartbeat:setEnabled", async (_event, enabled: boolean) => {
const hub = getCurrentHub();
if (!hub) {
return { ok: false, error: "Hub not initialized" };
}
if (typeof enabled !== "boolean") {
return { ok: false, error: "enabled must be boolean" };
}
hub.setHeartbeatsEnabled(enabled);
return { ok: true, enabled };
});
ipcMain.handle("heartbeat:wake", async (_event, reason?: string) => {
const hub = getCurrentHub();
if (!hub) {
return { ok: false, error: "Hub not initialized" };
}
const result = await hub.runHeartbeatOnce({
reason: typeof reason === "string" ? reason.trim() || "manual" : "manual",
});
return { ok: result.status !== "failed", result };
});
}

View file

@ -344,6 +344,9 @@ export function registerHubIpcHandlers(): void {
* Get message history for local chat with pagination.
* Returns raw AgentMessageItem[] so the renderer can render content blocks,
* tool results, thinking blocks, etc. same format as the Gateway RPC.
*
* Reads from session storage (not in-memory state) so that internal
* orchestration messages are excluded by default.
*/
ipcMain.handle('localChat:getHistory', async (_event, agentId: string, options?: { offset?: number; limit?: number }) => {
const h = getHub()
@ -354,7 +357,7 @@ export function registerHubIpcHandlers(): void {
try {
await agent.ensureInitialized()
const allMessages = agent.getMessages()
const allMessages = agent.loadSessionMessages()
const total = allMessages.length
// Must match DEFAULT_MESSAGES_LIMIT from @multica/sdk/actions/rpc
const limit = options?.limit ?? 200

View file

@ -7,6 +7,8 @@ export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmat
export { registerProfileIpcHandlers } from './profile.js'
export { registerProviderIpcHandlers } from './provider.js'
export { registerChannelsIpcHandlers } from './channels.js'
export { registerCronIpcHandlers } from './cron.js'
export { registerHeartbeatIpcHandlers } from './heartbeat.js'
import { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
import { registerSkillsIpcHandlers } from './skills.js'
@ -14,6 +16,8 @@ import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js'
import { registerProfileIpcHandlers } from './profile.js'
import { registerProviderIpcHandlers } from './provider.js'
import { registerChannelsIpcHandlers } from './channels.js'
import { registerCronIpcHandlers } from './cron.js'
import { registerHeartbeatIpcHandlers } from './heartbeat.js'
/**
* Register all IPC handlers.
@ -26,6 +30,8 @@ export function registerAllIpcHandlers(): void {
registerProfileIpcHandlers()
registerProviderIpcHandlers()
registerChannelsIpcHandlers()
registerCronIpcHandlers()
registerHeartbeatIpcHandlers()
}
/**

View file

@ -221,6 +221,19 @@ const electronAPI = {
ipcRenderer.invoke('channels:start', channelId, accountId),
},
// 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),
},
heartbeat: {
last: () => ipcRenderer.invoke('heartbeat:last'),
setEnabled: (enabled: boolean) => ipcRenderer.invoke('heartbeat:setEnabled', enabled),
wake: (reason?: string) => ipcRenderer.invoke('heartbeat:wake', reason),
},
// Local chat (direct IPC, no Gateway required)
localChat: {
/** Subscribe to agent events for local direct chat */

View file

@ -5,6 +5,7 @@ import ChatPage from './pages/chat'
import ToolsPage from './pages/tools'
import SkillsPage from './pages/skills'
import ChannelsPage from './pages/channels'
import CronsPage from './pages/crons'
const router = createHashRouter([
{
@ -16,6 +17,7 @@ const router = createHashRouter([
{ path: 'tools', element: <ToolsPage /> },
{ path: 'skills', element: <SkillsPage /> },
{ path: 'channels', element: <ChannelsPage /> },
{ 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

@ -0,0 +1,70 @@
import { useCallback, useEffect, useState } from "react";
export type HeartbeatEvent = {
ts: number;
status: "sent" | "ok-empty" | "ok-token" | "skipped" | "failed";
preview?: string;
durationMs?: number;
reason?: string;
};
export function useHeartbeat() {
const [enabled, setEnabled] = useState(true);
const [lastEvent, setLastEvent] = useState<HeartbeatEvent | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
setLoading(true);
setError(null);
const event = (await window.electronAPI.heartbeat.last()) as HeartbeatEvent | null;
setLastEvent(event);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
const timer = setInterval(() => {
void refresh();
}, 15000);
return () => clearInterval(timer);
}, [refresh]);
const toggleEnabled = useCallback(async () => {
const next = !enabled;
const result = await window.electronAPI.heartbeat.setEnabled(next);
if (result.ok) {
setEnabled(next);
} else {
setError(result.error ?? "Failed to update heartbeat setting");
}
}, [enabled]);
const wakeNow = useCallback(async () => {
setLoading(true);
try {
const result = await window.electronAPI.heartbeat.wake("manual");
if (!result.ok) {
setError(result.error ?? "Failed to run heartbeat");
}
await refresh();
} finally {
setLoading(false);
}
}, [refresh]);
return {
enabled,
lastEvent,
loading,
error,
refresh,
toggleEnabled,
wakeNow,
};
}

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

@ -1,9 +1,9 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import {
Comment01Icon,
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import {
Comment01Icon,
LinkSquare01Icon,
Loading03Icon,
AlertCircleIcon,
@ -15,17 +15,17 @@ import {
import { ConnectionQRCode } from '../components/qr-code'
import { DeviceList } from '../components/device-list'
import { AgentSettingsDialog } from '../components/agent-settings-dialog'
import { ApiKeyDialog } from '../components/api-key-dialog'
import { OAuthDialog } from '../components/oauth-dialog'
import { useHub } from '../hooks/use-hub'
import { useProvider } from '../hooks/use-provider'
export default function HomePage() {
const navigate = useNavigate()
const { hubInfo, agents, loading, error } = useHub()
const { providers, current, setProvider, refresh, loading: providerLoading } = useProvider()
const [settingsOpen, setSettingsOpen] = useState(false)
const [agentName, setAgentName] = useState<string | undefined>()
import { ApiKeyDialog } from '../components/api-key-dialog'
import { OAuthDialog } from '../components/oauth-dialog'
import { useHub } from '../hooks/use-hub'
import { useProvider } from '../hooks/use-provider'
export default function HomePage() {
const navigate = useNavigate()
const { hubInfo, agents, loading, error } = useHub()
const { providers, current, setProvider, refresh, loading: providerLoading } = useProvider()
const [settingsOpen, setSettingsOpen] = useState(false)
const [agentName, setAgentName] = useState<string | undefined>()
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false)
const [switching, setSwitching] = useState(false)
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
@ -186,8 +186,8 @@ export default function HomePage() {
<p className="font-medium">{agentName || 'Unnamed Agent'}</p>
</div>
{/* Provider Selector */}
<div className="p-4 rounded-lg bg-muted/50 border border-border/50 relative" ref={dropdownRef}>
{/* Provider Selector */}
<div className="p-4 rounded-lg bg-muted/50 border border-border/50 relative" ref={dropdownRef}>
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-2">
LLM Provider
</p>
@ -270,10 +270,10 @@ export default function HomePage() {
</div>
</div>
)}
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4">
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Gateway

View file

@ -9,6 +9,7 @@ import {
PlugIcon,
Comment01Icon,
Share08Icon,
Time04Icon,
} from '@hugeicons/core-free-icons'
import { cn } from '@multica/ui/lib/utils'
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
@ -20,6 +21,7 @@ const tabs = [
{ path: '/tools', label: 'Tools', icon: CodeIcon },
{ path: '/skills', label: 'Skills', icon: PlugIcon },
{ path: '/channels', label: 'Channels', icon: Share08Icon },
{ 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

@ -1,234 +0,0 @@
# App Store Submission Guide
Complete guide for publishing the Expo React Native app to Apple App Store and Google Play Store.
## 1. Prerequisites
### Accounts & Fees
| Platform | Cost | Notes |
|----------|------|-------|
| Apple Developer Program | $99/year | Required for App Store distribution |
| Google Play Console | $25 one-time | Developer registration |
| Expo Account | Free (paid plans available) | Required for EAS Build/Submit |
- Apple Developer account review: 1-2 days
- Google Play developer account review: days to weeks
### Tools
```bash
npm install -g eas-cli
eas login
eas whoami # verify login
```
## 2. Project Configuration
### Initialize EAS
```bash
eas build:configure
```
Generates `eas.json` with three build profiles: `development`, `preview`, `production`.
### Key `app.json` / `app.config.ts` Fields
```jsonc
{
"name": "Multica",
"slug": "multica",
"version": "1.0.0",
"ios": {
"bundleIdentifier": "com.multica.app",
"buildNumber": "1" // increment on each submission
},
"android": {
"package": "com.multica.app",
"versionCode": 1 // increment on each submission
},
"icon": "./assets/icon.png", // 1024x1024 PNG
"splash": {
"image": "./assets/splash.png"
}
}
```
## 3. App Signing & Credentials
### iOS
- EAS auto-manages credentials (recommended): Distribution Certificate + Provisioning Profile
- Or create manually in Apple Developer Portal
### Android
- EAS auto-generates Keystore (recommended), stored securely on EAS servers
- **Back up Keystore** — losing it means you cannot update the published app
- Play Store requires AAB (Android App Bundle) format
## 4. Production Build
```bash
# iOS
eas build --platform ios --profile production
# Android
eas build --platform android --profile production
# Both
eas build --platform all --profile production
```
Builds run in Expo cloud — no local Xcode or Android Studio needed.
## 5. Store Listing Preparation
### Required for Both Platforms
#### Privacy Policy
- **Mandatory** — must be a publicly accessible URL
- Must clearly state:
- What data the app collects and how
- Whether data is shared with third parties
- Data retention and deletion policies
- How users can request data deletion
- **2025 rule**: If data is sent to third-party AI, must disclose explicitly and obtain user consent
- Tools: Termly, PrivacyPolicies.com, or custom page
#### App Screenshots
- **iOS**: Multiple sizes required (6.7", 6.5", 5.5" iPhone + iPad)
- **Android**: 2-8 screenshots
- Must accurately reflect current app interface
#### App Icon
- 1024x1024 high-resolution PNG
- No alpha/transparency for iOS
#### App Description
- Short description (≤80 chars for Google Play)
- Full description
#### Support URL
- A link where users can get help
#### Account Deletion
- If the app supports registration, users **must** be able to delete their account and data in-app
- Both Apple and Google require this
### Apple App Store Connect — Additional Requirements
| Item | Details |
|------|---------|
| Privacy Nutrition Labels | Fill out data collection practices per category in App Store Connect |
| App Review Information | Reviewer contact info, demo/test account credentials |
| Content Rating | Age classification |
| Export Compliance | Encryption usage declaration |
| Info.plist Permission Strings | Clear purpose description for each permission (camera, location, etc.) |
### Google Play Console — Additional Requirements
| Item | Details |
|------|---------|
| Data Safety Form | Detail data collection and sharing (required even if no data is collected) |
| Content Rating Questionnaire | IARC rating questionnaire |
| Target Audience | Declare if the app targets children |
| First Upload | Must be done manually via Play Console (Google Play API limitation) |
## 6. Submit to Stores
### Apple App Store
```bash
eas submit --platform ios
```
This uploads the build to **App Store Connect / TestFlight**. Then you must:
1. Log into App Store Connect
2. Select the uploaded build
3. Associate it with a version
4. Fill in all metadata, screenshots, privacy labels
5. Submit for App Review
### Google Play Store
```bash
eas submit --platform android
```
**First time**: Must upload AAB manually in Play Console.
After initial upload:
1. Navigate to Production → Create new release
2. Upload AAB or use the EAS-submitted build
3. Fill in description, screenshots, data safety form
4. Submit for review
### Auto-Submit (Optional)
```bash
eas build --platform all --profile production --auto-submit
```
## 7. App Review
| | Apple | Google |
|---|---|---|
| Review time | Typically 24-48 hours | Hours to 7 days |
| Common rejections | Incomplete features, misleading screenshots, missing privacy policy, unclear permission strings | Data safety form mismatch, policy violations |
| After rejection | Fix issues, resubmit | Fix issues, resubmit |
## 8. Post-Launch
### OTA Updates (No Re-Review Needed)
```bash
eas update --branch production
```
- Only for JS/asset-level changes
- Native code changes still require a new build + review
### CI/CD Automation
Create `.eas/workflows/build-and-submit.yml` to auto-build and submit on push to main.
### Google Service Account Key (for Automated Android Submissions)
1. Go to EAS dashboard → Credentials → Android
2. Click Application identifier → Service Credentials
3. Add Google Service Account Key
## 9. Checklist
- [ ] Register Apple Developer + Google Play Console accounts
- [ ] Configure `app.json` and `eas.json`
- [ ] Prepare app icon, splash screen, screenshots
- [ ] Write and host privacy policy URL
- [ ] Implement in-app account deletion (if registration exists)
- [ ] Add Info.plist permission descriptions (iOS)
- [ ] Run `eas build --platform all --profile production`
- [ ] Create app in App Store Connect, fill metadata + privacy labels
- [ ] Create app in Google Play Console, fill data safety form, manual first AAB upload
- [ ] `eas submit` or submit manually for review
- [ ] Wait for review approval → live
- [ ] Set up `eas update` for OTA updates
## References
- [Expo: Submit to App Stores](https://docs.expo.dev/deploy/submit-to-app-stores/)
- [Expo: EAS Submit](https://docs.expo.dev/submit/introduction/)
- [Expo: Build Your Project](https://docs.expo.dev/deploy/build-project/)
- [Expo: App Stores Best Practices](https://docs.expo.dev/distribution/app-stores/)
- [Apple App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
- [Apple App Privacy Details](https://developer.apple.com/app-store/app-privacy-details/)
- [Google Play Data Safety](https://support.google.com/googleplay/android-developer/answer/10787469)
- [Google Play Developer Policy Center](https://play.google/developer-content-policy/)

497
docs/mobile/guide.md Normal file
View file

@ -0,0 +1,497 @@
# Mobile Development Guide
Complete lifecycle guide for developing, testing, and publishing the Expo React Native app — from first line of code to App Store / Google Play.
## Overview
```
Phase 1: Environment Setup You are here if starting fresh
Phase 2: Development & Testing Daily work loop
Phase 3: Pre-Release Preparation Before your first submission
Phase 4: Build & Submit Ship to stores
Phase 5: Post-Launch Maintain and update
```
---
## Phase 1: Environment Setup
### 1.1 Required Software
| Tool | Purpose | Install |
|------|---------|---------|
| **Node.js** (LTS) | JS runtime | `brew install node` or [nodejs.org](https://nodejs.org) |
| **pnpm** | Package manager | `corepack enable && corepack prepare pnpm@latest --activate` |
| **Xcode** | iOS build toolchain | Mac App Store (free) |
| **Xcode Command Line Tools** | Compilers, simulators | `xcode-select --install` |
| **CocoaPods** | iOS dependency manager | `sudo gem install cocoapods` |
| **Android Studio** | Android emulator + SDK (optional, iOS-first) | [developer.android.com](https://developer.android.com/studio) |
| **EAS CLI** | Expo build & submit | `npm install -g eas-cli` |
| **Expo CLI** | Dev server | Bundled with `npx expo` |
### 1.2 Xcode First-Time Setup
1. Open Xcode at least once to accept the license and install components
2. **Add your Apple ID** (free account is enough for development):
- Xcode → Settings → Accounts → `+` → Apple ID
- This creates a "Personal Team" for free code signing
3. Verify simulators are installed:
- Xcode → Settings → Components → download an iOS Simulator runtime
### 1.3 iPhone First-Time Setup (for Real Device Testing)
1. **Enable Developer Mode** (required on iOS 16+):
- Settings → Privacy & Security → Developer Mode → ON
- Device will restart
2. Connect iPhone to Mac via USB/USB-C cable
3. When prompted "Trust This Computer?" → tap Trust
### 1.4 Project Setup
```bash
# Install dependencies
pnpm install
# Generate native project files (creates ios/ and android/ directories)
npx expo prebuild
# Initialize EAS configuration (creates eas.json)
eas build:configure
```
### 1.5 Expo Account
```bash
# Create account at expo.dev, then:
eas login
eas whoami # verify
```
**No paid accounts needed at this stage.** Free Apple ID + free Expo account is enough for development.
---
## Phase 2: Development & Testing
### 2.1 Running on iOS Simulator
```bash
# Start the app in iOS simulator (no real device needed)
npx expo run:ios
```
- Fastest iteration loop — code changes hot-reload instantly
- Good for: UI layout, navigation, business logic, API calls
- **Cannot test**: camera, barcode scanner, real push notifications, biometrics
### 2.2 Running on Real iPhone
```bash
# Connect iPhone via USB, then:
npx expo run:ios --device
```
Expo CLI will:
1. Detect your connected device
2. Sign the app with your Personal Team (free Apple ID)
3. Build, install, and launch the app
**First time only**: After installation, go to:
- Settings → General → VPN & Device Management → Trust your developer certificate
#### Free Signing Limitations
| Limitation | Detail |
|-----------|--------|
| 7-day expiry | App stops launching after 7 days — just re-run `npx expo run:ios --device` |
| 3 devices max | Can register up to 3 test devices per Apple ID |
| Some entitlements unavailable | Push notifications, Apple Pay, iCloud require paid account |
| Cannot distribute to others | Only works on your own registered devices |
**Camera, barcode scanner, GPS, sensors all work fine with free signing.**
### 2.3 Daily Development Workflow
```
First time (or after native config changes):
npx expo prebuild Generate/update native projects
npx expo run:ios --device Build and install on device
Every day after that:
npx expo start --dev-client Start dev server only (no rebuild)
→ Open the app on device It connects automatically
→ Edit code, save Hot-reload updates instantly
```
**When do you need to rebuild?**
| Change | Rebuild needed? |
|--------|----------------|
| JS/TS code, React components | No — hot-reload |
| Styles, images, assets | No — hot-reload |
| Added new Expo SDK module | **Yes**`npx expo prebuild && npx expo run:ios --device` |
| Changed `app.json` permissions | **Yes** — rebuild |
| Updated native dependency | **Yes** — rebuild |
| Upgraded Expo SDK version | **Yes** — rebuild |
### 2.4 Testing Native Features (Camera, Scanner)
| Feature | Simulator | Real Device |
|---------|-----------|-------------|
| Camera preview | Not available | Works |
| Barcode / QR scan | Not available | Works |
| GPS location | Simulated location via Xcode menu | Real GPS |
| Push notifications | Not available | Requires paid Apple Developer account |
| Haptic feedback | Not available | Works |
| Device sensors (accelerometer, gyroscope) | Not available | Works |
For camera/scanner features, **always test on a real device**.
### 2.5 Debugging Tools
#### Developer Menu
Press `m` in the terminal (or shake the device) to open:
- Toggle Performance Monitor
- Toggle Element Inspector
- Open React Native DevTools
#### React Native DevTools
The primary debugging tool (replaced Chrome DevTools since RN 0.76):
| Tab | Use |
|-----|-----|
| Console | View logs, execute JS in app context |
| Sources | Set breakpoints, step through code |
| Network | Inspect API requests (Expo only) |
| Components | Inspect React component tree and props |
| Profiler | Measure render performance |
#### VS Code Integration
Install the **Expo Tools** extension for:
- Breakpoint debugging directly in VS Code
- `app.json` / `app.config.ts` IntelliSense
#### Native Crash Debugging
For crashes in native modules (not JS):
- **iOS**: Open Xcode → Window → Devices and Simulators → View Device Logs
- **Android**: `adb logcat` in terminal
---
## Phase 3: Pre-Release Preparation
**This is when you need to start spending money.**
### 3.1 Accounts & Fees
| Platform | Cost | Registration Time | Required For |
|----------|------|-------------------|--------------|
| **Apple Developer Program** | $99/year | 1-2 days review | App Store distribution |
| **Google Play Console** | $25 one-time | Days to weeks review | Play Store distribution |
| **Expo Account** | Free tier sufficient | Instant | EAS Build & Submit |
Register early — account review takes time, especially Google.
### 3.2 App Configuration
Update `app.json` or `app.config.ts`:
```jsonc
{
"name": "Multica",
"slug": "multica",
"version": "1.0.0",
"ios": {
"bundleIdentifier": "com.multica.app",
"buildNumber": "1", // increment each submission
"infoPlist": {
"NSCameraUsageDescription": "Used to scan QR codes and take photos",
"NSPhotoLibraryUsageDescription": "Used to save scanned images"
}
},
"android": {
"package": "com.multica.app",
"versionCode": 1, // increment each submission
"permissions": ["CAMERA"]
},
"icon": "./assets/icon.png", // 1024x1024 PNG, no transparency
"splash": {
"image": "./assets/splash.png"
}
}
```
### 3.3 EAS Build Profiles
`eas.json`:
```json
{
"cli": { "version": ">= 10.0.0" },
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {}
},
"submit": {
"production": {}
}
}
```
### 3.4 App Signing & Credentials
#### iOS
EAS auto-manages credentials (recommended):
- Distribution Certificate
- Provisioning Profile
- Or create manually in [Apple Developer Portal](https://developer.apple.com)
#### Android
- EAS auto-generates Keystore, stored securely on EAS servers
- **Back up your Keystore** — losing it means you can never update the published app
- Play Store requires AAB (Android App Bundle) format
### 3.5 Required Assets
| Asset | Spec |
|-------|------|
| **App Icon** | 1024x1024 PNG, no alpha/transparency (iOS) |
| **Splash Screen** | Platform-appropriate sizes |
| **iOS Screenshots** | 6.7", 6.5", 5.5" iPhone sizes + iPad (if universal) |
| **Android Screenshots** | 2-8 screenshots |
### 3.6 Required Metadata
#### Both Platforms
| Item | Notes |
|------|-------|
| **Privacy Policy URL** | Publicly accessible. Must disclose data collection, third-party sharing, AI usage, deletion rights |
| **App Description** | Short (≤80 chars for Google) + full description |
| **Support URL** | Where users can get help |
| **Account Deletion** | If app has registration, must support in-app account + data deletion |
#### Apple App Store Connect
| Item | Details |
|------|---------|
| Privacy Nutrition Labels | Data collection practices per category |
| App Review Information | Reviewer contact info, demo/test account |
| Content Rating | Age classification |
| Export Compliance | Encryption usage declaration |
| Info.plist Permission Strings | Clear purpose description for each permission |
#### Google Play Console
| Item | Details |
|------|---------|
| Data Safety Form | Required even if no data is collected |
| Content Rating Questionnaire | IARC rating |
| Target Audience | Must declare if targeting children |
| First Upload | Must upload AAB manually (Google API limitation) |
---
## Phase 4: Build & Submit
### 4.1 Production Build
```bash
# iOS
eas build --platform ios --profile production
# Android
eas build --platform android --profile production
# Both platforms
eas build --platform all --profile production
```
Builds run in Expo cloud — no local Xcode or Android Studio needed for production builds.
### 4.2 Submit to Apple App Store
```bash
eas submit --platform ios
```
This uploads the build to **App Store Connect / TestFlight**. Then:
1. Log into [App Store Connect](https://appstoreconnect.apple.com)
2. Select the uploaded build
3. Associate it with a version
4. Fill in all metadata, screenshots, privacy nutrition labels
5. Submit for App Review
### 4.3 Submit to Google Play Store
```bash
eas submit --platform android
```
**First time**: Must upload AAB manually in [Play Console](https://play.google.com/console).
After initial upload:
1. Navigate to Production → Create new release
2. Upload AAB or use the EAS-submitted build
3. Fill in description, screenshots, data safety form
4. Submit for review
### 4.4 Auto-Submit (Optional)
Build and submit in one step:
```bash
eas build --platform all --profile production --auto-submit
```
### 4.5 App Review
| | Apple | Google |
|---|---|---|
| Review time | Typically 24-48 hours | Hours to 7 days |
| Common rejections | Incomplete features, misleading screenshots, missing privacy policy, unclear permission strings | Data safety form mismatch, policy violations |
| After rejection | Fix issues, resubmit | Fix issues, resubmit |
---
## Phase 5: Post-Launch
### 5.1 OTA Updates (No Re-Review)
For JS/asset-only changes, push updates without going through App Review:
```bash
eas update --branch production
```
- Instant delivery to users — no store review
- Only works for JavaScript and asset changes
- **Native code changes still require a new build + review**
### 5.2 Version Bumping
For each new store submission:
- iOS: increment `buildNumber` in `app.json`
- Android: increment `versionCode` in `app.json`
- Bump `version` for user-visible version changes
### 5.3 CI/CD Automation
Create `.eas/workflows/build-and-submit.yml` to auto-build and submit on push to main.
#### Google Service Account Key (Automated Android Submissions)
1. EAS dashboard → Credentials → Android
2. Click Application identifier → Service Credentials
3. Add Google Service Account Key
---
## Quick Reference
### Common Commands
```bash
# Development
npx expo prebuild # Generate native projects
npx expo run:ios # Run on iOS simulator
npx expo run:ios --device # Run on connected iPhone
npx expo start --dev-client # Start dev server (after initial install)
# Building
eas build --platform ios --profile development # Dev build (for device testing)
eas build --platform ios --profile production # Production build
eas build --platform all --profile production # Both platforms
# Submitting
eas submit --platform ios # Submit to App Store
eas submit --platform android # Submit to Play Store
# OTA Updates
eas update --branch production # Push JS update to users
```
### Cost Summary
| Phase | Cost |
|-------|------|
| Development + local testing | **Free** (free Apple ID + Xcode) |
| EAS cloud builds | Free tier: 30 iOS + 30 Android builds/month |
| App Store submission | **$99/year** (Apple Developer Program) |
| Play Store submission | **$25 one-time** (Google Play Console) |
---
## Master Checklist
### Development Phase
- [ ] Install Node.js, pnpm, Xcode, EAS CLI
- [ ] Add Apple ID to Xcode (Settings → Accounts)
- [ ] Enable Developer Mode on iPhone
- [ ] Run `npx expo prebuild`
- [ ] Test on simulator: `npx expo run:ios`
- [ ] Test on real device: `npx expo run:ios --device`
- [ ] Trust developer certificate on device
- [ ] Verify camera/scanner functionality on real device
### Pre-Release Phase
- [ ] Register Apple Developer Program ($99/year)
- [ ] Register Google Play Console ($25)
- [ ] Configure `app.json` (bundleIdentifier, permissions, icon, splash)
- [ ] Configure `eas.json` build profiles
- [ ] Prepare app icon (1024x1024 PNG)
- [ ] Prepare splash screen
- [ ] Take App Store screenshots (all required sizes)
- [ ] Write and host privacy policy URL
- [ ] Write app description (short + full)
- [ ] Set up support URL
- [ ] Implement in-app account deletion (if registration exists)
### Submission Phase
- [ ] Run `eas build --platform all --profile production`
- [ ] iOS: `eas submit --platform ios`
- [ ] iOS: Fill metadata + privacy labels in App Store Connect
- [ ] iOS: Submit for App Review
- [ ] Android: Upload first AAB manually in Play Console
- [ ] Android: `eas submit --platform android`
- [ ] Android: Fill data safety form + metadata in Play Console
- [ ] Android: Submit for review
- [ ] Wait for review approval → app goes live
### Post-Launch Phase
- [ ] Set up `eas update` for OTA updates
- [ ] Set up CI/CD workflow (optional)
- [ ] Configure Google Service Account Key for automated Android submissions (optional)
---
## References
- [Expo: Getting Started](https://docs.expo.dev/get-started/introduction/)
- [Expo: Development Builds](https://docs.expo.dev/develop/development-builds/introduction/)
- [Expo: Local App Development](https://docs.expo.dev/guides/local-app-development/)
- [Expo: Debugging Tools](https://docs.expo.dev/debugging/tools/)
- [Expo: Submit to App Stores](https://docs.expo.dev/deploy/submit-to-app-stores/)
- [Expo: EAS Submit](https://docs.expo.dev/submit/introduction/)
- [Expo: EAS Update](https://docs.expo.dev/eas-update/introduction/)
- [Apple App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
- [Apple App Privacy Details](https://developer.apple.com/app-store/app-privacy-details/)
- [Google Play Data Safety](https://support.google.com/googleplay/android-developer/answer/10787469)
- [Google Play Developer Policy Center](https://play.google/developer-content-policy/)

View file

@ -60,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",
"grammy": "^1.39.3",
"json5": "^2.2.3",

View file

@ -22,7 +22,7 @@ export interface ExecApprovalRequestPayload {
riskLevel: "safe" | "needs-review" | "dangerous";
/** Reasons for the risk assessment */
riskReasons: string[];
/** When this approval expires (ms since epoch) */
/** When this approval expires (ms since epoch). -1 means no timeout. */
expiresAtMs: number;
}

View file

@ -20,12 +20,17 @@ export interface ExecApprovalItemProps {
onDecision: (decision: "allow-once" | "allow-always" | "deny") => void
}
function useCountdown(expiresAtMs: number): number {
const [remaining, setRemaining] = useState(() =>
Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000)),
function useCountdown(expiresAtMs: number): number | null {
const [remaining, setRemaining] = useState<number | null>(() =>
expiresAtMs < 0 ? null : Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000)),
)
useEffect(() => {
if (expiresAtMs < 0) {
setRemaining(null)
return
}
const id = setInterval(() => {
const next = Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000))
setRemaining(next)
@ -73,7 +78,7 @@ export const ExecApprovalItem = memo(function ExecApprovalItem({
<HugeiconsIcon icon={CommandLineIcon} strokeWidth={2} className="size-3.5 shrink-0" />
<span className="font-medium text-foreground">{riskLabel}</span>
</div>
{remaining > 0 && !decided && (
{remaining !== null && remaining > 0 && !decided && (
<span className="text-xs text-muted-foreground/60 font-[tabular-nums]">
{remaining}s
</span>
@ -100,7 +105,7 @@ export const ExecApprovalItem = memo(function ExecApprovalItem({
)}
{/* Actions */}
{!decided && remaining > 0 ? (
{!decided && (remaining === null || remaining > 0) ? (
<div className="flex items-center gap-2">
<Button
variant="outline"

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
@ -291,7 +294,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)
@ -1911,89 +1914,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==}
@ -2158,24 +2177,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==}
@ -2337,24 +2360,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==}
@ -2818,66 +2845,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==}
@ -3202,24 +3242,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==}
@ -3734,41 +3778,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==}
@ -4591,6 +4643,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==}
@ -6467,48 +6523,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==}
@ -9386,12 +9450,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
@ -10920,7 +10978,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
@ -11169,17 +11227,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
@ -11522,19 +11569,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)
@ -11548,30 +11582,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)
@ -11596,12 +11606,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
@ -14333,6 +14343,8 @@ snapshots:
crelt@1.0.6: {}
croner@10.0.1: {}
cross-fetch@3.2.0:
dependencies:
node-fetch: 2.7.0
@ -15303,7 +15315,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
@ -17715,11 +17727,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,264 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { AsyncAgent } from "./async-agent.js";
const subscribeCallbacks: Array<(event: any) => void> = [];
const internalRunState = { value: false };
const runMock = vi.fn(async () => ({ text: "", thinking: undefined, error: undefined }));
const runInternalMock = vi.fn(async () => ({ text: "", thinking: undefined, error: undefined }));
const flushSessionMock = vi.fn(async () => {});
const persistAssistantSummaryMock = vi.fn();
const subscribeAllMock = vi.fn((fn: (event: any) => void) => {
subscribeCallbacks.push(fn);
return () => {};
});
vi.mock("./runner.js", () => ({
Agent: class MockAgent {
sessionId = "test-session";
subscribeAll = subscribeAllMock;
run = runMock;
runInternal = runInternalMock;
flushSession = flushSessionMock;
persistAssistantSummary = persistAssistantSummaryMock;
get isInternalRun() {
return internalRunState.value;
}
getMessages() {
return [];
}
loadSessionMessages() {
return [];
}
async ensureInitialized() {}
getActiveTools() {
return [];
}
reloadTools() {
return [];
}
getSkillsWithStatus() {
return [];
}
getEligibleSkills() {
return [];
}
reloadSkills() {}
setToolStatus() {
return undefined;
}
getProfileId() {
return undefined;
}
getAgentName() {
return undefined;
}
setAgentName() {}
getUserContent() {
return undefined;
}
setUserContent() {}
getAgentStyle() {
return undefined;
}
setAgentStyle() {}
reloadSystemPrompt() {}
getProviderInfo() {
return { provider: "test", model: "test-model" };
}
setProvider() {
return { provider: "test", model: "test-model" };
}
},
}));
async function nextWithTimeout<T>(iter: AsyncIterator<T>, timeoutMs = 40): Promise<"timeout" | T> {
return await Promise.race([
iter.next().then((result) => (result.done ? "timeout" : result.value)),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), timeoutMs)),
]);
}
describe("AsyncAgent internal flow", () => {
afterEach(() => {
subscribeCallbacks.length = 0;
internalRunState.value = false;
runMock.mockReset();
runInternalMock.mockReset();
flushSessionMock.mockReset();
persistAssistantSummaryMock.mockReset();
subscribeAllMock.mockClear();
runMock.mockResolvedValue({ text: "", thinking: undefined, error: undefined });
runInternalMock.mockResolvedValue({ text: "", thinking: undefined, error: undefined });
flushSessionMock.mockResolvedValue(undefined);
});
it("filters internal events in direct subscribe stream", () => {
const agent = new AsyncAgent();
const events: Array<{ type: string }> = [];
const unsubscribe = agent.subscribe((event) => {
events.push(event as { type: string });
});
// subscribeAll is called twice:
// 1) constructor for read() channel forwarding
// 2) subscribe() for direct callback forwarding
const subscribeCallback = subscribeCallbacks[1];
expect(subscribeCallback).toBeDefined();
internalRunState.value = true;
subscribeCallback!({ type: "message_end" });
expect(events).toHaveLength(0);
internalRunState.value = false;
subscribeCallback!({ type: "message_end" });
expect(events).toHaveLength(1);
unsubscribe();
agent.close();
});
it("does not leak internal run errors to read() stream", async () => {
runInternalMock.mockResolvedValueOnce({ text: "", thinking: undefined, error: "internal failed" });
const agent = new AsyncAgent();
const iter = agent.read()[Symbol.asyncIterator]();
agent.writeInternal("test internal");
await agent.waitForIdle();
const value = await nextWithTimeout(iter);
expect(value).toBe("timeout");
agent.close();
});
it("does not leak internal run exceptions to read() stream", async () => {
runInternalMock.mockRejectedValueOnce(new Error("internal exception"));
const agent = new AsyncAgent();
const iter = agent.read()[Symbol.asyncIterator]();
agent.writeInternal("test internal");
await agent.waitForIdle();
const value = await nextWithTimeout(iter);
expect(value).toBe("timeout");
agent.close();
});
it("forwards assistant message stream (start/update/end) when writeInternal opts in", async () => {
let resolveRunInternal: ((value: { text: string; thinking: undefined; error: undefined }) => void) | undefined;
runInternalMock.mockImplementationOnce(
() => new Promise((resolve) => {
resolveRunInternal = resolve as typeof resolveRunInternal;
}),
);
const agent = new AsyncAgent();
const iter = agent.read()[Symbol.asyncIterator]();
const streamCallback = subscribeCallbacks[0];
expect(streamCallback).toBeDefined();
agent.writeInternal("announce", { forwardAssistant: true });
await Promise.resolve();
internalRunState.value = true;
streamCallback!({
type: "message_start",
message: { role: "assistant", content: [] },
});
streamCallback!({
type: "message_update",
message: { role: "assistant", content: [{ type: "text", text: "partial" }] },
});
streamCallback!({
type: "message_end",
message: { role: "user", content: [{ type: "text", text: "hidden internal prompt" }] },
});
streamCallback!({
type: "message_end",
message: { role: "assistant", content: [{ type: "text", text: "visible summary" }] },
});
const first = await nextWithTimeout(iter);
expect(first).not.toBe("timeout");
if (first !== "timeout") {
expect((first as { type: string }).type).toBe("message_start");
expect((first as { message: { role: string } }).message.role).toBe("assistant");
}
const second = await nextWithTimeout(iter);
expect(second).not.toBe("timeout");
if (second !== "timeout") {
expect((second as { type: string }).type).toBe("message_update");
expect((second as { message: { role: string } }).message.role).toBe("assistant");
}
const third = await nextWithTimeout(iter);
expect(third).not.toBe("timeout");
if (third !== "timeout") {
expect((third as { type: string }).type).toBe("message_end");
expect((third as { message: { role: string } }).message.role).toBe("assistant");
}
const fourth = await nextWithTimeout(iter);
expect(fourth).toBe("timeout");
resolveRunInternal!({ text: "", thinking: undefined, error: undefined });
await agent.waitForIdle();
internalRunState.value = false;
agent.close();
});
it("persists assistant summary when persistResponse is true and result has text", async () => {
runInternalMock.mockResolvedValueOnce({ text: "Summary of findings", thinking: undefined, error: undefined });
const agent = new AsyncAgent();
agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true });
await agent.waitForIdle();
expect(persistAssistantSummaryMock).toHaveBeenCalledOnce();
expect(persistAssistantSummaryMock).toHaveBeenCalledWith("Summary of findings");
// flushSession called twice: once after runInternal, once after persistAssistantSummary
expect(flushSessionMock).toHaveBeenCalledTimes(2);
agent.close();
});
it("does not persist assistant summary when result text is NO_REPLY", async () => {
runInternalMock.mockResolvedValueOnce({ text: "NO_REPLY", thinking: undefined, error: undefined });
const agent = new AsyncAgent();
agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true });
await agent.waitForIdle();
expect(persistAssistantSummaryMock).not.toHaveBeenCalled();
agent.close();
});
it("does not persist assistant summary when result text is empty", async () => {
runInternalMock.mockResolvedValueOnce({ text: " ", thinking: undefined, error: undefined });
const agent = new AsyncAgent();
agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true });
await agent.waitForIdle();
expect(persistAssistantSummaryMock).not.toHaveBeenCalled();
agent.close();
});
it("does not persist assistant summary when persistResponse is not set", async () => {
runInternalMock.mockResolvedValueOnce({ text: "Summary of findings", thinking: undefined, error: undefined });
const agent = new AsyncAgent();
agent.writeInternal("announce findings", { forwardAssistant: true });
await agent.waitForIdle();
expect(persistAssistantSummaryMock).not.toHaveBeenCalled();
agent.close();
});
});

View file

@ -10,12 +10,21 @@ const devNull = { write: () => true } as unknown as NodeJS.WritableStream;
/** Discriminated union of legacy Message, raw AgentEvent, and MulticaEvent */
export type ChannelItem = Message | AgentEvent | MulticaEvent;
export interface WriteInternalOptions {
/** Forward assistant message_end events to realtime stream during internal runs */
forwardAssistant?: boolean | undefined;
/** After internal run completes, persist the LLM's summary as a non-internal assistant message */
persistResponse?: boolean | undefined;
}
export class AsyncAgent {
private readonly agent: Agent;
private readonly channel = new Channel<ChannelItem>();
private _closed = false;
private queue: Promise<void> = Promise.resolve();
private pendingWrites = 0;
private closeCallbacks: Array<() => void> = [];
private forwardInternalAssistant = false;
readonly sessionId: string;
constructor(options?: AgentOptions) {
@ -25,8 +34,11 @@ export class AsyncAgent {
});
this.sessionId = this.agent.sessionId;
// Forward raw AgentEvent and MulticaEvent into the channel
// Forward raw AgentEvent and MulticaEvent into the channel.
// Suppress forwarding during internal runs to avoid leaking
// orchestration messages to the frontend/real-time stream.
this.agent.subscribeAll((event: AgentEvent | MulticaEvent) => {
if (!this.shouldForwardEvent(event)) return;
this.channel.send(event);
});
}
@ -43,6 +55,7 @@ export class AsyncAgent {
/** Enqueue an agent run, handling errors and session flush */
private enqueue(runFn: () => ReturnType<Agent["run"]>): void {
if (this._closed) throw new Error("Agent is closed");
this.pendingWrites += 1;
this.queue = this.queue
.then(async () => {
@ -63,6 +76,47 @@ export class AsyncAgent {
console.error(`[AsyncAgent] Agent run exception: ${message}`);
this.channel.send({ id: uuidv7(), content: `[error] ${message}` });
this.agent.emitMulticaEvent({ type: "agent_error", error: message });
})
.finally(() => {
this.pendingWrites = Math.max(0, this.pendingWrites - 1);
});
}
/**
* Write an internal message to agent (non-blocking, serialized queue).
* Messages are persisted with `internal: true` and rolled back from
* in-memory state. Events are suppressed from the real-time stream by default.
*/
writeInternal(content: string, options?: WriteInternalOptions): void {
if (this._closed) throw new Error("Agent is closed");
const forwardAssistant = options?.forwardAssistant === true;
const persistResponse = options?.persistResponse === true;
this.queue = this.queue
.then(async () => {
if (this._closed) return;
const prevForward = this.forwardInternalAssistant;
this.forwardInternalAssistant = forwardAssistant;
try {
const result = await this.agent.runInternal(content);
await this.agent.flushSession();
if (result.error) {
// Internal run errors are for diagnostics only; do not leak to user stream.
console.error(`[AsyncAgent] Internal run error: ${result.error}`);
}
// Persist the LLM summary so it remains in parent context for future turns
if (persistResponse && result.text?.trim() && result.text.trim() !== "NO_REPLY") {
this.agent.persistAssistantSummary(result.text.trim());
await this.agent.flushSession();
}
} finally {
this.forwardInternalAssistant = prevForward;
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err);
// Internal run exceptions are for diagnostics only; do not leak to user stream.
console.error(`[AsyncAgent] Internal run failed: ${message}`);
});
}
@ -79,6 +133,7 @@ export class AsyncAgent {
subscribe(callback: (event: AgentEvent | MulticaEvent) => void): () => void {
console.log(`[AsyncAgent] Adding subscriber for agent: ${this.sessionId}`);
const unsubscribe = this.agent.subscribeAll((event) => {
if (!this.shouldForwardEvent(event)) return;
console.log(`[AsyncAgent] Event received: ${event.type}`);
callback(event);
});
@ -93,6 +148,18 @@ export class AsyncAgent {
return this.queue;
}
private shouldForwardEvent(event: AgentEvent | MulticaEvent): boolean {
if (!this.agent.isInternalRun) return true;
if (!this.forwardInternalAssistant) return false;
if (event.type !== "message_start" && event.type !== "message_update" && event.type !== "message_end") {
return false;
}
const maybeMessage = (event as { message?: unknown }).message;
if (!maybeMessage || typeof maybeMessage !== "object") return false;
return (maybeMessage as { role?: unknown }).role === "assistant";
}
/** Register a callback to be invoked when the agent is closed */
onClose(callback: () => void): void {
if (this._closed) {
@ -179,6 +246,34 @@ export class AsyncAgent {
return this.agent.getProfileId();
}
/**
* Get profile directory path, if profile is enabled.
*/
getProfileDir(): string | undefined {
return this.agent.getProfileDir();
}
/**
* Get heartbeat configuration from profile config.
*/
getHeartbeatConfig():
| {
enabled?: boolean | undefined;
every?: string | undefined;
prompt?: string | undefined;
ackMaxChars?: number | undefined;
}
| undefined {
return this.agent.getHeartbeatConfig();
}
/**
* Number of queued/in-flight writes.
*/
getPendingWrites(): number {
return this.pendingWrites;
}
/**
* Get agent display name from profile config.
*/
@ -235,12 +330,20 @@ export class AsyncAgent {
}
/**
* Get all messages from the current session.
* Get all messages from the current session (in-memory state).
*/
getMessages(): AgentMessage[] {
return this.agent.getMessages();
}
/**
* Load messages from session storage with filtering.
* By default, internal messages are excluded.
*/
loadSessionMessages(options?: { includeInternal?: boolean }): AgentMessage[] {
return this.agent.loadSessionMessages(options);
}
/**
* Get current provider and model information.
*/

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

@ -22,7 +22,7 @@ ${cyan("Usage:")} multica session <command> [options]
${cyan("Commands:")}
${yellow("list")} List all sessions
${yellow("show")} <id> Show session details
${yellow("show")} <id> Show session details (use --show-internal to include internal messages)
${yellow("delete")} <id> Delete a session
${yellow("help")} Show this help
@ -122,7 +122,7 @@ function cmdList() {
console.log(`${dim("Resume with:")} multica --session <id>`);
}
function cmdShow(sessionId: string | undefined) {
function cmdShow(sessionId: string | undefined, showInternal = false) {
if (!sessionId) {
console.error("Error: Session ID is required");
console.error("Usage: multica session show <id>");
@ -160,14 +160,25 @@ function cmdShow(sessionId: string | undefined) {
console.log(cyan("─".repeat(60)));
console.log("");
// Parse and display messages
// Parse and display messages as SessionEntry objects
for (const line of lines) {
try {
const msg = JSON.parse(line);
const entry = JSON.parse(line);
// Only display message entries
if (entry.type !== "message") continue;
// Skip internal messages unless --show-internal
if (entry.internal && !showInternal) continue;
const msg = entry.message;
if (!msg) continue;
const role = msg.role || "unknown";
const roleColor = role === "user" ? green : role === "assistant" ? cyan : dim;
const internalTag = entry.internal ? dim(" [internal]") : "";
console.log(`${roleColor(`[${role}]`)}`);
console.log(`${roleColor(`[${role}]`)}${internalTag}`);
if (typeof msg.content === "string") {
// Truncate long content
@ -238,6 +249,7 @@ function cmdDelete(sessionId: string | undefined) {
export async function sessionCommand(args: string[]): Promise<void> {
const command = (args[0] || "help") as Command;
const arg1 = args[1];
const showInternal = args.includes("--show-internal");
if (args.includes("--help") || args.includes("-h")) {
printHelp();
@ -249,7 +261,7 @@ export async function sessionCommand(args: string[]): Promise<void> {
cmdList();
break;
case "show":
cmdShow(arg1);
cmdShow(arg1, showInternal);
break;
case "delete":
cmdDelete(arg1);

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

@ -50,6 +50,7 @@ export function createAgentProfile(
profile.user = DEFAULT_TEMPLATES.user;
profile.workspace = DEFAULT_TEMPLATES.workspace;
profile.memory = DEFAULT_TEMPLATES.memory;
profile.heartbeat = DEFAULT_TEMPLATES.heartbeat;
// 保存到文件
saveProfile(profile, { baseDir });
@ -150,6 +151,7 @@ export class ProfileManager {
user: profile.user,
workspace: profile.workspace,
memory: profile.memory,
heartbeat: profile.heartbeat,
config: profile.config,
},
profileDir: this.getProfileDir(),
@ -168,6 +170,19 @@ export class ProfileManager {
return profile?.config;
}
/** Get heartbeat configuration from profile config */
getHeartbeatConfig():
| {
enabled?: boolean | undefined;
every?: string | undefined;
prompt?: string | undefined;
ackMaxChars?: number | undefined;
}
| undefined {
const profile = this.getProfile();
return profile?.config?.heartbeat;
}
/** 更新 tools 配置 */
updateToolsConfig(toolsConfig: ToolsConfig): void {
const profile = this.getOrCreateProfile(false);

View file

@ -95,13 +95,14 @@ export function loadProfile(profileId: string, options?: StorageOptions): AgentP
user: readProfileFile(profileId, PROFILE_FILES.user, options),
workspace: readProfileFile(profileId, PROFILE_FILES.workspace, options),
memory: readProfileFile(profileId, PROFILE_FILES.memory, options),
heartbeat: readProfileFile(profileId, PROFILE_FILES.heartbeat, options),
config: readProfileConfig(profileId, options),
};
}
/** 保存 AgentProfile只写入非空字段 */
export function saveProfile(profile: AgentProfile, options?: StorageOptions): void {
const { id, soul, user, workspace, memory, config } = profile;
const { id, soul, user, workspace, memory, heartbeat, config } = profile;
if (soul !== undefined) {
writeProfileFile(id, PROFILE_FILES.soul, soul, options);
@ -115,6 +116,9 @@ export function saveProfile(profile: AgentProfile, options?: StorageOptions): vo
if (memory !== undefined) {
writeProfileFile(id, PROFILE_FILES.memory, memory, options);
}
if (heartbeat !== undefined) {
writeProfileFile(id, PROFILE_FILES.heartbeat, heartbeat, options);
}
if (config !== undefined) {
writeProfileConfig(id, config, options);
}

View file

@ -72,6 +72,7 @@ Your profile directory contains these files (use \`edit\` or \`write\` to update
| \`user.md\` | About your human | As you learn about them |
| \`workspace.md\` | This file — your rules | When you discover better conventions |
| \`memory.md\` | Long-term knowledge | Regularly — capture what matters |
| \`heartbeat.md\` | Background check instructions | When heartbeat behavior should change |
## Every Session
@ -89,6 +90,7 @@ You wake up fresh each session. These files are your continuity:
- **Long-term:** \`MEMORY.md\` — your curated memories, lessons learned
- **Daily notes:** \`memory/YYYY-MM-DD.md\` — raw logs of what happened (optional)
- **Heartbeat:** \`heartbeat.md\` — periodic check loop instructions
Capture what matters. Decisions, context, things to remember.
@ -101,6 +103,7 @@ Capture what matters. Decisions, context, things to remember.
- \`memory.md\` — Your learnings: decisions made, lessons learned, important context
- \`workspace.md\` — Your rules: conventions, workflows, how you should operate
- \`soul.md\` — Your identity: only change if user wants to reshape who you are
- \`heartbeat.md\` — Periodic background checks and alert rules
**Rules:**
- **DO NOT** say "I'll remember that" without ACTUALLY calling \`edit\` or \`write\` on a file
@ -148,5 +151,11 @@ _(Persistent knowledge will be stored here. Update this as you learn.)_
## Lessons Learned
## Important Context
`,
heartbeat: `# heartbeat.md
# Keep this file empty (or with only comments) to skip heartbeat API calls.
# Add tasks below when you want the agent to check something periodically.
`,
} as const;

View file

@ -11,6 +11,7 @@ export const PROFILE_FILES = {
user: "user.md",
workspace: "workspace.md",
memory: "memory.md",
heartbeat: "heartbeat.md",
config: "config.json",
} as const;
@ -42,6 +43,17 @@ export interface ProfileConfig {
reasoningMode?: "off" | "on" | "stream" | undefined;
/** Exec approval configuration (security level, ask mode, allowlist) */
execApproval?: ExecApprovalConfig | undefined;
/** Heartbeat configuration */
heartbeat?: {
/** Global heartbeat enable switch */
enabled?: boolean | undefined;
/** Interval, e.g. "30m", "1h" */
every?: string | undefined;
/** Optional prompt override */
prompt?: string | undefined;
/** Max chars after HEARTBEAT_OK to still treat as ack */
ackMaxChars?: number | undefined;
} | undefined;
}
/** Agent Profile configuration */
@ -56,6 +68,8 @@ export interface AgentProfile {
workspace?: string | undefined;
/** Persistent memory - long-term knowledge base */
memory?: string | undefined;
/** Periodic heartbeat instructions */
heartbeat?: string | undefined;
/** Profile configuration (from config.json) */
config?: ProfileConfig | undefined;
}

View file

@ -1,5 +1,4 @@
import { Agent as PiAgentCore, type AgentEvent, type AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import { v7 as uuidv7 } from "uuid";
import type { AgentOptions, AgentRunResult, ReasoningMode } from "./types.js";
import type { MulticaEvent } from "./events.js";
@ -85,6 +84,10 @@ export class Agent {
private readonly stderr: NodeJS.WritableStream;
private initialized = false;
// Internal run state
private _internalRun = false;
private _runMutex: Promise<void> = Promise.resolve();
// MulticaEvent subscribers (parallel to PiAgentCore's subscriber list)
// Typed as AgentEvent | MulticaEvent to match subscribeAll() callback signature
private multicaListeners: Array<(event: AgentEvent | MulticaEvent) => void> = [];
@ -353,7 +356,49 @@ export class Agent {
}
}
async run(prompt: string, images?: ImageContent[]): Promise<AgentRunResult> {
async run(prompt: string): Promise<AgentRunResult> {
// Run-level mutex: prevents concurrent run/runInternal from mis-tagging messages
return this.withRunMutex(() => this._run(prompt));
}
/**
* Run a prompt as an internal turn.
* Messages are persisted with `internal: true` and rolled back from
* in-memory state after the turn completes, so they do not pollute
* the main conversation context.
*/
async runInternal(prompt: string): Promise<AgentRunResult> {
return this.withRunMutex(async () => {
const messageCountBefore = this.agent.state.messages.length;
this._internalRun = true;
try {
const result = await this._run(prompt);
return result;
} finally {
this._internalRun = false;
// Roll back internal messages from in-memory state
const current = this.agent.state.messages;
if (current.length > messageCountBefore) {
this.agent.replaceMessages(current.slice(0, messageCountBefore));
}
}
});
}
private async withRunMutex<T>(fn: () => Promise<T>): Promise<T> {
// Chain on the mutex so only one run executes at a time
const prev = this._runMutex;
let resolve: () => void;
this._runMutex = new Promise<void>((r) => { resolve = r; });
await prev;
try {
return await fn();
} finally {
resolve!();
}
}
private async _run(prompt: string): Promise<AgentRunResult> {
await this.ensureInitialized();
this.output.state.lastAssistantText = "";
@ -363,7 +408,7 @@ export class Agent {
// Loop to exhaust all candidate profiles on rotatable errors
while (true) {
try {
await this.agent.prompt(prompt, images);
await this.agent.prompt(prompt);
break; // success — exit loop
} catch (error) {
lastError = error;
@ -443,8 +488,10 @@ export class Agent {
private handleSessionEvent(event: AgentEvent) {
if (event.type === "message_end") {
const message = event.message as AgentMessage;
this.session.saveMessage(message);
if (message.role === "assistant") {
this.session.saveMessage(message, this._internalRun ? { internal: true } : undefined);
// Skip compaction during internal runs — internal messages will be
// rolled back from memory afterwards, so compacting now would be incorrect.
if (message.role === "assistant" && !this._internalRun) {
void this.maybeCompact();
}
}
@ -511,6 +558,40 @@ export class Agent {
return this.agent.state.tools?.map(t => t.name) ?? [];
}
/** Whether the agent is currently executing an internal run */
get isInternalRun(): boolean {
return this._internalRun;
}
/**
* Persist a synthetic assistant message into both in-memory state and session JSONL.
* Used after an internal run to keep the LLM summary visible in future turns
* while the internal prompt stays hidden.
*/
persistAssistantSummary(text: string): void {
const model = this.agent.state.model;
const message = {
role: "assistant" as const,
content: [{ type: "text" as const, text }],
api: model?.api ?? "openai-completions",
provider: model?.provider ?? "internal",
model: model?.id ?? "unknown",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop" as const,
timestamp: Date.now(),
};
this.agent.appendMessage(message);
this.session.saveMessage(message);
}
/** Ensure session messages are loaded from disk (idempotent) */
async ensureInitialized(): Promise<void> {
if (this.initialized) return;
@ -522,11 +603,19 @@ export class Agent {
this.initialized = true;
}
/** Get all messages from the current session */
/** Get all messages from the current session (in-memory state) */
getMessages(): AgentMessage[] {
return this.agent.state.messages.slice();
}
/**
* Load messages from session storage with filtering.
* By default, internal messages are excluded.
*/
loadSessionMessages(options?: { includeInternal?: boolean }): AgentMessage[] {
return this.session.loadMessages(options);
}
/**
* Get all skills with their eligibility status.
* Returns empty array if skills are disabled.
@ -596,6 +685,27 @@ export class Agent {
return this.profile?.getProfile()?.id;
}
/**
* Get profile directory path, if profile is enabled.
*/
getProfileDir(): string | undefined {
return this.profile?.getProfileDir();
}
/**
* Get heartbeat configuration from profile config.
*/
getHeartbeatConfig():
| {
enabled?: boolean | undefined;
every?: string | undefined;
prompt?: string | undefined;
ackMaxChars?: number | undefined;
}
| undefined {
return this.profile?.getHeartbeatConfig();
}
/**
* Get agent display name from profile config.
*/
@ -771,6 +881,7 @@ export class Agent {
user: profile.user,
workspace: profile.workspace,
memory: profile.memory,
heartbeat: profile.heartbeat,
config: profile.config,
},
profileDir: this.profile!.getProfileDir(),

View file

@ -167,11 +167,15 @@ export class SessionManager {
return repairSessionFileIfNeeded({ sessionFile: filePath, warn });
}
loadMessages(): AgentMessage[] {
loadMessages(options?: { includeInternal?: boolean }): AgentMessage[] {
const entries = this.loadEntries();
let messages = entries
.filter((entry) => entry.type === "message")
.map((entry) => entry.message);
.filter((entry) => {
if (entry.type !== "message") return false;
if (!options?.includeInternal && entry.internal) return false;
return true;
})
.map((entry) => (entry as { type: "message"; message: AgentMessage }).message);
messages = sanitizeToolCallInputs(messages);
messages = sanitizeToolUseResultPairing(messages);
return messages;
@ -203,11 +207,16 @@ export class SessionManager {
);
}
saveMessage(message: AgentMessage) {
saveMessage(message: AgentMessage, options?: { internal?: boolean }) {
void this.enqueue(() =>
appendEntry(
this.sessionId,
{ type: "message", message, timestamp: Date.now() },
{
type: "message",
message,
timestamp: Date.now(),
...(options?.internal ? { internal: true } : {}),
},
{ baseDir: this.baseDir },
),
);

View file

@ -11,7 +11,7 @@ export type SessionMeta = {
};
export type SessionEntry =
| { type: "message"; message: AgentMessage; timestamp: number }
| { type: "message"; message: AgentMessage; timestamp: number; internal?: boolean }
| { type: "meta"; meta: SessionMeta; timestamp: number }
| {
type: "compaction";

View file

@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const readEntriesMock = vi.fn();
vi.mock("../session/storage.js", () => ({
readEntries: (sessionId: string) => readEntriesMock(sessionId),
}));
import { readLatestAssistantReply } from "./announce.js";
describe("readLatestAssistantReply", () => {
beforeEach(() => {
readEntriesMock.mockReset();
});
it("returns the latest non-empty assistant text when the last assistant message is tool-only", () => {
readEntriesMock.mockReturnValue([
{
type: "message",
timestamp: 1,
message: {
role: "assistant",
content: [{ type: "text", text: "南京天气12°C。" }],
},
},
{
type: "message",
timestamp: 2,
message: {
role: "assistant",
content: [{ type: "toolCall", id: "tool-1", name: "weather", arguments: { city: "Nanjing" } }],
},
},
]);
const result = readLatestAssistantReply("child-session");
expect(result).toBe("南京天气12°C。");
});
it("falls back to latest toolResult text when no assistant text exists", () => {
readEntriesMock.mockReturnValue([
{
type: "message",
timestamp: 1,
message: {
role: "assistant",
content: [{ type: "toolCall", id: "tool-2", name: "weather", arguments: { city: "Nanjing" } }],
},
},
{
type: "message",
timestamp: 2,
message: {
role: "toolResult",
toolCallId: "tool-2",
toolName: "weather",
content: [{ type: "text", text: "{\"city\":\"Nanjing\",\"tempC\":12,\"condition\":\"Sunny\"}" }],
isError: false,
},
},
]);
const result = readLatestAssistantReply("child-session");
expect(result).toContain("\"city\":\"Nanjing\"");
expect(result).toContain("\"condition\":\"Sunny\"");
});
});

View file

@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { buildSubagentSystemPrompt, formatAnnouncementMessage } from "./announce.js";
import { buildSubagentSystemPrompt, formatAnnouncementMessage, formatCoalescedAnnouncementMessage } from "./announce.js";
import type { FormatAnnouncementParams } from "./announce.js";
import type { SubagentRunRecord } from "./types.js";
describe("buildSubagentSystemPrompt", () => {
it("includes task and session context", () => {
@ -126,3 +127,128 @@ describe("formatAnnouncementMessage", () => {
expect(msg).toContain("NO_REPLY");
});
});
describe("formatCoalescedAnnouncementMessage", () => {
function makeRecord(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
return {
runId: "run-1",
childSessionId: "child-1",
requesterSessionId: "parent-1",
task: "Default task",
cleanup: "delete",
createdAt: 1000000,
startedAt: 1000000,
endedAt: 1030000,
outcome: { status: "ok" },
findings: "Some findings",
findingsCaptured: true,
announced: false,
...overrides,
};
}
it("delegates to formatAnnouncementMessage for a single record", () => {
const record = makeRecord({ label: "Code Analysis" });
const coalesced = formatCoalescedAnnouncementMessage([record]);
const direct = formatAnnouncementMessage({
runId: record.runId,
childSessionId: record.childSessionId,
requesterSessionId: record.requesterSessionId,
task: record.task,
label: record.label,
cleanup: record.cleanup,
outcome: record.outcome,
startedAt: record.startedAt,
endedAt: record.endedAt,
findings: record.findings,
});
expect(coalesced).toBe(direct);
});
it("formats multiple records with all task findings and stats", () => {
const records = [
makeRecord({
runId: "run-1",
childSessionId: "child-1",
label: "Task A",
findings: "Found issue A",
startedAt: 1000000,
endedAt: 1030000,
}),
makeRecord({
runId: "run-2",
childSessionId: "child-2",
label: "Task B",
findings: "Found issue B",
startedAt: 1000000,
endedAt: 1045000, // 45 seconds
}),
];
const msg = formatCoalescedAnnouncementMessage(records);
expect(msg).toContain("All 2 background tasks have completed");
expect(msg).toContain('Task 1: "Task A"');
expect(msg).toContain("Found issue A");
expect(msg).toContain('Task 2: "Task B"');
expect(msg).toContain("Found issue B");
expect(msg).toContain("Total wall time: 45s");
expect(msg).toContain("2 succeeded, 0 failed");
});
it("reports mixed outcomes correctly", () => {
const records = [
makeRecord({ runId: "run-1", label: "OK Task", outcome: { status: "ok" } }),
makeRecord({ runId: "run-2", label: "Failed Task", outcome: { status: "error", error: "crash" } }),
makeRecord({ runId: "run-3", label: "Timeout Task", outcome: { status: "timeout" } }),
];
const msg = formatCoalescedAnnouncementMessage(records);
expect(msg).toContain("completed successfully");
expect(msg).toContain("failed: crash");
expect(msg).toContain("timed out");
expect(msg).toContain("1 succeeded, 2 failed");
});
it("shows (no output) for missing findings", () => {
const records = [
makeRecord({ runId: "run-1", findings: undefined }),
makeRecord({ runId: "run-2", findings: "Has output" }),
];
const msg = formatCoalescedAnnouncementMessage(records);
expect(msg).toContain("(no output)");
expect(msg).toContain("Has output");
});
it("includes combined summary instruction for multi-record", () => {
const records = [
makeRecord({ runId: "run-1" }),
makeRecord({ runId: "run-2" }),
];
const msg = formatCoalescedAnnouncementMessage(records);
expect(msg).toContain("MUST include findings from every task item above");
expect(msg).toContain("NO_REPLY");
});
it("includes raw findings for every task in coalesced payload", () => {
const records = [
makeRecord({ runId: "run-1", label: "南京天气", findings: "南京12°C" }),
makeRecord({ runId: "run-2", label: "上海天气", findings: "上海多云9°C" }),
];
const msg = formatCoalescedAnnouncementMessage(records);
expect(msg).toContain("Raw findings from each task (MUST cover all items):");
expect(msg).toContain("[1] 南京天气:");
expect(msg).toContain("南京12°C");
expect(msg).toContain("[2] 上海天气:");
expect(msg).toContain("上海多云9°C");
expect(msg).toContain("MUST include findings from every task item above");
});
});

View file

@ -13,6 +13,7 @@ import { buildSystemPrompt } from "../system-prompt/index.js";
import type {
SubagentAnnounceParams,
SubagentRunOutcome,
SubagentRunRecord,
SubagentSystemPromptParams,
} from "./types.js";
@ -38,19 +39,29 @@ export function buildSubagentSystemPrompt(params: SubagentSystemPromptParams): s
*/
export function readLatestAssistantReply(sessionId: string): string | undefined {
const entries = readEntries(sessionId);
let latestToolResultText: string | undefined;
// Walk backwards to find last assistant message
// Walk backwards to find the last non-empty assistant reply.
// If no assistant text exists (e.g. run ended after tool execution),
// fall back to the latest non-empty toolResult content.
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]!;
if (entry.type !== "message") continue;
const message = entry.message;
if (message.role !== "assistant") continue;
if (message.role === "assistant") {
const text = extractAssistantText(message);
if (text) return text;
continue;
}
return extractAssistantText(message);
if (message.role === "toolResult" && !latestToolResultText) {
const text = extractToolResultText(message);
if (text) latestToolResultText = text;
}
}
return undefined;
return latestToolResultText;
}
/**
@ -58,7 +69,17 @@ export function readLatestAssistantReply(sessionId: string): string | undefined
* AgentMessage.content for assistant is (TextContent | ThinkingContent | ToolCall)[].
*/
function extractAssistantText(message: { role: string; content: unknown }): string {
const content = message.content;
return extractTextLikeContent(message.content);
}
/**
* Extract text content from a toolResult message.
*/
function extractToolResultText(message: { role: string; content: unknown }): string {
return extractTextLikeContent(message.content);
}
function extractTextLikeContent(content: unknown): string {
if (typeof content === "string") {
return sanitizeText(content);
}
@ -67,8 +88,9 @@ function extractAssistantText(message: { role: string; content: unknown }): stri
const textParts: string[] = [];
for (const block of content) {
if (block && typeof block === "object" && "type" in block && block.type === "text" && "text" in block) {
textParts.push(String(block.text));
if (!block || typeof block !== "object") continue;
if ("text" in block) {
textParts.push(String((block as { text: unknown }).text));
}
}
@ -167,11 +189,126 @@ export function formatAnnouncementMessage(params: FormatAnnouncementParams): str
return parts.join("\n");
}
/**
* Format a coalesced announcement message from multiple completed subagent runs.
* When only one record is provided, delegates to formatAnnouncementMessage.
*/
export function formatCoalescedAnnouncementMessage(
records: SubagentRunRecord[],
): string {
// Single record: delegate to existing format for backward-compatible behavior
if (records.length === 1) {
const r = records[0]!;
return formatAnnouncementMessage({
runId: r.runId,
childSessionId: r.childSessionId,
requesterSessionId: r.requesterSessionId,
task: r.task,
label: r.label,
cleanup: r.cleanup,
outcome: r.outcome,
startedAt: r.startedAt,
endedAt: r.endedAt,
findings: r.findings,
});
}
// Multiple records: build combined message.
// Include a strict raw-findings section so parent can reliably cover every task result.
const parts: string[] = [
`All ${records.length} background tasks have completed. Here are the combined results:`,
"",
];
for (let i = 0; i < records.length; i++) {
const r = records[i]!;
const displayName = r.label || r.task.slice(0, 60);
const statusLabel = formatStatusLabel(r.outcome);
const durationStr = (r.startedAt && r.endedAt)
? ` (${formatDuration(r.startedAt, r.endedAt)})`
: "";
parts.push(
`### Task ${i + 1}: "${displayName}"`,
`Status: ${statusLabel}${durationStr}`,
"",
"Findings:",
r.findings || "(no output)",
"",
);
}
// Overall stats
const allStartTimes = records.map(r => r.startedAt).filter(Boolean) as number[];
const allEndTimes = records.map(r => r.endedAt).filter(Boolean) as number[];
if (allStartTimes.length > 0 && allEndTimes.length > 0) {
const wallTime = formatDuration(Math.min(...allStartTimes), Math.max(...allEndTimes));
parts.push(`Total wall time: ${wallTime}`);
}
const okCount = records.filter(r => r.outcome?.status === "ok").length;
const failCount = records.length - okCount;
parts.push(`Results: ${okCount} succeeded, ${failCount} failed/timed out`);
parts.push("", "Raw findings from each task (MUST cover all items):", "");
for (let i = 0; i < records.length; i++) {
const r = records[i]!;
const displayName = r.label || r.task.slice(0, 60);
parts.push(
`[${i + 1}] ${displayName}:`,
r.findings || "(no output)",
"",
);
}
parts.push(
"",
"Summarize these results naturally for the user.",
"You MUST include findings from every task item above, without omission.",
"Keep it concise, but preserve concrete findings from each task.",
"Do not mention technical details like session IDs or that these were background tasks.",
"You can respond with NO_REPLY if no announcement is needed.",
);
return parts.join("\n");
}
/**
* Run the coalesced announcement flow for all completed runs of a requester.
* Formats a single combined message and delivers it to the parent agent.
*/
export function runCoalescedAnnounceFlow(
requesterSessionId: string,
records: SubagentRunRecord[],
): boolean {
const message = formatCoalescedAnnouncementMessage(records);
try {
const hub = getHub();
const parentAgent = hub.getAgent(requesterSessionId);
if (!parentAgent || parentAgent.closed) {
console.warn(
`[SubagentAnnounce] Parent agent not found or closed: ${requesterSessionId}`,
);
return false;
}
parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true });
return true;
} catch (err) {
console.error(`[SubagentAnnounce] Failed to coalesced-announce to parent:`, err);
return false;
}
}
/**
* Run the full subagent announcement flow:
* 1. Read child's last assistant reply
* 2. Format announcement message
* 3. Send to parent agent via Hub
*
* @deprecated Use runCoalescedAnnounceFlow instead, which supports
* batching multiple completed runs into a single announcement.
*/
export function runSubagentAnnounceFlow(params: SubagentAnnounceParams): boolean {
const { requesterSessionId, childSessionId } = params;
@ -204,7 +341,7 @@ export function runSubagentAnnounceFlow(params: SubagentAnnounceParams): boolean
return false;
}
parentAgent.write(message);
parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true });
return true;
} catch (err) {
console.error(`[SubagentAnnounce] Failed to announce to parent:`, err);

View file

@ -28,6 +28,8 @@ export {
readLatestAssistantReply,
formatAnnouncementMessage,
runSubagentAnnounceFlow,
formatCoalescedAnnouncementMessage,
runCoalescedAnnounceFlow,
} from "./announce.js";
export type { FormatAnnouncementParams } from "./announce.js";

View file

@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SubagentRunRecord } from "./types.js";
const loadSubagentRunsMock = vi.fn<() => Map<string, SubagentRunRecord>>();
const saveSubagentRunsMock = vi.fn();
const readLatestAssistantReplyMock = vi.fn();
const runCoalescedAnnounceFlowMock = vi.fn(() => false);
const resolveSessionDirMock = vi.fn((sessionId: string) => `/tmp/${sessionId}`);
const closeAgentMock = vi.fn();
const getHubMock = vi.fn(() => ({ closeAgent: closeAgentMock }));
const rmSyncMock = vi.fn();
vi.mock("./registry-store.js", () => ({
loadSubagentRuns: loadSubagentRunsMock,
saveSubagentRuns: saveSubagentRunsMock,
}));
vi.mock("./announce.js", () => ({
readLatestAssistantReply: readLatestAssistantReplyMock,
runCoalescedAnnounceFlow: runCoalescedAnnounceFlowMock,
}));
vi.mock("../session/storage.js", () => ({
resolveSessionDir: resolveSessionDirMock,
}));
vi.mock("../../hub/hub-singleton.js", () => ({
getHub: getHubMock,
isHubInitialized: vi.fn(() => false),
}));
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
rmSync: rmSyncMock,
};
});
describe("subagent registry recovery cleanup", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
loadSubagentRunsMock.mockReturnValue(new Map());
runCoalescedAnnounceFlowMock.mockReturnValue(false);
});
it("deletes child session on recovery even when findings were already captured", async () => {
const now = Date.now();
const record: SubagentRunRecord = {
runId: "run-1",
childSessionId: "child-1",
requesterSessionId: "parent-1",
task: "task",
cleanup: "delete",
createdAt: now - 1000,
startedAt: now - 900,
endedAt: now - 100,
outcome: { status: "ok" },
findings: "done",
findingsCaptured: true,
cleanupHandled: false,
announced: false,
};
loadSubagentRunsMock.mockReturnValue(new Map([["run-1", record]]));
const registry = await import("./registry.js");
registry.initSubagentRegistry();
expect(readLatestAssistantReplyMock).not.toHaveBeenCalled();
expect(resolveSessionDirMock).toHaveBeenCalledWith("child-1");
expect(rmSyncMock).toHaveBeenCalledWith("/tmp/child-1", { recursive: true, force: true });
});
});

View file

@ -78,4 +78,50 @@ describe("registry-store serialization", () => {
expect(parsed.outcome?.status).toBe("error");
expect(parsed.outcome?.error).toBe("Something went wrong");
});
it("round-trips coalescing fields (findings, findingsCaptured, announced)", () => {
const record: SubagentRunRecord = {
runId: "run-coalesce",
childSessionId: "child-1",
requesterSessionId: "parent-1",
task: "Coalesce test",
cleanup: "delete",
createdAt: Date.now(),
endedAt: Date.now() + 5000,
outcome: { status: "ok" },
findings: "Found 3 issues in auth module.",
findingsCaptured: true,
announced: true,
};
const json = JSON.stringify({ version: 1, runs: { "run-coalesce": record } });
const parsed = JSON.parse(json);
const restored = parsed.runs["run-coalesce"] as SubagentRunRecord;
expect(restored.findings).toBe("Found 3 issues in auth module.");
expect(restored.findingsCaptured).toBe(true);
expect(restored.announced).toBe(true);
});
it("round-trips record with undefined coalescing fields", () => {
const record: SubagentRunRecord = {
runId: "run-old",
childSessionId: "child-1",
requesterSessionId: "parent-1",
task: "Old record",
cleanup: "delete",
createdAt: Date.now(),
cleanupHandled: true,
// No findings, findingsCaptured, or announced fields (old format)
};
const json = JSON.stringify({ version: 1, runs: { "run-old": record } });
const parsed = JSON.parse(json);
const restored = parsed.runs["run-old"] as SubagentRunRecord;
expect(restored.findings).toBeUndefined();
expect(restored.findingsCaptured).toBeUndefined();
expect(restored.announced).toBeUndefined();
expect(restored.cleanupHandled).toBe(true);
});
});

View file

@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from "vitest";
import { describe, it, expect, beforeEach, vi } from "vitest";
import {
registerSubagentRun,
listSubagentRuns,
@ -159,3 +159,118 @@ describe("subagent registry", () => {
expect(getSubagentRun("run-1")).toBeUndefined();
});
});
describe("subagent registry — coalescing", () => {
// Without Hub, watchChildAgent ends runs immediately with "Hub not initialized".
// This allows us to test the coalescing state transitions.
it("captures findings when a run completes (no Hub)", () => {
registerSubagentRun({
runId: "run-1",
childSessionId: "child-1",
requesterSessionId: "parent-1",
task: "Task 1",
});
const record = getSubagentRun("run-1");
// Run ended immediately due to no Hub
expect(record?.endedAt).toBeGreaterThan(0);
expect(record?.findingsCaptured).toBe(true);
});
it("does not announce while sibling runs are still pending", () => {
// Register first run — ends immediately (no Hub)
registerSubagentRun({
runId: "run-1",
childSessionId: "child-1",
requesterSessionId: "parent-1",
task: "Task 1",
});
const record1 = getSubagentRun("run-1");
expect(record1?.findingsCaptured).toBe(true);
// Register second run — also ends immediately
registerSubagentRun({
runId: "run-2",
childSessionId: "child-2",
requesterSessionId: "parent-1",
task: "Task 2",
});
const record2 = getSubagentRun("run-2");
expect(record2?.findingsCaptured).toBe(true);
// Both ended, but announce fails because no Hub for parent agent.
// The key check: both records should have findings captured.
// announced will be false because runCoalescedAnnounceFlow fails (no Hub).
expect(record1?.announced).toBeUndefined();
expect(record2?.announced).toBeUndefined();
});
it("single run captures findings immediately", () => {
registerSubagentRun({
runId: "run-solo",
childSessionId: "child-solo",
requesterSessionId: "parent-solo",
task: "Solo task",
});
const record = getSubagentRun("run-solo");
expect(record?.endedAt).toBeGreaterThan(0);
expect(record?.findingsCaptured).toBe(true);
expect(record?.outcome?.status).toBe("error");
expect(record?.outcome?.error).toContain("Hub not initialized");
});
it("shutdownSubagentRegistry captures findings for ended-but-uncaptured runs", () => {
registerSubagentRun({
runId: "run-1",
childSessionId: "child-1",
requesterSessionId: "parent-1",
task: "Task",
});
const record = getSubagentRun("run-1");
if (record) {
// Simulate: run ended but findings not yet captured
record.endedAt = Date.now();
record.outcome = { status: "ok" };
record.findingsCaptured = undefined;
}
shutdownSubagentRegistry();
expect(record?.findingsCaptured).toBe(true);
});
});
describe("subagent registry — post-announce cleanup", () => {
it("removes runs from registry after successful announcement", async () => {
// Mock runCoalescedAnnounceFlow to succeed
const announceModule = await import("./announce.js");
const spy = vi.spyOn(announceModule, "runCoalescedAnnounceFlow").mockReturnValue(true);
// Register two runs for the same parent — both end immediately (no Hub)
registerSubagentRun({
runId: "run-a",
childSessionId: "child-a",
requesterSessionId: "parent-1",
task: "Task A",
});
registerSubagentRun({
runId: "run-b",
childSessionId: "child-b",
requesterSessionId: "parent-1",
task: "Task B",
});
// Both runs should have been announced and removed from registry
expect(spy).toHaveBeenCalled();
expect(getSubagentRun("run-a")).toBeUndefined();
expect(getSubagentRun("run-b")).toBeUndefined();
expect(listSubagentRuns("parent-1")).toHaveLength(0);
spy.mockRestore();
});
});

View file

@ -7,7 +7,7 @@
import { getHub, isHubInitialized } from "../../hub/hub-singleton.js";
import { loadSubagentRuns, saveSubagentRuns } from "./registry-store.js";
import { runSubagentAnnounceFlow } from "./announce.js";
import { readLatestAssistantReply, runCoalescedAnnounceFlow } from "./announce.js";
import type {
RegisterSubagentRunParams,
SubagentRunRecord,
@ -27,7 +27,7 @@ const SWEEP_INTERVAL_MS = 60 * 1000;
const subagentRuns = new Map<string, SubagentRunRecord>();
let sweepTimer: ReturnType<typeof setInterval> | undefined;
const resumedRuns = new Set<string>();
const resumedRequesters = new Set<string>();
// ============================================================================
// Public API
@ -39,25 +39,45 @@ export function initSubagentRegistry(): void {
for (const [runId, record] of persisted) {
subagentRuns.set(runId, record);
// Resume incomplete runs
if (!record.cleanupHandled) {
if (record.endedAt) {
// Completed but cleanup not done — run announce flow
if (!resumedRuns.has(runId)) {
resumedRuns.add(runId);
handleRunCompletion(record);
}
} else {
// If not ended, the child agent session is lost on restart —
// mark as ended with unknown outcome
record.endedAt = Date.now();
record.outcome = { status: "unknown" };
persist();
if (!resumedRuns.has(runId)) {
resumedRuns.add(runId);
handleRunCompletion(record);
}
}
// Backward compat: old records with cleanupHandled but no announced field
if (record.cleanupHandled && record.announced === undefined) {
record.announced = true;
record.findingsCaptured = true;
}
}
// Process incomplete runs
const affectedRequesters = new Set<string>();
for (const record of subagentRuns.values()) {
if (record.announced && record.cleanupHandled) continue; // Already fully done
if (!record.endedAt) {
// Child was running when process crashed — mark as ended/unknown
record.endedAt = Date.now();
record.outcome = { status: "unknown" };
}
if (!record.findingsCaptured) {
captureFindings(record);
}
// Recovery cleanup must be independent from findings capture:
// the process may crash after captureFindings() persisted but before deletion.
if (record.cleanup === "delete" && !record.cleanupHandled) {
deleteChildSession(record.childSessionId);
}
affectedRequesters.add(record.requesterSessionId);
}
persist();
// For each affected requester, check if coalesced announcement is needed
for (const requesterId of affectedRequesters) {
if (!resumedRequesters.has(requesterId)) {
resumedRequesters.add(requesterId);
checkAndAnnounce(requesterId);
}
}
@ -138,11 +158,17 @@ export function shutdownSubagentRegistry(): void {
record.outcome = { status: "unknown" };
updated++;
}
// Opportunistically capture findings for ended-but-uncaptured runs
if (record.endedAt && !record.findingsCaptured) {
captureFindings(record);
updated++;
}
}
if (updated > 0) {
persist();
console.log(`[SubagentRegistry] Marked ${updated} active run(s) as ended during shutdown`);
console.log(`[SubagentRegistry] Processed ${updated} run(s) during shutdown`);
}
stopSweeper();
@ -151,7 +177,7 @@ export function shutdownSubagentRegistry(): void {
/** Reset all state (for testing). */
export function resetSubagentRegistryForTests(): void {
subagentRuns.clear();
resumedRuns.clear();
resumedRequesters.clear();
stopSweeper();
}
@ -222,44 +248,76 @@ function watchChildAgent(record: SubagentRunRecord, timeoutSeconds?: number): vo
}
// ============================================================================
// Cleanup + Announce
// Cleanup + Announce (two-phase: capture findings, then coalesced announce)
// ============================================================================
function handleRunCompletion(record: SubagentRunRecord): void {
if (record.cleanupHandled) return;
record.cleanupHandled = true;
/** Phase 1: Capture child's findings before session deletion. */
function captureFindings(record: SubagentRunRecord): void {
try {
const findings = readLatestAssistantReply(record.childSessionId);
record.findings = findings ?? undefined;
} catch {
record.findings = "(failed to read findings)";
}
record.findingsCaptured = true;
persist();
}
// Run announce flow
const announced = runSubagentAnnounceFlow({
runId: record.runId,
childSessionId: record.childSessionId,
requesterSessionId: record.requesterSessionId,
task: record.task,
label: record.label,
cleanup: record.cleanup,
outcome: record.outcome,
startedAt: record.startedAt,
endedAt: record.endedAt,
});
/**
* Phase 2: Check if all unannounced runs for this requester have completed.
* If so, send a single coalesced announcement to the parent.
*/
function checkAndAnnounce(requesterSessionId: string): void {
const allRuns = listSubagentRuns(requesterSessionId);
if (!announced) {
console.warn(`[SubagentRegistry] Announce flow failed for run ${record.runId}`);
// Allow retry on next restart if announce failed.
record.cleanupHandled = false;
// Only consider unannounced runs
const pending = allRuns.filter(r => !r.announced);
if (pending.length === 0) return;
// Are all unannounced runs done?
const allDone = pending.every(r => r.endedAt !== undefined);
if (!allDone) return;
// Have all had findings captured?
const allCaptured = pending.every(r => r.findingsCaptured);
if (!allCaptured) return;
// All done — send coalesced announcement
const announced = runCoalescedAnnounceFlow(requesterSessionId, pending);
if (announced) {
for (const r of pending) {
r.announced = true;
r.cleanupHandled = true;
// Remove from registry immediately — findings already delivered to parent
subagentRuns.delete(r.runId);
}
persist();
return;
if (subagentRuns.size === 0) {
stopSweeper();
}
} else {
console.warn(
`[SubagentRegistry] Coalesced announce failed for requester ${requesterSessionId}`,
);
// Leave announced=false so initSubagentRegistry() can retry on restart
}
}
/** Entry point: called when a child completes. */
function handleRunCompletion(record: SubagentRunRecord): void {
// Phase 1: capture findings (before session deletion)
if (!record.findingsCaptured) {
captureFindings(record);
// Session cleanup (safe now that findings are persisted)
if (record.cleanup === "delete") {
deleteChildSession(record.childSessionId);
}
}
// Handle session cleanup
if (record.cleanup === "delete") {
deleteChildSession(record.childSessionId);
}
// Schedule archive
record.archiveAtMs = Date.now() + DEFAULT_ARCHIVE_AFTER_MS;
record.cleanupCompletedAt = Date.now();
persist();
// Phase 2: coalesced announce check
checkAndAnnounce(record.requesterSessionId);
}
function deleteChildSession(sessionId: string): void {
@ -305,7 +363,6 @@ function sweep(): void {
for (const [runId, record] of subagentRuns) {
if (record.archiveAtMs !== undefined && record.archiveAtMs <= now) {
subagentRuns.delete(runId);
resumedRuns.delete(runId);
removed++;
}
}

View file

@ -39,6 +39,12 @@ export type SubagentRunRecord = {
cleanupHandled?: boolean | undefined;
/** Timestamp when cleanup completed */
cleanupCompletedAt?: number | undefined;
/** Captured findings from the child session's last assistant reply */
findings?: string | undefined;
/** Whether findings have been captured (safe to delete session after this) */
findingsCaptured?: boolean | undefined;
/** Whether the coalesced announcement has been sent to parent */
announced?: boolean | undefined;
};
/** Parameters for registering a new subagent run */

View file

@ -10,6 +10,7 @@ import type {
SystemPromptReport,
} from "./types.js";
import {
buildHeartbeatSection,
buildConditionalToolSections,
buildExtraPromptSection,
buildIdentitySection,
@ -58,6 +59,7 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): {
{ name: "user", lines: buildUserSection(profile, mode) },
{ name: "workspace", lines: buildWorkspaceSection(profile, mode, profileDir) },
{ name: "memory", lines: buildMemoryFileSection(profile, mode) },
{ name: "heartbeat", lines: buildHeartbeatSection(profile, mode) },
{ name: "safety", lines: buildSafetySection(includeSafety) },
{ name: "tooling", lines: buildToolingSummary(tools, mode) },
{ name: "tool-call-style", lines: buildToolCallStyleSection(mode) },

View file

@ -6,6 +6,7 @@
import { SAFETY_CONSTITUTION } from "./constitution.js";
import { formatRuntimeLine } from "./runtime-info.js";
import { resolveHeartbeatPrompt } from "../../heartbeat/heartbeat-text.js";
import type {
ProfileContent,
RuntimeInfo,
@ -97,13 +98,14 @@ export function buildWorkspaceSection(
"## Profile",
"",
`Your profile directory: \`${profileDir}\``,
"Use this as the base path for profile files (soul.md, user.md, memory.md, memory/*.md).",
"Use this as the base path for profile files (soul.md, user.md, memory.md, heartbeat.md, memory/*.md).",
"",
"Profile files:",
"- `soul.md` — Your identity and values",
"- `user.md` — Information about your user",
"- `workspace.md` — Guidelines and conventions (below)",
"- `memory.md` — Persistent knowledge",
"- `heartbeat.md` — Background heartbeat loop instructions",
"",
);
}
@ -128,6 +130,26 @@ export function buildMemoryFileSection(
return [];
}
/**
* Heartbeat section full mode only.
* Keeps heartbeat protocol explicit in the agent instructions.
*/
export function buildHeartbeatSection(
profile: ProfileContent | undefined,
mode: SystemPromptMode,
): string[] {
if (mode !== "full") return [];
const prompt = resolveHeartbeatPrompt(profile?.config?.heartbeat?.prompt);
return [
"## Heartbeats",
`Heartbeat prompt: ${prompt}`,
'If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:',
"HEARTBEAT_OK",
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
"",
];
}
/**
* Safety constitution always included.
*/

View file

@ -53,6 +53,7 @@ export interface ProfileContent {
user?: string | undefined;
workspace?: string | undefined;
memory?: string | undefined;
heartbeat?: string | undefined;
config?: ProfileConfig | undefined;
}

View file

@ -7,7 +7,9 @@ import { createProcessTool } from "./tools/process.js";
import { createGlobTool } from "./tools/glob.js";
import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn.js";
import { createSessionsListTool } from "./tools/sessions-list.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 +109,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 +118,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
@ -129,6 +134,10 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
});
tools.push(sessionsSpawnTool as AgentTool<any>);
// Add sessions_list tool
const sessionsListTool = createSessionsListTool({ sessionId });
tools.push(sessionsListTool as AgentTool<any>);
return tools;
}

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

@ -50,8 +50,8 @@ export function createCliApprovalCallback(
const runtimeConfig = { ...config, allowlist: [...(config.allowlist ?? [])] };
return async (command: string, cwd: string | undefined): Promise<ApprovalResult> => {
const security = runtimeConfig.security ?? "allowlist";
const ask = runtimeConfig.ask ?? "on-miss";
const security = runtimeConfig.security ?? "full";
const ask = runtimeConfig.ask ?? "off";
const timeoutMs = runtimeConfig.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
// Security: deny blocks everything
@ -137,13 +137,15 @@ function promptTerminal(
rl.close();
};
// Timeout: auto-deny
const timer = setTimeout(() => {
if (resolved) return;
process.stderr.write(dim(`\n Approval timed out (${timeoutMs / 1000}s). Denying.\n\n`));
cleanup();
resolve("deny");
}, timeoutMs);
// Timeout: auto-deny (skip if timeoutMs is -1 for no timeout)
const timer = timeoutMs >= 0
? setTimeout(() => {
if (resolved) return;
process.stderr.write(dim(`\n Approval timed out (${timeoutMs / 1000}s). Denying.\n\n`));
cleanup();
resolve("deny");
}, timeoutMs)
: null;
// Display approval prompt
process.stderr.write("\n");
@ -161,7 +163,7 @@ function promptTerminal(
rl.question(
` ${bold("[a]")}llow once / ${bold("[A]")}llow always / ${bold("[d]")}eny (default: deny): `,
(answer) => {
clearTimeout(timer);
if (timer) clearTimeout(timer);
cleanup();
const trimmed = answer.trim();
@ -177,7 +179,7 @@ function promptTerminal(
// Handle Ctrl+C gracefully
rl.on("close", () => {
clearTimeout(timer);
if (timer) clearTimeout(timer);
if (!resolved) {
resolved = true;
resolve("deny");

View file

@ -32,7 +32,7 @@ export interface ExecApprovalRequest {
riskLevel: "safe" | "needs-review" | "dangerous";
/** Reasons for the risk assessment */
riskReasons: string[];
/** When this approval expires (ms since epoch) */
/** When this approval expires (ms since epoch). -1 means no timeout. */
expiresAtMs: number;
}
@ -50,7 +50,7 @@ export interface ExecApprovalConfig {
security?: ExecSecurity;
/** Ask mode: "off" never asks, "on-miss" asks when allowlist misses, "always" always asks */
ask?: ExecAsk;
/** Timeout before auto-deny in milliseconds (default: 60_000) */
/** Timeout before auto-deny in milliseconds (default: 60_000). Set to -1 for no timeout. */
timeoutMs?: number;
/** Fallback security level on timeout (default: "deny" — fail-closed) */
askFallback?: ExecSecurity;
@ -58,8 +58,8 @@ export interface ExecApprovalConfig {
allowlist?: ExecAllowlistEntry[];
}
/** Default timeout for approval requests (60 seconds) */
export const DEFAULT_APPROVAL_TIMEOUT_MS = 60_000;
/** Default timeout for approval requests (-1 = no timeout, wait indefinitely) */
export const DEFAULT_APPROVAL_TIMEOUT_MS = -1;
// ============ Allowlist ============

View file

@ -164,9 +164,22 @@ export function createExecTool(
// Don't reject, let close event handle
});
// Signal handling: don't kill if already backgrounded
const onAbort = signal ? () => {
if (yielded) return; // Already backgrounded, ignore abort
if (timeout) clearTimeout(timeout);
if (yieldTimer) clearTimeout(yieldTimer);
child.kill("SIGTERM");
} : undefined;
if (signal && onAbort) {
signal.addEventListener("abort", onAbort, { once: true });
}
child.on("close", (code) => {
if (timeout) clearTimeout(timeout);
if (yieldTimer) clearTimeout(yieldTimer);
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
// If already backgrounded, don't resolve again
if (yielded) return;
@ -202,16 +215,6 @@ export function createExecTool(
},
});
});
// Signal handling: don't kill if already backgrounded
if (signal) {
signal.addEventListener("abort", () => {
if (yielded) return; // Already backgrounded, ignore abort
if (timeout) clearTimeout(timeout);
if (yieldTimer) clearTimeout(yieldTimer);
child.kill("SIGTERM");
});
}
});
},
};

View file

@ -34,7 +34,10 @@ export const TOOL_GROUPS: Record<string, string[]> = {
"group:memory": ["memory_search"],
// Subagent tools
"group:subagent": ["sessions_spawn"],
"group:subagent": ["sessions_spawn", "sessions_list"],
// Cron/scheduling tools
"group:cron": ["cron"],
// All core tools
"group:core": [

View file

@ -7,6 +7,8 @@ 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";
export { createSessionsListTool } from "./sessions-list.js";
// Tool groups
export {

View file

@ -112,7 +112,7 @@ export function createProcessTool(defaultCwd?: string): AgentTool<typeof Process
if (signal) {
signal.addEventListener("abort", () => {
child.kill("SIGTERM");
});
}, { once: true });
}
resolve({ success: true });

View file

@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { SubagentRunRecord } from "../subagent/types.js";
// Mock the registry module before importing the tool
vi.mock("../subagent/registry.js", () => ({
listSubagentRuns: vi.fn(),
getSubagentRun: vi.fn(),
}));
import { createSessionsListTool } from "./sessions-list.js";
import { listSubagentRuns, getSubagentRun } from "../subagent/registry.js";
const mockListSubagentRuns = vi.mocked(listSubagentRuns);
const mockGetSubagentRun = vi.mocked(getSubagentRun);
function makeRecord(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
return {
runId: "run-001",
childSessionId: "child-001",
requesterSessionId: "parent-001",
task: "Test task",
cleanup: "delete",
createdAt: 1700000000000,
...overrides,
};
}
describe("sessions_list tool", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns empty message when no runs exist", async () => {
mockListSubagentRuns.mockReturnValue([]);
const tool = createSessionsListTool({ sessionId: "parent-001" });
const result = await tool.execute("call-1", {});
expect(result.content[0]).toEqual({
type: "text",
text: "No subagent runs for this session.",
});
expect(result.details).toEqual({ runs: [] });
});
it("lists multiple runs with correct status mapping", async () => {
const now = Date.now();
const runs: SubagentRunRecord[] = [
makeRecord({
runId: "run-aaa",
label: "Code Review",
startedAt: now - 45000,
}),
makeRecord({
runId: "run-bbb",
label: "Test Analysis",
startedAt: now - 60000,
endedAt: now - 30000,
outcome: { status: "ok" },
}),
makeRecord({
runId: "run-ccc",
label: "Lint Check",
startedAt: now - 60000,
endedAt: now,
outcome: { status: "error", error: "timeout" },
}),
];
mockListSubagentRuns.mockReturnValue(runs);
const tool = createSessionsListTool({ sessionId: "parent-001" });
const result = await tool.execute("call-1", {});
const text = result.content[0]!;
expect(text.type).toBe("text");
expect((text as { text: string }).text).toContain("3 total");
expect((text as { text: string }).text).toContain("[running]");
expect((text as { text: string }).text).toContain("[ok]");
expect((text as { text: string }).text).toContain("[error]");
expect((text as { text: string }).text).toContain("Code Review");
expect((text as { text: string }).text).toContain("Test Analysis");
expect((text as { text: string }).text).toContain("Lint Check");
expect(result.details!.runs).toHaveLength(3);
expect(result.details!.runs[0]!.status).toBe("running");
expect(result.details!.runs[1]!.status).toBe("ok");
expect(result.details!.runs[2]!.status).toBe("error");
});
it("returns detail for a specific runId", async () => {
const now = Date.now();
const record = makeRecord({
runId: "run-detail",
label: "Deep Analysis",
task: "Analyze the authentication module thoroughly",
startedAt: now - 90000,
endedAt: now - 10000,
outcome: { status: "ok" },
findings: "Found 2 potential issues in token validation.",
findingsCaptured: true,
});
mockGetSubagentRun.mockReturnValue(record);
const tool = createSessionsListTool({ sessionId: "parent-001" });
const result = await tool.execute("call-1", { runId: "run-detail" });
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Run: run-detail");
expect(text).toContain("Label: Deep Analysis");
expect(text).toContain("Status: ok");
expect(text).toContain("Found 2 potential issues");
expect(text).toContain("Duration:");
expect(result.details!.runs).toHaveLength(1);
expect(result.details!.runs[0]!.runId).toBe("run-detail");
});
it("returns not found for unknown runId", async () => {
mockGetSubagentRun.mockReturnValue(undefined);
const tool = createSessionsListTool({ sessionId: "parent-001" });
const result = await tool.execute("call-1", { runId: "nonexistent" });
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Run not found");
expect(result.details).toEqual({ runs: [] });
});
it("rejects runId belonging to a different requester", async () => {
const record = makeRecord({
runId: "run-other",
requesterSessionId: "other-parent",
});
mockGetSubagentRun.mockReturnValue(record);
const tool = createSessionsListTool({ sessionId: "parent-001" });
const result = await tool.execute("call-1", { runId: "run-other" });
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Run not found");
expect(result.details).toEqual({ runs: [] });
});
it("handles missing sessionId gracefully", async () => {
const tool = createSessionsListTool({});
const result = await tool.execute("call-1", {});
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("No session ID available");
expect(result.details).toEqual({ runs: [] });
});
it("shows findings status for running task", async () => {
const now = Date.now();
const record = makeRecord({
runId: "run-running",
label: "Still Running",
startedAt: now - 30000,
// no endedAt
});
mockGetSubagentRun.mockReturnValue(record);
const tool = createSessionsListTool({ sessionId: "parent-001" });
const result = await tool.execute("call-1", { runId: "run-running" });
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Status: running");
expect(text).toContain("Findings: (still running)");
});
});

View file

@ -0,0 +1,187 @@
/**
* sessions_list tool allows an agent to view its spawned subagent runs.
*
* Lists all subagent runs for the current session, or shows details for a
* specific run when a runId is provided.
*/
import { Type } from "@sinclair/typebox";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { listSubagentRuns, getSubagentRun } from "../subagent/registry.js";
import type { SubagentRunRecord } from "../subagent/types.js";
const SessionsListSchema = Type.Object({
runId: Type.Optional(
Type.String({ description: "Optional run ID to get details for a specific run. If omitted, lists all runs." }),
),
});
type SessionsListArgs = {
runId?: string;
};
export type SessionsListResult = {
runs: Array<{
runId: string;
label?: string;
task: string;
status: "running" | "ok" | "error" | "timeout" | "unknown";
startedAt?: number;
endedAt?: number;
findings?: string;
}>;
};
export interface CreateSessionsListToolOptions {
/** Session ID of the current (requester) agent */
sessionId?: string;
}
function resolveStatus(record: SubagentRunRecord): "running" | "ok" | "error" | "timeout" | "unknown" {
if (!record.endedAt) return "running";
return record.outcome?.status ?? "unknown";
}
function formatElapsed(ms: number): string {
const totalSeconds = Math.round(ms / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return seconds > 0 ? `${minutes}m${seconds}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0 ? `${hours}h${remainingMinutes}m` : `${hours}h`;
}
function formatRunSummary(record: SubagentRunRecord, index: number, now: number): string {
const status = resolveStatus(record);
const displayName = record.label || record.task.slice(0, 60);
const statusTag = `[${status}]`.padEnd(10);
let timing = "";
if (status === "running" && record.startedAt) {
timing = `started ${formatElapsed(now - record.startedAt)} ago`;
} else if (record.startedAt && record.endedAt) {
timing = `completed in ${formatElapsed(record.endedAt - record.startedAt)}`;
}
const parts = [`#${index + 1} ${statusTag} "${displayName}"`];
if (timing) parts.push(`(${record.runId.slice(0, 8)}…, ${timing})`);
else parts.push(`(${record.runId.slice(0, 8)}…)`);
return parts.join(" ");
}
function formatRunDetail(record: SubagentRunRecord, now: number): string {
const status = resolveStatus(record);
const lines: string[] = [
`Run: ${record.runId}`,
];
if (record.label) lines.push(`Label: ${record.label}`);
lines.push(`Task: ${record.task}`);
lines.push(`Status: ${status}${record.outcome?.error ? `${record.outcome.error}` : ""}`);
lines.push(`Child Session: ${record.childSessionId}`);
lines.push(`Created: ${new Date(record.createdAt).toISOString()} (${formatElapsed(now - record.createdAt)} ago)`);
if (record.startedAt) {
lines.push(`Started: ${new Date(record.startedAt).toISOString()} (${formatElapsed(now - record.startedAt)} ago)`);
}
if (record.endedAt) {
lines.push(`Ended: ${new Date(record.endedAt).toISOString()}`);
if (record.startedAt) {
lines.push(`Duration: ${formatElapsed(record.endedAt - record.startedAt)}`);
}
}
if (record.findingsCaptured) {
lines.push(`Findings: ${record.findings || "(no output)"}`);
} else if (record.endedAt) {
lines.push("Findings: (not yet captured)");
} else {
lines.push("Findings: (still running)");
}
if (record.announced) lines.push("Announced: yes");
return lines.join("\n");
}
function toResultRun(record: SubagentRunRecord) {
return {
runId: record.runId,
label: record.label,
task: record.task,
status: resolveStatus(record),
startedAt: record.startedAt,
endedAt: record.endedAt,
findings: record.findings,
};
}
export function createSessionsListTool(
options: CreateSessionsListToolOptions,
): AgentTool<typeof SessionsListSchema, SessionsListResult> {
return {
name: "sessions_list",
label: "List Subagent Runs",
description:
"List all subagent runs spawned by this session and their current status. " +
"Optionally pass a runId to get detailed information about a specific run.",
parameters: SessionsListSchema,
execute: async (_toolCallId, args) => {
const { runId } = args as SessionsListArgs;
const requesterSessionId = options.sessionId;
if (!requesterSessionId) {
return {
content: [{ type: "text", text: "No session ID available. Cannot list subagent runs." }],
details: { runs: [] },
};
}
const now = Date.now();
// Detail mode: specific run
if (runId) {
const record = getSubagentRun(runId);
if (!record) {
return {
content: [{ type: "text", text: `Run not found: ${runId}` }],
details: { runs: [] },
};
}
if (record.requesterSessionId !== requesterSessionId) {
return {
content: [{ type: "text", text: `Run not found: ${runId}` }],
details: { runs: [] },
};
}
return {
content: [{ type: "text", text: formatRunDetail(record, now) }],
details: { runs: [toResultRun(record)] },
};
}
// List mode: all runs for this session
const runs = listSubagentRuns(requesterSessionId);
if (runs.length === 0) {
return {
content: [{ type: "text", text: "No subagent runs for this session." }],
details: { runs: [] },
};
}
const lines = [`Subagent runs for this session: ${runs.length} total`, ""];
for (let i = 0; i < runs.length; i++) {
lines.push(formatRunSummary(runs[i]!, i, now));
}
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { runs: runs.map(toResultRun) },
};
},
};
}

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

@ -0,0 +1,147 @@
/**
* 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 };
const text = payload.text.trim();
if (!text) {
return { error: "system-event payload requires non-empty text" };
}
// 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 cronMessage = `[CRON] ${job.name}: ${text}`;
hub.enqueueSystemEvent(cronMessage, { agentId });
if (job.wakeMode === "now") {
const result = await hub.runHeartbeatOnce({ reason: `cron:${job.id}` });
if (result.status === "failed") {
return { error: result.reason };
}
if (result.status === "skipped") {
return {
summary: `Enqueued cron event for agent ${agentId.slice(0, 8)} (wake skipped: ${result.reason})`,
};
}
return {
summary: `Enqueued cron event and triggered immediate heartbeat for agent ${agentId.slice(0, 8)}`,
};
}
hub.requestHeartbeatNow({ reason: `cron:${job.id}` });
return {
summary: `Enqueued cron event for agent ${agentId.slice(0, 8)} (wakeMode: next-heartbeat)`,
};
}
/**
* 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

@ -0,0 +1,50 @@
export type HeartbeatIndicatorType = "ok" | "alert" | "error";
export type HeartbeatEventPayload = {
ts: number;
status: "sent" | "ok-empty" | "ok-token" | "skipped" | "failed";
preview?: string;
durationMs?: number;
reason?: string;
indicatorType?: HeartbeatIndicatorType;
};
export function resolveIndicatorType(
status: HeartbeatEventPayload["status"],
): HeartbeatIndicatorType | undefined {
switch (status) {
case "ok-empty":
case "ok-token":
return "ok";
case "sent":
return "alert";
case "failed":
return "error";
case "skipped":
return undefined;
}
}
let lastHeartbeat: HeartbeatEventPayload | null = null;
const listeners = new Set<(evt: HeartbeatEventPayload) => void>();
export function emitHeartbeatEvent(evt: Omit<HeartbeatEventPayload, "ts">): void {
const enriched: HeartbeatEventPayload = { ts: Date.now(), ...evt };
lastHeartbeat = enriched;
for (const listener of listeners) {
try {
listener(enriched);
} catch {
// Ignore listener errors so heartbeat flow stays robust.
}
}
}
export function onHeartbeatEvent(listener: (evt: HeartbeatEventPayload) => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function getLastHeartbeatEvent(): HeartbeatEventPayload | null {
return lastHeartbeat;
}

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
HEARTBEAT_TOKEN,
isHeartbeatContentEffectivelyEmpty,
stripHeartbeatToken,
} from "./heartbeat-text.js";
describe("heartbeat-text", () => {
it("treats comment-only heartbeat files as empty", () => {
expect(isHeartbeatContentEffectivelyEmpty("# title\n\n- [ ]\n")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("\n# note\n")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("check disk health")).toBe(false);
});
it("strips plain token responses", () => {
const result = stripHeartbeatToken(HEARTBEAT_TOKEN, { mode: "heartbeat" });
expect(result.shouldSkip).toBe(true);
expect(result.text).toBe("");
});
it("keeps substantial content around token in heartbeat mode", () => {
const longTail = "Potential issue detected: disk usage is 92% on /Users";
const result = stripHeartbeatToken(`${HEARTBEAT_TOKEN} ${longTail}`, {
mode: "heartbeat",
maxAckChars: 10,
});
expect(result.shouldSkip).toBe(false);
expect(result.text).toContain("disk usage");
});
});

View file

@ -0,0 +1,117 @@
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
export const HEARTBEAT_PROMPT =
"Read heartbeat.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.";
export const DEFAULT_HEARTBEAT_EVERY = "30m";
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
export function isHeartbeatContentEffectivelyEmpty(
content: string | undefined | null,
): boolean {
if (content === undefined || content === null || typeof content !== "string") {
return false;
}
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (/^#+(\s|$)/.test(trimmed)) continue;
if (/^[-*+]\s*(\[[\sXx]?\]\s*)?$/.test(trimmed)) continue;
return false;
}
return true;
}
export function resolveHeartbeatPrompt(raw?: string): string {
const trimmed = typeof raw === "string" ? raw.trim() : "";
return trimmed || HEARTBEAT_PROMPT;
}
export type StripHeartbeatMode = "heartbeat" | "message";
function stripTokenAtEdges(raw: string): { text: string; didStrip: boolean } {
let text = raw.trim();
if (!text) return { text: "", didStrip: false };
if (!text.includes(HEARTBEAT_TOKEN)) return { text, didStrip: false };
let didStrip = false;
let changed = true;
while (changed) {
changed = false;
const next = text.trim();
if (next.startsWith(HEARTBEAT_TOKEN)) {
text = next.slice(HEARTBEAT_TOKEN.length).trimStart();
didStrip = true;
changed = true;
continue;
}
if (next.endsWith(HEARTBEAT_TOKEN)) {
text = next.slice(0, Math.max(0, next.length - HEARTBEAT_TOKEN.length)).trimEnd();
didStrip = true;
changed = true;
}
}
return {
text: text.replace(/\s+/g, " ").trim(),
didStrip,
};
}
export function stripHeartbeatToken(
raw?: string,
opts: { mode?: StripHeartbeatMode; maxAckChars?: number } = {},
): { shouldSkip: boolean; text: string; didStrip: boolean } {
if (!raw) return { shouldSkip: true, text: "", didStrip: false };
const trimmed = raw.trim();
if (!trimmed) return { shouldSkip: true, text: "", didStrip: false };
const mode = opts.mode ?? "message";
const maxAckCharsRaw = opts.maxAckChars;
const maxAckChars = Math.max(
0,
typeof maxAckCharsRaw === "number" && Number.isFinite(maxAckCharsRaw)
? maxAckCharsRaw
: DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
);
const stripMarkup = (text: string) =>
text
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/^[*`~_]+/, "")
.replace(/[*`~_]+$/, "");
const normalized = stripMarkup(trimmed);
const hasToken =
trimmed.includes(HEARTBEAT_TOKEN) || normalized.includes(HEARTBEAT_TOKEN);
if (!hasToken) {
return { shouldSkip: false, text: trimmed, didStrip: false };
}
const strippedOriginal = stripTokenAtEdges(trimmed);
const strippedNormalized = stripTokenAtEdges(normalized);
const picked =
strippedOriginal.didStrip && strippedOriginal.text
? strippedOriginal
: strippedNormalized;
if (!picked.didStrip) {
return { shouldSkip: false, text: trimmed, didStrip: false };
}
if (!picked.text) {
return { shouldSkip: true, text: "", didStrip: true };
}
const rest = picked.text.trim();
if (mode === "heartbeat" && rest.length <= maxAckChars) {
return { shouldSkip: true, text: "", didStrip: true };
}
return { shouldSkip: false, text: rest, didStrip: true };
}

View file

@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
hasPendingHeartbeatWake,
requestHeartbeatNow,
setHeartbeatWakeHandler,
} from "./heartbeat-wake.js";
describe("heartbeat-wake", () => {
afterEach(() => {
setHeartbeatWakeHandler(null);
vi.useRealTimers();
});
it("coalesces multiple wake requests into one run", async () => {
vi.useFakeTimers();
const handler = vi.fn(async () => ({ status: "ran" as const, durationMs: 1 }));
setHeartbeatWakeHandler(handler);
requestHeartbeatNow({ reason: "a" });
requestHeartbeatNow({ reason: "b" });
requestHeartbeatNow({ reason: "c" });
expect(hasPendingHeartbeatWake()).toBe(true);
await vi.advanceTimersByTimeAsync(300);
expect(handler).toHaveBeenCalledTimes(1);
});
it("retries when requests are in flight", async () => {
vi.useFakeTimers();
const handler = vi
.fn()
.mockResolvedValueOnce({ status: "skipped" as const, reason: "requests-in-flight" })
.mockResolvedValueOnce({ status: "ran" as const, durationMs: 3 });
setHeartbeatWakeHandler(handler);
requestHeartbeatNow({ reason: "retry-case" });
await vi.advanceTimersByTimeAsync(300);
expect(handler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1100);
expect(handler).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,73 @@
export type HeartbeatRunResult =
| { status: "ran"; durationMs: number }
| { status: "skipped"; reason: string }
| { status: "failed"; reason: string };
export type HeartbeatWakeHandler = (opts: {
reason?: string;
}) => Promise<HeartbeatRunResult>;
let handler: HeartbeatWakeHandler | null = null;
let pendingReason: string | null = null;
let scheduled = false;
let running = false;
let timer: NodeJS.Timeout | null = null;
const DEFAULT_COALESCE_MS = 250;
const DEFAULT_RETRY_MS = 1000;
function schedule(coalesceMs: number): void {
if (timer) return;
timer = setTimeout(async () => {
timer = null;
scheduled = false;
const active = handler;
if (!active) return;
if (running) {
scheduled = true;
schedule(coalesceMs);
return;
}
const reason = pendingReason;
pendingReason = null;
running = true;
try {
const result = reason ? await active({ reason }) : await active({});
if (result.status === "skipped" && result.reason === "requests-in-flight") {
pendingReason = reason ?? "retry";
schedule(DEFAULT_RETRY_MS);
}
} catch {
pendingReason = reason ?? "retry";
schedule(DEFAULT_RETRY_MS);
} finally {
running = false;
if (pendingReason || scheduled) {
schedule(coalesceMs);
}
}
}, coalesceMs);
timer.unref?.();
}
export function setHeartbeatWakeHandler(next: HeartbeatWakeHandler | null): void {
handler = next;
if (handler && pendingReason) {
schedule(DEFAULT_COALESCE_MS);
}
}
export function requestHeartbeatNow(opts?: { reason?: string; coalesceMs?: number }): void {
pendingReason = opts?.reason ?? pendingReason ?? "requested";
schedule(opts?.coalesceMs ?? DEFAULT_COALESCE_MS);
}
export function hasHeartbeatWakeHandler(): boolean {
return handler !== null;
}
export function hasPendingHeartbeatWake(): boolean {
return pendingReason !== null || Boolean(timer) || scheduled;
}

45
src/heartbeat/index.ts Normal file
View file

@ -0,0 +1,45 @@
export {
emitHeartbeatEvent,
getLastHeartbeatEvent,
onHeartbeatEvent,
resolveIndicatorType,
type HeartbeatEventPayload,
type HeartbeatIndicatorType,
} from "./heartbeat-events.js";
export {
hasHeartbeatWakeHandler,
hasPendingHeartbeatWake,
requestHeartbeatNow,
setHeartbeatWakeHandler,
type HeartbeatRunResult,
type HeartbeatWakeHandler,
} from "./heartbeat-wake.js";
export {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
DEFAULT_HEARTBEAT_EVERY,
HEARTBEAT_PROMPT,
HEARTBEAT_TOKEN,
isHeartbeatContentEffectivelyEmpty,
resolveHeartbeatPrompt,
stripHeartbeatToken,
type StripHeartbeatMode,
} from "./heartbeat-text.js";
export {
drainSystemEvents,
enqueueSystemEvent,
hasSystemEvents,
peekSystemEvents,
resetSystemEventsForTest,
type SystemEvent,
} from "./system-events.js";
export {
runHeartbeatOnce,
setHeartbeatsEnabled,
startHeartbeatRunner,
type HeartbeatConfig,
type HeartbeatRunner,
} from "./runner.js";

View file

@ -0,0 +1,74 @@
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { afterEach, describe, expect, it } from "vitest";
import { runHeartbeatOnce, setHeartbeatsEnabled } from "./runner.js";
type StubAgent = {
closed: boolean;
sessionId: string;
ensureInitialized: () => Promise<void>;
getMessages: () => Array<any>;
write: (content: string) => void;
waitForIdle: () => Promise<void>;
getHeartbeatConfig: () => { prompt?: string; ackMaxChars?: number; enabled?: boolean };
getPendingWrites: () => number;
getProfileDir: () => string | undefined;
};
function createStubAgent(opts?: {
profileDir?: string;
replyText?: string;
heartbeatEnabled?: boolean;
}): StubAgent {
const messages: Array<any> = [];
const replyText = opts?.replyText ?? "HEARTBEAT_OK";
return {
closed: false,
sessionId: "test-session",
ensureInitialized: async () => {},
getMessages: () => messages,
write: (content: string) => {
messages.push({ role: "user", content });
messages.push({ role: "assistant", content: [{ type: "text", text: replyText }] });
},
waitForIdle: async () => {},
getHeartbeatConfig: () =>
typeof opts?.heartbeatEnabled === "boolean"
? { enabled: opts.heartbeatEnabled }
: {},
getPendingWrites: () => 0,
getProfileDir: () => opts?.profileDir,
};
}
describe("heartbeat runner", () => {
afterEach(() => {
setHeartbeatsEnabled(true);
});
it("skips when no agent is available", async () => {
const result = await runHeartbeatOnce({ agent: null });
expect(result).toEqual({ status: "skipped", reason: "disabled" });
});
it("skips when heartbeat file is effectively empty", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "heartbeat-test-"));
try {
await writeFile(path.join(dir, "heartbeat.md"), "# keep empty\n", "utf-8");
const agent = createStubAgent({ profileDir: dir });
const result = await runHeartbeatOnce({ agent: agent as any });
expect(result).toEqual({ status: "skipped", reason: "empty-heartbeat-file" });
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("runs and returns ran for heartbeat acknowledgements", async () => {
const agent = createStubAgent({ replyText: "HEARTBEAT_OK" });
const result = await runHeartbeatOnce({ agent: agent as any, reason: "manual" });
expect(result.status).toBe("ran");
});
});

321
src/heartbeat/runner.ts Normal file
View file

@ -0,0 +1,321 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AsyncAgent } from "../agent/async-agent.js";
import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
DEFAULT_HEARTBEAT_EVERY,
isHeartbeatContentEffectivelyEmpty,
resolveHeartbeatPrompt,
stripHeartbeatToken,
} from "./heartbeat-text.js";
import {
emitHeartbeatEvent,
resolveIndicatorType,
type HeartbeatEventPayload,
} from "./heartbeat-events.js";
import {
setHeartbeatWakeHandler,
requestHeartbeatNow,
type HeartbeatRunResult,
type HeartbeatWakeHandler,
} from "./heartbeat-wake.js";
import { drainSystemEvents } from "./system-events.js";
export type HeartbeatConfig = {
enabled?: boolean;
every?: string;
prompt?: string;
ackMaxChars?: number;
};
export type HeartbeatRunner = {
stop: () => void;
updateConfig: () => void;
};
type RunnerDeps = {
getAgent: () => AsyncAgent | null;
nowMs?: () => number;
logger?: Pick<Console, "info" | "warn" | "error">;
};
const HEARTBEAT_FILENAME = "heartbeat.md";
const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
let heartbeatsEnabled = true;
function resolveDurationMs(raw: string | undefined): number | null {
if (!raw) return DEFAULT_INTERVAL_MS;
const trimmed = raw.trim();
if (!trimmed) return DEFAULT_INTERVAL_MS;
const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*([smhd])$/i);
if (match) {
const num = Number.parseFloat(match[1]!);
const unit = match[2]!.toLowerCase();
const unitMs: Record<string, number> = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
const ms = unitMs[unit];
if (!Number.isFinite(num) || !ms) return null;
const value = Math.floor(num * ms);
return value > 0 ? value : null;
}
if (/^\d+$/.test(trimmed)) {
const value = Number.parseInt(trimmed, 10);
return value > 0 ? value : null;
}
return null;
}
function extractMessageText(message: AgentMessage | undefined): string {
if (!message) return "";
const raw = (message as { content?: unknown }).content;
if (typeof raw === "string") return raw;
if (!Array.isArray(raw)) return "";
const parts: string[] = [];
for (const block of raw) {
if (!block || typeof block !== "object") continue;
const text = (block as { text?: unknown }).text;
if (typeof text === "string" && text.trim()) {
parts.push(text);
}
}
return parts.join("\n").trim();
}
function getHeartbeatConfig(agent: AsyncAgent | null): HeartbeatConfig {
const cfg = agent?.getHeartbeatConfig();
if (!cfg) return {};
const out: HeartbeatConfig = {};
if (typeof cfg.enabled === "boolean") out.enabled = cfg.enabled;
if (typeof cfg.every === "string") out.every = cfg.every;
if (typeof cfg.prompt === "string") out.prompt = cfg.prompt;
if (typeof cfg.ackMaxChars === "number" && Number.isFinite(cfg.ackMaxChars)) {
out.ackMaxChars = cfg.ackMaxChars;
}
return out;
}
function resolveHeartbeatIntervalMs(agent: AsyncAgent | null): number {
const cfg = getHeartbeatConfig(agent);
return resolveDurationMs(cfg.every ?? DEFAULT_HEARTBEAT_EVERY) ?? DEFAULT_INTERVAL_MS;
}
function resolveSessionKey(agent: AsyncAgent): string {
return agent.sessionId;
}
async function isHeartbeatFileEmpty(agent: AsyncAgent): Promise<boolean> {
const profileDir = agent.getProfileDir();
if (!profileDir) return false;
const heartbeatPath = path.join(profileDir, HEARTBEAT_FILENAME);
try {
const content = await fs.readFile(heartbeatPath, "utf-8");
return isHeartbeatContentEffectivelyEmpty(content);
} catch {
return false;
}
}
export function setHeartbeatsEnabled(enabled: boolean): void {
heartbeatsEnabled = enabled;
}
export async function runHeartbeatOnce(opts: {
agent: AsyncAgent | null;
reason?: string;
nowMs?: () => number;
}): Promise<HeartbeatRunResult> {
const startedAt = opts.nowMs?.() ?? Date.now();
const agent = opts.agent;
if (!heartbeatsEnabled) {
return { status: "skipped", reason: "disabled" };
}
if (!agent || agent.closed) {
return { status: "skipped", reason: "disabled" };
}
const cfg = getHeartbeatConfig(agent);
if (cfg.enabled === false) {
return { status: "skipped", reason: "disabled" };
}
if (agent.getPendingWrites() > 0) {
return { status: "skipped", reason: "requests-in-flight" };
}
try {
const isExecEvent = opts.reason === "exec-event";
if (!isExecEvent && (await isHeartbeatFileEmpty(agent))) {
emitHeartbeatEvent({
status: "skipped",
reason: "empty-heartbeat-file",
durationMs: Date.now() - startedAt,
});
return { status: "skipped", reason: "empty-heartbeat-file" };
}
await agent.ensureInitialized();
const beforeMessages = agent.getMessages();
const sessionKey = resolveSessionKey(agent);
const pendingEvents = drainSystemEvents(sessionKey);
const basePrompt = resolveHeartbeatPrompt(cfg.prompt);
const prompt = pendingEvents.length
? `${basePrompt}\n\nSystem events:\n${pendingEvents.map((line) => `- ${line}`).join("\n")}`
: basePrompt;
agent.write(prompt);
await agent.waitForIdle();
const afterMessages = agent.getMessages();
const appended = afterMessages.slice(beforeMessages.length);
const assistant = [...appended]
.reverse()
.find((msg) => msg.role === "assistant");
const text = extractMessageText(assistant);
if (!text.trim()) {
const okEmptyEvent: Omit<HeartbeatEventPayload, "ts"> = {
status: "ok-empty",
durationMs: Date.now() - startedAt,
};
if (opts.reason) okEmptyEvent.reason = opts.reason;
const indicator = resolveIndicatorType("ok-empty");
if (indicator) okEmptyEvent.indicatorType = indicator;
emitHeartbeatEvent(okEmptyEvent);
return { status: "ran", durationMs: Date.now() - startedAt };
}
const stripped = stripHeartbeatToken(text, {
mode: "heartbeat",
maxAckChars: cfg.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
});
if (stripped.shouldSkip) {
const okTokenEvent: Omit<HeartbeatEventPayload, "ts"> = {
status: "ok-token",
durationMs: Date.now() - startedAt,
};
if (opts.reason) okTokenEvent.reason = opts.reason;
const indicator = resolveIndicatorType("ok-token");
if (indicator) okTokenEvent.indicatorType = indicator;
emitHeartbeatEvent(okTokenEvent);
return { status: "ran", durationMs: Date.now() - startedAt };
}
const sentEvent: Omit<HeartbeatEventPayload, "ts"> = {
status: "sent",
preview: stripped.text.slice(0, 200),
durationMs: Date.now() - startedAt,
};
if (opts.reason) sentEvent.reason = opts.reason;
const sentIndicator = resolveIndicatorType("sent");
if (sentIndicator) sentEvent.indicatorType = sentIndicator;
emitHeartbeatEvent(sentEvent);
return { status: "ran", durationMs: Date.now() - startedAt };
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
const failedEvent: Omit<HeartbeatEventPayload, "ts"> = {
status: "failed",
reason,
durationMs: Date.now() - startedAt,
};
const failedIndicator = resolveIndicatorType("failed");
if (failedIndicator) failedEvent.indicatorType = failedIndicator;
emitHeartbeatEvent(failedEvent);
return { status: "failed", reason };
}
}
export function startHeartbeatRunner(deps: RunnerDeps): HeartbeatRunner {
const logger = deps.logger ?? console;
const nowMs = deps.nowMs ?? (() => Date.now());
let timer: NodeJS.Timeout | null = null;
let stopped = false;
let intervalMs = resolveHeartbeatIntervalMs(deps.getAgent());
let nextDueAtMs = nowMs() + intervalMs;
const clearTimer = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
const scheduleNext = () => {
if (stopped) return;
clearTimer();
const delay = Math.max(0, nextDueAtMs - nowMs());
timer = setTimeout(() => {
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
}, delay);
timer.unref?.();
};
const run: HeartbeatWakeHandler = async (params) => {
const reason = params.reason;
const agent = deps.getAgent();
if (reason === "interval") {
const now = nowMs();
if (now < nextDueAtMs) {
return { status: "skipped", reason: "not-due" };
}
}
const result = await runHeartbeatOnce(
reason
? {
agent,
reason,
nowMs,
}
: {
agent,
nowMs,
},
);
const activeAgent = deps.getAgent();
intervalMs = resolveHeartbeatIntervalMs(activeAgent);
nextDueAtMs = nowMs() + intervalMs;
scheduleNext();
return result;
};
setHeartbeatWakeHandler(run);
scheduleNext();
logger.info?.("[Heartbeat] runner started");
return {
stop: () => {
if (stopped) return;
stopped = true;
clearTimer();
setHeartbeatWakeHandler(null);
logger.info?.("[Heartbeat] runner stopped");
},
updateConfig: () => {
const agent = deps.getAgent();
intervalMs = resolveHeartbeatIntervalMs(agent);
nextDueAtMs = nowMs() + intervalMs;
scheduleNext();
},
};
}
export type { HeartbeatEventPayload };

View file

@ -0,0 +1,51 @@
export type SystemEvent = { text: string; ts: number };
const MAX_EVENTS = 20;
const queues = new Map<string, SystemEvent[]>();
function normalizeSessionKey(key: string | undefined): string {
const trimmed = typeof key === "string" ? key.trim() : "";
if (!trimmed) {
throw new Error("system events require a sessionKey");
}
return trimmed;
}
export function enqueueSystemEvent(text: string, opts: { sessionKey: string }): void {
const sessionKey = normalizeSessionKey(opts.sessionKey);
const cleaned = text.trim();
if (!cleaned) return;
const list = queues.get(sessionKey) ?? [];
const previous = list[list.length - 1];
if (previous?.text === cleaned) {
return;
}
list.push({ text: cleaned, ts: Date.now() });
if (list.length > MAX_EVENTS) {
list.splice(0, list.length - MAX_EVENTS);
}
queues.set(sessionKey, list);
}
export function drainSystemEvents(sessionKey: string): string[] {
const key = normalizeSessionKey(sessionKey);
const list = queues.get(key) ?? [];
queues.delete(key);
return list.map((entry) => entry.text);
}
export function peekSystemEvents(sessionKey: string): string[] {
const key = normalizeSessionKey(sessionKey);
return (queues.get(key) ?? []).map((entry) => entry.text);
}
export function hasSystemEvents(sessionKey: string): boolean {
const key = normalizeSessionKey(sessionKey);
return (queues.get(key)?.length ?? 0) > 0;
}
export function resetSystemEventsForTest(): void {
queues.clear();
}

View file

@ -88,6 +88,27 @@ describe("ExecApprovalManager", () => {
expect(result.decision).toBe("deny");
});
it("keeps approval pending indefinitely when timeoutMs is -1", async () => {
const promise = manager.requestApproval({
agentId: "agent-1",
command: "cmd",
riskLevel: "needs-review",
riskReasons: [],
timeoutMs: -1,
});
const request = sendToClient.mock.calls[0]![1];
expect(request.expiresAtMs).toBe(-1);
vi.advanceTimersByTime(60_000);
expect(manager.pendingCount).toBe(1);
manager.resolveApproval(request.approvalId, "allow-once");
const result = await promise;
expect(result.approved).toBe(true);
expect(result.decision).toBe("allow-once");
});
it("honors askFallback full on timeout", async () => {
const promise = manager.requestApproval({
agentId: "agent-1",

View file

@ -15,7 +15,7 @@ import { DEFAULT_APPROVAL_TIMEOUT_MS } from "../agent/tools/exec-approval-types.
interface PendingEntry {
resolve: (result: ApprovalResult) => void;
timer: NodeJS.Timeout;
timer: NodeJS.Timeout | null;
request: ExecApprovalRequest;
}
@ -52,7 +52,7 @@ export class ExecApprovalManager {
}): Promise<ApprovalResult> {
const approvalId = uuidv7();
const timeoutMs = params.timeoutMs ?? this.defaultTimeoutMs;
const expiresAtMs = Date.now() + timeoutMs;
const expiresAtMs = timeoutMs >= 0 ? Date.now() + timeoutMs : -1;
const request: ExecApprovalRequest = {
approvalId,
@ -65,19 +65,21 @@ export class ExecApprovalManager {
};
return new Promise<ApprovalResult>((resolve) => {
// Timeout: follow askFallback (default: fail-closed)
const timer = setTimeout(() => {
if (this.pending.has(approvalId)) {
this.pending.delete(approvalId);
const fallback = params.askFallback ?? "deny";
const decision =
fallback === "full" ||
(fallback === "allowlist" && params.allowlistSatisfied)
? "allow-once"
: "deny";
resolve({ approved: decision !== "deny", decision });
}
}, timeoutMs);
// Timeout: follow askFallback (default: fail-closed). Skip if timeoutMs is -1 (no timeout).
const timer = timeoutMs >= 0
? setTimeout(() => {
if (this.pending.has(approvalId)) {
this.pending.delete(approvalId);
const fallback = params.askFallback ?? "deny";
const decision =
fallback === "full" ||
(fallback === "allowlist" && params.allowlistSatisfied)
? "allow-once"
: "deny";
resolve({ approved: decision !== "deny", decision });
}
}, timeoutMs)
: null;
this.pending.set(approvalId, { resolve, timer, request });
@ -86,7 +88,7 @@ export class ExecApprovalManager {
this.sendToClient(params.agentId, request);
} catch (err) {
// If sending fails, auto-deny (fail-closed)
clearTimeout(timer);
if (timer) clearTimeout(timer);
this.pending.delete(approvalId);
console.error(`[ExecApprovalManager] Failed to send approval request: ${err}`);
resolve({ approved: false, decision: "deny" });
@ -102,7 +104,7 @@ export class ExecApprovalManager {
const entry = this.pending.get(approvalId);
if (!entry) return false;
clearTimeout(entry.timer);
if (entry.timer) clearTimeout(entry.timer);
this.pending.delete(approvalId);
entry.resolve({
@ -120,7 +122,7 @@ export class ExecApprovalManager {
cancelPending(agentId: string): void {
for (const [id, entry] of this.pending) {
if (entry.request.agentId === agentId) {
clearTimeout(entry.timer);
if (entry.timer) clearTimeout(entry.timer);
this.pending.delete(id);
entry.resolve({ approved: false, decision: "deny" });
}

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
extractAssistantEventText,
isHeartbeatAckEvent,
} from "./heartbeat-filter.js";
describe("heartbeat-filter", () => {
it("extracts text from string content", () => {
const event = {
message: {
content: " HEARTBEAT_OK ",
},
};
expect(extractAssistantEventText(event)).toBe("HEARTBEAT_OK");
});
it("extracts text from content blocks", () => {
const event = {
message: {
content: [
{ type: "text", text: "line 1" },
{ type: "thinking", thinking: "hidden" },
{ type: "text", text: "line 2" },
],
},
};
expect(extractAssistantEventText(event)).toBe("line 1 line 2");
});
it("treats pure heartbeat token as ack", () => {
const event = {
message: {
content: [{ type: "text", text: "HEARTBEAT_OK" }],
},
};
expect(isHeartbeatAckEvent(event)).toBe(true);
});
it("treats marked-up heartbeat token as ack", () => {
const event = {
message: {
content: [{ type: "text", text: "**HEARTBEAT_OK**" }],
},
};
expect(isHeartbeatAckEvent(event)).toBe(true);
});
it("does not suppress real alert text", () => {
const event = {
message: {
content: [{ type: "text", text: "Reminder: go downstairs now." }],
},
};
expect(isHeartbeatAckEvent(event)).toBe(false);
});
it("does not suppress token plus extra content", () => {
const event = {
message: {
content: [{ type: "text", text: "HEARTBEAT_OK Reminder: check inbox." }],
},
};
expect(isHeartbeatAckEvent(event)).toBe(false);
});
});

View file

@ -0,0 +1,43 @@
import { stripHeartbeatToken } from "../heartbeat/index.js";
function collapseWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
/**
* Extract assistant text from an agent stream event.
* Supports both string and rich content-array message shapes.
*/
export function extractAssistantEventText(event: unknown): string {
if (!event || typeof event !== "object") return "";
const message = (event as { message?: unknown }).message;
if (!message || typeof message !== "object") return "";
const content = (message as { content?: unknown }).content;
if (typeof content === "string") {
return collapseWhitespace(content);
}
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
const text = (block as { text?: unknown }).text;
if (typeof text === "string" && text.trim()) {
parts.push(text);
}
}
return collapseWhitespace(parts.join("\n"));
}
/**
* True only for pure heartbeat ACK payloads (e.g. "HEARTBEAT_OK").
* Messages that include any extra text are not suppressed.
*/
export function isHeartbeatAckEvent(event: unknown): boolean {
const text = extractAssistantEventText(event);
if (!text) return false;
const stripped = stripHeartbeatToken(text, { mode: "message" });
return stripped.shouldSkip && stripped.didStrip;
}

View file

@ -22,6 +22,9 @@ import { createListAgentsHandler } from "./rpc/handlers/list-agents.js";
import { createCreateAgentHandler } from "./rpc/handlers/create-agent.js";
import { createDeleteAgentHandler } from "./rpc/handlers/delete-agent.js";
import { createUpdateGatewayHandler } from "./rpc/handlers/update-gateway.js";
import { createGetLastHeartbeatHandler } from "./rpc/handlers/get-last-heartbeat.js";
import { createSetHeartbeatsHandler } from "./rpc/handlers/set-heartbeats.js";
import { createWakeHeartbeatHandler } from "./rpc/handlers/wake-heartbeat.js";
import { DeviceStore, type DeviceMeta } from "./device-store.js";
import { createVerifyHandler } from "./rpc/handlers/verify.js";
import { ExecApprovalManager } from "./exec-approval-manager.js";
@ -31,15 +34,34 @@ import { addAllowlistEntry, recordAllowlistUse, matchAllowlist } from "../agent/
import type { ExecApprovalCallback, ExecApprovalConfig, ApprovalResult, ExecApprovalRequest } from "../agent/tools/exec-approval-types.js";
import { readProfileConfig, writeProfileConfig } from "../agent/profile/storage.js";
import { ChannelManager, initChannels } from "../channels/index.js";
import { getCronService, shutdownCronService, executeCronJob } from "../cron/index.js";
import {
getLastHeartbeatEvent,
onHeartbeatEvent,
requestHeartbeatNow,
runHeartbeatOnce,
setHeartbeatsEnabled,
startHeartbeatRunner,
type HeartbeatEventPayload,
type HeartbeatRunResult,
type HeartbeatRunner,
} from "../heartbeat/index.js";
import { enqueueSystemEvent } from "../heartbeat/system-events.js";
import { isHeartbeatAckEvent } from "./heartbeat-filter.js";
export class Hub {
private readonly agents = new Map<string, AsyncAgent>();
private readonly agentSenders = new Map<string, string>();
private readonly agentStreamIds = new Map<string, string>();
private readonly agentStreamCounters = new Map<string, number>();
private readonly pendingAssistantStarts = new Map<string, { agentId: string; event: unknown }>();
private readonly suppressedStreamAgents = new Set<string>();
private readonly localApprovalHandlers = new Map<string, (payload: ExecApprovalRequest) => void>();
private readonly rpc: RpcDispatcher;
private readonly approvalManager: ExecApprovalManager;
private readonly heartbeatListeners = new Set<(event: HeartbeatEventPayload) => void>();
private heartbeatRunner: HeartbeatRunner | null = null;
private heartbeatUnsubscribe: (() => void) | null = null;
private client: GatewayClient;
readonly deviceStore: DeviceStore;
private _onConfirmDevice: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise<boolean>) | null = null;
@ -78,6 +100,9 @@ export class Hub {
this.rpc.register("createAgent", createCreateAgentHandler(this));
this.rpc.register("deleteAgent", createDeleteAgentHandler(this));
this.rpc.register("updateGateway", createUpdateGatewayHandler(this));
this.rpc.register("last-heartbeat", createGetLastHeartbeatHandler(this));
this.rpc.register("set-heartbeats", createSetHeartbeatsHandler(this));
this.rpc.register("wake-heartbeat", createWakeHeartbeatHandler(this));
// Initialize exec approval manager
this.approvalManager = new ExecApprovalManager((agentId, payload) => {
@ -102,6 +127,10 @@ export class Hub {
// Restore subagent registry from persistent state
initSubagentRegistry();
// Initialize and start cron service
this.initCronService();
this.initHeartbeatService();
this.client = this.createClient(this.url);
this.client.connect();
this.restoreAgents();
@ -118,6 +147,42 @@ export class Hub {
});
}
/** 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");
}
/** Initialize heartbeat runner + event fanout. */
private initHeartbeatService(): void {
this.heartbeatRunner = startHeartbeatRunner({
getAgent: () => this.getDefaultAgent(),
logger: console,
});
this.heartbeatUnsubscribe = onHeartbeatEvent((event) => {
for (const listener of this.heartbeatListeners) {
try {
listener(event);
} catch {
// Keep fanout resilient against listener errors.
}
}
});
console.log("[Hub] Heartbeat service initialized");
}
private getDefaultAgent(): AsyncAgent | null {
const first = this.listAgents()[0];
if (!first) return null;
return this.getAgent(first) ?? null;
}
/** Restore agents from persistent storage */
private restoreAgents(): void {
const records = loadAgentRecords();
@ -279,6 +344,7 @@ export class Hub {
// Internally consume agent output (AgentEvent stream + error Messages)
void this.consumeAgent(agent);
this.heartbeatRunner?.updateConfig();
console.log(`Agent created: ${agent.sessionId}`);
return agent;
@ -313,6 +379,14 @@ export class Hub {
this.agentStreamIds.delete(agentId);
}
private clearPendingAssistantStarts(agentId: string): void {
for (const [streamId, pending] of this.pendingAssistantStarts) {
if (pending.agentId === agentId) {
this.pendingAssistantStarts.delete(streamId);
}
}
}
/** Internally read agent output and send via Gateway */
private async consumeAgent(agent: AsyncAgent): Promise<void> {
for await (const item of agent.read()) {
@ -327,6 +401,20 @@ export class Hub {
content: item.content,
});
} else {
const suppressForAgent = this.suppressedStreamAgents.has(agent.sessionId);
// Suppress all user-visible stream events during silent heartbeat runs.
if (suppressForAgent) {
if (item.type === "message_start") {
this.beginStream(agent.sessionId, item);
} else if (item.type === "message_end") {
const streamId = this.getActiveStreamId(agent.sessionId, item);
this.pendingAssistantStarts.delete(streamId);
this.endStream(agent.sessionId);
}
continue;
}
// Passthrough events: forward with synthetic streamId (no stream tracking)
const isPassthroughEvent =
item.type === "compaction_start" || item.type === "compaction_end" || item.type === "agent_error";
@ -348,18 +436,55 @@ export class Hub {
|| item.type === "tool_execution_end";
if (!shouldForward) continue;
if (item.type === "message_start") {
this.beginStream(agent.sessionId, item);
const isAssistantMessageEvent =
item.type === "message_start" || item.type === "message_update" || item.type === "message_end";
// Delay assistant message_start forwarding until we see content.
// This lets us suppress pure HEARTBEAT_OK acknowledgements end-to-end.
if (isAssistantMessageEvent && isAssistantMessage) {
if (item.type === "message_start") {
const streamId = this.beginStream(agent.sessionId, item);
this.pendingAssistantStarts.set(streamId, { agentId: agent.sessionId, event: item });
continue;
}
const streamId = this.getActiveStreamId(agent.sessionId, item);
const isHeartbeatAck = isHeartbeatAckEvent(item);
if (isHeartbeatAck) {
if (item.type === "message_end") {
this.pendingAssistantStarts.delete(streamId);
this.endStream(agent.sessionId);
}
continue;
}
const pendingStart = this.pendingAssistantStarts.get(streamId);
if (pendingStart) {
this.client.send(targetDeviceId, StreamAction, {
streamId,
agentId: agent.sessionId,
event: pendingStart.event,
});
this.pendingAssistantStarts.delete(streamId);
}
this.client.send(targetDeviceId, StreamAction, {
streamId,
agentId: agent.sessionId,
event: item,
});
if (item.type === "message_end") {
this.endStream(agent.sessionId);
}
continue;
}
const streamId = this.getActiveStreamId(agent.sessionId, item);
this.client.send(targetDeviceId, StreamAction, {
streamId,
agentId: agent.sessionId,
event: item,
});
if (item.type === "message_end") {
this.endStream(agent.sessionId);
}
}
}
}
@ -422,8 +547,8 @@ export class Hub {
// No profile config, use defaults
}
const security = config.security ?? "allowlist";
const ask = config.ask ?? "on-miss";
const security = config.security ?? "full";
const ask = config.ask ?? "off";
// Security: deny blocks everything
if (security === "deny") {
@ -507,6 +632,63 @@ export class Hub {
.map(([id]) => id);
}
/** Subscribe heartbeat state updates. Returns unsubscribe callback. */
onHeartbeatEvent(callback: (event: HeartbeatEventPayload) => void): () => void {
this.heartbeatListeners.add(callback);
return () => {
this.heartbeatListeners.delete(callback);
};
}
/** Get latest heartbeat event payload. */
getLastHeartbeat(): HeartbeatEventPayload | null {
return getLastHeartbeatEvent();
}
/** Enable/disable heartbeat runner globally. */
setHeartbeatsEnabled(enabled: boolean): void {
setHeartbeatsEnabled(enabled);
this.heartbeatRunner?.updateConfig();
}
/** Enqueue a heartbeat wake request. */
requestHeartbeatNow(opts?: { reason?: string }): void {
requestHeartbeatNow(opts);
}
/** Run heartbeat immediately using the current default agent. */
async runHeartbeatOnce(opts?: { reason?: string }): Promise<HeartbeatRunResult> {
const agent = this.getDefaultAgent();
const reason = opts?.reason;
const shouldSuppressStreams = reason === "manual";
if (shouldSuppressStreams && agent) {
this.suppressedStreamAgents.add(agent.sessionId);
}
try {
if (reason) {
return runHeartbeatOnce({
agent,
reason,
});
}
return runHeartbeatOnce({
agent,
});
} finally {
if (shouldSuppressStreams && agent) {
this.suppressedStreamAgents.delete(agent.sessionId);
}
}
}
/** Enqueue a system event for a specific agent or the default agent. */
enqueueSystemEvent(text: string, opts?: { agentId?: string }): void {
const agentId = opts?.agentId ?? this.listAgents()[0];
if (!agentId) return;
enqueueSystemEvent(text, { sessionKey: agentId });
}
closeAgent(id: string): boolean {
const agent = this.agents.get(id);
if (!agent) return false;
@ -516,8 +698,11 @@ export class Hub {
this.agentSenders.delete(id);
this.agentStreamIds.delete(id);
this.agentStreamCounters.delete(id);
this.clearPendingAssistantStarts(id);
this.suppressedStreamAgents.delete(id);
this.localApprovalHandlers.delete(id);
removeAgentRecord(id);
this.heartbeatRunner?.updateConfig();
return true;
}
@ -525,6 +710,14 @@ export class Hub {
// Stop all channel connections
this.channelManager.stopAll();
// Stop cron service
shutdownCronService();
this.heartbeatRunner?.stop();
this.heartbeatRunner = null;
this.heartbeatUnsubscribe?.();
this.heartbeatUnsubscribe = null;
this.heartbeatListeners.clear();
// Finalize subagent registry before closing agents
shutdownSubagentRegistry();
@ -534,6 +727,8 @@ export class Hub {
this.agentSenders.delete(id);
this.agentStreamIds.delete(id);
this.agentStreamCounters.delete(id);
this.clearPendingAssistantStarts(id);
this.suppressedStreamAgents.delete(id);
this.localApprovalHandlers.delete(id);
}
this.client.disconnect();

View file

@ -0,0 +1,9 @@
import type { RpcHandler } from "../dispatcher.js";
interface HubLike {
getLastHeartbeat(): unknown;
}
export function createGetLastHeartbeatHandler(hub: HubLike): RpcHandler {
return () => hub.getLastHeartbeat();
}

View file

@ -0,0 +1,18 @@
import type { RpcHandler } from "../dispatcher.js";
import { RpcError } from "../dispatcher.js";
interface HubLike {
setHeartbeatsEnabled(enabled: boolean): void;
}
export function createSetHeartbeatsHandler(hub: HubLike): RpcHandler {
return (params) => {
const enabled = (params as { enabled?: unknown } | undefined)?.enabled;
if (typeof enabled !== "boolean") {
throw new RpcError("INVALID_REQUEST", "enabled (boolean) is required");
}
hub.setHeartbeatsEnabled(enabled);
return { ok: true, enabled };
};
}

View file

@ -0,0 +1,14 @@
import type { RpcHandler } from "../dispatcher.js";
interface HubLike {
requestHeartbeatNow(opts?: { reason?: string }): void;
}
export function createWakeHeartbeatHandler(hub: HubLike): RpcHandler {
return (params) => {
const reasonRaw = (params as { reason?: unknown } | undefined)?.reason;
const reason = typeof reasonRaw === "string" ? reasonRaw.trim() : "";
hub.requestHeartbeatNow({ reason: reason || "manual" });
return { ok: true };
};
}

View file

@ -5,3 +5,6 @@ export { createListAgentsHandler } from "./handlers/list-agents.js";
export { createCreateAgentHandler } from "./handlers/create-agent.js";
export { createDeleteAgentHandler } from "./handlers/delete-agent.js";
export { createUpdateGatewayHandler } from "./handlers/update-gateway.js";
export { createGetLastHeartbeatHandler } from "./handlers/get-last-heartbeat.js";
export { createSetHeartbeatsHandler } from "./handlers/set-heartbeats.js";
export { createWakeHeartbeatHandler } from "./handlers/wake-heartbeat.js";

View file

@ -2,3 +2,4 @@ export * from "./agent/index.js";
export * from "./gateway/index.js";
export * from "./client/index.js";
export * from "./shared/index.js";
export * from "./heartbeat/index.js";

View file